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/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(); } } diff --git a/tests/e2e/journey-playback.test.ts b/tests/e2e/journey-playback.test.ts index f79a76ee..e8e22872 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,30 +453,24 @@ describe("playback journeys", () => { "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 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)); + 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); + }); +}