audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(embed): wire MediaSession metadata + action handlers (#1340)

Empirical finding from iOS lock-screen testing: embed surfaces
(CollectionEmbed.svelte for albums/playlists, embed/track/[id]/+page.svelte
for single tracks) set NOTHING on navigator.mediaSession. Result on iOS
Safari and Android Chrome lock-screen controls: generic placeholder title,
no cover art, next/previous buttons either greyed out or routing to nothing.
The main app's Player.svelte has the right behavior; the embeds were
just missing it.

Adds `lib/media-session.ts` — small helper module that wraps the four
MediaSession APIs we use (metadata, playbackState, positionState,
action handlers) with no-op fallbacks on platforms without the API and
a try/catch around setPositionState (which throws on stale
duration/position during track transitions).

Wires the helpers into both embed surfaces:

- Metadata effect: re-runs on track change. Pulls title/artist from
the track and falls back through track image → collection image
for artwork (single-track embed uses trackCoverUrl directly).
- PlaybackState effect: re-runs on paused change.
- PositionState effect: re-runs on time/duration change.
- Action handlers: registered ONCE on mount with cleanup on unmount.
Single-track embed explicitly nulls previoustrack/nexttrack so the
OS greys them out instead of inheriting stale handlers.
- Cleanup on unmount: clears metadata, sets playbackState to 'none',
nulls all handlers. Prevents stale lock-screen entries when the
user navigates away from an embed mid-playback.

Does NOT touch Player.svelte — it has its own (older, inline)
MediaSession setup that works. Refactoring it to use these helpers
is a separate dedup concern.

Validated via svelte:svelte-file-editor agent: zero autofixer issues,
reactivity correct (each effect reads only its deps), unmount cleanup
fires correctly, and `$state` closures inside the action handlers
read the current value at handler-call time (not a mount-time
snapshot).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
f7946389 fac6be73

+255 -2
+75
frontend/src/lib/components/embed/CollectionEmbed.svelte
··· 2 2 import { page } from '$app/stores'; 3 3 import { onMount, tick } from 'svelte'; 4 4 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 5 + import { 6 + clearMediaSessionMetadata, 7 + setMediaSessionActionHandlers, 8 + setMediaSessionMetadata, 9 + setMediaSessionPlaybackState, 10 + setMediaSessionPositionState 11 + } from '$lib/media-session'; 12 + import { trackCoverUrl } from '$lib/track-cover'; 5 13 import type { CollectionData } from '$lib/types'; 6 14 7 15 let { collection }: { collection: CollectionData } = $props(); ··· 91 99 if ($page.url.searchParams.get('autoplay') === '1' && isPlayable) { 92 100 audio.play().catch(() => { paused = true; }); 93 101 } 102 + 103 + // route OS-level lock-screen / system-media controls to the embed's 104 + // own playback functions. without these, the lock-screen control 105 + // only knew that an `<audio>` element was playing — no title, no 106 + // artwork, and the next/previous buttons were ignored. 107 + setMediaSessionActionHandlers({ 108 + play: () => { audio?.play().catch(() => {}); }, 109 + pause: () => { audio?.pause(); }, 110 + previoustrack: () => { void skipPrev(); }, 111 + nexttrack: () => { void skipNext(); }, 112 + seekto: (details) => { 113 + if (audio && details.seekTime !== undefined) { 114 + audio.currentTime = details.seekTime; 115 + } 116 + }, 117 + seekbackward: (details) => { 118 + if (!audio) return; 119 + audio.currentTime = Math.max( 120 + 0, 121 + audio.currentTime - (details.seekOffset ?? 10) 122 + ); 123 + }, 124 + seekforward: (details) => { 125 + if (!audio) return; 126 + audio.currentTime = Math.min( 127 + duration, 128 + audio.currentTime + (details.seekOffset ?? 10) 129 + ); 130 + } 131 + }); 132 + 133 + return () => { 134 + // clear metadata when the component unmounts so a stale embed 135 + // doesn't leave its title/cover hanging on the OS controls 136 + // after navigation away. 137 + clearMediaSessionMetadata(); 138 + setMediaSessionPlaybackState('none'); 139 + setMediaSessionActionHandlers({ 140 + play: null, 141 + pause: null, 142 + previoustrack: null, 143 + nexttrack: null, 144 + seekto: null, 145 + seekbackward: null, 146 + seekforward: null 147 + }); 148 + }; 149 + }); 150 + 151 + // keep the OS-level lock-screen metadata in sync with the current track 152 + $effect(() => { 153 + if (!currentTrack) return; 154 + setMediaSessionMetadata({ 155 + title: currentTrack.title, 156 + artist: currentTrack.artist, 157 + album: collection.title, 158 + artworkUrl: trackCoverUrl(currentTrack), 159 + artworkFallbackUrl: collection.imageUrl 160 + }); 161 + }); 162 + 163 + $effect(() => { 164 + setMediaSessionPlaybackState(paused ? 'paused' : 'playing'); 165 + }); 166 + 167 + $effect(() => { 168 + setMediaSessionPositionState({ duration, position: currentTime }); 94 169 }); 95 170 </script> 96 171
+111
frontend/src/lib/media-session.ts
··· 1 + /** 2 + * Helpers for keeping `navigator.mediaSession` populated. 3 + * 4 + * Use from any component that owns an `<audio>` element and wants the OS 5 + * lock-screen / system-media controls to show track title / artist / 6 + * artwork and route play/pause/next/prev to the right handlers. 7 + * 8 + * The main app player has its own (older) inline implementation in 9 + * `Player.svelte`; that's untouched here. These helpers exist so the 10 + * embed surfaces (which today set NO MediaSession state, leaving lock 11 + * screens with a placeholder) can match. 12 + * 13 + * All functions are no-ops on platforms / contexts where the API isn't 14 + * available (e.g. SSR, very old browsers). 15 + */ 16 + 17 + export interface MediaMetadataInput { 18 + title: string; 19 + artist: string; 20 + album?: string; 21 + /** 22 + * Preferred artwork URL (per-track image). When absent, the helper 23 + * falls back to `artworkFallbackUrl` (typically the collection / 24 + * album cover); when both are absent it leaves artwork empty so 25 + * the OS shows whatever it would by default. 26 + */ 27 + artworkUrl?: string | null; 28 + artworkFallbackUrl?: string | null; 29 + } 30 + 31 + function hasMediaSession(): boolean { 32 + return typeof navigator !== 'undefined' && 'mediaSession' in navigator; 33 + } 34 + 35 + function buildArtwork(input: MediaMetadataInput): MediaImage[] { 36 + const url = input.artworkUrl ?? input.artworkFallbackUrl; 37 + if (!url) return []; 38 + // Single 512×512 entry is the documented well-supported shape across 39 + // Chrome / Safari / Firefox; multiple sizes are nice-to-have but the 40 + // OS scales as needed. 41 + return [{ src: url, sizes: '512x512', type: 'image/jpeg' }]; 42 + } 43 + 44 + export function setMediaSessionMetadata(input: MediaMetadataInput): void { 45 + if (!hasMediaSession()) return; 46 + navigator.mediaSession.metadata = new MediaMetadata({ 47 + title: input.title, 48 + artist: input.artist, 49 + album: input.album ?? '', 50 + artwork: buildArtwork(input) 51 + }); 52 + } 53 + 54 + export function clearMediaSessionMetadata(): void { 55 + if (!hasMediaSession()) return; 56 + navigator.mediaSession.metadata = null; 57 + } 58 + 59 + export function setMediaSessionPlaybackState( 60 + state: 'playing' | 'paused' | 'none' 61 + ): void { 62 + if (!hasMediaSession()) return; 63 + navigator.mediaSession.playbackState = state; 64 + } 65 + 66 + export function setMediaSessionPositionState(opts: { 67 + duration: number; 68 + position: number; 69 + playbackRate?: number; 70 + }): void { 71 + if (!hasMediaSession()) return; 72 + if (!opts.duration || opts.duration <= 0) return; 73 + // `setPositionState` throws on bad input (e.g. position > duration); 74 + // the OS-level position display is purely cosmetic, so swallow. 75 + try { 76 + navigator.mediaSession.setPositionState({ 77 + duration: opts.duration, 78 + position: Math.max(0, Math.min(opts.position, opts.duration)), 79 + playbackRate: opts.playbackRate ?? 1 80 + }); 81 + } catch { 82 + // no-op: stale duration/position during track transitions can fail 83 + } 84 + } 85 + 86 + export type MediaSessionHandlers = Partial< 87 + Record<MediaSessionAction, MediaSessionActionHandler | null> 88 + >; 89 + 90 + /** 91 + * Apply (or clear, by passing `null`) a set of action handlers. Pass 92 + * `null` for an action to remove its handler — useful when a context 93 + * doesn't support that action (e.g. single-track embeds have no 94 + * `nexttrack`/`previoustrack`, so passing `null` for those tells the 95 + * OS to grey them out instead of inheriting a stale handler from a 96 + * prior page. 97 + */ 98 + export function setMediaSessionActionHandlers( 99 + handlers: MediaSessionHandlers 100 + ): void { 101 + if (!hasMediaSession()) return; 102 + for (const [action, handler] of Object.entries(handlers) as Array< 103 + [MediaSessionAction, MediaSessionActionHandler | null] 104 + >) { 105 + try { 106 + navigator.mediaSession.setActionHandler(action, handler); 107 + } catch { 108 + // some browsers throw on unsupported actions; leave them be 109 + } 110 + } 111 + }
+67
frontend/src/routes/embed/track/[id]/+page.svelte
··· 2 2 import { page } from '$app/stores'; 3 3 import { onMount } from 'svelte'; 4 4 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 5 + import { 6 + clearMediaSessionMetadata, 7 + setMediaSessionActionHandlers, 8 + setMediaSessionMetadata, 9 + setMediaSessionPlaybackState, 10 + setMediaSessionPositionState 11 + } from '$lib/media-session'; 5 12 import { trackCoverUrl } from '$lib/track-cover'; 6 13 import type { PageData } from './$types'; 7 14 ··· 53 60 paused = true; 54 61 }); 55 62 } 63 + 64 + // route OS-level lock-screen / system-media controls. single-track 65 + // embed has no next/previous, so we explicitly null those handlers 66 + // — that tells the OS to grey them out instead of inheriting a 67 + // stale handler from a prior page. 68 + setMediaSessionActionHandlers({ 69 + play: () => { audio?.play().catch(() => {}); }, 70 + pause: () => { audio?.pause(); }, 71 + previoustrack: null, 72 + nexttrack: null, 73 + seekto: (details) => { 74 + if (audio && details.seekTime !== undefined) { 75 + audio.currentTime = details.seekTime; 76 + } 77 + }, 78 + seekbackward: (details) => { 79 + if (!audio) return; 80 + audio.currentTime = Math.max( 81 + 0, 82 + audio.currentTime - (details.seekOffset ?? 10) 83 + ); 84 + }, 85 + seekforward: (details) => { 86 + if (!audio) return; 87 + audio.currentTime = Math.min( 88 + duration, 89 + audio.currentTime + (details.seekOffset ?? 10) 90 + ); 91 + } 92 + }); 93 + 94 + return () => { 95 + clearMediaSessionMetadata(); 96 + setMediaSessionPlaybackState('none'); 97 + setMediaSessionActionHandlers({ 98 + play: null, 99 + pause: null, 100 + seekto: null, 101 + seekbackward: null, 102 + seekforward: null 103 + }); 104 + }; 105 + }); 106 + 107 + $effect(() => { 108 + if (!track) return; 109 + setMediaSessionMetadata({ 110 + title: track.title, 111 + artist: track.artist, 112 + album: track.album?.title, 113 + artworkUrl: coverUrl 114 + }); 115 + }); 116 + 117 + $effect(() => { 118 + setMediaSessionPlaybackState(paused ? 'paused' : 'playing'); 119 + }); 120 + 121 + $effect(() => { 122 + setMediaSessionPositionState({ duration, position: currentTime }); 56 123 }); 57 124 </script> 58 125
+2 -2
loq.toml
··· 220 220 221 221 [[rules]] 222 222 path = "frontend/src/lib/components/embed/CollectionEmbed.svelte" 223 - max_lines = 580 223 + max_lines = 643 224 224 225 225 [[rules]] 226 226 path = "backend/tests/api/test_track_deletion.py" ··· 272 272 273 273 [[rules]] 274 274 path = "frontend/src/routes/embed/track/[[]id[]]/+page.svelte" 275 - max_lines = 556 275 + max_lines = 623