Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/player/PlayerController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 3 additions & 4 deletions src/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
}
}
Expand Down
38 changes: 16 additions & 22 deletions tests/e2e/journey-playback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down Expand Up @@ -453,30 +453,24 @@ describe("playback journeys", () => {
"three seconds played",
);

await plugin.data<Record<string, any>>().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<boolean>(
`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<number>(`(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(
Expand Down
81 changes: 81 additions & 0 deletions tests/e2e/synced-progress-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PodNotesData>(
(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<PodNotesData>().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<number>(
`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<PodNotesData>().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<PodNotesData>().read()).playedEpisodes?.[PROGRESS_KEY];
expect(progress).toMatchObject({ time: paused.seconds, finished: false });
expect(progress?.time).toBeGreaterThanOrEqual(expected);
expect(progress?.time).toBeLessThan(expected + 5);
});
}
Loading