From ac05f57ba7b77ad9549c47abb3aa4ddc99dda46c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 2 May 2026 17:47:02 +0200 Subject: [PATCH] fix: keep timestamp links working after played --- src/URIHandler.test.ts | 127 +++++++++++++++++++++++ src/URIHandler.ts | 36 +++++-- src/store/index.ts | 4 + src/ui/PodcastView/EpisodePlayer.svelte | 23 +++- src/ui/PodcastView/EpisodePlayer.test.ts | 87 ++++++++++++++++ src/utility/encodePodnotesURI.ts | 2 +- 6 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 src/URIHandler.test.ts create mode 100644 src/ui/PodcastView/EpisodePlayer.test.ts diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts new file mode 100644 index 0000000..a732918 --- /dev/null +++ b/src/URIHandler.test.ts @@ -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, + ); + }); +}); diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 1681995..8065e9e 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -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; } @@ -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." - ); } diff --git a/src/store/index.ts b/src/store/index.ts index 1e21001..5ec5f37 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,6 +12,10 @@ import { getEpisodeKey } from "src/utility/episodeKey"; export const plugin = writable(); export const currentTime = writable(0); +export const requestedPlaybackTime = writable<{ + episodeKey: string; + time: number; +} | null>(null); export const duration = writable(0); export const volume = writable(1); export const hidePlayedEpisodes = writable(false); diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 5a1b380..eb410ac 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -11,6 +11,7 @@ playlists, viewState, downloadedEpisodes, + requestedPlaybackTime, } from "src/store"; import { formatSeconds } from "src/utility/formatSeconds"; import { fetchChapters } from "src/utility/fetchChapters"; @@ -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 { @@ -116,6 +117,7 @@ function restorePlaybackTime() { const playedEps = $playedEpisodes; const currentEp = $currentEpisode; + const requestedPlayback = $requestedPlaybackTime; if (!currentEp) { currentTime.set(0); @@ -123,6 +125,25 @@ 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 diff --git a/src/ui/PodcastView/EpisodePlayer.test.ts b/src/ui/PodcastView/EpisodePlayer.test.ts new file mode 100644 index 0000000..e175b1e --- /dev/null +++ b/src/ui/PodcastView/EpisodePlayer.test.ts @@ -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(); + }); +}); diff --git a/src/utility/encodePodnotesURI.ts b/src/utility/encodePodnotesURI.ts index 4afab4c..2415089 100644 --- a/src/utility/encodePodnotesURI.ts +++ b/src/utility/encodePodnotesURI.ts @@ -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()); }