|
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 2 | + |
| 3 | +import { MediaSessionController } from './MediaSessionController'; |
| 4 | + |
| 5 | +type ActionHandlers = Record<string, MediaSessionActionHandler>; |
| 6 | + |
| 7 | +function installMockSession() { |
| 8 | + const handlers: ActionHandlers = {}; |
| 9 | + const session = { |
| 10 | + metadata: null as MediaMetadata | null, |
| 11 | + playbackState: 'none' as MediaSessionPlaybackState, |
| 12 | + setActionHandler: vi.fn((action: string, handler: MediaSessionActionHandler | null) => { |
| 13 | + if (handler) { |
| 14 | + handlers[action] = handler; |
| 15 | + } else { |
| 16 | + delete handlers[action]; |
| 17 | + } |
| 18 | + }), |
| 19 | + setPositionState: vi.fn(), |
| 20 | + }; |
| 21 | + Object.defineProperty(navigator, 'mediaSession', { |
| 22 | + value: session, |
| 23 | + configurable: true, |
| 24 | + writable: true, |
| 25 | + }); |
| 26 | + // jsdom has no MediaMetadata constructor — provide a minimal stand-in. |
| 27 | + (globalThis as unknown as { MediaMetadata: unknown }).MediaMetadata = class { |
| 28 | + title?: string; |
| 29 | + artist?: string; |
| 30 | + album?: string; |
| 31 | + artwork?: MediaImage[]; |
| 32 | + constructor(init: MediaMetadataInit = {}) { |
| 33 | + this.title = init.title; |
| 34 | + this.artist = init.artist; |
| 35 | + this.album = init.album; |
| 36 | + this.artwork = init.artwork as MediaImage[] | undefined; |
| 37 | + } |
| 38 | + }; |
| 39 | + return { session, handlers }; |
| 40 | +} |
| 41 | + |
| 42 | +function removeMockSession() { |
| 43 | + Reflect.deleteProperty(navigator, 'mediaSession'); |
| 44 | + Reflect.deleteProperty(globalThis as object, 'MediaMetadata'); |
| 45 | +} |
| 46 | + |
| 47 | +function makeActions() { |
| 48 | + return { |
| 49 | + play: vi.fn(), |
| 50 | + pause: vi.fn(), |
| 51 | + stop: vi.fn(), |
| 52 | + seekTo: vi.fn(), |
| 53 | + seekBackward: vi.fn(), |
| 54 | + seekForward: vi.fn(), |
| 55 | + }; |
| 56 | +} |
| 57 | + |
| 58 | +describe('MediaSessionController', () => { |
| 59 | + afterEach(() => { |
| 60 | + removeMockSession(); |
| 61 | + vi.restoreAllMocks(); |
| 62 | + }); |
| 63 | + |
| 64 | + describe('with a Media Session available', () => { |
| 65 | + let session: ReturnType<typeof installMockSession>['session']; |
| 66 | + let handlers: ActionHandlers; |
| 67 | + let controller: MediaSessionController; |
| 68 | + let actions: ReturnType<typeof makeActions>; |
| 69 | + |
| 70 | + beforeEach(() => { |
| 71 | + ({ session, handlers } = installMockSession()); |
| 72 | + controller = new MediaSessionController(); |
| 73 | + actions = makeActions(); |
| 74 | + }); |
| 75 | + |
| 76 | + it('publishes track metadata and a playing state on setNowPlaying', () => { |
| 77 | + controller.setNowPlaying( |
| 78 | + { title: 'Cantina Band', artist: 'Figrin D’an', album: 'Mos Eisley' }, |
| 79 | + actions, |
| 80 | + ); |
| 81 | + expect(session.metadata?.title).toBe('Cantina Band'); |
| 82 | + expect(session.metadata?.artist).toBe('Figrin D’an'); |
| 83 | + expect(session.playbackState).toBe('playing'); |
| 84 | + }); |
| 85 | + |
| 86 | + it('routes OS transport actions to the supplied callbacks', () => { |
| 87 | + controller.setNowPlaying({ title: 'Track' }, actions); |
| 88 | + |
| 89 | + handlers.play?.({ action: 'play' }); |
| 90 | + handlers.pause?.({ action: 'pause' }); |
| 91 | + handlers.stop?.({ action: 'stop' }); |
| 92 | + expect(actions.play).toHaveBeenCalledOnce(); |
| 93 | + expect(actions.pause).toHaveBeenCalledOnce(); |
| 94 | + expect(actions.stop).toHaveBeenCalledOnce(); |
| 95 | + }); |
| 96 | + |
| 97 | + it('forwards seekto with the OS-supplied time', () => { |
| 98 | + controller.setNowPlaying({ title: 'Track' }, actions); |
| 99 | + handlers.seekto?.({ action: 'seekto', seekTime: 42 }); |
| 100 | + expect(actions.seekTo).toHaveBeenCalledWith(42); |
| 101 | + }); |
| 102 | + |
| 103 | + it('falls back to a default offset for seekforward/backward', () => { |
| 104 | + controller.setNowPlaying({ title: 'Track' }, actions); |
| 105 | + handlers.seekforward?.({ action: 'seekforward' }); |
| 106 | + handlers.seekbackward?.({ action: 'seekbackward', seekOffset: 5 }); |
| 107 | + expect(actions.seekForward).toHaveBeenCalledWith(10); |
| 108 | + expect(actions.seekBackward).toHaveBeenCalledWith(5); |
| 109 | + }); |
| 110 | + |
| 111 | + it('re-targets the once-registered handlers to the latest track', () => { |
| 112 | + controller.setNowPlaying({ title: 'First' }, actions); |
| 113 | + const second = makeActions(); |
| 114 | + controller.setNowPlaying({ title: 'Second' }, second); |
| 115 | + |
| 116 | + // Handlers are registered exactly once per action (6 actions total). |
| 117 | + expect(session.setActionHandler).toHaveBeenCalledTimes(6); |
| 118 | + |
| 119 | + handlers.pause?.({ action: 'pause' }); |
| 120 | + expect(second.pause).toHaveBeenCalledOnce(); |
| 121 | + expect(actions.pause).not.toHaveBeenCalled(); |
| 122 | + }); |
| 123 | + |
| 124 | + it('reports a finite position to the scrubber', () => { |
| 125 | + controller.setPositionState(120, 30, 1); |
| 126 | + expect(session.setPositionState).toHaveBeenCalledWith({ |
| 127 | + duration: 120, |
| 128 | + position: 30, |
| 129 | + playbackRate: 1, |
| 130 | + }); |
| 131 | + }); |
| 132 | + |
| 133 | + it('clamps position into [0, duration]', () => { |
| 134 | + controller.setPositionState(100, 250, 1); |
| 135 | + expect(session.setPositionState).toHaveBeenCalledWith({ |
| 136 | + duration: 100, |
| 137 | + position: 100, |
| 138 | + playbackRate: 1, |
| 139 | + }); |
| 140 | + }); |
| 141 | + |
| 142 | + it('skips position reporting when the duration is unknown', () => { |
| 143 | + controller.setPositionState(NaN, 10); |
| 144 | + controller.setPositionState(0, 10); |
| 145 | + expect(session.setPositionState).not.toHaveBeenCalled(); |
| 146 | + }); |
| 147 | + |
| 148 | + it('clears metadata, state, and position on clear', () => { |
| 149 | + controller.setNowPlaying({ title: 'Track' }, actions); |
| 150 | + controller.clear(); |
| 151 | + expect(session.metadata).toBeNull(); |
| 152 | + expect(session.playbackState).toBe('none'); |
| 153 | + expect(session.setPositionState).toHaveBeenLastCalledWith(); |
| 154 | + }); |
| 155 | + }); |
| 156 | + |
| 157 | + describe('without a Media Session (unsupported browser / SSR)', () => { |
| 158 | + it('no-ops on every method instead of throwing', () => { |
| 159 | + // No installMockSession(): navigator.mediaSession is undefined. |
| 160 | + const controller = new MediaSessionController(); |
| 161 | + const actions = makeActions(); |
| 162 | + expect(() => { |
| 163 | + controller.setNowPlaying({ title: 'Track' }, actions); |
| 164 | + controller.setPlaybackState('paused'); |
| 165 | + controller.setPositionState(100, 10); |
| 166 | + controller.clear(); |
| 167 | + }).not.toThrow(); |
| 168 | + }); |
| 169 | + }); |
| 170 | +}); |
0 commit comments