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
127 changes: 127 additions & 0 deletions src/URIHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { get } from "svelte/store";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";

import podNotesURIHandler from "./URIHandler";
import {
currentEpisode,
currentTime,
isPaused,
playedEpisodes,
requestedPlaybackTime,
viewState,
} from "./store";
import type { Episode } from "./types/Episode";
import { ViewState } from "./types/ViewState";

const mockFindItemByTitle = vi.fn();
const testFeedUrl = "https://pod.example.com/feed.xml";

vi.mock("./parser/feedParser", () => ({
default: class {
findItemByTitle = mockFindItemByTitle;
},
}));

const testEpisode: Episode = {
title: "Finished Episode",
streamUrl: "https://pod.example.com/audio.mp3",
url: "https://pod.example.com/episode",
description: "",
content: "",
podcastName: "Test Podcast",
feedUrl: testFeedUrl,
};

function resetStores() {
currentEpisode.update(() => undefined as unknown as Episode);
currentTime.set(0);
isPaused.set(true);
playedEpisodes.set({});
requestedPlaybackTime.set(null);
viewState.set(ViewState.PodcastGrid);
}

const api = {
set currentTime(value: number) {
currentTime.set(value);
},
};

beforeEach(() => {
resetStores();
mockFindItemByTitle.mockResolvedValue(testEpisode);

(globalThis as { app?: unknown }).app = {
vault: {
getAbstractFileByPath: vi.fn(() => null),
},
};
});

afterEach(() => {
resetStores();
mockFindItemByTitle.mockReset();
delete (globalThis as { app?: unknown }).app;
});

describe("podNotesURIHandler", () => {
test("seeks and resumes when the linked episode is already visible", async () => {
currentEpisode.set(testEpisode);
viewState.set(ViewState.Player);
currentTime.set(3600);
isPaused.set(true);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName: testEpisode.title,
time: "120",
},
api as never,
);

expect(get(viewState)).toBe(ViewState.Player);
expect(get(currentTime)).toBe(120);
expect(get(isPaused)).toBe(false);
expect(get(requestedPlaybackTime)).toBeNull();
expect(mockFindItemByTitle).not.toHaveBeenCalled();
});

test("keeps the requested time for the player to apply after loading metadata", async () => {
playedEpisodes.markAsPlayed(testEpisode);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName: testEpisode.title,
time: "240",
},
api as never,
);

expect(mockFindItemByTitle).toHaveBeenCalledWith(
testEpisode.title,
testFeedUrl,
);
expect(get(currentEpisode)).toMatchObject({
title: testEpisode.title,
});
expect(get(viewState)).toBe(ViewState.Player);
expect(get(requestedPlaybackTime)).toEqual({
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
time: 240,
});
expect(get(playedEpisodes)[`${testEpisode.podcastName}::${testEpisode.title}`]?.finished).toBe(
true,
);
});
});
36 changes: 29 additions & 7 deletions src/URIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,50 @@ import type { ObsidianProtocolData } from "obsidian";
import { get } from "svelte/store";
import type { IAPI } from "./API/IAPI";
import FeedParser from "./parser/feedParser";
import { currentEpisode, viewState, localFiles } from "./store";
import {
currentEpisode,
isPaused,
localFiles,
requestedPlaybackTime,
viewState,
} from "./store";
import type { Episode } from "./types/Episode";
import { getEpisodeKey } from "./utility/episodeKey";
import { ViewState } from "./types/ViewState";

export default async function podNotesURIHandler(
{ url, episodeName, time }: ObsidianProtocolData,
api: IAPI
) {
if (!url || !episodeName || !time) {
if (!url || !episodeName || time === undefined) {
new Notice(
"URL, episode name, and timestamp are required to play an episode"
);
return;
}

const requestedTime = parseFloat(time);
if (!Number.isFinite(requestedTime)) {
new Notice("Timestamp must be a valid number");
return;
}

const decodedName = episodeName.replace(/\+/g, " ");
const currentEp = get(currentEpisode);
const episodeIsPlaying = currentEp?.title === decodedName;
const playerIsVisible = get(viewState) === ViewState.Player;

if (episodeIsPlaying) {
requestedPlaybackTime.set({
episodeKey: getEpisodeKey(currentEp),
time: requestedTime,
});
viewState.set(ViewState.Player);
api.currentTime = parseFloat(time);
api.currentTime = requestedTime;
isPaused.set(false);
if (playerIsVisible) {
requestedPlaybackTime.set(null);
}

return;
}
Expand All @@ -47,10 +69,10 @@ export default async function podNotesURIHandler(
return;
}

requestedPlaybackTime.set({
episodeKey: getEpisodeKey(episode),
time: requestedTime,
});
currentEpisode.set(episode);
viewState.set(ViewState.Player);

new Notice(
"Episode found, playing now. Please click timestamp again to play at specific time."
);
}
4 changes: 4 additions & 0 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { getEpisodeKey } from "src/utility/episodeKey";

export const plugin = writable<PodNotes>();
export const currentTime = writable<number>(0);
export const requestedPlaybackTime = writable<{
episodeKey: string;
time: number;
} | null>(null);
export const duration = writable<number>(0);
export const volume = writable<number>(1);
export const hidePlayedEpisodes = writable<boolean>(false);
Expand Down
23 changes: 22 additions & 1 deletion src/ui/PodcastView/EpisodePlayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
playlists,
viewState,
downloadedEpisodes,
requestedPlaybackTime,
} from "src/store";
import { formatSeconds } from "src/utility/formatSeconds";
import { fetchChapters } from "src/utility/fetchChapters";
Expand All @@ -28,7 +29,7 @@
import { ViewState } from "src/types/ViewState";
import { createMediaUrlObjectFromFilePath } from "src/utility/createMediaUrlObjectFromFilePath";
import Image from "../common/Image.svelte";
import { getEpisodeKey } from "src/utility/episodeKey";
import { episodeMatchesKey, getEpisodeKey } from "src/utility/episodeKey";

