From 3258e03cc7a958faadf9bc8b33d4214fb206ec75 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:38:49 +0200 Subject: [PATCH 1/2] test(player): reproduce native seeks after completion --- src/player/PlayerController.test.ts | 184 ++++++++++++++++++++++ tests/e2e/playback-position-edges.test.ts | 86 ++++++++++ 2 files changed, 270 insertions(+) create mode 100644 tests/e2e/playback-position-edges.test.ts diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 375d86df..5469ff9b 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -309,6 +309,141 @@ describe("synced progress handoff", () => { }, ); + test.each(playAgain)( + "%s after a natural end starts at a seek from the browser's controls whose events have not arrived (#465)", + (_, again) => { + const { player, media, state } = ended(); + media().ended = false; + media().currentTime = 12; + + again(player); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 12, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 12 }); + player.pause(); + expect(progress(A)).toMatchObject({ time: 12, duration: 120, finished: false }); + }, + ); + + test.each(playAgain)( + "%s after a natural end starts at a seek from the browser's controls whose events arrived before a checkpoint (#465)", + (_, again) => { + const { player, media, state } = ended(); + media().ended = false; + seekedByBrowser(media(), 12, 1000); + + again(player); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 12, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 12 }); + player.pause(); + expect(progress(A)).toMatchObject({ time: 12, duration: 120, finished: false }); + }, + ); + + test("a seek from the browser's controls after a natural end wins over a re-listen synced before Play", () => { + const { player, media, state } = ended(); + media().ended = false; + seekedByBrowser(media(), 12, 1000); + saved(A, 30, 120); + player.restore(A); + + player.resume(); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 12, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 12 }); + player.pause(); + expect(progress(A)).toMatchObject({ time: 12, duration: 120, finished: false }); + }); + + test("a re-listen synced after a saved seek from the browser's controls past a natural end plays from the synced position", () => { + const { player, media, state } = ended(); + media().ended = false; + seekedByBrowser(media(), 12, 1000); + player.save(); + expect(progress(A)).toMatchObject({ time: 12, duration: 120, finished: false }); + saved(A, 30, 120); + player.restore(A); + + player.play(A); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 30, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 30 }); + }); + + test.each(playAgain)("%s after a seek shortly before a natural end starts over", (_, again) => { + const { player, media, state } = setup({ autoQueue: false }); + player.play(A); + metadata(media(), 120); + reach(media(), 100); + player.seek(115); + reach(media(), 120, 1000); + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + + again(player); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 0, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 0 }); + }); + + test.each(playAgain)( + "%s before a reset element reports emptied preserves the position", + (_, again) => { + const { player, media, state } = setup(); + player.play(A); + metadata(media(), 120); + reach(media(), 12); + player.pause(); + + media().load(); + again(player); + media().emit("emptied"); + metadata(media(), 120); + + expect(media()).toMatchObject({ currentTime: 12, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 12 }); + }, + ); + + test.each([0, 12])( + "play() with a time of %s after a natural end starts there, not at a seek from the browser's controls", + (time) => { + const { player, media, state } = ended(); + media().ended = false; + media().currentTime = 20; + + player.play(A, { time }); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: time, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: time }); + player.pause(); + expect(progress(A)).toMatchObject({ time, duration: 120, finished: false }); + }, + ); + + test.each([ + ["a negative time", { time: -1 }], + ["an end before its start", { time: 30, endTime: 20 }], + ] as const)( + "play() rejecting %s after a natural end leaves the player as it was", + (_, options) => { + const { player, media, state } = ended(); + media().ended = false; + media().currentTime = 12; + + expect(player.play(A, options)).toBe(false); + expect(state()).toMatchObject({ status: "ended", currentTime: 120 }); + }, + ); + test("play() with a time after a natural end starts there, not at a re-listen synced afterwards", () => { const { player, media, state } = ended(); saved(A, 30, 120); @@ -679,6 +814,31 @@ describe("switching episodes (#353)", () => { expect(queued()).toEqual([]); }); + test.each([ + ["a seek", (player: PlayerController) => player.seek(12)], + [ + "a seek from the browser's controls", + (_: PlayerController, media: FakeMedia) => { + media.ended = false; + media.currentTime = 12; + }, + ], + ] as const)("saves and queues an episode that %s moved after its natural end", (_, move) => { + const { player, media } = setup(); + player.play(A); + metadata(media(), 60); + reach(media(), 60); + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + + move(player, media()); + player.play(B); + + expect(progress(A)).toMatchObject({ time: 12, duration: 60, finished: false }); + expect(queued()).toEqual(["A"]); + }); + test("reloads the same episode when its media changed, without queueing it", () => { const { player, media, state } = setup(); player.play(A); @@ -2170,6 +2330,30 @@ describe("video stage", () => { expect(media().paused).toBe(false); }); + test("a seek from the browser's controls in a finished video plays from there after a reset", () => { + const { player, doc, media, state } = setup(); + const stage = new FakeParent(doc); + player.play(VIDEO); + metadata(media(), 60); + player.videoStage(stage as unknown as HTMLElement); + reach(media(), 60); + media().ended = true; + pausedByBrowser(media()); + media().emit("ended"); + media().ended = false; + seekedByBrowser(media(), 12, 1000); + + stage.ownerDocument = {}; + media().load(); + media().emit("emptied"); + metadata(media(), 60); + player.resume(); + metadata(media(), 60); + + expect(media()).toMatchObject({ currentTime: 12, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 12 }); + }); + test("a reset element saves no position until it holds media again", () => { const { player, media, state, writes } = setup(); const written = writes(A); diff --git a/tests/e2e/playback-position-edges.test.ts b/tests/e2e/playback-position-edges.test.ts new file mode 100644 index 00000000..a5ce5c3e --- /dev/null +++ b/tests/e2e/playback-position-edges.test.ts @@ -0,0 +1,86 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; +import { showLibrary, showPodNotes } from "./journey/actions"; +import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { click } from "./journey/input"; +import { seedLibrary, waitForPlayback } from "./journey/library"; +import { ui } from "./journey/targets"; +import { jsLiteral, WAIT_OPTS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("playback-position-edges"); +const FEED: FixtureFeed = { + title: "Position Edges", + slug: "position-edges", + artwork: true, + episodes: [{ title: "Ended Seek", seconds: 60, pubDate: pubDate(0) }], +}; +const KEY = "Position Edges::Ended Seek"; + +for (const delivered of [false, true]) { + for (const path of ["player button", "episode play"] as const) { + test(`#465 a native seek after natural end wins ${delivered ? "after native seek events" : "before events arrive"} through ${path}`, 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("Ended Seek")); + await waitForPlayback( + obsidian, + (state) => state.playing && state.seconds > 0, + "playing", + ); + await obsidian.dev.evalJson("app.plugins.plugins.podnotes.api.currentTime = 59, true"); + await plugin.waitForData( + (data) => data.playedEpisodes?.[KEY]?.finished === true, + WAIT_OPTS, + ); + await waitForPlayback( + obsidian, + (state) => !state.playing && state.seconds >= 59, + "ended", + ); + if (delivered) { + await obsidian.dev.evalJsonAsync(`(async () => { + const media = document.querySelector(".podnotes-media"); + const seeked = new Promise(resolve => media.addEventListener("seeked", resolve, {once: true})); + media.currentTime = 12; + await seeked; + return true; + })()`); + } + const result = await obsidian.dev.evalJson<{ + before: number; + seek: number; + after: number; + }>(`(() => { + const plugin = app.plugins.plugins.podnotes; + const media = document.querySelector(".podnotes-media"); + const before = media.currentTime; + media.currentTime = 12; + const seek = media.currentTime; + if (${jsLiteral(path)} === "player button") { + document.querySelector(".pn-player button.pn-play").click(); + } else { + plugin.player.play(plugin.api.podcast); + } + return { before, seek, after: media.currentTime }; + })()`); + console.info(`#465 ${path}, events delivered=${delivered}: ${JSON.stringify(result)}`); + expect(result.before).toBe(delivered ? 12 : 60); + expect(result.seek).toBe(12); + expect(result.after).toBe(12); + const resumed = await waitForPlayback(obsidian, (state) => state.playing, "resumed"); + expect(resumed.seconds).toBeGreaterThanOrEqual(12); + expect(resumed.seconds).toBeLessThan(15); + await click(obsidian, ui.playbackToggle); + await plugin.waitForData((data) => { + const progress = data.playedEpisodes?.[KEY]; + return progress?.finished === false && progress.time >= 12 && progress.time < 15; + }, WAIT_OPTS); + }); + } +} From d2c2bfff891f03643373962fec7c55f915857c85 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:39:46 +0200 Subject: [PATCH 2/2] fix(player): preserve native seeks after completion --- src/player/PlayerController.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 7f354962..25915e9f 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -173,6 +173,7 @@ export class PlayerController { this.requests++; const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); + this.noteStoppedMove(); const { status, episode: outgoing, mediaType } = this.snapshot; const sameEpisode = !!outgoing && getEpisodeKey(outgoing) === getEpisodeKey(episode); this.select(episode); @@ -234,8 +235,10 @@ export class PlayerController { /** Continues the current episode without selecting it. */ resume(): void { + if (this.disposed) return; + this.noteStoppedMove(); const { status, episode } = this.snapshot; - if (this.disposed || !episode) return; + if (!episode) return; this.requests++; if (status === "playing" || status === "loading") return; this.endPreview(); @@ -613,7 +616,7 @@ export class PlayerController { const time = this.el.currentTime; if (status === "playing" || !this.holdsMedia() || time === currentTime) return; this.moved = true; - this.setState({ currentTime: time }); + this.setState({ currentTime: time, status: status === "ended" ? "paused" : status }); } private persist(): void {