diff --git a/.claude/skills/verify/features/player.md b/.claude/skills/verify/features/player.md index 5f6a278e..68b95772 100644 --- a/.claude/skills/verify/features/player.md +++ b/.claude/skills/verify/features/player.md @@ -38,8 +38,10 @@ Preconditions: ## Gotchas -- Clicking a finished episode resumes it at its end. That fires `ended` at once, and the queue auto-advances to another episode. Use unplayed episodes or a fresh vault. +- Playing a finished episode starts it over at 0. +- Audio keeps playing on the grid and list tabs, in settings, and with the view closed. The one media element is `audio.podnotes-media` (or `video.podnotes-media`) in the main window, not inside the view. +- After a restart the last episode shows paused at its saved position. Nothing loads or plays until you press play. A sync that lands before then updates the shown episode and position, unless you moved the position. +- A video plays in the main window only. With the view in a popout, the player shows the artwork and the video keeps playing hidden in the main window. - With `Keep a queue of episodes you switch away from` on (the default), switching episodes queues the previous one, and it plays again when the current one ends. - The skip buttons have no accessible name. Address them by position inside `.controls-container`. -- The playback-rate slider renders at 1.5 while the real rate is 1x. Assert on `api.playbackRate` or the rate label, not the slider. - Headless Obsidian plays to a null audio sink. Prove playback through `api.currentTime` advancing, not by sound. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index a6e7db00..bdb7ee7b 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -73,15 +73,17 @@ Local episodes extend this type with `podcastName: "local file"`, empty `descrip ## `isPlaying` -Returns the inverse of PodNotes' paused state. It describes the requested player state rather than proof that media playback has started. +Returns `true` while the current episode plays, or loads to play. It is `false` for a paused, finished, or freshly restored episode. ## `length` -Returns the player duration in seconds. The value starts at zero before media metadata loads. +Returns the player duration in seconds. Before the media loads, it is the saved or feed-provided duration, or zero when neither is known. ## `currentTime` -Gets or sets the player position in seconds. Setting a position clears an active segment's end boundary. +Gets or sets the player position in seconds. Before the media loads, it is the position playback starts from. +Setting a position clears an active segment's end boundary. A value that is not a finite, non-negative number is ignored. +A position at or past the end stops 0.25 seconds before the end and pauses, so the episode does not finish. ## `volume` @@ -162,11 +164,12 @@ Non-finite writes fall back to **Default Playback Rate**. Changing the current rate does not change that saved default. ## Playback controls -`start()`, `stop()`, and `togglePlayback()` control the current episode. -`stop()` pauses without resetting the position. +`start()`, `stop()`, and `togglePlayback()` control the current episode, also while the PodNotes view is closed. +`stop()` pauses without resetting the position. `start()` on a finished episode starts it over. `skipBackward()` and `skipForward()` use **Skip backward length (s)** and **Skip forward length (s)**. Invalid skip lengths fall back to 15 seconds. Backward skips stop at zero. -With a known duration, forward skips stop up to 0.25 seconds before the end without moving backward. +With a known duration, a forward skip goes no further than 0.25 seconds before the end and never moves backward. +Playback continues, so a playing episode finishes and the queue advances. `increasePlaybackRate()` and `decreasePlaybackRate()` adjust the current playback speed in `0.1x` steps. `resetPlaybackRate()` returns playback to the default diff --git a/docs/src/content/docs/commands.md b/docs/src/content/docs/commands.md index 7844b368..13f68c26 100644 --- a/docs/src/content/docs/commands.md +++ b/docs/src/content/docs/commands.md @@ -37,7 +37,8 @@ It requires a loaded episode and works while paused. The default skip length is ## Skip Forward `podnotes:skip-forward` seeks forward by **Skip forward length (s)**. -When duration is known, a forward skip stops up to 0.25 seconds before the end without rewinding an episode already nearer its end. +When duration is known, a forward skip goes no further than 0.25 seconds before the end and never rewinds an episode already nearer its end. +Playback continues, so a playing episode finishes and the queue advances. It requires a loaded episode and works while paused. The default skip length is 15 seconds. ## Download Playing Episode diff --git a/docs/src/content/docs/podcasts.md b/docs/src/content/docs/podcasts.md index d468474c..6615c9f1 100644 --- a/docs/src/content/docs/podcasts.md +++ b/docs/src/content/docs/podcasts.md @@ -92,9 +92,13 @@ If you would rather manage the queue yourself, turn off **Keep a queue of episod You can still add, remove, and reorder episodes in the queue manually from the episode context menu. While the queue is empty in this mode, its tile in the podcast grid and its list in the player are hidden. ## Player -The player will automatically load and play the current episode. +An episode keeps playing while you browse podcasts, change settings, or close the PodNotes view. +When Obsidian starts, the player shows the last episode paused at its saved position. Nothing plays until you press play. +If a sync brings newer progress or another episode before then, the player shows that instead. +A position you already moved that episode to stays. +A video plays in the main Obsidian window. When the PodNotes view is in a separate window, it shows the episode artwork. -Clicking on the epsiode image will toggle episode playback. +Clicking on the episode image will toggle episode playback. The player also features a progress bar, which you can use to seek to a specific time in the episode. You can also press the buttons on the player to skip backwards or forwards in the episode. diff --git a/docs/src/content/docs/timestamps.md b/docs/src/content/docs/timestamps.md index add2c74d..b22523ad 100644 --- a/docs/src/content/docs/timestamps.md +++ b/docs/src/content/docs/timestamps.md @@ -88,13 +88,14 @@ The handler accepts these query parameters: | --- | --- | --- | | `url` | Yes | Podcast feed URL, vault media path, or protected private-feed placeholder. | | `episodeName` | Yes | Episode title. | -| `time` | No | Start time parsed as a non-negative finite number of seconds. | -| `endTime` | No | Segment end parsed as a finite number of seconds greater than `time`. Requires a start time. | +| `time` | No | Start time in seconds. The whole value must be a non-negative finite number, so `12abc` is refused. | +| `endTime` | No | Segment end in seconds, greater than `time`. The whole value must be a finite number. Requires a start time. | | `end` | No | Accepted alias of `endTime`. | | `to` | No | Accepted alias of `endTime`, after `end` in precedence. | An omitted or empty `time` resumes from saved progress. An unplayed or finished episode starts at zero. +A `time` at or past the episode's end stops 0.25 seconds before the end and pauses. `endTime` takes precedence over `end`, which takes precedence over `to`. Query values are percent-encoded. Current links encode spaces as `%20` and literal plus signs as `%2B`. @@ -102,7 +103,9 @@ Older links using `+` for spaces are also tried during title and local-path look Feed targets must use a public HTTP or HTTPS URL. A protected private-feed placeholder resolves only through a saved subscription and its device-local secret. Local targets need a registered episode and a media file. See [Sync and devices](/sync/#removal-and-existing-links). +When the vault path in `url` exists, that file plays, even if a same-named file in another folder took over its registration. +A link plays the loaded episode without a feed lookup only when its `url` names that episode's feed, or its file for a local file. Episode lookup first tries the title, ignoring case and surrounding whitespace. Its fallback requires a unique match with the same normalized word set, ignoring punctuation, word order, and leading zeros in numeric words. Different words or episode numbers can prevent a match. Timestamp links do not guarantee recovery after every feed title change. diff --git a/src/API/API.test.ts b/src/API/API.test.ts index 17c34ee3..eda7465d 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); @@ -203,20 +209,17 @@ describe("API skip controls (PB-02)", () => { expect(api.currentTime).toBe(100); }); - 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(); + test("clamps a forward skip just short of the end and keeps playing", () => { + 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,142 @@ 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("start() then stop() on loaded media leaves it paused", () => { + const { api, player, media } = playingAt(100); + player.pause(); + + api.start(); + expect(api.isPlaying).toBe(true); + api.stop(); + media().emit("play"); + + expect(api.isPlaying).toBe(false); + expect(media().paused).toBe(true); + }); + + 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); + }); + + test("start() while a segment plays leaves its stop in place", () => { + playedEpisodes.set({ + "Feed Podcast::Feed Episode": { + title: feedEpisode.title, + podcastName: feedEpisode.podcastName, + time: 1000, + duration: 3600, + finished: false, + }, + }); + const { api, player, media } = setup(); + player.play(feedEpisode, { time: 100, endTime: 125 }); + media().duration = 3600; + media().emit("loadedmetadata"); + + api.start(); + media().currentTime = 130; + media().emit("timeupdate"); + + expect(media().currentTime).toBe(125); + expect(media().paused).toBe(true); + expect(get(playedEpisodes)["Feed Podcast::Feed Episode"]?.time).toBe(1000); + }); + + test("ignores an undefined seek before the metadata loads", () => { + const { api, player, media } = setup(); + player.play(feedEpisode, { time: 115 }); + + api.currentTime = undefined as unknown as number; + media().duration = 3600; + media().emit("loadedmetadata"); + + expect(api.currentTime).toBe(115); + expect(media().currentTime).toBe(115); + }); + + test("ignores an undefined seek during a segment, which still stops at its end", () => { + const { api, player, media } = setup(); + player.play(feedEpisode, { time: 115, endTime: 125 }); + media().duration = 3600; + media().emit("loadedmetadata"); + + api.currentTime = undefined as unknown as number; + media().currentTime = 126; + media().emit("timeupdate"); + + expect(media().currentTime).toBe(125); + expect(media().paused).toBe(true); + expect(get(playedEpisodes)["Feed Podcast::Feed Episode"]).toBeUndefined(); + }); +}); + 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 +376,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 +408,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 +425,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..a1429ff6 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -2,20 +2,10 @@ 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 { plugin } from "src/store"; import { get } from "svelte/store"; import encodePodnotesURI from "src/utility/encodePodnotesURI"; -import { isLocalFile } from "src/utility/isLocalFile"; +import { episodeLinkTarget } from "src/utility/episodeLinkTarget"; import { formatPodcastSegment, normalizePodcastSegmentTimes } from "src/utility/podcastSegment"; import { adjustPlaybackRate, @@ -32,17 +22,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 +40,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); } /** @@ -99,15 +90,15 @@ export class API implements IAPI { if (!linkify) return time; - const feedUrl = this.getEpisodeLinkTarget(); + const target = episodeLinkTarget(this.podcast); - if (!feedUrl || feedUrl === "") { + if (!target) { // Considered handling this as an error case, but I think // it's better UX to just show the time rather than getting an error. return time; } - const url = encodePodnotesURI(this.podcast.title, feedUrl, adjustedTime); + const url = encodePodnotesURI(this.podcast.title, target, adjustedTime); return `[${time}](${url.href})`; } @@ -129,15 +120,15 @@ export class API implements IAPI { if (!linkify || !segmentTimes) return segment; - const feedUrl = this.getEpisodeLinkTarget(); + const target = episodeLinkTarget(this.podcast); - if (!feedUrl || feedUrl === "") { + if (!target) { return segment; } const url = encodePodnotesURI( this.podcast.title, - feedUrl, + target, segmentTimes.startTime, segmentTimes.endTime, ); @@ -164,23 +155,16 @@ export class API implements IAPI { return await pluginInstance.app.vault.read(transcriptFile); } - private getEpisodeLinkTarget(): string | undefined { - const epIsLocal = isLocalFile(this.podcast); - return !epIsLocal - ? this.podcast.feedUrl - : 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 { @@ -194,21 +178,21 @@ export class API implements IAPI { const skipForwardLen = normalizeSkipLength(get(plugin).settings.skipForwardLength); const target = this.currentTime + skipForwardLen; const dur = this.length; - // Clamp just short of the end so an over-skip lands at the end instead of - // firing 'ended' and auto-advancing the queue. Never let the clamp move the - // position BACKWARD: when already within the last 0.25s, a forward skip must - // not rewind, so keep at least the current time (PB-02 / Codex review #213). - // With an unknown/zero duration (metadata not loaded) leave it unclamped. + // An over-skip lands 0.25s short of the end, so a playing episode finishes and + // the queue advances; a seek to the end itself would pause. Never let the clamp + // move the position BACKWARD: when already within the last 0.25s, a forward + // skip must not rewind, so keep at least the current time (PB-02 / Codex review + // #213). With an unknown/zero duration (metadata not loaded) leave it unclamped. this.currentTime = dur > 0 ? Math.max(this.currentTime, Math.min(target, dur - 0.25)) : target; } 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.feeds.test.ts b/src/URIHandler.feeds.test.ts index 5693065e..b5c71766 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -7,18 +7,15 @@ import type { ParsedFeedEpisodes } from "./parser/feedDocumentParser"; import { FeedRepository } from "./services/FeedRepository"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { resolveFeedUrl } from "./services/privateFeeds"; -import { - activePlaybackSegment, - currentEpisode, - plugin, - requestedPlaybackTime, - viewState, -} from "./store"; +import { DEFAULT_SETTINGS } from "./constants"; +import { PlayerController } from "./player/PlayerController"; +import { plugin, savedFeeds, viewState } from "./store"; import type { Episode } from "./types/Episode"; import type { PodcastFeed } from "./types/PodcastFeed"; import { ViewState } from "./types/ViewState"; import { privateFeedPlaceholder } from "./utility/privateFeedUrl"; import { deferred, feedDocument, MemoryStorage, stubLegacyStorage } from "../tests/mocks/feeds"; +import { FakeDocument } from "../tests/mocks/fakeMedia"; const testFeedUrl = "https://pod.example.com/feed.xml"; @@ -32,13 +29,18 @@ const testEpisode: Episode = { feedUrl: testFeedUrl, }; -const api = {}; +let player: PlayerController; +const playing = () => get(player.state).episode; function resetStores() { - currentEpisode.update(() => undefined as unknown as Episode); - activePlaybackSegment.set(null); - requestedPlaybackTime.set(null); + player = new PlayerController(new FakeDocument().asPlayerDocument(), { + settings: structuredClone(DEFAULT_SETTINGS), + app: { vault: {} as never }, + checkpoint: (update) => update(), + saveSettings: () => Promise.resolve(), + }); viewState.set(ViewState.PodcastGrid); + savedFeeds.set({}); } beforeEach(resetStores); @@ -79,16 +81,20 @@ describe("podNotesURIHandler with saved feeds", () => { cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); - feeds.setSubscriptions({ [patron.title]: patron, [testEpisode.podcastName]: publicFeed }); + const subscriptions = { [patron.title]: patron, [testEpisode.podcastName]: publicFeed }; + savedFeeds.set(subscriptions); + feeds.setSubscriptions(subscriptions); plugin.set({ app: { vault: { getAbstractFileByPath: () => null } }, feeds, + feedUrls, + player, } as never); return { feeds, feedUrls, patron }; } const publicFeed: PodcastFeed = { title: "Test Podcast", url: testFeedUrl, artworkUrl: "" }; const open = (url: string, episodeName: string) => - podNotesURIHandler({ action: "podnotes", url, episodeName, time: "5" }, api as never); + podNotesURIHandler({ action: "podnotes", url, episodeName, time: "5" }); const privateLink = privateFeedPlaceholder("Patron Show"); afterEach(() => { @@ -104,7 +110,7 @@ describe("podNotesURIHandler with saved feeds", () => { await open(privateLink, second.title); expect(load).toHaveBeenCalledWith(SECRET_URL); - expect(get(currentEpisode)).toMatchObject({ + expect(playing()).toMatchObject({ title: second.title, podcastName: "Patron Show", }); @@ -122,7 +128,7 @@ describe("podNotesURIHandler with saved feeds", () => { expect(notice.mock.calls).toEqual([[PRIVATE_LINK_NOTICE]]); expect(load).not.toHaveBeenCalled(); - expect(get(currentEpisode)).toBeUndefined(); + expect(playing()).toBeUndefined(); }); test("a private feed link that cannot load says so once", async () => { @@ -134,7 +140,7 @@ describe("podNotesURIHandler with saved feeds", () => { await open(privateLink, first.title); expect(notice.mock.calls).toEqual([["Could not load the podcast feed"]]); - expect(get(currentEpisode)).toBeUndefined(); + expect(playing()).toBeUndefined(); }); /** "Patron Show" loaded once with `first`, then its URL left this device. */ @@ -154,7 +160,7 @@ describe("podNotesURIHandler with saved feeds", () => { expect(load).not.toHaveBeenCalled(); expect(notice).not.toHaveBeenCalled(); - expect(get(currentEpisode)).toMatchObject({ title: first.title }); + expect(playing()).toMatchObject({ title: first.title }); }); test("a private feed link to an episode the last copy lacks says the feed is not available", async () => { @@ -164,7 +170,7 @@ describe("podNotesURIHandler with saved feeds", () => { await open(privateLink, second.title); expect(notice.mock.calls).toEqual([[PRIVATE_LINK_NOTICE]]); - expect(get(currentEpisode)).toBeUndefined(); + expect(playing()).toBeUndefined(); }); test("a private feed link to an episode the last copy lacks says the feed could not load when offline", async () => { @@ -178,7 +184,48 @@ describe("podNotesURIHandler with saved feeds", () => { await open(privateLink, second.title); expect(notice.mock.calls).toEqual([["Could not load the podcast feed"]]); - expect(get(currentEpisode)).toBeUndefined(); + expect(playing()).toBeUndefined(); + }); + + test("a link to a saved private feed whose URL is not on this device leaves an earlier link to play", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + follow(false); + const earlier = deferred(); + load.mockReturnValueOnce(earlier.promise); + + const opening = open(testFeedUrl, testEpisode.title); + await open(privateLink, first.title); + earlier.resolve(feedDocument("Test Podcast", [testEpisode])); + await opening; + + expect(notice.mock.calls).toEqual([[PRIVATE_LINK_NOTICE]]); + expect(playing()).toMatchObject({ title: testEpisode.title, podcastName: "Test Podcast" }); + }); + + test("a play the user starts while a private feed link loads wins", async () => { + follow(); + const fetching = deferred(); + load.mockReturnValueOnce(fetching.promise); + + const opening = open(privateLink, first.title); + player.play(testEpisode); + fetching.resolve(feedDocument("Patron Show", [first])); + await opening; + + expect(playing()).toMatchObject({ title: testEpisode.title, podcastName: "Test Podcast" }); + }); + + test("a link that plays from a private feed's last copy wins over an earlier link", async () => { + await lostPrivateUrl(); + const earlier = deferred(); + load.mockReturnValueOnce(earlier.promise); + + const opening = open(testFeedUrl, testEpisode.title); + await open(privateLink, first.title); + earlier.resolve(feedDocument("Test Podcast", [testEpisode])); + await opening; + + expect(playing()).toMatchObject({ title: first.title, podcastName: "Patron Show" }); }); test("a private feed link that loads after a newer link changes nothing", async () => { @@ -196,7 +243,7 @@ describe("podNotesURIHandler with saved feeds", () => { older.resolve(feedDocument("Patron Show", [first])); await opening; - expect(get(currentEpisode)).toMatchObject({ title: testEpisode.title }); + expect(playing()).toMatchObject({ title: testEpisode.title }); expect(notice).not.toHaveBeenCalled(); }); @@ -213,7 +260,7 @@ describe("podNotesURIHandler with saved feeds", () => { await open(url, second.title); - expect(get(currentEpisode)).toMatchObject({ title: second.title }); + expect(playing()).toMatchObject({ title: second.title }); }, ); @@ -232,7 +279,7 @@ describe("podNotesURIHandler with saved feeds", () => { await open(url, first.title); expect(load).toHaveBeenCalledTimes(3); - expect(get(currentEpisode)).toMatchObject({ title: first.title }); + expect(playing()).toMatchObject({ title: first.title }); }, ); }); diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 557a68d1..89c3f65a 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -1,24 +1,18 @@ import * as obsidian from "obsidian"; +import { TFile, type ObsidianProtocolData } 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 type { Episode } from "./types/Episode"; +import { API } from "./API/API"; +import { DEFAULT_SETTINGS } from "./constants"; +import { PlayerController } from "./player/PlayerController"; +import { localFiles, playedEpisodes, plugin, queue, viewState } from "./store"; +import type { Episode, EpisodeMediaType } 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"; import { deferred } from "../tests/mocks/feeds"; const mockEpisodesAt = vi.fn(); @@ -53,113 +47,144 @@ 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); - }, -}; +/** Follows a Markdown link PodNotes wrote, as Obsidian decodes it: each value with decodeURIComponent. */ +function follow(markdownLink: string) { + const href = markdownLink.slice(markdownLink.indexOf("](") + 2, -1); + const params: Record = { action: "podnotes" }; + for (const pair of new URL(href).search.slice(1).split("&")) { + const [key, value] = pair.split("="); + params[key] = decodeURIComponent(value); + } + return podNotesURIHandler(params as ObsidianProtocolData); +} + +/** A vault file as "Play with PodNotes" registers it. */ +const clip = (folder: string, extension = "mp3", mediaType?: EpisodeMediaType): LocalEpisode => ({ + title: "Clip", + streamUrl: `app://resource/${folder}/Clip.${extension}`, + url: `[[${folder}/Clip.${extension}]]`, + description: "", + content: "", + podcastName: "local file", + filePath: `${folder}/Clip.${extension}`, + mediaType, +}); + +function vaultFile(path: string): TFile { + const name = path.split("/").pop() ?? path; + return Object.assign(new TFile(), { + path, + extension: name.split(".").pop() ?? "", + basename: name.replace(/\.[^.]+$/, ""), + stat: { ctime: 0, mtime: 0, size: 1 }, + }); +} + +/** 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) } }, - feeds: { episodesAt: mockEpisodesAt }, - } 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) ? vaultFile(path) : null, + ), + getResourcePath: (file: TFile) => `app://resource/${file.path}`, + }, + fileManager: { generateMarkdownLink: (file: TFile) => `[[${file.path}]]` }, + }, + checkpoint: (update: () => void) => update(), + saveSettings: vi.fn(() => Promise.resolve()), + }; + player = new PlayerController(doc.asPlayerDocument(), host as never); + const feeds = { + episodesAt: mockEpisodesAt, + load: () => Promise.resolve(), + subscribe: (run: (feeds: Map) => void) => (run(new Map()), () => {}), + }; + plugin.set({ ...host, player, feeds } as never); + playedEpisodes.set({}); + queue.set({ ...structuredClone(DEFAULT_SETTINGS.queue), episodes: [] }); + viewState.set(ViewState.PodcastGrid); + localFiles.set({ ...emptyLocalFiles, episodes: [] }); mockEpisodesAt.mockResolvedValue([testEpisode]); - setApp(() => null); }); afterEach(() => { - resetStores(); mockEpisodesAt.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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).not.toHaveBeenCalled(); + }, + ); test.each([ "http://169.254.169.254/latest/meta-data/", @@ -170,179 +195,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(mockEpisodesAt).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(mockEpisodesAt).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); + test("plays a segment of the already-loaded episode", async () => { + loadedAt(testEpisode, 3000); - await podNotesURIHandler( - { - action: "podnotes", - url: testFeedUrl, - episodeName: testEpisode.title, - time: "120", - endTime: "135", - }, - api as never, - ); + await link({ time: "120", endTime: "135" }); + media().currentTime = 136; + media().emit("timeupdate"); - expect(get(currentTime)).toBe(120); - expect(get(isPaused)).toBe(false); - expect(get(activePlaybackSegment)).toEqual({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - startTime: 120, - endTime: 135, - }); + expect(media().currentTime).toBe(135); + expect(media().paused).toBe(true); expect(mockEpisodesAt).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 () => { mockEpisodesAt.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); - expect(get(currentTime)).toBe(90); - expect(get(isPaused)).toBe(false); + await link({ episodeName: "Episode 50: C++ Tips", time: "90" }); + + expect(media().currentTime).toBe(90); + expect(media().paused).toBe(false); expect(mockEpisodesAt).not.toHaveBeenCalled(); }); test("resolves a legacy '+'-as-space link to a non-loaded episode", async () => { - mockEpisodesAt.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(mockEpisodesAt).not.toHaveBeenCalled(); }); @@ -358,24 +293,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"]; + + await link({ url: "Notes+/C++ Tips.mp3", episodeName: "C++ Tips", time: "30" }); + metadata(); - expect(get(currentEpisode)).toMatchObject({ title: "C++ Tips" }); + 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(mockEpisodesAt).not.toHaveBeenCalled(); }); @@ -392,24 +318,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(mockEpisodesAt).not.toHaveBeenCalled(); }); @@ -426,49 +339,30 @@ describe("podNotesURIHandler", () => { }; mockEpisodesAt.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(mockEpisodesAt).not.toHaveBeenCalled(); }); @@ -486,71 +380,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); mockEpisodesAt.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(mockEpisodesAt).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); + mockEpisodesAt.mockResolvedValue([trailerB]); + + await link({ url: "https://b.example.com/feed.xml", episodeName: "Trailer", time: "5" }); + + expect(mockEpisodesAt).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; + mockEpisodesAt.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 () => { - mockEpisodesAt.mockResolvedValue([ - { - ...testEpisode, - title: "Weekly News Roundup Extra", - }, - ]); + mockEpisodesAt.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 () => { mockEpisodesAt.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); }); @@ -558,18 +458,10 @@ describe("podNotesURIHandler", () => { mockEpisodesAt.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); }); @@ -577,56 +469,86 @@ describe("podNotesURIHandler", () => { const older = deferred(); const newer = deferred(); mockEpisodesAt.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); - const open = (episodeName: string) => - podNotesURIHandler( - { action: "podnotes", url: testFeedUrl, episodeName, time: "5" }, - api as never, - ); - const opening = [open(testEpisode.title), open(plusEpisode.title)]; + const opening = [ + link({ episodeName: testEpisode.title, time: "5" }), + link({ episodeName: plusEpisode.title, time: "5" }), + ]; newer.resolve([testEpisode, plusEpisode]); older.resolve([testEpisode, plusEpisode]); await Promise.all(opening); - expect(get(currentEpisode)).toMatchObject({ title: plusEpisode.title }); - expect(get(requestedPlaybackTime)?.episodeKey).toBe(`Test Podcast::${plusEpisode.title}`); + expect(state().episode).toMatchObject({ title: plusEpisode.title }); + expect(media().src).toBe(plusEpisode.streamUrl); }); test("a link whose feed fails after a newer link's shows no notice", async () => { const notice = vi.spyOn(obsidian, "Notice"); const older = deferred(); mockEpisodesAt.mockReturnValueOnce(older.promise).mockResolvedValueOnce([plusEpisode]); - const open = (episodeName: string) => - podNotesURIHandler( - { action: "podnotes", url: testFeedUrl, episodeName, time: "5" }, - api as never, - ); - - const opening = open(testEpisode.title); - await open(plusEpisode.title); + + const opening = link({ episodeName: testEpisode.title, time: "5" }); + await link({ episodeName: plusEpisode.title, time: "5" }); + older.reject(new Error("offline")); + await opening; + + expect(notice).not.toHaveBeenCalled(); + expect(state().episode).toMatchObject({ title: plusEpisode.title }); + notice.mockRestore(); + }); + + test.each([ + [ + "fails", + (older: ReturnType>) => older.reject(new Error("offline")), + ], + ["lacks the title", (older: ReturnType>) => older.resolve([])], + ])( + "a link whose feed %s after a newer link to the loaded episode shows no notice", + async (_, settle) => { + const notice = vi.spyOn(obsidian, "Notice"); + vi.spyOn(console, "error").mockImplementation(() => {}); + loadedAt(plusEpisode, 30); + const older = deferred(); + mockEpisodesAt.mockReturnValueOnce(older.promise); + + const opening = link({ episodeName: testEpisode.title, time: "5" }); + await link({ episodeName: plusEpisode.title, time: "15" }); + settle(older); + await opening; + + expect(notice).not.toHaveBeenCalled(); + expect(state().episode).toMatchObject({ title: plusEpisode.title }); + expect(media().currentTime).toBe(15); + vi.restoreAllMocks(); + }, + ); + + test("a link whose feed fails after a newer link to a local file shows no notice", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + vi.spyOn(console, "error").mockImplementation(() => {}); + const local = clip("A"); + localFiles.set({ ...emptyLocalFiles, episodes: [local] }); + vaultFiles = [local.filePath ?? ""]; + const older = deferred(); + mockEpisodesAt.mockReturnValueOnce(older.promise); + + const opening = link({ episodeName: testEpisode.title, time: "5" }); + await link({ url: local.filePath ?? "", episodeName: local.title, time: "15" }); older.reject(new Error("offline")); await opening; expect(notice).not.toHaveBeenCalled(); - expect(get(currentEpisode)).toMatchObject({ title: plusEpisode.title }); + expect(state().episode).toMatchObject({ filePath: local.filePath }); + vi.restoreAllMocks(); }); test("does not reject when feed parsing throws (no silent unhandled rejection)", async () => { mockEpisodesAt.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); }); @@ -646,227 +568,86 @@ describe("podNotesURIHandler", () => { // Space-twin first: an order-insensitive (membership) match would wrongly pick it. mockEpisodesAt.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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).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(mockEpisodesAt).not.toHaveBeenCalled(); }); @@ -884,22 +665,83 @@ 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"]; + + await link({ url: "A+B.mp3", episodeName: "A+B", time: "20" }); + metadata(); + + expect(state().episode).toMatchObject({ title: "A+B" }); + expect(media().currentTime).toBe(20); + }); +}); + +describe("a timestamp link PodNotes wrote for the loaded episode", () => { + test("seeks a feed episode without fetching its feed", async () => { + loadedAt(testEpisode, 754); + const written = new API().getPodcastTimeFormatted("HH:mm:ss", true); + player.seek(10); + + await follow(written); + + expect(media().currentTime).toBe(754); + expect(media().src).toBe(testEpisode.streamUrl); + expect(mockEpisodesAt).not.toHaveBeenCalled(); + }); + + test("seeks a local file without fetching a feed", async () => { + vaultFiles = ["A/Clip.mp3"]; + loadedAt(clip("A"), 15); + const written = new API().getPodcastTimeFormatted("HH:mm:ss", true); + player.seek(40); - expect(get(currentEpisode)).toMatchObject({ title: "A+B" }); - expect(get(requestedPlaybackTime)).toEqual({ - episodeKey: "local file::A+B", - time: 20, + await follow(written); + + expect(media().currentTime).toBe(15); + expect(media().src).toBe("app://resource/A/Clip.mp3"); + expect(mockEpisodesAt).not.toHaveBeenCalled(); + }); + + test("plays the file it names, as that file, after a same-named file in another folder replaced it", async () => { + vaultFiles = ["A/Clip.mov", "B/Clip.mp3"]; + loadedAt(clip("A", "mov", "video"), 15); + const written = new API().getPodcastTimeFormatted("HH:mm:ss", true); + const registered = clip("B", "mp3", "audio"); + localFiles.set({ ...emptyLocalFiles, episodes: [registered] }); + loadedAt(registered, 40); + + await follow(written); + metadata(); + + expect(state().episode).toMatchObject({ + title: "Clip", + filePath: "A/Clip.mov", + url: "[[A/Clip.mov]]", + streamUrl: "app://resource/A/Clip.mov", }); + expect(state().mediaType).toBe("video"); + expect(media().tagName).toBe("VIDEO"); + expect(media().src).toBe("app://resource/A/Clip.mov"); + expect(media().currentTime).toBe(15); + }); +}); + +describe("links that arrive while another link loads its feed", () => { + test("a link refused before its fetch leaves the earlier one to play", async () => { + let resolveFeed!: (episodes: Episode[]) => void; + mockEpisodesAt.mockReturnValue( + new Promise((resolve) => { + resolveFeed = resolve; + }), + ); + + const pending = link({ time: "240" }); + await link({ url: "http://localhost/feed.xml", time: "5" }); + await link({ url: "podnotes-private-feed:Unknown%20Feed", time: "5" }); + await link({ url: "Notes/moved.mp3", episodeName: "Nothing by this name", time: "5" }); + resolveFeed([testEpisode]); + await pending; + + expect(state().episode).toMatchObject({ title: testEpisode.title }); + expect(media().src).toBe(testEpisode.streamUrl); }); }); diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 5f98d0f4..8a9ad83b 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -1,24 +1,16 @@ -import { Notice } from "obsidian"; +import { Notice, TFile } from "obsidian"; import type { ObsidianProtocolData } from "obsidian"; import { get } from "svelte/store"; -import type { IAPI } from "./API/IAPI"; -import { - currentEpisode, - currentTime, - duration, - isPaused, - activePlaybackSegment, - localFiles, - playedEpisodes, - plugin, - requestedPlaybackTime, - viewState, -} from "./store"; +import { localFiles, plugin, savedFeeds, viewState } from "./store"; import type { Episode } from "./types/Episode"; +import type { LocalEpisode } from "./types/LocalEpisode"; 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 { episodeLinkTarget } from "./utility/episodeLinkTarget"; +import { localEpisodeFromFile } from "./utility/localEpisodeFromFile"; +import { getEpisodeMediaType, getUnambiguousMediaTypeFromPath } from "./utility/mediaType"; import { ViewState } from "./types/ViewState"; type PodNotesProtocolData = ObsidianProtocolData & { @@ -70,44 +62,30 @@ 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); } // Two links opened in quick succession can load their feeds out of order, so a -// link acts after loading only if no newer link has arrived since. +// link acts after loading only if no newer link that fetched or played has arrived since. let latestRequest = 0; export default async function podNotesURIHandler( { url, episodeName, time, endTime, end, to }: PodNotesProtocolData, - api: IAPI, revealPlayer?: RevealPodNotesPlayer, ) { - const request = ++latestRequest; if (!url || !episodeName) { new Notice("URL and episode name are required to play an episode"); 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; @@ -128,74 +106,65 @@ 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 && episodeLinkTarget(loaded); // 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; - } + const episodeIsLoaded = + !!loaded && + !!loadedLinkTarget && + candidateValues(url).includes(loadedLinkTarget) && + (nameCandidates.includes(loaded.title) || + nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); - // 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); + if (episodeIsLoaded) { + latestRequest++; + 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; } - // The path probe only decides local-vs-feed routing; the episode itself is resolved by name. - const { vault } = get(plugin).app; + // The path probe decides local-vs-feed routing; the episode itself is resolved by name. + const { app } = get(plugin); const localFile = candidateValues(url) - .map((path) => vault.getAbstractFileByPath(path)) + .map((path) => app.vault.getAbstractFileByPath(path)) .find((file) => file !== null); let episode: Episode | undefined; + let play = player.play.bind(player); // A private-feed placeholder always routes to placeholder resolution (the // final branch): a vault file literally named like a placeholder must not // shadow private links. const isPlaceholderLink = isPrivateFeedPlaceholder(url); if (!isPlaceholderLink && localFile) { - episode = findLocalEpisodeByCandidates(nameCandidates); + // The file the link names plays, even when a same-named file in another + // folder has since taken over the name's registration. + const named = findLocalEpisodeByCandidates(nameCandidates); + episode = + named && + localFile instanceof TFile && + (named as LocalEpisode).filePath !== localFile.path + ? localEpisodeFromFile( + app, + localFile, + // A file does not record whether an mp4 or webm is audio or video, so it takes the registration's. + getUnambiguousMediaTypeFromPath(localFile.path) ?? + getEpisodeMediaType(named), + ) + : named; } else if (!isPlaceholderLink && !candidateValues(url).some((u) => /^https?:\/\//i.test(u))) { // The probe found no file, yet `url` has no http(s) scheme, so it can only be // a vault path whose file was moved/renamed. Resolve the episode by name from @@ -212,6 +181,16 @@ export default async function podNotesURIHandler( const feeds = get(plugin).feeds; const placeholderKey = parsePrivateFeedPlaceholder(url); if (placeholderKey !== null) { + // Only a feed whose URL is on this device is fetched, and a newer link or play + // wins over that fetch. Any other link is decided from the feed's last copy + // without a fetch: it claims a turn only when it plays, so a refused one + // cancels nothing. + const saved = get(savedFeeds); + const fetches = + Object.prototype.hasOwnProperty.call(saved, placeholderKey) && + resolveFeedUrl(saved[placeholderKey], get(plugin).feedUrls) !== null; + const request = fetches ? ++latestRequest : latestRequest; + if (fetches) play = player.deferPlay(); // Fetch even a fresh feed so the link finds an episode published since. await feeds.load([placeholderKey], "network"); if (request !== latestRequest) return; @@ -235,6 +214,10 @@ export default async function podNotesURIHandler( return; } + // Taken once the link passed every check, so a refused link cancels nothing, + // and a newer link or play wins. + const request = ++latestRequest; + play = player.deferPlay(); let episodes: Episode[]; try { // Fetch with the raw url (current-format links are correct as-is); only the @@ -258,22 +241,8 @@ 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); + latestRequest++; + if (!play(episode, playOptions)) return; viewState.set(ViewState.Player); await revealPlayer?.(); } @@ -295,7 +264,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; @@ -308,19 +277,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..c23279b0 100644 --- a/src/getContextMenuHandler.ts +++ b/src/getContextMenuHandler.ts @@ -2,11 +2,10 @@ 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"; -import { createMediaUrlObjectFromFilePath } from "./utility/createMediaUrlObjectFromFilePath"; +import { localEpisodeFromFile } from "./utility/localEpisodeFromFile"; import { getMediaTypeFromPath, isAudioContainerExtension } from "./utility/mediaType"; export default function getContextMenuHandler(app: App): EventRef { @@ -43,17 +42,7 @@ function addPlayLocalFileItem( .setIcon("play") .setTitle(title) .onClick(async () => { - const localEpisode: LocalEpisode = { - title: file.basename, - description: "", - content: "", - podcastName: "local file", - url: app.fileManager.generateMarkdownLink(file, ""), - streamUrl: createMediaUrlObjectFromFilePath(app.vault, file.path), - filePath: file.path, - episodeDate: new Date(file.stat.ctime), - mediaType, - }; + const localEpisode = localEpisodeFromFile(app, file, mediaType); // The Local Files playlist is mirrored from downloadedEpisodes // (see localFiles.syncWithDownloaded), so this single write @@ -83,16 +72,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..71e82fb7 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -213,6 +213,8 @@ describe("PodNotes onload wiring (#55)", () => { beforeEach(() => { vi.clearAllMocks(); + // jsdom implements no media loading; unload releases the player's element. + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); }); afterEach(() => { @@ -507,6 +509,76 @@ 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 store bindings are disposed", async () => { + vi.spyOn(HTMLMediaElement.prototype, "play").mockReturnValue(new Promise(() => {})); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const { plugin } = await loadPlugin(savedData); + plugin.api.start(); + // jsdom loads no media; the browser would hold its metadata by now. + Object.defineProperty(media(), "readyState", { value: HTMLMediaElement.HAVE_METADATA }); + 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.persistence.test.ts b/src/main.persistence.test.ts index ad79197d..fc38c0ff 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -1,13 +1,16 @@ // @vitest-environment jsdom +import { get } from "svelte/store"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Notice, type SecretStorage } from "obsidian"; import { DEFAULT_SETTINGS } from "./constants"; import PodNotes from "./main"; import { encodePodNotesData } from "./persistence/podNotesData"; +import { PlayerController } from "./player/PlayerController"; import { downloadedEpisodes, localFiles } from "./store"; import { bindStoresToSettings } from "./store/persistence"; import { CredentialRepository } from "./services/CredentialRepository"; import { attachDataFileWriter, dataFileText } from "../tests/mocks/dataFileWriter"; +import { FakeDocument } from "../tests/mocks/fakeMedia"; function memorySecretStorage(initial: Record = {}) { const values = new Map(Object.entries(initial)); @@ -369,6 +372,68 @@ describe("PodNotes persistence integration", () => { expect(plugin.settings.playedEpisodes["Pod::A"].time).toBe(99); }); + describe("while the selected episode is restored at 600 s", () => { + function restored() { + const plugin = makePlugin(vi.fn().mockResolvedValue(undefined)); + plugin.settings.currentEpisode = { + title: "A", + streamUrl: "https://pod.example.com/a.mp3", + url: "", + description: "", + content: "", + podcastName: "Pod", + }; + plugin.settings.playedEpisodes = { + "Pod::A": { + title: "A", + podcastName: "Pod", + time: 600, + duration: 3600, + finished: false, + }, + }; + attachDataFileWriter(plugin); + (plugin as unknown as { setCollectionStores(): void }).setCollectionStores(); + const doc = new FakeDocument(); + plugin.player = new PlayerController(doc.asPlayerDocument(), plugin); + plugin.player.restore(plugin.settings.currentEpisode); + const synced = JSON.parse(JSON.stringify(encodePodNotesData(plugin.settings))); + Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(synced) }); + return { plugin, doc, synced }; + } + + it("shows the progress a sync brings, without loading it", async () => { + const { plugin, doc, synced } = restored(); + synced.playedEpisodes["Pod::A"].time = 2400; + + await plugin.onExternalSettingsChange(); + + expect(get(plugin.player.state)).toMatchObject({ + status: "restored", + episode: { title: "A" }, + currentTime: 2400, + }); + expect(doc.media.src).toBe(""); + plugin.player.dispose(); + }); + + it("keeps a position the user moved when a sync changes something else", async () => { + const { plugin, doc, synced } = restored(); + plugin.player.seek(900); + synced.hidePlayedEpisodes = !plugin.settings.hidePlayedEpisodes; + + await plugin.onExternalSettingsChange(); + expect(plugin.settings.hidePlayedEpisodes).toBe(synced.hidePlayedEpisodes); + expect(get(plugin.player.state).currentTime).toBe(900); + plugin.player.resume(); + doc.media.duration = 3600; + doc.media.emit("loadedmetadata"); + + expect(doc.media.currentTime).toBe(900); + plugin.player.dispose(); + }); + }); + describe("once a data.json it could not read at startup becomes readable", () => { async function startUnreadable(saveData: (data: unknown) => Promise) { const plugin = makePlugin(saveData, { dataFileExists: true }); diff --git a/src/main.ts b/src/main.ts index 94ae8d7b..400a6612 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, @@ -109,15 +105,14 @@ export default class PodNotes extends Plugin implements IPodNotes { public override app!: PartialAppExtension; public credentials!: CredentialRepository; public feedUrls!: FeedUrlRepository; + public player!: PlayerController; public feeds!: FeedRepository; 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; @@ -156,12 +151,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); @@ -177,19 +166,23 @@ 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, wire the queue - // automation that drops the now-playing episode from the up-next queue, and - // keep the feed repository's subscriptions current. All are store - // subscriptions tied to the plugin lifetime; onunload disposes them. + // Mirror every store-backed slice of state into settings, and keep the feed + // repository's subscriptions current. The subscriptions are tied to the plugin + // lifetime; onunload disposes them. this.storeUnsubscribers.push( bindStoresToSettings(this), - subscribeQueueToCurrentEpisode(), savedFeeds.subscribe((feeds) => this.feeds.setSubscriptions(feeds)), ); @@ -202,21 +195,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). @@ -251,7 +229,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)); @@ -476,12 +454,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 store bindings are disposed, so the position it saves reaches the writer. + 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; @@ -726,9 +705,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. @@ -745,16 +721,16 @@ 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. 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); + // A sync can land after startup, before anything loads. The restored episode + // follows it, and loaded media stays as it is. + this.player?.restore(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.test.ts b/src/player/PlayerController.test.ts new file mode 100644 index 00000000..6233b8a6 --- /dev/null +++ b/src/player/PlayerController.test.ts @@ -0,0 +1,1451 @@ +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; +const subscriptions: Array<() => void> = []; + +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; + const unsubscribe = playedEpisodes.subscribe((played) => { + const record = played[getEpisodeKey(ep)]; + if (!initial && record) { + seen.push({ + time: record.time, + finished: record.finished, + checkpoint: inCheckpoint, + }); + } + }); + initial = false; + subscriptions.push(unsubscribe); + 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(() => { + for (const unsubscribe of subscriptions.splice(0)) unsubscribe(); + 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 }); + }); + + test("again, after a sync, shows the synced progress and resumes from it", () => { + saved(A, 600, 3600); + const { player, host, media, state } = setup(); + player.restore(A); + + saved(A, 2400, 3600); + player.restore(A); + expect(state()).toMatchObject({ status: "restored", episode: A, currentTime: 2400 }); + expect(media().src).toBe(""); + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(2400); + expect(host.saveSettings).not.toHaveBeenCalled(); + }); + + test("again, after a sync, shows the synced selection, even after a seek", () => { + saved(B, 300, 1800); + const { player, state } = setup(); + player.restore(A); + player.seek(900); + + player.restore(B); + + expect(state()).toMatchObject({ + status: "restored", + episode: B, + currentTime: 300, + duration: 1800, + }); + }); + + const starts = [ + ["resume()", (player: PlayerController) => player.resume()], + ["play()", (player: PlayerController) => player.play(A)], + ] as const; + + test.each(starts)( + "again, after an unrelated sync, %s starts where the user moved it", + (_, start) => { + saved(A, 600, 3600); + const { player, media, state } = setup(); + player.restore(A); + player.seek(900); + + player.restore(A); + expect(state()).toMatchObject({ status: "restored", currentTime: 900 }); + start(player); + metadata(media()); + + expect(media().currentTime).toBe(900); + }, + ); + + test.each(starts)( + "after a failed first play, %s starts at the progress a sync brought", + (_, start) => { + saved(A, 600, 3600); + const { player, media } = setup(); + player.restore(A); + player.resume(); + media().emit("error"); + + saved(A, 2400, 3600); + player.restore(A); + start(player); + metadata(media()); + + expect(media().currentTime).toBe(2400); + }, + ); + + test("leaves loaded media alone", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 40); + + player.restore(B); + + expect(state()).toMatchObject({ status: "playing", episode: A, currentTime: 40 }); + expect(media().src).toBe(A.streamUrl); + expect(media().paused).toBe(false); + }); +}); + +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("a new load shows its own position and length until its metadata loads (#94)", () => { + test("after a switch away from a playing episode", () => { + saved(B, 300, 1800); + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + + player.play(B); + + expect(state()).toMatchObject({ + status: "loading", + episode: B, + currentTime: 300, + duration: 1800, + }); + }); + + test("after the queue advances at a natural end", () => { + const next = episode("N", { duration: 900 }); + queue.set({ ...get(queue), episodes: [next] }); + const { player, media, state } = setup(); + player.play(A); + metadata(media(), 60); + reach(media(), 60); + pausedByBrowser(media()); + + media().emit("ended"); + + expect(state()).toMatchObject({ + status: "loading", + episode: next, + currentTime: 0, + duration: 900, + }); + }); + + test("with no saved or feed length, zero", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + + player.play(B); + + expect(state()).toMatchObject({ currentTime: 0, duration: 0 }); + }); +}); + +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.parentNode).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("by the browser while loading still holds once the media is ready", async () => { + const { player, media, state, writes } = setup(); + const written = writes(A); + player.play(A, { time: 30 }); + + media().pause(); + media().emit("pause"); + await flush(); + metadata(media()); + + expect(state().status).toBe("paused"); + expect(media().paused).toBe(true); + 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("consecutive intents on loaded media, with no media event between them", () => { + function pausedAfterMetadata() { + const context = setup(); + context.player.play(A); + metadata(context.media()); + context.player.pause(); + return context; + } + + test("resume() then pause() leaves it paused", () => { + const { player, media, state } = pausedAfterMetadata(); + + player.resume(); + expect(state().status).toBe("playing"); + player.pause(); + media().emit("play"); + media().emit("pause"); + + expect(media().paused).toBe(true); + expect(state().status).toBe("paused"); + }); + + test("toggle() twice leaves it paused", () => { + const { player, media, state } = pausedAfterMetadata(); + + player.toggle(); + player.toggle(); + media().emit("play"); + + expect(media().paused).toBe(true); + expect(state().status).toBe("paused"); + }); + + test("resume() then a seek past the end pauses short of the end without finishing", () => { + queue.set({ ...get(queue), episodes: [B] }); + const { player, media, state } = pausedAfterMetadata(); + + player.resume(); + player.seek(1e9); + media().emit("play"); + + expect(media().paused).toBe(true); + expect(media().currentTime).toBe(3599.75); + expect(state().status).toBe("paused"); + expect(queued()).toEqual(["B"]); + }); +}); + +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("legacy title-only progress (PB-06)", () => { + function savedUnderTitle(ep: Episode, podcastName: string, time: number) { + playedEpisodes.update((played) => ({ + ...played, + [ep.title]: { title: ep.title, podcastName, time, duration: 3600, finished: false }, + })); + } + + const starts = [ + [ + "restore() then resume()", + (player: PlayerController) => { + player.restore(A); + player.resume(); + }, + ], + ["play()", (player: PlayerController) => void player.play(A)], + ] as const; + + test.each(starts)("another show's same-titled record is ignored by %s", (_, start) => { + savedUnderTitle(A, "A Different Podcast", 1800); + const { player, media, state } = setup(); + + start(player); + expect(state().currentTime).toBe(0); + metadata(media()); + + expect(media().currentTime).toBe(0); + }); + + test.each(starts)("a record without a podcast name resumes the episode by %s", (_, start) => { + savedUnderTitle(A, "", 900); + const { player, media, state } = setup(); + + start(player); + expect(state().currentTime).toBe(900); + metadata(media()); + + expect(media().currentTime).toBe(900); + }); + + test("the episode's own record wins over a title-only one", () => { + savedUnderTitle(A, "", 900); + saved(A, 1200, 3600); + const { player, media } = setup(); + + player.restore(A); + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(1200); + }); +}); + +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.each([ + ["resume()", (player: PlayerController) => player.resume()], + ["toggle()", (player: PlayerController) => player.toggle()], + ["seek()", (player: PlayerController) => void player.seek(30)], + ["pause()", (player: PlayerController) => player.pause()], + ["play()", (player: PlayerController) => void player.play(A)], + ])("a %s of the paused episode while it waits makes it stale", (_, intent) => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + player.pause(); + const link = player.deferPlay(); + + intent(player); + + expect(link(B)).toBe(false); + expect(state().episode).toBe(A); + expect(media().src).toBe(A.streamUrl); + }); + + test("a resume() while it already plays makes it stale", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + const link = player.deferPlay(); + + player.resume(); + + expect(link(B)).toBe(false); + expect(state().episode).toBe(A); + }); + + test("a play the browser starts, such as from a media key, makes it stale", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + player.pause(); + const link = player.deferPlay(); + + media().paused = false; + media().emit("play"); + + expect(link(B)).toBe(false); + expect(state()).toMatchObject({ status: "playing", episode: A }); + }); + + test("a pause the browser makes, such as from a media key, makes it stale", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + const link = player.deferPlay(); + + pausedByBrowser(media()); + + expect(link(B)).toBe(false); + expect(state()).toMatchObject({ status: "paused", episode: A }); + }); + + test("a natural end with nothing queued leaves it to play", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media(), 60); + const link = player.deferPlay(); + + reach(media(), 60); + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + + expect(link(B)).toBe(true); + 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(); + + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + + expect(later(C)).toBe(false); + expect(state().episode).toBe(B); + }); + + test("the browser's pause after a segment stops leaves it to play", () => { + const { player, media, state } = setup(); + player.play(A, { time: 100, endTime: 125 }); + metadata(media()); + const link = player.deferPlay(); + + reach(media(), 126); + expect(state().status).toBe("paused"); + media().emit("pause"); + + expect(link(B)).toBe(true); + 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"); + }); + + test("resume() starts at the progress a sync brought, whatever the user moved before the load", () => { + const { player, media } = setup(); + player.restore(A); + player.seek(900); + player.play(B); + media().emit("error"); + + saved(B, 300, 1800); + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(300); + }); + + test.each([ + ["on loaded media", true], + ["while it loads", false], + ])("resume() retries at a position the user sought %s", (_, loaded) => { + saved(A, 600, 3600); + const { player, media } = setup(); + player.play(A); + if (loaded) metadata(media()); + player.seek(2000); + media().emit("error"); + + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(2000); + }); + + test.each([ + [ + "on the loaded episode", + (player: PlayerController, media: () => FakeMedia) => { + player.play(A); + metadata(media()); + reach(media(), 610, 1000); + return player.play(A, { time: 2000 }); + }, + 2000, + ], + [ + "on the restored episode", + (player: PlayerController) => { + player.restore(A); + return player.play(A, { time: 2000 }); + }, + 2000, + ], + [ + "on another episode", + (player: PlayerController, media: () => FakeMedia) => { + player.play(A); + metadata(media()); + return player.play(B, { time: 1500 }); + }, + 1500, + ], + ])("resume() retries at a link's time %s after a media error", (_, link, time) => { + saved(A, 600, 3600); + saved(B, 300, 1800); + const { player, media } = setup(); + expect(link(player, media)).toBe(true); + media().emit("error"); + + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(time); + }); + + test("a link's time on the loaded episode survives a retry that fails too", () => { + saved(A, 600, 3600); + const { player, media } = setup(); + player.play(A); + metadata(media()); + reach(media(), 610, 1000); + player.play(A, { time: 2000 }); + media().emit("error"); + player.resume(); + media().emit("error"); + + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(2000); + }); + + test.each([ + ["resume()", (player: PlayerController) => player.resume()], + ["play()", (player: PlayerController) => void player.play(A)], + ])("a position moved on the restored episode survives a failed first %s", (_, start) => { + saved(A, 600, 3600); + const { player, media, state } = setup(); + player.restore(A); + player.seek(900); + start(player); + media().emit("error"); + expect(state()).toMatchObject({ status: "error", currentTime: 900 }); + + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(900); + }); + + test("resume() starts at the progress a sync brought during an error, after an earlier seek was saved", () => { + saved(A, 600, 3600); + const { player, media } = setup(); + player.play(A); + metadata(media()); + player.seek(630); + reach(media(), 700, 10_000); + reach(media(), 1800, 20_000); + media().emit("error"); + + saved(A, 3000, 3600); + player.restore(A); + player.resume(); + metadata(media()); + + expect(media().currentTime).toBe(3000); + }); +}); + +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("dropped elements", () => { + test("let go of their media when a video replaces the audio and on dispose", () => { + const { player, doc } = setup(); + player.play(A); + const audio = doc.media; + metadata(audio); + + player.play(VIDEO); + const video = doc.media; + metadata(video); + player.dispose(); + + for (const dropped of [audio, video]) { + expect(dropped.src).toBe(""); + expect(dropped.readyState).toBe(0); + expect(dropped.paused).toBe(true); + } + }); +}); + +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("a video carried to a popout reloads where it was and keeps playing in the main window", () => { + const { player, doc, media, state, writes } = setup(); + const written = writes(VIDEO); + const stage = new FakeParent(doc); + player.play(VIDEO); + metadata(media()); + player.videoStage(stage as unknown as HTMLElement); + reach(media(), 40, 1000); + written.length = 0; + + // The popout adopts the stage with the video in it, and the browser reloads the video. + stage.ownerDocument = {}; + media().load(); + media().emit("emptied"); + media().emit("timeupdate"); + + expect(written).toEqual([]); + expect(state()).toMatchObject({ status: "loading", currentTime: 40 }); + expect(doc.body.children).toEqual([media()]); + metadata(media()); + expect(media().currentTime).toBe(40); + expect(media().paused).toBe(false); + expect(state().status).toBe("playing"); + }); + + test.each(["before", "after"])( + "a reset that aborts a pending play (rejected %s the emptied event) plays on once the media is back", + async (order) => { + const { player, doc, media, state } = setup(); + const stage = new FakeParent(doc); + player.play(VIDEO); + metadata(media()); + player.videoStage(stage as unknown as HTMLElement); + expect(media().plays).toHaveLength(1); + + stage.ownerDocument = {}; + media().load(); + if (order === "before") await flush(); + media().emit("emptied"); + await flush(); + metadata(media()); + + expect(media().paused).toBe(false); + expect(state().status).toBe("playing"); + }, + ); + + /** VIDEO played to its end in a stage the popout then carried away, and the browser reloaded it. */ + function finishedThenReset(seekDuringPlay?: number) { + const context = setup(); + const { player, doc, media } = context; + const stage = new FakeParent(doc); + player.play(VIDEO); + metadata(media(), 60); + player.videoStage(stage as unknown as HTMLElement); + if (seekDuringPlay !== undefined) player.seek(seekDuringPlay); + reach(media(), 60); + pausedByBrowser(media()); + media().emit("ended"); + + stage.ownerDocument = {}; + media().load(); + media().emit("emptied"); + metadata(media(), 60); + return context; + } + + test("a finished video that a reset reloads stays finished", () => { + const { player, state } = finishedThenReset(); + + player.save(); + player.play(B); + + expect(progress(VIDEO)).toMatchObject({ time: 60, duration: 60, finished: true }); + expect(queued()).toEqual([]); + expect(state()).toMatchObject({ status: "loading", episode: B }); + }); + + test("a finished video that a reset reloads lets go of its media", () => { + const { media } = finishedThenReset(); + + expect(media().src).toBe(""); + }); + + test("resume() starts a finished video that a reset reloaded over", () => { + const { player, media, state } = finishedThenReset(); + + player.resume(); + metadata(media(), 60); + + expect(media().src).toBe(VIDEO.streamUrl); + expect(media().currentTime).toBe(0); + expect(media().paused).toBe(false); + expect(state().status).toBe("playing"); + }); + + test("a finished video sought during play restarts at the beginning after a reset", () => { + const { player, media, state } = finishedThenReset(30); + + player.resume(); + metadata(media(), 60); + + expect(media().currentTime).toBe(0); + expect(media().paused).toBe(false); + expect(state().status).toBe("playing"); + }); + + test("a new rendition of a finished episode starts at the beginning", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media(), 60); + reach(media(), 60); + pausedByBrowser(media()); + media().emit("ended"); + expect(state().status).toBe("ended"); + + player.play({ ...A, streamUrl: "https://cdn.example.com/A-reissued.mp3" }); + metadata(media(), 60); + + expect(media().src).toBe("https://cdn.example.com/A-reissued.mp3"); + expect(media().currentTime).toBe(0); + expect(media().paused).toBe(false); + }); + + test("a seek in a finished video that a reset reloaded plays from there", () => { + const { player, media, state } = finishedThenReset(); + + player.seek(30); + expect(state()).toMatchObject({ status: "paused", currentTime: 30 }); + player.resume(); + metadata(media(), 60); + + expect(media().currentTime).toBe(30); + expect(media().paused).toBe(false); + }); + + test("a reset element saves no position until it holds media again", () => { + const { player, media, state, writes } = setup(); + const written = writes(A); + player.play(A); + metadata(media()); + reach(media(), 40, 1000); + written.length = 0; + + media().load(); + player.save(); + player.pause(); + media().emit("emptied"); + + expect(written).toEqual([]); + expect(state()).toMatchObject({ status: "paused", currentTime: 40 }); + metadata(media()); + expect(media().currentTime).toBe(40); + expect(media().paused).toBe(true); + }); + + test("names the video after the episode it loads", () => { + const { player, media } = setup(); + const next = episode("W", { + streamUrl: "https://cdn.example.com/W.mp4", + mediaType: "video", + }); + + player.play(VIDEO); + expect(media().attributes.get("aria-label")).toBe("V"); + player.play(next); + + expect(media().tagName).toBe("VIDEO"); + expect(media().attributes.get("aria-label")).toBe("W"); + }); + + 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..a4dd03ae --- /dev/null +++ b/src/player/PlayerController.ts @@ -0,0 +1,596 @@ +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 PlaybackSource } 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. */ + 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; +/** HTMLMediaElement.HAVE_NOTHING: the element holds no media. */ +const HAVE_NOTHING = 0; +/** A seek at or past the end stops this far short of it, so a seek never finishes an episode. */ +const END_MARGIN_S = 0.25; +/** The media element's class, wherever it is. */ +export const MEDIA_CLASS = "podnotes-media"; + +const isTime = (value: number) => Number.isFinite(value) && value >= 0; +const isOptionalTime = (value: number | undefined) => value === undefined || isTime(value); +const finiteOr0 = (value: number) => (Number.isFinite(value) ? value : 0); + +/** + * 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; + /** The user moved the position and it is not saved yet: a new load starts there, not at the saved progress. */ + private moved = false; + 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 paused at its saved position, without loading it: at + * startup, and again after a sync while nothing is loaded. Loaded media stays, and + * so does a position the user moved the same episode to. + */ + restore(episode: Episode | undefined): void { + const { status, episode: shown, currentTime } = this.snapshot; + if (!episode || (status !== "empty" && status !== "restored")) return; + this.moved &&= !!shown && getEpisodeKey(shown) === getEpisodeKey(episode); + this.setState({ + status: "restored", + episode, + mediaType: getEpisodeMediaType(episode), + currentTime: this.moved ? currentTime : this.resumeTime(episode), + duration: this.startDuration(episode), + }); + } + + /** + * 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 || !isOptionalTime(time) || !isOptionalTime(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, 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.moved = true; + 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 ? this.restartTime(episode) : this.resumeTime(episode); + this.load(episode, source, time ?? resumeAt, endTime); + this.moved = time !== undefined || (sameEpisode && this.moved); + return true; + } + + /** Continues the current episode without selecting it. */ + resume(): void { + const { status, episode } = this.snapshot; + if (this.disposed || !episode) return; + this.requests++; + if (status === "playing" || status === "loading") return; + this.endPreview(); + if (!this.session) { + const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); + this.load(episode, source, this.restartTime(episode)); + } else { + 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.requests++; + this.endPreview(); + this.moved = true; + 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 }); + 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, resume, pause, seek 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.release(); + this.el.remove(); + } + + private select(episode: Episode): void { + writePlayerSettings(this.plugin, { currentEpisode: episode }); + queue.remove(episode); + } + + private load(episode: Episode, source: PlaybackSource, time: number, endTime?: number): void { + this.session = null; + const duration = this.startDuration(episode); + if (!source.src) { + this.release(); + this.setState({ + status: "error", + episode, + mediaType: source.mediaType, + currentTime: time, + duration, + }); + return; + } + + if (this.el.tagName.toLowerCase() !== source.mediaType) { + this.release(); + 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.setAttribute("aria-label", episode.title); + this.el.src = source.src; + // In the intent's own call, so a mobile user's tap still counts as the gesture. + this.playElement(); + } + + /** Stops the element and lets go of its media, which a removed element would keep. */ + private release(): void { + this.el.removeAttribute("src"); + this.el.load(); + } + + 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(), + emptied: () => this.onEmptied(), + 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 && el.parentNode !== stage) stage.prepend(el); + if (!staged && el.parentNode !== this.doc.body) this.doc.body.append(el); + el.hidden = !staged; + } + + /** play() unpauses the element at once, so the next intent can act on the new status. */ + private start(): void { + const { status } = this.snapshot; + if (status === "playing" || status === "loading") return; + this.setState({ status: this.holdsMedia() ? "playing" : "loading" }); + this.playElement(); + } + + private playElement(): void { + this.el.play().catch((error: unknown) => { + // A pause, a newer load or an element reset aborts a play, and onEmptied + // recovers a reset. Only a refusal, such as the autoplay policy, pauses. + if (error instanceof DOMException && error.name === "AbortError") return; + // A play that has since started, such as the next load's, keeps 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; + const { duration, status: was } = this.snapshot; + const status = was === "ended" ? "paused" : was; + if (!this.holdsMedia()) { + if (session) session.pendingTime = time; + this.setState({ currentTime: time, status }); + return true; + } + + 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 }); + 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.holdsMedia() + ? { 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; + // A reset element (see onEmptied) lost the play() of its load. + if (this.snapshot.status === "loading" && this.el.paused) this.playElement(); + this.setState({ + status: this.el.paused ? "paused" : "playing", + duration: finiteOr0(this.el.duration), + }); + this.moveTo(session.pendingTime); + } + + /** + * The element dropped its media without a load from us. Moving a media element + * to another window's document reloads it, which a video stage in a popout does. + * Start again where it was, playing if it played, back in the main window. A + * finished episode stays finished: its session ends, and resume() loads it again. + */ + private onEmptied(): void { + const session = this.session; + if (!session?.ready) return; + if (this.snapshot.status === "ended") { + this.session = null; + this.release(); + } else { + session.ready = false; + session.pendingTime = this.snapshot.currentTime; + if (this.snapshot.status === "playing") this.setState({ status: "loading" }); + } + this.placeElement(); + } + + private onPlay(): void { + const { status } = this.snapshot; + if (!this.holdsMedia() || this.el.paused) return; + // A play the player did not start, such as from a media key, is the user's. + if (status !== "playing" && status !== "loading") this.requests++; + this.setState({ status: "playing" }); + } + + /** A reset pauses without this event (see onEmptied), so a pause while loading is a real one. */ + private onPause(): void { + const { status } = this.snapshot; + if ((status !== "playing" && status !== "loading") || !this.el.paused) return; + // A pause the player did not make and no natural end made, such as from a media key. + if (!this.el.ended) this.requests++; + this.settlePaused(); + } + + private onTimeUpdate(): void { + const session = this.session; + if (!session || !this.holdsMedia()) 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 || !this.holdsMedia() || !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" }); + } + + /** The current load set its start position, and the element still holds its media. */ + private holdsMedia(): boolean { + return !!this.session?.ready && this.el.readyState > HAVE_NOTHING; + } + + private persist(): void { + const { session } = this; + const { episode, duration } = this.snapshot; + if (!session || !this.holdsMedia() || session.preview || this.disposed || !episode) return; + const time = this.el.currentTime; + playedEpisodes.setEpisodeTime(episode, time, duration, duration > 0 && time >= duration); + this.moved = false; + } + + /** 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) + ); + } + + /** + * Where the shown episode starts on a new load: where it is while a session holds it + * or after the user moved it, else at its saved progress, which a sync may have changed. + */ + private restartTime(episode: Episode): number { + const { status, currentTime } = this.snapshot; + const live = status !== "ended" && (!!this.session || this.moved); + return live ? currentTime : this.resumeTime(episode); + } + + 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; + } + + /** The length shown until the metadata loads. */ + private startDuration(episode: Episode): number { + return this.savedProgress(episode)?.duration || episode.duration || 0; + } + + private setState(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + this.store.set(this.snapshot); + } +} 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..3fbe5d8d --- /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 PlaybackSource { + 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, +): PlaybackSource { + 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) }; +} diff --git a/src/store/index.test.ts b/src/store/index.test.ts index e9689785..d31b2ef7 100644 --- a/src/store/index.test.ts +++ b/src/store/index.test.ts @@ -1,5 +1,5 @@ import { get } from "svelte/store"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import type { Episode } from "src/types/Episode"; import type { IPodNotes } from "src/types/IPodNotes"; @@ -13,18 +13,13 @@ import { } from "src/constants"; import { bindStoresToSettings } from "src/store/persistence"; import { - currentEpisode, - currentTime, dedupeEpisodesByKey, downloadedEpisodes, - duration, localFiles, playedEpisodes, - plugin, queue, reorderEpisodes, sanitizeEpisodeListLimit, - subscribeQueueToCurrentEpisode, } from "./index"; const episode: Episode = { @@ -456,40 +451,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: [] } }, @@ -499,7 +516,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(); @@ -507,101 +524,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)", () => { @@ -628,42 +550,3 @@ describe("sanitizeEpisodeListLimit (issue #114)", () => { expect(sanitizeEpisodeListLimit(10 ** 9)).toBe(MAX_EPISODE_LIST_LIMIT); }); }); - -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 56a288df..cd80e2ab 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"; @@ -19,50 +17,8 @@ export { savedFeeds, episodeListLimit, sanitizeEpisodeListLimit } from "./feeds" 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; @@ -221,6 +177,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", @@ -237,19 +195,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, @@ -274,27 +219,32 @@ 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; - + /** Puts the episode first, unless it is already queued. */ + prepend: (episode: Episode) => { update((queue) => { - const nextEp = queue.episodes.shift(); + if (isAlreadyQueued(queue.episodes, episode)) return queue; - if (nextEp) { - currentEpisode.set(nextEp, false); - } + queue.episodes = [episode, ...queue.episodes]; + return queue; + }); + }, + /** 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); + // 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), }; })(); @@ -419,57 +369,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 b9dbd094..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) => { @@ -107,18 +97,35 @@ 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; } /** - * 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. */ @@ -135,6 +142,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 +152,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/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..15ae9b78 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -1,23 +1,9 @@ -
- {#if isVideoEpisode} -
- - - +
+ {#if showVideo} +
{#if isLoading}
@@ -553,12 +137,12 @@ {/if} @@ -576,7 +160,7 @@ @@ -620,37 +204,19 @@ {/if}

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

- {#if !isVideoEpisode} - - {/if} -
- {formatSeconds($currentTime, "HH:mm:ss")} + {formatSeconds($playerState.currentTime, "HH:mm:ss")} - {formatSeconds($duration - $currentTime, "HH:mm:ss")} + {formatSeconds(remaining, "HH:mm:ss")}
@@ -670,22 +236,30 @@
- Volume: {Math.round(playerVolume * 100)}% - + Volume: {Math.round($playerState.volume * 100)}% + player.setVolume(event.detail.value)} + value={$playerState.volume} + limits={[0, 1, 0.05]} + />
- {$playbackRate}x + {$playerState.playbackRate}x player.setPlaybackRate(event.detail.value)} + value={$playerState.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 = {}, target = document.body) { + 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; -} - -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, - ); + return { ...render(EpisodePlayer, { target }), state, player }; } -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(); - }); - - test("arms a requested segment after metadata loads", async () => { - requestedPlaybackTime.set({ - episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, - time: 240, - endTime: 260, - }); - - 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(); + expect(container.querySelector(".podcast-artwork-load-error")?.textContent).toContain( + "Could not load this episode's media.", + ); }); - 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, + 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); - - 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"); - }); - - 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; - - await fireEvent.loadedMetadata(audio); - - expect(get(currentTime)).toBe(1800); - expect(get(isPaused)).toBe(false); - expect(get(requestedPlaybackTime)).toBeNull(); + 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("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); - - 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"); - }); - - 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); - - 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("app://resource/Downloads/legacy-video.mp4?token"); - }); - - 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); + const slider = container.querySelector(".playbackrate-container input") as HTMLInputElement; + await fireEvent.input(slider, { target: { value: "2.2" } }); - 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("app://resource/Downloads/video.mp4?token"); - }); -}); - -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); + expect(player.setPlaybackRate).toHaveBeenCalledWith(2.2); }); - test("saves the exact position immediately on pause, bypassing the throttle", async () => { - let now = 5_000_000; - vi.spyOn(Date, "now").mockImplementation(() => now); + test("the volume label shows the volume, and its slider sets it", async () => { + const { container, player } = renderPlayer({ volume: 0.4 }); - const audio = await renderLoadedPlayer(); + 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" } }); - // 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); + expect(player.setVolume).toHaveBeenCalledWith(0.65); }); - test("saves the position when the player closes, at once rather than as a checkpoint", async () => { - const audio = await renderLoadedPlayer(); - currentTime.set(42); + 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(); - audio.unmount(); + await fireEvent.click(screen.getByText("Queued Episode")); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(42); - expect(get(plugin).checkpoint).not.toHaveBeenCalled(); + expect(player.play).toHaveBeenCalledWith(queued); + expect(get(viewState)).toBe(ViewState.Player); }); - 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); + test("a video episode in a popout shows its artwork, since the video plays in the main window", async () => { + const popout = document.body.appendChild(document.createElement("iframe")); + const { container, player } = renderPlayer( + { mediaType: "video", status: "paused" }, + popout.contentDocument?.body, + ); + await Promise.resolve(); - expect(get(playedEpisodes)[episodeKey]?.time).toBe(1800); + expect(container.querySelector(".episode-video-container")).toBeNull(); + expect(container.querySelector(".podcast-video-fullscreen")).toBeNull(); + await fireEvent.click(container.querySelector(".hover-container") as HTMLElement); + expect(player.toggle).toHaveBeenCalledTimes(1); + popout.remove(); }); - 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 b4a94e77..ca54635c 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -9,20 +9,16 @@ import type { ParsedFeedEpisodes } from "src/parser/feedDocumentParser"; import { FeedRepository } from "src/services/FeedRepository"; import { FeedUrlRepository } from "src/services/FeedUrlRepository"; import { resolveFeedUrl } from "src/services/privateFeeds"; -import { - currentEpisode, - hidePlayedEpisodes, - playedEpisodes, - plugin, - savedFeeds, - viewState, -} from "src/store"; +import { hidePlayedEpisodes, playedEpisodes, plugin, savedFeeds, viewState } from "src/store"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { ViewState } from "src/types/ViewState"; import { privateFeedPlaceholder } from "src/utility/privateFeedUrl"; import EpisodePlayerStub from "../../../tests/mocks/EpisodePlayerStub.svelte"; import { deferred, feedDocument, MemoryStorage } from "../../../tests/mocks/feeds"; +import { FakeDocument } from "../../../tests/mocks/fakeMedia"; +import { DEFAULT_SETTINGS } from "src/constants"; +import { PlayerController } from "src/player/PlayerController"; vi.mock("./EpisodePlayer.svelte", () => ({ default: EpisodePlayerStub, @@ -114,6 +110,7 @@ function usePlugin(fields: Record = {}, cacheEnabled = false) { settings: { feedCache: { enabled: cacheEnabled, ttlHours: 6 } }, ...fields, feeds, + player, } as never); return feeds; } @@ -148,10 +145,18 @@ function resetStores() { hidePlayedEpisodes.set(false); playedEpisodes.set({}); viewState.set(ViewState.PodcastGrid); - currentEpisode.update(() => undefined as unknown as Episode); + player = new PlayerController(new FakeDocument().asPlayerDocument(), { + settings: structuredClone(DEFAULT_SETTINGS), + app: { vault: {} as never }, + checkpoint: (update) => update(), + saveSettings: () => Promise.resolve(), + }); plugin.set(undefined as never); } +let player: PlayerController; +const playingEpisode = () => get(player.state).episode; + const listed = () => Array.from(document.querySelectorAll(".episode-item-title"), (node) => node.textContent?.trim(), @@ -218,10 +223,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); @@ -504,7 +509,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 4ae333ea..38f018f3 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -1,7 +1,6 @@
@@ -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/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)); } 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 3c8e3e21..b9ede718 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -24,13 +24,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"; @@ -45,7 +43,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"; @@ -1023,9 +1020,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 diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index be95e7b2..2f4e44ef 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -1,7 +1,7 @@ import type { Episode } from "src/types/Episode"; import { downloadedEpisodes } from "src/store"; import encodePodnotesURI from "./encodePodnotesURI"; -import { isLocalFile } from "./isLocalFile"; +import { episodeLinkTarget } from "./episodeLinkTarget"; /** * Build an `obsidian://podnotes` deep link that reopens `episode` in the PodNotes @@ -18,10 +18,7 @@ import { isLocalFile } from "./isLocalFile"; * address it, so a template degrades to an empty value rather than a broken link. */ export default function buildEpisodeResumeLink(episode: Episode): string { - const downloadedPath = () => downloadedEpisodes.getEpisode(episode)?.filePath; - const target = isLocalFile(episode) - ? (episode.filePath ?? downloadedPath()) - : (episode.feedUrl ?? downloadedPath()); + const target = episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath; // URIHandler rejects an empty episodeName, so without a title there is no // working link to emit — degrade to "" rather than a dead link (UR-03). diff --git a/src/utility/episodeLinkTarget.ts b/src/utility/episodeLinkTarget.ts new file mode 100644 index 00000000..22e4a36d --- /dev/null +++ b/src/utility/episodeLinkTarget.ts @@ -0,0 +1,13 @@ +import { downloadedEpisodes } from "src/store"; +import type { Episode } from "src/types/Episode"; +import { isLocalFile } from "./isLocalFile"; + +/** + * The `url` of a PodNotes link to `episode`: a local file's own vault path, else the + * path it is registered under, and the feed for any other episode. The URI handler + * compares an incoming link's `url` with it, so links PodNotes writes find their episode. + */ +export function episodeLinkTarget(episode: Episode): string | undefined { + if (!isLocalFile(episode)) return episode.feedUrl; + return episode.filePath ?? downloadedEpisodes.getEpisode(episode)?.filePath; +} diff --git a/src/utility/localEpisodeFromFile.ts b/src/utility/localEpisodeFromFile.ts new file mode 100644 index 00000000..0c313702 --- /dev/null +++ b/src/utility/localEpisodeFromFile.ts @@ -0,0 +1,23 @@ +import type { App, TFile } from "obsidian"; +import type { EpisodeMediaType } from "src/types/Episode"; +import type { LocalEpisode } from "src/types/LocalEpisode"; +import { createMediaUrlObjectFromFilePath } from "./createMediaUrlObjectFromFilePath"; + +/** The episode that plays a vault file. */ +export function localEpisodeFromFile( + app: App, + file: TFile, + mediaType: EpisodeMediaType, +): LocalEpisode { + return { + title: file.basename, + description: "", + content: "", + podcastName: "local file", + url: app.fileManager.generateMarkdownLink(file, ""), + streamUrl: createMediaUrlObjectFromFilePath(app.vault, file.path), + filePath: file.path, + episodeDate: new Date(file.stat.ctime), + mediaType, + }; +} diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 01dc1105..0092ffc2 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -161,11 +161,12 @@ const createSharedHarness = createPluginHarness({ // Beyond plugin-loaded and the ready command, PodNotes is only usable once its // public API is attached and its protocol handler is registered. waitUntilReady: podNotesReady, - // PodNotes persists asynchronously and keeps live player views; both must - // settle before the settings file is rolled back, so they run while the - // plugin is still enabled. + // PodNotes persists asynchronously, keeps live player views, and plays on with + // them closed; all must settle before the settings file is rolled back, so they + // run while the plugin is still enabled. beforeDataRestore: async (obsidian) => { await detachPodNotesViews(obsidian); + await stopPodNotesPlayback(obsidian); await flushPodNotesSaves(obsidian); const library = await import("./journey/library"); if (library.hasJourneySnapshot(obsidian)) { @@ -262,6 +263,13 @@ async function detachPodNotesViews(obsidian: ObsidianClient): Promise { ); } +// Playing on would save a checkpoint over the rolled-back file. +async function stopPodNotesPlayback(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJson( + `(() => { app.plugins.plugins.${PLUGIN_ID}?.api?.stop(); return true; })()`, + ); +} + async function flushPodNotesSaves(obsidian: ObsidianClient): Promise { await evalJsonAsync( obsidian, 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 4e99b98c..2beac5b4 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, 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); @@ -148,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; @@ -187,30 +225,281 @@ 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, ); + + 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("Start playing while a slow link to another show loads keeps playing the paused episode", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const server = await serve(FEED, OTHER_SHOW); + await seedLibrary(context, server, { subscriptions: [FEED, OTHER_SHOW] }); + await playFromList(LONG); + await click(obsidian, ui.playbackToggle); + await waitForPlayback( + obsidian, + (playback) => !playback.playing && playback.media?.paused === true, + "paused", + ); + + server.delayFeed(OTHER_SHOW, 3000); + const otherFeedPath = new URL(server.feedUrl(OTHER_SHOW)).pathname; + const notePath = sandbox.path("Slow 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); + const requestsBefore = server.requests().filter((path) => path === otherFeedPath).length; + await click(obsidian, ui.readingLink("00:00:10")); + await obsidian.waitFor( + () => + server.requests().filter((path) => path === otherFeedPath).length > requestsBefore, + { timeoutMs: 10_000, intervalMs: 50, message: "the link's feed request to arrive" }, + ); + await runCommand(obsidian, `${PLUGIN_ID}:start-playing`); + await expectAdvancing("resumed while the link's feed loads"); + + await obsidian.sleep(4000); + const after = await expectAdvancing("after the link's feed arrived"); + expect(after.episode).toEqual(episodeRef(FEED, LONG)); + expect(after.media?.src).toBe(server.mediaUrl(FEED, LONG)); + await obsidian.dev.evalJsonAsync( + `(async () => { await app.plugins.plugins.${PLUGIN_ID}.flushSaves(); return true; })()`, + ); + expect((await readLibrary(plugin)).currentEpisode).toEqual(episodeRef(FEED, LONG)); + }, 60_000); + + 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); }); diff --git a/tests/e2e/journey/library.ts b/tests/e2e/journey/library.ts index 42b8918b..3194b5ee 100644 --- a/tests/e2e/journey/library.ts +++ b/tests/e2e/journey/library.ts @@ -423,7 +423,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