From df01605c4c8ad54e8dd6cbbd43ebfd5c74f773da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:21:12 +0000 Subject: [PATCH 1/2] fix(player): restart on re-tap + kill loop-all Now Playing flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load effect was keyed on [index, current?.id], so re-selecting the track already playing (same index) never reloaded it — tapping a finished/playing track did nothing instead of restarting. next()/prev() on a single-track loop-all queue had the same dead-end. And the loop-all wrap used setIndex(-1)+requestAnimationFrame(()=>setIndex(0)), which made `current` null for a frame and flashed the Now Playing screen closed (losing lyrics scroll) every cycle. Add a playToken nonce to the load effect's deps and bump it whenever a restart is intended: - playQueue bumps it, so re-tapping the current track restarts it. - next()/prev() bump it (instead of a no-op setIndex) when the target index equals the current one (single-track loop-all). - onEnded's loop-all wrap sets index 0 and bumps the token instead of the setIndex(-1) bounce, so `current` never becomes null — no more flicker. next()/prev() now read `index` from the render closure (added to deps) so the same-index restart case is detectable. Verified in-browser: playing a track, seeking to 5s, and re-tapping it restarts to ~0; switching to a different track still works; no errors. Fixes #9. Fixes #14. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FkDKh1Uo1a4D7n5wCKdcKF --- src/state/PlayerProvider.jsx | 38 +++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index 7ef4f8f..d85254b 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -28,6 +28,11 @@ export function PlayerProvider({ children }) { const [loopMode, setLoopMode] = useState('off') const [progress, setProgress] = useState(0) const [duration, setDuration] = useState(0) + // Bumped to force the load effect to re-run even when index/current.id are + // unchanged — i.e. restart the current track on re-tap or a single-track + // loop-all wrap, without the setIndex(-1) bounce that made `current` briefly + // null (which flashed the Now Playing screen closed). + const [playToken, setPlayToken] = useState(0) const current = index >= 0 ? queue[index] : null @@ -36,6 +41,7 @@ export function PlayerProvider({ children }) { if (!tracks.length) return setQueue(tracks) setIndex(startIndex) + setPlayToken((t) => t + 1) // reload even if startIndex === the current index }, []) // Insert a track right after the current one (Spotify's "Play next"). @@ -106,7 +112,7 @@ export function PlayerProvider({ children }) { return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [index, current?.id]) + }, [index, current?.id, playToken]) // Keep the native loop flag in sync when the mode changes mid-track. useEffect(() => { @@ -143,11 +149,14 @@ export function PlayerProvider({ children }) { }, [current, play, pause]) const next = useCallback(() => { - setIndex((i) => { - if (i + 1 < queue.length) return i + 1 - return loopMode === 'all' ? 0 : i // stay put at the end when not looping all - }) - }, [queue.length, loopMode]) + let target + if (index + 1 < queue.length) target = index + 1 + else if (loopMode === 'all') target = 0 + else return // at the end with no loop → stay put + // Same index (single-track loop-all) → force a restart; otherwise just move. + if (target === index) setPlayToken((t) => t + 1) + else setIndex(target) + }, [index, queue.length, loopMode]) const prev = useCallback(() => { const audio = audioRef.current @@ -156,8 +165,13 @@ export function PlayerProvider({ children }) { audio.currentTime = 0 return } - setIndex((i) => (i > 0 ? i - 1 : loopMode === 'all' ? queue.length - 1 : i)) - }, [queue.length, loopMode]) + let target + if (index > 0) target = index - 1 + else if (loopMode === 'all') target = queue.length - 1 + else return // at the start with no loop → stay put + if (target === index) setPlayToken((t) => t + 1) + else setIndex(target) + }, [index, queue.length, loopMode]) const seek = useCallback((t) => { if (audioRef.current) audioRef.current.currentTime = t @@ -197,9 +211,11 @@ export function PlayerProvider({ children }) { if (index + 1 < queue.length) { setIndex((i) => i + 1) } else if (loopMode === 'all') { - // Re-trigger index 0 even if already there. - setIndex(-1) - requestAnimationFrame(() => setIndex(0)) + // Wrap to the top and restart even if already at index 0 (single-track + // queue). The playToken bump forces the reload without nulling `current`, + // so Now Playing no longer flashes closed on every loop. + setIndex(0) + setPlayToken((t) => t + 1) } else { setIsPlaying(false) } From 11f509f8171738940da10a79caa9e38cacb11015 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:25:17 +0000 Subject: [PATCH 2/2] refactor(player): keep next/prev advances atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: instead of reading `index` from the closure (which could collapse two synchronous next()/prev() calls into a single advance), special- case only the single-track queue — where a restart needs a playToken bump because the index can't change — and keep the functional setIndex updater for the multi-track advance so it stays atomic. Also drops `index` from the deps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FkDKh1Uo1a4D7n5wCKdcKF --- src/state/PlayerProvider.jsx | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index d85254b..5bfc46c 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -149,14 +149,16 @@ export function PlayerProvider({ children }) { }, [current, play, pause]) const next = useCallback(() => { - let target - if (index + 1 < queue.length) target = index + 1 - else if (loopMode === 'all') target = 0 - else return // at the end with no loop → stay put - // Same index (single-track loop-all) → force a restart; otherwise just move. - if (target === index) setPlayToken((t) => t + 1) - else setIndex(target) - }, [index, queue.length, loopMode]) + // Single-track queue: the only "advance" is restarting the lone track, and + // only when looping all (otherwise stay put). A token bump handles it since + // the index can't change. + if (queue.length <= 1) { + if (loopMode === 'all') setPlayToken((t) => t + 1) + return + } + // Multi-track: functional updater keeps advances atomic under rapid taps. + setIndex((i) => (i + 1 < queue.length ? i + 1 : loopMode === 'all' ? 0 : i)) + }, [queue.length, loopMode]) const prev = useCallback(() => { const audio = audioRef.current @@ -165,13 +167,12 @@ export function PlayerProvider({ children }) { audio.currentTime = 0 return } - let target - if (index > 0) target = index - 1 - else if (loopMode === 'all') target = queue.length - 1 - else return // at the start with no loop → stay put - if (target === index) setPlayToken((t) => t + 1) - else setIndex(target) - }, [index, queue.length, loopMode]) + if (queue.length <= 1) { + if (loopMode === 'all') setPlayToken((t) => t + 1) + return + } + setIndex((i) => (i > 0 ? i - 1 : loopMode === 'all' ? queue.length - 1 : i)) + }, [queue.length, loopMode]) const seek = useCallback((t) => { if (audioRef.current) audioRef.current.currentTime = t