Skip to content

Commit 6ae7737

Browse files
authored
Merge pull request #36 from MongooseMoo/feat/media-session-spatial-overlays
feat(audio): Media Session controls + spatial overlay scaffolding
2 parents a5e4f48 + 5b9f252 commit 6ae7737

17 files changed

Lines changed: 1862 additions & 0 deletions
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Bridges playing media to the browser's Media Session API
3+
* (`navigator.mediaSession`), which is what drives OS-level "now playing"
4+
* surfaces: the lock screen, notification-shade transport controls, hardware
5+
* media keys, smartwatch remotes, and car head units.
6+
*
7+
* The controller owns NO audio. It is a presentation surface: callers tell it
8+
* what is playing and supply callbacks the OS controls invoke, and it relays
9+
* those to the platform. Everything is feature-detected, so on a browser (or
10+
* test environment) without the API every method is a silent no-op.
11+
*/
12+
13+
/** Lock-screen track info. `title` is the only required field. */
14+
export interface MediaSessionTrack {
15+
title: string;
16+
artist?: string;
17+
album?: string;
18+
artwork?: MediaImage[];
19+
}
20+
21+
/**
22+
* Callbacks the OS transport controls invoke. The controller never decides what
23+
* these do — it forwards the user's lock-screen gesture to the owner, which acts
24+
* on whatever it currently considers "now playing".
25+
*/
26+
export interface MediaSessionActions {
27+
play(): void;
28+
pause(): void;
29+
stop(): void;
30+
seekTo(time: number): void;
31+
seekBackward(offset: number): void;
32+
seekForward(offset: number): void;
33+
}
34+
35+
/** Default jump (seconds) for seek-forward/backward when the OS omits an offset. */
36+
const DEFAULT_SEEK_OFFSET_SECONDS = 10;
37+
38+
export class MediaSessionController {
39+
/** Active forwarding target; null when nothing is playing. */
40+
private actions: MediaSessionActions | null = null;
41+
/** Platform action handlers are registered once, then re-target via `actions`. */
42+
private handlersBound = false;
43+
44+
/** The live MediaSession, or null where the API is unavailable. */
45+
private get session(): MediaSession | null {
46+
if (typeof navigator === 'undefined') {
47+
return null;
48+
}
49+
return navigator.mediaSession ?? null;
50+
}
51+
52+
/**
53+
* Announce a new now-playing track and bind the OS controls to `actions`.
54+
* Re-targets the (once-registered) platform handlers so the controls always
55+
* drive the most recently started track.
56+
*/
57+
setNowPlaying(track: MediaSessionTrack, actions: MediaSessionActions): void {
58+
const session = this.session;
59+
if (!session) {
60+
return;
61+
}
62+
this.actions = actions;
63+
this.bindHandlers(session);
64+
session.metadata = this.buildMetadata(track);
65+
session.playbackState = 'playing';
66+
}
67+
68+
/** Reflect play/pause state so the OS shows the right transport icon. */
69+
setPlaybackState(state: MediaSessionPlaybackState): void {
70+
const session = this.session;
71+
if (session) {
72+
session.playbackState = state;
73+
}
74+
}
75+
76+
/**
77+
* Report the scrubber position. The OS extrapolates position from
78+
* `playbackRate`, so this only needs calling on play/pause/seek — not on a
79+
* timer. Skipped entirely when the duration is unknown (NaN/0), which is the
80+
* common case for open-ended streams.
81+
*/
82+
setPositionState(duration: number, position: number, playbackRate = 1): void {
83+
const session = this.session;
84+
if (!session || typeof session.setPositionState !== 'function') {
85+
return;
86+
}
87+
if (!Number.isFinite(duration) || duration <= 0) {
88+
return;
89+
}
90+
const clamped = Math.min(Math.max(position, 0), duration);
91+
session.setPositionState({ duration, position: clamped, playbackRate: playbackRate || 1 });
92+
}
93+
94+
/** Tear down the now-playing surface (track ended/stopped). */
95+
clear(): void {
96+
const session = this.session;
97+
if (!session) {
98+
return;
99+
}
100+
this.actions = null;
101+
session.metadata = null;
102+
session.playbackState = 'none';
103+
if (typeof session.setPositionState === 'function') {
104+
session.setPositionState();
105+
}
106+
}
107+
108+
private buildMetadata(track: MediaSessionTrack): MediaMetadata | null {
109+
if (typeof MediaMetadata === 'undefined') {
110+
return null;
111+
}
112+
return new MediaMetadata({
113+
title: track.title,
114+
artist: track.artist ?? '',
115+
album: track.album ?? '',
116+
artwork: track.artwork ?? [],
117+
});
118+
}
119+
120+
private bindHandlers(session: MediaSession): void {
121+
if (this.handlersBound) {
122+
return;
123+
}
124+
this.handlersBound = true;
125+
126+
const set = (action: MediaSessionAction, handler: MediaSessionActionHandler): void => {
127+
try {
128+
session.setActionHandler(action, handler);
129+
} catch {
130+
// The browser doesn't support this action — expected; nothing to do.
131+
}
132+
};
133+
134+
set('play', () => this.actions?.play());
135+
set('pause', () => this.actions?.pause());
136+
set('stop', () => this.actions?.stop());
137+
set('seekto', (details) => {
138+
if (typeof details.seekTime === 'number') {
139+
this.actions?.seekTo(details.seekTime);
140+
}
141+
});
142+
set('seekbackward', (details) => {
143+
this.actions?.seekBackward(details.seekOffset ?? DEFAULT_SEEK_OFFSET_SECONDS);
144+
});
145+
set('seekforward', (details) => {
146+
this.actions?.seekForward(details.seekOffset ?? DEFAULT_SEEK_OFFSET_SECONDS);
147+
});
148+
}
149+
}

0 commit comments

Comments
 (0)