From d2aed85f98f8c4344fe82f55b7e17547c7e66e41 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 22:00:16 +0200 Subject: [PATCH 1/4] test(sync): reproduce paused playback overwriting synced progress --- src/player/PlayerController.test.ts | 76 ++++++++++++++ tests/e2e/synced-progress-handoff.test.ts | 121 ++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/e2e/synced-progress-handoff.test.ts diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index b6449d67..ef47523f 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -109,6 +109,82 @@ function pausedByBrowser(media: FakeMedia) { const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +describe("synced progress handoff", () => { + function paused() { + const context = setup(); + context.player.play(A); + metadata(context.media()); + reach(context.media(), 17); + context.player.pause(); + return context; + } + + test("adopts the loaded paused episode's synced position even when another episode is selected", () => { + const { player, media, state } = paused(); + saved(A, 45); + + player.restore(B); + + expect(state()).toMatchObject({ status: "paused", episode: A, currentTime: 45 }); + expect(media().currentTime).toBe(45); + player.save(); + expect(progress(A)).toMatchObject({ time: 45, finished: false }); + }); + + test("an unchanged paused save does not overwrite a different progress record", () => { + const { player } = paused(); + saved(A, 45); + + player.save(); + player.dispose(); + + expect(progress(A)).toMatchObject({ time: 45, finished: false }); + }); + + test("a local seek after adoption wins over the next sync", () => { + const { player, media, state } = paused(); + saved(A, 45); + player.restore(A); + expect(state().currentTime).toBe(45); + + player.seek(25); + saved(A, 50); + player.restore(A); + player.save(); + + expect(state().currentTime).toBe(25); + expect(media().currentTime).toBe(25); + expect(progress(A)?.time).toBe(25); + }); + + test("playing after adoption owns the position before the first timeupdate", () => { + const { player, state } = paused(); + saved(A, 45); + player.restore(A); + expect(state().currentTime).toBe(45); + + player.resume(); + saved(A, 50); + player.restore(A); + player.save(); + + expect(state()).toMatchObject({ status: "playing", currentTime: 45 }); + expect(progress(A)?.time).toBe(45); + }); + + test("a synced finish survives delayed paused media events and saving", () => { + const { player, media } = paused(); + saved(A, 3600, 3600, true); + player.restore(A); + + media().emit("timeupdate"); + player.save(); + player.dispose(); + + expect(progress(A)).toMatchObject({ time: 3600, duration: 3600, finished: true }); + }); +}); + beforeEach(() => { now = 0; vi.spyOn(Date, "now").mockImplementation(() => now); diff --git a/tests/e2e/synced-progress-handoff.test.ts b/tests/e2e/synced-progress-handoff.test.ts new file mode 100644 index 00000000..a585dc82 --- /dev/null +++ b/tests/e2e/synced-progress-handoff.test.ts @@ -0,0 +1,121 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; +import { click, showLibrary, showPodNotes, ui } from "./journey/actions"; +import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { observePlayback, seedLibrary, waitForPlayback } from "./journey/library"; +import { jsLiteral, PLUGIN_ID, WAIT_OPTS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("synced-progress-handoff"); +const FEED: FixtureFeed = { + title: "Handoff Podcast", + slug: "handoff", + artwork: true, + episodes: [{ title: "Handoff Episode", seconds: 60, pubDate: pubDate(0) }], +}; +const PROGRESS_KEY = "Handoff Podcast::Handoff Episode"; +const POLL = { timeout: WAIT_OPTS.timeoutMs, interval: WAIT_OPTS.intervalMs }; + +for (const action of ["hide", "seek"] as const) { + test(`a paused episode adopts synced progress before a local ${action} (#461)`, async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [FEED] }); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(FEED.title)); + await click(obsidian, ui.episode("Handoff Episode")); + await waitForPlayback(obsidian, (state) => state.playing && state.seconds > 0, "playing"); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (state) => !state.playing, "paused"); + const before = await observePlayback(obsidian); + await plugin.waitForData( + (data) => data.playedEpisodes?.[PROGRESS_KEY]?.time === before.seconds, + WAIT_OPTS, + ); + expect(before.seconds).toBeLessThan(45); + + await obsidian.dev.evalJson(`(() => { + const original = navigator.mediaSession.setPositionState.bind(navigator.mediaSession); + navigator.mediaSession.setPositionState = (state) => { + window.__handoffMediaPosition = state?.position; + return original(state); + }; + window.__handoffRestoreMediaPosition = () => { navigator.mediaSession.setPositionState = original; }; + return true; + })()`); + onTestFinished(() => + obsidian.dev.evalJson(`(() => { + window.__handoffRestoreMediaPosition?.(); + delete window.__handoffRestoreMediaPosition; + delete window.__handoffMediaPosition; + require("@electron/remote").getCurrentWindow().show(); + return true; + })()`), + ); + await plugin.data().patch((data) => { + const progress = data.playedEpisodes?.[PROGRESS_KEY]; + if (!progress) throw new Error("The paused episode has no saved progress."); + progress.time = 45; + }); + await expect + .poll( + () => + obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.settings.playedEpisodes[${jsLiteral(PROGRESS_KEY)}]?.time`, + ), + POLL, + ) + .toBe(45); + await expect + .poll(() => observePlayback(obsidian), POLL) + .toMatchObject({ + playing: false, + seconds: 45, + media: { seconds: 45, paused: true }, + }); + expect(await obsidian.dev.evalJson("window.__handoffMediaPosition")).toBe(45); + await expect + .poll( + () => + obsidian.dev.evalJson( + 'document.querySelector(".episode-player .status-container")?.textContent', + ), + POLL, + ) + .toContain("00:00:45"); + await click(obsidian, ui.collapsePlayer); + expect( + await obsidian.dev.evalJson( + 'document.querySelector(".pn-mini [role=progressbar]")?.getAttribute("aria-valuenow")', + ), + ).toBe("45"); + + if (action === "seek") { + await click(obsidian, ui.openPlayer); + await obsidian.dev.evalJson(`(() => { + app.plugins.plugins.${PLUGIN_ID}.api.currentTime = 25; + return true; + })()`); + } else { + await obsidian.dev.evalJson( + 'require("@electron/remote").getCurrentWindow().hide(), true', + ); + await expect + .poll(() => obsidian.dev.evalJson("document.visibilityState"), POLL) + .toBe("hidden"); + } + await obsidian.dev.evalJsonAsync(`(async () => { + const plugin = app.plugins.plugins.${PLUGIN_ID}; + plugin.player.save(); + await plugin.flushSaves(); + return true; + })()`); + const expected = action === "seek" ? 25 : 45; + expect( + (await plugin.data().read()).playedEpisodes?.[PROGRESS_KEY]?.time, + ).toBe(expected); + expect((await observePlayback(obsidian)).seconds).toBe(expected); + }); +} From 4f2a3d5cbb245c15a46f6e4813bdc5bc491bd9be Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 22:14:15 +0200 Subject: [PATCH 2/4] fix(sync): adopt synced progress in an untouched paused player --- src/main.activateView.test.ts | 4 +- src/main.persistence.test.ts | 75 +++++++++++++++++++++- src/main.ts | 2 +- src/player/PlayerController.test.ts | 97 ++++++++++++++++++++++++++++- src/player/PlayerController.ts | 18 ++++-- 5 files changed, 188 insertions(+), 8 deletions(-) diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index e06cb92e..42b12b75 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -673,8 +673,10 @@ describe("PodNotes onload wiring (#55)", () => { 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. + // jsdom loads no media and play() is stubbed; the browser would hold its metadata + // and be playing by now. Object.defineProperty(media(), "readyState", { value: HTMLMediaElement.HAVE_METADATA }); + Object.defineProperty(media(), "paused", { value: false }); media().dispatchEvent(new Event("loadedmetadata")); media().currentTime = 1300; diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 094a0ae7..2c22253e 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { get } from "svelte/store"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { Notice, type SecretStorage } from "obsidian"; import * as obsidian from "obsidian"; import { DEFAULT_SETTINGS } from "./constants"; @@ -870,6 +870,79 @@ describe("PodNotes persistence integration", () => { }); }); + describe("while the loaded episode is paused at 17 s", () => { + async function paused() { + const plugin = makePlugin(); + const episode = { + title: "A", + streamUrl: "https://pod.example.com/a.mp3", + url: "", + description: "", + content: "", + podcastName: "Pod", + }; + let disk: unknown = JSON.parse(JSON.stringify(encodePodNotesData(plugin.settings))); + Object.assign(plugin, { + loadData: vi.fn(async () => structuredClone(disk)), + saveData: vi.fn(async (next: unknown) => { + disk = JSON.parse(JSON.stringify(next)); + }), + }); + Object.assign(plugin.app.vault.adapter, { + read: vi.fn(async () => dataFileText(disk)), + }); + attachDataFileWriter(plugin); + (plugin as unknown as { setCollectionStores(): void }).setCollectionStores(); + onTestFinished(bindStoresToSettings(plugin)); + const doc = new FakeDocument(); + plugin.player = new PlayerController(doc.asPlayerDocument(), plugin); + onTestFinished(() => plugin.player.dispose()); + plugin.player.play(episode); + doc.media.duration = 3600; + doc.media.emit("loadedmetadata"); + doc.media.currentTime = 17; + plugin.player.pause(); + await plugin.flushSaves(); + const saved = () => + (disk as { playedEpisodes: Record }) + .playedEpisodes["Pod::A"]; + expect(saved()).toMatchObject({ time: 17, finished: false }); + return { + plugin, + doc, + saved, + sync: async (time: number) => { + saved().time = time; + await plugin.onExternalSettingsChange(); + }, + }; + } + + it("shows the progress a sync brings and keeps it in data.json through a save", async () => { + const { plugin, doc, saved, sync } = await paused(); + + await sync(45); + plugin.player.save(); + await plugin.flushSaves(); + + expect(saved()).toMatchObject({ time: 45, finished: false }); + expect(get(plugin.player.state)).toMatchObject({ status: "paused", currentTime: 45 }); + expect(doc.media.currentTime).toBe(45); + }); + + it("saves a seek made after the sync", async () => { + const { plugin, doc, saved, sync } = await paused(); + + await sync(45); + plugin.player.seek(25); + plugin.player.save(); + await plugin.flushSaves(); + + expect(saved()).toMatchObject({ time: 25, finished: false }); + expect(doc.media.currentTime).toBe(25); + }); + }); + 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 e6906b4b..b552f0df 100644 --- a/src/main.ts +++ b/src/main.ts @@ -658,7 +658,7 @@ export default class PodNotes extends Plugin implements IPodNotes { this.persistenceUnknownFields = decoded.unknownFields; this.setCollectionStores(); // A sync can land after startup, before anything loads. The restored episode - // follows it, and loaded media stays as it is. + // follows it, and so does paused media this device has not moved. this.player?.restore(this.settings.currentEpisode); } diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index ef47523f..b20c7b09 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -183,6 +183,84 @@ describe("synced progress handoff", () => { expect(progress(A)).toMatchObject({ time: 3600, duration: 3600, finished: true }); }); + + test("the seek's delayed timeupdate at a checkpoint does not save over newer progress", () => { + const { player, media } = paused(); + saved(A, 45); + player.restore(A); + + saved(A, 50); + reach(media(), media().currentTime, 10_000); + + expect(progress(A)?.time).toBe(50); + }); + + test.each([ + ["play()", (player: PlayerController) => player.play(A)], + [ + "a media key", + (_: PlayerController, media: FakeMedia) => { + media.paused = false; + media.emit("play"); + }, + ], + ] as const)("%s after adoption plays on from the synced position", (_, start) => { + const { player, media, state } = paused(); + saved(A, 45); + player.restore(A); + + start(player, media()); + saved(A, 50); + player.restore(A); + player.save(); + + expect(state()).toMatchObject({ status: "playing", currentTime: 45 }); + expect(media()).toMatchObject({ currentTime: 45, paused: false }); + expect(progress(A)?.time).toBe(45); + }); + + test("a natural end keeps a re-listen synced afterwards", () => { + const { player, media } = setup({ autoQueue: false }); + player.play(A); + metadata(media(), 60); + reach(media(), 60); + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + + saved(A, 20, 60); + player.restore(A); + player.save(); + player.dispose(); + + expect(progress(A)).toMatchObject({ time: 20, duration: 60, finished: false }); + }); + + test("a seek made before the sync arrives wins over it", () => { + const { player, media, state } = paused(); + player.seek(25); + + saved(A, 45); + player.restore(A); + player.save(); + + expect(state().currentTime).toBe(25); + expect(media().currentTime).toBe(25); + expect(progress(A)?.time).toBe(25); + }); + + test("a paused segment keeps its position when a sync arrives", () => { + const { player, media, state } = setup(); + player.play(A, { time: 100, endTime: 125 }); + metadata(media()); + reach(media(), 126); + + saved(A, 45); + player.restore(A); + + expect(state()).toMatchObject({ status: "paused", currentTime: 125 }); + expect(media().currentTime).toBe(125); + }); }); beforeEach(() => { @@ -551,6 +629,22 @@ describe("pause", () => { expect(state().status).toBe("paused"); expect(written.at(-1)).toEqual({ time: 137, finished: false, checkpoint: false }); }); + + test.each([ + ["pause()", (player: PlayerController) => player.pause()], + ["a media key", (_: PlayerController, media: FakeMedia) => pausedByBrowser(media)], + ] as const)("from %s saves where playback got to after the last checkpoint", (_, pause) => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 10, 1000); + reach(media(), 12, 2000); + + pause(player, media()); + + expect(state()).toMatchObject({ status: "paused", currentTime: 12 }); + expect(progress(A)?.time).toBe(12); + }); }); describe("consecutive intents on loaded media, with no media event between them", () => { @@ -799,6 +893,7 @@ describe("progress", () => { metadata(media()); media().paused = false; + media().emit("play"); for (let second = 1; second <= 11; second++) reach(media(), second, second * 1000); expect(written).toEqual([ @@ -1536,7 +1631,7 @@ describe("errors", () => { metadata(media()); player.seek(630); reach(media(), 700, 10_000); - reach(media(), 1800, 20_000); + reach(media(), 1800, 12_000); media().emit("error"); saved(A, 3000, 3600); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 76b8895f..1d7078ff 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -121,11 +121,17 @@ export class PlayerController { /** * 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. + * startup, and again after a sync while nothing is loaded. Paused media goes to its + * own episode's saved progress, which a sync may have changed. Other loaded media + * stays, and so does a position the user moved and has not saved. */ restore(episode: Episode | undefined): void { const { status, episode: shown, currentTime } = this.snapshot; + if (status === "paused" && shown && !this.moved) { + const time = this.resumeTime(shown); + if (time !== currentTime) this.moveTo(time); + return; + } if (!episode || (status !== "empty" && status !== "restored")) return; this.moved &&= !!shown && getEpisodeKey(shown) === getEpisodeKey(episode); this.setState({ @@ -467,13 +473,14 @@ export class PlayerController { this.session.preview = false; } + /** Saves first: once paused, the position is no longer this device's to save. */ private settlePaused(): void { + this.persist(); this.setState( this.holdsMedia() ? { status: "paused", currentTime: this.el.currentTime } : { status: "paused" }, ); - this.persist(); } private onLoadedMetadata(): void { @@ -593,8 +600,11 @@ export class PlayerController { private persist(): void { const { session } = this; - const { episode, duration } = this.snapshot; + const { episode, duration, status } = this.snapshot; if (!session || !this.holdsMedia() || session.preview || this.disposed || !episode) return; + // A paused or ended position is saved already, or came from a sync, possibly an + // older one than the saved progress: only playing or a seek makes it this device's. + if (status !== "playing" && !this.moved) return; const time = this.el.currentTime; playedEpisodes.setEpisodeTime(episode, time, duration, duration > 0 && time >= duration); this.moved = false; From d2b85b533ff886eff7a551c46bd23046885e9162 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 22:21:26 +0200 Subject: [PATCH 3/4] test(sync): align handoff journey with current UI helpers --- src/main.ts | 2 -- src/player/PlayerController.ts | 9 --------- tests/e2e/synced-progress-handoff.test.ts | 4 +++- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index 4af95c7b..6afb708b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -616,8 +616,6 @@ export default class PodNotes extends Plugin implements IPodNotes { Object.assign(settings, decoded.settings); this.persistenceUnknownFields = decoded.unknownFields; this.setCollectionStores(); - // A sync can land after startup, before anything loads. The restored episode - // follows it, and so does paused media this device has not moved. this.player?.restore(this.settings.currentEpisode); } diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 1d7078ff..1fb7c22f 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -119,12 +119,6 @@ export class PlayerController { 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. Paused media goes to its - * own episode's saved progress, which a sync may have changed. Other loaded media - * stays, and so does a position the user moved and has not saved. - */ restore(episode: Episode | undefined): void { const { status, episode: shown, currentTime } = this.snapshot; if (status === "paused" && shown && !this.moved) { @@ -473,7 +467,6 @@ export class PlayerController { this.session.preview = false; } - /** Saves first: once paused, the position is no longer this device's to save. */ private settlePaused(): void { this.persist(); this.setState( @@ -602,8 +595,6 @@ export class PlayerController { const { session } = this; const { episode, duration, status } = this.snapshot; if (!session || !this.holdsMedia() || session.preview || this.disposed || !episode) return; - // A paused or ended position is saved already, or came from a sync, possibly an - // older one than the saved progress: only playing or a seek makes it this device's. if (status !== "playing" && !this.moved) return; const time = this.el.currentTime; playedEpisodes.setEpisodeTime(episode, time, duration, duration > 0 && time >= duration); diff --git a/tests/e2e/synced-progress-handoff.test.ts b/tests/e2e/synced-progress-handoff.test.ts index a585dc82..43eef07d 100644 --- a/tests/e2e/synced-progress-handoff.test.ts +++ b/tests/e2e/synced-progress-handoff.test.ts @@ -1,8 +1,10 @@ import { expect, onTestFinished, test } from "vitest"; import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; -import { click, showLibrary, showPodNotes, ui } from "./journey/actions"; +import { showLibrary, showPodNotes } from "./journey/actions"; import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { click } from "./journey/input"; import { observePlayback, seedLibrary, waitForPlayback } from "./journey/library"; +import { ui } from "./journey/targets"; import { jsLiteral, PLUGIN_ID, WAIT_OPTS } from "./runtime"; const getContext = createPodNotesE2EHarness("synced-progress-handoff"); From 35d08de1e4f6b5e33a39195b586fa32d284d831a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 22:47:23 +0200 Subject: [PATCH 4/4] fix(sync): preserve native seeks while playback is paused --- src/main.persistence.test.ts | 13 ++ src/player/PlayerController.test.ts | 146 ++++++++++++++++++++++ src/player/PlayerController.ts | 11 ++ tests/e2e/synced-progress-handoff.test.ts | 13 +- 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 2880b74e..007eceba 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -941,6 +941,19 @@ describe("PodNotes persistence integration", () => { expect(saved()).toMatchObject({ time: 25, finished: false }); expect(doc.media.currentTime).toBe(25); }); + + it("saves a seek from the browser's own controls made after the sync", async () => { + const { plugin, doc, saved, sync } = await paused(); + + await sync(45); + doc.media.currentTime = 42; + for (const type of ["seeking", "timeupdate", "seeked"]) doc.media.emit(type); + plugin.player.save(); + await plugin.flushSaves(); + + expect(saved()).toMatchObject({ time: 42, finished: false }); + expect(get(plugin.player.state)).toMatchObject({ status: "paused", currentTime: 42 }); + }); }); describe("once a data.json it could not read at startup becomes readable", () => { diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index b20c7b09..62be0ec6 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -107,6 +107,14 @@ function pausedByBrowser(media: FakeMedia) { media.emit("pause"); } +const SEEK_EVENTS = ["seeking", "timeupdate", "seeked"] as const; + +function seekedByBrowser(media: FakeMedia, time: number, at = now) { + now = at; + media.currentTime = time; + for (const type of SEEK_EVENTS) media.emit(type); +} + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("synced progress handoff", () => { @@ -249,6 +257,144 @@ describe("synced progress handoff", () => { expect(progress(A)?.time).toBe(25); }); + test.each([ + ["the next save", 0, (player: PlayerController) => player.save()], + ["its own checkpoint", 10_000, () => {}], + ] as const)("a seek from the browser's controls while paused is saved by %s", (_, at, save) => { + const { player, media, state } = paused(); + + seekedByBrowser(media(), 42, at); + save(player); + + expect(state()).toMatchObject({ status: "paused", currentTime: 42 }); + expect(progress(A)?.time).toBe(42); + }); + + test("a seek from the browser's controls before the sync arrives wins over it", () => { + const { player, media, state } = paused(); + seekedByBrowser(media(), 42); + + saved(A, 45); + player.restore(A); + player.save(); + + expect(state().currentTime).toBe(42); + expect(media().currentTime).toBe(42); + expect(progress(A)?.time).toBe(42); + }); + + test("a seek from the browser's controls after adoption wins over the next sync", () => { + const { player, media, state } = paused(); + saved(A, 45); + player.restore(A); + for (const type of SEEK_EVENTS) media().emit(type); + + seekedByBrowser(media(), 42); + saved(A, 50); + player.restore(A); + player.save(); + + expect(state().currentTime).toBe(42); + expect(media().currentTime).toBe(42); + expect(progress(A)?.time).toBe(42); + }); + + test("the adoption's own seek events leave the next sync free to apply", () => { + const { player, media, state } = paused(); + saved(A, 45); + player.restore(A); + now = 10_000; + for (const type of SEEK_EVENTS) media().emit(type); + + saved(A, 50); + player.restore(A); + player.save(); + + expect(state().currentTime).toBe(50); + expect(progress(A)?.time).toBe(50); + }); + + test("a seek from the browser's controls right after a pause counts when the pause's timeupdate comes first", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 17); + player.pause(); + + media().currentTime = 42; + for (const type of ["timeupdate", "pause", ...SEEK_EVENTS]) media().emit(type); + player.save(); + + expect(state().currentTime).toBe(42); + expect(progress(A)?.time).toBe(42); + }); + + test.each([ + ["save()", (player: PlayerController) => player.save()], + ["dispose()", (player: PlayerController) => player.dispose()], + ["playing another episode", (player: PlayerController) => player.play(B)], + ] as const)( + "%s keeps a seek from the browser's controls before its events arrive", + (_, end) => { + const { player, media } = paused(); + + media().currentTime = 42; + end(player); + + expect(progress(A)?.time).toBe(42); + }, + ); + + test("a sync leaves a seek from the browser's controls whose events have not arrived", () => { + const { player, media } = paused(); + media().currentTime = 42; + + saved(A, 45); + player.restore(A); + player.save(); + + expect(media().currentTime).toBe(42); + expect(progress(A)?.time).toBe(42); + }); + + test.each([ + ["no events", []], + ["its delayed events", SEEK_EVENTS], + ] as const)( + "a sync applies once a seek from the browser's controls is saved, with %s", + (_, events) => { + const { player, media, state } = paused(); + media().currentTime = 42; + player.save(); + expect(progress(A)?.time).toBe(42); + for (const type of events) media().emit(type); + + saved(A, 45); + player.restore(A); + player.save(); + + expect(state().currentTime).toBe(45); + expect(media().currentTime).toBe(45); + expect(progress(A)?.time).toBe(45); + }, + ); + + test("a seek from the browser's controls after a natural end saves the new position", () => { + const { player, media } = setup({ autoQueue: false }); + player.play(A); + metadata(media(), 60); + reach(media(), 60); + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + + media().ended = false; + seekedByBrowser(media(), 20); + player.save(); + + expect(progress(A)).toMatchObject({ time: 20, duration: 60, finished: false }); + }); + test("a paused segment keeps its position when a sync arrives", () => { const { player, media, state } = setup(); player.play(A, { time: 100, endTime: 125 }); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 1fb7c22f..6090c688 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -120,6 +120,7 @@ export class PlayerController { } restore(episode: Episode | undefined): void { + this.noteStoppedMove(); const { status, episode: shown, currentTime } = this.snapshot; if (status === "paused" && shown && !this.moved) { const time = this.resumeTime(shown); @@ -538,6 +539,7 @@ export class PlayerController { return; } + this.noteStoppedMove(); this.setState({ currentTime: time, duration: finiteOr0(this.el.duration) }); const now = Date.now(); if (now - session.lastCheckpoint < SAVE_POSITION_THROTTLE_MS) return; @@ -591,10 +593,19 @@ export class PlayerController { return !!this.session?.ready && this.el.readyState > HAVE_NOTHING; } + private noteStoppedMove(): void { + const { status, currentTime } = this.snapshot; + const time = this.el.currentTime; + if (status === "playing" || !this.holdsMedia() || time === currentTime) return; + this.moved = true; + this.setState({ currentTime: time }); + } + private persist(): void { const { session } = this; const { episode, duration, status } = this.snapshot; if (!session || !this.holdsMedia() || session.preview || this.disposed || !episode) return; + this.noteStoppedMove(); if (status !== "playing" && !this.moved) return; const time = this.el.currentTime; playedEpisodes.setEpisodeTime(episode, time, duration, duration > 0 && time >= duration); diff --git a/tests/e2e/synced-progress-handoff.test.ts b/tests/e2e/synced-progress-handoff.test.ts index 43eef07d..19645c2f 100644 --- a/tests/e2e/synced-progress-handoff.test.ts +++ b/tests/e2e/synced-progress-handoff.test.ts @@ -17,7 +17,9 @@ const FEED: FixtureFeed = { const PROGRESS_KEY = "Handoff Podcast::Handoff Episode"; const POLL = { timeout: WAIT_OPTS.timeoutMs, interval: WAIT_OPTS.intervalMs }; -for (const action of ["hide", "seek"] as const) { +const SAVED_AFTER = { hide: 45, seek: 25, "native seek": 42 }; + +for (const [action, expected] of Object.entries(SAVED_AFTER)) { test(`a paused episode adopts synced progress before a local ${action} (#461)`, async () => { const context = getContext(); const { obsidian, plugin } = context; @@ -100,6 +102,14 @@ for (const action of ["hide", "seek"] as const) { app.plugins.plugins.${PLUGIN_ID}.api.currentTime = 25; return true; })()`); + } else if (action === "native seek") { + await obsidian.dev.evalJsonAsync(`(async () => { + const media = document.querySelector(".podnotes-media"); + const seeked = new Promise((resolve) => media.addEventListener("seeked", resolve, { once: true })); + media.currentTime = 42; + await seeked; + return true; + })()`); } else { await obsidian.dev.evalJson( 'require("@electron/remote").getCurrentWindow().hide(), true', @@ -114,7 +124,6 @@ for (const action of ["hide", "seek"] as const) { await plugin.flushSaves(); return true; })()`); - const expected = action === "seek" ? 25 : 45; expect( (await plugin.data().read()).playedEpisodes?.[PROGRESS_KEY]?.time, ).toBe(expected);