mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
fix(player): prevent streaming seek UI freezes and progress snapbacks (#236)
* fix(player): prevent streaming seek UI freezes and progress snapbacks Move blocking seek work off the command path with a bounded timeout, switch fullscreen/mobile seek bars to commit-on-release, and stabilize fallback progress state so rapid early seeks on streamed tracks do not freeze the UI or jump progress to zero. * fix(player): avoid second-seek lockups and progress flicker Add a short lock timeout for audio seek state access to prevent UI stalls when rapid seeks overlap, and keep waveform progress stable right after seek commit to eliminate brief visual snapbacks. * fix(player): harden seek recovery and reduce stream log noise Keep seek fallback stable during delayed backend responses to prevent occasional resets to track start, and remove per-read stream blocking logs so normal playback diagnostics stay readable. --------- Co-authored-by: Maxim Isaev <im@friclub.ru>
This commit is contained in:
committed by
GitHub
parent
c61bcacd0d
commit
fa21379dbb
+62
-38
@@ -1,5 +1,5 @@
|
|||||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock, TryLockError};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -709,39 +709,14 @@ impl Read for RangedHttpSource {
|
|||||||
let target_end = self.pos + max_read as u64;
|
let target_end = self.pos + max_read as u64;
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
let deadline = Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
let block_started = Instant::now();
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
let mut blocked = false;
|
|
||||||
loop {
|
loop {
|
||||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
|
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
|
||||||
if dl >= target_end {
|
if dl >= target_end {
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
if blocked {
|
|
||||||
eprintln!(
|
|
||||||
"[stream] read unblocked after {:.2}s — pos={} target_end={} dl={}",
|
|
||||||
block_started.elapsed().as_secs_f64(),
|
|
||||||
self.pos,
|
|
||||||
target_end,
|
|
||||||
dl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
if !blocked {
|
|
||||||
blocked = true;
|
|
||||||
eprintln!(
|
|
||||||
"[stream] read blocking — pos={} need={} dl_to={} (waiting for {} more bytes)",
|
|
||||||
self.pos,
|
|
||||||
target_end,
|
|
||||||
dl,
|
|
||||||
target_end - dl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Download finished but our cursor is past downloaded_to (e.g. seek
|
// Download finished but our cursor is past downloaded_to (e.g. seek
|
||||||
// beyond a partial download that aborted). Return what we have.
|
// beyond a partial download that aborted). Return what we have.
|
||||||
if self.done.load(Ordering::SeqCst) {
|
if self.done.load(Ordering::SeqCst) {
|
||||||
@@ -2032,7 +2007,7 @@ pub struct AudioEngine {
|
|||||||
pub(crate) current_is_seekable: Arc<AtomicBool>,
|
pub(crate) current_is_seekable: Arc<AtomicBool>,
|
||||||
pub crossfade_enabled: Arc<AtomicBool>,
|
pub crossfade_enabled: Arc<AtomicBool>,
|
||||||
pub crossfade_secs: Arc<AtomicU32>,
|
pub crossfade_secs: Arc<AtomicU32>,
|
||||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
pub fading_out_sink: Arc<Mutex<Option<Arc<Sink>>>>,
|
||||||
/// When true, audio_play chains sources to the existing Sink instead of
|
/// When true, audio_play chains sources to the existing Sink instead of
|
||||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||||
pub gapless_enabled: Arc<AtomicBool>,
|
pub gapless_enabled: Arc<AtomicBool>,
|
||||||
@@ -2055,7 +2030,7 @@ pub struct AudioEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioCurrent {
|
pub struct AudioCurrent {
|
||||||
pub sink: Option<Sink>,
|
pub sink: Option<Arc<Sink>>,
|
||||||
pub duration_secs: f64,
|
pub duration_secs: f64,
|
||||||
pub seek_offset: f64,
|
pub seek_offset: f64,
|
||||||
pub play_started: Option<Instant>,
|
pub play_started: Option<Instant>,
|
||||||
@@ -2885,7 +2860,7 @@ pub async fn audio_play(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
|
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
|
||||||
sink.set_volume(effective_volume);
|
sink.set_volume(effective_volume);
|
||||||
|
|
||||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||||
@@ -3414,6 +3389,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||||
|
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
|
||||||
|
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
|
||||||
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
|
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
|
||||||
{
|
{
|
||||||
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
|
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
|
||||||
@@ -3439,26 +3416,73 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
|||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
eprintln!("[seek] target={:.2}s", seconds);
|
eprintln!("[seek] target={:.2}s", seconds);
|
||||||
|
|
||||||
|
let lock_current_with_timeout = |timeout_ms: u64| {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||||
|
loop {
|
||||||
|
match state.current.try_lock() {
|
||||||
|
Ok(guard) => break Ok(guard),
|
||||||
|
Err(TryLockError::WouldBlock) => {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
break Err("audio seek busy".to_string());
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
Err(TryLockError::Poisoned(_)) => {
|
||||||
|
break Err("audio state lock poisoned".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Seeking back invalidates any pending gapless chain.
|
// Seeking back invalidates any pending gapless chain.
|
||||||
let cur_pos = {
|
let cur_pos = {
|
||||||
let cur = state.current.lock().unwrap();
|
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||||
cur.position()
|
cur.position()
|
||||||
};
|
};
|
||||||
if seconds < cur_pos - 1.0 {
|
if seconds < cur_pos - 1.0 {
|
||||||
*state.chained_info.lock().unwrap() = None;
|
*state.chained_info.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cur = state.current.lock().unwrap();
|
let seek_seconds = seconds.max(0.0);
|
||||||
|
let seek_duration = Duration::from_secs_f64(seek_seconds);
|
||||||
|
let seek_generation = state.generation.load(Ordering::SeqCst);
|
||||||
|
let sink = {
|
||||||
|
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||||
|
match cur.sink.as_ref() {
|
||||||
|
Some(sink) => Arc::clone(sink),
|
||||||
|
None => return Ok(()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = sink.try_seek(seek_duration).map_err(|e| e.to_string());
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
match rx.recv_timeout(Duration::from_millis(AUDIO_SEEK_TIMEOUT_MS)) {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => return Err(e),
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
return Err("audio seek timeout".into());
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
return Err("audio seek worker disconnected".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If playback switched while seek was in flight, skip timestamp updates.
|
||||||
|
if state.generation.load(Ordering::SeqCst) != seek_generation {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||||
if cur.sink.is_none() { return Ok(()); }
|
if cur.sink.is_none() { return Ok(()); }
|
||||||
|
|
||||||
cur.sink.as_ref().unwrap()
|
|
||||||
.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if cur.paused_at.is_some() {
|
if cur.paused_at.is_some() {
|
||||||
cur.paused_at = Some(seconds);
|
cur.paused_at = Some(seek_seconds);
|
||||||
} else {
|
} else {
|
||||||
cur.seek_offset = seconds;
|
cur.seek_offset = seek_seconds;
|
||||||
cur.play_started = Some(Instant::now());
|
cur.play_started = Some(Instant::now());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3704,7 +3728,7 @@ pub async fn audio_play_radio(
|
|||||||
|
|
||||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||||
|
|
||||||
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
|
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
|
||||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||||
sink.append(counting);
|
sink.append(counting);
|
||||||
|
|
||||||
|
|||||||
@@ -428,6 +428,28 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
|||||||
const playedRef = useRef<HTMLDivElement>(null);
|
const playedRef = useRef<HTMLDivElement>(null);
|
||||||
const bufRef = useRef<HTMLDivElement>(null);
|
const bufRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
const pendingSeekRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const previewSeek = useCallback((progress: number) => {
|
||||||
|
const s = usePlayerStore.getState();
|
||||||
|
const p = Math.max(0, Math.min(1, progress));
|
||||||
|
pendingSeekRef.current = p;
|
||||||
|
if (timeRef.current) {
|
||||||
|
const previewTime = duration > 0 ? p * duration : s.currentTime;
|
||||||
|
timeRef.current.textContent = formatTime(previewTime);
|
||||||
|
}
|
||||||
|
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
|
||||||
|
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
|
||||||
|
if (inputRef.current) inputRef.current.value = String(p);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
const commitSeek = useCallback(() => {
|
||||||
|
const pending = pendingSeekRef.current;
|
||||||
|
if (pending === null) return;
|
||||||
|
pendingSeekRef.current = null;
|
||||||
|
seek(pending);
|
||||||
|
}, [seek]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
@@ -438,6 +460,7 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
|||||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||||
|
|
||||||
return usePlayerStore.subscribe(state => {
|
return usePlayerStore.subscribe(state => {
|
||||||
|
if (isDraggingRef.current) return;
|
||||||
const p = state.progress * 100;
|
const p = state.progress * 100;
|
||||||
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
|
||||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||||
@@ -447,8 +470,10 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSeek = useCallback(
|
const handleSeek = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
[seek]
|
previewSeek(parseFloat(e.target.value));
|
||||||
|
},
|
||||||
|
[previewSeek]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -466,6 +491,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
|||||||
type="range" min={0} max={1} step={0.001}
|
type="range" min={0} max={1} step={0.001}
|
||||||
defaultValue={0}
|
defaultValue={0}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
|
onMouseDown={() => { isDraggingRef.current = true; }}
|
||||||
|
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||||
|
onTouchStart={() => { isDraggingRef.current = true; }}
|
||||||
|
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||||
|
onPointerDown={() => { isDraggingRef.current = true; }}
|
||||||
|
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||||
|
onKeyUp={commitSeek}
|
||||||
|
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||||
aria-label="seek"
|
aria-label="seek"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,14 +204,21 @@ export default function MobilePlayerView() {
|
|||||||
// Scrubber touch/mouse drag
|
// Scrubber touch/mouse drag
|
||||||
const scrubberRef = useRef<HTMLDivElement>(null);
|
const scrubberRef = useRef<HTMLDivElement>(null);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
|
const pendingSeekRef = useRef<number | null>(null);
|
||||||
|
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const setPreviewSeek = useCallback((pct: number) => {
|
||||||
|
pendingSeekRef.current = pct;
|
||||||
|
setPreviewProgress(pct);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const seekFromX = useCallback((clientX: number) => {
|
const seekFromX = useCallback((clientX: number) => {
|
||||||
const el = scrubberRef.current;
|
const el = scrubberRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
seek(pct);
|
setPreviewSeek(pct);
|
||||||
}, [seek]);
|
}, [setPreviewSeek]);
|
||||||
|
|
||||||
const onScrubStart = useCallback((clientX: number) => {
|
const onScrubStart = useCallback((clientX: number) => {
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
@@ -224,7 +231,14 @@ export default function MobilePlayerView() {
|
|||||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||||
seekFromX(clientX);
|
seekFromX(clientX);
|
||||||
};
|
};
|
||||||
const onEnd = () => { isDragging.current = false; };
|
const onEnd = () => {
|
||||||
|
if (!isDragging.current) return;
|
||||||
|
isDragging.current = false;
|
||||||
|
const pending = pendingSeekRef.current;
|
||||||
|
pendingSeekRef.current = null;
|
||||||
|
setPreviewProgress(null);
|
||||||
|
if (pending !== null) seek(pending);
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMove);
|
window.addEventListener('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onEnd);
|
window.addEventListener('mouseup', onEnd);
|
||||||
@@ -238,6 +252,11 @@ export default function MobilePlayerView() {
|
|||||||
};
|
};
|
||||||
}, [seekFromX]);
|
}, [seekFromX]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingSeekRef.current = null;
|
||||||
|
setPreviewProgress(null);
|
||||||
|
}, [currentTrack?.id]);
|
||||||
|
|
||||||
// Drawers
|
// Drawers
|
||||||
const [showQueue, setShowQueue] = useState(false);
|
const [showQueue, setShowQueue] = useState(false);
|
||||||
const [showLyrics, setShowLyrics] = useState(false);
|
const [showLyrics, setShowLyrics] = useState(false);
|
||||||
@@ -264,6 +283,11 @@ export default function MobilePlayerView() {
|
|||||||
const bgStyle: CSSProperties = {
|
const bgStyle: CSSProperties = {
|
||||||
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
||||||
};
|
};
|
||||||
|
const effectiveProgress = previewProgress ?? progress;
|
||||||
|
const effectiveTime =
|
||||||
|
previewProgress !== null && duration > 0
|
||||||
|
? previewProgress * duration
|
||||||
|
: currentTime;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mp-view" style={bgStyle}>
|
<div className="mp-view" style={bgStyle}>
|
||||||
@@ -328,12 +352,12 @@ export default function MobilePlayerView() {
|
|||||||
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
||||||
>
|
>
|
||||||
<div className="mp-scrubber-bg" />
|
<div className="mp-scrubber-bg" />
|
||||||
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
|
<div className="mp-scrubber-fill" style={{ width: `${effectiveProgress * 100}%` }} />
|
||||||
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
|
<div className="mp-scrubber-thumb" style={{ left: `${effectiveProgress * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mp-scrubber-times">
|
<div className="mp-scrubber-times">
|
||||||
<span>{formatTime(currentTime)}</span>
|
<span>{formatTime(effectiveTime)}</span>
|
||||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
<span>-{formatTime(Math.max(0, duration - effectiveTime))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -801,6 +801,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WaveformSeek({ trackId }: Props) {
|
export default function WaveformSeek({ trackId }: Props) {
|
||||||
|
const SEEK_COMMIT_GUARD_MS = 900;
|
||||||
|
const SEEK_COMMIT_MIN_HOLD_MS = 320;
|
||||||
|
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const heightsRef = useRef<Float32Array | null>(null);
|
const heightsRef = useRef<Float32Array | null>(null);
|
||||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||||
@@ -835,6 +838,15 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
// While user drags, keep the local preview stable. External progress ticks
|
// While user drags, keep the local preview stable. External progress ticks
|
||||||
// during streaming/recovery would otherwise fight the cursor and flicker.
|
// during streaming/recovery would otherwise fight the cursor and flicker.
|
||||||
if (isDragging.current) return;
|
if (isDragging.current) return;
|
||||||
|
const pendingCommit = pendingCommittedSeekRef.current;
|
||||||
|
if (pendingCommit) {
|
||||||
|
const ageMs = Date.now() - pendingCommit.setAtMs;
|
||||||
|
if (ageMs < SEEK_COMMIT_MIN_HOLD_MS) return;
|
||||||
|
const matched = Math.abs(state.progress - pendingCommit.fraction) <= SEEK_COMMIT_PROGRESS_EPS;
|
||||||
|
const expired = ageMs > SEEK_COMMIT_GUARD_MS;
|
||||||
|
if (!matched && !expired) return;
|
||||||
|
pendingCommittedSeekRef.current = null;
|
||||||
|
}
|
||||||
progressRef.current = state.progress;
|
progressRef.current = state.progress;
|
||||||
bufferedRef.current = state.buffered;
|
bufferedRef.current = state.buffered;
|
||||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||||
@@ -894,6 +906,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
const seekRef = useRef(seek);
|
const seekRef = useRef(seek);
|
||||||
seekRef.current = seek;
|
seekRef.current = seek;
|
||||||
const pendingSeekRef = useRef<number | null>(null);
|
const pendingSeekRef = useRef<number | null>(null);
|
||||||
|
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
|
||||||
|
|
||||||
// Preview a 0–1 fraction while dragging: draw immediately for 1:1
|
// Preview a 0–1 fraction while dragging: draw immediately for 1:1
|
||||||
// responsiveness; the actual seek is committed on mouseup.
|
// responsiveness; the actual seek is committed on mouseup.
|
||||||
@@ -910,6 +923,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
const fraction = pendingSeekRef.current;
|
const fraction = pendingSeekRef.current;
|
||||||
if (fraction === null) return;
|
if (fraction === null) return;
|
||||||
pendingSeekRef.current = null;
|
pendingSeekRef.current = null;
|
||||||
|
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
|
||||||
seekRef.current(fraction);
|
seekRef.current(fraction);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+150
-25
@@ -220,10 +220,88 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||||
// engine has actually caught up to the new position.
|
// engine has actually caught up to the new position.
|
||||||
let seekTarget: number | null = null;
|
let seekTarget: number | null = null;
|
||||||
|
let seekTargetSetAt = 0;
|
||||||
|
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
||||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let seekFallbackRetryStartedAt = 0;
|
||||||
|
let seekFallbackRetryTarget: { trackId: string; seconds: number } | null = null;
|
||||||
let seekFallbackTrackId: string | null = null;
|
let seekFallbackTrackId: string | null = null;
|
||||||
let seekFallbackRestartAt = 0;
|
let seekFallbackRestartAt = 0;
|
||||||
|
let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: number } | null = null;
|
||||||
|
const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
|
||||||
|
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
|
||||||
|
const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
|
||||||
|
|
||||||
|
function setSeekTarget(seconds: number) {
|
||||||
|
seekTarget = seconds;
|
||||||
|
seekTargetSetAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSeekTarget() {
|
||||||
|
seekTarget = null;
|
||||||
|
seekTargetSetAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSeekFallbackRetry() {
|
||||||
|
if (seekFallbackRetryTimer) {
|
||||||
|
clearTimeout(seekFallbackRetryTimer);
|
||||||
|
seekFallbackRetryTimer = null;
|
||||||
|
}
|
||||||
|
seekFallbackRetryStartedAt = 0;
|
||||||
|
seekFallbackRetryTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableSeekError(msg: string): boolean {
|
||||||
|
return msg.includes('not seekable')
|
||||||
|
|| msg.includes('audio sink not ready')
|
||||||
|
|| msg.includes('audio seek busy')
|
||||||
|
|| msg.includes('audio seek timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
!seekFallbackRetryTarget
|
||||||
|
|| seekFallbackRetryTarget.trackId !== trackId
|
||||||
|
|| Math.abs(seekFallbackRetryTarget.seconds - seconds) > 0.25
|
||||||
|
) {
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
seekFallbackRetryStartedAt = now;
|
||||||
|
seekFallbackRetryTarget = { trackId, seconds };
|
||||||
|
} else if (seekFallbackRetryStartedAt === 0) {
|
||||||
|
seekFallbackRetryStartedAt = now;
|
||||||
|
}
|
||||||
|
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
|
||||||
|
seekFallbackRetryTimer = setTimeout(() => {
|
||||||
|
seekFallbackRetryTimer = null;
|
||||||
|
const target = seekFallbackRetryTarget;
|
||||||
|
const s = usePlayerStore.getState();
|
||||||
|
if (!target || !s.currentTrack || s.currentTrack.id !== target.trackId) {
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - seekFallbackRetryStartedAt > SEEK_FALLBACK_RETRY_MAX_MS) {
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invoke('audio_seek', { seconds: target.seconds }).then(() => {
|
||||||
|
setSeekTarget(target.seconds);
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
const msg = String(err ?? '');
|
||||||
|
if (!isRecoverableSeekError(msg)) {
|
||||||
|
console.error(err);
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleSeekFallbackRetry(target.trackId, target.seconds);
|
||||||
|
});
|
||||||
|
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// Guard against rapid double-click play/pause sending two state transitions
|
// Guard against rapid double-click play/pause sending two state transitions
|
||||||
// to the Rust backend before it has finished the previous one.
|
// to the Rust backend before it has finished the previous one.
|
||||||
@@ -388,17 +466,40 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
// position before the seek takes effect. Block until current_time is
|
// position before the seek takes effect. Block until current_time is
|
||||||
// within 2 s of the requested target, then clear the guard.
|
// within 2 s of the requested target, then clear the guard.
|
||||||
if (seekTarget !== null) {
|
if (seekTarget !== null) {
|
||||||
if (Math.abs(current_time - seekTarget) > 2.0) return;
|
if (Math.abs(current_time - seekTarget) > 2.0) {
|
||||||
seekTarget = null;
|
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||||||
|
if (Date.now() - seekTargetSetAt <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||||||
|
clearSeekTarget();
|
||||||
|
} else {
|
||||||
|
clearSeekTarget();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
const track = store.currentTrack;
|
const track = store.currentTrack;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) {
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
}
|
||||||
|
let displayTime = current_time;
|
||||||
|
if (
|
||||||
|
seekFallbackVisualTarget
|
||||||
|
&& seekFallbackVisualTarget.trackId === track.id
|
||||||
|
) {
|
||||||
|
const nearTarget = Math.abs(current_time - seekFallbackVisualTarget.seconds) <= 2.0;
|
||||||
|
if (nearTarget) {
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
} else if (Date.now() - seekFallbackVisualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
||||||
|
// Keep UI at the requested position while backend catches up.
|
||||||
|
displayTime = seekFallbackVisualTarget.seconds;
|
||||||
|
} else {
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const dur = duration > 0 ? duration : track.duration;
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
if (dur <= 0) return;
|
if (dur <= 0) return;
|
||||||
const progress = current_time / dur;
|
const progress = displayTime / dur;
|
||||||
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||||
|
|
||||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||||
if (progress >= 0.5 && !store.scrobbled) {
|
if (progress >= 0.5 && !store.scrobbled) {
|
||||||
@@ -966,7 +1067,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
}
|
}
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
clearSeekFallbackRetry();
|
||||||
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||||
set({
|
set({
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -986,7 +1088,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
clearRadioReconnectTimer();
|
clearRadioReconnectTimer();
|
||||||
radioReconnectCount = 0;
|
radioReconnectCount = 0;
|
||||||
gaplessPreloadingId = null; bytePreloadingId = null;
|
gaplessPreloadingId = null; bytePreloadingId = null;
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
clearSeekFallbackRetry();
|
||||||
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||||
// Stop Rust engine in case a regular track was playing.
|
// Stop Rust engine in case a regular track was playing.
|
||||||
invoke('audio_stop').catch(() => {});
|
invoke('audio_stop').catch(() => {});
|
||||||
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
|
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
|
||||||
@@ -1028,9 +1131,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const gen = ++playGeneration;
|
const gen = ++playGeneration;
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||||
if (seekFallbackRetryTimer) { clearTimeout(seekFallbackRetryTimer); seekFallbackRetryTimer = null; }
|
clearSeekFallbackRetry();
|
||||||
seekFallbackTrackId = null;
|
|
||||||
seekFallbackRestartAt = 0;
|
seekFallbackRestartAt = 0;
|
||||||
|
|
||||||
// If a radio stream is active, stop it before the new track starts so
|
// If a radio stream is active, stop it before the new track starts so
|
||||||
@@ -1044,8 +1146,20 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
const prevTrack = state.currentTrack;
|
const prevTrack = state.currentTrack;
|
||||||
|
seekFallbackTrackId = prevTrack?.id === track.id ? seekFallbackTrackId : null;
|
||||||
|
if (seekFallbackVisualTarget?.trackId !== track.id) {
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
}
|
||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id
|
||||||
|
? seekFallbackVisualTarget.seconds
|
||||||
|
: null;
|
||||||
|
const initialTime = pendingVisualTarget !== null
|
||||||
|
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
|
||||||
|
: 0;
|
||||||
|
const initialProgress =
|
||||||
|
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
||||||
|
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||||
@@ -1073,9 +1187,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
queue: newQueue,
|
queue: newQueue,
|
||||||
queueIndex: idx >= 0 ? idx : 0,
|
queueIndex: idx >= 0 ? idx : 0,
|
||||||
progress: 0,
|
progress: initialProgress,
|
||||||
buffered: 0,
|
buffered: 0,
|
||||||
currentTime: 0,
|
currentTime: initialTime,
|
||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
lastfmLoved: false,
|
lastfmLoved: false,
|
||||||
isPlaying: true, // optimistic — reverted on error
|
isPlaying: true, // optimistic — reverted on error
|
||||||
@@ -1141,7 +1255,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncQueueToServer(newQueue, track, 0);
|
syncQueueToServer(newQueue, track, initialTime);
|
||||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1425,35 +1539,45 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (seekDebounce) clearTimeout(seekDebounce);
|
if (seekDebounce) clearTimeout(seekDebounce);
|
||||||
seekDebounce = setTimeout(() => {
|
seekDebounce = setTimeout(() => {
|
||||||
seekDebounce = null;
|
seekDebounce = null;
|
||||||
seekTarget = time;
|
invoke('audio_seek', { seconds: time }).then(() => {
|
||||||
invoke('audio_seek', { seconds: time }).catch((err: unknown) => {
|
// Arm stale-progress guard only after backend acknowledged seek.
|
||||||
|
setSeekTarget(time);
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
clearSeekFallbackRetry();
|
||||||
|
}).catch((err: unknown) => {
|
||||||
// Release the progress-tick guard so the UI doesn't freeze
|
// Release the progress-tick guard so the UI doesn't freeze
|
||||||
// waiting for a target the engine will never reach.
|
// waiting for a target the engine will never reach.
|
||||||
seekTarget = null;
|
clearSeekTarget();
|
||||||
const msg = String(err ?? '');
|
const msg = String(err ?? '');
|
||||||
if (!msg.includes('not seekable')) {
|
if (!isRecoverableSeekError(msg)) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
seekFallbackVisualTarget = null;
|
||||||
|
clearSeekFallbackRetry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Streaming-start path can be non-seekable until the download finishes.
|
// Streaming-start path can be temporarily non-seekable or busy.
|
||||||
// Fallback: at most one restart burst per track, then keep only the latest retry seek.
|
// Keep UI at target and retry seek for a short bounded window.
|
||||||
const s = get();
|
const s = get();
|
||||||
if (!s.currentTrack) return;
|
if (!s.currentTrack) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sameBurst =
|
const sameBurst =
|
||||||
seekFallbackTrackId === s.currentTrack.id
|
seekFallbackTrackId === s.currentTrack.id
|
||||||
&& now - seekFallbackRestartAt < 600;
|
&& now - seekFallbackRestartAt < 600;
|
||||||
if (!sameBurst) {
|
seekFallbackVisualTarget = {
|
||||||
|
trackId: s.currentTrack.id,
|
||||||
|
seconds: time,
|
||||||
|
setAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
// Keep stale progress ticks from snapping UI back to start while
|
||||||
|
// recoverable seek retries are still in flight.
|
||||||
|
setSeekTarget(time);
|
||||||
|
if (msg.includes('not seekable') && !sameBurst) {
|
||||||
seekFallbackTrackId = s.currentTrack.id;
|
seekFallbackTrackId = s.currentTrack.id;
|
||||||
seekFallbackRestartAt = now;
|
seekFallbackRestartAt = now;
|
||||||
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
||||||
s.playTrack(s.currentTrack, s.queue, true);
|
s.playTrack(s.currentTrack, s.queue, true);
|
||||||
}
|
}
|
||||||
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
|
scheduleSeekFallbackRetry(s.currentTrack.id, time);
|
||||||
seekFallbackRetryTimer = setTimeout(() => {
|
|
||||||
seekFallbackRetryTimer = null;
|
|
||||||
invoke('audio_seek', { seconds: time }).catch(() => {});
|
|
||||||
}, 220);
|
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
@@ -1533,7 +1657,8 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
clearQueue: () => {
|
clearQueue: () => {
|
||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
clearSeekFallbackRetry();
|
||||||
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user