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
184 changes: 184 additions & 0 deletions src/player/PlayerController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions tests/e2e/playback-position-edges.test.ts
Original file line number Diff line number Diff line change
@@ -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<PodNotesData>(
(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<PodNotesData>((data) => {
const progress = data.playedEpisodes?.[KEY];
return progress?.finished === false && progress.time >= 12 && progress.time < 15;
}, WAIT_OPTS);
});
}
}
Loading