diff --git a/web/src/lib/player/mediaSession.svelte.test.ts b/web/src/lib/player/mediaSession.svelte.test.ts new file mode 100644 index 00000000..faf89dc6 --- /dev/null +++ b/web/src/lib/player/mediaSession.svelte.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { flushSync } from 'svelte'; +import type { TrackRef } from '$lib/api/types'; + +function track(id: string): TrackRef { + return { + id, title: `T ${id}`, + album_id: 'a1', album_title: 'A', + artist_id: 'ar1', artist_name: 'Ar', + track_number: Number(id), disc_number: 1, + duration_sec: 180, stream_url: `/api/tracks/${id}/stream` + }; +} + +type Handler = (details: MediaSessionActionDetails) => void; + +// Spy MediaSession replaces navigator.mediaSession for the duration of each test. +function makeSpyMediaSession() { + const handlers: Record = {}; + const spy = { + metadata: null as MediaMetadata | null, + playbackState: 'none' as MediaSessionPlaybackState, + setActionHandler: vi.fn((action: string, h: Handler | null) => { + if (h) handlers[action] = h; + else delete handlers[action]; + }), + setPositionState: vi.fn(), + handlers + }; + return spy; +} + +describe('useMediaSession', () => { + let spy: ReturnType; + let originalMS: unknown; + + beforeEach(async () => { + spy = makeSpyMediaSession(); + originalMS = (navigator as unknown as { mediaSession?: unknown }).mediaSession; + Object.defineProperty(navigator, 'mediaSession', { + configurable: true, + value: spy + }); + // Also stub MediaMetadata so we can construct it in jsdom. + (globalThis as unknown as { MediaMetadata: typeof Object }).MediaMetadata = + class { constructor(public init: unknown) {} } as unknown as typeof Object; + + const store = await import('./store.svelte'); + store.playQueue([]); + }); + + afterEach(() => { + if (originalMS === undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (navigator as any).mediaSession; + } else { + Object.defineProperty(navigator, 'mediaSession', { + configurable: true, + value: originalMS + }); + } + vi.restoreAllMocks(); + }); + + test('sets metadata when current track changes; clears when queue empties', async () => { + const store = await import('./store.svelte'); + const { useMediaSession } = await import('./mediaSession.svelte'); + const cleanup = $effect.root(() => useMediaSession()); + + store.playQueue([track('1')]); + flushSync(); + expect(spy.metadata).not.toBeNull(); + + store.playQueue([]); + flushSync(); + expect(spy.metadata).toBeNull(); + + cleanup(); + }); + + test('registers play/pause/previoustrack/nexttrack/seekto handlers', async () => { + const { useMediaSession } = await import('./mediaSession.svelte'); + const cleanup = $effect.root(() => useMediaSession()); + flushSync(); + + expect(spy.handlers['play']).toBeTypeOf('function'); + expect(spy.handlers['pause']).toBeTypeOf('function'); + expect(spy.handlers['previoustrack']).toBeTypeOf('function'); + expect(spy.handlers['nexttrack']).toBeTypeOf('function'); + expect(spy.handlers['seekto']).toBeTypeOf('function'); + + cleanup(); + }); + + test('playbackState mirrors player.isPlaying', async () => { + const store = await import('./store.svelte'); + const { useMediaSession } = await import('./mediaSession.svelte'); + const cleanup = $effect.root(() => useMediaSession()); + + store.playQueue([track('1')]); + store.reportStateFromAudio('playing'); + flushSync(); + expect(spy.playbackState).toBe('playing'); + + store.reportStateFromAudio('paused'); + flushSync(); + expect(spy.playbackState).toBe('paused'); + + cleanup(); + }); + + test('setPositionState called when duration > 0', async () => { + const store = await import('./store.svelte'); + const { useMediaSession } = await import('./mediaSession.svelte'); + const cleanup = $effect.root(() => useMediaSession()); + + store.playQueue([track('1')]); + store.reportDuration(240); + store.reportTimeUpdate(30); + flushSync(); + + expect(spy.setPositionState).toHaveBeenCalled(); + const call = spy.setPositionState.mock.calls.at(-1)?.[0]; + expect(call?.duration).toBe(240); + expect(call?.position).toBeLessThanOrEqual(240); + + cleanup(); + }); +}); diff --git a/web/src/lib/player/mediaSession.svelte.ts b/web/src/lib/player/mediaSession.svelte.ts new file mode 100644 index 00000000..acc966ee --- /dev/null +++ b/web/src/lib/player/mediaSession.svelte.ts @@ -0,0 +1,56 @@ +import { + player, + togglePlay, + skipNext, + skipPrev, + seekTo +} from './store.svelte'; + +export function useMediaSession(): void { + if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return; + const ms = navigator.mediaSession; + + $effect(() => { + const t = player.current; + if (!t) { + ms.metadata = null; + return; + } + ms.metadata = new MediaMetadata({ + title: t.title, + artist: t.artist_name, + album: t.album_title, + artwork: [{ + src: `/api/albums/${t.album_id}/cover`, + sizes: '512x512', + type: 'image/jpeg' + }] + }); + }); + + ms.setActionHandler('play', () => togglePlay()); + ms.setActionHandler('pause', () => togglePlay()); + ms.setActionHandler('previoustrack', () => skipPrev()); + ms.setActionHandler('nexttrack', () => skipNext()); + ms.setActionHandler('seekto', (e) => { + if (typeof e.seekTime === 'number') seekTo(e.seekTime); + }); + + $effect(() => { + if (player.duration > 0 && 'setPositionState' in ms) { + try { + ms.setPositionState({ + duration: player.duration, + position: Math.min(player.position, player.duration), + playbackRate: 1 + }); + } catch { + // setPositionState can throw on NaN/Infinity briefly between tracks. + } + } + }); + + $effect(() => { + ms.playbackState = player.isPlaying ? 'playing' : 'paused'; + }); +}