diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 71e82fb7..7e989501 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import PodNotes from "./main"; import { dataFileText } from "../tests/mocks/dataFileWriter"; import { VIEW_TYPE } from "./constants"; -import { Notice, Platform } from "obsidian"; +import { Notice, Platform, type WorkspaceLeaf } from "obsidian"; import type { Episode } from "./types/Episode"; // Regression coverage for #55: "Show PodNotes" / the ribbon icon must reliably @@ -257,7 +257,6 @@ describe("PodNotes onload wiring (#55)", () => { registerDomEvent: vi.fn(), writeRefusal: null, writeInFlight: null, - mediaSessionActions: [], storeUnsubscribers: [], views: new Set(), manifest: { dir: ".obsidian/plugins/podnotes" }, @@ -411,37 +410,6 @@ describe("PodNotes onload wiring (#55)", () => { expect(commandIds.has("reset-playback-rate")).toBe(true); }); - it("registers a previous-track Media Session handler for headphone timestamp capture", async () => { - const originalMediaSession = Object.getOwnPropertyDescriptor(navigator, "mediaSession"); - const calls: Array<{ action: string; hasHandler: boolean }> = []; - - Object.defineProperty(navigator, "mediaSession", { - configurable: true, - value: { - setActionHandler: vi.fn((action: string, handler: (() => void) | null) => { - calls.push({ - action, - hasHandler: typeof handler === "function", - }); - }), - }, - }); - - try { - await loadPlugin(); - expect(calls).toContainEqual({ - action: "previoustrack", - hasHandler: true, - }); - } finally { - if (originalMediaSession) { - Object.defineProperty(navigator, "mediaSession", originalMediaSession); - } else { - Reflect.deleteProperty(navigator, "mediaSession"); - } - } - }); - it("takes its refusal Notice away on unload", async () => { const { plugin } = await loadPlugin({ schemaVersion: 2 }); Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(undefined) }); @@ -456,44 +424,6 @@ describe("PodNotes onload wiring (#55)", () => { expect(hide).toHaveBeenCalledTimes(1); }); - it("clears the previous-track Media Session handler on unload", async () => { - const originalMediaSession = Object.getOwnPropertyDescriptor(navigator, "mediaSession"); - const calls: Array<{ action: string; hasHandler: boolean }> = []; - - Object.defineProperty(navigator, "mediaSession", { - configurable: true, - value: { - setActionHandler: vi.fn((action: string, handler: (() => void) | null) => { - calls.push({ - action, - hasHandler: typeof handler === "function", - }); - }), - }, - }); - - try { - const { plugin } = await loadPlugin(); - plugin.onunload(); - loaded.splice(loaded.indexOf(plugin), 1); - - expect(calls).toContainEqual({ - action: "previoustrack", - hasHandler: true, - }); - expect(calls).toContainEqual({ - action: "previoustrack", - hasHandler: false, - }); - } finally { - if (originalMediaSession) { - Object.defineProperty(navigator, "mediaSession", originalMediaSession); - } else { - Reflect.deleteProperty(navigator, "mediaSession"); - } - } - }); - it("Show PodNotes command and ribbon icon both route to activateView", async () => { const { commands, ribbonCalls, activateSpy } = await loadPlugin(); @@ -579,6 +509,60 @@ describe("PodNotes onload wiring (#55)", () => { }); }); + function recordMediaSession(target: { navigator: Navigator }) { + const calls: Array<{ action: string; hasHandler: boolean }> = []; + const original = Object.getOwnPropertyDescriptor(target.navigator, "mediaSession"); + Object.defineProperty(target.navigator, "mediaSession", { + configurable: true, + value: { + setActionHandler: vi.fn((action: string, handler: (() => void) | null) => { + calls.push({ action, hasHandler: typeof handler === "function" }); + }), + setPositionState: vi.fn(), + }, + }); + restoreMediaSessions.push(() => { + if (original) Object.defineProperty(target.navigator, "mediaSession", original); + else Reflect.deleteProperty(target.navigator, "mediaSession"); + }); + return calls; + } + const restoreMediaSessions: Array<() => void> = []; + afterEach(() => { + for (const restore of restoreMediaSessions.splice(0)) restore(); + vi.unstubAllGlobals(); + }); + + it("sets the headphone capture handler on the main window's Media Session once an episode is shown", async () => { + const calls = recordMediaSession(window); + const popout = { navigator: {} as Navigator }; + const popoutCalls = recordMediaSession(popout); + vi.stubGlobal("activeWindow", popout); + + const { plugin } = await loadPlugin(savedData); + const createView = vi.mocked(plugin.registerView).mock.calls[0][1]; + createView({} as WorkspaceLeaf); + createView({} as WorkspaceLeaf); + + expect(calls.filter(({ action }) => action === "previoustrack")).toEqual([ + { action: "previoustrack", hasHandler: true }, + ]); + expect(popoutCalls).toEqual([]); + }); + + it("clears the Media Session handlers on unload", async () => { + const calls = recordMediaSession(window); + + const { plugin } = await loadPlugin(savedData); + plugin.onunload(); + loaded.splice(loaded.indexOf(plugin), 1); + + const set = calls.filter(({ hasHandler }) => hasHandler).map(({ action }) => action); + const cleared = calls.filter(({ hasHandler }) => !hasHandler).map(({ action }) => action); + expect(set).toContain("previoustrack"); + expect(cleared.sort()).toEqual(set.sort()); + }); + it("does not offer transcription for known video episodes", async () => { const videoEpisode: Episode = { title: "Video Episode", diff --git a/src/main.ts b/src/main.ts index 6962e31a..92736072 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,7 @@ import getContextMenuHandler from "./getContextMenuHandler"; import { TranscriptionService } from "./services/TranscriptionService"; import type { Unsubscriber } from "svelte/store"; import { PlayerController } from "./player/PlayerController"; +import { bindMediaSession } from "./player/mediaSession"; import { decodePodNotesData, encodePodNotesData, @@ -45,17 +46,6 @@ import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds"; import { FeedRepository, loadFeed } from "./services/FeedRepository"; -type MediaSessionActionName = - | "previoustrack" - | "play" - | "pause" - | "stop" - | "nexttrack" - | "seekbackward" - | "seekforward" - | "seekto" - | "skipad"; - type WriteRefusal = | "unreadable-data-file" | "older-data-file" @@ -130,7 +120,6 @@ export default class PodNotes extends Plugin implements IPodNotes { private writer: DataFileWriter | null = null; private refusalNotice: Notice | null = null; private persistenceUnknownFields: Record = {}; - private mediaSessionActions: MediaSessionActionName[] = []; override async onload() { this.isUnloaded = false; @@ -229,7 +218,15 @@ export default class PodNotes extends Plugin implements IPodNotes { ); this.registerEvent(getContextMenuHandler(this.app)); - this.registerMediaSessionHandlers(); + this.storeUnsubscribers.push( + bindMediaSession(this.player, { + mediaSession: window.navigator.mediaSession, + MediaMetadata: window.MediaMetadata, + captureTimestamp: () => void this.captureTimestampFromMediaSession(), + skipBackward: () => this.api.skipBackward(), + skipForward: () => this.api.skipForward(), + }), + ); this.isReady = true; } @@ -409,43 +406,6 @@ export default class PodNotes extends Plugin implements IPodNotes { return await this.appendTimestampToEpisodeNote(); } - private registerMediaSessionHandlers(): void { - this.registerMediaSessionAction("previoustrack", () => { - void this.captureTimestampFromMediaSession(); - }); - } - - private registerMediaSessionAction(action: MediaSessionActionName, handler: () => void): void { - const mediaSession = window.navigator?.mediaSession; - if (!mediaSession?.setActionHandler) { - return; - } - - try { - mediaSession.setActionHandler(action, handler); - this.mediaSessionActions.push(action); - } catch (error) { - console.warn(`PodNotes: Media Session action "${action}" is not supported`, error); - } - } - - private clearMediaSessionHandlers(): void { - const mediaSession = window.navigator?.mediaSession; - if (!mediaSession?.setActionHandler) { - return; - } - - for (const action of this.mediaSessionActions) { - try { - mediaSession.setActionHandler(action, null); - } catch { - // Ignore unsupported cleanup paths; registration already guarded them. - } - } - - this.mediaSessionActions = []; - } - override onunload() { this.isUnloaded = true; // The refusal ends with this instance; the next one reads the file itself. @@ -454,7 +414,6 @@ export default class PodNotes extends Plugin implements IPodNotes { this.player?.dispose(); void this.writer?.unload(); this.clearLayoutReadyRetry(); - this.clearMediaSessionHandlers(); for (const unsubscribe of this.storeUnsubscribers) unsubscribe(); this.storeUnsubscribers = []; this.localFilesMirrorUnsubscribe?.(); diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 6233b8a6..1351fbca 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -1013,6 +1013,12 @@ describe("deferPlay", () => { }); describe("volume and rate", () => { + const levels = (media: FakeMedia) => ({ + volume: media.volume, + playbackRate: media.playbackRate, + defaultPlaybackRate: media.defaultPlaybackRate, + }); + test("volume is clamped and saved as the default volume", () => { const { player, host, media, state } = setup({ defaultVolume: 0.5 }); @@ -1025,14 +1031,145 @@ describe("volume and rate", () => { expect(host.saveSettings).toHaveBeenCalledTimes(1); }); - test("the rate survives a new load", () => { - const { player, media } = setup(); - player.setPlaybackRate(1.7); + test("a new episode starts at the default speed as it is now, at the player's volume", () => { + const { player, host, media, state } = setup(); + host.settings.defaultVolume = 0.4; + host.settings.defaultPlaybackRate = 1.8; + + player.play(A); + metadata(media()); + + expect(levels(media())).toEqual({ + volume: 1, + playbackRate: 1.8, + defaultPlaybackRate: 1.8, + }); + expect(state()).toMatchObject({ volume: 1, playbackRate: 1.8 }); + }); + test("a live speed lasts for its episode, and the volume carries over to the next", () => { + const { player, host, media, state } = setup({ + defaultVolume: 0.4, + defaultPlaybackRate: 1.8, + }); player.play(A); metadata(media()); - expect(media().playbackRate).toBe(1.7); + player.setVolume(0.7); + player.setPlaybackRate(1.3); + player.pause(); + player.resume(); + player.play(A); + expect(levels(media())).toEqual({ + volume: 0.7, + playbackRate: 1.3, + defaultPlaybackRate: 1.3, + }); + + player.play(B); + metadata(media()); + expect(levels(media())).toEqual({ + volume: 0.7, + playbackRate: 1.8, + defaultPlaybackRate: 1.8, + }); + expect(state()).toMatchObject({ volume: 0.7, playbackRate: 1.8 }); + expect(host.settings).toMatchObject({ defaultVolume: 0.7, defaultPlaybackRate: 1.8 }); + }); + + test("a level set before the first play of a restored episode survives the load", () => { + const { player, media, state } = setup({ defaultPlaybackRate: 1.8 }); + player.restore(A); + player.setVolume(0.5); + player.setPlaybackRate(2.3); + + player.resume(); + + expect(levels(media())).toEqual({ + volume: 0.5, + playbackRate: 2.3, + defaultPlaybackRate: 2.3, + }); + expect(state()).toMatchObject({ status: "loading", volume: 0.5, playbackRate: 2.3 }); + }); + + test.each([ + [ + "resume() after an error", + (player: PlayerController, media: () => FakeMedia) => { + media().emit("error"); + player.resume(); + }, + ], + [ + "play() of its new media, such as a finished download,", + (player: PlayerController) => + player.play({ ...A, streamUrl: "https://cdn.example.com/A-v2.mp3" }), + ], + ])("%s reloads the episode at its live levels", (_, reload) => { + const { player, host, media, state } = setup(); + player.play(A); + metadata(media()); + player.setVolume(0.7); + player.setPlaybackRate(1.3); + host.settings.defaultPlaybackRate = 1.8; + + reload(player, media); + + expect(state().status).toBe("loading"); + expect(levels(media())).toEqual({ + volume: 0.7, + playbackRate: 1.3, + defaultPlaybackRate: 1.3, + }); + }); + + test("the queue's next episode starts at the default speed and the live volume", () => { + const { player, media } = setup({ defaultVolume: 0.4, defaultPlaybackRate: 1.8 }); + queue.set({ ...get(queue), episodes: [B] }); + player.play(A); + metadata(media()); + player.setVolume(0.7); + player.setPlaybackRate(1.3); + + media().emit("ended"); + + expect(media().src).toBe(B.streamUrl); + expect(levels(media())).toEqual({ + volume: 0.7, + playbackRate: 1.8, + defaultPlaybackRate: 1.8, + }); + }); + + test("a popout's reload of the video keeps the live levels", () => { + const { player, doc, media } = setup({ defaultVolume: 0.4, defaultPlaybackRate: 1.8 }); + const stage = new FakeParent(doc); + player.play(VIDEO); + metadata(media()); + player.videoStage(stage as unknown as HTMLElement); + player.setVolume(0.7); + player.setPlaybackRate(1.3); + + stage.ownerDocument = {}; + media().load(); + media().emit("emptied"); + metadata(media()); + + expect(levels(media())).toEqual({ + volume: 0.7, + playbackRate: 1.3, + defaultPlaybackRate: 1.3, + }); + }); + + test("a default speed out of range loads like it does at startup", () => { + const { player, host, media } = setup(); + host.settings.defaultPlaybackRate = 9; + + player.play(A); + + expect(levels(media())).toEqual({ volume: 1, playbackRate: 4, defaultPlaybackRate: 4 }); }); }); @@ -1364,6 +1501,17 @@ describe("video stage", () => { expect(state().status).toBe("playing"); }); + test("resume() of a finished video that a reset reloaded keeps its live speed", () => { + const { player, host, media, state } = finishedThenReset(); + player.setPlaybackRate(1.3); + host.settings.defaultPlaybackRate = 1.8; + + player.resume(); + + expect(state().status).toBe("loading"); + expect(media().playbackRate).toBe(1.3); + }); + test("a finished video sought during play restarts at the beginning after a reset", () => { const { player, media, state } = finishedThenReset(30); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index bd51e450..5b0ec177 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -66,7 +66,7 @@ const SAVE_POSITION_THROTTLE_MS = 5000; /** HTMLMediaElement.HAVE_NOTHING: the element holds no media. */ const HAVE_NOTHING = 0; /** A seek at or past the end stops this far short of it, so a seek never finishes an episode. */ -const END_MARGIN_S = 0.25; +export const END_MARGIN_S = 0.25; /** The media element's class, wherever it is. */ export const MEDIA_CLASS = "podnotes-media"; @@ -225,7 +225,8 @@ export class PlayerController { setPlaybackRate(rate: number): void { if (this.disposed) return; const playbackRate = normalizePlaybackRate(rate, this.plugin.settings.defaultPlaybackRate); - // A new load resets the rate to the default rate. + // The browser's own reload of the element, as in a popout's video stage, resets + // the rate to the element's default rate. this.el.playbackRate = this.el.defaultPlaybackRate = playbackRate; this.setState({ playbackRate }); } @@ -290,6 +291,12 @@ export class PlayerController { private load(episode: Episode, source: PlaybackSource, time: number, endTime?: number): void { this.session = null; const duration = this.startDuration(episode); + const shown = this.snapshot.episode; + // Reloading the shown episode, as on its first play after a restart, keeps the user's speed. + const playbackRate = + shown && getEpisodeKey(shown) === getEpisodeKey(episode) + ? this.snapshot.playbackRate + : normalizePlaybackRate(this.plugin.settings.defaultPlaybackRate); if (!source.src) { this.release(); this.setState({ @@ -298,6 +305,7 @@ export class PlayerController { mediaType: source.mediaType, currentTime: time, duration, + playbackRate, }); return; } @@ -316,12 +324,14 @@ export class PlayerController { preview: endTime !== undefined, lastCheckpoint: Number.NEGATIVE_INFINITY, }; + this.el.playbackRate = this.el.defaultPlaybackRate = playbackRate; this.setState({ status: "loading", episode, mediaType: source.mediaType, currentTime: time, duration, + playbackRate, }); this.el.setAttribute("aria-label", episode.title); this.el.src = source.src; diff --git a/src/player/mediaSession.test.ts b/src/player/mediaSession.test.ts new file mode 100644 index 00000000..6231652a --- /dev/null +++ b/src/player/mediaSession.test.ts @@ -0,0 +1,420 @@ +import type { Vault } from "obsidian"; +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { API } from "src/API/API"; +import { DEFAULT_SETTINGS } from "src/constants"; +import { downloadedEpisodes, playedEpisodes, plugin, queue } from "src/store"; +import type { Episode } from "src/types/Episode"; +import { getEpisodeKey } from "src/utility/episodeKey"; +import { FakeDocument, type FakeMedia } from "../../tests/mocks/fakeMedia"; +import { bindMediaSession } from "./mediaSession"; +import { PlayerController } from "./PlayerController"; + +const A: Episode = { + title: "A", + podcastName: "Show", + streamUrl: "https://cdn.example.com/A.mp3", + url: "", + description: "", + content: "", + artworkUrl: "https://cdn.example.com/show.jpg", +}; + +const ACTIONS = [ + "play", + "pause", + "seekbackward", + "seekforward", + "previoustrack", + "nexttrack", + "seekto", +]; + +class FakeMetadata { + static built = 0; + readonly title: string; + readonly artist: string; + readonly album: string; + readonly artwork: Array<{ src: string }>; + + constructor({ title = "", artist = "", album = "", artwork = [] }: MediaMetadataInit = {}) { + for (const { src } of artwork) new URL(src, "app://obsidian.md/"); + FakeMetadata.built++; + this.title = title; + this.artist = artist; + this.album = album; + this.artwork = artwork.map(({ src }) => ({ src })); + } +} + +class FakeMediaSession { + readonly handlers = new Map(); + metadata: FakeMetadata | null = null; + playbackState: MediaSessionPlaybackState = "none"; + position: MediaPositionState | null = null; + + constructor(private readonly unsupported: string[] = []) {} + + setActionHandler(action: string, handler: MediaSessionActionHandler | null): void { + if (this.unsupported.includes(action)) throw new TypeError(`"${action}" is not supported`); + if (handler) this.handlers.set(action, handler); + else this.handlers.delete(action); + } + + setPositionState(state?: MediaPositionState): void { + if (!state) { + this.position = null; + return; + } + const { duration = Number.NaN, position = 0, playbackRate = 1 } = state; + if (!(duration >= 0) || position < 0 || position > duration || playbackRate === 0) { + throw new TypeError("invalid position state"); + } + this.position = state; + } + + act(action: string, details: Partial = {}): void { + const handler = this.handlers.get(action); + if (!handler) throw new Error(`no "${action}" handler`); + handler({ action: action as MediaSessionAction, ...details }); + } +} + +function setup({ + mediaSession = new FakeMediaSession() as FakeMediaSession | null, + Metadata = FakeMetadata as typeof FakeMetadata | null, +} = {}) { + const doc = new FakeDocument(); + const host = { + settings: structuredClone(DEFAULT_SETTINGS), + app: { vault: { getAbstractFileByPath: () => null } as unknown as Vault }, + checkpoint: (update: () => void) => update(), + saveSettings: vi.fn(() => Promise.resolve()), + }; + const player = new PlayerController(doc.asPlayerDocument(), host); + plugin.set({ ...host, player } as never); + const api = new API(); + const capture = vi.fn(); + const unbind = bindMediaSession(player, { + mediaSession: (mediaSession ?? undefined) as unknown as MediaSession | undefined, + MediaMetadata: (Metadata ?? undefined) as unknown as typeof MediaMetadata | undefined, + captureTimestamp: capture, + skipBackward: () => api.skipBackward(), + skipForward: () => api.skipForward(), + }); + unbinds.push(unbind); + return { + session: mediaSession as FakeMediaSession, + host, + player, + capture, + unbind, + media: () => doc.media, + state: () => get(player.state), + }; +} + +function playing(context: ReturnType, time: number, duration = 3600, episode = A) { + context.player.play(episode); + loaded(context.media(), duration); + reach(context.media(), time); +} + +function loaded(media: FakeMedia, duration: number) { + media.duration = duration; + media.emit("loadedmetadata"); +} + +function reach(media: FakeMedia, time: number) { + media.currentTime = time; + media.emit("timeupdate"); +} + +const unbinds: Array<() => void> = []; + +beforeEach(() => { + FakeMetadata.built = 0; + playedEpisodes.set({}); + downloadedEpisodes.set({}); + queue.set({ ...structuredClone(DEFAULT_SETTINGS.queue), episodes: [] }); +}); + +afterEach(() => { + for (const unbind of unbinds.splice(0)) unbind(); + vi.restoreAllMocks(); +}); + +describe("what the lock screen shows", () => { + test("the playing episode, its podcast and artwork, and where it is", () => { + const context = setup(); + + playing(context, 40); + + expect(context.session.metadata).toEqual({ + title: "A", + artist: "Show", + album: "PodNotes", + artwork: [{ src: A.artworkUrl }], + }); + expect(context.session.playbackState).toBe("playing"); + expect(context.session.position).toEqual({ duration: 3600, playbackRate: 1, position: 40 }); + }); + + test("the position follows the speed, and a pause", () => { + const context = setup(); + playing(context, 40); + + context.player.setPlaybackRate(1.5); + context.player.pause(); + + expect(context.session.playbackState).toBe("paused"); + expect(context.session.position).toEqual({ + duration: 3600, + playbackRate: 1.5, + position: 40, + }); + }); + + test("a position past a stale length is clamped to it, and an unknown length shows none", () => { + const context = setup(); + playedEpisodes.set({ + [getEpisodeKey(A)]: { + title: "A", + podcastName: "Show", + time: 500, + duration: 300, + finished: false, + }, + }); + + context.player.play(A, { time: 500 }); + expect(context.session.position).toEqual({ duration: 300, playbackRate: 1, position: 300 }); + + loaded(context.media(), Number.POSITIVE_INFINITY); + reach(context.media(), 20); + expect(context.session.playbackState).toBe("playing"); + expect(context.session.position).toBeNull(); + }); + + test("a renamed episode shows its new title, without rebuilding the metadata as it plays", () => { + const context = setup(); + context.player.restore(A); + context.player.restore({ ...A, title: "A, renamed" }); + expect(context.session.metadata?.title).toBe("A, renamed"); + const built = FakeMetadata.built; + + playing(context, 40, 3600, { ...A, title: "A, renamed" }); + reach(context.media(), 41); + reach(context.media(), 42); + + expect(FakeMetadata.built).toBe(built); + }); + + test("an artwork URL MediaMetadata rejects leaves the title and podcast", () => { + const context = setup(); + + context.player.restore({ ...A, artworkUrl: "https://[broken" }); + + expect(context.session.metadata).toEqual({ + title: "A", + artist: "Show", + album: "PodNotes", + artwork: [], + }); + }); +}); + +describe("actions", () => { + test("play resumes the shown episode and pause pauses it", () => { + const context = setup(); + context.player.restore(A); + + context.session.act("play"); + expect(context.state().status).toBe("loading"); + expect(context.media().src).toBe(A.streamUrl); + + context.session.act("pause"); + expect(context.state().status).toBe("paused"); + }); + + test("seekto moves to the browser's time and ignores a missing or negative one", () => { + const context = setup(); + playing(context, 40); + + context.session.act("seekto", { seekTime: 100 }); + context.session.act("seekto", {}); + context.session.act("seekto", { seekTime: -5 }); + + expect(context.media().currentTime).toBe(100); + }); + + test("seekbackward and seekforward skip by the saved lengths, not the browser's offset", () => { + const context = setup(); + playing(context, 100); + context.host.settings.skipBackwardLength = 10; + context.host.settings.skipForwardLength = 25; + + context.session.act("seekbackward", { seekOffset: 99 }); + expect(context.media().currentTime).toBe(90); + + context.session.act("seekforward", { seekOffset: 99 }); + expect(context.media().currentTime).toBe(115); + }); + + test("nexttrack skips 30 seconds, stopping short of the end and never going back", () => { + const context = setup(); + playing(context, 100, 200); + + context.session.act("nexttrack"); + expect(context.media().currentTime).toBe(130); + + reach(context.media(), 190); + context.session.act("nexttrack"); + expect(context.media().currentTime).toBe(199.75); + expect(context.state().status).toBe("playing"); + + reach(context.media(), 199.9); + context.session.act("nexttrack"); + expect(context.media().currentTime).toBe(199.9); + }); + + test("nexttrack skips the whole 30 seconds while the length is unknown", () => { + const context = setup(); + context.player.play(A, { time: 10 }); + + context.session.act("nexttrack"); + + expect(context.state().currentTime).toBe(40); + }); + + test("previoustrack captures a timestamp, even before the episode plays", () => { + const context = setup(); + context.player.restore(A); + + context.session.act("previoustrack"); + + expect(context.capture).toHaveBeenCalledTimes(1); + }); +}); + +describe("lifetime", () => { + test("with nothing shown, the session is empty", () => { + const context = setup(); + + expect(context.session.handlers.size).toBe(0); + expect(context.session.metadata).toBeNull(); + expect(context.session.playbackState).toBe("none"); + expect(context.session.position).toBeNull(); + }); + + test("a restored episode has its actions and metadata, but no playback or position", () => { + const context = setup(); + playedEpisodes.set({ + [getEpisodeKey(A)]: { + title: "A", + podcastName: "Show", + time: 1234, + duration: 3600, + finished: false, + }, + }); + + context.player.restore(A); + + expect([...context.session.handlers.keys()].sort()).toEqual([...ACTIONS].sort()); + expect(context.session.metadata?.title).toBe("A"); + expect(context.session.playbackState).toBe("none"); + expect(context.session.position).toBeNull(); + }); + + test("an episode that failed to load shows no playback or position, and play retries it", () => { + const context = setup(); + playing(context, 40); + + context.media().emit("error"); + expect(context.session.playbackState).toBe("none"); + expect(context.session.position).toBeNull(); + + context.session.act("play"); + expect(context.state().status).toBe("loading"); + }); + + test("unbinding clears the session, and its old handlers do nothing", () => { + const context = setup(); + playing(context, 40); + context.player.pause(); + const handlers = new Map(context.session.handlers); + const setActionHandler = vi.spyOn(context.session, "setActionHandler"); + + context.unbind(); + context.unbind(); + for (const [action, handler] of handlers) { + handler({ action: action as MediaSessionAction, seekTime: 5 }); + } + context.player.play({ ...A, title: "B", streamUrl: "https://cdn.example.com/B.mp3" }); + + expect(context.session.handlers.size).toBe(0); + expect(setActionHandler).toHaveBeenCalledTimes(ACTIONS.length); + expect(context.session.metadata).toBeNull(); + expect(context.session.playbackState).toBe("none"); + expect(context.session.position).toBeNull(); + expect(context.capture).not.toHaveBeenCalled(); + expect(context.state()).toMatchObject({ status: "loading", currentTime: 0 }); + }); +}); + +describe("partial browser support", () => { + test("an action the browser rejects leaves the others, and is not retried as it plays", () => { + const session = new FakeMediaSession(["nexttrack", "seekto"]); + const setActionHandler = vi.spyOn(session, "setActionHandler"); + const context = setup({ mediaSession: session }); + + playing(context, 40); + reach(context.media(), 41); + + expect([...session.handlers.keys()].sort()).toEqual( + ACTIONS.filter((a) => a !== "nexttrack" && a !== "seekto").sort(), + ); + expect(setActionHandler).toHaveBeenCalledTimes(ACTIONS.length); + }); + + test("without MediaMetadata, the actions and the position still work", () => { + const context = setup({ Metadata: null }); + + playing(context, 40); + + expect(context.session.handlers.size).toBe(ACTIONS.length); + expect(context.session.metadata).toBeNull(); + expect(context.session.position).toEqual({ duration: 3600, playbackRate: 1, position: 40 }); + }); + + test("browser calls that throw never reach the player", () => { + const session = new FakeMediaSession(); + const fail = () => { + throw new TypeError("not supported"); + }; + session.setPositionState = fail; + Object.defineProperty(session, "playbackState", { set: fail }); + Object.defineProperty(session, "metadata", { set: fail }); + const context = setup({ mediaSession: session }); + const seen: string[] = []; + const unsubscribe = context.player.state.subscribe(({ status }) => seen.push(status)); + + playing(context, 40); + unsubscribe(); + + expect(session.handlers.size).toBe(ACTIONS.length); + expect(seen).toContain("playing"); + expect(context.state()).toMatchObject({ status: "playing", currentTime: 40 }); + }); + + test("without a Media Session, playback is unaffected", () => { + const context = setup({ mediaSession: null }); + + playing(context, 40); + context.unbind(); + + expect(context.state()).toMatchObject({ status: "playing", currentTime: 40 }); + }); +}); diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts new file mode 100644 index 00000000..fc41df10 --- /dev/null +++ b/src/player/mediaSession.ts @@ -0,0 +1,150 @@ +import { get, type Unsubscriber } from "svelte/store"; +import type { Episode } from "src/types/Episode"; +import { + END_MARGIN_S, + type PlayerController, + type PlayerState, + type PlayerStatus, +} from "./PlayerController"; + +interface MediaSessionHost { + mediaSession: MediaSession | undefined; + MediaMetadata: typeof MediaMetadata | undefined; + captureTimestamp(): void; + skipBackward(): void; + skipForward(): void; +} + +const NEXT_TRACK_SKIP_S = 30; + +const PLAYBACK_STATES: Record = { + empty: "none", + restored: "none", + loading: "playing", + playing: "playing", + paused: "paused", + ended: "paused", + error: "none", +}; + +/** + * Browsers implement Media Session in part. Each call stands alone, and none may + * throw: a throwing subscriber leaves Svelte's shared queue stuck for every store. + */ +function attempt(call: () => void): boolean { + try { + call(); + return true; + } catch { + return false; + } +} + +const isPositive = (value: number) => Number.isFinite(value) && value > 0; + +function positionOf({ currentTime, duration, playbackRate }: PlayerState) { + if (!isPositive(duration) || !isPositive(playbackRate) || !Number.isFinite(currentTime)) { + return undefined; + } + return { duration, playbackRate, position: Math.min(duration, Math.max(0, currentTime)) }; +} + +export function bindMediaSession( + player: Pick, + host: MediaSessionHost, +): Unsubscriber { + if (!host.mediaSession) return () => {}; + const { mediaSession, MediaMetadata } = host; + + const actions: Array<[MediaSessionAction, MediaSessionActionHandler]> = [ + ["play", () => player.resume()], + ["pause", () => player.pause()], + ["seekbackward", () => host.skipBackward()], + ["seekforward", () => host.skipForward()], + ["previoustrack", () => host.captureTimestamp()], + [ + "nexttrack", + () => { + const { currentTime, duration } = get(player.state); + const target = currentTime + NEXT_TRACK_SKIP_S; + player.seek( + duration > 0 + ? Math.max(currentTime, Math.min(target, duration - END_MARGIN_S)) + : target, + ); + }, + ], + [ + "seekto", + ({ seekTime }) => { + if (seekTime !== undefined) player.seek(seekTime); + }, + ], + ]; + let unbound = false; + let registered: MediaSessionAction[] | null = null; + let shownMetadata: string | undefined; + + function setHandlers(shown: boolean): void { + if (shown && !registered) { + registered = []; + for (const [action, handle] of actions) { + const handler: MediaSessionActionHandler = (details) => { + if (!unbound) handle(details); + }; + if (attempt(() => mediaSession.setActionHandler(action, handler))) { + registered.push(action); + } + } + } else if (!shown && registered) { + for (const action of registered) { + attempt(() => mediaSession.setActionHandler(action, null)); + } + registered = null; + } + } + + function showMetadata(episode: Episode | undefined): void { + const key = JSON.stringify( + episode ? [episode.title, episode.podcastName, episode.artworkUrl] : null, + ); + if (key === shownMetadata) return; + shownMetadata = key; + if (!episode || !MediaMetadata) { + attempt(() => { + mediaSession.metadata = null; + }); + return; + } + const text = { title: episode.title, artist: episode.podcastName, album: "PodNotes" }; + const artwork = episode.artworkUrl ? [{ src: episode.artworkUrl }] : []; + // MediaMetadata rejects an artwork URL it cannot parse. + const shown = attempt(() => { + mediaSession.metadata = new MediaMetadata({ ...text, artwork }); + }); + if (!shown) { + attempt(() => { + mediaSession.metadata = new MediaMetadata(text); + }); + } + } + + function show(state: PlayerState): void { + setHandlers(!!state.episode); + showMetadata(state.episode); + const playbackState = PLAYBACK_STATES[state.status]; + attempt(() => { + mediaSession.playbackState = playbackState; + }); + const position = playbackState === "none" ? undefined : positionOf(state); + attempt(() => mediaSession.setPositionState(position)); + } + + const unsubscribe = player.state.subscribe(show); + return () => { + if (unbound) return; + unbound = true; + unsubscribe(); + show({ ...get(player.state), status: "empty", episode: undefined }); + }; +} diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index dcf4e459..85300cf6 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -32,15 +32,25 @@ function deferred() { } describe("PodNotesSettingsTab settings import", () => { - it("sets the live playback rate when importing a new default", async () => { + it("applies an imported volume at once, and an imported speed from the next episode", async () => { + const episode = (title: string): Episode => ({ + title, + streamUrl: `https://pod.example.com/${title}.mp3`, + url: "", + description: "", + content: "", + podcastName: "Show", + }); const plugin = { settings: structuredClone(DEFAULT_SETTINGS), + app: { vault: {} }, credentials: { storeValues: vi.fn(() => ({})) }, saveSettings: vi.fn().mockResolvedValue(undefined), saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(tab, "update").mockImplementation(() => {}); + plugin.player.play(episode("A")); await ( tab as unknown as { @@ -48,10 +58,12 @@ describe("PodNotesSettingsTab settings import", () => { imported: Partial, ) => Promise; } - ).applyImportedSettings({ defaultPlaybackRate: 2.3 }); + ).applyImportedSettings({ defaultPlaybackRate: 2.3, defaultVolume: 0.4 }); - expect(plugin.settings.defaultPlaybackRate).toBe(2.3); - expect(get(plugin.player.state).playbackRate).toBe(2.3); + expect(plugin.settings).toMatchObject({ defaultPlaybackRate: 2.3, defaultVolume: 0.4 }); + expect(get(plugin.player.state)).toMatchObject({ playbackRate: 1, volume: 0.4 }); + plugin.player.play(episode("B")); + expect(get(plugin.player.state)).toMatchObject({ playbackRate: 2.3, volume: 0.4 }); expect(plugin.saveSettingsStrict).toHaveBeenCalledTimes(2); }); @@ -597,12 +609,12 @@ describe("PodNotesSettingsTab settings import", () => { }); await vi.waitFor(() => expect(saveData).toHaveBeenCalledTimes(1)); + plugin.player.play(selected); hidePlayedEpisodes.set(true); plugin.player.setVolume(0.4); episodeListLimit.set(20); // Current playback speed is runtime state, not the persisted default. plugin.player.setPlaybackRate(2.5); - plugin.player.play(selected); firstWrite.reject(new Error("first import write failed")); await importing; diff --git a/src/ui/settings/PodNotesSettingsTab.playback.test.ts b/src/ui/settings/PodNotesSettingsTab.playback.test.ts new file mode 100644 index 00000000..d4cc3025 --- /dev/null +++ b/src/ui/settings/PodNotesSettingsTab.playback.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { get } from "svelte/store"; +import { expect, test, vi } from "vitest"; +import type { App } from "obsidian"; + +import { DEFAULT_SETTINGS } from "src/constants"; +import type PodNotes from "src/main"; +import { PlayerController } from "src/player/PlayerController"; +import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; +import { FakeDocument } from "../../../tests/mocks/fakeMedia"; + +test("the Default Volume slider sets the playing volume and saves it", () => { + const doc = new FakeDocument(); + const plugin = { + settings: structuredClone(DEFAULT_SETTINGS), + app: { vault: {} }, + saveSettings: vi.fn().mockResolvedValue(undefined), + } as unknown as PodNotes; + plugin.player = new PlayerController(doc.asPlayerDocument(), plugin); + plugin.player.play({ + title: "A", + streamUrl: "https://pod.example.com/A.mp3", + url: "", + description: "", + content: "", + podcastName: "Show", + }); + const container = document.createElement("div"); + ( + new PodNotesSettingsTab({} as App, plugin) as unknown as { + addDefaultVolumeSetting: (container: HTMLElement) => void; + } + ).addDefaultVolumeSetting(container); + const slider = container.querySelector("input") as HTMLInputElement; + + slider.value = "0.25"; + slider.dispatchEvent(new Event("input")); + + expect(doc.media.volume).toBe(0.25); + expect(get(plugin.player.state).volume).toBe(0.25); + expect(plugin.settings.defaultVolume).toBe(0.25); + expect(plugin.saveSettings).toHaveBeenCalled(); +}); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 07708ceb..e43d16f9 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -388,10 +388,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { slider .setLimits(0, 1, 0.05) .setValue(this.plugin.settings.defaultVolume) - .onChange((value) => { - this.plugin.settings.defaultVolume = value; - void this.plugin.saveSettings(); - }), + .onChange((value) => this.plugin.player.setVolume(value)), ); } @@ -1007,7 +1004,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { hidePlayedEpisodes.set(merged.hidePlayedEpisodes); episodeListLimit.set(merged.episodeListLimit); this.plugin.player.setVolume(merged.defaultVolume); - this.plugin.player.setPlaybackRate(merged.defaultPlaybackRate); try { // Store setters can canonicalize or deduplicate their slices. Await one diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index 1d939486..dc9ddd95 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -13,6 +13,7 @@ import { seedAudio, waitForPlaybackState, evalJsonAsync, + getPlaybackState, openPodNotesView, PLUGIN_ID, RELOAD_OPTIONS, @@ -370,6 +371,112 @@ describe("PodNotes runtime", () => { } }); + test("the Media Session shows the episode, and each of its actions drives the player", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(obsidian, sandbox); + const episode = createLocalEpisode("E2E Media Session Episode", audioPath); + + await seedRuntimeData(plugin, sandbox, episode, { currentEpisode: episode }); + await installMediaSessionRecorder(obsidian); + try { + await waitForPodNotesReady(obsidian); + expect(await getMediaSession(obsidian)).toEqual({ + actions: [...MEDIA_SESSION_ACTIONS].sort(), + metadata: { title: episode.title, artist: "local file", album: "PodNotes" }, + playbackState: "none", + position: null, + }); + + await invokeRecordedMediaSessionAction(obsidian, "play"); + await waitForPlaybackState( + obsidian, + (state) => state.isPlaying && (state.currentTime ?? 0) > 0, + ); + const playing = await waitForMediaSession( + obsidian, + (session) => session.position !== null, + ); + expect(playing).toMatchObject({ + playbackState: "playing", + position: { duration: 600, playbackRate: 1 }, + }); + + await invokeRecordedMediaSessionAction(obsidian, "pause"); + await waitForPlaybackState(obsidian, (state) => !state.isPlaying); + await invokeRecordedMediaSessionAction(obsidian, "seekto", { seekTime: 100 }); + expect(await getMediaSession(obsidian)).toMatchObject({ + playbackState: "paused", + position: { duration: 600, position: 100 }, + }); + + await invokeRecordedMediaSessionAction(obsidian, "seekforward", { seekOffset: 99 }); + expect((await getPlaybackState(obsidian)).currentTime).toBe(115); + await invokeRecordedMediaSessionAction(obsidian, "seekbackward", { seekOffset: 99 }); + expect((await getPlaybackState(obsidian)).currentTime).toBe(100); + await invokeRecordedMediaSessionAction(obsidian, "nexttrack"); + expect((await getPlaybackState(obsidian)).currentTime).toBe(130); + await invokeRecordedMediaSessionAction(obsidian, "play"); + await waitForPlaybackState(obsidian, (state) => state.isPlaying); + + await plugin.disable(); + expect(await getMediaSession(obsidian)).toEqual({ + actions: [], + metadata: null, + playbackState: "none", + position: null, + }); + } finally { + await restoreMediaSessionRecorder(obsidian); + await plugin.enable(); + await waitForPodNotesReady(obsidian); + } + }); + + test("a level set before a restored episode plays survives it, and the next episode starts at the default speed", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(obsidian, sandbox); + const restored = createLocalEpisode("E2E Restored Levels Episode", audioPath); + const next = createLocalEpisode("E2E Next Levels Episode", audioPath); + + await seedRuntimeData(plugin, sandbox, next, { + currentEpisode: restored, + defaultPlaybackRate: 1.8, + }); + await waitForPodNotesReady(obsidian); + await openPodNotesView(obsidian); + await setVolume(obsidian, 0.5); + await obsidian.dev.evalJson(`(() => { + const api = app.plugins.plugins.${PLUGIN_ID}.api; + api.playbackRate = 2.3; + api.start(); + return true; + })()`); + await waitForPlaybackState( + obsidian, + (state) => + state.title === restored.title && state.isPlaying && (state.currentTime ?? 0) > 0, + ); + expect(await getLevels(obsidian)).toEqual({ api: [0.5, 2.3], audio: [0.5, 2.3] }); + await plugin.waitForData((data) => data.defaultVolume === 0.5, WAIT_OPTS); + + await invokePodNotesUri(obsidian, next, 0); + await waitForPlaybackState( + obsidian, + (state) => + state.title === next.title && state.isPlaying && (state.currentTime ?? 0) > 0, + ); + expect(await getLevels(obsidian)).toEqual({ api: [0.5, 1.8], audio: [0.5, 1.8] }); + + await plugin.disable(); + await plugin.enable(); + await waitForPodNotesReady(obsidian); + expect(await getVolume(obsidian)).toBe(0.5); + expect(await plugin.data().read()).toMatchObject({ + defaultVolume: 0.5, + defaultPlaybackRate: 1.8, + }); + }); + test("persists API volume changes and clamps out-of-range values", async () => { const { obsidian, plugin } = getContext(); @@ -952,6 +1059,21 @@ async function setVolume( `); } +async function getLevels(obsidian: { + dev: { evalJson: (code: string) => Promise }; +}): Promise<{ api: [number, number]; audio: [number, number] | null }> { + return await obsidian.dev.evalJson(` + (() => { + const api = app.plugins.plugins.${PLUGIN_ID}.api; + const audio = document.querySelector("audio.podnotes-media"); + return { + api: [api.volume, api.playbackRate], + audio: audio ? [audio.volume, audio.playbackRate] : null, + }; + })() + `); +} + /** The rate as the API, the media element, the rate label and the rate slider show it. */ type PlaybackRates = { api: number | null; @@ -1015,16 +1137,24 @@ async function installMediaSessionRecorder( } const original = mediaSession.setActionHandler.bind(mediaSession); + const originalPosition = mediaSession.setPositionState.bind(mediaSession); const handlers = {}; globalThis.__podnotesMediaSessionHandlers = handlers; + globalThis.__podnotesMediaSessionPosition = null; globalThis.__podnotesOriginalSetActionHandler = original; + globalThis.__podnotesOriginalSetPositionState = originalPosition; mediaSession.setActionHandler = (action, handler) => { const result = original(action, handler); handlers[action] = handler; return result; }; + mediaSession.setPositionState = (state) => { + const result = originalPosition(state); + globalThis.__podnotesMediaSessionPosition = state ?? null; + return result; + }; await app.plugins.disablePlugin(${JSON.stringify(PLUGIN_ID)}); await app.plugins.enablePlugin(${JSON.stringify(PLUGIN_ID)}); @@ -1047,11 +1177,17 @@ async function restoreMediaSessionRecorder( ` (() => { const original = globalThis.__podnotesOriginalSetActionHandler; + const originalPosition = globalThis.__podnotesOriginalSetPositionState; if (navigator.mediaSession?.setActionHandler && original) { navigator.mediaSession.setActionHandler = original; } + if (navigator.mediaSession?.setPositionState && originalPosition) { + navigator.mediaSession.setPositionState = originalPosition; + } delete globalThis.__podnotesMediaSessionHandlers; + delete globalThis.__podnotesMediaSessionPosition; delete globalThis.__podnotesOriginalSetActionHandler; + delete globalThis.__podnotesOriginalSetPositionState; return true; })() `, @@ -1061,6 +1197,7 @@ async function restoreMediaSessionRecorder( async function invokeRecordedMediaSessionAction( obsidian: Parameters[0], action: string, + details: { seekOffset?: number; seekTime?: number } = {}, ): Promise<{ action: string; error?: string; ok: boolean }> { return await evalJsonAsync( obsidian, @@ -1077,13 +1214,64 @@ async function invokeRecordedMediaSessionAction( }; } - handler({ action: ${JSON.stringify(action)} }); + handler({ ...${JSON.stringify(details)}, action: ${JSON.stringify(action)} }); return { action: ${JSON.stringify(action)}, ok: true }; })() `, ); } +const MEDIA_SESSION_ACTIONS = [ + "nexttrack", + "pause", + "play", + "previoustrack", + "seekbackward", + "seekforward", + "seekto", +]; + +type MediaSessionView = { + actions: string[]; + metadata: { title: string; artist: string; album: string } | null; + playbackState: string; + position: MediaPositionState | null; +}; + +async function getMediaSession( + obsidian: Parameters[0], +): Promise { + return await evalJsonAsync( + obsidian, + ` + (() => { + const { metadata, playbackState } = navigator.mediaSession; + const handlers = globalThis.__podnotesMediaSessionHandlers ?? {}; + return { + actions: Object.keys(handlers).filter((action) => typeof handlers[action] === "function").sort(), + metadata: metadata && { title: metadata.title, artist: metadata.artist, album: metadata.album }, + playbackState, + position: globalThis.__podnotesMediaSessionPosition ?? null, + }; + })() + `, + ); +} + +async function waitForMediaSession( + obsidian: Parameters[0] & { sleep: (ms: number) => Promise }, + predicate: (session: MediaSessionView) => boolean, +): Promise { + const startedAt = Date.now(); + let last: MediaSessionView | null = null; + while (Date.now() - startedAt < WAIT_OPTS.timeoutMs) { + last = await getMediaSession(obsidian); + if (predicate(last)) return last; + await obsidian.sleep(WAIT_OPTS.intervalMs); + } + throw new Error(`Timed out waiting for the Media Session. Last: ${JSON.stringify(last)}`); +} + async function getVolume(obsidian: { dev: { evalJson: (code: string) => Promise }; }): Promise {