diff --git a/docs/docs/api.md b/docs/docs/api.md index 52e718fc..a04938e9 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -5,10 +5,24 @@ export interface IAPI { readonly isPlaying: boolean; readonly length: number; currentTime: number; + volume: number; - getPodcastTimeFormatted(format: string, linkify?: boolean): string; + getPodcastTimeFormatted( + format: string, + linkify?: boolean, + offsetSeconds?: number, + ): string; + getPodcastSegmentFormatted( + format: string, + startTime: number, + endTime: number, + linkify?: boolean, + ): string; start(): void; stop(): void; + togglePlayback(): void; + skipBackward(): void; + skipForward(): void; } ``` @@ -34,3 +48,7 @@ export interface Episode { ## `getPodcastTimeFormatted(format: string, linkify?: boolean)` This function will return the current playback time formatted according to the given (moment) format. If `linkify` is true, the time will be linked to the current episode at the given time. This is used by PodNotes to play from the recorded time. + +## `getPodcastSegmentFormatted(format: string, startTime: number, endTime: number, linkify?: boolean)` +This function returns a formatted `start-end` playback range. +If `linkify` is true, the range links to the current episode with both `time` and `endTime` parameters so PodNotes starts at `startTime` and pauses at `endTime`. diff --git a/docs/docs/commands.md b/docs/docs/commands.md index e41041fa..e272b775 100644 --- a/docs/docs/commands.md +++ b/docs/docs/commands.md @@ -35,6 +35,12 @@ This will capture the current timestamp of the currently playing episode. See [timestamps](timestamps.md) for more information on timestamp templates. +## Capture Last 10 Seconds / Capture Last 20 Seconds +These commands capture a linked start-end segment ending at the current playback time. +When opened, the link seeks to the segment start and pauses playback at the segment end. + +See [timestamps](timestamps.md#capturing-segments) for more information on segment templates and behavior. + ## Create episode note This will create a note for the currently playing episode. @@ -68,4 +74,4 @@ This command will transcribe the currently playing episode using OpenAI's Whispe The transcription will be saved in the location specified in the transcript settings. -Note: This feature requires an OpenAI API key to be set in the settings. \ No newline at end of file +Note: This feature requires an OpenAI API key to be set in the settings. diff --git a/docs/docs/timestamps.md b/docs/docs/timestamps.md index fd8e4cbc..c624ea03 100644 --- a/docs/docs/timestamps.md +++ b/docs/docs/timestamps.md @@ -1,15 +1,18 @@ Timestamps can be created with the `Capture Timestamp` Obsidian command. This will make PodNotes capture the current playback time to the active note, in the format given in the plugin settings. +PodNotes can also capture recent playback segments with the `Capture Last 10 Seconds` and `Capture Last 20 Seconds` commands. ## Settings For timestamps, you can use the following format strings: - `{{time}}`: The current playback time. Default format is `HH:mm:ss`. - `{{linktime}}`: The current playback time, formatted as a link to the current episode. Default format is `HH:mm:ss`. +- `{{segment}}`: A start-end range for a captured segment. Default format is `HH:mm:ss`. +- `{{linksegment}}`: A start-end range, formatted as a link that opens the current episode at the segment start and pauses at the segment end. Default format is `HH:mm:ss`. -Both of these allow for custom formatting. -By using `{{time:format}}` or `{{linktime:format}}`, you can specify a custom [Moment.js](https://momentjs.com) format. +These allow for custom formatting. +By using `{{time:format}}`, `{{linktime:format}}`, `{{segment:format}}`, or `{{linksegment:format}}`, you can specify a custom [Moment.js](https://momentjs.com) format. For example, you might use `{{time:H\h mm\m ss\s}}` to get the time in the format `0h 20m 37s`. @@ -28,3 +31,10 @@ You can set this up by going to the `Mobile` tab of the Obsidian settings. When there, you can add the `PodNotes: Capture Timestamp` command to the editor toolbar. If it hasn't already been added as an option, it is either under `More toolbar options`, or you can add it manully by entering `PodNotes: Capture Timestamp` in the `Add global command` field. You can change the order of the buttons in the editor toolbar by dragging them up and down. The further up they are, the more to the left they will be. + +## Capturing segments +You can use the `PodNotes: Capture Last 10 Seconds` and `PodNotes: Capture Last 20 Seconds` commands to insert a link for the recent playback range ending at the current playback time. + +Segment capture uses the same timestamp template setting. If your template uses `{{time}}` or `{{linktime}}`, PodNotes automatically uses the segment equivalent for these commands, so the default `- {{linktime}}` template inserts a linked range such as `00:01:55-00:02:05`. + +Clicking a segment link reopens the episode, seeks to the segment start, starts playback, and pauses when the segment end is reached. Segment links do not extract or save separate audio clips, so they work without ffmpeg or other external dependencies. diff --git a/src/API/API.test.ts b/src/API/API.test.ts new file mode 100644 index 00000000..07588d4d --- /dev/null +++ b/src/API/API.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { get } from "svelte/store"; +import { API } from "./API"; +import { + currentEpisode, + currentTime, + activePlaybackSegment, + downloadedEpisodes, +} from "src/store"; +import type { Episode } from "src/types/Episode"; +import type { LocalEpisode } from "src/types/LocalEpisode"; + +const feedEpisode: Episode = { + title: "Feed Episode", + streamUrl: "https://pod.example.com/audio.mp3", + url: "https://pod.example.com/episode", + description: "", + content: "", + podcastName: "Feed Podcast", + feedUrl: "https://pod.example.com/feed.xml", +}; + +const localEpisode: LocalEpisode = { + title: "Local Episode", + streamUrl: "Audio/Local Episode.mp3", + url: "Audio/Local Episode.mp3", + description: "", + content: "", + podcastName: "local file", + filePath: "Audio/Local Episode.mp3", +}; + +beforeEach(() => { + currentEpisode.update(() => undefined as unknown as Episode); + currentTime.set(0); + activePlaybackSegment.set(null); + downloadedEpisodes.set({}); +}); + +describe("API.getPodcastSegmentFormatted", () => { + test("formats a plain segment range", () => { + currentEpisode.set(feedEpisode); + const api = new API(); + + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125)).toBe( + "00:01:55-00:02:05", + ); + }); + + test("links feed episodes with start and end times", () => { + currentEpisode.set(feedEpisode); + const api = new API(); + + const rendered = api.getPodcastSegmentFormatted( + "HH:mm:ss", + 115, + 125, + true, + ); + + expect(rendered).toContain("[00:01:55-00:02:05]"); + expect(rendered).toContain("time=115"); + expect(rendered).toContain("endTime=125"); + expect(rendered).toContain("url=https%3A%2F%2Fpod.example.com%2Ffeed.xml"); + }); + + test("links downloaded local episodes by file path", () => { + currentEpisode.set(localEpisode); + downloadedEpisodes.set({ + [localEpisode.podcastName]: [ + { + ...localEpisode, + filePath: "Audio/Local Episode.mp3", + size: 1, + }, + ], + }); + const api = new API(); + + const rendered = api.getPodcastSegmentFormatted("HH:mm:ss", 1, 2, true); + + expect(rendered).toContain("url=Audio%2FLocal%20Episode.mp3"); + expect(rendered).toContain("time=1"); + expect(rendered).toContain("endTime=2"); + }); + + test("does not link invalid segment ranges", () => { + currentEpisode.set(feedEpisode); + const api = new API(); + + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 125, 125, true)).toBe( + "00:02:05-00:02:05", + ); + expect(api.getPodcastSegmentFormatted("HH:mm:ss", 126, 125, true)).toBe( + "00:02:06-00:02:05", + ); + expect( + api.getPodcastSegmentFormatted("HH:mm:ss", 125, Number.NaN, true), + ).toBe("00:02:05-00:00:00"); + }); + + test("seeking through the public API clears an active playback segment", () => { + currentEpisode.set(feedEpisode); + activePlaybackSegment.set({ + episodeKey: `${feedEpisode.podcastName}::${feedEpisode.title}`, + startTime: 115, + endTime: 125, + }); + const api = new API(); + + api.currentTime = 500; + + expect(api.currentTime).toBe(500); + expect(get(activePlaybackSegment)).toBeNull(); + }); +}); diff --git a/src/API/API.ts b/src/API/API.ts index c39811d2..ed72deb7 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -7,12 +7,17 @@ import { downloadedEpisodes, duration, isPaused, + activePlaybackSegment, plugin, volume as volumeStore, } from "src/store"; import { get } from "svelte/store"; import encodePodnotesURI from "src/utility/encodePodnotesURI"; import { isLocalFile } from "src/utility/isLocalFile"; +import { + formatPodcastSegment, + normalizePodcastSegmentTimes, +} from "src/utility/podcastSegment"; const clampVolume = (value: number): number => Math.min(1, Math.max(0, value)); @@ -31,6 +36,7 @@ export class API implements IAPI { } public set currentTime(value: number) { + activePlaybackSegment.set(null); currentTime.update((_) => value); } @@ -67,10 +73,7 @@ export class API implements IAPI { if (!linkify) return time; - const epIsLocal = isLocalFile(this.podcast); - const feedUrl = !epIsLocal - ? this.podcast.feedUrl - : downloadedEpisodes.getEpisode(this.podcast)?.filePath; + const feedUrl = this.getEpisodeLinkTarget(); if (!feedUrl || feedUrl === "") { // Considered handling this as an error case, but I think @@ -87,6 +90,50 @@ export class API implements IAPI { return `[${time}](${url.href})`; } + getPodcastSegmentFormatted( + format: string, + startTime: number, + endTime: number, + linkify = false, + ): string { + if (!this.podcast) { + throw new Error("No podcast loaded"); + } + + const segmentTimes = normalizePodcastSegmentTimes(startTime, endTime); + const segment = segmentTimes + ? formatPodcastSegment( + segmentTimes.startTime, + segmentTimes.endTime, + format, + ) + : formatPodcastSegment(startTime, endTime, format); + + if (!linkify || !segmentTimes) return segment; + + const feedUrl = this.getEpisodeLinkTarget(); + + if (!feedUrl || feedUrl === "") { + return segment; + } + + const url = encodePodnotesURI( + this.podcast.title, + feedUrl, + segmentTimes.startTime, + segmentTimes.endTime, + ); + + return `[${segment}](${url.href})`; + } + + private getEpisodeLinkTarget(): string | undefined { + const epIsLocal = isLocalFile(this.podcast); + return !epIsLocal + ? this.podcast.feedUrl + : downloadedEpisodes.getEpisode(this.podcast)?.filePath; + } + start(): void { isPaused.update((_) => false); } diff --git a/src/API/IAPI.ts b/src/API/IAPI.ts index ac686980..5d41ba7f 100644 --- a/src/API/IAPI.ts +++ b/src/API/IAPI.ts @@ -12,6 +12,13 @@ export interface IAPI { linkify?: boolean, offsetSeconds?: number, ): string; + + getPodcastSegmentFormatted( + format: string, + startTime: number, + endTime: number, + linkify?: boolean, + ): string; start(): void; stop(): void; diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index b1fb14a0..c0b9f70a 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -5,12 +5,13 @@ import { FeedNoteTemplateEngine, FilePathTemplateEngine, NoteTemplateEngine, + TimestampTemplateEngine, TranscriptTemplateEngine, getFeedNoteWikilink, } from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import type { PodcastFeed } from "./types/PodcastFeed"; -import { downloadedEpisodes, plugin } from "./store"; +import { currentEpisode, currentTime, downloadedEpisodes, plugin } from "./store"; import { DEFAULT_SETTINGS } from "./constants"; // The illegal-character sanitizer is private; exercise it through @@ -73,6 +74,48 @@ const demoEpisode: Episode = { episodeDate: new Date("2024-01-01"), }; +describe("TimestampTemplateEngine segment tags", () => { + beforeEach(() => { + currentEpisode.set(demoEpisode); + currentTime.set(125); + downloadedEpisodes.set({}); + plugin.set({ + settings: { + timestamp: { offset: 0 }, + }, + api: { + getPodcastTimeFormatted: ( + format: string, + linkify: boolean, + offsetSeconds: number, + ) => + `time:${format}:${linkify ? "link" : "plain"}:${offsetSeconds}`, + getPodcastSegmentFormatted: ( + format: string, + startTime: number, + endTime: number, + linkify: boolean, + ) => + `segment:${format}:${startTime}-${endTime}:${linkify ? "link" : "plain"}`, + }, + } as never); + }); + + it("renders plain and linked segment ranges when segment context is provided", () => { + expect( + TimestampTemplateEngine("{{segment}} {{linksegment:mm:ss}}", { + segment: { startTime: 115, endTime: 125 }, + }), + ).toBe("segment:HH:mm:ss:115-125:plain segment:mm:ss:115-125:link"); + }); + + it("falls back to current time behavior when segment tags are used without segment context", () => { + expect(TimestampTemplateEngine("{{segment}} {{linksegment}}")).toBe( + "time:HH:mm:ss:plain:0 time:HH:mm:ss:link:0", + ); + }); +}); + describe("NoteTemplateEngine feed-scoped tags (#163)", () => { it("keeps {{url}} and {{artwork}} pointing at the episode itself", () => { plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never); diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index add62f35..0b40f497 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -12,6 +12,7 @@ import { parseEpisodeNumberFromTitle } from "./utility/parseEpisodeNumber"; import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink"; import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; +import type { PodcastSegmentTimes } from "./utility/podcastSegment"; // Each tag is either a literal string or a function taking at most one argument // (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The @@ -170,7 +171,10 @@ export function NoteTemplateEngine(template: string, episode: Episode) { return replacer(template); } -export function TimestampTemplateEngine(template: string) { +export function TimestampTemplateEngine( + template: string, + options: { segment?: PodcastSegmentTimes } = {}, +) { const [replacer, addTag] = useTemplateEngine(); const { api, settings } = get(plugin); const timestampOffset = settings.timestamp.offset ?? 0; @@ -181,6 +185,38 @@ export function TimestampTemplateEngine(template: string) { addTag("linktime", (format?: string) => api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true, timestampOffset), ); + addTag("segment", (format?: string) => { + if (!options.segment) { + return api.getPodcastTimeFormatted( + format ?? "HH:mm:ss", + false, + timestampOffset, + ); + } + + return api.getPodcastSegmentFormatted( + format ?? "HH:mm:ss", + options.segment.startTime, + options.segment.endTime, + false, + ); + }); + addTag("linksegment", (format?: string) => { + if (!options.segment) { + return api.getPodcastTimeFormatted( + format ?? "HH:mm:ss", + true, + timestampOffset, + ); + } + + return api.getPodcastSegmentFormatted( + format ?? "HH:mm:ss", + options.segment.startTime, + options.segment.endTime, + true, + ); + }); return replacer(template); } diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 59ea6174..247ab281 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -14,6 +14,7 @@ import { currentTime, duration, isPaused, + activePlaybackSegment, localFiles, playedEpisodes, requestedPlaybackTime, @@ -67,6 +68,7 @@ function resetStores() { currentTime.set(0); duration.set(0); isPaused.set(true); + activePlaybackSegment.set(null); playedEpisodes.set({}); requestedPlaybackTime.set(null); viewState.set(ViewState.PodcastGrid); @@ -141,11 +143,87 @@ describe("podNotesURIHandler", () => { episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, time: 240, }); + expect(get(activePlaybackSegment)).toBeNull(); expect( get(playedEpisodes)[`${testEpisode.podcastName}::${testEpisode.title}`]?.finished, ).toBe(true); }); + test("keeps the requested segment end for the player to apply after loading metadata", async () => { + await podNotesURIHandler( + { + action: "podnotes", + url: testFeedUrl, + episodeName: testEpisode.title, + time: "240", + endTime: "260", + }, + api as never, + ); + + expect(mockGetEpisodes).toHaveBeenCalledWith(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, + endTime: 260, + }); + expect(get(activePlaybackSegment)).toEqual({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + startTime: 240, + endTime: 260, + }); + }); + + test("seeks and arms a segment 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", + endTime: "135", + }, + api as never, + ); + + expect(get(currentTime)).toBe(120); + expect(get(isPaused)).toBe(false); + expect(get(activePlaybackSegment)).toEqual({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + startTime: 120, + endTime: 135, + }); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + }); + + test("normal timestamp links clear a stale active segment", async () => { + currentEpisode.set(testEpisode); + activePlaybackSegment.set({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + startTime: 10, + endTime: 20, + }); + + await podNotesURIHandler( + { + action: "podnotes", + url: testFeedUrl, + episodeName: testEpisode.title, + time: "120", + }, + api as never, + ); + + expect(get(activePlaybackSegment)).toBeNull(); + }); + test("switches to a non-loaded episode whose title contains a literal '+'", async () => { mockGetEpisodes.mockResolvedValue([plusEpisode]); @@ -471,6 +549,59 @@ describe("podNotesURIHandler", () => { expect(mockGetEpisodes).not.toHaveBeenCalled(); }); + test("rejects a segment end without a start timestamp", async () => { + await podNotesURIHandler( + { + action: "podnotes", + url: testFeedUrl, + episodeName: testEpisode.title, + endTime: "20", + }, + api as never, + ); + + expect(get(currentEpisode)).toBeUndefined(); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + expect(get(activePlaybackSegment)).toBeNull(); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + }); + + test("rejects a segment end before the start timestamp", async () => { + await podNotesURIHandler( + { + action: "podnotes", + url: testFeedUrl, + episodeName: testEpisode.title, + time: "20", + endTime: "20", + }, + api as never, + ); + + expect(get(currentEpisode)).toBeUndefined(); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + expect(get(activePlaybackSegment)).toBeNull(); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + }); + + test("rejects a negative segment start timestamp", async () => { + await podNotesURIHandler( + { + action: "podnotes", + url: testFeedUrl, + episodeName: testEpisode.title, + time: "-10", + endTime: "5", + }, + api as never, + ); + + expect(get(currentEpisode)).toBeUndefined(); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + expect(get(activePlaybackSegment)).toBeNull(); + expect(mockGetEpisodes).not.toHaveBeenCalled(); + }); + test("picks the '+'-variant local file (raw-first) even when its space-twin precedes it", async () => { const makeLocal = (title: string): LocalEpisode => ({ title, diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 6b240391..77930e55 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -8,6 +8,7 @@ import { currentTime, duration, isPaused, + activePlaybackSegment, localFiles, playedEpisodes, requestedPlaybackTime, @@ -17,6 +18,12 @@ import type { Episode } from "./types/Episode"; import { getEpisodeKey } from "./utility/episodeKey"; import { ViewState } from "./types/ViewState"; +type PodNotesProtocolData = ObsidianProtocolData & { + end?: string; + endTime?: string; + to?: string; +}; + /** * Obsidian decodes protocol query values with decodeURIComponent only, which does NOT turn '+' * into a space. Current PodNotes links percent-encode spaces as '%20' (see encodePodnotesURI), so @@ -64,7 +71,7 @@ function resolveResumeTime(episode: Episode): number { } export default async function podNotesURIHandler( - { url, episodeName, time }: ObsidianProtocolData, + { url, episodeName, time, endTime, end, to }: PodNotesProtocolData, api: IAPI ) { if (!url || !episodeName) { @@ -86,6 +93,13 @@ export default async function podNotesURIHandler( } } + const requestedEndTime = parseSegmentEndTime( + endTime ?? end ?? to, + hasExplicitTime, + requestedTime, + ); + if (requestedEndTime === null) return; + const nameCandidates = candidateValues(episodeName); const currentEp = get(currentEpisode); // Membership (not ordered selection) is correct here: there is only one loaded episode, and @@ -99,12 +113,15 @@ export default async function podNotesURIHandler( if (episodeIsPlaying) { if (hasExplicitTime) { + const episodeKey = getEpisodeKey(currentEp); requestedPlaybackTime.set({ - episodeKey: getEpisodeKey(currentEp), + episodeKey, time: requestedTime, + endTime: requestedEndTime, }); viewState.set(ViewState.Player); api.currentTime = requestedTime; + setActivePlaybackSegment(episodeKey, requestedTime, requestedEndTime); isPaused.set(false); if (playerIsVisible) { requestedPlaybackTime.set(null); @@ -117,6 +134,7 @@ export default async function podNotesURIHandler( // surface the player and resume playback without seeking — unless the // episode already finished (live position at its end), in which case // replaying would instantly fire `ended` and auto-advance, so restart it. + activePlaybackSegment.set(null); viewState.set(ViewState.Player); const liveTime = get(currentTime); const liveDuration = get(duration); @@ -170,7 +188,60 @@ export default async function podNotesURIHandler( requestedPlaybackTime.set({ episodeKey: getEpisodeKey(episode), time: hasExplicitTime ? requestedTime : resolveResumeTime(episode), + endTime: hasExplicitTime ? requestedEndTime : undefined, }); + setActivePlaybackSegment( + getEpisodeKey(episode), + hasExplicitTime ? requestedTime : resolveResumeTime(episode), + hasExplicitTime ? requestedEndTime : undefined, + ); currentEpisode.set(episode); viewState.set(ViewState.Player); } + +function parseSegmentEndTime( + rawEndTime: string | undefined, + hasExplicitTime: boolean, + requestedTime: number, +): number | undefined | null { + if (rawEndTime === undefined || rawEndTime === "") return undefined; + + if (!hasExplicitTime) { + new Notice("Segment links require a start timestamp"); + return null; + } + + if (requestedTime < 0) { + new Notice("Segment start time must be zero or greater"); + return null; + } + + const parsed = parseFloat(rawEndTime); + if (!Number.isFinite(parsed)) { + new Notice("Segment end time must be a valid number"); + return null; + } + + if (parsed <= requestedTime) { + new Notice("Segment end time must be after the start time"); + return null; + } + + return parsed; +} + +function setActivePlaybackSegment( + episodeKey: string, + startTime: number, + endTime: number | undefined, +): void { + activePlaybackSegment.set( + endTime === undefined + ? null + : { + episodeKey, + startTime, + endTime, + }, + ); +} diff --git a/src/main.ts b/src/main.ts index 04a7ed0c..12cfe9b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { sanitizeEpisodeListLimit, volume, } from "src/store"; -import { Plugin, type WorkspaceLeaf } from "obsidian"; +import { Notice, Plugin, type Editor, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants"; @@ -40,6 +40,10 @@ import CurrentEpisodeController from "./store_controllers/CurrentEpisodeControll import { HidePlayedEpisodesController } from "./store_controllers/HidePlayedEpisodesController"; import { TimestampTemplateEngine } from "./TemplateEngine"; import { prepareTimestampForInsertion } from "./utility/prepareTimestampInsertion"; +import { + createRecentPodcastSegment, + getSegmentCaptureTemplate, +} from "./utility/podcastSegment"; import createPodcastNote from "./createPodcastNote"; import createFeedNote from "./createFeedNote"; import { FeedSuggestModal, orderFeedsByCurrent } from "./ui/FeedSuggestModal"; @@ -265,33 +269,83 @@ export default class PodNotes extends Plugin implements IPodNotes { }, }); + const canCaptureTimestamp = () => + !!this.api.podcast && !!this.settings.timestamp.template; + const insertCapture = (editor: Editor, capture: string) => { + // Insert with replaceSelection (not getCursor + replaceRange + + // setCursor): it drops the text at the live cursor and lets the + // editor place the caret after it, which is reliable inside Live + // Preview table cells where hand-computed positions land in the + // wrong cell. Inside a table the capture is escaped so pipes and + // newlines don't break the row. See issue #165. + const cursor = editor.getCursor("from"); + const textToInsert = prepareTimestampForInsertion(capture, { + getLine: (line) => editor.getLine(line), + lineCount: editor.lineCount(), + cursorLine: cursor.line, + }); + + editor.replaceSelection(textToInsert); + }; + const captureRecentSegment = (editor: Editor, lengthSeconds: number) => { + const segment = createRecentPodcastSegment( + this.api.currentTime, + lengthSeconds, + this.settings.timestamp.offset ?? 0, + ); + + if (!segment) { + new Notice("Play more of the episode before capturing a segment"); + return; + } + + const capture = TimestampTemplateEngine( + getSegmentCaptureTemplate(this.settings.timestamp.template), + { segment }, + ); + insertCapture(editor, capture); + }; + this.addCommand({ id: "capture-timestamp", name: "Capture Timestamp", icon: "clock" as IconType, editorCheckCallback: (checking, editor, view) => { if (checking) { - return !!this.api.podcast && !!this.settings.timestamp.template; + return canCaptureTimestamp(); } const capture = TimestampTemplateEngine( this.settings.timestamp.template, ); - // Insert with replaceSelection (not getCursor + replaceRange + - // setCursor): it drops the text at the live cursor and lets the - // editor place the caret after it, which is reliable inside Live - // Preview table cells where hand-computed positions land in the - // wrong cell. Inside a table the capture is escaped so pipes and - // newlines don't break the row. See issue #165. - const cursor = editor.getCursor("from"); - const textToInsert = prepareTimestampForInsertion(capture, { - getLine: (line) => editor.getLine(line), - lineCount: editor.lineCount(), - cursorLine: cursor.line, - }); - - editor.replaceSelection(textToInsert); + insertCapture(editor, capture); + }, + }); + + this.addCommand({ + id: "capture-segment-10s", + name: "Capture Last 10 Seconds", + icon: "scissors" as IconType, + editorCheckCallback: (checking, editor) => { + if (checking) { + return canCaptureTimestamp(); + } + + captureRecentSegment(editor, 10); + }, + }); + + this.addCommand({ + id: "capture-segment-20s", + name: "Capture Last 20 Seconds", + icon: "scissors" as IconType, + editorCheckCallback: (checking, editor) => { + if (checking) { + return canCaptureTimestamp(); + } + + captureRecentSegment(editor, 20); }, }); diff --git a/src/store/index.ts b/src/store/index.ts index ca592651..6068b492 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -8,6 +8,7 @@ import { ViewState } from "src/types/ViewState"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import { TFile } from "obsidian"; import type { LocalEpisode } from "src/types/LocalEpisode"; +import type { PlaybackSegment } from "src/types/PlaybackSegment"; import { DEFAULT_EPISODE_LIST_LIMIT, LOCAL_FILES_SETTINGS, @@ -24,7 +25,9 @@ export const currentTime = writable(0); export const requestedPlaybackTime = writable<{ episodeKey: string; time: number; + endTime?: number; } | null>(null); +export const activePlaybackSegment = writable(null); export const duration = writable(0); export const volume = writable(1); export const hidePlayedEpisodes = writable(false); diff --git a/src/types/PlaybackSegment.ts b/src/types/PlaybackSegment.ts new file mode 100644 index 00000000..0fde21c2 --- /dev/null +++ b/src/types/PlaybackSegment.ts @@ -0,0 +1,5 @@ +export interface PlaybackSegment { + episodeKey: string; + startTime: number; + endTime: number; +} diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index b7c4189a..943fb032 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -12,6 +12,7 @@ viewState, downloadedEpisodes, requestedPlaybackTime, + activePlaybackSegment, } from "src/store"; import { formatSeconds } from "src/utility/formatSeconds"; import { fetchChapters } from "src/utility/fetchChapters"; @@ -66,6 +67,7 @@ let hasSeenFirstEpisodeFire: boolean = false; let chapters: Chapter[] = []; let lastChaptersUrl: string | undefined = undefined; + let segmentStopTimeWithoutProgressSave: number | null = null; function togglePlayback() { isPaused.update((value) => !value); @@ -75,14 +77,14 @@ { detail: { event, percent } }: CustomEvent<{ event: MouseEvent | KeyboardEvent; percent?: number }> ) { if (typeof percent === "number") { - currentTime.set(percent * $duration); + seekPlaybackTo(percent * $duration); return; } if (event instanceof MouseEvent) { const progressbar = event.currentTarget as HTMLDivElement; const ratio = progressbar.offsetWidth ? event.offsetX / progressbar.offsetWidth : 0; - currentTime.set(ratio * $duration); + seekPlaybackTo(ratio * $duration); } } @@ -101,6 +103,19 @@ } function onEpisodeEnded() { + const activeSegment = $activePlaybackSegment; + if (activeSegment && episodeMatchesKey($currentEpisode, activeSegment.episodeKey)) { + const stopTime = + Number.isFinite($duration) && $duration > 0 + ? Math.min(activeSegment.endTime, $duration) + : $currentTime; + currentTime.set(stopTime); + segmentStopTimeWithoutProgressSave = stopTime; + activePlaybackSegment.set(null); + isPaused.set(true); + return; + } + playedEpisodes.markAsPlayed($currentEpisode); removeEpisodeFromPlaylists(); @@ -118,7 +133,13 @@ } function onChapterSeek(event: CustomEvent<{ time: number }>) { - currentTime.set(event.detail.time); + seekPlaybackTo(event.detail.time); + } + + function seekPlaybackTo(time: number) { + activePlaybackSegment.set(null); + segmentStopTimeWithoutProgressSave = null; + currentTime.set(time); } function onMetadataLoaded() { @@ -140,16 +161,29 @@ if (requestedPlayback !== null) { requestedPlaybackTime.set(null); + activePlaybackSegment.set(null); + segmentStopTimeWithoutProgressSave = null; if (!episodeMatchesKey(currentEp, requestedPlayback.episodeKey)) { restoreSavedPlaybackTime(currentEp, playedEps); return; } currentTime.set(requestedPlayback.time); + activePlaybackSegment.set( + requestedPlayback.endTime === undefined + ? null + : { + episodeKey: requestedPlayback.episodeKey, + startTime: requestedPlayback.time, + endTime: requestedPlayback.endTime, + }, + ); isPaused.set(false); return; } + activePlaybackSegment.set(null); + segmentStopTimeWithoutProgressSave = null; restoreSavedPlaybackTime(currentEp, playedEps); } @@ -191,6 +225,7 @@ // restored (onMetadataLoaded) — writing the pre-restore 0 would clobber the // stored position we are about to resume from. if (isLoading || !$currentEpisode) return; + if (shouldSuppressSegmentProgressPersistence()) return; playedEpisodes.setEpisodeTime( $currentEpisode, @@ -202,7 +237,9 @@ ); } - function onTimeUpdate() { + function onTimeUpdate(event: Event) { + if (stopActivePlaybackSegmentIfEnded(event)) return; + const now = Date.now(); if (now - lastPositionSaveMs < SAVE_POSITION_THROTTLE_MS) return; @@ -210,6 +247,41 @@ persistPlaybackPosition(); } + function shouldSuppressSegmentProgressPersistence(): boolean { + const activeSegment = $activePlaybackSegment; + if (activeSegment && episodeMatchesKey($currentEpisode, activeSegment.episodeKey)) { + return true; + } + + if (segmentStopTimeWithoutProgressSave === null) return false; + if ($currentTime === segmentStopTimeWithoutProgressSave) return true; + + segmentStopTimeWithoutProgressSave = null; + return false; + } + + function stopActivePlaybackSegmentIfEnded(event: Event): boolean { + const activeSegment = $activePlaybackSegment; + if (!activeSegment) return false; + + if (!episodeMatchesKey($currentEpisode, activeSegment.episodeKey)) { + activePlaybackSegment.set(null); + return false; + } + + if ($currentTime < activeSegment.endTime) return false; + + const audio = event.currentTarget as HTMLAudioElement | null; + if (audio) { + audio.currentTime = activeSegment.endTime; + } + currentTime.set(activeSegment.endTime); + segmentStopTimeWithoutProgressSave = activeSegment.endTime; + activePlaybackSegment.set(null); + isPaused.set(true); + return true; + } + function onPause() { // An explicit stop is the moment the user is most likely to leave the app, // so capture the exact position immediately rather than waiting for the @@ -235,6 +307,7 @@ // time and clobber its saved resume position (issue #33). isLoading = true; lastPositionSaveMs = Number.NEGATIVE_INFINITY; + segmentStopTimeWithoutProgressSave = null; // Clear the outgoing episode's progress the instant the episode changes // so the player never renders its full/last position against the diff --git a/src/ui/PodcastView/EpisodePlayer.test.ts b/src/ui/PodcastView/EpisodePlayer.test.ts index 2a5b693c..4426deb7 100644 --- a/src/ui/PodcastView/EpisodePlayer.test.ts +++ b/src/ui/PodcastView/EpisodePlayer.test.ts @@ -7,6 +7,7 @@ import { currentTime, duration, isPaused, + activePlaybackSegment, playedEpisodes, plugin, requestedPlaybackTime, @@ -29,6 +30,7 @@ beforeEach(() => { currentTime.set(0); duration.set(3600); isPaused.set(true); + activePlaybackSegment.set(null); playedEpisodes.set({}); requestedPlaybackTime.set(null); HTMLMediaElement.prototype.play = vi.fn(() => Promise.resolve()); @@ -65,6 +67,129 @@ describe("EpisodePlayer", () => { expect(get(requestedPlaybackTime)).toBeNull(); }); + test("arms a requested segment after metadata loads", async () => { + requestedPlaybackTime.set({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + time: 240, + endTime: 260, + }); + + 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(); + expect(get(activePlaybackSegment)).toEqual({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + startTime: 240, + endTime: 260, + }); + }); + + test("stops playback at the active segment end", async () => { + requestedPlaybackTime.set({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + time: 115, + endTime: 125, + }); + + const { container } = render(EpisodePlayer); + await waitFor(() => { + expect(container.querySelector("audio")).not.toBeNull(); + }); + const audio = container.querySelector("audio") as HTMLAudioElement; + await fireEvent.loadedMetadata(audio); + + currentTime.set(126); + isPaused.set(false); + await fireEvent.timeUpdate(audio); + + expect(get(currentTime)).toBe(125); + expect(get(isPaused)).toBe(true); + expect(get(activePlaybackSegment)).toBeNull(); + }); + + test("does not persist preview segment progress over saved listening progress", async () => { + const episodeKey = `${testEpisode.podcastName}::${testEpisode.title}`; + playedEpisodes.setEpisodeTime(testEpisode, 1000, 3600, false); + requestedPlaybackTime.set({ + episodeKey, + time: 115, + endTime: 125, + }); + + const { container, unmount } = render(EpisodePlayer); + await waitFor(() => { + expect(container.querySelector("audio")).not.toBeNull(); + }); + const audio = container.querySelector("audio") as HTMLAudioElement; + await fireEvent.loadedMetadata(audio); + + currentTime.set(120); + await fireEvent.timeUpdate(audio); + expect(get(playedEpisodes)[episodeKey]?.time).toBe(1000); + + currentTime.set(126); + await fireEvent.timeUpdate(audio); + await fireEvent.pause(audio); + unmount(); + + expect(get(currentTime)).toBe(125); + expect(get(playedEpisodes)[episodeKey]?.time).toBe(1000); + }); + + test("manual seeks clear the active segment", async () => { + requestedPlaybackTime.set({ + episodeKey: `${testEpisode.podcastName}::${testEpisode.title}`, + time: 115, + endTime: 125, + }); + + const { container } = render(EpisodePlayer); + await waitFor(() => { + expect(container.querySelector("audio")).not.toBeNull(); + }); + const audio = container.querySelector("audio") as HTMLAudioElement; + await fireEvent.loadedMetadata(audio); + duration.set(3600); + + const progress = container.querySelector(".progress") as HTMLElement; + await fireEvent.keyDown(progress, { key: "End" }); + + expect(get(currentTime)).toBe(3600); + expect(get(activePlaybackSegment)).toBeNull(); + }); + + test("a segment ending at media end pauses instead of marking the episode played", async () => { + const episodeKey = `${testEpisode.podcastName}::${testEpisode.title}`; + requestedPlaybackTime.set({ + episodeKey, + time: 3590, + endTime: 3600, + }); + + const { container } = render(EpisodePlayer); + await waitFor(() => { + expect(container.querySelector("audio")).not.toBeNull(); + }); + const audio = container.querySelector("audio") as HTMLAudioElement; + await fireEvent.loadedMetadata(audio); + + currentTime.set(3600); + await fireEvent.ended(audio); + + expect(get(currentTime)).toBe(3600); + expect(get(isPaused)).toBe(true); + expect(get(activePlaybackSegment)).toBeNull(); + expect(get(playedEpisodes)[episodeKey]).toBeUndefined(); + }); + test("ignores stale requested timestamp for a different episode", async () => { playedEpisodes.setEpisodeTime(testEpisode, 1800, 3600, false); requestedPlaybackTime.set({ diff --git a/src/utility/encodePodnotesURI.test.ts b/src/utility/encodePodnotesURI.test.ts index fa9ab434..cc362ee3 100644 --- a/src/utility/encodePodnotesURI.test.ts +++ b/src/utility/encodePodnotesURI.test.ts @@ -51,4 +51,16 @@ describe("encodePodnotesURI", () => { const url = encodePodnotesURI("Title", "https://x/feed"); expect(url.href).not.toContain("time="); }); + + test("encodes segment end time when a start time is present", () => { + const url = encodePodnotesURI("Title", "https://x/feed", 115, 125); + expect(param(url.href, "time")).toBe("115"); + expect(param(url.href, "endTime")).toBe("125"); + }); + + test("omits segment end time when no start time is present", () => { + const url = encodePodnotesURI("Title", "https://x/feed", undefined, 125); + expect(url.href).not.toContain("time="); + expect(url.href).not.toContain("endTime="); + }); }); diff --git a/src/utility/encodePodnotesURI.ts b/src/utility/encodePodnotesURI.ts index 5c251c2e..300cd096 100644 --- a/src/utility/encodePodnotesURI.ts +++ b/src/utility/encodePodnotesURI.ts @@ -1,4 +1,9 @@ -export default function encodePodnotesURI(title: string, feedUrl: string, time?: number): URL { +export default function encodePodnotesURI( + title: string, + feedUrl: string, + time?: number, + endTime?: number, +): URL { const url = new URL(`obsidian://podnotes`); const params = new URLSearchParams(); @@ -7,6 +12,9 @@ export default function encodePodnotesURI(title: string, feedUrl: string, time?: if (time !== undefined) { params.set('time', time.toString()); + if (endTime !== undefined) { + params.set('endTime', endTime.toString()); + } } // Obsidian decodes protocol query values with decodeURIComponent only, which does NOT turn diff --git a/src/utility/podcastSegment.test.ts b/src/utility/podcastSegment.test.ts new file mode 100644 index 00000000..72fed57a --- /dev/null +++ b/src/utility/podcastSegment.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest"; +import { + createRecentPodcastSegment, + formatPodcastSegment, + getSegmentCaptureTemplate, + normalizePodcastSegmentTimes, +} from "./podcastSegment"; + +describe("normalizePodcastSegmentTimes", () => { + test("normalizes valid positive segment ranges", () => { + expect(normalizePodcastSegmentTimes(115, 125)).toEqual({ + startTime: 115, + endTime: 125, + }); + }); + + test("rejects non-finite and non-positive ranges", () => { + expect(normalizePodcastSegmentTimes(Number.NaN, 125)).toBeNull(); + expect(normalizePodcastSegmentTimes(125, Number.POSITIVE_INFINITY)).toBeNull(); + expect(normalizePodcastSegmentTimes(125, 125)).toBeNull(); + expect(normalizePodcastSegmentTimes(126, 125)).toBeNull(); + }); +}); + +describe("createRecentPodcastSegment", () => { + test("creates a trailing segment ending at the adjusted playback time", () => { + expect(createRecentPodcastSegment(125, 10, 3)).toEqual({ + startTime: 112, + endTime: 122, + }); + }); + + test("clamps the start to zero", () => { + expect(createRecentPodcastSegment(5, 10, 0)).toEqual({ + startTime: 0, + endTime: 5, + }); + }); + + test("returns null when no positive segment can be captured", () => { + expect(createRecentPodcastSegment(0, 10, 0)).toBeNull(); + expect(createRecentPodcastSegment(125, 0, 0)).toBeNull(); + }); +}); + +describe("formatPodcastSegment", () => { + test("formats start and end with the same clock format", () => { + expect(formatPodcastSegment(115, 125, "HH:mm:ss")).toBe( + "00:01:55-00:02:05", + ); + }); +}); + +describe("getSegmentCaptureTemplate", () => { + test("turns the default timestamp tags into segment tags", () => { + expect(getSegmentCaptureTemplate("- {{linktime}}")).toBe( + "- {{linksegment}}", + ); + expect(getSegmentCaptureTemplate("- {{time:mm:ss}}")).toBe( + "- {{segment:mm:ss}}", + ); + }); + + test("preserves an explicit segment template", () => { + expect(getSegmentCaptureTemplate("> {{linksegment}}")).toBe( + "> {{linksegment}}", + ); + }); + + test("falls back to a linked segment when no time tag exists", () => { + expect(getSegmentCaptureTemplate("captured")).toBe("- {{linksegment}}"); + }); +}); diff --git a/src/utility/podcastSegment.ts b/src/utility/podcastSegment.ts new file mode 100644 index 00000000..9c746a17 --- /dev/null +++ b/src/utility/podcastSegment.ts @@ -0,0 +1,69 @@ +import { formatSeconds } from "./formatSeconds"; + +export type PodcastSegmentTimes = { + startTime: number; + endTime: number; +}; + +export function normalizePodcastSegmentTimes( + startTime: number, + endTime: number, +): PodcastSegmentTimes | null { + if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) { + return null; + } + + const normalized = { + startTime: Math.max(0, startTime), + endTime: Math.max(0, endTime), + }; + + return normalized.endTime > normalized.startTime ? normalized : null; +} + +export function createRecentPodcastSegment( + currentTime: number, + lengthSeconds: number, + offsetSeconds = 0, +): PodcastSegmentTimes | null { + if ( + !Number.isFinite(currentTime) || + !Number.isFinite(lengthSeconds) || + lengthSeconds <= 0 + ) { + return null; + } + + const endTime = Math.max(0, currentTime - offsetSeconds); + const startTime = Math.max(0, endTime - lengthSeconds); + + return normalizePodcastSegmentTimes(startTime, endTime); +} + +export function formatPodcastSegment( + startTime: number, + endTime: number, + format: string, +): string { + return `${formatSeconds(Math.max(0, startTime), format)}-${formatSeconds( + Math.max(0, endTime), + format, + )}`; +} + +export function getSegmentCaptureTemplate(template: string): string { + if (/\{\{(?:linksegment|segment)(?::\s*?.+?)?\}\}/i.test(template)) { + return template; + } + + const withLinkSegment = template.replace( + /\{\{linktime(:\s*?.+?)?\}\}/gi, + "{{linksegment$1}}", + ); + const withSegment = withLinkSegment.replace( + /\{\{time(:\s*?.+?)?\}\}/gi, + "{{segment$1}}", + ); + + return withSegment === template ? "- {{linksegment}}" : withSegment; +} diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index 7178568e..5a4fe2b5 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -34,18 +34,24 @@ describe("PodNotes runtime", () => { await openPodNotesView(obsidian); const state = await obsidian.dev.evalJson<{ + hasCaptureSegment10Command: boolean; + hasCaptureSegment20Command: boolean; hasProtocolHandler: boolean; hasShowCommand: boolean; viewCount: number; }>(` - (() => ({ - hasProtocolHandler: app.workspace.protocolHandlers?.has(${JSON.stringify(PLUGIN_ID)}) ?? false, - hasShowCommand: Boolean(app.commands?.commands?.[${JSON.stringify(`${PLUGIN_ID}:podnotes-show-leaf`)}]), - viewCount: app.workspace.getLeavesOfType(${JSON.stringify(VIEW_TYPE)}).length, + (() => ({ + hasCaptureSegment10Command: Boolean(app.commands?.commands?.[${JSON.stringify(`${PLUGIN_ID}:capture-segment-10s`)}]), + hasCaptureSegment20Command: Boolean(app.commands?.commands?.[${JSON.stringify(`${PLUGIN_ID}:capture-segment-20s`)}]), + hasProtocolHandler: app.workspace.protocolHandlers?.has(${JSON.stringify(PLUGIN_ID)}) ?? false, + hasShowCommand: Boolean(app.commands?.commands?.[${JSON.stringify(`${PLUGIN_ID}:podnotes-show-leaf`)}]), + viewCount: app.workspace.getLeavesOfType(${JSON.stringify(VIEW_TYPE)}).length, }))() `); expect(state).toMatchObject({ + hasCaptureSegment10Command: true, + hasCaptureSegment20Command: true, hasProtocolHandler: true, hasShowCommand: true, }); @@ -171,9 +177,7 @@ describe("PodNotes runtime", () => { timestampTemplate: "- {{linktime}}", }); await waitForPodNotesReady(obsidian); - await sandbox.write("capture-target.md", "", { waitForContent: true }); - await obsidian.open({ path: notePath }); - await obsidian.waitForActiveFile(notePath, WAIT_OPTS); + await openMarkdownFile(obsidian, notePath); await setPlayback(obsidian, { currentTime: 125, paused: false }); await obsidian.command(`${PLUGIN_ID}:capture-timestamp`).run(); @@ -191,6 +195,97 @@ describe("PodNotes runtime", () => { expect(content).toContain("time=125"); }); + test("captures a linked segment into the active editor", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(sandbox, "capture-segment-episode.mp3"); + const episode = createLocalEpisode( + "E2E Capture Segment Episode", + audioPath, + ); + const notePath = sandbox.path("capture-segment-target.md"); + + await seedRuntimeData(plugin, sandbox, episode, { + currentEpisode: episode, + timestampTemplate: "- {{linktime}}", + }); + await waitForPodNotesReady(obsidian); + await openMarkdownFile(obsidian, notePath); + await setPlayback(obsidian, { currentTime: 125, paused: false }); + + await obsidian.command(`${PLUGIN_ID}:capture-segment-10s`).run(); + + const expectedLink = `- ${expectedTimestampLink( + "00:01:55-00:02:05", + episode, + 115, + 125, + )}`; + const content = await sandbox.waitForContent( + "capture-segment-target.md", + (value) => value.includes(expectedLink), + WAIT_OPTS, + ); + + expect(content).toContain(expectedLink); + expect(content).toContain("time=115"); + expect(content).toContain("endTime=125"); + + await obsidian.command(`${PLUGIN_ID}:capture-segment-20s`).run(); + const expected20SecondLink = expectedTimestampLink( + "00:01:45-00:02:05", + episode, + 105, + 125, + ); + const contentWithBothSegments = await sandbox.waitForContent( + "capture-segment-target.md", + (value) => value.includes(expected20SecondLink), + WAIT_OPTS, + ); + + expect(contentWithBothSegments).toContain(expected20SecondLink); + }); + + test("stops playback when a segment URI reaches its end", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(sandbox, "segment-uri-episode.mp3"); + const episode = createLocalEpisode("E2E Segment URI Episode", audioPath); + + await seedRuntimeData(plugin, sandbox, episode, { + timestampTemplate: "- {{linktime}}", + }); + await waitForPodNotesReady(obsidian); + await openPodNotesView(obsidian); + + await invokePodNotesUri(obsidian, episode, 115, 125); + await dispatchLoadedMetadata(obsidian); + + await waitForPlaybackState( + obsidian, + (value) => + value.hasPlayer && + value.title === episode.title && + value.isPlaying && + value.currentTime === 115, + ); + + await dispatchAudioTimeUpdate(obsidian, 126); + + const state = await waitForPlaybackState( + obsidian, + (value) => + value.title === episode.title && + !value.isPlaying && + value.currentTime === 125, + ); + + expect(state).toMatchObject({ + currentTime: 125, + isPlaying: false, + title: episode.title, + }); + }); + test("persists API volume changes and clamps out-of-range values", async () => { const { obsidian, plugin } = getContext(); @@ -229,6 +324,61 @@ async function seedAudio( return sandbox.path(fileName); } +async function openMarkdownFile( + obsidian: Parameters[0], + path: string, +): Promise { + const result = await evalJsonAsync<{ + activePath: string | null; + error?: string; + ok: boolean; + }>( + obsidian, + ` + (async () => { + const targetPath = ${JSON.stringify(path)}; + let file = app.vault.getAbstractFileByPath(targetPath); + if (!file) { + const parentParts = targetPath.split("/").slice(0, -1); + let current = ""; + for (const part of parentParts) { + current = current ? current + "/" + part : part; + if (!app.vault.getAbstractFileByPath(current)) { + await app.vault.createFolder(current).catch(() => undefined); + } + } + + if (await app.vault.adapter.exists(targetPath)) { + await app.vault.adapter.remove(targetPath); + } + + file = await app.vault.create(targetPath, ""); + } + + if (!file) { + return { ok: false, activePath: app.workspace.getActiveFile()?.path ?? null, error: "File not found." }; + } + + const leaf = app.workspace.getLeaf(true); + await leaf.openFile(file); + await app.workspace.revealLeaf(leaf); + app.workspace.setActiveLeaf(leaf, { focus: true }); + + return { + ok: app.workspace.getActiveFile()?.path === targetPath, + activePath: app.workspace.getActiveFile()?.path ?? null, + }; + })() + `, + ); + + if (!result.ok) { + throw new Error( + `Failed to open ${path}. Active file: ${result.activePath}. ${result.error ?? ""}`, + ); + } +} + function createLocalEpisode(title: string, audioPath: string): LocalEpisode { return { title, @@ -319,6 +469,7 @@ async function invokePodNotesUri( obsidian: Parameters[0], episode: LocalEpisode, time: number, + endTime?: number, ): Promise { const result = await evalJsonAsync<{ error?: string; ok: boolean }>( obsidian, @@ -334,6 +485,7 @@ async function invokePodNotesUri( url: ${JSON.stringify(episode.filePath ?? episode.streamUrl)}, episodeName: ${JSON.stringify(episode.title)}, time: ${JSON.stringify(String(time))}, + endTime: ${JSON.stringify(endTime === undefined ? undefined : String(endTime))}, }); return { ok: true }; @@ -401,6 +553,42 @@ async function dispatchAudioPlay(obsidian: { } } +async function dispatchAudioTimeUpdate( + obsidian: { + dev: { evalJson: (code: string) => Promise }; + }, + currentTime?: number, +): Promise { + const result = await obsidian.dev.evalJson<{ error?: string; ok: boolean }>(` + (() => { + const audio = document.querySelector(".podcast-view audio"); + if (!audio) { + return { ok: false, error: "No PodNotes audio element found." }; + } + + if (${JSON.stringify(currentTime)} !== undefined) { + Object.defineProperty(audio, "currentTime", { + configurable: true, + writable: true, + value: ${JSON.stringify(currentTime)}, + }); + } + + Object.defineProperty(audio, "paused", { + configurable: true, + value: false, + }); + audio.dispatchEvent(new Event("timeupdate")); + + return { ok: true }; + })() + `); + + if (!result.ok) { + throw new Error(result.error ?? "Failed to dispatch audio timeupdate."); + } +} + async function waitForPlaybackState( obsidian: { dev: { evalJson: (code: string) => Promise }; @@ -480,12 +668,14 @@ function expectedTimestampLink( label: string, episode: LocalEpisode, time: number, + endTime?: number, ): string { // Use the production encoder so this helper can never drift from the real wire format. const uri = encodePodnotesURI( episode.title, episode.filePath ?? episode.streamUrl, time, + endTime, ); return `[${label}](${uri.href})`;