feat: add real-time playback position sync and skip buttons

Backend (Rust):
- Add position tracking based on elapsed wall clock time
- SharedState now tracks playback start time and position at start
- get_state() returns real-time position instead of stale value
- Position automatically pauses/resumes with playback

Frontend (Svelte):
- Replace dumb setInterval timer with backend polling (500ms)
- Poll get_playback_state to sync position from Rust
- Add onSkipBack/onSkipForward handlers to NowPlayingBar
- Add onSkipBack/onSkipForward handlers to FullScreenNowPlaying
- Wire skip buttons to handlers (restart if >3s, otherwise previous)
- Add keyboard shortcuts: Ctrl+Left/Right for skip
This commit is contained in:
vicrodh committed 2026-01-09 09:26:02 -06:00
1 parent b354161d82
commit d1a13110ea
4 files changed
+177 -32

No files matched your search

+89 -14
View File
@@ -5,6 +5,7 @@
//! - FLAC, MP3 decoding via symphonia
//! - Gapless playback
//! - Volume control
//! - Real-time position tracking via events
//!
//! Uses a dedicated audio thread since rodio's OutputStream is not Send.
@@ -13,15 +14,14 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::thread;
use rodio::{Decoder, OutputStream, Sink, Source};
use crate::api::{client::QobuzClient, models::Quality};
/// Commands sent to the audio thread
enum AudioCommand {
/// Play audio data with track ID
Play { data: Vec<u8>, track_id: u64 },
/// Play audio data with track ID and duration
Play { data: Vec<u8>, track_id: u64, duration_secs: u64 },
/// Pause playback
Pause,
/// Resume playback
@@ -32,6 +32,16 @@ enum AudioCommand {
SetVolume(f32),
}
/// Event payload for playback state updates
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlaybackEvent {
pub is_playing: bool,
pub position: u64,
pub duration: u64,
pub track_id: u64,
pub volume: f32,
}
/// Shared state between main thread and audio thread
#[derive(Clone)]
pub struct SharedState {
@@ -45,6 +55,10 @@ pub struct SharedState {
current_track_id: Arc<AtomicU64>,
/// Volume (0.0 - 1.0 stored as 0-100)
volume: Arc<AtomicU64>,
/// Playback start time (Unix timestamp millis when started/resumed)
playback_start_millis: Arc<AtomicU64>,
/// Position when playback was started/resumed (in seconds)
position_at_start: Arc<AtomicU64>,
}
impl Default for SharedState {
@@ -61,9 +75,53 @@ impl SharedState {
duration: Arc::new(AtomicU64::new(0)),
current_track_id: Arc::new(AtomicU64::new(0)),
volume: Arc::new(AtomicU64::new(75)),
playback_start_millis: Arc::new(AtomicU64::new(0)),
position_at_start: Arc::new(AtomicU64::new(0)),
}
}
/// Get current position based on elapsed time since playback started
pub fn current_position(&self) -> u64 {
if !self.is_playing.load(Ordering::SeqCst) {
return self.position.load(Ordering::SeqCst);
}
let start_millis = self.playback_start_millis.load(Ordering::SeqCst);
if start_millis == 0 {
return self.position.load(Ordering::SeqCst);
}
let now_millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let elapsed_secs = (now_millis.saturating_sub(start_millis)) / 1000;
let position_at_start = self.position_at_start.load(Ordering::SeqCst);
let duration = self.duration.load(Ordering::SeqCst);
// Clamp to duration
(position_at_start + elapsed_secs).min(duration)
}
/// Mark playback as started/resumed at current position
fn start_playback_timer(&self, position: u64) {
let now_millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
self.playback_start_millis.store(now_millis, Ordering::SeqCst);
self.position_at_start.store(position, Ordering::SeqCst);
}
/// Mark playback as paused, saving current position
fn pause_playback_timer(&self) {
let current_pos = self.current_position();
self.position.store(current_pos, Ordering::SeqCst);
self.playback_start_millis.store(0, Ordering::SeqCst);
}
pub fn is_playing(&self) -> bool {
self.is_playing.load(Ordering::SeqCst)
}
@@ -141,7 +199,7 @@ impl Player {
loop {
match rx.recv() {
Ok(AudioCommand::Play { data, track_id }) => {
Ok(AudioCommand::Play { data, track_id, duration_secs }) => {
log::info!("Audio thread: playing track {}", track_id);
// Stop existing playback
@@ -172,12 +230,11 @@ impl Player {
}
};
// Get duration if available
if let Some(duration) = source.total_duration() {
thread_state
.duration
.store(duration.as_secs(), Ordering::SeqCst);
}
// Get duration from source if available, otherwise use provided duration
let actual_duration = source.total_duration()
.map(|d| d.as_secs())
.unwrap_or(duration_secs);
thread_state.duration.store(actual_duration, Ordering::SeqCst);
sink.append(source);
@@ -185,20 +242,24 @@ impl Player {
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
current_sink = Some(sink);
log::info!("Audio thread: playback started");
log::info!("Audio thread: playback started, duration: {}s", actual_duration);
}
Ok(AudioCommand::Pause) => {
if let Some(ref sink) = current_sink {
sink.pause();
thread_state.pause_playback_timer();
thread_state.is_playing.store(false, Ordering::SeqCst);
log::info!("Audio thread: paused");
log::info!("Audio thread: paused at {}s", thread_state.position.load(Ordering::SeqCst));
}
}
Ok(AudioCommand::Resume) => {
if let Some(ref sink) = current_sink {
sink.play();
let current_pos = thread_state.position.load(Ordering::SeqCst);
thread_state.start_playback_timer(current_pos);
thread_state.is_playing.store(true, Ordering::SeqCst);
log::info!("Audio thread: resumed");
}
@@ -209,6 +270,8 @@ impl Player {
}
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.playback_start_millis.store(0, Ordering::SeqCst);
thread_state.position_at_start.store(0, Ordering::SeqCst);
log::info!("Audio thread: stopped");
}
Ok(AudioCommand::SetVolume(volume)) => {
@@ -267,6 +330,7 @@ impl Player {
.send(AudioCommand::Play {
data: audio_data,
track_id,
duration_secs: 0, // Will be determined by decoder
})
.map_err(|e| {
log::error!("Player: Failed to send to audio thread: {}", e);
@@ -348,16 +412,27 @@ impl Player {
Ok(())
}
/// Get current playback state
/// Get current playback state with real-time position
pub fn get_state(&self) -> Result<PlaybackState, String> {
Ok(PlaybackState {
is_playing: self.state.is_playing(),
position: self.state.position(),
position: self.state.current_position(),
duration: self.state.duration(),
track_id: self.state.current_track_id(),
volume: self.state.volume(),
})
}
/// Get playback event for emitting to frontend
pub fn get_playback_event(&self) -> PlaybackEvent {
PlaybackEvent {
is_playing: self.state.is_playing(),
position: self.state.current_position(),
duration: self.state.duration(),
track_id: self.state.current_track_id(),
volume: self.state.volume(),
}
}
}
/// Playback state snapshot
@@ -12,6 +12,8 @@
qualityLevel: number;
isPlaying: boolean;
onTogglePlay: () => void;
onSkipBack?: () => void;
onSkipForward?: () => void;
currentTime: number;
duration: number;
onSeek: (time: number) => void;
@@ -38,6 +40,8 @@
qualityLevel,
isPlaying,
onTogglePlay,
onSkipBack,
onSkipForward,
currentTime,
duration,
onSeek,
@@ -170,7 +174,7 @@
<button class="control-btn" class:active={isShuffle} onclick={onToggleShuffle}>
<Shuffle size={24} />
</button>
<button class="control-btn primary">
<button class="control-btn primary" onclick={onSkipBack}>
<SkipBack size={32} />
</button>
<button
@@ -186,7 +190,7 @@
<Play size={28} fill="white" color="white" class="play-icon" />
{/if}
</button>
<button class="control-btn primary">
<button class="control-btn primary" onclick={onSkipForward}>
<SkipForward size={32} />
</button>
<button class="control-btn" class:active={repeatMode !== 'off'} onclick={onToggleRepeat}>
+6 -2
View File
@@ -22,6 +22,8 @@
qualityLevel?: number;
isPlaying?: boolean;
onTogglePlay?: () => void;
onSkipBack?: () => void;
onSkipForward?: () => void;
currentTime?: number;
duration?: number;
onSeek?: (time: number) => void;
@@ -45,6 +47,8 @@
qualityLevel = 5,
isPlaying = false,
onTogglePlay,
onSkipBack,
onSkipForward,
currentTime = 201,
duration = 345,
onSeek,
@@ -157,7 +161,7 @@
>
<Shuffle size={20} />
</button>
<button class="control-btn primary">
<button class="control-btn primary" onclick={onSkipBack}>
<SkipBack size={24} />
</button>
<button class="play-btn" onclick={onTogglePlay}>
@@ -167,7 +171,7 @@
<Play size={24} fill="white" color="white" class="play-icon" />
{/if}
</button>
<button class="control-btn primary">
<button class="control-btn primary" onclick={onSkipForward}>
<SkipForward size={24} />
</button>
<button
+76 -14
View File
@@ -322,6 +322,25 @@
showToast(isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success');
}
// Skip track handlers (will be wired to queue when implemented)
function handleSkipBack() {
if (!currentTrack) return;
// If more than 3 seconds in, restart track; otherwise go to previous
if (currentTime > 3) {
handleSeek(0);
showToast('Restarted track', 'info');
} else {
// TODO: Go to previous track in queue
showToast('Previous track (not implemented yet)', 'info');
}
}
function handleSkipForward() {
if (!currentTrack) return;
// TODO: Go to next track in queue
showToast('Next track (not implemented yet)', 'info');
}
// Toast Functions
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
toast = { message, type };
@@ -364,6 +383,18 @@
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSkipBack();
}
break;
case 'ArrowRight':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSkipForward();
}
break;
case 'f':
if (!e.ctrlKey && !e.metaKey) {
isFocusModeOpen = !isFocusModeOpen;
@@ -380,25 +411,52 @@
}
}
// Playback progress timer
let progressInterval: ReturnType<typeof setInterval> | null = null;
// Playback state polling - sync with backend every 500ms
interface PlaybackState {
is_playing: boolean;
position: number;
duration: number;
track_id: number;
volume: number;
}
let pollInterval: ReturnType<typeof setInterval> | null = null;
async function pollPlaybackState() {
if (!currentTrack) return;
try {
const state = await invoke<PlaybackState>('get_playback_state');
// Only update if we have a matching track
if (state.track_id === currentTrack.id) {
currentTime = state.position;
isPlaying = state.is_playing;
// Check if track ended
if (state.duration > 0 && state.position >= state.duration && !state.is_playing) {
// Track finished - could trigger next track here
console.log('Track finished playing');
}
}
} catch (err) {
console.error('Failed to poll playback state:', err);
}
}
$effect(() => {
if (isPlaying && currentTrack) {
progressInterval = setInterval(() => {
if (currentTime < duration) {
currentTime += 1;
} else {
isPlaying = false;
}
}, 1000);
} else if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (currentTrack) {
// Start polling when we have a track
pollInterval = setInterval(pollPlaybackState, 500);
// Also poll immediately
pollPlaybackState();
} else if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
return () => {
if (progressInterval) clearInterval(progressInterval);
if (pollInterval) clearInterval(pollInterval);
};
});
@@ -467,6 +525,8 @@
quality={currentTrack.quality}
{isPlaying}
onTogglePlay={togglePlay}
onSkipBack={handleSkipBack}
onSkipForward={handleSkipForward}
{currentTime}
{duration}
onSeek={handleSeek}
@@ -514,6 +574,8 @@
qualityLevel={currentTrack.quality.includes('24') ? 5 : 3}
{isPlaying}
onTogglePlay={togglePlay}
onSkipBack={handleSkipBack}
onSkipForward={handleSkipForward}
{currentTime}
{duration}
onSeek={handleSeek}