From f4ace6119a90fb6f353c2e15cb5312f92ebeaa9f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 11:03:12 +0200 Subject: [PATCH 01/66] test(e2e): cover playback that keeps going while browsing, with the view closed, and across sync Before the player controller these journeys fail on master: browsing stops audio (#355), switching before the restored player opens zeroes its position (#353), commands and the API cannot play with the view closed, a link to a same-titled episode of another show resumes the loaded one, and a remote data.json change gets the local currentEpisode written back over it. --- tests/e2e/journey-playback.test.ts | 260 ++++++++++++++++++++++++++++- 1 file changed, 255 insertions(+), 5 deletions(-) diff --git a/tests/e2e/journey-playback.test.ts b/tests/e2e/journey-playback.test.ts index 4e99b98c..7cc3bda6 100644 --- a/tests/e2e/journey-playback.test.ts +++ b/tests/e2e/journey-playback.test.ts @@ -1,6 +1,16 @@ import { describe, expect, onTestFinished, test } from "vitest"; -import { createPodNotesE2EHarness, expectKnownFailure } from "./harness"; -import { click, restartObsidian, showPodNotes, ui } from "./journey/actions"; +import { createPodNotesE2EHarness, expectKnownFailure, PLUGIN_ID } from "./harness"; +import { + click, + closePodNotes, + closeSettings, + openInReadingView, + openPodNotesSettings, + restartObsidian, + runCommand, + showPodNotes, + ui, +} from "./journey/actions"; import { type FixtureEpisode, type FixtureFeed, @@ -11,33 +21,61 @@ import { import { episodeRef, observePlayback, + type PlaybackObservation, playbackOver, readLibrary, seedLibrary, storedEpisode, waitForLibrary, waitForPlayback, + writeVaultFile, } from "./journey/library"; const LONG: FixtureEpisode = { title: "Long Listen", seconds: 60, pubDate: pubDate(0) }; const OPENER: FixtureEpisode = { title: "Short Opener", seconds: 6, pubDate: pubDate(1) }; const FOLLOW_UP: FixtureEpisode = { title: "Short Follow-up", seconds: 6, pubDate: pubDate(2) }; +const SECOND: FixtureEpisode = { title: "Second Listen", seconds: 60, pubDate: pubDate(3) }; const FEED: FixtureFeed = { title: "Journey Playback", slug: "playback", artwork: true, - episodes: [LONG, OPENER, FOLLOW_UP], + episodes: [LONG, OPENER, FOLLOW_UP, SECOND], +}; +const OTHER_SHOW: FixtureFeed = { + title: "Journey Playback Other", + slug: "playback-other", + artwork: true, + episodes: [{ title: LONG.title, seconds: 60, pubDate: pubDate(0) }], }; const isolatedInstance = Boolean(process.env.OBSIDIAN_E2E_OBSIDIAN_HOME); const getContext = createPodNotesE2EHarness("journey-playback"); -async function serve(): Promise { - const server = await startFixtureServer([FEED]); +async function serve(...feeds: FixtureFeed[]): Promise { + const server = await startFixtureServer(feeds.length ? feeds : [FEED]); onTestFinished(() => server.stop()); return server; } +async function expectAdvancing(label: string): Promise { + const [first, later] = await playbackOver(getContext().obsidian, 1500); + expect({ label, playing: later.playing, advance: later.seconds - first.seconds }).toMatchObject( + { + label, + playing: true, + }, + ); + expect(later.seconds - first.seconds, `${label}: seconds advanced`).toBeGreaterThan(1); + expect(later.episode?.title, `${label}: same episode`).toBe(first.episode?.title); + return later; +} + +async function evalApi(code: string): Promise { + await getContext().obsidian.dev.evalJson( + `(() => { const api = app.plugins.plugins.${PLUGIN_ID}.api; ${code}; return true; })()`, + ); +} + async function playFromList(episode: FixtureEpisode): Promise { const { obsidian } = getContext(); await showPodNotes(obsidian); @@ -213,4 +251,216 @@ describe("playback journeys", () => { }, 120_000, ); + + test("#355 audio keeps playing while the user browses the grid, an episode list and settings", async () => { + const context = getContext(); + const { obsidian } = context; + await seedLibrary(context, await serve(), { subscriptions: [FEED] }); + await playFromList(LONG); + + await click(obsidian, ui.podcastGridTab); + await expectAdvancing("on the podcast grid"); + await click(obsidian, ui.podcastCard(FEED.title)); + await expectAdvancing("in an episode list"); + await openPodNotesSettings(obsidian); + try { + await expectAdvancing("in settings"); + } finally { + await closeSettings(obsidian); + } + await click(obsidian, ui.playerTab); + const back = await expectAdvancing("back in the player"); + expect(back.media).toMatchObject({ paused: false }); + }); + + test("#353 a restored position survives switching to another episode before the player opens", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await serve(); + const long = storedEpisode(server, FEED, LONG); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: long, + progress: [{ episode: long, seconds: 30, duration: 60, finished: false }], + }); + + await playFromList(SECOND); + expect((await readLibrary(plugin)).progress(episodeRef(FEED, LONG))).toEqual({ + seconds: 30, + duration: 60, + finished: false, + }); + + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard(FEED.title)); + await click(obsidian, ui.episode(LONG.title)); + const resumed = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === LONG.title && playback.playing && playback.seconds > 0, + "the restored episode playing again", + ); + expect(resumed.seconds).toBeGreaterThanOrEqual(29.5); + expect(resumed.seconds).toBeLessThan(34); + }); + + test("J-PLAY-12 J-API-06 J-URI-07 commands, the API and a timestamp link control playback with the view closed", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const server = await serve(); + await seedLibrary(context, server, { subscriptions: [FEED] }); + await playFromList(LONG); + await closePodNotes(obsidian); + await expectAdvancing("after closing the PodNotes view"); + + await runCommand(obsidian, `${PLUGIN_ID}:stop-playing`); + await waitForPlayback(obsidian, (playback) => !playback.playing, "stopped by the command"); + const [stopped, stillStopped] = await playbackOver(obsidian, 1000); + expect(stillStopped.seconds).toBe(stopped.seconds); + await runCommand(obsidian, `${PLUGIN_ID}:start-playing`); + await expectAdvancing("started by the command"); + await runCommand(obsidian, `${PLUGIN_ID}:podnotes-toggle-playback`); + await waitForPlayback(obsidian, (playback) => !playback.playing, "paused by the toggle"); + + await evalApi("api.start()"); + await expectAdvancing("started by api.start()"); + await evalApi("api.currentTime = 40"); + const seeked = await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds >= 40, + "playing on from the API seek", + ); + expect(seeked.seconds).toBeLessThan(44); + await evalApi("api.currentTime = Number.NaN"); + const afterNaN = await observePlayback(obsidian); + expect(Number.isFinite(afterNaN.seconds)).toBe(true); + expect(afterNaN.seconds).toBeGreaterThanOrEqual(seeked.seconds); + await evalApi("api.stop()"); + await waitForPlayback(obsidian, (playback) => !playback.playing, "stopped by api.stop()"); + + const notePath = sandbox.path("Closed view link.md"); + await writeVaultFile( + obsidian, + notePath, + `[00:00:20](obsidian://podnotes?episodeName=${encodeURIComponent(LONG.title)}&url=${encodeURIComponent(server.feedUrl(FEED))}&time=20)\n`, + ); + await openInReadingView(obsidian, notePath); + await click(obsidian, ui.readingLink("00:00:20")); + const linked = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === LONG.title && + playback.playing && + playback.seconds >= 20 && + playback.seconds < 24, + "the link seeks and plays", + ); + expect(linked.episode).toEqual(episodeRef(FEED, LONG)); + }); + + test("a link to another show's same-titled episode plays that show's episode", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const server = await serve(FEED, OTHER_SHOW); + await seedLibrary(context, server, { subscriptions: [FEED, OTHER_SHOW] }); + await playFromList(LONG); + + const notePath = sandbox.path("Other show link.md"); + await writeVaultFile( + obsidian, + notePath, + `[00:00:10](obsidian://podnotes?episodeName=${encodeURIComponent(LONG.title)}&url=${encodeURIComponent(server.feedUrl(OTHER_SHOW))}&time=10)\n`, + ); + await openInReadingView(obsidian, notePath); + await click(obsidian, ui.readingLink("00:00:10")); + const other = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.podcast === OTHER_SHOW.title && + playback.playing && + playback.seconds >= 10, + "the other show's episode playing", + ); + expect(other.episode).toEqual(episodeRef(OTHER_SHOW, OTHER_SHOW.episodes[0])); + expect(other.media?.src).toBe(server.mediaUrl(OTHER_SHOW, OTHER_SHOW.episodes[0])); + expect(other.seconds).toBeLessThan(14); + }); + + test("a data.json change from another device during playback does not seek, reload, or advance the queue", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + queue: [storedEpisode(server, FEED, FOLLOW_UP)], + }); + await playFromList(LONG); + const before = await waitForPlayback( + obsidian, + (playback) => playback.seconds >= 3, + "three seconds played", + ); + + await plugin.data>().patch((data) => { + data.playedEpisodes[`${FEED.title}::${LONG.title}`] = { + title: LONG.title, + podcastName: FEED.title, + time: 50, + duration: 60, + finished: false, + }; + data.currentEpisode = storedEpisode(server, FEED, OPENER); + data.queue.episodes = [ + storedEpisode(server, FEED, FOLLOW_UP), + storedEpisode(server, FEED, OPENER), + ]; + }); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.settings.queue.episodes.length === 2`, + ), + { timeoutMs: 15_000, intervalMs: 200, message: "the remote change merged" }, + ); + + const after = await expectAdvancing("after the remote change"); + expect(after.episode).toEqual(episodeRef(FEED, LONG)); + expect(after.seconds).toBeLessThan(before.seconds + 10); + expect(after.media?.src).toBe(before.media?.src); + await obsidian.dev.evalJsonAsync( + `(async () => { await app.plugins.plugins.${PLUGIN_ID}.flushSaves(); return true; })()`, + ); + const saved = await readLibrary(plugin); + expect(saved.queue).toEqual([FOLLOW_UP.title, OPENER.title]); + expect(saved.currentEpisode).toEqual(episodeRef(FEED, OPENER)); + }); + + test("#355 J-PLAY-09 an episode that ends while the user browses plays the queued one", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + queue: [storedEpisode(server, FEED, FOLLOW_UP)], + }); + await playFromList(OPENER); + await click(obsidian, ui.podcastGridTab); + + await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === FOLLOW_UP.title && + playback.playing && + playback.seconds > 0.5, + "the queued episode playing after the first one ends", + 20_000, + ); + const library = await waitForLibrary( + plugin, + (saved) => saved.progress(episodeRef(FEED, OPENER))?.finished === true, + "the finished episode saved as played", + ); + expect(library.queue).toEqual([]); + expect(library.currentEpisode).toEqual(episodeRef(FEED, FOLLOW_UP)); + }, 60_000); }); From 26da45339bfd88b0d8e9d6478dce8604723b8a93 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 11:21:21 +0200 Subject: [PATCH 02/66] feat(player): add selectSource, the per-load media source rules Plays the snapshot it is given: its own vault file, else a downloaded copy of the same media, else the stream. A missing file is an empty source, never other audio. --- src/player/source.test.ts | 131 ++++++++++++++++++++++++++++++++++++++ src/player/source.ts | 51 +++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/player/source.test.ts create mode 100644 src/player/source.ts diff --git a/src/player/source.test.ts b/src/player/source.test.ts new file mode 100644 index 00000000..b7303f1a --- /dev/null +++ b/src/player/source.test.ts @@ -0,0 +1,131 @@ +import { TFile, type Vault } from "obsidian"; +import { describe, expect, test } from "vitest"; + +import type DownloadedEpisode from "src/types/DownloadedEpisode"; +import type { Episode } from "src/types/Episode"; +import { selectSource } from "./source"; + +const episode: Episode = { + title: "Episode", + streamUrl: "https://cdn.example.com/u.mp3", + url: "https://pod.example.com/episode", + description: "", + content: "", + podcastName: "Show", + feedUrl: "https://pod.example.com/feed.xml", +}; + +function vaultWith(...paths: string[]): Vault { + return { + getAbstractFileByPath: (path: string) => + paths.includes(path) ? Object.assign(new TFile(), { path }) : null, + getResourcePath: (file: TFile) => `app://resource/${file.path}`, + } as unknown as Vault; +} + +function copy(of: Episode, filePath: string, extra: Partial = {}): DownloadedEpisode { + return { ...of, ...extra, filePath, size: 10 }; +} + +describe("selectSource", () => { + test("a queued snapshot plays its downloaded copy of the same media", () => { + const downloaded = { Show: [copy(episode, "Downloads/u.mp3")] }; + + expect(selectSource(episode, downloaded, vaultWith("Downloads/u.mp3"))).toEqual({ + src: "app://resource/Downloads/u.mp3", + mediaType: "audio", + }); + }); + + test("a live snapshot of other media streams instead of playing the old copy", () => { + const downloaded = { Show: [copy(episode, "Downloads/u.mp3")] }; + const live = { ...episode, streamUrl: "https://cdn.example.com/v.mp3" }; + + expect(selectSource(live, downloaded, vaultWith("Downloads/u.mp3"))).toEqual({ + src: "https://cdn.example.com/v.mp3", + mediaType: "audio", + }); + }); + + test("a snapshot with its own file plays that file, not the registered copy", () => { + const downloaded = { Show: [copy(episode, "folder-a/Episode.mp3")] }; + const snapshot = copy(episode, "folder-b/Episode.mp3"); + + const source = selectSource( + snapshot, + downloaded, + vaultWith("folder-a/Episode.mp3", "folder-b/Episode.mp3"), + ); + + expect(source.src).toBe("app://resource/folder-b/Episode.mp3"); + }); + + test("a missing file plays nothing rather than the stream", () => { + const vault = vaultWith(); + + expect(selectSource(copy(episode, "Downloads/u.mp3"), {}, vault).src).toBe(""); + expect(selectSource(episode, { Show: [copy(episode, "Downloads/u.mp3")] }, vault).src).toBe( + "", + ); + }); + + test("a local file snapshot without a path plays its registered file", () => { + const local: Episode = { + ...episode, + podcastName: "local file", + streamUrl: "app://resource/old-snapshot", + }; + const downloaded = { + "local file": [copy(local, "Media/Episode.mp3", { streamUrl: "app://resource/other" })], + }; + + expect(selectSource(local, downloaded, vaultWith("Media/Episode.mp3")).src).toBe( + "app://resource/Media/Episode.mp3", + ); + }); + + test("a video episode does not play a same-titled audio download", () => { + const video: Episode = { + ...episode, + streamUrl: "https://cdn.example.com/u.mp4", + mediaType: "video", + }; + const downloaded = { Show: [copy(video, "Downloads/u.mp3", { mediaType: "audio" })] }; + + expect(selectSource(video, downloaded, vaultWith("Downloads/u.mp3"))).toEqual({ + src: "https://cdn.example.com/u.mp4", + mediaType: "video", + }); + }); + + test.each(["audio", "video"] as const)( + "a legacy mp4 download plays as the %s the episode declares", + (mediaType) => { + const mp4: Episode = { + ...episode, + streamUrl: "https://cdn.example.com/e.mp4", + mediaType, + }; + const downloaded = { + Show: [copy({ ...mp4, mediaType: undefined }, "Downloads/e.mp4")], + }; + + expect(selectSource(mp4, downloaded, vaultWith("Downloads/e.mp4"))).toEqual({ + src: "app://resource/Downloads/e.mp4", + mediaType, + }); + }, + ); + + test("an extensionless episode takes its media type from the matching download", () => { + const extensionless = { ...episode, streamUrl: "https://cdn.example.com/watch?id=42" }; + const downloaded = { + Show: [copy(extensionless, "Downloads/video.mp4", { mediaType: "video" })], + }; + + expect(selectSource(extensionless, downloaded, vaultWith("Downloads/video.mp4"))).toEqual({ + src: "app://resource/Downloads/video.mp4", + mediaType: "video", + }); + }); +}); diff --git a/src/player/source.ts b/src/player/source.ts new file mode 100644 index 00000000..8ef54868 --- /dev/null +++ b/src/player/source.ts @@ -0,0 +1,51 @@ +import type { Vault } from "obsidian"; +import type DownloadedEpisode from "src/types/DownloadedEpisode"; +import type { Episode, EpisodeMediaType } from "src/types/Episode"; +import { createMediaUrlObjectFromFilePath } from "src/utility/createMediaUrlObjectFromFilePath"; +import { isLocalFile } from "src/utility/isLocalFile"; +import { + getEpisodeMediaType, + getEpisodeMediaTypeWithContainerHint, + isSameMediaSource, +} from "src/utility/mediaType"; + +/** What one load plays. `src` is empty when there is nothing to play, such as a missing vault file. */ +export interface MediaSource { + src: string; + mediaType: EpisodeMediaType; +} + +/** + * Plays the snapshot it is given, decided once per load: + * 1. The snapshot's own vault `filePath` (a local file, a download snapshot) plays that file. + * 2. Otherwise the downloaded copy registered under the same podcast and title plays when it + * is a copy of the same media: its original stream URL matches, or it is a local file. A + * media type the snapshot declares must agree. + * 3. Otherwise the stream plays. + * A chosen file that is missing is an empty `src`, never a fall back to other audio. + */ +export function selectSource( + episode: Episode, + downloaded: { [podcastName: string]: DownloadedEpisode[] }, + vault: Vault, +): MediaSource { + const ownPath = (episode as Partial).filePath; + if (ownPath) { + return { + src: createMediaUrlObjectFromFilePath(vault, ownPath), + mediaType: getEpisodeMediaType(episode), + }; + } + + const copy = downloaded[episode.podcastName]?.find((d) => d.title === episode.title); + if (copy?.filePath) { + const mediaType = getEpisodeMediaTypeWithContainerHint(copy, episode.mediaType); + const sameMedia = + isLocalFile(episode) || isSameMediaSource(copy.streamUrl, episode.streamUrl); + if (sameMedia && (!episode.mediaType || mediaType === episode.mediaType)) { + return { src: createMediaUrlObjectFromFilePath(vault, copy.filePath), mediaType }; + } + } + + return { src: episode.streamUrl, mediaType: getEpisodeMediaType(episode) }; +} From 737b4e7ee9d416dadce3d47afcbddc59dbed48aa Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 11:34:37 +0200 Subject: [PATCH 03/66] feat(player): add PlayerController, one media element with a status machine The controller owns the single media element in the main window and exposes one read-only state plus intents. Only an explicit play selects an episode; restore, resume and progress never write settings.currentEpisode. Progress is saved only once the current load's metadata set the start position. --- src/player/PlayerController.test.ts | 793 ++++++++++++++++++++++++++++ src/player/PlayerController.ts | 521 ++++++++++++++++++ src/store/index.ts | 9 + src/store/persistence.ts | 21 + tests/mocks/fakeMedia.ts | 138 +++++ 5 files changed, 1482 insertions(+) create mode 100644 src/player/PlayerController.test.ts create mode 100644 src/player/PlayerController.ts create mode 100644 tests/mocks/fakeMedia.ts diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts new file mode 100644 index 00000000..eb28d1c5 --- /dev/null +++ b/src/player/PlayerController.test.ts @@ -0,0 +1,793 @@ +import type { Vault } from "obsidian"; +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { DEFAULT_SETTINGS } from "src/constants"; +import { downloadedEpisodes, playedEpisodes, playlists, queue } from "src/store"; +import type { Episode } from "src/types/Episode"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import { getEpisodeKey } from "src/utility/episodeKey"; +import { FakeDocument, FakeParent, type FakeMedia } from "../../tests/mocks/fakeMedia"; +import { PlayerController } from "./PlayerController"; + +const episode = (title: string, extra: Partial = {}): Episode => ({ + title, + podcastName: "Show", + streamUrl: `https://cdn.example.com/${title}.mp3`, + url: "", + description: "", + content: "", + ...extra, +}); + +const A = episode("A"); +const B = episode("B"); +const C = episode("C"); +const VIDEO = episode("V", { streamUrl: "https://cdn.example.com/V.mp4", mediaType: "video" }); + +let now = 0; + +function setup(settings: Partial = {}) { + const doc = new FakeDocument(); + let inCheckpoint = false; + const host = { + settings: { ...structuredClone(DEFAULT_SETTINGS), ...settings }, + app: { vault: { getAbstractFileByPath: () => null } as unknown as Vault }, + checkpoint: vi.fn((update: () => void) => { + inCheckpoint = true; + try { + update(); + } finally { + inCheckpoint = false; + } + }), + saveSettings: vi.fn(() => Promise.resolve()), + }; + const player = new PlayerController(doc.asPlayerDocument(), host); + /** Every progress write for `ep`, and whether it went through a checkpoint. */ + const writes = (ep: Episode) => { + const seen: Array<{ time: number; finished: boolean; checkpoint: boolean }> = []; + let initial = true; + playedEpisodes.subscribe((played) => { + const record = played[getEpisodeKey(ep)]; + if (!initial && record) { + seen.push({ + time: record.time, + finished: record.finished, + checkpoint: inCheckpoint, + }); + } + }); + initial = false; + return seen; + }; + return { + doc, + host, + player, + writes, + media: () => doc.media, + state: () => get(player.state), + }; +} + +function saved(ep: Episode, time: number, duration = 3600, finished = false) { + playedEpisodes.update((played) => ({ + ...played, + [getEpisodeKey(ep)]: { + title: ep.title, + podcastName: ep.podcastName, + time, + duration, + finished, + }, + })); +} + +const progress = (ep: Episode) => get(playedEpisodes)[getEpisodeKey(ep)]; +const queued = () => get(queue).episodes.map((e) => e.title); + +function metadata(media: FakeMedia, duration = 3600) { + media.duration = duration; + media.emit("loadedmetadata"); +} + +function reach(media: FakeMedia, time: number, at = now) { + now = at; + media.currentTime = time; + media.emit("timeupdate"); +} + +/** The browser pauses on its own, as at a natural end or from a headset. */ +function pausedByBrowser(media: FakeMedia) { + media.paused = true; + media.emit("pause"); +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +beforeEach(() => { + now = 0; + vi.spyOn(Date, "now").mockImplementation(() => now); + playedEpisodes.set({}); + playlists.set({}); + downloadedEpisodes.set({}); + queue.set({ ...structuredClone(DEFAULT_SETTINGS.queue), episodes: [] }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("restore", () => { + test("shows the saved position paused without loading media or writing settings (#377)", () => { + saved(A, 1234, 3600); + const { player, host, media, state } = setup(); + + player.restore(A); + + expect(state()).toMatchObject({ + status: "restored", + episode: A, + currentTime: 1234, + duration: 3600, + }); + expect(media().src).toBe(""); + expect(media().paused).toBe(true); + expect(host.saveSettings).not.toHaveBeenCalled(); + expect(host.settings.currentEpisode).toBeUndefined(); + }); + + test("shows a finished episode at its start", () => { + saved(A, 3600, 3600, true); + const { player, state } = setup(); + + player.restore(A); + + expect(state().currentTime).toBe(0); + }); + + test("uses the feed's duration when nothing was saved", () => { + const { player, state } = setup(); + + player.restore({ ...A, duration: 1800 }); + + expect(state()).toMatchObject({ currentTime: 0, duration: 1800 }); + }); +}); + +describe("switching episodes (#353)", () => { + test("never overwrites the restored episode's position when another is played first", () => { + saved(A, 1234, 3600); + const { player, host, media, state } = setup(); + player.restore(A); + + player.play(B); + player.play(C); + + expect(progress(A)).toMatchObject({ time: 1234, duration: 3600, finished: false }); + expect(progress(B)).toBeUndefined(); + expect(host.settings.currentEpisode).toBe(C); + expect(queued()).toEqual(["B", "A"]); + expect(state()).toMatchObject({ status: "loading", episode: C }); + expect(media().src).toBe(C.streamUrl); + + player.play(A); + metadata(media()); + expect(media().currentTime).toBe(1234); + }); + + test("does not queue the episode switched away from when the queue is not automatic", () => { + const { player } = setup({ autoQueue: false }); + player.restore(A); + + player.play(B); + + expect(queued()).toEqual([]); + }); + + test("saves the position of the episode it was playing", () => { + const { player, media } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + + player.play(B); + + expect(progress(A)).toMatchObject({ time: 42, finished: false }); + expect(media().paused).toBe(false); + expect(media().src).toBe(B.streamUrl); + }); + + test("does not queue an episode that just ended", () => { + const { player, media } = setup(); + player.play(A); + metadata(media(), 60); + pausedByBrowser(media()); + media().emit("ended"); + + player.play(B); + + expect(queued()).toEqual([]); + }); + + test("reloads the same episode when its media changed, without queueing it", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + + player.play({ ...A, streamUrl: "https://cdn.example.com/A-v2.mp3" }); + + expect(media().src).toBe("https://cdn.example.com/A-v2.mp3"); + expect(state().status).toBe("loading"); + expect(progress(A)?.time).toBe(42); + expect(queued()).toEqual([]); + metadata(media()); + expect(media().currentTime).toBe(42); + }); + + test("stops the episode it was playing when the next one's file is missing", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + + player.play({ ...B, filePath: "Missing/B.mp3" } as Episode); + + expect(media().paused).toBe(true); + expect(media().src).toBe(""); + expect(state()).toMatchObject({ status: "error", episode: { title: "B" } }); + expect(progress(A)?.time).toBe(42); + }); +}); + +describe("stale events", () => { + test("ignores events from an element a video replaced", () => { + const { player, doc, state } = setup(); + player.play(A); + const audio = doc.media; + metadata(audio); + + player.play(VIDEO); + const before = state(); + audio.currentTime = 10; + audio.emit("timeupdate"); + audio.emit("ended"); + audio.emit("error"); + + expect(doc.media).not.toBe(audio); + expect(doc.media.tagName).toBe("VIDEO"); + expect(audio.parent).toBeNull(); + expect(state()).toEqual(before); + expect(progress(A)?.finished).toBe(false); + }); + + test("ignores the rejection of an earlier load's play()", async () => { + const { player, media, state } = setup(); + player.play(A); + + player.play(B); + await flush(); + + expect(state().status).toBe("loading"); + metadata(media()); + expect(state().status).toBe("playing"); + }); +}); + +describe("play() rejected", () => { + test("leaves the player paused and writes no progress", async () => { + saved(A, 100, 3600); + const { player, media, state, writes } = setup(); + const written = writes(A); + player.play(A); + + media().paused = true; + media().plays[0].reject(new DOMException("Blocked", "NotAllowedError")); + await flush(); + expect(state().status).toBe("paused"); + + metadata(media()); + expect(state().status).toBe("paused"); + expect(media().currentTime).toBe(100); + expect(written).toEqual([]); + }); +}); + +describe("pause", () => { + test("while loading cancels playing once the media is ready", () => { + const { player, media, state, writes } = setup(); + const written = writes(A); + player.play(A, { time: 30 }); + + player.pause(); + metadata(media()); + + expect(state().status).toBe("paused"); + expect(media().paused).toBe(true); + expect(media().currentTime).toBe(30); + expect(written).toEqual([]); + }); + + test("saves the position at once", () => { + const { player, media, state, writes } = setup(); + const written = writes(A); + player.play(A); + metadata(media()); + reach(media(), 137, 1000); + + player.pause(); + + expect(state().status).toBe("paused"); + expect(written.at(-1)).toEqual({ time: 137, finished: false, checkpoint: false }); + }); +}); + +describe("natural end", () => { + test("pausing near the end, then ending, marks played and advances the queue once", () => { + queue.set({ ...get(queue), episodes: [B, C] }); + const otherShow = episode("A", { podcastName: "Other show" }); + playlists.set({ + Listen: { + ...structuredClone(DEFAULT_SETTINGS.queue), + name: "Listen", + episodes: [A, otherShow], + }, + }); + const { player, host, media, state } = setup(); + player.play(A); + metadata(media()); + + reach(media(), 3599.9); + pausedByBrowser(media()); + expect(progress(A)).toMatchObject({ time: 3599.9, finished: false }); + expect(queued()).toEqual(["B", "C"]); + + media().emit("ended"); + expect(progress(A)?.finished).toBe(true); + expect(get(playlists).Listen.episodes).toEqual([otherShow]); + expect(queued()).toEqual(["C"]); + expect(host.settings.currentEpisode).toBe(B); + expect(state()).toMatchObject({ status: "loading", episode: B }); + expect(media().src).toBe(B.streamUrl); + }); + + test("advances to the next episode at its saved position", () => { + saved(B, 300, 3600); + queue.set({ ...get(queue), episodes: [B] }); + const { player, media } = setup(); + player.play(A); + metadata(media()); + + pausedByBrowser(media()); + media().emit("ended"); + metadata(media()); + + expect(media().currentTime).toBe(300); + expect(media().paused).toBe(false); + }); + + test("stops at the end when the queue is not automatic", () => { + queue.set({ ...get(queue), episodes: [B] }); + const { player, host, media, state } = setup({ autoQueue: false }); + player.play(A); + metadata(media()); + + pausedByBrowser(media()); + media().emit("ended"); + + expect(state().status).toBe("ended"); + expect(queued()).toEqual(["B"]); + expect(host.settings.currentEpisode).toBe(A); + expect(media().src).toBe(A.streamUrl); + }); + + test("does its bookkeeping once per load", () => { + const { player, media } = setup({ autoQueue: false }); + player.play(A); + metadata(media()); + pausedByBrowser(media()); + media().emit("ended"); + + playedEpisodes.markAsUnplayed(A); + media().emit("ended"); + + expect(progress(A)?.finished).toBe(false); + }); +}); + +describe("seek", () => { + test("before the metadata loads lands where it asked once it does", () => { + const { player, media, state } = setup(); + player.play(A, { time: 120 }); + + expect(player.seek(300)).toBe(true); + expect(state().currentTime).toBe(300); + metadata(media()); + + expect(media().currentTime).toBe(300); + }); + + test("rejects a time that is not a finite number of seconds", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 50); + + for (const time of [Number.NaN, -1, Number.POSITIVE_INFINITY]) { + expect(player.seek(time)).toBe(false); + } + + expect(media().currentTime).toBe(50); + expect(state().currentTime).toBe(50); + }); + + test("past the end stops short of it and pauses, without finishing", () => { + queue.set({ ...get(queue), episodes: [B] }); + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + + player.seek(99999); + + expect(media().currentTime).toBe(3599.75); + expect(media().paused).toBe(true); + expect(state().status).toBe("paused"); + expect(progress(A)?.finished).toBe(false); + expect(queued()).toEqual(["B"]); + }); + + test("a pending start past the end stops short of it and pauses at the metadata", () => { + const { player, media, state } = setup(); + player.play(A, { time: 99999 }); + + metadata(media(), 60); + + expect(media().currentTime).toBe(59.75); + expect(state().status).toBe("paused"); + }); + + test("is kept as the start before anything loads", () => { + const { player, media, state } = setup(); + player.restore(A); + + expect(player.seek(125)).toBe(true); + expect(state().currentTime).toBe(125); + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(125); + }); +}); + +describe("segment links", () => { + test("pause at the segment's end and save no progress until a normal intent", () => { + saved(A, 1000, 3600); + const { player, media, state, writes } = setup(); + const written = writes(A); + player.play(A, { time: 100, endTime: 125 }); + metadata(media()); + expect(media().currentTime).toBe(100); + + reach(media(), 110, 10_000); + reach(media(), 126, 20_000); + expect(media().currentTime).toBe(125); + expect(media().paused).toBe(true); + expect(state().status).toBe("paused"); + + player.save(); + pausedByBrowser(media()); + expect(written).toEqual([]); + + player.seek(200); + reach(media(), 200, 30_000); + expect(written).toEqual([{ time: 200, finished: false, checkpoint: true }]); + }); + + test("ending at the media's end does not finish the episode", () => { + queue.set({ ...get(queue), episodes: [B] }); + const { player, media, state } = setup(); + player.play(A, { time: 3590, endTime: 3600 }); + metadata(media()); + + reach(media(), 3599.9); + pausedByBrowser(media()); + media().emit("ended"); + + expect(state().status).toBe("paused"); + expect(progress(A)).toBeUndefined(); + expect(queued()).toEqual(["B"]); + }); + + test("reject an end at or before the start", () => { + const { player, state } = setup(); + + expect(player.play(A, { time: 30, endTime: 30 })).toBe(false); + expect(player.play(A, { endTime: 30 })).toBe(false); + expect(player.play(A, { time: Number.NaN })).toBe(false); + expect(state().status).toBe("empty"); + }); +}); + +describe("progress", () => { + test("is checkpointed about every 5 s while playing, and never before the metadata", () => { + const { player, media, writes } = setup(); + const written = writes(A); + player.play(A); + reach(media(), 7, 0); + pausedByBrowser(media()); + player.save(); + expect(written).toEqual([]); + + metadata(media()); + media().paused = false; + for (let second = 1; second <= 11; second++) reach(media(), second, second * 1000); + + expect(written).toEqual([ + { time: 1, finished: false, checkpoint: true }, + { time: 6, finished: false, checkpoint: true }, + { time: 11, finished: false, checkpoint: true }, + ]); + }); + + test("save() writes the position at once", () => { + const { player, media, writes } = setup(); + const written = writes(A); + player.play(A); + metadata(media()); + reach(media(), 3, 1000); + + media().currentTime = 4; + player.save(); + + expect(written.at(-1)).toEqual({ time: 4, finished: false, checkpoint: false }); + }); + + test("is not written for a restored episode or after an error", () => { + saved(A, 1234, 3600); + const { player, media, state, writes } = setup(); + const written = writes(A); + player.restore(A); + player.save(); + player.play(A); + metadata(media()); + reach(media(), 1300, 1000); + written.length = 0; + + media().currentTime = 0; + media().emit("error"); + player.save(); + player.play(B); + + expect(state().status).toBe("loading"); + expect(written).toEqual([]); + expect(progress(A)?.time).toBe(1300); + }); +}); + +describe("selection", () => { + test("only play() writes the selected episode; resume() continues without it", () => { + const { player, host, media, state } = setup(); + host.settings.currentEpisode = A; + player.restore(A); + queue.set({ ...get(queue), episodes: [A] }); + host.settings.currentEpisode = C; + + player.resume(); + expect(state()).toMatchObject({ status: "loading", episode: A }); + expect(host.settings.currentEpisode).toBe(C); + expect(queued()).toEqual(["A"]); + + metadata(media()); + player.play(A); + expect(host.settings.currentEpisode).toBe(A); + expect(queued()).toEqual([]); + }); + + test("play() of the episode already playing keeps its position", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + + player.play(A); + + expect(media().src).toBe(A.streamUrl); + expect(media().currentTime).toBe(42); + expect(state().status).toBe("playing"); + }); + + test("play() of an ended episode starts it over", () => { + const { player, media, state } = setup({ autoQueue: false }); + player.play(A); + metadata(media(), 60); + reach(media(), 60); + pausedByBrowser(media()); + media().emit("ended"); + + player.play(A); + media().emit("play"); + + expect(media().currentTime).toBe(0); + expect(media().paused).toBe(false); + expect(state().status).toBe("playing"); + }); + + test("an external change to settings, progress or the queue leaves playback alone", () => { + const { player, host, media, state, writes } = setup(); + player.play(A); + metadata(media()); + reach(media(), 40, 1000); + const written = writes(A); + + host.settings.currentEpisode = C; + saved(A, 999, 3600); + queue.set({ ...get(queue), episodes: [C, A] }); + downloadedEpisodes.set({ Show: [{ ...A, filePath: "Downloads/A.mp3", size: 1 }] }); + + expect(media().src).toBe(A.streamUrl); + expect(media().currentTime).toBe(40); + expect(media().paused).toBe(false); + expect(state()).toMatchObject({ status: "playing", episode: A }); + expect(host.settings.currentEpisode).toBe(C); + expect(queued()).toEqual(["C", "A"]); + + reach(media(), 46, 7000); + expect(written.at(-1)).toEqual({ time: 46, finished: false, checkpoint: true }); + }); +}); + +describe("deferPlay", () => { + test("plays only when nothing newer happened", () => { + const { player, state } = setup(); + const fresh = player.deferPlay(); + expect(fresh(A)).toBe(true); + + const stale = player.deferPlay(); + player.play(B); + expect(stale(C)).toBe(false); + + const paused = player.deferPlay(); + player.pause(); + expect(paused(C)).toBe(false); + + expect(state().episode).toBe(B); + }); + + test("a queue advance makes it stale", () => { + queue.set({ ...get(queue), episodes: [B] }); + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + const later = player.deferPlay(); + + pausedByBrowser(media()); + media().emit("ended"); + + expect(later(C)).toBe(false); + expect(state().episode).toBe(B); + }); +}); + +describe("volume and rate", () => { + test("volume is clamped and saved as the default volume", () => { + const { player, host, media, state } = setup({ defaultVolume: 0.5 }); + + player.setVolume(1.4); + player.setVolume(Number.NaN); + + expect(media().volume).toBe(1); + expect(state().volume).toBe(1); + expect(host.settings.defaultVolume).toBe(1); + expect(host.saveSettings).toHaveBeenCalledTimes(1); + }); + + test("the rate survives a new load", () => { + const { player, media } = setup(); + player.setPlaybackRate(1.7); + + player.play(A); + metadata(media()); + + expect(media().playbackRate).toBe(1.7); + }); +}); + +describe("errors", () => { + test("resume() retries the load at the position it had", () => { + saved(A, 500, 3600); + const { player, media, state } = setup(); + player.play(A); + media().emit("error"); + expect(state()).toMatchObject({ status: "error", currentTime: 500 }); + + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(500); + expect(state().status).toBe("playing"); + }); +}); + +describe("dispose", () => { + test("saves the position, releases the element and ignores everything after", () => { + const { player, doc, media, state, writes } = setup(); + const written = writes(A); + player.play(A); + metadata(media()); + reach(media(), 90, 1000); + written.length = 0; + + player.dispose(); + const after = state(); + + expect(written).toEqual([{ time: 90, finished: false, checkpoint: false }]); + expect(media().paused).toBe(true); + expect(doc.body.children).toEqual([]); + expect(player.play(B)).toBe(false); + expect(player.seek(10)).toBe(false); + player.resume(); + media().emit("ended"); + expect(state()).toEqual(after); + }); +}); + +describe("video stage", () => { + test("shows the video in a stage in the main window and keeps it hidden elsewhere", () => { + const { player, doc } = setup(); + const stage = new FakeParent(doc); + player.play(VIDEO); + const action = player.videoStage(stage as unknown as HTMLElement); + + expect(stage.children).toEqual([doc.media]); + expect(doc.media.hidden).toBe(false); + + stage.migrateTo({}); + expect(doc.body.children).toEqual([doc.media]); + expect(doc.media.hidden).toBe(true); + + stage.migrateTo(doc); + expect(stage.children).toEqual([doc.media]); + + action.destroy(); + expect(doc.body.children).toEqual([doc.media]); + expect(doc.media.hidden).toBe(true); + }); + + test("moves a video that loads later into a stage that is already shown", () => { + const { player, doc } = setup(); + player.restore(VIDEO); + const stage = new FakeParent(doc); + player.videoStage(stage as unknown as HTMLElement); + expect(stage.children).toEqual([]); + + player.resume(); + + expect(stage.children).toEqual([doc.media]); + expect(doc.media.tagName).toBe("VIDEO"); + expect(doc.media.attributes.has("playsinline")).toBe(true); + }); + + test("an older stage's teardown leaves the video in the newer stage", () => { + const { player, doc } = setup(); + player.play(VIDEO); + const older = player.videoStage(new FakeParent(doc) as unknown as HTMLElement); + const newer = new FakeParent(doc); + player.videoStage(newer as unknown as HTMLElement); + + older.destroy(); + + expect(newer.children).toEqual([doc.media]); + }); + + test("keeps audio hidden in the main window", () => { + const { player, doc } = setup(); + player.play(A); + player.videoStage(new FakeParent(doc) as unknown as HTMLElement); + + expect(doc.body.children).toEqual([doc.media]); + expect(doc.media.hidden).toBe(true); + expect(doc.media.className).toBe("podnotes-media"); + }); +}); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts new file mode 100644 index 00000000..8bb0813f --- /dev/null +++ b/src/player/PlayerController.ts @@ -0,0 +1,521 @@ +import type { Vault } from "obsidian"; +import { get, writable, type Readable, type Writable } from "svelte/store"; +import { downloadedEpisodes, playedEpisodes, playlists, queue } from "src/store"; +import { writePlayerSettings } from "src/store/persistence"; +import type { Episode, EpisodeMediaType } from "src/types/Episode"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { getEpisodeKey, isSameStoredEpisode } from "src/utility/episodeKey"; +import { getEpisodeMediaType } from "src/utility/mediaType"; +import { normalizePlaybackRate } from "src/utility/playbackRate"; +import { selectSource, type MediaSource } from "./source"; + +export type PlayerStatus = + | "empty" + /** The selected episode at startup, paused at its saved position. No media is loaded. */ + | "restored" + /** Media is loading and plays once ready. */ + | "loading" + | "playing" + | "paused" + | "ended" + | "error"; + +export interface PlayerState { + status: PlayerStatus; + episode: Episode | undefined; + mediaType: EpisodeMediaType; + /** Seconds. Before the media is ready, where playback will start. */ + currentTime: number; + /** Seconds. Before the media is ready, the saved or the feed's duration. */ + duration: number; + playbackRate: number; + volume: number; +} + +export interface PlayOptions { + time?: number; + /** Plays the segment from `time` to here, then stops, without saving progress. */ + endTime?: number; +} + +export type PlayerHost = { + settings: IPodNotesSettings; + app: { vault: Vault }; + checkpoint(update: () => void): void; + saveSettings(): Promise; +}; + +export type PlayerDocument = Pick; + +/** One load of one episode. A load replaces it. */ +interface Session { + src: string; + /** Where playback starts once the metadata loads. */ + pendingTime: number; + /** The metadata loaded and the start position is set. Only now is progress saved. */ + ready: boolean; + segmentEnd: number | null; + /** A segment link's session saves no progress and does not finish until a normal intent. */ + preview: boolean; + lastCheckpoint: number; +} + +const SAVE_POSITION_THROTTLE_MS = 5000; +/** 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; +/** The media element's class, wherever it is. */ +export const MEDIA_CLASS = "podnotes-media"; + +const isTime = (value: number | undefined) => + value === undefined || (Number.isFinite(value) && value >= 0); +const finiteOr0 = (value: number) => (Number.isFinite(value) ? value : 0); + +/** + * Owns the one media element, in the main window, from plugin load to unload. + * Views render `state` and call the intents. + * + * Only an explicit `play` (and the queue advancing at a natural end) selects an + * episode: it writes settings.currentEpisode and takes the episode out of the + * queue. Restoring, resuming and saving progress never do. + */ +export class PlayerController { + readonly state: Readable; + private readonly store: Writable; + private snapshot: PlayerState; + private el: HTMLMediaElement; + private session: Session | null = null; + private stage: HTMLElement | null = null; + /** Advanced by every intent that makes a deferred play stale. */ + private requests = 0; + private disposed = false; + + constructor( + 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, + }; + this.store = writable(this.snapshot); + this.state = { subscribe: this.store.subscribe }; + this.el = this.createElement("audio"); + this.placeElement(); + } + + /** Shows the selected episode at startup, paused at its saved position, without loading it. */ + restore(episode: Episode | undefined): void { + if (!episode || this.snapshot.status !== "empty") return; + this.setState({ + status: "restored", + episode, + mediaType: getEpisodeMediaType(episode), + currentTime: this.resumeTime(episode), + duration: this.savedProgress(episode)?.duration || episode.duration || 0, + }); + } + + /** + * Selects and plays `episode`. Without a time it resumes where it was left, or + * from the start once finished. Switching away from another episode saves its + * position and queues it first when the queue is automatic. + */ + play(episode: Episode, { time, endTime }: PlayOptions = {}): boolean { + if (this.disposed || !isTime(time) || !isTime(endTime)) return false; + if (endTime !== undefined && (time === undefined || endTime <= time)) return false; + this.requests++; + + const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); + const { status, episode: outgoing, currentTime, mediaType } = this.snapshot; + const sameEpisode = !!outgoing && getEpisodeKey(outgoing) === getEpisodeKey(episode); + this.select(episode); + + const session = this.session; + if ( + session && + sameEpisode && + session.src === source.src && + mediaType === source.mediaType + ) { + session.segmentEnd = endTime ?? null; + session.preview = endTime !== undefined; + if (time === undefined && status === "ended") this.moveTo(0); + if (time === undefined || this.moveTo(time)) this.start(); + return true; + } + + this.persist(); + if ( + outgoing && + !sameEpisode && + status !== "ended" && + this.plugin.settings.autoQueue !== false + ) { + queue.prepend(outgoing); + } + const resumeAt = sameEpisode && status !== "ended" ? currentTime : this.resumeTime(episode); + this.load(episode, source, time ?? resumeAt, endTime); + return true; + } + + /** Continues the current episode without selecting it. */ + resume(): void { + const { status, episode, currentTime } = this.snapshot; + if (this.disposed || !episode) return; + this.endPreview(); + if (status === "restored" || status === "error") { + const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); + this.load(episode, source, currentTime); + } else if (status === "paused" || status === "ended") { + if (status === "ended") this.moveTo(0); + this.start(); + } + } + + pause(): void { + if (this.disposed) return; + this.requests++; + const { status } = this.snapshot; + if (status !== "playing" && status !== "loading") return; + this.el.pause(); + this.settlePaused(); + } + + toggle(): void { + const { status } = this.snapshot; + if (status === "playing" || status === "loading") this.pause(); + else this.resume(); + } + + /** + * False, changing nothing, for a time that is not a finite number of seconds or + * with no episode. A time at or past the end stops just short of it and pauses. + */ + seek(time: number): boolean { + if (this.disposed || !this.snapshot.episode || !isTime(time)) return false; + this.endPreview(); + this.moveTo(time); + return true; + } + + 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. + 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)); + this.el.volume = clamped; + this.setState({ volume: clamped }); + if (this.plugin.settings.defaultVolume !== clamped) { + writePlayerSettings(this.plugin, { defaultVolume: clamped }); + } + } + + /** Saves the position now rather than at the next checkpoint. */ + save(): void { + this.persist(); + } + + /** + * For a play that waits on something first, such as a feed fetch. The returned + * function plays only if no newer play, pause or queue advance happened since. + */ + deferPlay(): (episode: Episode, options?: PlayOptions) => boolean { + const request = ++this.requests; + return (episode, options) => request === this.requests && this.play(episode, options); + } + + /** + * A Svelte action for the view's video stage. The video plays inside the stage + * while the stage is in the main window, and hidden in the main window otherwise. + */ + readonly videoStage = (stage: HTMLElement): { destroy(): void } => { + this.stage = stage; + this.placeElement(); + const stopFollowingWindow = stage.onWindowMigrated?.(() => this.placeElement()); + return { + destroy: () => { + stopFollowingWindow?.(); + if (this.stage !== stage) return; + this.stage = null; + this.placeElement(); + }, + }; + }; + + /** Saves the position, then releases the element. Every intent is a no-op afterwards. */ + dispose(): void { + if (this.disposed) return; + this.persist(); + this.requests++; + this.disposed = true; + this.session = null; + this.el.pause(); + this.el.remove(); + } + + private select(episode: Episode): void { + writePlayerSettings(this.plugin, { currentEpisode: episode }); + queue.remove(episode); + } + + private load(episode: Episode, source: MediaSource, time: number, endTime?: number): void { + this.session = null; + const duration = this.savedProgress(episode)?.duration || episode.duration || 0; + if (!source.src) { + this.el.removeAttribute("src"); + this.el.load(); + this.setState({ + status: "error", + episode, + mediaType: source.mediaType, + currentTime: time, + duration, + }); + return; + } + + if (this.el.tagName.toLowerCase() !== source.mediaType) { + this.el.remove(); + this.el = this.createElement(source.mediaType); + this.placeElement(); + } + this.session = { + src: source.src, + pendingTime: time, + ready: false, + segmentEnd: endTime ?? null, + preview: endTime !== undefined, + lastCheckpoint: Number.NEGATIVE_INFINITY, + }; + this.setState({ + status: "loading", + episode, + mediaType: source.mediaType, + currentTime: time, + duration, + }); + this.el.src = source.src; + // In the intent's own call, so a mobile user's tap still counts as the gesture. + this.playElement(); + } + + private createElement(kind: EpisodeMediaType): HTMLMediaElement { + const el = this.doc.createElement(kind); + el.className = MEDIA_CLASS; + if (kind === "video") el.setAttribute("playsinline", ""); + el.playbackRate = el.defaultPlaybackRate = this.snapshot.playbackRate; + el.volume = this.snapshot.volume; + const handlers: Record void> = { + loadedmetadata: () => this.onLoadedMetadata(), + play: () => this.onPlay(), + pause: () => this.onPause(), + timeupdate: () => this.onTimeUpdate(), + ended: () => this.onEnded(), + error: () => this.onError(), + }; + for (const [type, handle] of Object.entries(handlers)) { + // A replaced element's events are stale. + el.addEventListener(type, () => { + if (el === this.el && !this.disposed) handle(); + }); + } + return el; + } + + /** Removing a media element from its document pauses it; moving it does not. */ + private placeElement(): void { + if (this.disposed) return; + const { stage, el } = this; + const staged = !!stage && stage.ownerDocument === this.doc && el.tagName === "VIDEO"; + if (staged) stage.prepend(el); + else this.doc.body.append(el); + el.hidden = !staged; + } + + private start(): void { + const { status } = this.snapshot; + if (status === "playing" || status === "loading") return; + if (!this.session?.ready) this.setState({ status: "loading" }); + this.playElement(); + } + + private playElement(): void { + this.el.play().catch(() => { + // The autoplay policy, a pause or another load. A play that has since + // started, such as the next load's, leaves the element playing. + const { status } = this.snapshot; + if (this.disposed || !this.el.paused) return; + if (status === "loading" || status === "playing") this.settlePaused(); + }); + } + + /** + * Moves the position, or the pending start before the media is ready. Returns + * false when the time was at or past the end and playback paused short of it. + */ + private moveTo(time: number): boolean { + const session = this.session; + if (!session?.ready) { + if (session) session.pendingTime = time; + this.setState({ currentTime: time }); + return true; + } + + const { duration, status } = this.snapshot; + const pastEnd = duration > 0 && time >= duration; + this.el.currentTime = pastEnd ? Math.max(0, duration - END_MARGIN_S) : time; + this.setState({ + currentTime: this.el.currentTime, + status: status === "ended" ? "paused" : status, + }); + if (pastEnd && status === "playing") { + this.el.pause(); + this.settlePaused(); + } + return !pastEnd; + } + + private endPreview(): void { + if (!this.session) return; + this.session.segmentEnd = null; + this.session.preview = false; + } + + private settlePaused(): void { + this.setState( + this.session?.ready + ? { status: "paused", currentTime: this.el.currentTime } + : { status: "paused" }, + ); + this.persist(); + } + + private onLoadedMetadata(): void { + const session = this.session; + if (!session || session.ready) return; + session.ready = true; + this.setState({ + status: this.el.paused ? "paused" : "playing", + duration: finiteOr0(this.el.duration), + }); + this.moveTo(session.pendingTime); + } + + private onPlay(): void { + if (this.session?.ready && !this.el.paused) this.setState({ status: "playing" }); + } + + private onPause(): void { + if (this.snapshot.status === "playing" && this.el.paused) this.settlePaused(); + } + + private onTimeUpdate(): void { + const session = this.session; + if (!session?.ready) return; + const time = this.el.currentTime; + if (session.segmentEnd !== null && time >= session.segmentEnd) { + this.el.currentTime = session.segmentEnd; + session.segmentEnd = null; + this.el.pause(); + this.settlePaused(); + return; + } + + this.setState({ currentTime: time, duration: finiteOr0(this.el.duration) }); + const now = Date.now(); + if (now - session.lastCheckpoint < SAVE_POSITION_THROTTLE_MS) return; + session.lastCheckpoint = now; + this.plugin.checkpoint(() => this.persist()); + } + + /** A natural end: marks the episode played, takes it out of every list, then plays the queue's next. */ + private onEnded(): void { + const session = this.session; + const { status, episode } = this.snapshot; + if (!session?.ready || !episode || status === "ended") return; + if (session.preview) { + session.segmentEnd = null; + this.settlePaused(); + return; + } + + this.setState({ status: "ended", currentTime: this.el.currentTime }); + playedEpisodes.markAsPlayed(episode); + playlists.update((lists) => { + for (const playlist of Object.values(lists)) { + playlist.episodes = playlist.episodes.filter( + (e) => !isSameStoredEpisode(e, episode), + ); + } + return lists; + }); + queue.remove(episode); + + const next = get(queue).episodes[0]; + if (!next || this.plugin.settings.autoQueue === false) return; + this.requests++; + this.select(next); + this.load( + next, + selectSource(next, get(downloadedEpisodes), this.plugin.app.vault), + this.resumeTime(next), + ); + } + + /** Saved progress stays as it was. */ + private onError(): void { + if (!this.session) return; + this.session = null; + this.setState({ status: "error" }); + } + + private persist(): void { + const { session } = this; + const { episode, duration } = this.snapshot; + if (!session?.ready || session.preview || this.disposed || !episode) return; + const time = this.el.currentTime; + playedEpisodes.setEpisodeTime(episode, time, duration, duration > 0 && time >= duration); + } + + /** The composite key's record, else a legacy title-only record that does not name another podcast. */ + private savedProgress(episode: Episode): PlayedEpisode | undefined { + const played = get(playedEpisodes); + const legacy = played[episode.title]; + return ( + played[getEpisodeKey(episode)] ?? + (legacy && + (!legacy.podcastName || + !episode.podcastName || + legacy.podcastName === episode.podcastName) + ? legacy + : undefined) + ); + } + + private resumeTime(episode: Episode): number { + const saved = this.savedProgress(episode); + if (!saved || saved.finished || (saved.duration > 0 && saved.time >= saved.duration)) { + return 0; + } + return saved.time; + } + + private setState(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + this.store.set(this.snapshot); + } +} diff --git a/src/store/index.ts b/src/store/index.ts index fa02f567..254a824e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -280,6 +280,15 @@ export const queue = (() => { return queue; }); }, + /** Puts the episode first, unless it is already queued. */ + prepend: (episode: Episode) => { + update((queue) => { + if (isAlreadyQueued(queue.episodes, episode)) return queue; + + queue.episodes = [episode, ...queue.episodes]; + return queue; + }); + }, playNext: () => { // Auto-advance is part of queue automation (issue #108): when the user // has turned the queue off, finishing an episode must not pull the next diff --git a/src/store/persistence.ts b/src/store/persistence.ts index b9dbd094..c5e4b3c9 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -107,6 +107,22 @@ const IMPORT_ROLLBACK_BINDINGS: PersistenceBinding[] = [ }), ]; +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, +): void { + Object.assign(plugin.settings, written); + for (const record of playerSettingsRecorders) record(structuredClone(written)); + void plugin.saveSettings(); +} + export interface PersistedStoreChangeReplay { replayInto: (settings: IPodNotesSettings) => void; dispose: () => void; @@ -135,6 +151,9 @@ export function observePersistedStoreChanges(): PersistedStoreChangeReplay { latestValues.set(binding, structuredClone(value)); }); }); + const playerWrites: PlayerSettings = {}; + const recordPlayerWrite = (written: PlayerSettings) => Object.assign(playerWrites, written); + playerSettingsRecorders.add(recordPlayerWrite); let disposed = false; return { @@ -142,11 +161,13 @@ export function observePersistedStoreChanges(): PersistedStoreChangeReplay { for (const [binding, value] of latestValues) { binding.apply(settings, structuredClone(value)); } + Object.assign(settings, structuredClone(playerWrites)); }, dispose() { if (disposed) return; disposed = true; for (const unsubscribe of unsubscribers) unsubscribe(); + playerSettingsRecorders.delete(recordPlayerWrite); latestValues.clear(); }, }; diff --git a/tests/mocks/fakeMedia.ts b/tests/mocks/fakeMedia.ts new file mode 100644 index 00000000..df5ab2bb --- /dev/null +++ b/tests/mocks/fakeMedia.ts @@ -0,0 +1,138 @@ +import type { PlayerDocument } from "src/player/PlayerController"; + +/** An element's parent: a document body or a video stage. */ +export class FakeParent { + readonly children: FakeMedia[] = []; + private migrated: Array<() => void> = []; + + constructor(public ownerDocument: unknown) {} + + append(media: FakeMedia): void { + media.remove(); + this.children.push(media); + media.parent = this; + } + + prepend(media: FakeMedia): void { + media.remove(); + this.children.unshift(media); + media.parent = this; + } + + /** Obsidian's DOM extension: runs `listener` when the element moves to another window. */ + onWindowMigrated(listener: () => void): () => void { + this.migrated.push(listener); + return () => { + this.migrated = this.migrated.filter((l) => l !== listener); + }; + } + + /** Moves this stage to another window's document. */ + migrateTo(ownerDocument: unknown): void { + this.ownerDocument = ownerDocument; + for (const listener of this.migrated) listener(); + } +} + +/** + * A media element without a browser. Tests set what the browser would (duration, + * position) and dispatch its events. Setting `src` starts over like the HTML load + * algorithm: position 0, paused, the default rate, and pending plays rejected. + */ +export class FakeMedia extends EventTarget { + readonly tagName: string; + className = ""; + hidden = false; + parent: FakeParent | null = null; + currentTime = 0; + duration = Number.NaN; + paused = true; + defaultPlaybackRate = 1; + playbackRate = 1; + volume = 1; + readonly attributes = new Map(); + /** Unsettled play() calls, oldest first. */ + readonly plays: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; + private source = ""; + + constructor(kind: string) { + super(); + this.tagName = kind.toUpperCase(); + } + + get src(): string { + return this.source; + } + + set src(value: string) { + this.source = value; + this.reset(); + } + + play(): Promise { + this.paused = false; + return new Promise((resolve, reject) => this.plays.push({ resolve, reject })); + } + + pause(): void { + if (this.paused) return; + this.paused = true; + this.rejectPlays(); + } + + load(): void { + this.reset(); + } + + removeAttribute(name: string): void { + if (name === "src") this.source = ""; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + remove(): void { + const siblings = this.parent?.children; + siblings?.splice(siblings.indexOf(this), 1); + this.parent = null; + } + + emit(type: string): void { + this.dispatchEvent(new Event(type)); + } + + private reset(): void { + this.currentTime = 0; + this.duration = Number.NaN; + this.paused = true; + this.playbackRate = this.defaultPlaybackRate; + this.rejectPlays(); + } + + private rejectPlays(): void { + for (const play of this.plays.splice(0)) { + play.reject(new DOMException("The play() request was interrupted.", "AbortError")); + } + } +} + +export class FakeDocument { + readonly body = new FakeParent(this); + readonly created: FakeMedia[] = []; + + createElement(kind: string): FakeMedia { + const media = new FakeMedia(kind); + this.created.push(media); + return media; + } + + /** The element created last, the one the player drives. */ + get media(): FakeMedia { + return this.created[this.created.length - 1]; + } + + asPlayerDocument(): PlayerDocument { + return this as unknown as PlayerDocument; + } +} From 444a80fa5580773f8ad47d654f5f4b9a89b19950 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 11:50:54 +0200 Subject: [PATCH 04/66] fix(ui): apply slider limits before its value A range input snaps its value to the step from its minimum. Setting the value under the default step of 1 and then min 0.5 turned a playback rate of 1 into 1.5 on the rate slider. --- src/ui/obsidian/Slider.svelte | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ui/obsidian/Slider.svelte b/src/ui/obsidian/Slider.svelte index 2946fc04..c0e01ce2 100644 --- a/src/ui/obsidian/Slider.svelte +++ b/src/ui/obsidian/Slider.svelte @@ -48,6 +48,15 @@ currentLimits: [min: number, max: number] | [min: number, max: number, step: number], currentStyles: CSSObject, ) { + // Limits first: a range input snaps its value to the step from its minimum, + // so the default step of 1 would turn a rate of 1 into 1.5 once min is 0.5. + if (currentLimits) { + if (currentLimits.length === 2) { + sldr.setLimits(currentLimits[0], currentLimits[1], 1); + } else { + sldr.setLimits(currentLimits[0], currentLimits[1], currentLimits[2]); + } + } const sliderValue = typeof sldr.getValue === "function" ? sldr.getValue() : Number(sldr.sliderEl?.value); @@ -56,13 +65,6 @@ sldr.setValue(currentValue); isProgrammaticUpdate = false; } - if (currentLimits) { - if (currentLimits.length === 2) { - sldr.setLimits(currentLimits[0], currentLimits[1], 1); - } else { - sldr.setLimits(currentLimits[0], currentLimits[1], currentLimits[2]); - } - } if (currentStyles) { sldr.sliderEl.setAttr("style", extractStylesFromObj(currentStyles)); } From 44957490a68910ca5d80a709b20e5ee8d18ade92 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 11:50:54 +0200 Subject: [PATCH 05/66] fix(player): play through PlayerController and delete the playback stores Every caller now plays, pauses, seeks and reads playback through the controller: the API, commands, URIs, menus, Play with PodNotes, the view and settings import. The player view only renders the controller's state, so audio keeps playing on other tabs, with the view closed and after a popout closes. Restoring at startup no longer autoplays or loads media, and playing another episode first no longer overwrites the restored one's position. The queue's reorder moves now name the episode instead of an index. A link to the loaded episode must name its feed or file as well as its title, and a link whose feed resolves after a newer play does not play. --- src/API/API.test.ts | 257 +++--- src/API/API.ts | 69 +- src/TemplateEngine.test.ts | 4 +- src/URIHandler.test.ts | 812 ++++++----------- src/URIHandler.ts | 147 +-- src/getContextMenuHandler.test.ts | 37 +- src/getContextMenuHandler.ts | 14 +- src/main.activateView.test.ts | 68 ++ src/main.ts | 72 +- src/player/PlayerController.ts | 4 +- src/store/index.test.ts | 187 +--- src/store/index.ts | 143 +-- src/store/persistence.ts | 21 +- src/types/PlaybackSegment.ts | 5 - src/ui/PodcastView/EpisodePlayer.svelte | 573 ++---------- src/ui/PodcastView/EpisodePlayer.test.ts | 835 +++--------------- .../PodcastView.integration.test.ts | 34 +- src/ui/PodcastView/PodcastView.svelte | 13 +- src/ui/PodcastView/queueReorderMenu.test.ts | 5 +- src/ui/PodcastView/queueReorderMenu.ts | 10 +- src/ui/PodcastView/spawnEpisodeContextMenu.ts | 28 +- src/ui/ReorderQueue.svelte | 48 +- .../PodNotesSettingsTab.import.test.ts | 69 +- src/ui/settings/PodNotesSettingsTab.ts | 10 +- 24 files changed, 917 insertions(+), 2548 deletions(-) delete mode 100644 src/types/PlaybackSegment.ts diff --git a/src/API/API.test.ts b/src/API/API.test.ts index 17c34ee3..9b337454 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -1,20 +1,15 @@ -import { TFile } from "obsidian"; +import { TFile, type Vault } from "obsidian"; import { get } from "svelte/store"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { API } from "./API"; -import { - activePlaybackSegment, - currentEpisode, - currentTime, - downloadedEpisodes, - duration, - playbackRate, - plugin, - volume, -} from "src/store"; +import { DEFAULT_SETTINGS } from "src/constants"; +import { PlayerController } from "src/player/PlayerController"; +import { downloadedEpisodes, playedEpisodes, plugin, queue } from "src/store"; import type { Episode } from "src/types/Episode"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import type { LocalEpisode } from "src/types/LocalEpisode"; +import { FakeDocument } from "../../tests/mocks/fakeMedia"; const feedEpisode: Episode = { title: "Feed Episode", @@ -36,30 +31,45 @@ const localEpisode: LocalEpisode = { filePath: "Audio/Local Episode.mp3", }; +function setup(settings: Partial = {}, vault: Partial = {}) { + const doc = new FakeDocument(); + const host = { + settings: { ...structuredClone(DEFAULT_SETTINGS), defaultPlaybackRate: 1.8, ...settings }, + app: { vault: vault as Vault }, + checkpoint: (update: () => void) => update(), + saveSettings: vi.fn(() => Promise.resolve()), + }; + const player = new PlayerController(doc.asPlayerDocument(), host); + plugin.set({ ...host, player } as never); + return { api: new API(), player, media: () => doc.media }; +} + +/** Plays the feed episode from `time`, with its metadata loaded. */ +function playingAt(time: number, duration = 3600, settings: Partial = {}) { + const context = setup(settings); + context.player.play(feedEpisode, { time }); + context.media().duration = duration; + context.media().emit("loadedmetadata"); + return context; +} + beforeEach(() => { - currentEpisode.update(() => undefined as unknown as Episode); - currentTime.set(0); - activePlaybackSegment.set(null); downloadedEpisodes.set({}); - playbackRate.set(1); - plugin.set({ - settings: { - defaultPlaybackRate: 1.8, - }, - } as never); + playedEpisodes.set({}); + queue.set({ ...structuredClone(DEFAULT_SETTINGS.queue), episodes: [] }); }); describe("API.getPodcastSegmentFormatted", () => { test("formats a plain segment range", () => { - currentEpisode.set(feedEpisode); - const api = new API(); + const { api, player } = setup(); + player.restore(feedEpisode); expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125)).toBe("00:01:55-00:02:05"); }); test("links feed episodes with start and end times", () => { - currentEpisode.set(feedEpisode); - const api = new API(); + const { api, player } = setup(); + player.restore(feedEpisode); const rendered = api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125, true); @@ -69,18 +79,14 @@ describe("API.getPodcastSegmentFormatted", () => { expect(rendered).toContain("url=https%3A%2F%2Fpod.example.com%2Ffeed.xml"); }); - test("links downloaded local episodes by file path", () => { - currentEpisode.set(localEpisode); + test("links a local episode by the file it plays, not a later same-named one", () => { + const { api, player } = setup(); + player.restore(localEpisode); downloadedEpisodes.set({ [localEpisode.podcastName]: [ - { - ...localEpisode, - filePath: "Audio/Local Episode.mp3", - size: 1, - }, + { ...localEpisode, filePath: "Other/Local Episode.mp3", size: 1 }, ], }); - const api = new API(); const rendered = api.getPodcastSegmentFormatted("HH:mm:ss", 1, 2, true); @@ -89,9 +95,23 @@ describe("API.getPodcastSegmentFormatted", () => { expect(rendered).toContain("endTime=2"); }); + test("links a local episode without a path by its registered file", () => { + const { api, player } = setup(); + player.restore({ ...localEpisode, filePath: undefined } as Episode); + downloadedEpisodes.set({ + [localEpisode.podcastName]: [ + { ...localEpisode, filePath: "Audio/Local Episode.mp3", size: 1 }, + ], + }); + + expect(api.getPodcastTimeFormatted("HH:mm:ss", true)).toContain( + "url=Audio%2FLocal%20Episode.mp3", + ); + }); + test("does not link invalid segment ranges", () => { - currentEpisode.set(feedEpisode); - const api = new API(); + const { api, player } = setup(); + player.restore(feedEpisode); expect(api.getPodcastSegmentFormatted("HH:mm:ss", 125, 125, true)).toBe( "00:02:05-00:02:05", @@ -104,36 +124,37 @@ describe("API.getPodcastSegmentFormatted", () => { ); }); - test("seeking through the public API clears an active playback segment", () => { - currentEpisode.set(feedEpisode); - activePlaybackSegment.set({ - episodeKey: `${feedEpisode.podcastName}::${feedEpisode.title}`, - startTime: 115, - endTime: 125, - }); - const api = new API(); + test("seeking through the public API ends a segment link's stop", () => { + const { api, player, media } = setup(); + player.play(feedEpisode, { time: 115, endTime: 125 }); + media().duration = 3600; + media().emit("loadedmetadata"); api.currentTime = 500; + media().currentTime = 600; + media().emit("timeupdate"); - expect(api.currentTime).toBe(500); - expect(get(activePlaybackSegment)).toBeNull(); + expect(api.currentTime).toBe(600); + expect(media().paused).toBe(false); + expect(get(playedEpisodes)["Feed Podcast::Feed Episode"]?.time).toBe(600); }); }); describe("API playback rate controls", () => { test("increases and decreases the runtime playback rate in tenth steps", () => { - const api = new API(); + const { api, media } = setup({ defaultPlaybackRate: 1 }); api.increasePlaybackRate(); - expect(get(playbackRate)).toBe(1.1); + expect(api.playbackRate).toBe(1.1); api.decreasePlaybackRate(); api.decreasePlaybackRate(); - expect(get(playbackRate)).toBe(0.9); + expect(api.playbackRate).toBe(0.9); + expect(media().playbackRate).toBe(0.9); }); test("clamps playback rate changes to the player-supported range", () => { - const api = new API(); + const { api } = setup(); api.playbackRate = 3.95; api.increasePlaybackRate(); @@ -145,7 +166,7 @@ describe("API playback rate controls", () => { }); test("resets playback rate to the configured default", () => { - const api = new API(); + const { api } = setup(); api.playbackRate = 2.5; api.resetPlaybackRate(); @@ -155,46 +176,31 @@ describe("API playback rate controls", () => { }); describe("API skip controls (PB-02)", () => { - beforeEach(() => { - duration.set(0); - plugin.set({ - settings: { - defaultPlaybackRate: 1, - skipBackwardLength: 15, - skipForwardLength: 30, - }, - } as never); - }); + const lengths = { skipBackwardLength: 15, skipForwardLength: 30 }; test("skips backward and forward by the configured lengths", () => { - currentTime.set(100); - const api = new API(); + const { api, media } = playingAt(100, 3600, lengths); api.skipBackward(); expect(api.currentTime).toBe(85); api.skipForward(); expect(api.currentTime).toBe(115); + expect(media().currentTime).toBe(115); }); test("never seeks before 0 when skipping backward past the start", () => { - currentTime.set(5); - const api = new API(); + const { api } = playingAt(5, 3600, lengths); api.skipBackward(); expect(api.currentTime).toBe(0); }); test("falls back to a 15s default when a skip length is NaN/invalid", () => { - currentTime.set(100); - plugin.set({ - settings: { - defaultPlaybackRate: 1, - skipBackwardLength: Number.NaN, - skipForwardLength: undefined, - }, - } as never); - const api = new API(); + const { api } = playingAt(100, 3600, { + skipBackwardLength: Number.NaN, + skipForwardLength: undefined as unknown as number, + }); api.skipBackward(); expect(api.currentTime).toBe(85); @@ -204,19 +210,16 @@ describe("API skip controls (PB-02)", () => { }); test("clamps a forward skip just short of the end so it doesn't auto-advance", () => { - currentTime.set(100); - duration.set(110); - const api = new API(); + const { api, media } = playingAt(100, 110, lengths); api.skipForward(); // 100 + 30 = 130, clamped to duration - 0.25. expect(api.currentTime).toBe(109.75); + expect(media().paused).toBe(false); }); test("does not rewind on a forward skip when already within the last 0.25s (#213)", () => { - currentTime.set(9.9); - duration.set(10); - const api = new API(); + const { api } = playingAt(9.9, 10, lengths); api.skipForward(); // The end-clamp (dur - 0.25 = 9.75) must never move a forward skip backward. @@ -224,30 +227,78 @@ describe("API skip controls (PB-02)", () => { }); test("leaves the forward target unclamped when duration is unknown", () => { - currentTime.set(100); - duration.set(0); - const api = new API(); + const { api, player } = setup(lengths); + player.restore(feedEpisode); + api.currentTime = 100; api.skipForward(); expect(api.currentTime).toBe(130); }); }); +describe("API playback", () => { + test("start resumes the restored episode, stop pauses it, and toggle flips it", () => { + playedEpisodes.set({ + "Feed Podcast::Feed Episode": { + title: feedEpisode.title, + podcastName: feedEpisode.podcastName, + time: 42, + duration: 3600, + finished: false, + }, + }); + const { api, player, media } = setup(); + player.restore(feedEpisode); + expect(api.isPlaying).toBe(false); + expect(api.length).toBe(3600); + + api.start(); + expect(api.isPlaying).toBe(true); + expect(media().src).toBe(feedEpisode.streamUrl); + + api.stop(); + expect(api.isPlaying).toBe(false); + expect(media().paused).toBe(true); + + api.togglePlayback(); + expect(api.isPlaying).toBe(true); + expect(media().paused).toBe(false); + }); + + test("a seek before anything loads reads back at once", () => { + const { api, player } = setup(); + player.restore(feedEpisode); + + api.currentTime = 125; + + expect(api.currentTime).toBe(125); + }); + + test("ignores a seek to a time that is not a number of seconds", () => { + const { api } = playingAt(100); + + api.currentTime = Number.NaN; + api.currentTime = -5; + + expect(api.currentTime).toBe(100); + }); +}); + describe("API volume (AP-07)", () => { test("clamps volume into [0, 1]", () => { - volume.set(0.5); - const api = new API(); + const { api, media } = setup(); api.volume = 2; expect(api.volume).toBe(1); api.volume = -1; expect(api.volume).toBe(0); + expect(media().volume).toBe(0); }); test("ignores a non-finite volume (keeps the current value) instead of poisoning it to NaN", () => { - volume.set(0.6); - const api = new API(); + const { api } = setup(); + api.volume = 0.6; api.volume = Number.NaN; expect(api.volume).toBe(0.6); @@ -261,36 +312,28 @@ describe("API transcript access", () => { Object.prototype.hasOwnProperty.call(files, path) ? createTFile(path) : null, ); const read = vi.fn(async (file: TFile) => files[file.path] ?? ""); - - plugin.set({ - settings: { - defaultPlaybackRate: 1.8, + const { api, player } = setup( + { transcript: { + ...DEFAULT_SETTINGS.transcript, path: "Transcripts/{{podcast}}/{{title}}.md", }, }, - app: { - vault: { - getAbstractFileByPath, - read, - }, - }, - } as never); + { getAbstractFileByPath, read } as unknown as Partial, + ); - return { getAbstractFileByPath, read }; + return { api, player, getAbstractFileByPath, read }; } test("returns null when no episode is loaded", async () => { - setTranscriptVault({}); - const api = new API(); + const { api } = setTranscriptVault({}); await expect(api.getTranscript()).resolves.toBeNull(); }); test("returns null when the current episode has no generated transcript file", async () => { - currentEpisode.set(feedEpisode); - const { getAbstractFileByPath, read } = setTranscriptVault({}); - const api = new API(); + const { api, player, getAbstractFileByPath, read } = setTranscriptVault({}); + player.restore(feedEpisode); await expect(api.getTranscript()).resolves.toBeNull(); @@ -301,12 +344,11 @@ describe("API transcript access", () => { }); test("reads the generated transcript note for the current episode", async () => { - currentEpisode.set(feedEpisode); const transcript = "# Feed Episode\n\nTranscript body for AI macros."; - const { read } = setTranscriptVault({ + const { api, player, read } = setTranscriptVault({ "Transcripts/Feed Podcast/Feed Episode.md": transcript, }); - const api = new API(); + player.restore(feedEpisode); await expect(api.getTranscript()).resolves.toBe(transcript); await expect(api.transcript).resolves.toBe(transcript); @@ -319,12 +361,11 @@ describe("API transcript access", () => { }); test("reads the generated transcript note for an explicit episode", async () => { - currentEpisode.set(feedEpisode); const transcript = "Local episode transcript"; - const { getAbstractFileByPath } = setTranscriptVault({ + const { api, player, getAbstractFileByPath } = setTranscriptVault({ "Transcripts/local file/Local Episode.md": transcript, }); - const api = new API(); + player.restore(feedEpisode); await expect(api.getTranscript(localEpisode)).resolves.toBe(transcript); diff --git a/src/API/API.ts b/src/API/API.ts index 69e8b408..b84c582e 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -2,17 +2,7 @@ import type { Episode } from "src/types/Episode"; import { formatSeconds } from "src/utility/formatSeconds"; import type { IAPI } from "./IAPI"; import { TFile } from "obsidian"; -import { - currentEpisode, - currentTime, - downloadedEpisodes, - duration, - isPaused, - activePlaybackSegment, - playbackRate as playbackRateStore, - plugin, - volume as volumeStore, -} from "src/store"; +import { downloadedEpisodes, plugin } from "src/store"; import { get } from "svelte/store"; import encodePodnotesURI from "src/utility/encodePodnotesURI"; import { isLocalFile } from "src/utility/isLocalFile"; @@ -23,6 +13,7 @@ import { PLAYBACK_RATE_STEP, } from "src/utility/playbackRate"; import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; +import type DownloadedEpisode from "src/types/DownloadedEpisode"; // Default used when a persisted/configured skip length is missing or invalid // (e.g. a cleared settings field serialized to null/NaN). Mirrors @@ -32,17 +23,17 @@ const DEFAULT_SKIP_LENGTH = 15; const normalizeSkipLength = (length: number): number => Number.isFinite(length) && length > 0 ? length : DEFAULT_SKIP_LENGTH; -// A non-finite volume (e.g. api.volume = NaN) must never poison the player, which -// two-way binds the store onto the media element. Fall back to the current value -// (itself clamped) so a bad write is a no-op rather than corrupting playback. -const clampVolume = (value: number, fallback = 1): number => { - const safeFallback = Number.isFinite(fallback) ? Math.min(1, Math.max(0, fallback)) : 1; - return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : safeFallback; -}; - export class API implements IAPI { + private get player() { + return get(plugin).player; + } + + private get state() { + return get(this.player.state); + } + public get podcast(): Episode { - return get(currentEpisode); + return this.state.episode as Episode; } public get transcript(): Promise { @@ -50,36 +41,37 @@ export class API implements IAPI { } public get length(): number { - return get(duration); + return this.state.duration; } public get currentTime(): number { - return get(currentTime); + return this.state.currentTime; } public set currentTime(value: number) { - activePlaybackSegment.set(null); - currentTime.update((_) => value); + this.player.seek(value); } + /** True while loading too: it plays once the media is ready, and Stop can cancel that. */ public get isPlaying(): boolean { - return !get(isPaused); + const { status } = this.state; + return status === "playing" || status === "loading"; } public get volume(): number { - return get(volumeStore); + return this.state.volume; } public set volume(value: number) { - volumeStore.set(clampVolume(value, get(volumeStore))); + this.player.setVolume(value); } public get playbackRate(): number { - return get(playbackRateStore); + return this.state.playbackRate; } public set playbackRate(value: number) { - playbackRateStore.set(normalizePlaybackRate(value, this.defaultPlaybackRate)); + this.player.setPlaybackRate(value); } /** @@ -165,22 +157,23 @@ export class API implements IAPI { } private getEpisodeLinkTarget(): string | undefined { - const epIsLocal = isLocalFile(this.podcast); - return !epIsLocal - ? this.podcast.feedUrl - : downloadedEpisodes.getEpisode(this.podcast)?.filePath; + if (!isLocalFile(this.podcast)) return this.podcast.feedUrl; + return ( + (this.podcast as Partial).filePath ?? + downloadedEpisodes.getEpisode(this.podcast)?.filePath + ); } start(): void { - isPaused.update((_) => false); + this.player.resume(); } stop(): void { - isPaused.update((_) => true); + this.player.pause(); } togglePlayback(): void { - isPaused.update((isPaused) => !isPaused); + this.player.toggle(); } skipBackward(): void { @@ -204,11 +197,11 @@ export class API implements IAPI { } increasePlaybackRate(): void { - playbackRateStore.update((rate) => adjustPlaybackRate(rate, PLAYBACK_RATE_STEP)); + this.playbackRate = adjustPlaybackRate(this.playbackRate, PLAYBACK_RATE_STEP); } decreasePlaybackRate(): void { - playbackRateStore.update((rate) => adjustPlaybackRate(rate, -PLAYBACK_RATE_STEP)); + this.playbackRate = adjustPlaybackRate(this.playbackRate, -PLAYBACK_RATE_STEP); } resetPlaybackRate(): void { diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index 2691df4e..13316ea2 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -13,7 +13,7 @@ import { } from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import type { PodcastFeed } from "./types/PodcastFeed"; -import { currentEpisode, currentTime, downloadedEpisodes, plugin } from "./store"; +import { downloadedEpisodes, plugin } from "./store"; import { DEFAULT_SETTINGS } from "./constants"; // The illegal-character sanitizer is private; exercise it through @@ -139,8 +139,6 @@ describe("DownloadPathTemplateEngine extension stripping (#DL-04)", () => { describe("TimestampTemplateEngine segment tags", () => { beforeEach(() => { - currentEpisode.set(demoEpisode); - currentTime.set(125); downloadedEpisodes.set({}); plugin.set({ settings: { diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 476f87b7..6e35c7a2 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -1,23 +1,16 @@ +import { TFile } from "obsidian"; import { get } from "svelte/store"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import podNotesURIHandler from "./URIHandler"; -import { - currentEpisode, - currentTime, - duration, - isPaused, - activePlaybackSegment, - localFiles, - playedEpisodes, - plugin, - requestedPlaybackTime, - viewState, -} from "./store"; +import { DEFAULT_SETTINGS } from "./constants"; +import { PlayerController } from "./player/PlayerController"; +import { localFiles, playedEpisodes, plugin, queue, viewState } from "./store"; import type { Episode } from "./types/Episode"; import type { LocalEpisode } from "./types/LocalEpisode"; import type { Playlist } from "./types/Playlist"; import { ViewState } from "./types/ViewState"; +import { FakeDocument } from "../tests/mocks/fakeMedia"; const mockGetEpisodes = vi.fn(); const testFeedUrl = "https://pod.example.com/feed.xml"; @@ -57,112 +50,105 @@ const emptyLocalFiles: Playlist = { shouldRepeat: false, }; -function resetStores() { - currentEpisode.update(() => undefined as unknown as Episode); - currentTime.set(0); - duration.set(0); - isPaused.set(true); - activePlaybackSegment.set(null); - playedEpisodes.set({}); - requestedPlaybackTime.set(null); - viewState.set(ViewState.PodcastGrid); - localFiles.set({ ...emptyLocalFiles, episodes: [] }); +let doc: FakeDocument; +let player: PlayerController; +let vaultFiles: string[] = []; + +const state = () => get(player.state); +const media = () => doc.media; + +function link(params: Record, revealPlayer?: () => void) { + return podNotesURIHandler( + { action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title, ...params }, + revealPlayer, + ); } -const api = { - set currentTime(value: number) { - currentTime.set(value); - }, -}; +/** The media's metadata arrives, after which the player is at its start position. */ +function metadata(duration = 3600) { + media().duration = duration; + media().emit("loadedmetadata"); +} -function setApp(getAbstractFileByPath: (path: string) => unknown) { - plugin.set({ - app: { vault: { getAbstractFileByPath: vi.fn(getAbstractFileByPath) } }, - } as never); +/** `episode` is loaded and paused at `time`. */ +function loadedAt(episode: Episode, time: number) { + player.play(episode, { time }); + metadata(); + player.pause(); } beforeEach(() => { - resetStores(); + vaultFiles = []; + doc = new FakeDocument(); + const host = { + settings: structuredClone(DEFAULT_SETTINGS), + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => + vaultFiles.includes(path) ? Object.assign(new TFile(), { path }) : null, + ), + getResourcePath: (file: TFile) => `app://resource/${file.path}`, + }, + }, + checkpoint: (update: () => void) => update(), + saveSettings: vi.fn(() => Promise.resolve()), + }; + player = new PlayerController(doc.asPlayerDocument(), host as never); + plugin.set({ ...host, player } as never); + playedEpisodes.set({}); + queue.set({ ...structuredClone(DEFAULT_SETTINGS.queue), episodes: [] }); + viewState.set(ViewState.PodcastGrid); + localFiles.set({ ...emptyLocalFiles, episodes: [] }); mockGetEpisodes.mockResolvedValue([testEpisode]); - setApp(() => null); }); afterEach(() => { - resetStores(); mockGetEpisodes.mockReset(); plugin.set(undefined as never); }); describe("podNotesURIHandler", () => { - test("seeks and resumes when the linked episode is already visible", async () => { - currentEpisode.set(testEpisode); - viewState.set(ViewState.Player); - currentTime.set(3600); - isPaused.set(true); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "120", - }, - api as never, - ); + test("seeks and plays the linked episode when it is already loaded", async () => { + loadedAt(testEpisode, 3000); + + await link({ time: "120" }); expect(get(viewState)).toBe(ViewState.Player); - expect(get(currentTime)).toBe(120); - expect(get(isPaused)).toBe(false); - expect(get(requestedPlaybackTime)).toBeNull(); + expect(media().currentTime).toBe(120); + expect(media().paused).toBe(false); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); - test("keeps the requested time for the player to apply after loading metadata", async () => { + test("plays a not-loaded episode at the requested time once its metadata loads", async () => { playedEpisodes.markAsPlayed(testEpisode); const revealPlayer = vi.fn(); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "240", - }, - api as never, - revealPlayer, - ); + await link({ time: "240" }, revealPlayer); expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl); - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); + expect(state()).toMatchObject({ status: "loading", episode: { title: testEpisode.title } }); expect(get(viewState)).toBe(ViewState.Player); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 240, - }); - expect(get(activePlaybackSegment)).toBeNull(); + expect(revealPlayer).toHaveBeenCalledTimes(1); + metadata(); + expect(media().currentTime).toBe(240); expect( get(playedEpisodes)[`${testEpisode.podcastName}::${testEpisode.title}`]?.finished, ).toBe(true); - expect(revealPlayer).toHaveBeenCalledTimes(1); }); - test("does not reveal the player when URI validation fails", async () => { - const revealPlayer = vi.fn(); + test.each(["not-a-number", "12abc", " "])( + "does not play or reveal the player for the timestamp %j", + async (time) => { + const revealPlayer = vi.fn(); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "not-a-number", - }, - api as never, - revealPlayer, - ); + await link({ time }, revealPlayer); - expect(revealPlayer).not.toHaveBeenCalled(); - expect(get(viewState)).toBe(ViewState.PodcastGrid); - }); + expect(revealPlayer).not.toHaveBeenCalled(); + expect(state().episode).toBeUndefined(); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + }, + ); test.each([ "http://169.254.169.254/latest/meta-data/", @@ -173,179 +159,89 @@ describe("podNotesURIHandler", () => { async (url) => { const revealPlayer = vi.fn(); - await podNotesURIHandler( - { - action: "podnotes", - url, - episodeName: "Some Episode", - }, - api as never, - revealPlayer, - ); + await link({ url, episodeName: "Some Episode" }, revealPlayer); // The attacker-controlled url is never handed to the feed parser. expect(mockGetEpisodes).not.toHaveBeenCalled(); - expect(get(currentEpisode)).toBeUndefined(); + expect(state().episode).toBeUndefined(); expect(get(viewState)).toBe(ViewState.PodcastGrid); expect(revealPlayer).not.toHaveBeenCalled(); }, ); - test("keeps the requested segment end for the player to apply after loading metadata", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "240", - endTime: "260", - }, - api as never, - ); + test("plays a not-loaded episode's segment and stops at its end", async () => { + await link({ time: "240", endTime: "260" }); + metadata(); - expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl); - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); - expect(get(viewState)).toBe(ViewState.Player); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 240, - endTime: 260, - }); - expect(get(activePlaybackSegment)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - startTime: 240, - endTime: 260, - }); + expect(media().currentTime).toBe(240); + media().currentTime = 261; + media().emit("timeupdate"); + expect(media().currentTime).toBe(260); + expect(media().paused).toBe(true); }); - test("seeks and arms a segment when the linked episode is already visible", async () => { - currentEpisode.set(testEpisode); - viewState.set(ViewState.Player); - currentTime.set(3600); - isPaused.set(true); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "120", - endTime: "135", - }, - api as never, - ); + test("plays a segment of the already-loaded episode", async () => { + loadedAt(testEpisode, 3000); - expect(get(currentTime)).toBe(120); - expect(get(isPaused)).toBe(false); - expect(get(activePlaybackSegment)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - startTime: 120, - endTime: 135, - }); + await link({ time: "120", endTime: "135" }); + media().currentTime = 136; + media().emit("timeupdate"); + + expect(media().currentTime).toBe(135); + expect(media().paused).toBe(true); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); - test("normal timestamp links clear a stale active segment", async () => { - currentEpisode.set(testEpisode); - activePlaybackSegment.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - startTime: 10, - endTime: 20, - }); + test("a plain timestamp link ends a running segment", async () => { + player.play(testEpisode, { time: 10, endTime: 20 }); + metadata(); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "120", - }, - api as never, - ); + await link({ time: "120" }); + media().currentTime = 130; + media().emit("timeupdate"); - expect(get(activePlaybackSegment)).toBeNull(); + expect(media().currentTime).toBe(130); + expect(media().paused).toBe(false); }); test("switches to a non-loaded episode whose title contains a literal '+'", async () => { mockGetEpisodes.mockResolvedValue([plusEpisode]); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - // Current-format link: Obsidian decodes %2B back to a literal '+'. - episodeName: "Episode 50: C++ Tips", - time: "300", - }, - api as never, - ); + // Current-format link: Obsidian decodes %2B back to a literal '+'. + await link({ episodeName: "Episode 50: C++ Tips", time: "300" }); + metadata(); - expect(get(currentEpisode)).toMatchObject({ title: "Episode 50: C++ Tips" }); + expect(state().episode).toMatchObject({ title: "Episode 50: C++ Tips" }); expect(get(viewState)).toBe(ViewState.Player); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${plusEpisode.podcastName}::${plusEpisode.title}`, - time: 300, - }); + expect(media().currentTime).toBe(300); }); test("resumes a paused, already-loaded episode whose title contains a literal '+'", async () => { - currentEpisode.set(plusEpisode); - viewState.set(ViewState.Player); - isPaused.set(true); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: "Episode 50: C++ Tips", - time: "90", - }, - api as never, - ); + loadedAt(plusEpisode, 10); + + await link({ episodeName: "Episode 50: C++ Tips", time: "90" }); - expect(get(currentTime)).toBe(90); - expect(get(isPaused)).toBe(false); + expect(media().currentTime).toBe(90); + expect(media().paused).toBe(false); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); test("resolves a legacy '+'-as-space link to a non-loaded episode", async () => { - mockGetEpisodes.mockResolvedValue([testEpisode]); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - // Legacy link: spaces were encoded as '+'. - episodeName: "Finished+Episode", - time: "60", - }, - api as never, - ); + // Legacy link: spaces were encoded as '+'. + await link({ episodeName: "Finished+Episode", time: "60" }); + metadata(); - expect(get(currentEpisode)).toMatchObject({ title: "Finished Episode" }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 60, - }); + expect(state().episode).toMatchObject({ title: "Finished Episode" }); + expect(media().currentTime).toBe(60); }); test("resumes the loaded episode for a legacy '+'-as-space link without re-resolving", async () => { - currentEpisode.set(testEpisode); - viewState.set(ViewState.Player); - isPaused.set(true); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: "Finished+Episode", - time: "45", - }, - api as never, - ); + loadedAt(testEpisode, 10); - expect(get(currentTime)).toBe(45); - expect(get(isPaused)).toBe(false); + await link({ episodeName: "Finished+Episode", time: "45" }); + + expect(media().currentTime).toBe(45); + expect(media().paused).toBe(false); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); @@ -361,24 +257,15 @@ describe("podNotesURIHandler", () => { }; localFiles.set({ ...emptyLocalFiles, episodes: [localEpisode] }); // Only the raw (current-format) path resolves to a file. - setApp((path) => (path === "Notes+/C++ Tips.mp3" ? {} : null)); - - await podNotesURIHandler( - { - action: "podnotes", - url: "Notes+/C++ Tips.mp3", - episodeName: "C++ Tips", - time: "30", - }, - api as never, - ); + vaultFiles = ["Notes+/C++ Tips.mp3"]; - expect(get(currentEpisode)).toMatchObject({ title: "C++ Tips" }); + await link({ url: "Notes+/C++ Tips.mp3", episodeName: "C++ Tips", time: "30" }); + metadata(); + + expect(state().episode).toMatchObject({ title: "C++ Tips" }); expect(get(viewState)).toBe(ViewState.Player); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: "local file::C++ Tips", - time: 30, - }); + expect(media().src).toBe("app://resource/Notes+/C++ Tips.mp3"); + expect(media().currentTime).toBe(30); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); @@ -395,24 +282,11 @@ describe("podNotesURIHandler", () => { localFiles.set({ ...emptyLocalFiles, episodes: [localEpisode] }); // The file was moved: the recorded path no longer resolves to a file, and // the url has no http(s) scheme, so it must be treated as a vault path. - setApp(() => null); - - await podNotesURIHandler( - { - action: "podnotes", - url: "Old/Local Note.mp3", - episodeName: "Local Note", - time: "15", - }, - api as never, - ); - expect(get(currentEpisode)).toMatchObject({ title: "Local Note" }); + await link({ url: "Old/Local Note.mp3", episodeName: "Local Note", time: "15" }); + + expect(state()).toMatchObject({ episode: { title: "Local Note" }, currentTime: 15 }); expect(get(viewState)).toBe(ViewState.Player); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: "local file::Local Note", - time: 15, - }); // A schemeless path must never hit the feed parser. expect(mockGetEpisodes).not.toHaveBeenCalled(); }); @@ -429,49 +303,30 @@ describe("podNotesURIHandler", () => { }; mockGetEpisodes.mockResolvedValue([hubermanEpisode, testEpisode]); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: - "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination", - time: "2580", - }, - api as never, - ); - - expect(get(currentEpisode)).toMatchObject({ title: currentTitle }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `Huberman Lab::${currentTitle}`, - time: 2580, + await link({ + episodeName: + "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination", + time: "2580", }); + metadata(); + + expect(state().episode).toMatchObject({ title: currentTitle }); + expect(media().currentTime).toBe(2580); }); test("seeks the already-loaded episode when a timestamp uses the old title (#315)", async () => { const currentTitle = "Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination | Dr. Martha Beck"; - const hubermanEpisode: Episode = { - ...testEpisode, - title: currentTitle, - podcastName: "Huberman Lab", - }; - currentEpisode.set(hubermanEpisode); - viewState.set(ViewState.Player); - isPaused.set(true); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: - "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination", - time: "90", - }, - api as never, - ); + loadedAt({ ...testEpisode, title: currentTitle, podcastName: "Huberman Lab" }, 10); - expect(get(currentTime)).toBe(90); - expect(get(isPaused)).toBe(false); + await link({ + episodeName: + "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination", + time: "90", + }); + + expect(media().currentTime).toBe(90); + expect(media().paused).toBe(false); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); @@ -489,71 +344,77 @@ describe("podNotesURIHandler", () => { url: "https://pod.example.com/part2", streamUrl: "https://pod.example.com/part2.mp3", }; - currentEpisode.set(part2); - viewState.set(ViewState.Player); - isPaused.set(true); + loadedAt(part2, 10); mockGetEpisodes.mockResolvedValue([part1, part2]); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: part1.title, - time: "90", - }, - api as never, - ); + await link({ episodeName: part1.title, time: "90" }); + metadata(); expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl); - expect(get(currentEpisode)).toMatchObject({ title: part1.title }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${part1.podcastName}::${part1.title}`, - time: 90, - }); + expect(state().episode).toMatchObject({ title: part1.title }); + expect(media().src).toBe(part1.streamUrl); + expect(media().currentTime).toBe(90); + }); + + test("does not take a same-titled episode of another show for the loaded one", async () => { + const trailerA: Episode = { ...testEpisode, title: "Trailer", podcastName: "Show A" }; + const trailerB: Episode = { + ...trailerA, + podcastName: "Show B", + feedUrl: "https://b.example.com/feed.xml", + streamUrl: "https://b.example.com/trailer.mp3", + }; + loadedAt(trailerA, 10); + mockGetEpisodes.mockResolvedValue([trailerB]); + + await link({ url: "https://b.example.com/feed.xml", episodeName: "Trailer", time: "5" }); + + expect(mockGetEpisodes).toHaveBeenCalledWith("https://b.example.com/feed.xml"); + expect(state().episode).toMatchObject({ podcastName: "Show B" }); + expect(media().src).toBe(trailerB.streamUrl); + }); + + test("does not play a feed episode that arrives after the user played another", async () => { + let resolveFeed!: (episodes: Episode[]) => void; + mockGetEpisodes.mockReturnValue( + new Promise((resolve) => { + resolveFeed = resolve; + }), + ); + const revealPlayer = vi.fn(); + const other: Episode = { ...plusEpisode, title: "Other" }; + + const pending = link({ time: "240" }, revealPlayer); + player.play(other); + resolveFeed([testEpisode]); + await pending; + + expect(state().episode).toBe(other); + expect(media().src).toBe(other.streamUrl); + expect(revealPlayer).not.toHaveBeenCalled(); }); test("does not pick a leftover similar episode when the linked title is gone (#315)", async () => { - mockGetEpisodes.mockResolvedValue([ - { - ...testEpisode, - title: "Weekly News Roundup Extra", - }, - ]); + mockGetEpisodes.mockResolvedValue([{ ...testEpisode, title: "Weekly News Roundup Extra" }]); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: "Weekly News Roundup", - time: "10", - }, - api as never, - ); + await link({ episodeName: "Weekly News Roundup", time: "10" }); - expect(get(currentEpisode)).toBeUndefined(); + expect(state().episode).toBeUndefined(); expect(get(viewState)).toBe(ViewState.PodcastGrid); }); test("does not treat a weakly similar feed title as a match (#315)", async () => { mockGetEpisodes.mockResolvedValue([ - { - ...testEpisode, - title: "Optimize Testosterone & Estrogen | Dr. Kyle Gillett", - }, + { ...testEpisode, title: "Optimize Testosterone & Estrogen | Dr. Kyle Gillett" }, ]); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: - "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination", - time: "10", - }, - api as never, - ); + await link({ + episodeName: + "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination", + time: "10", + }); - expect(get(currentEpisode)).toBeUndefined(); + expect(state().episode).toBeUndefined(); expect(get(viewState)).toBe(ViewState.PodcastGrid); }); @@ -561,37 +422,19 @@ describe("podNotesURIHandler", () => { mockGetEpisodes.mockResolvedValue([]); await expect( - podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: "Nonexistent Episode", - time: "10", - }, - api as never, - ), + link({ episodeName: "Nonexistent Episode", time: "10" }), ).resolves.toBeUndefined(); - expect(get(currentEpisode)).toBeUndefined(); + expect(state().episode).toBeUndefined(); expect(get(viewState)).toBe(ViewState.PodcastGrid); }); test("does not reject when feed parsing throws (no silent unhandled rejection)", async () => { mockGetEpisodes.mockRejectedValue(new Error("network down")); - await expect( - podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: "Anything", - time: "10", - }, - api as never, - ), - ).resolves.toBeUndefined(); + await expect(link({ episodeName: "Anything", time: "10" })).resolves.toBeUndefined(); - expect(get(currentEpisode)).toBeUndefined(); + expect(state().episode).toBeUndefined(); expect(get(viewState)).toBe(ViewState.PodcastGrid); }); @@ -611,227 +454,86 @@ describe("podNotesURIHandler", () => { // Space-twin first: an order-insensitive (membership) match would wrongly pick it. mockGetEpisodes.mockResolvedValue([spaceVariant, plusVariant]); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: "A+B", - time: "75", - }, - api as never, - ); + await link({ episodeName: "A+B", time: "75" }); + metadata(); - expect(get(currentEpisode)).toMatchObject({ title: "A+B" }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${plusVariant.podcastName}::A+B`, - time: 75, - }); + expect(state().episode).toMatchObject({ title: "A+B" }); + expect(media().currentTime).toBe(75); }); test("reopens a non-loaded episode without a timestamp and resumes from the saved location (#35)", async () => { playedEpisodes.setEpisodeTime(testEpisode, 321, 3600, false); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - // No `time`: the {{episodelink}} template tag omits it. - }, - api as never, - ); + // No `time`: the {{episodelink}} template tag omits it. + await link({}); + metadata(); expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl); - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); + expect(state().episode).toMatchObject({ title: testEpisode.title }); expect(get(viewState)).toBe(ViewState.Player); - // The resume point is resolved from saved progress and armed explicitly, so - // the player seeks to 321s — and any stale pending request can't override it. - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 321, - }); - }); - - test("a no-timestamp link overrides a stale pending seek for the same episode (#35)", async () => { - playedEpisodes.setEpisodeTime(testEpisode, 321, 3600, false); - // A previous timestamp link left a pending seek that has not been applied - // (metadata still loading). The resume link must win, not the stale 999. - requestedPlaybackTime.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 999, - }); - - await podNotesURIHandler( - { action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title }, - api as never, - ); - - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 321, - }); + expect(media().currentTime).toBe(321); }); test("restarts a finished episode from the beginning for a no-timestamp link (#35)", async () => { // A finished episode is stored at its end; resuming there would auto-advance. - playedEpisodes.markAsPlayed(testEpisode); playedEpisodes.setEpisodeTime(testEpisode, 3600, 3600, true); - await podNotesURIHandler( - { action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title }, - api as never, - ); + await link({}); + metadata(); - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 0, - }); + expect(state().episode).toMatchObject({ title: testEpisode.title }); + expect(media().currentTime).toBe(0); }); test("restarts an already-loaded finished episode from the beginning for a no-timestamp link (#35)", async () => { - currentEpisode.set(testEpisode); - viewState.set(ViewState.PodcastGrid); - duration.set(180); - currentTime.set(180); // sitting at the very end - isPaused.set(true); - - await podNotesURIHandler( - { action: "podnotes", url: testFeedUrl, episodeName: testEpisode.title }, - api as never, - ); + player.play(testEpisode); + metadata(180); + media().currentTime = 180; + media().paused = true; + media().emit("pause"); + media().emit("ended"); + + await link({}); expect(get(viewState)).toBe(ViewState.Player); - expect(get(currentTime)).toBe(0); - expect(get(isPaused)).toBe(false); + expect(media().currentTime).toBe(0); + expect(media().paused).toBe(false); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); test("surfaces the player and resumes the already-loaded episode without seeking when no timestamp is given (#35)", async () => { - currentEpisode.set(testEpisode); - viewState.set(ViewState.PodcastGrid); - currentTime.set(1234); - isPaused.set(true); - - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - }, - api as never, - ); + loadedAt(testEpisode, 1234); + + await link({}); expect(get(viewState)).toBe(ViewState.Player); - // The live position is already the last played location — left untouched. - expect(get(currentTime)).toBe(1234); - expect(get(isPaused)).toBe(false); - expect(get(requestedPlaybackTime)).toBeNull(); + // The live position is already the last played location: left untouched. + expect(media().currentTime).toBe(1234); + expect(media().paused).toBe(false); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); test("treats an empty timestamp the same as an omitted one (resume, not error) (#35)", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "", - }, - api as never, - ); + await link({ time: "" }); + metadata(); - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); + expect(state().episode).toMatchObject({ title: testEpisode.title }); expect(get(viewState)).toBe(ViewState.Player); - // No saved progress for this episode → resume from the start. - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 0, - }); + // No saved progress for this episode: resume from the start. + expect(media().currentTime).toBe(0); }); - test("still rejects a present-but-non-numeric timestamp", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "not-a-number", - }, - api as never, - ); - - expect(get(currentEpisode)).toBeUndefined(); - expect(get(viewState)).toBe(ViewState.PodcastGrid); - expect(mockGetEpisodes).not.toHaveBeenCalled(); - }); - - test("rejects a segment end without a start timestamp", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - endTime: "20", - }, - api as never, - ); - - expect(get(currentEpisode)).toBeUndefined(); - expect(get(viewState)).toBe(ViewState.PodcastGrid); - expect(get(activePlaybackSegment)).toBeNull(); - expect(mockGetEpisodes).not.toHaveBeenCalled(); - }); - - test("rejects a segment end before the start timestamp", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "20", - endTime: "20", - }, - api as never, - ); - - expect(get(currentEpisode)).toBeUndefined(); - expect(get(viewState)).toBe(ViewState.PodcastGrid); - expect(get(activePlaybackSegment)).toBeNull(); - expect(mockGetEpisodes).not.toHaveBeenCalled(); - }); - - test("rejects a negative plain (non-segment) timestamp", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "-10", - }, - api as never, - ); - - expect(get(currentEpisode)).toBeUndefined(); - expect(get(viewState)).toBe(ViewState.PodcastGrid); - expect(mockGetEpisodes).not.toHaveBeenCalled(); - }); - - test("rejects a negative segment start timestamp", async () => { - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "-10", - endTime: "5", - }, - api as never, - ); - - expect(get(currentEpisode)).toBeUndefined(); + test.each([ + ["a segment end without a start timestamp", { endTime: "20" }], + ["a segment end at the start timestamp", { time: "20", endTime: "20" }], + ["a segment end with trailing text", { time: "10", endTime: "20s" }], + ["a negative plain timestamp", { time: "-10" }], + ["a negative segment start timestamp", { time: "-10", endTime: "5" }], + ])("rejects %s", async (_, params) => { + await link(params); + + expect(state().episode).toBeUndefined(); expect(get(viewState)).toBe(ViewState.PodcastGrid); - expect(get(activePlaybackSegment)).toBeNull(); expect(mockGetEpisodes).not.toHaveBeenCalled(); }); @@ -849,22 +551,12 @@ describe("podNotesURIHandler", () => { ...emptyLocalFiles, episodes: [makeLocal("A B"), makeLocal("A+B")], }); - setApp((path) => (path === "A+B.mp3" ? {} : null)); - - await podNotesURIHandler( - { - action: "podnotes", - url: "A+B.mp3", - episodeName: "A+B", - time: "20", - }, - api as never, - ); + vaultFiles = ["A+B.mp3"]; - expect(get(currentEpisode)).toMatchObject({ title: "A+B" }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: "local file::A+B", - time: 20, - }); + await link({ url: "A+B.mp3", episodeName: "A+B", time: "20" }); + metadata(); + + expect(state().episode).toMatchObject({ title: "A+B" }); + expect(media().currentTime).toBe(20); }); }); diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 054c6012..0181bbea 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -1,28 +1,16 @@ import { Notice } from "obsidian"; import type { ObsidianProtocolData } from "obsidian"; import { get } from "svelte/store"; -import type { IAPI } from "./API/IAPI"; import FeedParser from "./parser/feedParser"; -import { - currentEpisode, - currentTime, - duration, - isPaused, - activePlaybackSegment, - localFiles, - playedEpisodes, - plugin, - savedFeeds, - requestedPlaybackTime, - viewState, -} from "./store"; +import { downloadedEpisodes, localFiles, plugin, savedFeeds, viewState } from "./store"; +import type DownloadedEpisode from "./types/DownloadedEpisode"; import type { Episode } from "./types/Episode"; import type { PodcastFeed } from "./types/PodcastFeed"; import { isFetchableUrl } from "./utility/assertFetchableUrl"; import { resolveFeedUrl } from "./services/privateFeeds"; import { isPrivateFeedPlaceholder, parsePrivateFeedPlaceholder } from "./utility/privateFeedUrl"; -import { getEpisodeKey } from "./utility/episodeKey"; import { findUniqueTitleMatch, titleTokenSimilarity } from "./utility/episodeTitleMatch"; +import { isLocalFile } from "./utility/isLocalFile"; import { ViewState } from "./types/ViewState"; type PodNotesProtocolData = ObsidianProtocolData & { @@ -74,24 +62,13 @@ function findLocalEpisodeByCandidates(nameCandidates: string[]): Episode | undef ); } -/** - * The time a no-timestamp link ({{episodelink}}, issue #35) should resume an - * episode at: the last played location, or the start. A FINISHED episode is - * stored at its end (time === duration); resuming there would immediately fire - * the player's `ended` handler and auto-advance the queue, so we restart it from - * the beginning instead — matching the issue's "the beginning if not played". - */ -function resolveResumeTime(episode: Episode): number { - const played = playedEpisodes.get(episode); - if (!played) return 0; - - const isFinished = played.finished || (played.duration > 0 && played.time >= played.duration); - return isFinished ? 0 : played.time; +/** A number of seconds from a link, or NaN unless the whole value is one. */ +function parseSeconds(value: string): number { + return value.trim() === "" ? Number.NaN : Number(value); } export default async function podNotesURIHandler( { url, episodeName, time, endTime, end, to }: PodNotesProtocolData, - api: IAPI, revealPlayer?: RevealPodNotesPlayer, ) { if (!url || !episodeName) { @@ -99,14 +76,12 @@ export default async function podNotesURIHandler( return; } - // A link may omit the timestamp ({{episodelink}}, issue #35). When it does we - // reopen the episode and resume from the last played location (resolved from - // saved progress below), or the start if it was never played or already - // finished — rather than seeking to a baked-in time. + // A link may omit the timestamp ({{episodelink}}, issue #35). The player then + // resumes the episode where it was left, or from the start once finished. const hasExplicitTime = time !== undefined && time !== ""; let requestedTime = 0; if (hasExplicitTime) { - requestedTime = parseFloat(time); + requestedTime = parseSeconds(time); if (!Number.isFinite(requestedTime)) { new Notice("Timestamp must be a valid number"); return; @@ -127,60 +102,43 @@ export default async function podNotesURIHandler( if (requestedEndTime === null) return; const nameCandidates = candidateValues(episodeName); - const currentEp = get(currentEpisode); + const player = get(plugin).player; + const playOptions = hasExplicitTime ? { time: requestedTime, endTime: requestedEndTime } : {}; + const loaded = get(player.state).episode; + // A link to the loaded episode names its feed, or its file for a local file, so a + // same-titled episode of another show is not taken for it. + const loadedLinkTarget = + loaded && + (isLocalFile(loaded) + ? ((loaded as Partial).filePath ?? + downloadedEpisodes.getEpisode(loaded)?.filePath) + : loaded.feedUrl); // Membership (not ordered selection) is correct here: there is only one loaded episode, and // keeping the legacy candidate lets a legacy '+'-as-space link to the loaded episode resume - // without a feed round-trip (and without currentEpisode.set re-enqueueing it). The only cost is - // a rare false-positive — a current-format link to a "X+Y" episode while a distinct "X Y" twin - // is loaded resumes the loaded twin instead of switching — which we accept over regressing the - // far more common same-episode legacy-link case. + // without a feed round-trip. The only cost is a rare false-positive - a current-format link to + // a "X+Y" episode while a distinct "X Y" twin is loaded resumes the loaded twin instead of + // switching - which we accept over regressing the far more common same-episode legacy-link case. // A retitled same-episode link (guest name moved) has the same token set. // Sub-1.0 overlap is not enough here: "… Part 1" vs the loaded "… Part 2" // scores ~0.78, and with only one candidate the uniqueness guard is vacuous. // Those links fall through to the feed lookup, which can still exact-match. - const episodeIsPlaying = - !!currentEp && - (nameCandidates.includes(currentEp.title) || - nameCandidates.some((name) => titleTokenSimilarity(name, currentEp.title) === 1)); - const playerIsVisible = get(viewState) === ViewState.Player; - - if (episodeIsPlaying) { - if (hasExplicitTime) { - const episodeKey = getEpisodeKey(currentEp); - requestedPlaybackTime.set({ - episodeKey, - time: requestedTime, - endTime: requestedEndTime, - }); - viewState.set(ViewState.Player); - api.currentTime = requestedTime; - setActivePlaybackSegment(episodeKey, requestedTime, requestedEndTime); - isPaused.set(false); - if (playerIsVisible) { - requestedPlaybackTime.set(null); - } - - await revealPlayer?.(); - return; - } - - // No timestamp: the live position already is the last played location, so - // surface the player and resume playback without seeking — unless the - // episode already finished (live position at its end), in which case - // replaying would instantly fire `ended` and auto-advance, so restart it. - activePlaybackSegment.set(null); + const episodeIsLoaded = + !!loaded && + !!loadedLinkTarget && + candidateValues(url).includes(loadedLinkTarget) && + (nameCandidates.includes(loaded.title) || + nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); + + if (episodeIsLoaded) { + if (!player.play(loaded, playOptions)) return; viewState.set(ViewState.Player); - const liveTime = get(currentTime); - const liveDuration = get(duration); - if (liveDuration > 0 && liveTime >= liveDuration) { - api.currentTime = 0; - } - isPaused.set(false); - await revealPlayer?.(); return; } + // Taken before resolving the episode: a play the user starts meanwhile wins. + const play = player.deferPlay(); + // The path probe only decides local-vs-feed routing; the episode itself is resolved by name. const { vault } = get(plugin).app; const localFile = candidateValues(url) @@ -259,22 +217,7 @@ export default async function podNotesURIHandler( return; } - // Always arm requestedPlaybackTime so the resume point is explicit. For a - // no-timestamp link (issue #35) we resolve it from saved progress here rather - // than relying on the player's restore-on-absence: that makes the seek - // deterministic and overrides any stale pending request from an earlier link - // to this same episode (which would otherwise win on metadata load). - requestedPlaybackTime.set({ - episodeKey: getEpisodeKey(episode), - time: hasExplicitTime ? requestedTime : resolveResumeTime(episode), - endTime: hasExplicitTime ? requestedEndTime : undefined, - }); - setActivePlaybackSegment( - getEpisodeKey(episode), - hasExplicitTime ? requestedTime : resolveResumeTime(episode), - hasExplicitTime ? requestedEndTime : undefined, - ); - currentEpisode.set(episode); + if (!play(episode, playOptions)) return; viewState.set(ViewState.Player); await revealPlayer?.(); } @@ -296,7 +239,7 @@ function parseSegmentEndTime( return null; } - const parsed = parseFloat(rawEndTime); + const parsed = parseSeconds(rawEndTime); if (!Number.isFinite(parsed)) { new Notice("Segment end time must be a valid number"); return null; @@ -309,19 +252,3 @@ function parseSegmentEndTime( return parsed; } - -function setActivePlaybackSegment( - episodeKey: string, - startTime: number, - endTime: number | undefined, -): void { - activePlaybackSegment.set( - endTime === undefined - ? null - : { - episodeKey, - startTime, - endTime, - }, - ); -} diff --git a/src/getContextMenuHandler.test.ts b/src/getContextMenuHandler.test.ts index 90ec90d3..32a5dce2 100644 --- a/src/getContextMenuHandler.test.ts +++ b/src/getContextMenuHandler.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TFile } from "obsidian"; import { get } from "svelte/store"; import getContextMenuHandler from "./getContextMenuHandler"; -import { VIEW_TYPE } from "./constants"; -import { currentEpisode, downloadedEpisodes, viewState } from "./store"; +import { DEFAULT_SETTINGS, VIEW_TYPE } from "./constants"; +import { PlayerController } from "./player/PlayerController"; +import { downloadedEpisodes, playedEpisodes, plugin, viewState } from "./store"; import { ViewState } from "./types/ViewState"; +import { FakeDocument } from "../tests/mocks/fakeMedia"; // `tsc` resolves `obsidian` to the real typings while Vitest aliases it to // tests/mocks/obsidian.ts. Build a TFile (so `file instanceof TFile` holds in @@ -77,6 +79,10 @@ function setupWorkspace(openLeaves: unknown[]) { return { workspace, newLeaf }; } +let doc: FakeDocument; +let player: PlayerController; +const playing = () => get(player.state).episode; + function makeApp(workspace: unknown, file: TFile) { const vault = { getAbstractFileByPath: vi.fn((path: string) => (path ? audioFile(path) : file)), @@ -84,6 +90,15 @@ function makeApp(workspace: unknown, file: TFile) { }; // createMediaUrlObjectFromFilePath reads the global `app`. (globalThis as { app?: unknown }).app = { vault }; + const host = { + settings: structuredClone(DEFAULT_SETTINGS), + app: { vault }, + checkpoint: (update: () => void) => update(), + saveSettings: vi.fn(() => Promise.resolve()), + }; + doc = new FakeDocument(); + player = new PlayerController(doc.asPlayerDocument(), host as never); + plugin.set({ ...host, player, enablePodcastViewMount: vi.fn() } as never); return { workspace, vault, @@ -114,17 +129,18 @@ async function playFile( afterEach(() => { (globalThis as { app?: unknown }).app = undefined; + plugin.set(undefined as never); vi.restoreAllMocks(); }); beforeEach(() => { - currentEpisode.set(undefined as never); downloadedEpisodes.set({}); + playedEpisodes.set({}); viewState.set(ViewState.PodcastGrid); }); describe("getContextMenuHandler — Play with PodNotes", () => { - it("offers the item for audio files and sets it as the current episode", async () => { + it("offers the item for audio files and plays the file", async () => { const file = audioFile("Audio/tone.mp3"); const { workspace } = setupWorkspace([{}]); const app = makeApp(workspace, file); @@ -133,12 +149,14 @@ describe("getContextMenuHandler — Play with PodNotes", () => { const play = menu.items.find((i) => i.title === "Play with PodNotes"); expect(play?.icon).toBe("play"); - expect(get(currentEpisode)).toMatchObject({ + expect(playing()).toMatchObject({ title: "tone", podcastName: "local file", filePath: "Audio/tone.mp3", streamUrl: "app://resource/Audio/tone.mp3?1", }); + expect(doc.media.src).toBe("app://resource/Audio/tone.mp3?1"); + expect(doc.media.paused).toBe(false); expect(get(viewState)).toBe(ViewState.Player); }); @@ -153,13 +171,14 @@ describe("getContextMenuHandler — Play with PodNotes", () => { "Play as audio with PodNotes", "Play as video with PodNotes", ]); - expect(get(currentEpisode)).toMatchObject({ + expect(playing()).toMatchObject({ title: "lecture", podcastName: "local file", filePath: "Videos/lecture.mp4", mediaType: "video", streamUrl: "app://resource/Videos/lecture.mp4?1", }); + expect(doc.media.tagName).toBe("VIDEO"); expect(get(viewState)).toBe(ViewState.Player); }); @@ -177,11 +196,12 @@ describe("getContextMenuHandler — Play with PodNotes", () => { mediaType: "audio", streamUrl: "app://resource/Audio/lecture.mp4?1", }); - expect(get(currentEpisode)).toMatchObject({ + expect(playing()).toMatchObject({ title: "lecture", filePath: "Audio/lecture.mp4", mediaType: "audio", }); + expect(doc.media.tagName).toBe("AUDIO"); expect(get(viewState)).toBe(ViewState.Player); }); @@ -201,11 +221,12 @@ describe("getContextMenuHandler — Play with PodNotes", () => { mediaType: "video", streamUrl: "app://resource/Videos/lecture.mp4?1", }); - expect(get(currentEpisode)).toMatchObject({ + expect(playing()).toMatchObject({ title: "lecture", filePath: "Videos/lecture.mp4", mediaType: "video", }); + expect(doc.media.src).toBe("app://resource/Videos/lecture.mp4?1"); }); it("does not offer the item for non-media files", () => { diff --git a/src/getContextMenuHandler.ts b/src/getContextMenuHandler.ts index 3835110b..06f9026e 100644 --- a/src/getContextMenuHandler.ts +++ b/src/getContextMenuHandler.ts @@ -2,7 +2,7 @@ import type { App, EventRef, Menu, TAbstractFile, WorkspaceLeaf } from "obsidian import { Notice, TFile } from "obsidian"; import { get } from "svelte/store"; import { VIEW_TYPE } from "./constants"; -import { downloadedEpisodes, playedEpisodes, currentEpisode, viewState, plugin } from "./store"; +import { downloadedEpisodes, playedEpisodes, viewState, plugin } from "./store"; import type { EpisodeMediaType } from "./types/Episode"; import type { LocalEpisode } from "./types/LocalEpisode"; import { ViewState } from "./types/ViewState"; @@ -83,16 +83,14 @@ function addPlayLocalFileItem( playedEpisodes.markAsUnplayed(localEpisode); } - currentEpisode.set(localEpisode); + get(plugin).player.play(localEpisode); viewState.set(ViewState.Player); get(plugin)?.enablePodcastViewMount(); - // Setting the stores above only updates an already-mounted - // PodNotes view. When the view is closed (or hidden in a - // collapsed sidebar) nothing reacts, so "Play with PodNotes" - // silently did nothing — the file played to no visible player - // (issue #84). Open the view if needed and reveal it so the - // player surfaces with the just-selected episode. + // Playing opens no view, so with the view closed (or hidden in a + // collapsed sidebar) the file played to no visible player (issue + // #84). Open the view if needed and reveal it so the player + // surfaces with the just-selected episode. await revealPodcastView(app); }), ); diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index f2fc40d5..5bf6d36e 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -507,6 +507,74 @@ describe("PodNotes onload wiring (#55)", () => { expect(activateSpy).toHaveBeenCalledTimes(2); }); + const savedEpisode: Episode = { + title: "Saved Episode", + streamUrl: "https://example.com/saved.mp3", + url: "https://example.com/saved", + description: "", + content: "", + podcastName: "Pod", + }; + const savedData = { + schemaVersion: 2, + currentEpisode: savedEpisode, + playedEpisodes: { + "Pod::Saved Episode": { + title: "Saved Episode", + podcastName: "Pod", + time: 1234, + duration: 3600, + finished: false, + }, + }, + }; + const media = () => document.querySelector("audio.podnotes-media") as HTMLAudioElement; + const checkCommand = (commands: Array>, id: string) => + (commands.find((c) => c.id === id)!.checkCallback as (checking: boolean) => boolean)(true); + const runCommand = (commands: Array>, id: string) => + (commands.find((c) => c.id === id)!.checkCallback as (checking: boolean) => void)(false); + + it("restores the selected episode paused at its saved position, without loading it", async () => { + const { plugin } = await loadPlugin(savedData); + + expect(plugin.api.podcast).toMatchObject({ title: "Saved Episode" }); + expect(plugin.api.currentTime).toBe(1234); + expect(plugin.api.isPlaying).toBe(false); + expect(media().getAttribute("src")).toBeNull(); + }); + + it("lets Stop cancel an episode that is still loading", async () => { + vi.spyOn(HTMLMediaElement.prototype, "play").mockReturnValue(new Promise(() => {})); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const { commands, plugin } = await loadPlugin(savedData); + expect(checkCommand(commands, "start-playing")).toBe(true); + + runCommand(commands, "start-playing"); + expect(media().getAttribute("src")).toBe(savedEpisode.streamUrl); + expect(checkCommand(commands, "stop-playing")).toBe(true); + runCommand(commands, "stop-playing"); + + expect(plugin.api.isPlaying).toBe(false); + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled(); + expect(checkCommand(commands, "start-playing")).toBe(true); + }); + + it("saves the playing position on unload, before the writer stops", async () => { + vi.spyOn(HTMLMediaElement.prototype, "play").mockReturnValue(new Promise(() => {})); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const { plugin } = await loadPlugin(savedData); + plugin.api.start(); + media().dispatchEvent(new Event("loadedmetadata")); + media().currentTime = 1300; + + plugin.onunload(); + + await vi.waitFor(() => { + const written = vi.mocked(plugin.saveData).mock.lastCall?.[0] as typeof savedData; + expect(written?.playedEpisodes["Pod::Saved Episode"]?.time).toBe(1300); + }); + }); + 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 36155815..8d8bb69b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ import { - currentEpisode, downloadedEpisodes, episodeListLimit, favorites, @@ -9,10 +8,7 @@ import { queue, savedFeeds, hidePlayedEpisodes, - playbackRate, plugin, - volume, - subscribeQueueToCurrentEpisode, } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; import { registerCommands } from "src/commands"; @@ -31,8 +27,8 @@ import type PartialAppExtension from "./global"; import podNotesURIHandler from "./URIHandler"; import getContextMenuHandler from "./getContextMenuHandler"; import { TranscriptionService } from "./services/TranscriptionService"; -import { get, type Unsubscriber } from "svelte/store"; -import { normalizePlaybackRate } from "./utility/playbackRate"; +import type { Unsubscriber } from "svelte/store"; +import { PlayerController } from "./player/PlayerController"; import { decodePodNotesData, encodePodNotesData, @@ -111,14 +107,13 @@ export default class PodNotes extends Plugin implements IPodNotes { public override app!: PartialAppExtension; public credentials!: CredentialRepository; public feedUrls!: FeedUrlRepository; + public player!: PlayerController; private views = new Set(); - // Store subscriptions tied to the plugin lifetime: settings persistence and - // queue automation. Disposed together in onunload. + // Store subscriptions tied to the plugin lifetime. Disposed together in onunload. private storeUnsubscribers: Unsubscriber[] = []; private transcriptionService?: TranscriptionService; - private volumeUnsubscribe?: Unsubscriber; private localFilesMirrorUnsubscribe?: Unsubscriber; private maxLayoutReadyAttempts = 10; @@ -157,12 +152,6 @@ export default class PodNotes extends Plugin implements IPodNotes { if (this.writeInFlight) tasks.addPromise(this.writeInFlight); }), ); - // A phone can kill the app once it is in the background, with no event first. - // The main window's document: the app, not a popout, goes to the background. - const mainDocument = window.document; - this.registerDomEvent(mainDocument, "visibilitychange", () => { - if (mainDocument.visibilityState === "hidden") void this.flushSaves(); - }); plugin.set(this); this.credentials = new CredentialRepository(this.app.secretStorage); this.feedUrls = new FeedUrlRepository(this.app.secretStorage); @@ -171,16 +160,21 @@ export default class PodNotes extends Plugin implements IPodNotes { this.writer = this.createWriter(MAIN_WINDOW_CLOCK); this.setCollectionStores(); - if (this.settings.currentEpisode) { - currentEpisode.set(this.settings.currentEpisode); - } - volume.set(Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1))); - playbackRate.set(normalizePlaybackRate(this.settings.defaultPlaybackRate)); + // The media lives in the main window, so playback outlasts the view and popouts. + const mainDocument = window.document; + this.player = new PlayerController(mainDocument, this); + this.player.restore(this.settings.currentEpisode); + // A phone can kill the app once it is in the background, with no event first. + // The main window's document: the app, not a popout, goes to the background. + this.registerDomEvent(mainDocument, "visibilitychange", () => { + if (mainDocument.visibilityState !== "hidden") return; + this.player.save(); + void this.flushSaves(); + }); - // Mirror every store-backed slice of state into settings, and wire the queue - // automation that drops the now-playing episode from the up-next queue. Both - // are store subscriptions tied to the plugin lifetime; onunload disposes them. - this.storeUnsubscribers.push(bindStoresToSettings(this), subscribeQueueToCurrentEpisode()); + // Mirror every store-backed slice of state into settings. The subscriptions + // are tied to the plugin lifetime; onunload disposes them. + this.storeUnsubscribers.push(bindStoresToSettings(this)); // Keep the Local Files playlist in sync with downloaded episodes (issue #176). // downloadedEpisodes is the authoritative offline set, so mirror it into the @@ -191,21 +185,6 @@ export default class PodNotes extends Plugin implements IPodNotes { ); this.api = new API(); - this.volumeUnsubscribe = volume.subscribe((value) => { - const clamped = Math.min(1, Math.max(0, value)); - - if (clamped !== value) { - volume.set(clamped); - return; - } - - if (clamped === this.settings.defaultVolume) { - return; - } - - this.settings.defaultVolume = clamped; - void this.saveSettings(); - }); // All user-facing commands live in src/commands.ts; this keeps onload // focused on lifecycle wiring (stores, view, ribbon, protocol, media session). @@ -240,7 +219,7 @@ export default class PodNotes extends Plugin implements IPodNotes { this.app.workspace.onLayoutReady(this.onLayoutReady.bind(this)); this.registerObsidianProtocolHandler("podnotes", (action) => - podNotesURIHandler(action, this.api, () => this.activateView()), + podNotesURIHandler(action, () => this.activateView()), ); this.registerEvent(getContextMenuHandler(this.app)); @@ -465,12 +444,13 @@ export default class PodNotes extends Plugin implements IPodNotes { this.isUnloaded = true; // The refusal ends with this instance; the next one reads the file itself. this.refusalNotice?.hide(); + // Before the writer unloads, so the position it saves is written. + this.player?.dispose(); void this.writer?.unload(); this.clearLayoutReadyRetry(); this.clearMediaSessionHandlers(); for (const unsubscribe of this.storeUnsubscribers) unsubscribe(); this.storeUnsubscribers = []; - this.volumeUnsubscribe?.(); this.localFilesMirrorUnsubscribe?.(); this.transcriptionService?.dispose(); this.transcriptionService = undefined; @@ -716,9 +696,6 @@ export default class PodNotes extends Plugin implements IPodNotes { }; } - // In place, so code that holds plugin.settings across a save (an import) sees the - // merge. Never through currentEpisode.set: it saves the outgoing episode's position - // from the live player and queues that episode. A sync leaves what is playing alone. /** The stores of the persisted collections, from settings. The player's state is not one. */ private setCollectionStores(): void { // Before downloads: their mirror rebuilds Local Files from this store's playlist. @@ -735,16 +712,13 @@ export default class PodNotes extends Plugin implements IPodNotes { private publishDocument(doc: PersistedPodNotesDataV2): void { const decoded = decodePodNotesData(doc); + // In place, so code that holds plugin.settings across a save (an import) sees the + // merge. A sync leaves the player alone: a synced selection is the next startup's. const settings = this.settings as unknown as Record; for (const key of Object.keys(settings)) delete settings[key]; Object.assign(settings, decoded.settings); this.persistenceUnknownFields = decoded.unknownFields; this.setCollectionStores(); - // Memory keeps naming the episode loaded here, and the next save writes it. With - // nothing loaded, the synced episode loads, as it would at startup. - const loaded = get(currentEpisode); - if (loaded) this.settings.currentEpisode = loaded; - else if (this.settings.currentEpisode) currentEpisode.set(this.settings.currentEpisode); } // Every data.json write goes through here, so no path can write while writes are refused. diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 8bb0813f..9c2638a7 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -219,9 +219,7 @@ export class PlayerController { const clamped = Math.min(1, Math.max(0, volume)); this.el.volume = clamped; this.setState({ volume: clamped }); - if (this.plugin.settings.defaultVolume !== clamped) { - writePlayerSettings(this.plugin, { defaultVolume: clamped }); - } + writePlayerSettings(this.plugin, { defaultVolume: clamped }); } /** Saves the position now rather than at the next checkpoint. */ diff --git a/src/store/index.test.ts b/src/store/index.test.ts index 2ab0c304..d2377792 100644 --- a/src/store/index.test.ts +++ b/src/store/index.test.ts @@ -13,21 +13,16 @@ import { } from "src/constants"; import { bindStoresToSettings } from "src/store/persistence"; import { - currentEpisode, - currentTime, dedupeEpisodesByKey, downloadedEpisodes, - duration, episodeCache, episodeListLimit, latestEpisodes, localFiles, playedEpisodes, - plugin, queue, reorderEpisodes, sanitizeEpisodeListLimit, - subscribeQueueToCurrentEpisode, } from "./index"; const episode: Episode = { @@ -459,40 +454,62 @@ describe("queue store", () => { expect(queueTitles()).toEqual(["A", "B", "C"]); }); - test("moveToTop / moveToBottom reorder the queue", () => { + test("move takes an episode to the top or the bottom", () => { setQueue("A", "B", "C"); - queue.moveToBottom(0); + queue.move(ep("A"), "bottom"); expect(queueTitles()).toEqual(["B", "C", "A"]); - queue.moveToTop(2); + queue.move(ep("A"), "top"); expect(queueTitles()).toEqual(["A", "B", "C"]); }); - test("moveUp / moveDown shift a single position", () => { + test("move shifts an episode up or down one place", () => { setQueue("A", "B", "C"); - queue.moveDown(0); + queue.move(ep("A"), "down"); expect(queueTitles()).toEqual(["B", "A", "C"]); - queue.moveUp(2); + queue.move(ep("C"), "up"); expect(queueTitles()).toEqual(["B", "C", "A"]); }); - test("end and out-of-range moves are no-ops", () => { + test("move finds the episode itself, not a same-titled one from another podcast (#356)", () => { + queue.set({ + ...QUEUE_SETTINGS, + episodes: [ep("Trailer", "Pod A"), ep("Trailer", "Pod B")], + }); + + queue.move(ep("Trailer", "Pod B"), "top"); + + expect(get(queue).episodes.map((e) => e.podcastName)).toEqual(["Pod B", "Pod A"]); + }); + + test("a move past an end, or of an episode not queued, changes and saves nothing", () => { setQueue("A", "B", "C"); + let writes = 0; + const unsubscribe = queue.subscribe(() => writes++); - queue.moveUp(0); - queue.moveDown(2); - queue.move(-1, 0); - queue.move(0, 99); + queue.move(ep("A"), "up"); + queue.move(ep("C"), "down"); + queue.move(ep("Z"), "top"); + unsubscribe(); expect(queueTitles()).toEqual(["A", "B", "C"]); + expect(writes).toBe(1); + }); + + test("prepend puts an episode first unless it is already queued", () => { + setQueue("A", "B"); + + queue.prepend(ep("C")); + queue.prepend(ep("B")); + + expect(queueTitles()).toEqual(["C", "A", "B"]); }); test("a move persists the new order through the settings binding", () => { setQueue("A", "B", "C"); - currentEpisode.set(undefined as unknown as Episode, false); const fakePlugin = { settings: { queue: { ...QUEUE_SETTINGS, episodes: [] } }, @@ -502,7 +519,7 @@ describe("queue store", () => { const unbind = bindStoresToSettings(fakePlugin); try { - queue.moveToBottom(0); + queue.move(ep("A"), "bottom"); expect(fakePlugin.settings.queue.episodes.map((e) => e.title)).toEqual(["B", "C", "A"]); expect(fakePlugin.saveSettings).toHaveBeenCalled(); @@ -510,101 +527,6 @@ describe("queue store", () => { unbind(); } }); - - test("subscribeQueueToCurrentEpisode drops the now-playing episode", () => { - setQueue("A", "B", "C"); - currentEpisode.set(undefined as unknown as Episode, false); - - const unsubscribe = subscribeQueueToCurrentEpisode(); - - try { - // Switching to B (without auto-enqueuing the prior episode) removes B - // from the up-next queue while leaving the rest in place. - currentEpisode.set(ep("B"), false); - - expect(queueTitles()).toEqual(["A", "C"]); - } finally { - unsubscribe(); - } - }); - - test("playing an episode keeps a same-titled episode from another podcast queued (#356)", () => { - queue.set({ - ...QUEUE_SETTINGS, - episodes: [ep("Trailer", "Pod A"), ep("Trailer", "Pod B")], - }); - currentEpisode.set(undefined as unknown as Episode, false); - - const unsubscribe = subscribeQueueToCurrentEpisode(); - - try { - currentEpisode.set(ep("Trailer", "Pod A"), false); - - expect(get(queue).episodes.map((e) => e.podcastName)).toEqual(["Pod B"]); - } finally { - unsubscribe(); - } - }); -}); - -describe("queue automation toggle (issue #108)", () => { - beforeEach(() => { - queue.set({ ...QUEUE_SETTINGS, episodes: [] }); - currentEpisode.set(undefined as unknown as Episode, false); - }); - - afterEach(() => { - // Reset the shared module singletons so other suites keep the default - // (enabled) behavior that an unset plugin store implies. - plugin.set(undefined as never); - currentEpisode.set(undefined as unknown as Episode, false); - }); - - function setAutoQueue(value: boolean) { - plugin.set({ settings: { autoQueue: value } } as never); - } - - test("enqueues the episode you switch away from when enabled", () => { - setAutoQueue(true); - currentEpisode.set(ep("A")); - currentEpisode.set(ep("B")); - - expect(queueTitles()).toEqual(["A"]); - }); - - test("does not enqueue the previous episode when disabled", () => { - setAutoQueue(false); - currentEpisode.set(ep("A")); - currentEpisode.set(ep("B")); - - expect(queueTitles()).toEqual([]); - }); - - test("treats an unset plugin store as enabled (historical default)", () => { - currentEpisode.set(ep("A")); - currentEpisode.set(ep("B")); - - expect(queueTitles()).toEqual(["A"]); - }); - - test("playNext advances to the front of the queue when enabled", () => { - setAutoQueue(true); - setQueue("A", "B"); - - queue.playNext(); - - expect(get(currentEpisode)?.title).toBe("A"); - expect(queueTitles()).toEqual(["B"]); - }); - - test("playNext is a no-op when disabled", () => { - setAutoQueue(false); - setQueue("A", "B"); - - queue.playNext(); - - expect(queueTitles()).toEqual(["A", "B"]); - }); }); describe("sanitizeEpisodeListLimit (issue #114)", () => { @@ -751,42 +673,3 @@ describe("latestEpisodes respects episodeListLimit (issue #114)", () => { tracker.stop(); }); }); - -describe("currentEpisode.set finished guard (issue #94)", () => { - beforeEach(() => { - playedEpisodes.set({}); - currentTime.set(0); - duration.set(0); - currentEpisode.set(undefined as unknown as Episode, false); - }); - - afterEach(() => { - currentTime.set(0); - duration.set(0); - currentEpisode.set(undefined as unknown as Episode, false); - }); - - test("does not persist a never-played episode as finished when switched away at unknown (0) duration", () => { - // The player resets currentTime/duration to 0 the instant the episode - // changes (#94). Rapidly switching A -> B -> C before B's metadata loads - // reads ct === dur === 0; that must not mark the skipped episode finished. - currentEpisode.set(ep("A"), false); - currentTime.set(0); - duration.set(0); - currentEpisode.set(ep("B"), false); - - const stored = get(playedEpisodes)["Pod::A"]; - expect(stored?.finished).toBe(false); - }); - - test("still persists a genuinely finished episode as finished", () => { - currentEpisode.set(ep("A"), false); - currentTime.set(3600); - duration.set(3600); - currentEpisode.set(ep("B"), false); - - const stored = get(playedEpisodes)["Pod::A"]; - expect(stored?.finished).toBe(true); - expect(stored?.time).toBe(3600); - }); -}); diff --git a/src/store/index.ts b/src/store/index.ts index 254a824e..b5dfaf3f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,4 @@ -import { get, writable, type Unsubscriber } from "svelte/store"; +import { get, writable } from "svelte/store"; import type PodNotes from "src/main"; import type { Episode } from "src/types/Episode"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; @@ -6,9 +6,7 @@ import type { Playlist } from "src/types/Playlist"; import { ViewState } from "src/types/ViewState"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { LocalEpisode } from "src/types/LocalEpisode"; -import type { PlaybackSegment } from "src/types/PlaybackSegment"; import { LOCAL_FILES_SETTINGS } from "src/constants"; -import { DEFAULT_PLAYBACK_RATE } from "src/utility/playbackRate"; import { getEpisodeKey, isSameEpisode } from "src/utility/episodeKey"; import { getPlayedEpisode, getPlayedEpisodeAliasKeys } from "src/utility/episodeStatus"; @@ -25,50 +23,8 @@ export { export { downloadedEpisodes } from "./downloads"; export const plugin = writable(); -export const currentTime = writable(0); -export const requestedPlaybackTime = writable<{ - episodeKey: string; - time: number; - endTime?: number; -} | null>(null); -export const activePlaybackSegment = writable(null); -export const duration = writable(0); -export const playbackRate = writable(DEFAULT_PLAYBACK_RATE); -export const volume = writable(1); export const hidePlayedEpisodes = writable(false); -export const currentEpisode = (() => { - const store = writable(); - const { subscribe, update } = store; - - return { - subscribe, - update, - set: (newEpisode: Episode, addPrevToQueue = true) => { - update((previousEpisode) => { - if (previousEpisode) { - if (addPrevToQueue) { - addEpisodeToQueue(previousEpisode); - } - - const ct = get(currentTime); - const dur = get(duration); - // A zero/unknown duration is never a finished episode. The player - // resets currentTime/duration to 0 the instant the episode changes - // (issue #94); if the user switches away again before the next - // episode's metadata loads, ct === dur === 0 would otherwise persist - // a never-played episode as finished at 0:00 and surface it as played. - const isFinished = dur > 0 && ct === dur; - playedEpisodes.setEpisodeTime(previousEpisode, ct, dur, isFinished); - } - - return newEpisode; - }); - }, - }; -})(); - -export const isPaused = writable(true); export const playedEpisodes = (() => { const store = writable<{ [key: string]: PlayedEpisode }>({}); const { subscribe, update, set } = store; @@ -227,6 +183,8 @@ export function dedupeEpisodesByKey(episodes: Episode[] = []): Episode[] { return result; } +export type QueueMove = "top" | "up" | "down" | "bottom"; + export const queue = (() => { const store = writable({ icon: "list-ordered", @@ -243,19 +201,6 @@ export const queue = (() => { const isAlreadyQueued = (episodes: Episode[], episode: Episode) => episodes.some((e) => isSameEpisode(e, episode)); - function move(from: number, to: number) { - const { episodes } = get(store); - const reordered = reorderEpisodes(episodes, from, to); - - // No-op move: skip the store write so we don't churn saveSettings. - if (reordered === episodes) return; - - update((queue) => { - queue.episodes = reordered; - return queue; - }); - } - return { subscribe, update, @@ -289,27 +234,23 @@ export const queue = (() => { return queue; }); }, - playNext: () => { - // Auto-advance is part of queue automation (issue #108): when the user - // has turned the queue off, finishing an episode must not pull the next - // one in. The manual queue is left intact for when they re-enable it. - if (!autoQueueEnabled()) return; - - update((queue) => { - const nextEp = queue.episodes.shift(); + /** Finds the episode when the move applies, so a queue that changed since cannot move another. */ + move: (episode: Episode, to: QueueMove) => { + const { episodes } = get(store); + const from = episodes.findIndex((e) => isSameEpisode(e, episode)); + const target = { top: 0, up: from - 1, down: from + 1, bottom: episodes.length - 1 }[ + to + ]; + const reordered = reorderEpisodes(episodes, from, target); - if (nextEp) { - currentEpisode.set(nextEp, false); - } + // No-op move: skip the store write so we don't churn saveSettings. + if (reordered === episodes) return; + update((queue) => { + queue.episodes = reordered; return queue; }); }, - move, - moveUp: (index: number) => move(index, index - 1), - moveDown: (index: number) => move(index, index + 1), - moveToTop: (index: number) => move(index, 0), - moveToBottom: (index: number) => move(index, get(store).episodes.length - 1), }; })(); @@ -434,57 +375,3 @@ export const viewState = (() => { }, }; })(); - -/** - * Whether the queue's automatic behavior is enabled (issue #108). Gates both - * auto-population (enqueuing the episode you switch away from) and auto-advance - * (queue.playNext on episode end). Reads the live plugin setting on each call so - * toggling takes effect without a reload. Defaults to enabled so a missing - * setting or an uninitialised plugin store preserves the historical behavior. - */ -function autoQueueEnabled(): boolean { - return get(plugin)?.settings?.autoQueue !== false; -} - -/** - * Drops the current episode from the queue whenever it changes — an episode you - * are now playing should not also sit in the up-next queue. Subscribes to - * currentEpisode and returns an unsubscriber for lifecycle cleanup. - * - * This is queue automation, not persistence: it formerly lived inside - * QueueController, which conflated the two. Unlike auto-population/advance it is - * intentionally NOT gated on autoQueueEnabled — a manually started episode is - * removed from the queue regardless of the automation toggle. - */ -export function subscribeQueueToCurrentEpisode(): Unsubscriber { - return currentEpisode.subscribe((episode) => { - if (!episode) return; - - const { episodes } = get(queue); - const episodeIsInQueue = episodes.some((e) => isSameEpisode(e, episode)); - if (!episodeIsInQueue) return; - - queue.update((playlist) => { - playlist.episodes = playlist.episodes.filter((e) => !isSameEpisode(e, episode)); - return playlist; - }); - }); -} - -function addEpisodeToQueue(episode: Episode) { - // Gate at the operation, not the call site, so no future caller can bypass it. - if (!autoQueueEnabled()) return; - - queue.update((playlist) => { - // Keep the queue unique: a previously-played episode that is already queued - // stays in place rather than being re-prepended. - if (playlist.episodes.some((e) => isSameEpisode(e, episode))) { - return playlist; - } - - const newEpisodes = [episode, ...playlist.episodes]; - playlist.episodes = newEpisodes; - - return playlist; - }); -} diff --git a/src/store/persistence.ts b/src/store/persistence.ts index c5e4b3c9..3f74c96a 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -3,7 +3,6 @@ import { FAVORITES_SETTINGS, LOCAL_FILES_SETTINGS, QUEUE_SETTINGS } from "src/co import type { IPodNotes } from "src/types/IPodNotes"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { - currentEpisode, downloadedEpisodes, episodeListLimit, favorites, @@ -13,7 +12,6 @@ import { playlists, queue, savedFeeds, - volume, } from "src/store"; import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; @@ -79,9 +77,6 @@ const BINDINGS: PersistenceBinding[] = [ bind(downloadedEpisodes, (settings, value) => { settings.downloadedEpisodes = value; }), - bind(currentEpisode, (settings, value) => { - settings.currentEpisode = value; - }), bind( hidePlayedEpisodes, (settings, value) => { @@ -95,11 +90,6 @@ const BINDINGS: PersistenceBinding[] = [ const IMPORT_ROLLBACK_BINDINGS: PersistenceBinding[] = [ ...BINDINGS, - // Volume changes are persisted by main.ts rather than bindStoresToSettings. - // Capture them here so an equal-to-candidate event cannot be lost on rollback. - bind(volume, (settings, value) => { - if (Number.isFinite(value)) settings.defaultVolume = Math.min(1, Math.max(0, value)); - }), // The limit control owns both this store and the persisted setting. It lives // outside BINDINGS because changing it also rebuilds the Latest Episodes list. bind(episodeListLimit, (settings, value) => { @@ -129,12 +119,13 @@ export interface PersistedStoreChangeReplay { } /** - * Record authoritative store emissions while an import candidate is being - * written. Replaying only stores that emitted avoids stale snapshots and solves - * the ABA case where the new store value already equals the import candidate, - * so the normal persistence binding intentionally performs no settings write. + * Record authoritative store emissions and the player's settings writes while + * an import candidate is being written. Replaying only what changed avoids stale + * 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. * - * playbackRate is deliberately absent. That store is the current episode's + * 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. */ diff --git a/src/types/PlaybackSegment.ts b/src/types/PlaybackSegment.ts deleted file mode 100644 index 0fde21c2..00000000 --- a/src/types/PlaybackSegment.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface PlaybackSegment { - episodeKey: string; - startTime: number; - endTime: number; -} diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index b962c45b..80aca855 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -1,23 +1,8 @@ -
+
{#if isVideoEpisode} -
- - - +
{#if isLoading}
@@ -553,12 +126,12 @@ {/if} @@ -576,7 +149,7 @@ @@ -620,37 +193,19 @@ {/if}

- {$currentEpisode.title} + {episode.title}

- {#if !isVideoEpisode} - - {/if} -
- {formatSeconds($currentTime, "HH:mm:ss")} + {formatSeconds($state.currentTime, "HH:mm:ss")} - {formatSeconds($duration - $currentTime, "HH:mm:ss")} + {formatSeconds(Math.max(0, $state.duration - $state.currentTime), "HH:mm:ss")}
@@ -670,22 +225,30 @@
- Volume: {Math.round(playerVolume * 100)}% - + Volume: {Math.round($state.volume * 100)}% + player.setVolume(event.detail.value)} + value={$state.volume} + limits={[0, 1, 0.05]} + />
- {$playbackRate}x + {$state.playbackRate}x player.setPlaybackRate(event.detail.value)} + value={$state.playbackRate} limits={[PLAYBACK_RATE_MIN, PLAYBACK_RATE_MAX, PLAYBACK_RATE_STEP]} />
- + player.seek(event.detail.time)} + /> {#if showQueue} { - currentEpisode.set(testEpisode); - currentTime.set(0); - duration.set(3600); - isPaused.set(true); - activePlaybackSegment.set(null); - playbackRate.set(1); - playedEpisodes.set({}); - requestedPlaybackTime.set(null); - downloadedEpisodes.set({}); - playlists.set({}); - queue.set({ - icon: "list-ordered", - name: "Queue", - episodes: [], - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - }); - HTMLMediaElement.prototype.play = vi.fn(() => Promise.resolve()); - HTMLMediaElement.prototype.pause = vi.fn(); +/** The view over a player whose state the test sets, recording the intents it receives. */ +function renderPlayer(patch: Partial = {}) { + const state = writable({ + status: "playing", + episode, + mediaType: "audio", + currentTime: 0, + duration: 3600, + playbackRate: 1, + volume: 1, + ...patch, + }); + const player = { + state, + play: vi.fn(), + seek: vi.fn(), + toggle: vi.fn(), + setVolume: vi.fn(), + setPlaybackRate: vi.fn(), + videoStage: vi.fn(() => ({ destroy: vi.fn() })), + }; plugin.set({ - settings: { - defaultPlaybackRate: 1, - }, - flushSaves: vi.fn(() => Promise.resolve()), - checkpoint: vi.fn((update: () => void) => update()), - api: { - skipBackward: vi.fn(), - skipForward: vi.fn(), - }, + settings: structuredClone(DEFAULT_SETTINGS), + api: { skipBackward: vi.fn(), skipForward: vi.fn() }, + player, } as never); -}); - -afterEach(() => { - (globalThis as { app?: unknown }).app = undefined; -}); - -function makeTFile(path: string): TFile { - const file = new TFile(); - const dot = path.lastIndexOf("."); - const slash = path.lastIndexOf("/"); - Object.assign(file as unknown as Record, { - path, - extension: dot > slash ? path.slice(dot + 1) : "", - basename: path.slice(slash + 1, dot > slash ? dot : undefined), - }); - return file; + return { ...render(EpisodePlayer), state, player }; } -function mockVaultFile(path: string): void { - const file = makeTFile(path); - const vault = { - getAbstractFileByPath: vi.fn((candidate: string) => (candidate === path ? file : null)), - getResourcePath: vi.fn(() => `app://resource/${path}?token`), - }; - // The player reads the vault off the plugin's app reference ($plugin.app), - // so expose it there (merging onto the base mock set in beforeEach). - plugin.update( - (current) => - ({ - ...(current as unknown as Record), - app: { vault }, - }) as never, - ); -} - -describe("EpisodePlayer", () => { - test("uses requested timestamp before restored played progress", async () => { - playedEpisodes.markAsPlayed(testEpisode); - requestedPlaybackTime.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 240, - }); +const labels = (container: HTMLElement) => + Array.from(container.querySelectorAll(".status-container span")).map((s) => s.textContent); +const barWidth = (container: HTMLElement) => + (container.querySelector(".progress__bar") as HTMLElement).style.width; - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; +beforeEach(() => { + queue.set({ ...structuredClone(DEFAULT_SETTINGS.queue), episodes: [] }); + viewState.set(ViewState.Player); +}); - await fireEvent.loadedMetadata(audio); +describe("EpisodePlayer", () => { + test("holds no media element: playback outlives the view", () => { + const { container } = renderPlayer(); - expect(get(currentTime)).toBe(240); - expect(get(isPaused)).toBe(false); - expect(get(requestedPlaybackTime)).toBeNull(); + expect(container.querySelector("audio, video")).toBeNull(); }); - test("shows an error state instead of a permanent spinner when media fails to load", async () => { - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; + test("shows a spinner while loading, then an error instead when the media fails", async () => { + const { container, state } = renderPlayer({ status: "loading" }); + expect(container.querySelector(".podcast-artwork-isloading-overlay")).not.toBeNull(); - // A blocked/dead stream URL fires error, never loadedmetadata. - await fireEvent.error(audio); + state.update((s) => ({ ...s, status: "error" })); + await Promise.resolve(); expect(container.querySelector(".podcast-artwork-isloading-overlay")).toBeNull(); - const error = container.querySelector(".podcast-artwork-load-error"); - expect(error).not.toBeNull(); - expect(error?.textContent).toContain("Could not load"); - }); - - test("does not persist a pre-restore position when media fails to load", async () => { - // Seed a saved resume point; a failed load must not overwrite it with 0. - playedEpisodes.setEpisodeTime(testEpisode, 1234, 3600, false); - const { container, unmount } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - - await fireEvent.error(audio); - // Destroy fires persistPlaybackPosition; the guard must keep it closed. - unmount(); - - const saved = get(playedEpisodes)[`${testEpisode.podcastName}::${testEpisode.title}`]; - expect(saved?.time).toBe(1234); - }); - - test("clears the error state when the next episode loads", async () => { - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.error(audio); - expect(container.querySelector(".podcast-artwork-load-error")).not.toBeNull(); - - currentEpisode.set({ ...testEpisode, title: "Next Episode" }); - await waitFor(() => { - expect(container.querySelector(".podcast-artwork-isloading-overlay")).not.toBeNull(); - }); - await fireEvent.loadedMetadata(container.querySelector("audio") as HTMLAudioElement); - expect(container.querySelector(".podcast-artwork-load-error")).toBeNull(); + expect(container.querySelector(".podcast-artwork-load-error")?.textContent).toContain( + "Could not load this episode's media.", + ); }); - test("arms a requested segment after metadata loads", async () => { - requestedPlaybackTime.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 240, - endTime: 260, + test("shows a restored episode paused at its saved position", () => { + const { container } = renderPlayer({ + status: "restored", + currentTime: 1800, + duration: 3600, }); - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - - await fireEvent.loadedMetadata(audio); - - expect(get(currentTime)).toBe(240); - expect(get(isPaused)).toBe(false); - expect(get(requestedPlaybackTime)).toBeNull(); - expect(get(activePlaybackSegment)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - startTime: 240, - endTime: 260, - }); - }); - - test("stops playback at the active segment end", async () => { - requestedPlaybackTime.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 115, - endTime: 125, - }); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - - currentTime.set(126); - isPaused.set(false); - await fireEvent.timeUpdate(audio); - - expect(get(currentTime)).toBe(125); - expect(get(isPaused)).toBe(true); - expect(get(activePlaybackSegment)).toBeNull(); - }); - - test("does not persist preview segment progress over saved listening progress", async () => { - const episodeKey = `${testEpisode.podcastName}::${testEpisode.title}`; - playedEpisodes.setEpisodeTime(testEpisode, 1000, 3600, false); - requestedPlaybackTime.set({ - episodeKey, - time: 115, - endTime: 125, - }); - - const { container, unmount } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - - currentTime.set(120); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(1000); - - currentTime.set(126); - await fireEvent.timeUpdate(audio); - await fireEvent.pause(audio); - unmount(); - - expect(get(currentTime)).toBe(125); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(1000); - }); - - test("manual seeks clear the active segment", async () => { - requestedPlaybackTime.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 115, - endTime: 125, - }); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - duration.set(3600); - - const progress = container.querySelector(".progress") as HTMLElement; - await fireEvent.keyDown(progress, { key: "End" }); - - expect(get(currentTime)).toBe(3600); - expect(get(activePlaybackSegment)).toBeNull(); - }); - - test("a segment ending at media end pauses instead of marking the episode played", async () => { - const episodeKey = `${testEpisode.podcastName}::${testEpisode.title}`; - requestedPlaybackTime.set({ - episodeKey, - time: 3590, - endTime: 3600, - }); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - - currentTime.set(3600); - await fireEvent.ended(audio); - - expect(get(currentTime)).toBe(3600); - expect(get(isPaused)).toBe(true); - expect(get(activePlaybackSegment)).toBeNull(); - expect(get(playedEpisodes)[episodeKey]).toBeUndefined(); - }); - - test("on episode end only removes the matching episode from playlists, keeping same-titled episodes from other podcasts (PB-07)", async () => { - const otherPodcastSameTitle: Episode = { - ...testEpisode, - podcastName: "Other Podcast", - streamUrl: "https://other.example.com/audio.mp3", - }; - playlists.set({ - "My List": { - icon: "list-ordered", - name: "My List", - episodes: [testEpisode, otherPodcastSameTitle], - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - }, - }); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - - currentTime.set(3600); - await fireEvent.ended(audio); - - const remaining = get(playlists)["My List"].episodes; - expect(remaining).toHaveLength(1); - expect(remaining[0].podcastName).toBe("Other Podcast"); + expect(labels(container)).toEqual(["00:30:00", "00:30:00"]); + expect(barWidth(container)).toBe("50%"); + expect(container.querySelector(".podcast-artwork-overlay.visible")).not.toBeNull(); + expect(container.querySelector(".podcast-artwork-isloading-overlay")).toBeNull(); }); - test("ignores stale requested timestamp for a different episode", async () => { - playedEpisodes.setEpisodeTime(testEpisode, 1800, 3600, false); - requestedPlaybackTime.set({ - episodeKey: "Other Podcast::Other Episode", - time: 240, - }); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; + test("shows the next episode's own progress, not the last one's end (#94)", async () => { + const { container, state } = renderPlayer({ currentTime: 3600, duration: 3600 }); + expect(barWidth(container)).toBe("100%"); - await fireEvent.loadedMetadata(audio); + state.update((s) => ({ + ...s, + status: "loading", + episode: { ...episode, title: "Next" }, + currentTime: 0, + duration: 0, + })); + await Promise.resolve(); - expect(get(currentTime)).toBe(1800); - expect(get(isPaused)).toBe(false); - expect(get(requestedPlaybackTime)).toBeNull(); + expect(barWidth(container)).toBe("0%"); + expect(labels(container)).toEqual(["00:00:00", "00:00:00"]); }); - test("does not restore a title-only saved position from a different podcast's same-titled episode (PB-06)", async () => { - // Legacy title-only entry that actually belongs to another podcast. - playedEpisodes.set({ - [testEpisode.title]: { - title: testEpisode.title, - podcastName: "A Different Podcast", - time: 1800, - duration: 3600, - finished: false, - }, - }); + test("clicking the artwork toggles playback", async () => { + const { container, player } = renderPlayer(); - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); + await fireEvent.click(container.querySelector(".hover-container") as HTMLElement); - // The current episode is "Test Podcast" — the other podcast's saved - // position must be ignored, resuming from 0 instead of 1800. - expect(get(currentTime)).toBe(0); + expect(player.toggle).toHaveBeenCalledTimes(1); }); - test("restores a legacy title-only saved position for the same episode (PB-06)", async () => { - // Legacy entry with no podcastName recorded — must still resume. - playedEpisodes.set({ - [testEpisode.title]: { - title: testEpisode.title, - podcastName: "", - time: 900, - duration: 3600, - finished: false, - }, - }); + test("the End key on the progress bar seeks to the end", async () => { + const { container, player } = renderPlayer({ currentTime: 100, duration: 3600 }); - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); + await fireEvent.keyDown(container.querySelector(".progress") as HTMLElement, { + key: "End", }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - expect(get(currentTime)).toBe(900); + expect(player.seek).toHaveBeenCalledWith(3600); }); - test("keeps the audio element and slider label in sync with playbackRate store", async () => { - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - - playbackRate.set(1.7); + test("the rate label shows the rate, and its slider sets it", async () => { + const { container, player } = renderPlayer({ playbackRate: 1.7 }); - await waitFor(() => { - expect(audio.playbackRate).toBe(1.7); - }); expect(container.querySelector(".playbackrate-container span")?.textContent).toBe("1.7x"); - }); - - test("updates playbackRate store from the playback-rate slider", async () => { - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - - const sliders = Array.from(container.querySelectorAll("input[type='range']")); - const rateSlider = sliders[sliders.length - 1] as HTMLInputElement; - rateSlider.value = "2.2"; - await fireEvent.input(rateSlider); - - expect(get(playbackRate)).toBe(2.2); - }); - - test("renders video episodes with a video media element and shared playback bindings", async () => { - const videoEpisode: Episode = { - ...testEpisode, - title: "Video Episode", - streamUrl: "https://pod.example.com/video.mp4", - mediaType: "video", - }; - const episodeKey = `${videoEpisode.podcastName}::${videoEpisode.title}`; - currentEpisode.set(videoEpisode); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("video")).not.toBeNull(); - }); - const video = container.querySelector("video") as HTMLVideoElement; - - expect(container.querySelector("audio")).toBeNull(); - expect(video.getAttribute("src")).toBe(videoEpisode.streamUrl); - - await fireEvent.loadedMetadata(video); - expect(get(isPaused)).toBe(false); + const slider = container.querySelector(".playbackrate-container input") as HTMLInputElement; + await fireEvent.input(slider, { target: { value: "2.2" } }); - playbackRate.set(1.5); - await waitFor(() => { - expect(video.playbackRate).toBe(1.5); - }); - - currentTime.set(42); - await fireEvent.timeUpdate(video); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(42); - }); - - test("does not reuse a same-title downloaded audio file for a video episode", async () => { - downloadedEpisodes.set({ - [testEpisode.podcastName]: [ - { - ...testEpisode, - title: "Video Episode", - streamUrl: "https://pod.example.com/audio.mp3", - filePath: "Downloads/audio.mp3", - size: 10, - }, - ], - }); - const videoEpisode: Episode = { - ...testEpisode, - title: "Video Episode", - streamUrl: "https://pod.example.com/video.mp4", - mediaType: "video", - }; - currentEpisode.set(videoEpisode); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("video")).not.toBeNull(); - }); - const video = container.querySelector("video") as HTMLVideoElement; - - expect(container.querySelector("audio")).toBeNull(); - expect(video.getAttribute("src")).toBe(videoEpisode.streamUrl); - }); - - test("uses a legacy downloaded audio mp4 file for an explicitly audio feed episode", async () => { - mockVaultFile("Downloads/legacy-audio.mp4"); - const audioEpisode: Episode = { - ...testEpisode, - title: "Legacy Audio MP4", - streamUrl: "https://cdn.example.com/episode.mp4", - mediaType: "audio", - }; - downloadedEpisodes.set({ - [testEpisode.podcastName]: [ - { - ...testEpisode, - title: "Legacy Audio MP4", - streamUrl: "https://cdn.example.com/episode.mp4", - filePath: "Downloads/legacy-audio.mp4", - size: 10, - }, - ], - }); - currentEpisode.set(audioEpisode); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - - expect(container.querySelector("video")).toBeNull(); - expect(audio.getAttribute("src")).toBe("app://resource/Downloads/legacy-audio.mp4?token"); + expect(player.setPlaybackRate).toHaveBeenCalledWith(2.2); }); - test("uses a legacy downloaded video mp4 file for an explicitly video feed episode", async () => { - mockVaultFile("Downloads/legacy-video.mp4"); - const videoEpisode: Episode = { - ...testEpisode, - title: "Legacy Video MP4", - streamUrl: "https://cdn.example.com/episode.mp4", - mediaType: "video", - }; - downloadedEpisodes.set({ - [testEpisode.podcastName]: [ - { - ...testEpisode, - title: "Legacy Video MP4", - streamUrl: "https://cdn.example.com/episode.mp4", - filePath: "Downloads/legacy-video.mp4", - size: 10, - }, - ], - }); - currentEpisode.set(videoEpisode); + test("the volume label shows the volume, and its slider sets it", async () => { + const { container, player } = renderPlayer({ volume: 0.4 }); - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("video")).not.toBeNull(); - }); - const video = container.querySelector("video") as HTMLVideoElement; + expect(container.querySelector(".volume-container span")?.textContent).toBe("Volume: 40%"); + const slider = container.querySelector(".volume-container input") as HTMLInputElement; + await fireEvent.input(slider, { target: { value: "0.65" } }); - expect(container.querySelector("audio")).toBeNull(); - expect(video.getAttribute("src")).toBe("app://resource/Downloads/legacy-video.mp4?token"); + expect(player.setVolume).toHaveBeenCalledWith(0.65); }); - test("uses matching downloaded video metadata to classify extensionless records", async () => { - mockVaultFile("Downloads/video.mp4"); - const extensionlessEpisode: Episode = { - ...testEpisode, - title: "Extensionless Video", - streamUrl: "https://cdn.example.com/watch?id=42", - }; - downloadedEpisodes.set({ - [testEpisode.podcastName]: [ - { - ...extensionlessEpisode, - filePath: "Downloads/video.mp4", - mediaType: "video", - size: 10, - }, - ], - }); - currentEpisode.set(extensionlessEpisode); + test("clicking a queued episode plays it", async () => { + const queued = { ...episode, title: "Queued Episode" }; + queue.set({ ...get(queue), episodes: [queued] }); + viewState.set(ViewState.EpisodeList); + const { player } = renderPlayer(); - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("video")).not.toBeNull(); - }); - const video = container.querySelector("video") as HTMLVideoElement; + await fireEvent.click(screen.getByText("Queued Episode")); - expect(container.querySelector("audio")).toBeNull(); - expect(video.getAttribute("src")).toBe("app://resource/Downloads/video.mp4?token"); + expect(player.play).toHaveBeenCalledWith(queued); + expect(get(viewState)).toBe(ViewState.Player); }); -}); -describe("EpisodePlayer — persists playback position during playback (issue #33)", () => { - const episodeKey = `${testEpisode.podcastName}::${testEpisode.title}`; - - afterEach(() => { - vi.restoreAllMocks(); - }); - - async function renderLoadedPlayer() { - const { container, unmount } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - // loadedmetadata flips isLoading off and runs the restore, after which - // progress is allowed to persist. - await fireEvent.loadedMetadata(audio); - return Object.assign(audio, { unmount }); - } - - test("saves position on timeupdate, throttling rapid updates", async () => { - let now = 1_000_000; - vi.spyOn(Date, "now").mockImplementation(() => now); - - const audio = await renderLoadedPlayer(); - - currentTime.set(120); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(120); - - // A second update within the throttle window must not overwrite it. - now += 2000; - currentTime.set(121); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(120); - - // Past the throttle window, the latest position is persisted. - now += 4000; - currentTime.set(200); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(200); - - // Boundary: an update exactly SAVE_POSITION_THROTTLE_MS after the last save - // is not throttled (the guard uses a strict <), so it persists. - now += 5000; - currentTime.set(260); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(260); - // Periodic positions are checkpoints, which data.json takes at most every 10 s. - expect(get(plugin).checkpoint).toHaveBeenCalledTimes(3); - }); - - test("saves the exact position immediately on pause, bypassing the throttle", async () => { - let now = 5_000_000; - vi.spyOn(Date, "now").mockImplementation(() => now); - - const audio = await renderLoadedPlayer(); - - // Consume the leading-edge save so the throttle window is now active. - currentTime.set(50); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(50); - - // A timeupdate within the throttle window is ignored... - now += 1000; - currentTime.set(60); - await fireEvent.timeUpdate(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(50); - - // ...but a pause within the same window must still persist immediately. - currentTime.set(137); - await fireEvent.pause(audio); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(137); - }); - - test("saves the position when the player closes, at once rather than as a checkpoint", async () => { - const audio = await renderLoadedPlayer(); - currentTime.set(42); - - audio.unmount(); - - expect(get(playedEpisodes)[episodeKey]?.time).toBe(42); - expect(get(plugin).checkpoint).not.toHaveBeenCalled(); - }); - - test("does not clobber the saved position before metadata has loaded", async () => { - playedEpisodes.setEpisodeTime(testEpisode, 1800, 3600, false); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - - // No loadedmetadata yet: isLoading is still true and currentTime is the - // pre-restore 0. Neither a pause nor a timeupdate may overwrite 1800. - currentTime.set(0); - await fireEvent.pause(audio); - await fireEvent.timeUpdate(audio); - - expect(get(playedEpisodes)[episodeKey]?.time).toBe(1800); - }); - - test("re-arms the load guard on episode switch so the next episode's saved position is not clobbered", async () => { - const episodeB: Episode = { - title: "Episode B", - streamUrl: "https://pod.example.com/b.mp3", - url: "https://pod.example.com/b", - description: "", - content: "", - podcastName: "Test Podcast", - }; - const keyB = `${episodeB.podcastName}::${episodeB.title}`; - // B already has a saved resume position from a previous listen. - playedEpisodes.setEpisodeTime(episodeB, 1800, 3600, false); - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - await fireEvent.loadedMetadata(container.querySelector("audio") as HTMLAudioElement); - - // Switch to B in-player (queue click / auto-advance reuse this instance). - // The re-arm must set isLoading=true until B's metadata loads. - currentEpisode.set(episodeB); - await waitFor(() => { - const next = container.querySelector("audio"); - expect(next?.getAttribute("src")).toBe(episodeB.streamUrl); - }); - const audioB = container.querySelector("audio") as HTMLAudioElement; - - // A pause/timeupdate during B's src swap, before its loadedmetadata and at - // the pre-restore 0, must not overwrite B's saved 1800. - currentTime.set(0); - await fireEvent.pause(audioB); - await fireEvent.timeUpdate(audioB); - - expect(get(playedEpisodes)[keyB]?.time).toBe(1800); - }); - - test("resets progress immediately on episode switch so the finished episode's bar is not shown against the next (issue #94)", async () => { - const episodeB: Episode = { - title: "Episode B", - streamUrl: "https://pod.example.com/b.mp3", - url: "https://pod.example.com/b", - description: "", - content: "", - podcastName: "Test Podcast", - }; - - const { container } = render(EpisodePlayer); - await waitFor(() => { - expect(container.querySelector("audio")).not.toBeNull(); - }); - const audio = container.querySelector("audio") as HTMLAudioElement; - await fireEvent.loadedMetadata(audio); - - // Simulate episode A finishing: progress pinned at the very end. - currentTime.set(3600); - duration.set(3600); - await fireEvent.timeUpdate(audio); - - // Auto-advance (queue.playNext) swaps the episode via currentEpisode.set. - currentEpisode.set(episodeB); - await waitFor(() => { - const next = container.querySelector("audio"); - expect(next?.getAttribute("src")).toBe(episodeB.streamUrl); - }); - - // Before B's loadedmetadata fires, the finished episode's playback position - // must be cleared so the UI renders a zeroed loading state — not A's full - // bar — for the duration of B's (network-bound) metadata fetch. - expect(get(currentTime)).toBe(0); - - // The user-visible outcome: the progress bar collapses to 0% and neither - // timestamp shows A's end or a garbled "NaN" (the bar/text must not lag the - // title/artwork switch). - const bar = container.querySelector(".progress__bar") as HTMLElement; - expect(bar.style.width).toBe("0%"); - const [elapsed, remaining] = Array.from( - container.querySelectorAll(".status-container span"), - ).map((s) => s.textContent); - expect(elapsed).toBe("00:00:00"); - expect(remaining).toBe("00:00:00"); - }); - - test("persists immediately when the app is backgrounded (visibilitychange → hidden)", async () => { - await renderLoadedPlayer(); - currentTime.set(222); - - const originalDescriptor = - Object.getOwnPropertyDescriptor(document, "visibilityState") ?? - Object.getOwnPropertyDescriptor(Document.prototype, "visibilityState"); - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => "hidden", - }); + test("a video episode gives the player a stage for its video", async () => { + const { container, player } = renderPlayer({ mediaType: "video", status: "paused" }); + const stage = container.querySelector(".episode-video-container"); - try { - document.dispatchEvent(new Event("visibilitychange")); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(222); - // Written now, not after the save delay: the OS may kill a backgrounded app. - expect(get(plugin).flushSaves).toHaveBeenCalledTimes(1); - } finally { - if (originalDescriptor) { - Object.defineProperty(document, "visibilityState", originalDescriptor); - } - } + expect(player.videoStage).toHaveBeenCalledWith(stage); + await fireEvent.click(container.querySelector(".podcast-video-overlay") as HTMLElement); + expect(player.toggle).toHaveBeenCalledTimes(1); }); }); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 85efc26a..c67fc737 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import createPodcastNote from "src/createPodcastNote"; import { clearFeedCache, setCachedEpisodes } from "src/services/FeedCacheService"; import { - currentEpisode, episodeCache, hidePlayedEpisodes, playedEpisodes, @@ -18,6 +17,9 @@ import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { ViewState } from "src/types/ViewState"; import EpisodePlayerStub from "../../../tests/mocks/EpisodePlayerStub.svelte"; +import { FakeDocument } from "../../../tests/mocks/fakeMedia"; +import { DEFAULT_SETTINGS } from "src/constants"; +import { PlayerController } from "src/player/PlayerController"; vi.mock("./EpisodePlayer.svelte", () => ({ default: EpisodePlayerStub, @@ -60,11 +62,23 @@ function resetStores() { hidePlayedEpisodes.set(false); playedEpisodes.set({}); viewState.set(ViewState.PodcastGrid); - currentEpisode.update(() => undefined as unknown as Episode); - plugin.set(undefined as never); + player = new PlayerController(new FakeDocument().asPlayerDocument(), { + settings: structuredClone(DEFAULT_SETTINGS), + app: { vault: {} as never }, + checkpoint: (update) => update(), + saveSettings: () => Promise.resolve(), + }); + setPlugin({}); clearFeedCache(); } +let player: PlayerController; +const playingEpisode = () => get(player.state).episode; + +function setPlugin(value: object) { + plugin.set({ ...value, player } as never); +} + function createNumberedEpisode(number: number): Episode { return { title: `Episode ${number}`, @@ -89,7 +103,7 @@ function createFullFeed(): Episode[] { } function enableFeedCache() { - plugin.set({ + setPlugin({ settings: { feedCache: { enabled: true, @@ -153,7 +167,7 @@ describe("PodcastView integration flow", () => { }, }, }; - plugin.set(pluginSettings as never); + setPlugin(pluginSettings); render(PodcastView); @@ -163,10 +177,10 @@ describe("PodcastView integration flow", () => { const episodeNode = await screen.findByText(testEpisode.title); await fireEvent.click(episodeNode); - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); + expect(playingEpisode()).toMatchObject({ title: testEpisode.title }); expect(get(viewState)).toBe(ViewState.Player); - const current = get(currentEpisode); + const current = playingEpisode(); expect(current).toBeDefined(); await createPodcastNote(current as Episode); @@ -225,7 +239,7 @@ describe("PodcastView integration flow", () => { }), ); - plugin.set({ + setPlugin({ settings: { feedCache: { enabled: false, @@ -319,7 +333,7 @@ describe("PodcastView integration flow", () => { finished: true, }, }); - plugin.set({ + setPlugin({ settings: { feedCache: { enabled: false, @@ -400,7 +414,7 @@ describe("issue #174 feed cache cap regression", () => { expect(screen.queryByText("Unavailable in current feeds")).not.toBeInTheDocument(); await fireEvent.click(screen.getByText(oldEpisode.title)); - expect(get(currentEpisode)).toMatchObject({ title: oldEpisode.title }); + expect(playingEpisode()).toMatchObject({ title: oldEpisode.title }); expect(get(viewState)).toBe(ViewState.Player); }); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index ffe9f74d..a4090358 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -2,7 +2,6 @@ import type { PodcastFeed } from "src/types/PodcastFeed"; import PodcastGrid from "./PodcastGrid.svelte"; import { - currentEpisode, savedFeeds, episodeCache, latestEpisodes as latestEpisodesStore, @@ -41,6 +40,8 @@ type PlayedEpisodeListEntry, } from "src/utility/episodeListEntry"; + const player = get(plugin).player; + const playerState = player.state; let feeds: PodcastFeed[] = []; let selectedFeed: PodcastFeed | null = null; let selectedPlaylist: Playlist | null = null; @@ -471,7 +472,7 @@ return; } - currentEpisode.set(episode); + player.play(episode); viewState.set(ViewState.Player); } @@ -576,11 +577,7 @@ selectedPlaylist = null; isShowingPlayedEpisodes = false; displayedEpisodeEntries = null; - // Only need to set the current episode if there isn't any. - // The current episode _is_ the front of the queue. - if (!$currentEpisode) { - currentEpisode.set($queue.episodes[0]); - } + if ($playerState.status === "empty") player.play($queue.episodes[0]); viewState.set(ViewState.Player); } else { @@ -599,7 +596,7 @@ {#if $viewState === ViewState.Player} diff --git a/src/ui/PodcastView/queueReorderMenu.test.ts b/src/ui/PodcastView/queueReorderMenu.test.ts index 648ec89d..769b12c6 100644 --- a/src/ui/PodcastView/queueReorderMenu.test.ts +++ b/src/ui/PodcastView/queueReorderMenu.test.ts @@ -36,7 +36,7 @@ describe("buildQueueReorderMenuItems", () => { expect(buildQueueReorderMenuItems(ViewState.Player, [ep("A")], ep("A"))).toEqual([]); }); - test("a middle item offers all four moves with its index", () => { + test("a middle item offers all four moves", () => { const items = buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("B")); expect(titles(items)).toEqual([ @@ -46,20 +46,17 @@ describe("buildQueueReorderMenuItems", () => { "Move to bottom of queue", ]); expect(items.map((i) => i.kind)).toEqual(["top", "up", "down", "bottom"]); - expect(items.every((i) => i.index === 1)).toBe(true); }); test("the first item omits the upward moves", () => { const items = buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("A")); expect(items.map((i) => i.kind)).toEqual(["down", "bottom"]); - expect(items.every((i) => i.index === 0)).toBe(true); }); test("the last item omits the downward moves", () => { const items = buildQueueReorderMenuItems(ViewState.Player, queueEpisodes, ep("C")); expect(items.map((i) => i.kind)).toEqual(["top", "up"]); - expect(items.every((i) => i.index === 2)).toBe(true); }); }); diff --git a/src/ui/PodcastView/queueReorderMenu.ts b/src/ui/PodcastView/queueReorderMenu.ts index e27c5019..73cb042a 100644 --- a/src/ui/PodcastView/queueReorderMenu.ts +++ b/src/ui/PodcastView/queueReorderMenu.ts @@ -1,15 +1,13 @@ +import type { QueueMove } from "src/store"; import type { Episode } from "src/types/Episode"; import { isSameEpisode } from "src/utility/episodeKey"; import type { IconType } from "src/types/IconType"; import { ViewState } from "src/types/ViewState"; -type QueueMoveKind = "top" | "up" | "down" | "bottom"; - export interface QueueReorderMenuItem { icon: IconType; title: string; - kind: QueueMoveKind; - index: number; + kind: QueueMove; } /** @@ -39,13 +37,11 @@ export function buildQueueReorderMenuItems( icon: "chevrons-up", title: "Move to top of queue", kind: "top", - index, }); items.push({ icon: "chevron-up", title: "Move up in queue", kind: "up", - index, }); } @@ -54,13 +50,11 @@ export function buildQueueReorderMenuItems( icon: "chevron-down", title: "Move down in queue", kind: "down", - index, }); items.push({ icon: "chevrons-down", title: "Move to bottom of queue", kind: "bottom", - index, }); } diff --git a/src/ui/PodcastView/spawnEpisodeContextMenu.ts b/src/ui/PodcastView/spawnEpisodeContextMenu.ts index 98ac4598..6ba1ffff 100644 --- a/src/ui/PodcastView/spawnEpisodeContextMenu.ts +++ b/src/ui/PodcastView/spawnEpisodeContextMenu.ts @@ -3,7 +3,6 @@ import createPodcastNote, { getPodcastNote, openPodcastNote } from "src/createPo import createFeedNote, { getFeedNote, openFeedNote } from "src/createFeedNote"; import downloadEpisodeWithProgessNotice, { removeDownloadedEpisode } from "src/downloadEpisode"; import { - currentEpisode, downloadedEpisodes, favorites, playedEpisodes, @@ -55,7 +54,7 @@ export default function spawnEpisodeContextMenu( .setIcon("play") .setTitle("Play") .onClick(() => { - currentEpisode.set(episode); + get(plugin).player.play(episode); viewState.set(ViewState.Player); }), ); @@ -224,30 +223,7 @@ export default function spawnEpisodeContextMenu( item .setIcon(reorderItem.icon) .setTitle(reorderItem.title) - .onClick(() => { - // Resolve the index live: the queue can shift (e.g. the - // episode ends and playNext advances it) between opening - // the menu and clicking. - const index = get(queue).episodes.findIndex((e) => - isSameEpisode(e, episode), - ); - if (index === -1) return; - - switch (reorderItem.kind) { - case "top": - queue.moveToTop(index); - break; - case "up": - queue.moveUp(index); - break; - case "down": - queue.moveDown(index); - break; - case "bottom": - queue.moveToBottom(index); - break; - } - }), + .onClick(() => queue.move(episode, reorderItem.kind)), ); } } diff --git a/src/ui/ReorderQueue.svelte b/src/ui/ReorderQueue.svelte index 8bcaae46..ef2dff40 100644 --- a/src/ui/ReorderQueue.svelte +++ b/src/ui/ReorderQueue.svelte @@ -1,47 +1,11 @@
@@ -64,34 +28,34 @@ label="Move to top" size={20} disabled={index === 0} - on:click={() => moveToTop(episode)} + on:click={() => queue.move(episode, "top")} /> moveUp(episode)} + on:click={() => queue.move(episode, "up")} /> moveDown(episode)} + on:click={() => queue.move(episode, "down")} /> moveToBottom(episode)} + on:click={() => queue.move(episode, "bottom")} /> removeFromQueue(episode)} + on:click={() => queue.remove(episode)} />
diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index 154ea808..f1b21d3f 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -6,10 +6,19 @@ import * as obsidian from "obsidian"; import { DEFAULT_SETTINGS } from "src/constants"; import PodNotes from "src/main"; -import { episodeListLimit, hidePlayedEpisodes, playbackRate, savedFeeds, volume } from "src/store"; +import { PlayerController } from "src/player/PlayerController"; +import { episodeListLimit, hidePlayedEpisodes, savedFeeds } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; +import type { Episode } from "src/types/Episode"; import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; import { attachDataFileWriter, dataFileText } from "../../../tests/mocks/dataFileWriter"; +import { FakeDocument } from "../../../tests/mocks/fakeMedia"; + +/** The player the import rehydrates, driving a fake media element. */ +function withPlayer(plugin: PodNotes): PodNotes { + plugin.player = new PlayerController(new FakeDocument().asPlayerDocument(), plugin); + return plugin; +} function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -22,15 +31,14 @@ function deferred() { } describe("PodNotesSettingsTab settings import", () => { - it("rehydrates the live playback-rate store when importing a new default", async () => { - playbackRate.set(1); + it("sets the live playback rate when importing a new default", async () => { const plugin = { settings: structuredClone(DEFAULT_SETTINGS), credentials: { storeValues: vi.fn(() => ({})) }, saveSettings: vi.fn().mockResolvedValue(undefined), saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(tab, "update").mockImplementation(() => {}); await ( @@ -42,12 +50,11 @@ describe("PodNotesSettingsTab settings import", () => { ).applyImportedSettings({ defaultPlaybackRate: 2.3 }); expect(plugin.settings.defaultPlaybackRate).toBe(2.3); - expect(get(playbackRate)).toBe(2.3); + expect(get(plugin.player.state).playbackRate).toBe(2.3); expect(plugin.saveSettingsStrict).toHaveBeenCalledTimes(2); }); it("restores the previous settings and reports a strict save failure", async () => { - playbackRate.set(1); const previous = structuredClone(DEFAULT_SETTINGS); const failure = new Error("disk full"); const plugin = { @@ -59,7 +66,7 @@ describe("PodNotesSettingsTab settings import", () => { .mockRejectedValueOnce(failure) .mockResolvedValueOnce(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); const update = vi.spyOn(tab, "update").mockImplementation(() => {}); const notice = vi.spyOn(obsidian, "Notice"); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -73,7 +80,7 @@ describe("PodNotesSettingsTab settings import", () => { ).applyImportedSettings({ defaultPlaybackRate: 2.3 }); expect(plugin.settings).toBe(previous); - expect(get(playbackRate)).toBe(1); + expect(get(plugin.player.state).playbackRate).toBe(1); expect(update).not.toHaveBeenCalled(); expect(notice).toHaveBeenCalledWith( "Could not import PodNotes settings. The failed import was rolled back without overwriting newer changes.", @@ -93,7 +100,7 @@ describe("PodNotesSettingsTab settings import", () => { .mockImplementationOnce(() => firstSave.promise) .mockResolvedValue(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); const enabledInput = tab.containerEl.createEl("input"); const disabledButton = tab.containerEl.createEl("button"); disabledButton.disabled = true; @@ -114,7 +121,6 @@ describe("PodNotesSettingsTab settings import", () => { expect(enabledInput.disabled).toBe(false); expect(disabledButton.disabled).toBe(true); - playbackRate.set(DEFAULT_SETTINGS.defaultPlaybackRate); }); it("stores imported secret values first and persists only their references", async () => { @@ -129,7 +135,7 @@ describe("PodNotesSettingsTab settings import", () => { saveSettings: vi.fn().mockResolvedValue(undefined), saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(tab, "update").mockImplementation(() => {}); await ( @@ -163,7 +169,7 @@ describe("PodNotesSettingsTab settings import", () => { saveSettings: vi.fn().mockResolvedValue(undefined), saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); const notice = vi.spyOn(obsidian, "Notice"); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -200,7 +206,7 @@ describe("PodNotesSettingsTab settings import", () => { .mockRejectedValueOnce(new Error("disk full")) .mockResolvedValueOnce(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(console, "error").mockImplementation(() => undefined); await ( @@ -231,7 +237,7 @@ describe("PodNotesSettingsTab settings import", () => { .mockRejectedValueOnce(new Error("disk full")) .mockResolvedValueOnce(undefined), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(console, "error").mockImplementation(() => undefined); const saved = await ( @@ -278,7 +284,7 @@ describe("PodNotesSettingsTab settings import", () => { return Promise.resolve(); }), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(console, "error").mockImplementation(() => undefined); let ui = "shared-a"; const secret = { @@ -344,7 +350,7 @@ describe("PodNotesSettingsTab settings import", () => { return Promise.resolve(); }), } as unknown as PodNotes; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(console, "error").mockImplementation(() => undefined); let ui = "shared-b"; const secret = { @@ -432,7 +438,7 @@ describe("PodNotesSettingsTab settings import", () => { saveData, }); attachDataFileWriter(plugin); - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(tab, "update").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => undefined); let ui = "shared-newer"; @@ -481,7 +487,15 @@ describe("PodNotesSettingsTab settings import", () => { expect(ui).toBe(newerSecretId); }); - it("keeps an ABA hide-played store update when the matching import fails", async () => { + it("keeps ABA store and player updates when the matching import fails", async () => { + const selected: Episode = { + title: "Selected", + streamUrl: "https://pod.example.com/selected.mp3", + url: "", + description: "", + content: "", + podcastName: "Show", + }; const firstWrite = deferred(); let disk = { ...structuredClone(DEFAULT_SETTINGS), schemaVersion: 2 }; let saveCall = 0; @@ -515,12 +529,10 @@ describe("PodNotesSettingsTab settings import", () => { }); attachDataFileWriter(plugin); hidePlayedEpisodes.set(false); - volume.set(DEFAULT_SETTINGS.defaultVolume); episodeListLimit.set(DEFAULT_SETTINGS.episodeListLimit); - playbackRate.set(DEFAULT_SETTINGS.defaultPlaybackRate); const unsubscribe = bindStoresToSettings(plugin); Object.assign(plugin, { isReady: true }); - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(tab, "update").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -540,10 +552,11 @@ describe("PodNotesSettingsTab settings import", () => { await vi.waitFor(() => expect(saveData).toHaveBeenCalledTimes(1)); hidePlayedEpisodes.set(true); - volume.set(0.4); + plugin.player.setVolume(0.4); episodeListLimit.set(20); // Current playback speed is runtime state, not the persisted default. - playbackRate.set(2.5); + plugin.player.setPlaybackRate(2.5); + plugin.player.play(selected); firstWrite.reject(new Error("first import write failed")); await importing; @@ -554,15 +567,15 @@ describe("PodNotesSettingsTab settings import", () => { expect(disk.defaultVolume).toBe(0.4); expect(plugin.settings.episodeListLimit).toBe(20); expect(disk.episodeListLimit).toBe(20); - expect(get(playbackRate)).toBe(2.5); + 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 }); + expect(disk.currentEpisode).toMatchObject({ title: selected.title }); } finally { unsubscribe(); hidePlayedEpisodes.set(false); - volume.set(DEFAULT_SETTINGS.defaultVolume); episodeListLimit.set(DEFAULT_SETTINGS.episodeListLimit); - playbackRate.set(DEFAULT_SETTINGS.defaultPlaybackRate); } }); @@ -604,7 +617,7 @@ describe("PodNotesSettingsTab settings import", () => { savedFeeds.set(plugin.settings.savedFeeds); const unbind = bindStoresToSettings(plugin); disk.savedFeeds = { Before: feed("Before"), Remote: feed("Remote") }; - const tab = new PodNotesSettingsTab({} as App, plugin); + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); vi.spyOn(tab, "update").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => undefined); const applyImport = ( @@ -663,7 +676,7 @@ describe("PodNotesSettingsTab settings import", () => { credentials: { exportValues }, manifest: { version: "2.17.3" }, } as unknown as PodNotes; - const tab = new PodNotesSettingsTab(app, plugin); + const tab = new PodNotesSettingsTab(app, withPlayer(plugin)); await ( tab as unknown as { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index be46ea01..5e94cbf9 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -25,13 +25,11 @@ import { favorites, hidePlayedEpisodes, localFiles, - playbackRate, playlists, plugin, queue, sanitizeEpisodeListLimit, savedFeeds, - volume, } from "src/store/index"; import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; import type { Episode } from "src/types/Episode"; @@ -47,7 +45,6 @@ import { parseImport, serializeSettings, } from "src/settingsTransfer"; -import { normalizePlaybackRate } from "src/utility/playbackRate"; import { DEFAULT_SPEAKER_TEMPLATE, type DiarizationProviderId } from "src/services/diarization"; import type { CredentialKind, CredentialValues } from "src/types/Credentials"; import { observePersistedStoreChanges } from "src/store/persistence"; @@ -1026,9 +1023,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { const sanitizedLimit = sanitizeEpisodeListLimit(merged.episodeListLimit); merged.episodeListLimit = sanitizedLimit; episodeListLimit.set(sanitizedLimit); - const importedVolume = Number.isFinite(merged.defaultVolume) ? merged.defaultVolume : 1; - volume.set(Math.min(1, Math.max(0, importedVolume))); - playbackRate.set(normalizePlaybackRate(merged.defaultPlaybackRate)); + this.plugin.player.setVolume( + Number.isFinite(merged.defaultVolume) ? merged.defaultVolume : 1, + ); + this.plugin.player.setPlaybackRate(merged.defaultPlaybackRate); try { // Store setters can canonicalize or deduplicate their slices. Await one From 9b580668337ece2d60523d8719018408b75f35ec Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 12:00:46 +0200 Subject: [PATCH 06/66] test(e2e): observe the player's own media element and assert the new playback contract The journeys find the one media element by its class in the main window. J-PLAY-11 asserts the restored episode stays paused and then resumes from its saved position. The runtime helpers that faked the element are gone: links play real media and are checked within a tolerance. Closing a popout keeps the audio playing, and a reload right after keeps data.json intact. A synced selection now becomes this device's selection while what plays stays. --- tests/e2e/journey-links-local.test.ts | 10 +- tests/e2e/journey-playback.test.ts | 41 ++--- tests/e2e/journey/library.ts | 2 +- tests/e2e/local-files-runtime.test.ts | 10 -- tests/e2e/player-popout-save.test.ts | 170 ++++++++++-------- tests/e2e/podnotes-runtime.test.ts | 243 +++++--------------------- tests/e2e/sync-persistence.test.ts | 5 +- 7 files changed, 166 insertions(+), 315 deletions(-) diff --git a/tests/e2e/journey-links-local.test.ts b/tests/e2e/journey-links-local.test.ts index d0d997b1..4a384609 100644 --- a/tests/e2e/journey-links-local.test.ts +++ b/tests/e2e/journey-links-local.test.ts @@ -120,9 +120,7 @@ describe("link and local file journeys", () => { const resumed = await waitForPlayback( obsidian, (playback) => - playback.episode?.title === "Noted Episode" && - playback.media !== null && - playback.seconds > 0, + playback.episode?.title === "Noted Episode" && (playback.media?.seconds ?? 0) > 0, "the Resume link reopens Noted Episode", ); expect(resumed.seconds).toBeGreaterThanOrEqual(paused.seconds - 0.5); @@ -142,7 +140,7 @@ describe("link and local file journeys", () => { (playback) => playback.episode?.title === "Finished Episode" && playback.playing && - playback.media !== null, + playback.media?.paused === false, "the Resume link reopens Finished Episode", ); expect(restarted.seconds).toBeLessThan(3); @@ -191,9 +189,7 @@ describe("link and local file journeys", () => { const playing = await waitForPlayback( obsidian, (playback) => - playback.episode?.title === "Linked Episode" && - playback.media !== null && - playback.seconds > 0, + playback.episode?.title === "Linked Episode" && (playback.media?.seconds ?? 0) > 0, "the linked episode loads", ); expect(playing.episode).toEqual({ podcast: "Journey Links", title: "Linked Episode" }); diff --git a/tests/e2e/journey-playback.test.ts b/tests/e2e/journey-playback.test.ts index 7cc3bda6..35ecb48e 100644 --- a/tests/e2e/journey-playback.test.ts +++ b/tests/e2e/journey-playback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, onTestFinished, test } from "vitest"; -import { createPodNotesE2EHarness, expectKnownFailure, PLUGIN_ID } from "./harness"; +import { createPodNotesE2EHarness, PLUGIN_ID } from "./harness"; import { click, closePodNotes, @@ -186,7 +186,7 @@ describe("playback journeys", () => { }, 60_000); test.runIf(isolatedInstance)( - "#377 known failure J-PLAY-11: after restart the restored player keeps its saved position without autoplay", + "#377 J-PLAY-11: after restart the restored player keeps its saved position without autoplay", async () => { const context = getContext(); const { obsidian, plugin } = context; @@ -225,29 +225,26 @@ describe("playback journeys", () => { await showPodNotes(obsidian); await click(obsidian, ui.playerTab); - const resumed = await waitForPlayback( + const shown = await waitForPlayback( obsidian, - (playback) => playback.media !== null && playback.seconds > 0, - "the player loads the restored episode", + (playback) => playback.seconds > 0, + "the player shows the restored episode", ); - expect(resumed.episode).toEqual({ podcast: "Journey Playback", title: "Long Listen" }); - expect(resumed.seconds).toBeGreaterThanOrEqual(saved - 0.5); - expect(resumed.seconds).toBeLessThanOrEqual(saved + 3); + expect(shown.episode).toEqual({ podcast: "Journey Playback", title: "Long Listen" }); + expect(shown.seconds).toBeGreaterThanOrEqual(saved - 0.5); + expect(shown.seconds).toBeLessThanOrEqual(saved + 3); const [first, later] = await playbackOver(obsidian, 1500); - const captured = { - playing: later.playing, - mediaPaused: later.media?.paused, - advance: later.seconds - first.seconds, - }; - expectKnownFailure(377, captured, { - correct: (value) => { - expect(value.playing).toBe(false); - expect(value.mediaPaused).toBe(true); - expect(value.advance).toBe(0); - }, - reproduces: (value) => - value.playing && value.mediaPaused === false && value.advance > 1, - }); + expect(later.playing).toBe(false); + expect(later.media?.paused).toBe(true); + expect(later.seconds - first.seconds).toBe(0); + + await click(obsidian, ui.playbackToggle); + const resumed = await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds > saved + 0.5, + "the restored episode resumes from its saved position", + ); + expect(resumed.seconds).toBeLessThan(saved + 5); }, 120_000, ); diff --git a/tests/e2e/journey/library.ts b/tests/e2e/journey/library.ts index 54b8fa46..728407ce 100644 --- a/tests/e2e/journey/library.ts +++ b/tests/e2e/journey/library.ts @@ -413,7 +413,7 @@ export async function waitForLibrary( export async function observePlayback(obsidian: ObsidianClient): Promise { return await obsidian.dev.evalJson(`(() => { const api = app.plugins.plugins.${PLUGIN_ID}.api; - const media = document.querySelector('.workspace-leaf-content[data-type="${VIEW_TYPE}"] .episode-player :is(audio, video)'); + const media = document.querySelector("audio.podnotes-media, video.podnotes-media"); const episode = api.podcast; return { episode: episode ? { podcast: episode.podcastName, title: episode.title } : null, diff --git a/tests/e2e/local-files-runtime.test.ts b/tests/e2e/local-files-runtime.test.ts index ce12b327..89479b27 100644 --- a/tests/e2e/local-files-runtime.test.ts +++ b/tests/e2e/local-files-runtime.test.ts @@ -49,16 +49,6 @@ describe("local files runtime", () => { (data) => inDownloads(data) && inLocalFiles(data), WAIT_OPTS, ); - // Until #355 keeps the player's