From f6c312afbd4184e2d010458b0889004bc9d44ae4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 00:14:41 +0200 Subject: [PATCH 1/3] test(player): reproduce Play restarting a re-listen synced after a natural end Play and resume() on a naturally ended episode restart at 0 even when another device has since saved unfinished progress for it, and the first pause then saves over that progress (#463). The unit tests cover both entry points, a finished episode that starts over, a synced position past the end of the local copy, which must pause short of the end without finishing, and an explicit time, which wins over synced progress. The ended journey pauses with the visible toggle and checks the saved position. The playing-sync journey checks that the remote position merges and playback keeps advancing. --- src/player/PlayerController.test.ts | 91 +++++++++++++++++++++++ tests/e2e/journey-playback.test.ts | 13 +++- tests/e2e/synced-progress-handoff.test.ts | 81 ++++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 62be0ec6..d0da3b88 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -244,6 +244,85 @@ describe("synced progress handoff", () => { expect(progress(A)).toMatchObject({ time: 20, duration: 60, finished: false }); }); + function ended() { + const context = setup({ autoQueue: false }); + context.player.play(A); + metadata(context.media(), 120); + reach(context.media(), 120); + context.media().ended = true; + pausedByBrowser(context.media()); + context.media().emit("ended"); + return context; + } + + const playAgain = Object.entries({ + "resume()": (player: PlayerController) => player.resume(), + "play() of the same episode": (player: PlayerController) => player.play(A), + }); + + test.each(playAgain)( + "%s after a natural end continues a re-listen synced afterwards (#463)", + (_, again) => { + const { player, media, state } = ended(); + saved(A, 30, 120); + player.restore(A); + player.save(); + expect(progress(A)).toMatchObject({ time: 30, duration: 120, finished: false }); + + again(player); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 30, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 30 }); + }, + ); + + test.each(playAgain)( + "%s after a natural end with no newer progress starts over", + (_, again) => { + const { player, media, state } = ended(); + player.restore(A); + player.save(); + expect(progress(A)).toMatchObject({ time: 120, duration: 120, finished: true }); + + again(player); + media().emit("play"); + + expect(media()).toMatchObject({ currentTime: 0, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 0 }); + }, + ); + + test.each(playAgain)( + "%s after a natural end pauses short of the end of a shorter copy than the synced re-listen's", + (_, again) => { + const { player, media, state } = ended(); + saved(A, 125, 130); + player.restore(A); + + again(player); + player.save(); + + expect(media()).toMatchObject({ currentTime: 119.75, paused: true }); + expect(state()).toMatchObject({ status: "paused", currentTime: 119.75 }); + expect(progress(A)).toMatchObject({ time: 125, duration: 130, finished: false }); + }, + ); + + 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); + player.restore(A); + + player.play(A, { time: 12 }); + 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 made before the sync arrives wins over it", () => { const { player, media, state } = paused(); player.seek(25); @@ -1976,6 +2055,18 @@ describe("video stage", () => { expect(state().status).toBe("playing"); }); + test("resume() of a finished video that a reset reloaded continues a re-listen synced afterwards", () => { + const { player, media, state } = finishedThenReset(); + saved(VIDEO, 30, 60); + player.restore(VIDEO); + + player.resume(); + metadata(media(), 60); + + expect(media()).toMatchObject({ currentTime: 30, paused: false }); + expect(state()).toMatchObject({ status: "playing", currentTime: 30 }); + }); + test("resume() of a finished video that a reset reloaded keeps its live speed", () => { const { player, host, media, state } = finishedThenReset(); player.setPlaybackRate(1.3); diff --git a/tests/e2e/journey-playback.test.ts b/tests/e2e/journey-playback.test.ts index f79a76ee..9bbe58bd 100644 --- a/tests/e2e/journey-playback.test.ts +++ b/tests/e2e/journey-playback.test.ts @@ -33,7 +33,7 @@ import { waitForPlayback, writeVaultFile, } from "./journey/library"; -import { PLUGIN_ID } from "./runtime"; +import { jsLiteral, PLUGIN_ID } from "./runtime"; const LONG: FixtureEpisode = { title: "Long Listen", seconds: 60, pubDate: pubDate(0) }; const OPENER: FixtureEpisode = { title: "Short Opener", seconds: 6, pubDate: pubDate(1) }; @@ -453,6 +453,10 @@ describe("playback journeys", () => { "three seconds played", ); + await obsidian.dev.evalJsonAsync( + `(async () => { await app.plugins.plugins.${PLUGIN_ID}.flushSaves(); return true; })()`, + ); + await plugin.data>().patch((data) => { data.playedEpisodes[`${FEED.title}::${LONG.title}`] = { title: LONG.title, @@ -475,8 +479,15 @@ describe("playback journeys", () => { { timeoutMs: 15_000, intervalMs: 200, message: "the remote change merged" }, ); + expect( + await obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.settings.playedEpisodes[${jsLiteral(`${FEED.title}::${LONG.title}`)}]?.time`, + ), + ).toBe(50); + const after = await expectAdvancing("after the remote change"); expect(after.episode).toEqual(episodeRef(FEED, LONG)); + expect(after.seconds).toBeGreaterThan(before.seconds); expect(after.seconds).toBeLessThan(before.seconds + 10); expect(after.media?.src).toBe(before.media?.src); await obsidian.dev.evalJsonAsync( diff --git a/tests/e2e/synced-progress-handoff.test.ts b/tests/e2e/synced-progress-handoff.test.ts index 19645c2f..b2394a15 100644 --- a/tests/e2e/synced-progress-handoff.test.ts +++ b/tests/e2e/synced-progress-handoff.test.ts @@ -130,3 +130,84 @@ for (const [action, expected] of Object.entries(SAVED_AFTER)) { expect((await observePlayback(obsidian)).seconds).toBe(expected); }); } + +const PLAY_AFTER_END = { "a re-listen synced afterwards": 30, "no newer progress": 0 }; + +for (const [sync, expected] of Object.entries(PLAY_AFTER_END)) { + test(`Play on a naturally ended episode with ${sync} starts at ${expected} s (#463)`, 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 obsidian.dev.evalJson(`(() => { + app.plugins.plugins.${PLUGIN_ID}.api.currentTime = 57; + return true; + })()`); + await plugin.waitForData( + (data) => data.playedEpisodes?.[PROGRESS_KEY]?.finished === true, + WAIT_OPTS, + ); + const ended = await waitForPlayback( + obsidian, + (state) => !state.playing && state.media?.paused === true, + "ended", + ); + expect(ended.seconds).toBeGreaterThan(59); + + if (expected > 0) { + await plugin.data().patch((data) => { + const progress = data.playedEpisodes?.[PROGRESS_KEY]; + if (!progress) throw new Error("The ended episode has no saved progress."); + progress.time = expected; + progress.finished = false; + }); + await expect + .poll( + () => + obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.settings.playedEpisodes[${jsLiteral(PROGRESS_KEY)}]?.time`, + ), + POLL, + ) + .toBe(expected); + } + await obsidian.dev.evalJsonAsync(`(async () => { + const plugin = app.plugins.plugins.${PLUGIN_ID}; + plugin.player.save(); + await plugin.flushSaves(); + return true; + })()`); + expect( + (await plugin.data().read()).playedEpisodes?.[PROGRESS_KEY], + ).toMatchObject(expected > 0 ? { time: expected, finished: false } : { finished: true }); + + await click(obsidian, ui.playbackToggle); + const resumed = await waitForPlayback( + obsidian, + (state) => state.playing && state.media?.paused === false, + "playing again", + ); + expect(resumed.seconds).toBeGreaterThanOrEqual(expected); + expect(resumed.seconds).toBeLessThan(expected + 5); + + await click(obsidian, ui.playbackToggle); + const paused = await waitForPlayback( + obsidian, + (state) => !state.playing && state.media?.paused === true, + "paused again", + ); + await obsidian.dev.evalJsonAsync( + `(async () => { await app.plugins.plugins.${PLUGIN_ID}.flushSaves(); return true; })()`, + ); + const progress = (await plugin.data().read()).playedEpisodes?.[PROGRESS_KEY]; + expect(progress).toMatchObject({ time: paused.seconds, finished: false }); + expect(progress?.time).toBeGreaterThanOrEqual(expected); + expect(progress?.time).toBeLessThan(expected + 5); + }); +} From 7501852ad6c3567f59bb4284f8d100893addc3aa Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 00:15:15 +0200 Subject: [PATCH 2/3] fix(player): resume a re-listen synced after a natural end Play and resume() on a naturally ended episode now start at its saved progress, the same position a new load of it starts at, instead of 0. A finished, past-duration or missing record still starts over, and an explicit time still wins. A synced position at or past the end of the local copy pauses short of the end, as a seek does, instead of playing the last moment and finishing the episode (#463). --- src/player/PlayerController.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 6090c688..b5c106b8 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -195,9 +195,9 @@ export class PlayerController { } session.segmentEnd = endTime ?? null; session.preview = endTime !== undefined; - if (time === undefined && status === "ended") this.moveTo(0); + const at = time ?? (status === "ended" ? this.resumeTime(episode) : undefined); if (time !== undefined) this.moved = true; - if (time === undefined || this.moveTo(time)) this.start(); + if (at === undefined || this.moveTo(at)) this.start(); return true; } @@ -226,8 +226,7 @@ export class PlayerController { 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); + } else if (status !== "ended" || this.moveTo(this.resumeTime(episode))) { this.start(); } } From c52b56974ca2d4f744eed2f7463147087f7cbc2a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 00:31:31 +0200 Subject: [PATCH 3/3] test(player): apply the playing sync and read its merge in one eval The journey now flushes local saves, rewrites data.json through the vault adapter, awaits onExternalSettingsChange() and returns the merged progress from the same renderer eval. The separate read after the merge poll could run after the player's next position save had replaced the synced time. --- tests/e2e/journey-playback.test.ts | 45 ++++++++++-------------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/tests/e2e/journey-playback.test.ts b/tests/e2e/journey-playback.test.ts index 9bbe58bd..e8e22872 100644 --- a/tests/e2e/journey-playback.test.ts +++ b/tests/e2e/journey-playback.test.ts @@ -453,37 +453,20 @@ describe("playback journeys", () => { "three seconds played", ); - await obsidian.dev.evalJsonAsync( - `(async () => { await app.plugins.plugins.${PLUGIN_ID}.flushSaves(); return true; })()`, - ); - - 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" }, - ); - - expect( - await obsidian.dev.evalJson( - `app.plugins.plugins.${PLUGIN_ID}.settings.playedEpisodes[${jsLiteral(`${FEED.title}::${LONG.title}`)}]?.time`, - ), - ).toBe(50); + const key = jsLiteral(`${FEED.title}::${LONG.title}`); + const synced = await obsidian.dev.evalJsonAsync(`(async () => { + const plugin = app.plugins.plugins.${PLUGIN_ID}; + await plugin.flushSaves(); + const path = plugin.manifest.dir + "/data.json"; + const data = JSON.parse(await app.vault.adapter.read(path)); + data.playedEpisodes[${key}] = ${jsLiteral({ title: LONG.title, podcastName: FEED.title, time: 50, duration: 60, finished: false })}; + data.currentEpisode = ${jsLiteral(storedEpisode(server, FEED, OPENER))}; + data.queue.episodes = ${jsLiteral([storedEpisode(server, FEED, FOLLOW_UP), storedEpisode(server, FEED, OPENER)])}; + await app.vault.adapter.write(path, JSON.stringify(data)); + await plugin.onExternalSettingsChange(); + return plugin.settings.playedEpisodes[${key}]?.time; + })()`); + expect(synced).toBe(50); const after = await expectAdvancing("after the remote change"); expect(after.episode).toEqual(episodeRef(FEED, LONG));