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:
1 parent
b354161d82
commit
d1a13110ea
4 files changed
+177
-32
No files matched your search
+89
-14
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user