From 08f845080b2e6bd32ad97cd97c14cfc984d6c020 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 19:44:51 +0200 Subject: [PATCH 1/2] feat(player): add media session controls and honor playback defaults --- src/main.activateView.test.ts | 126 +++--- src/main.ts | 61 +-- src/player/PlayerController.test.ts | 118 ++++- src/player/PlayerController.ts | 28 +- src/player/mediaSession.test.ts | 420 ++++++++++++++++++ src/player/mediaSession.ts | 150 +++++++ src/store/persistence.ts | 10 +- .../PodNotesSettingsTab.import.test.ts | 31 +- src/ui/settings/PodNotesSettingsTab.ts | 5 - tests/e2e/podnotes-runtime.test.ts | 224 +++++++++- tests/e2e/quit-save.test.ts | 6 +- tests/e2e/sync-persistence.test.ts | 27 +- 12 files changed, 1022 insertions(+), 184 deletions(-) create mode 100644 src/player/mediaSession.test.ts create mode 100644 src/player/mediaSession.ts 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 20e0c511..14c9e949 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..32274ef2 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -1013,7 +1013,13 @@ describe("deferPlay", () => { }); describe("volume and rate", () => { - test("volume is clamped and saved as the default volume", () => { + const levels = (media: FakeMedia) => ({ + volume: media.volume, + playbackRate: media.playbackRate, + defaultPlaybackRate: media.defaultPlaybackRate, + }); + + test("a live volume is clamped and never saved as the default volume", () => { const { player, host, media, state } = setup({ defaultVolume: 0.5 }); player.setVolume(1.4); @@ -1021,18 +1027,116 @@ describe("volume and rate", () => { expect(media().volume).toBe(1); expect(state().volume).toBe(1); - expect(host.settings.defaultVolume).toBe(1); - expect(host.saveSettings).toHaveBeenCalledTimes(1); + expect(host.settings.defaultVolume).toBe(0.5); + expect(host.saveSettings).not.toHaveBeenCalled(); }); - test("the rate survives a new load", () => { - const { player, media } = setup(); - player.setPlaybackRate(1.7); + test("a new load starts at the defaults as they are now", () => { + 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: 0.4, + playbackRate: 1.8, + defaultPlaybackRate: 1.8, + }); + expect(state()).toMatchObject({ volume: 0.4, playbackRate: 1.8 }); + }); + + test("live levels last for the loaded episode, and the next episode starts at the defaults", () => { + const { player, media, state } = setup({ defaultVolume: 0.4, defaultPlaybackRate: 1.8 }); + player.play(A); + metadata(media()); + 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.4, + playbackRate: 1.8, + defaultPlaybackRate: 1.8, + }); + expect(state()).toMatchObject({ volume: 0.4, playbackRate: 1.8 }); + }); + + test("the first play of a restored episode starts at the defaults", () => { + const { player, host, media, state } = setup(); + player.restore(A); + player.setVolume(0.7); + host.settings.defaultVolume = 0.4; + host.settings.defaultPlaybackRate = 1.8; + + player.resume(); + + expect(levels(media())).toEqual({ + volume: 0.4, + playbackRate: 1.8, + defaultPlaybackRate: 1.8, + }); + expect(state()).toMatchObject({ status: "loading", volume: 0.4, playbackRate: 1.8 }); + }); + + test("the queue's next episode starts at the defaults", () => { + 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.4, + 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 out of range loads like it does at startup", () => { + const { player, host, media } = setup(); + host.settings.defaultVolume = Number.NaN; + host.settings.defaultPlaybackRate = 9; + + player.play(A); - expect(media().playbackRate).toBe(1.7); + expect(levels(media())).toEqual({ volume: 1, playbackRate: 4, defaultPlaybackRate: 4 }); }); }); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index a4dd03ae..8e859cdb 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -65,13 +65,24 @@ 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"; const isTime = (value: number) => Number.isFinite(value) && value >= 0; const isOptionalTime = (value: number | undefined) => value === undefined || isTime(value); const finiteOr0 = (value: number) => (Number.isFinite(value) ? value : 0); +const clampVolume = (volume: number) => Math.min(1, Math.max(0, volume)); + +function defaultLevels({ + defaultPlaybackRate, + defaultVolume, +}: IPodNotesSettings): Pick { + return { + playbackRate: normalizePlaybackRate(defaultPlaybackRate), + volume: Number.isFinite(defaultVolume) ? clampVolume(defaultVolume) : 1, + }; +} /** * Owns the one media element, in the main window, from plugin load to unload. @@ -98,15 +109,13 @@ export class PlayerController { private readonly doc: PlayerDocument, private readonly plugin: PlayerHost, ) { - const { defaultPlaybackRate, defaultVolume } = plugin.settings; this.snapshot = { status: "empty", episode: undefined, mediaType: "audio", currentTime: 0, duration: 0, - playbackRate: normalizePlaybackRate(defaultPlaybackRate), - volume: Number.isFinite(defaultVolume) ? Math.min(1, Math.max(0, defaultVolume)) : 1, + ...defaultLevels(plugin.settings), }; this.store = writable(this.snapshot); this.state = { subscribe: this.store.subscribe }; @@ -224,18 +233,17 @@ 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 }); } - /** The live volume is the saved default volume. */ setVolume(volume: number): void { if (this.disposed || !Number.isFinite(volume)) return; - const clamped = Math.min(1, Math.max(0, volume)); + const clamped = clampVolume(volume); this.el.volume = clamped; this.setState({ volume: clamped }); - writePlayerSettings(this.plugin, { defaultVolume: clamped }); } /** Saves the position now rather than at the next checkpoint. */ @@ -315,12 +323,16 @@ export class PlayerController { preview: endTime !== undefined, lastCheckpoint: Number.NEGATIVE_INFINITY, }; + const levels = defaultLevels(this.plugin.settings); + this.el.playbackRate = this.el.defaultPlaybackRate = levels.playbackRate; + this.el.volume = levels.volume; this.setState({ status: "loading", episode, mediaType: source.mediaType, currentTime: time, duration, + ...levels, }); 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/store/persistence.ts b/src/store/persistence.ts index 3f74c96a..d5c0cbca 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -97,13 +97,9 @@ const IMPORT_ROLLBACK_BINDINGS: PersistenceBinding[] = [ }), ]; -type PlayerSettings = Partial>; +type PlayerSettings = Partial>; const playerSettingsRecorders = new Set<(written: PlayerSettings) => void>(); -/** - * The player writes the episode it selects and its volume into settings itself, - * not through a store. An import in progress records these writes to replay them. - */ export function writePlayerSettings( plugin: Pick, written: PlayerSettings, @@ -124,10 +120,6 @@ export interface PersistedStoreChangeReplay { * snapshots and solves the ABA case where the new value already equals the import * candidate, so the normal persistence binding intentionally performs no settings * write. - * - * The live playback rate is deliberately absent. It is the current episode's - * transient speed, while defaultPlaybackRate is a preference changed directly - * by the settings tab. The tab locks its controls while an import is pending. */ export function observePersistedStoreChanges(): PersistedStoreChangeReplay { const latestValues = new Map(); diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index f68598c5..97f42571 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -31,15 +31,25 @@ function deferred() { } describe("PodNotesSettingsTab settings import", () => { - it("sets the live playback rate when importing a new default", async () => { + it("starts the next episode at imported defaults, leaving the playing one as it is", 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 { @@ -47,10 +57,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: 1 }); + plugin.player.play(episode("B")); + expect(get(plugin.player.state)).toMatchObject({ playbackRate: 2.3, volume: 0.4 }); expect(plugin.saveSettingsStrict).toHaveBeenCalledTimes(2); }); @@ -549,22 +561,21 @@ describe("PodNotesSettingsTab settings import", () => { await vi.waitFor(() => expect(saveData).toHaveBeenCalledTimes(1)); 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); + plugin.player.setVolume(0.4); + plugin.player.setPlaybackRate(2.5); firstWrite.reject(new Error("first import write failed")); await importing; expect(get(hidePlayedEpisodes)).toBe(true); expect(plugin.settings.hidePlayedEpisodes).toBe(true); expect(disk.hidePlayedEpisodes).toBe(true); - expect(plugin.settings.defaultVolume).toBe(0.4); - expect(disk.defaultVolume).toBe(0.4); expect(plugin.settings.episodeListLimit).toBe(20); expect(disk.episodeListLimit).toBe(20); - expect(get(plugin.player.state).playbackRate).toBe(2.5); + expect(get(plugin.player.state)).toMatchObject({ volume: 0.4, playbackRate: 2.5 }); + expect(plugin.settings.defaultVolume).toBe(DEFAULT_SETTINGS.defaultVolume); + expect(disk.defaultVolume).toBe(DEFAULT_SETTINGS.defaultVolume); expect(plugin.settings.defaultPlaybackRate).toBe(DEFAULT_SETTINGS.defaultPlaybackRate); expect(disk.defaultPlaybackRate).toBe(DEFAULT_SETTINGS.defaultPlaybackRate); expect(plugin.settings.currentEpisode).toMatchObject({ title: selected.title }); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 3e0d5f1a..199d3040 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -985,9 +985,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { } concurrentStoreChanges.dispose(); - // Re-hydrate the live stores so the running UI and the persistence - // bindings reflect the import. Keys without a store (templates, paths, - // skip lengths, feed cache) are applied via `merged` above. savedFeeds.set(merged.savedFeeds); playlists.set(merged.playlists); favorites.set(merged.favorites); @@ -996,8 +993,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { playedEpisodes.update((live) => ({ ...progress, ...live })); 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..921202f8 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,7 +371,111 @@ describe("PodNotes runtime", () => { } }); - test("persists API volume changes and clamps out-of-range values", async () => { + 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 new load starts at the default volume and speed, and live changes stay with the episode", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(obsidian, sandbox); + const episode = createLocalEpisode("E2E Default Levels Episode", audioPath); + + await seedRuntimeData(plugin, sandbox, episode, { currentEpisode: episode }); + await waitForPodNotesReady(obsidian); + await openPodNotesView(obsidian); + await evalJsonAsync( + obsidian, + `(async () => { + const podnotes = app.plugins.plugins.${PLUGIN_ID}; + podnotes.settings.defaultVolume = 0.4; + podnotes.settings.defaultPlaybackRate = 1.8; + await podnotes.saveSettings(); + return true; + })()`, + ); + + await invokePodNotesUri(obsidian, episode, 0); + await waitForPlaybackState( + obsidian, + (state) => state.isPlaying && (state.currentTime ?? 0) > 0, + ); + expect(await getLevels(obsidian)).toEqual({ api: [0.4, 1.8], audio: [0.4, 1.8] }); + + await setVolume(obsidian, 0.7); + await obsidian.dev.evalJson( + `(() => { app.plugins.plugins.${PLUGIN_ID}.api.playbackRate = 1.3; return true; })()`, + ); + await setPlayback(obsidian, { currentTime: 10, paused: true }); + await setPlayback(obsidian, { currentTime: 10, paused: false }); + await waitForPlaybackState(obsidian, (state) => state.isPlaying); + expect(await getLevels(obsidian)).toEqual({ api: [0.7, 1.3], audio: [0.7, 1.3] }); + + await evalJsonAsync( + obsidian, + `app.plugins.plugins.${PLUGIN_ID}.saveSettingsStrict().then(() => true)`, + ); + const data = await plugin.data().read(); + expect(data).toMatchObject({ defaultVolume: 0.4, defaultPlaybackRate: 1.8 }); + }); + + test("keeps a live volume change off the default volume", async () => { const { obsidian, plugin } = getContext(); await plugin.updateDataAndReload((data) => { @@ -379,17 +484,15 @@ describe("PodNotes runtime", () => { await waitForPodNotesReady(obsidian); await setVolume(obsidian, 0.42); - await plugin.waitForData((data) => data.defaultVolume === 0.42, WAIT_OPTS); - - await setVolume(obsidian, 1.5); - const data = await plugin.waitForData( - (value) => value.defaultVolume === 1, - WAIT_OPTS, + await evalJsonAsync( + obsidian, + `app.plugins.plugins.${PLUGIN_ID}.saveSettingsStrict().then(() => true)`, ); - const runtimeVolume = await getVolume(obsidian); + expect(await getVolume(obsidian)).toBe(0.42); + expect((await plugin.data().read()).defaultVolume).toBe(1); - expect(data.defaultVolume).toBe(1); - expect(runtimeVolume).toBe(1); + await setVolume(obsidian, 1.5); + expect(await getVolume(obsidian)).toBe(1); }); test("migrates legacy dates and credentials before runtime hydration", async () => { @@ -469,7 +572,7 @@ describe("PodNotes runtime", () => { ); expect(note).toContain("date: 2024-03-01"); - await setVolume(obsidian, 0.42); + await saveDefaultVolume(obsidian, 0.42); const persisted = await plugin.waitForData( (data) => data.schemaVersion === 2 && data.defaultVolume === 0.42, WAIT_OPTS, @@ -744,7 +847,7 @@ describe("PodNotes runtime", () => { obsidian, `(async () => { const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.api.volume = 0.42; + podnotes.settings.defaultVolume = 0.42; await podnotes.saveSettings(); return { feeds: Object.keys(podnotes.settings.savedFeeds), @@ -952,6 +1055,35 @@ async function setVolume( `); } +async function saveDefaultVolume( + obsidian: { dev: { evalJson: (code: string) => Promise } }, + value: number, +): Promise { + await obsidian.dev.evalJson(` + (() => { + const podnotes = app.plugins.plugins.${PLUGIN_ID}; + podnotes.settings.defaultVolume = ${JSON.stringify(value)}; + void podnotes.saveSettings(); + return true; + })() + `); +} + +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 +1147,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 +1187,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 +1207,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 +1224,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 { diff --git a/tests/e2e/quit-save.test.ts b/tests/e2e/quit-save.test.ts index 111c94a3..a087d1e1 100644 --- a/tests/e2e/quit-save.test.ts +++ b/tests/e2e/quit-save.test.ts @@ -25,7 +25,11 @@ test.runIf(isolatedInstance)( // Read by this process while Obsidian is gone. onDisk = JSON.parse(await readFile(dataPath, "utf8")) as PodNotesData; }, - `app.plugins.plugins.${PLUGIN_ID}.api.volume = 0.392`, + `(() => { + const podnotes = app.plugins.plugins.${PLUGIN_ID}; + podnotes.settings.defaultVolume = 0.392; + void podnotes.saveSettings(); + })()`, ); expect(onDisk?.defaultVolume).toBe(0.392); diff --git a/tests/e2e/sync-persistence.test.ts b/tests/e2e/sync-persistence.test.ts index 133c26e0..131499a6 100644 --- a/tests/e2e/sync-persistence.test.ts +++ b/tests/e2e/sync-persistence.test.ts @@ -89,9 +89,14 @@ function addSyncedChanges(data: PodNotesData): void { }; } -function setVolume(obsidian: Parameters[0], volume: number) { +function saveDefaultVolume(obsidian: Parameters[0], volume: number) { return obsidian.dev.evalJson( - `(() => { app.plugins.plugins.${PLUGIN_ID}.api.volume = ${volume}; return true; })()`, + `(() => { + const podnotes = app.plugins.plugins.${PLUGIN_ID}; + podnotes.settings.defaultVolume = ${volume}; + void podnotes.saveSettings(); + return true; + })()`, ); } @@ -144,7 +149,7 @@ describe("PodNotes data.json changed elsewhere", () => { ) .toBe(true); - await setVolume(obsidian, 0.42); + await saveDefaultVolume(obsidian, 0.42); const disk = await plugin.waitForData( (data) => data.defaultVolume === 0.42, WAIT_OPTS, @@ -156,14 +161,17 @@ describe("PodNotes data.json changed elsewhere", () => { test("keeps an edit made here that was not saved yet when another device's change lands", async () => { const { obsidian, plugin } = getContext(); - await setVolume(obsidian, 0.32); + await saveDefaultVolume(obsidian, 0.32); await plugin.waitForData((data) => data.defaultVolume === 0.32, WAIT_OPTS); // A change saves at once, but a playback checkpoint waits up to 10 s after the // last save. This edit stands in for one. await obsidian.dev.evalJson(`(() => { const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.checkpoint(() => { podnotes.api.volume = 0.33; }); + podnotes.checkpoint(() => { + podnotes.settings.defaultVolume = 0.33; + void podnotes.saveSettings(); + }); return true; })()`); await plugin.data().patch(addSyncedChanges); @@ -243,7 +251,7 @@ describe("PodNotes data.json changed elsewhere", () => { return true; })()`); - await setVolume(obsidian, 0.55); + await saveDefaultVolume(obsidian, 0.55); await plugin.waitForData((data) => data.defaultVolume === 0.55, WAIT_OPTS); // Obsidian debounces its file watcher; give it well past that to call in. await obsidian.sleep(2_500); @@ -270,7 +278,8 @@ describe("PodNotes data.json changed elsewhere", () => { podnotes.loadData = () => held.then(read); try { await app.vault.adapter.write(path, JSON.stringify(data)); - podnotes.api.volume = 0.27; + podnotes.settings.defaultVolume = 0.27; + void podnotes.saveSettings(); void podnotes.onExternalSettingsChange?.(); await app.plugins.disablePlugin(${JSON.stringify(PLUGIN_ID)}); release(); @@ -323,7 +332,7 @@ describe("PodNotes data.json changed elsewhere", () => { POLL, ) .toBe(false); - await setVolume(obsidian, 0.61); + await saveDefaultVolume(obsidian, 0.61); const disk = await plugin.waitForData( (data) => data.defaultVolume === 0.61, WAIT_OPTS, @@ -379,7 +388,7 @@ describe("PodNotes data.json changed elsewhere", () => { const library = await plugin.data().read(); addSyncedChanges(library); await startAsNewDevice(plugin); - await setVolume(obsidian, 0.42); + await saveDefaultVolume(obsidian, 0.42); await plugin.waitForData((data) => data.defaultVolume === 0.42, WAIT_OPTS); await plugin.data().write(library); From f0202a157997c23e8869638bf1d54171c5e1fdec Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 21:09:16 +0200 Subject: [PATCH 2/2] fix(player): preserve live speed and saved volume --- src/player/PlayerController.test.ts | 88 ++++++++++++----- src/player/PlayerController.ts | 32 +++---- src/store/persistence.ts | 10 +- .../PodNotesSettingsTab.import.test.ts | 15 +-- .../PodNotesSettingsTab.playback.test.ts | 43 +++++++++ src/ui/settings/PodNotesSettingsTab.ts | 9 +- tests/e2e/podnotes-runtime.test.ts | 96 +++++++++---------- tests/e2e/quit-save.test.ts | 6 +- tests/e2e/sync-persistence.test.ts | 27 ++---- 9 files changed, 199 insertions(+), 127 deletions(-) create mode 100644 src/ui/settings/PodNotesSettingsTab.playback.test.ts diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 32274ef2..1351fbca 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -1019,7 +1019,7 @@ describe("volume and rate", () => { defaultPlaybackRate: media.defaultPlaybackRate, }); - test("a live volume is clamped and never saved as the default volume", () => { + test("volume is clamped and saved as the default volume", () => { const { player, host, media, state } = setup({ defaultVolume: 0.5 }); player.setVolume(1.4); @@ -1027,11 +1027,11 @@ describe("volume and rate", () => { expect(media().volume).toBe(1); expect(state().volume).toBe(1); - expect(host.settings.defaultVolume).toBe(0.5); - expect(host.saveSettings).not.toHaveBeenCalled(); + expect(host.settings.defaultVolume).toBe(1); + expect(host.saveSettings).toHaveBeenCalledTimes(1); }); - test("a new load starts at the defaults as they are now", () => { + 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; @@ -1040,15 +1040,18 @@ describe("volume and rate", () => { metadata(media()); expect(levels(media())).toEqual({ - volume: 0.4, + volume: 1, playbackRate: 1.8, defaultPlaybackRate: 1.8, }); - expect(state()).toMatchObject({ volume: 0.4, playbackRate: 1.8 }); + expect(state()).toMatchObject({ volume: 1, playbackRate: 1.8 }); }); - test("live levels last for the loaded episode, and the next episode starts at the defaults", () => { - const { player, media, state } = setup({ defaultVolume: 0.4, defaultPlaybackRate: 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()); @@ -1066,31 +1069,62 @@ describe("volume and rate", () => { player.play(B); metadata(media()); expect(levels(media())).toEqual({ - volume: 0.4, + volume: 0.7, playbackRate: 1.8, defaultPlaybackRate: 1.8, }); - expect(state()).toMatchObject({ volume: 0.4, playbackRate: 1.8 }); + expect(state()).toMatchObject({ volume: 0.7, playbackRate: 1.8 }); + expect(host.settings).toMatchObject({ defaultVolume: 0.7, defaultPlaybackRate: 1.8 }); }); - test("the first play of a restored episode starts at the defaults", () => { - const { player, host, media, state } = setup(); + 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); - host.settings.defaultVolume = 0.4; + player.setPlaybackRate(1.3); host.settings.defaultPlaybackRate = 1.8; - player.resume(); + reload(player, media); + expect(state().status).toBe("loading"); expect(levels(media())).toEqual({ - volume: 0.4, - playbackRate: 1.8, - defaultPlaybackRate: 1.8, + volume: 0.7, + playbackRate: 1.3, + defaultPlaybackRate: 1.3, }); - expect(state()).toMatchObject({ status: "loading", volume: 0.4, playbackRate: 1.8 }); }); - test("the queue's next episode starts at the defaults", () => { + 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); @@ -1102,7 +1136,7 @@ describe("volume and rate", () => { expect(media().src).toBe(B.streamUrl); expect(levels(media())).toEqual({ - volume: 0.4, + volume: 0.7, playbackRate: 1.8, defaultPlaybackRate: 1.8, }); @@ -1129,9 +1163,8 @@ describe("volume and rate", () => { }); }); - test("a default out of range loads like it does at startup", () => { + test("a default speed out of range loads like it does at startup", () => { const { player, host, media } = setup(); - host.settings.defaultVolume = Number.NaN; host.settings.defaultPlaybackRate = 9; player.play(A); @@ -1468,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 8e859cdb..ee722803 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -72,17 +72,6 @@ export const MEDIA_CLASS = "podnotes-media"; const isTime = (value: number) => Number.isFinite(value) && value >= 0; const isOptionalTime = (value: number | undefined) => value === undefined || isTime(value); const finiteOr0 = (value: number) => (Number.isFinite(value) ? value : 0); -const clampVolume = (volume: number) => Math.min(1, Math.max(0, volume)); - -function defaultLevels({ - defaultPlaybackRate, - defaultVolume, -}: IPodNotesSettings): Pick { - return { - playbackRate: normalizePlaybackRate(defaultPlaybackRate), - volume: Number.isFinite(defaultVolume) ? clampVolume(defaultVolume) : 1, - }; -} /** * Owns the one media element, in the main window, from plugin load to unload. @@ -109,13 +98,15 @@ export class PlayerController { private readonly doc: PlayerDocument, private readonly plugin: PlayerHost, ) { + const { defaultPlaybackRate, defaultVolume } = plugin.settings; this.snapshot = { status: "empty", episode: undefined, mediaType: "audio", currentTime: 0, duration: 0, - ...defaultLevels(plugin.settings), + playbackRate: normalizePlaybackRate(defaultPlaybackRate), + volume: Number.isFinite(defaultVolume) ? Math.min(1, Math.max(0, defaultVolume)) : 1, }; this.store = writable(this.snapshot); this.state = { subscribe: this.store.subscribe }; @@ -239,11 +230,13 @@ export class PlayerController { this.setState({ playbackRate }); } + /** The live volume is the saved default volume. */ setVolume(volume: number): void { if (this.disposed || !Number.isFinite(volume)) return; - const clamped = clampVolume(volume); + const clamped = Math.min(1, Math.max(0, volume)); this.el.volume = clamped; this.setState({ volume: clamped }); + writePlayerSettings(this.plugin, { defaultVolume: clamped }); } /** Saves the position now rather than at the next checkpoint. */ @@ -297,6 +290,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({ @@ -305,6 +304,7 @@ export class PlayerController { mediaType: source.mediaType, currentTime: time, duration, + playbackRate, }); return; } @@ -323,16 +323,14 @@ export class PlayerController { preview: endTime !== undefined, lastCheckpoint: Number.NEGATIVE_INFINITY, }; - const levels = defaultLevels(this.plugin.settings); - this.el.playbackRate = this.el.defaultPlaybackRate = levels.playbackRate; - this.el.volume = levels.volume; + this.el.playbackRate = this.el.defaultPlaybackRate = playbackRate; this.setState({ status: "loading", episode, mediaType: source.mediaType, currentTime: time, duration, - ...levels, + playbackRate, }); this.el.setAttribute("aria-label", episode.title); this.el.src = source.src; diff --git a/src/store/persistence.ts b/src/store/persistence.ts index d5c0cbca..3f74c96a 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -97,9 +97,13 @@ const IMPORT_ROLLBACK_BINDINGS: PersistenceBinding[] = [ }), ]; -type PlayerSettings = Partial>; +type PlayerSettings = Partial>; const playerSettingsRecorders = new Set<(written: PlayerSettings) => void>(); +/** + * The player writes the episode it selects and its volume into settings itself, + * not through a store. An import in progress records these writes to replay them. + */ export function writePlayerSettings( plugin: Pick, written: PlayerSettings, @@ -120,6 +124,10 @@ export interface PersistedStoreChangeReplay { * snapshots and solves the ABA case where the new value already equals the import * candidate, so the normal persistence binding intentionally performs no settings * write. + * + * The live playback rate is deliberately absent. It is the current episode's + * transient speed, while defaultPlaybackRate is a preference changed directly + * by the settings tab. The tab locks its controls while an import is pending. */ export function observePersistedStoreChanges(): PersistedStoreChangeReplay { const latestValues = new Map(); diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index 97f42571..6189eb7c 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -31,7 +31,7 @@ function deferred() { } describe("PodNotesSettingsTab settings import", () => { - it("starts the next episode at imported defaults, leaving the playing one as it is", 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`, @@ -60,7 +60,7 @@ describe("PodNotesSettingsTab settings import", () => { ).applyImportedSettings({ defaultPlaybackRate: 2.3, defaultVolume: 0.4 }); expect(plugin.settings).toMatchObject({ defaultPlaybackRate: 2.3, defaultVolume: 0.4 }); - expect(get(plugin.player.state)).toMatchObject({ playbackRate: 1, volume: 1 }); + 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); @@ -560,10 +560,11 @@ describe("PodNotesSettingsTab settings import", () => { }); await vi.waitFor(() => expect(saveData).toHaveBeenCalledTimes(1)); - hidePlayedEpisodes.set(true); - episodeListLimit.set(20); 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); firstWrite.reject(new Error("first import write failed")); await importing; @@ -571,11 +572,11 @@ describe("PodNotesSettingsTab settings import", () => { expect(get(hidePlayedEpisodes)).toBe(true); expect(plugin.settings.hidePlayedEpisodes).toBe(true); expect(disk.hidePlayedEpisodes).toBe(true); + expect(plugin.settings.defaultVolume).toBe(0.4); + expect(disk.defaultVolume).toBe(0.4); expect(plugin.settings.episodeListLimit).toBe(20); expect(disk.episodeListLimit).toBe(20); - expect(get(plugin.player.state)).toMatchObject({ volume: 0.4, playbackRate: 2.5 }); - expect(plugin.settings.defaultVolume).toBe(DEFAULT_SETTINGS.defaultVolume); - expect(disk.defaultVolume).toBe(DEFAULT_SETTINGS.defaultVolume); + expect(get(plugin.player.state).playbackRate).toBe(2.5); expect(plugin.settings.defaultPlaybackRate).toBe(DEFAULT_SETTINGS.defaultPlaybackRate); expect(disk.defaultPlaybackRate).toBe(DEFAULT_SETTINGS.defaultPlaybackRate); expect(plugin.settings.currentEpisode).toMatchObject({ title: selected.title }); 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 199d3040..97522038 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -381,10 +381,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)), ); } @@ -985,6 +982,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { } concurrentStoreChanges.dispose(); + // Re-hydrate the live stores so the running UI and the persistence + // bindings reflect the import. Keys without a store (templates, paths, + // skip lengths, feed cache) are applied via `merged` above. savedFeeds.set(merged.savedFeeds); playlists.set(merged.playlists); favorites.set(merged.favorites); @@ -993,6 +993,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { playedEpisodes.update((live) => ({ ...progress, ...live })); hidePlayedEpisodes.set(merged.hidePlayedEpisodes); episodeListLimit.set(merged.episodeListLimit); + this.plugin.player.setVolume(merged.defaultVolume); 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 921202f8..dc9ddd95 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -432,50 +432,52 @@ describe("PodNotes runtime", () => { } }); - test("a new load starts at the default volume and speed, and live changes stay with the episode", async () => { + 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 episode = createLocalEpisode("E2E Default Levels Episode", audioPath); + const restored = createLocalEpisode("E2E Restored Levels Episode", audioPath); + const next = createLocalEpisode("E2E Next Levels Episode", audioPath); - await seedRuntimeData(plugin, sandbox, episode, { currentEpisode: episode }); + await seedRuntimeData(plugin, sandbox, next, { + currentEpisode: restored, + defaultPlaybackRate: 1.8, + }); await waitForPodNotesReady(obsidian); await openPodNotesView(obsidian); - await evalJsonAsync( + 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, - `(async () => { - const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.settings.defaultVolume = 0.4; - podnotes.settings.defaultPlaybackRate = 1.8; - await podnotes.saveSettings(); - return true; - })()`, + (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, episode, 0); + await invokePodNotesUri(obsidian, next, 0); await waitForPlaybackState( obsidian, - (state) => state.isPlaying && (state.currentTime ?? 0) > 0, - ); - expect(await getLevels(obsidian)).toEqual({ api: [0.4, 1.8], audio: [0.4, 1.8] }); - - await setVolume(obsidian, 0.7); - await obsidian.dev.evalJson( - `(() => { app.plugins.plugins.${PLUGIN_ID}.api.playbackRate = 1.3; return true; })()`, + (state) => + state.title === next.title && state.isPlaying && (state.currentTime ?? 0) > 0, ); - await setPlayback(obsidian, { currentTime: 10, paused: true }); - await setPlayback(obsidian, { currentTime: 10, paused: false }); - await waitForPlaybackState(obsidian, (state) => state.isPlaying); - expect(await getLevels(obsidian)).toEqual({ api: [0.7, 1.3], audio: [0.7, 1.3] }); + expect(await getLevels(obsidian)).toEqual({ api: [0.5, 1.8], audio: [0.5, 1.8] }); - await evalJsonAsync( - obsidian, - `app.plugins.plugins.${PLUGIN_ID}.saveSettingsStrict().then(() => true)`, - ); - const data = await plugin.data().read(); - expect(data).toMatchObject({ defaultVolume: 0.4, defaultPlaybackRate: 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("keeps a live volume change off the default volume", async () => { + test("persists API volume changes and clamps out-of-range values", async () => { const { obsidian, plugin } = getContext(); await plugin.updateDataAndReload((data) => { @@ -484,15 +486,17 @@ describe("PodNotes runtime", () => { await waitForPodNotesReady(obsidian); await setVolume(obsidian, 0.42); - await evalJsonAsync( - obsidian, - `app.plugins.plugins.${PLUGIN_ID}.saveSettingsStrict().then(() => true)`, - ); - expect(await getVolume(obsidian)).toBe(0.42); - expect((await plugin.data().read()).defaultVolume).toBe(1); + await plugin.waitForData((data) => data.defaultVolume === 0.42, WAIT_OPTS); await setVolume(obsidian, 1.5); - expect(await getVolume(obsidian)).toBe(1); + const data = await plugin.waitForData( + (value) => value.defaultVolume === 1, + WAIT_OPTS, + ); + const runtimeVolume = await getVolume(obsidian); + + expect(data.defaultVolume).toBe(1); + expect(runtimeVolume).toBe(1); }); test("migrates legacy dates and credentials before runtime hydration", async () => { @@ -572,7 +576,7 @@ describe("PodNotes runtime", () => { ); expect(note).toContain("date: 2024-03-01"); - await saveDefaultVolume(obsidian, 0.42); + await setVolume(obsidian, 0.42); const persisted = await plugin.waitForData( (data) => data.schemaVersion === 2 && data.defaultVolume === 0.42, WAIT_OPTS, @@ -847,7 +851,7 @@ describe("PodNotes runtime", () => { obsidian, `(async () => { const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.settings.defaultVolume = 0.42; + podnotes.api.volume = 0.42; await podnotes.saveSettings(); return { feeds: Object.keys(podnotes.settings.savedFeeds), @@ -1055,20 +1059,6 @@ async function setVolume( `); } -async function saveDefaultVolume( - obsidian: { dev: { evalJson: (code: string) => Promise } }, - value: number, -): Promise { - await obsidian.dev.evalJson(` - (() => { - const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.settings.defaultVolume = ${JSON.stringify(value)}; - void podnotes.saveSettings(); - return true; - })() - `); -} - async function getLevels(obsidian: { dev: { evalJson: (code: string) => Promise }; }): Promise<{ api: [number, number]; audio: [number, number] | null }> { diff --git a/tests/e2e/quit-save.test.ts b/tests/e2e/quit-save.test.ts index a087d1e1..111c94a3 100644 --- a/tests/e2e/quit-save.test.ts +++ b/tests/e2e/quit-save.test.ts @@ -25,11 +25,7 @@ test.runIf(isolatedInstance)( // Read by this process while Obsidian is gone. onDisk = JSON.parse(await readFile(dataPath, "utf8")) as PodNotesData; }, - `(() => { - const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.settings.defaultVolume = 0.392; - void podnotes.saveSettings(); - })()`, + `app.plugins.plugins.${PLUGIN_ID}.api.volume = 0.392`, ); expect(onDisk?.defaultVolume).toBe(0.392); diff --git a/tests/e2e/sync-persistence.test.ts b/tests/e2e/sync-persistence.test.ts index 131499a6..133c26e0 100644 --- a/tests/e2e/sync-persistence.test.ts +++ b/tests/e2e/sync-persistence.test.ts @@ -89,14 +89,9 @@ function addSyncedChanges(data: PodNotesData): void { }; } -function saveDefaultVolume(obsidian: Parameters[0], volume: number) { +function setVolume(obsidian: Parameters[0], volume: number) { return obsidian.dev.evalJson( - `(() => { - const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.settings.defaultVolume = ${volume}; - void podnotes.saveSettings(); - return true; - })()`, + `(() => { app.plugins.plugins.${PLUGIN_ID}.api.volume = ${volume}; return true; })()`, ); } @@ -149,7 +144,7 @@ describe("PodNotes data.json changed elsewhere", () => { ) .toBe(true); - await saveDefaultVolume(obsidian, 0.42); + await setVolume(obsidian, 0.42); const disk = await plugin.waitForData( (data) => data.defaultVolume === 0.42, WAIT_OPTS, @@ -161,17 +156,14 @@ describe("PodNotes data.json changed elsewhere", () => { test("keeps an edit made here that was not saved yet when another device's change lands", async () => { const { obsidian, plugin } = getContext(); - await saveDefaultVolume(obsidian, 0.32); + await setVolume(obsidian, 0.32); await plugin.waitForData((data) => data.defaultVolume === 0.32, WAIT_OPTS); // A change saves at once, but a playback checkpoint waits up to 10 s after the // last save. This edit stands in for one. await obsidian.dev.evalJson(`(() => { const podnotes = app.plugins.plugins.${PLUGIN_ID}; - podnotes.checkpoint(() => { - podnotes.settings.defaultVolume = 0.33; - void podnotes.saveSettings(); - }); + podnotes.checkpoint(() => { podnotes.api.volume = 0.33; }); return true; })()`); await plugin.data().patch(addSyncedChanges); @@ -251,7 +243,7 @@ describe("PodNotes data.json changed elsewhere", () => { return true; })()`); - await saveDefaultVolume(obsidian, 0.55); + await setVolume(obsidian, 0.55); await plugin.waitForData((data) => data.defaultVolume === 0.55, WAIT_OPTS); // Obsidian debounces its file watcher; give it well past that to call in. await obsidian.sleep(2_500); @@ -278,8 +270,7 @@ describe("PodNotes data.json changed elsewhere", () => { podnotes.loadData = () => held.then(read); try { await app.vault.adapter.write(path, JSON.stringify(data)); - podnotes.settings.defaultVolume = 0.27; - void podnotes.saveSettings(); + podnotes.api.volume = 0.27; void podnotes.onExternalSettingsChange?.(); await app.plugins.disablePlugin(${JSON.stringify(PLUGIN_ID)}); release(); @@ -332,7 +323,7 @@ describe("PodNotes data.json changed elsewhere", () => { POLL, ) .toBe(false); - await saveDefaultVolume(obsidian, 0.61); + await setVolume(obsidian, 0.61); const disk = await plugin.waitForData( (data) => data.defaultVolume === 0.61, WAIT_OPTS, @@ -388,7 +379,7 @@ describe("PodNotes data.json changed elsewhere", () => { const library = await plugin.data().read(); addSyncedChanges(library); await startAsNewDevice(plugin); - await saveDefaultVolume(obsidian, 0.42); + await setVolume(obsidian, 0.42); await plugin.waitForData((data) => data.defaultVolume === 0.42, WAIT_OPTS); await plugin.data().write(library);