// #region Circumventing the forced two-way binding of the playback rate.
class CircumentForcedTwoWayBinding {
Expand Down Expand Up @@ -116,13 +117,33 @@
function restorePlaybackTime() {
const playedEps = $playedEpisodes;
const currentEp = $currentEpisode;
const requestedPlayback = $requestedPlaybackTime;

if (!currentEp) {
currentTime.set(0);
isPaused.set(false);
return;
}

if (requestedPlayback !== null) {
requestedPlaybackTime.set(null);
if (!episodeMatchesKey(currentEp, requestedPlayback.episodeKey)) {
restoreSavedPlaybackTime(currentEp, playedEps);
return;
}

currentTime.set(requestedPlayback.time);
isPaused.set(false);
return;
}

restoreSavedPlaybackTime(currentEp, playedEps);
}

function restoreSavedPlaybackTime(
currentEp: Episode,
playedEps: typeof $playedEpisodes,
) {
const key = getEpisodeKey(currentEp);

// Check composite key first, then fallback to title-only for backwards compat
Expand Down
87 changes: 87 additions & 0 deletions src/ui/PodcastView/EpisodePlayer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { get } from "svelte/store";
import { beforeEach, describe, expect, test, vi } from "vitest";

import {
currentEpisode,
currentTime,
duration,
isPaused,
playedEpisodes,
plugin,
requestedPlaybackTime,
} from "src/store";
import type { Episode } from "src/types/Episode";
import EpisodePlayer from "./EpisodePlayer.svelte";

const testEpisode: Episode = {
title: "Finished Episode",
streamUrl: "https://pod.example.com/audio.mp3",
url: "https://pod.example.com/episode",
description: "",
content: "",
podcastName: "Test Podcast",
feedUrl: "https://pod.example.com/feed.xml",
};

beforeEach(() => {
currentEpisode.set(testEpisode);
currentTime.set(0);
duration.set(3600);
isPaused.set(true);
playedEpisodes.set({});
requestedPlaybackTime.set(null);
HTMLMediaElement.prototype.play = vi.fn(() => Promise.resolve());
HTMLMediaElement.prototype.pause = vi.fn();
plugin.set({
settings: {
defaultPlaybackRate: 1,
},
api: {
skipBackward: vi.fn(),
skipForward: vi.fn(),
},
} as never);
});

describe("EpisodePlayer", () => {
test("uses requested timestamp before restored played progress", async () => {
playedEpisodes.markAsPlayed(testEpisode);
requestedPlaybackTime.set({
episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`,
time: 240,
});

const { container } = render(EpisodePlayer);
await waitFor(() => {
expect(container.querySelector("audio")).not.toBeNull();
});
const audio = container.querySelector("audio") as HTMLAudioElement;

await fireEvent.loadedMetadata(audio);

expect(get(currentTime)).toBe(240);
expect(get(isPaused)).toBe(false);
expect(get(requestedPlaybackTime)).toBeNull();
});

test("ignores stale requested timestamp for a different episode", async () => {
playedEpisodes.setEpisodeTime(testEpisode, 1800, 3600, false);
requestedPlaybackTime.set({
episodeKey: "Other Podcast::Other Episode",
time: 240,
});

const { container } = render(EpisodePlayer);
await waitFor(() => {
expect(container.querySelector("audio")).not.toBeNull();
});
const audio = container.querySelector("audio") as HTMLAudioElement;

await fireEvent.loadedMetadata(audio);

expect(get(currentTime)).toBe(1800);
expect(get(isPaused)).toBe(false);
expect(get(requestedPlaybackTime)).toBeNull();
});
});
2 changes: 1 addition & 1 deletion src/utility/encodePodnotesURI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default function encodePodnotesURI(title: string, feedUrl: string, time?:
url.searchParams.set('episodeName', title);
url.searchParams.set('url', feedUrl);

if (time) {
if (time !== undefined) {
url.searchParams.set('time', time.toString());
}

Expand Down
Loading