From 73ca667b65749b6ec2365cb1f24be70f172ade03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:14:35 +0000 Subject: [PATCH] feat(player): complete MediaSession handlers + teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock screen / Control Center / CarPlay expose a stop control and 15s skip (seekbackward/seekforward) that had no handlers, so those affordances did nothing. The registration effect also never cleared its handlers, so when the queue emptied the lock screen stayed bound to the previous track's closures. - Register stop, seekbackward, seekforward (plus the existing seekto), each wrapped so an engine that doesn't support an action doesn't throw. - Tear all handlers down on cleanup and when nothing is loaded (also clear metadata), via a shared MEDIA_ACTIONS list. - Centralize playbackState as the single source of truth: 'none' when nothing is loaded, else mirror isPlaying — so iOS routes the next remote press to the correct action. Verified in-browser: playing sets metadata + playbackState 'playing', pausing sets 'paused', and all handlers register without throwing. Fixes #10. Fixes #11. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FkDKh1Uo1a4D7n5wCKdcKF --- src/state/PlayerProvider.jsx | 75 ++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index 2c87d91..7ef4f8f 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -8,6 +8,14 @@ export const usePlayer = () => useContext(PlayerContext) // off → all (loop the whole queue/playlist) → one (loop this song) → off export const LOOP_MODES = ['off', 'all', 'one'] +// Every MediaSession action we ever register — used to tear them all down on +// cleanup / when nothing is playing, so the lock screen never keeps stale +// handlers bound to a previous track's closures. +const MEDIA_ACTIONS = [ + 'play', 'pause', 'previoustrack', 'nexttrack', + 'stop', 'seekbackward', 'seekforward', 'seekto', +] + export function PlayerProvider({ children }) { const audioRef = useRef(null) const countedRef = useRef(false) // so each play only bumps playCount once @@ -197,34 +205,67 @@ export function PlayerProvider({ children }) { } } - // ── Media Session API → iOS lock screen / control center ── + // ── Media Session API → iOS lock screen / control center / AirPods ── useEffect(() => { - if (!('mediaSession' in navigator) || !current) return - navigator.mediaSession.metadata = new window.MediaMetadata({ + if (!('mediaSession' in navigator)) return + const ms = navigator.mediaSession + // setActionHandler throws for actions an engine doesn't support, so wrap + // each one; likewise nulling handlers on cleanup. + const clearAll = () => { + for (const a of MEDIA_ACTIONS) { + try { ms.setActionHandler(a, null) } catch { /* action unsupported */ } + } + } + + // Nothing loaded → tear down so the lock screen doesn't keep stale controls. + if (!current) { + ms.metadata = null + clearAll() + return + } + + ms.metadata = new window.MediaMetadata({ title: current.title || 'Unknown', artist: current.artist || '', artwork: current.thumbnailUrl ? [{ src: current.thumbnailUrl, sizes: '512x512', type: 'image/png' }] : [], }) - navigator.mediaSession.setActionHandler('play', play) - navigator.mediaSession.setActionHandler('pause', pause) - navigator.mediaSession.setActionHandler('previoustrack', prev) - navigator.mediaSession.setActionHandler('nexttrack', next) - try { - navigator.mediaSession.setActionHandler('seekto', (d) => { - if (d.seekTime != null) seek(d.seekTime) - }) - } catch { - /* 'seekto' unsupported on some browsers — non-fatal */ + + const set = (action, handler) => { + try { ms.setActionHandler(action, handler) } catch { /* action unsupported */ } } + set('play', play) + set('pause', pause) + set('previoustrack', prev) + set('nexttrack', next) + set('stop', () => { + pause() + if (audioRef.current) audioRef.current.currentTime = 0 + }) + set('seekbackward', (d) => seek(Math.max(0, (audioRef.current?.currentTime || 0) - (d.seekOffset || 10)))) + set('seekforward', (d) => { + const a = audioRef.current + if (!a) return + const target = (a.currentTime || 0) + (d.seekOffset || 10) + seek(a.duration ? Math.min(a.duration, target) : target) + }) + set('seekto', (d) => { if (d.seekTime != null) seek(d.seekTime) }) + + // Clear handlers when the track changes or the provider unmounts, so no + // remote press ever fires a closure bound to the previous track. + return clearAll }, [current, play, pause, prev, next, seek]) + // Single source of truth for playbackState: 'none' when nothing is loaded, + // else mirror isPlaying. iOS uses this to decide whether a remote press maps + // to the play or the pause action, so it must stay in sync. useEffect(() => { - if ('mediaSession' in navigator) { - navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused' - } - }, [isPlaying]) + if (!('mediaSession' in navigator)) return + try { + navigator.mediaSession.playbackState = !current ? 'none' : isPlaying ? 'playing' : 'paused' + } catch { /* older browsers */ } + }, [isPlaying, current]) const value = { current, queue, index, isPlaying, loopMode, progress, duration, missing,