Skip to content

Commit d97e59e

Browse files
authored
feat: add podcast segment links (#205)
1 parent 964e342 commit d97e59e

20 files changed

Lines changed: 1137 additions & 40 deletions

docs/docs/api.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,24 @@ export interface IAPI {
55
readonly isPlaying: boolean;
66
readonly length: number;
77
currentTime: number;
8+
volume: number;
89

9-
getPodcastTimeFormatted(format: string, linkify?: boolean): string;
10+
getPodcastTimeFormatted(
11+
format: string,
12+
linkify?: boolean,
13+
offsetSeconds?: number,
14+
): string;
15+
getPodcastSegmentFormatted(
16+
format: string,
17+
startTime: number,
18+
endTime: number,
19+
linkify?: boolean,
20+
): string;
1021
start(): void;
1122
stop(): void;
23+
togglePlayback(): void;
24+
skipBackward(): void;
25+
skipForward(): void;
1226
}
1327
```
1428

@@ -34,3 +48,7 @@ export interface Episode {
3448
## `getPodcastTimeFormatted(format: string, linkify?: boolean)`
3549
This function will return the current playback time formatted according to the given (moment) format.
3650
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.
51+
52+
## `getPodcastSegmentFormatted(format: string, startTime: number, endTime: number, linkify?: boolean)`
53+
This function returns a formatted `start-end` playback range.
54+
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`.

docs/docs/commands.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ This will capture the current timestamp of the currently playing episode.
3535

3636
See [timestamps](timestamps.md) for more information on timestamp templates.
3737

38+
## Capture Last 10 Seconds / Capture Last 20 Seconds
39+
These commands capture a linked start-end segment ending at the current playback time.
40+
When opened, the link seeks to the segment start and pauses playback at the segment end.
41+
42+
See [timestamps](timestamps.md#capturing-segments) for more information on segment templates and behavior.
43+
3844
## Create episode note
3945
This will create a note for the currently playing episode.
4046

@@ -68,4 +74,4 @@ This command will transcribe the currently playing episode using OpenAI's Whispe
6874

6975
The transcription will be saved in the location specified in the transcript settings.
7076

71-
Note: This feature requires an OpenAI API key to be set in the settings.
77+
Note: This feature requires an OpenAI API key to be set in the settings.

docs/docs/timestamps.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
Timestamps can be created with the `Capture Timestamp` Obsidian command.
22

33
This will make PodNotes capture the current playback time to the active note, in the format given in the plugin settings.
4+
PodNotes can also capture recent playback segments with the `Capture Last 10 Seconds` and `Capture Last 20 Seconds` commands.
45

56
## Settings
67
For timestamps, you can use the following format strings:
78

89
- `{{time}}`: The current playback time. Default format is `HH:mm:ss`.
910
- `{{linktime}}`: The current playback time, formatted as a link to the current episode. Default format is `HH:mm:ss`.
11+
- `{{segment}}`: A start-end range for a captured segment. Default format is `HH:mm:ss`.
12+
- `{{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`.
1013

11-
Both of these allow for custom formatting.
12-
By using `{{time:format}}` or `{{linktime:format}}`, you can specify a custom [Moment.js](https://momentjs.com) format.
14+
These allow for custom formatting.
15+
By using `{{time:format}}`, `{{linktime:format}}`, `{{segment:format}}`, or `{{linksegment:format}}`, you can specify a custom [Moment.js](https://momentjs.com) format.
1316

1417
For example, you might use `{{time:H\h mm\m ss\s}}` to get the time in the format `0h 20m 37s`.
1518

@@ -28,3 +31,10 @@ You can set this up by going to the `Mobile` tab of the Obsidian settings.
2831
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.
2932

3033
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.
34+
35+
## Capturing segments
36+
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.
37+
38+
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`.
39+
40+
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.

src/API/API.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { beforeEach, describe, expect, test } from "vitest";
2+
import { get } from "svelte/store";
3+
import { API } from "./API";
4+
import {
5+
currentEpisode,
6+
currentTime,
7+
activePlaybackSegment,
8+
downloadedEpisodes,
9+
} from "src/store";
10+
import type { Episode } from "src/types/Episode";
11+
import type { LocalEpisode } from "src/types/LocalEpisode";
12+
13+
const feedEpisode: Episode = {
14+
title: "Feed Episode",
15+
streamUrl: "https://pod.example.com/audio.mp3",
16+
url: "https://pod.example.com/episode",
17+
description: "",
18+
content: "",
19+
podcastName: "Feed Podcast",
20+
feedUrl: "https://pod.example.com/feed.xml",
21+
};
22+
23+
const localEpisode: LocalEpisode = {
24+
title: "Local Episode",
25+
streamUrl: "Audio/Local Episode.mp3",
26+
url: "Audio/Local Episode.mp3",
27+
description: "",
28+
content: "",
29+
podcastName: "local file",
30+
filePath: "Audio/Local Episode.mp3",
31+
};
32+
33+
beforeEach(() => {
34+
currentEpisode.update(() => undefined as unknown as Episode);
35+
currentTime.set(0);
36+
activePlaybackSegment.set(null);
37+
downloadedEpisodes.set({});
38+
});
39+
40+
describe("API.getPodcastSegmentFormatted", () => {
41+
test("formats a plain segment range", () => {
42+
currentEpisode.set(feedEpisode);
43+
const api = new API();
44+
45+
expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125)).toBe(
46+
"00:01:55-00:02:05",
47+
);
48+
});
49+
50+
test("links feed episodes with start and end times", () => {
51+
currentEpisode.set(feedEpisode);
52+
const api = new API();
53+
54+
const rendered = api.getPodcastSegmentFormatted(
55+
"HH:mm:ss",
56+
115,
57+
125,
58+
true,
59+
);
60+
61+
expect(rendered).toContain("[00:01:55-00:02:05]");
62+
expect(rendered).toContain("time=115");
63+
expect(rendered).toContain("endTime=125");
64+
expect(rendered).toContain("url=https%3A%2F%2Fpod.example.com%2Ffeed.xml");
65+
});
66+
67+
test("links downloaded local episodes by file path", () => {
68+
currentEpisode.set(localEpisode);
69+
downloadedEpisodes.set({
70+
[localEpisode.podcastName]: [
71+
{
72+
...localEpisode,
73+
filePath: "Audio/Local Episode.mp3",
74+
size: 1,
75+
},
76+
],
77+
});
78+
const api = new API();
79+
80+
const rendered = api.getPodcastSegmentFormatted("HH:mm:ss", 1, 2, true);
81+
82+
expect(rendered).toContain("url=Audio%2FLocal%20Episode.mp3");
83+
expect(rendered).toContain("time=1");
84+
expect(rendered).toContain("endTime=2");
85+
});
86+
87+
test("does not link invalid segment ranges", () => {
88+
currentEpisode.set(feedEpisode);
89+
const api = new API();
90+
91+
expect(api.getPodcastSegmentFormatted("HH:mm:ss", 125, 125, true)).toBe(
92+
"00:02:05-00:02:05",
93+
);
94+
expect(api.getPodcastSegmentFormatted("HH:mm:ss", 126, 125, true)).toBe(
95+
"00:02:06-00:02:05",
96+
);
97+
expect(
98+
api.getPodcastSegmentFormatted("HH:mm:ss", 125, Number.NaN, true),
99+
).toBe("00:02:05-00:00:00");
100+
});
101+
102+
test("seeking through the public API clears an active playback segment", () => {
103+
currentEpisode.set(feedEpisode);
104+
activePlaybackSegment.set({
105+
episodeKey: `${feedEpisode.podcastName}::${feedEpisode.title}`,
106+
startTime: 115,
107+
endTime: 125,
108+
});
109+
const api = new API();
110+
111+
api.currentTime = 500;
112+
113+
expect(api.currentTime).toBe(500);
114+
expect(get(activePlaybackSegment)).toBeNull();
115+
});
116+
});

src/API/API.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import {
77
downloadedEpisodes,
88
duration,
99
isPaused,
10+
activePlaybackSegment,
1011
plugin,
1112
volume as volumeStore,
1213
} from "src/store";
1314
import { get } from "svelte/store";
1415
import encodePodnotesURI from "src/utility/encodePodnotesURI";
1516
import { isLocalFile } from "src/utility/isLocalFile";
17+
import {
18+
formatPodcastSegment,
19+
normalizePodcastSegmentTimes,
20+
} from "src/utility/podcastSegment";
1621

1722
const clampVolume = (value: number): number =>
1823
Math.min(1, Math.max(0, value));
@@ -31,6 +36,7 @@ export class API implements IAPI {
3136
}
3237

3338
public set currentTime(value: number) {
39+
activePlaybackSegment.set(null);
3440
currentTime.update((_) => value);
3541
}
3642

@@ -67,10 +73,7 @@ export class API implements IAPI {
6773

6874
if (!linkify) return time;
6975

70-
const epIsLocal = isLocalFile(this.podcast);
71-
const feedUrl = !epIsLocal
72-
? this.podcast.feedUrl
73-
: downloadedEpisodes.getEpisode(this.podcast)?.filePath;
76+
const feedUrl = this.getEpisodeLinkTarget();
7477

7578
if (!feedUrl || feedUrl === "") {
7679
// Considered handling this as an error case, but I think
@@ -87,6 +90,50 @@ export class API implements IAPI {
8790
return `[${time}](${url.href})`;
8891
}
8992

93+
getPodcastSegmentFormatted(
94+
format: string,
95+
startTime: number,
96+
endTime: number,
97+
linkify = false,
98+
): string {
99+
if (!this.podcast) {
100+
throw new Error("No podcast loaded");
101+
}
102+
103+
const segmentTimes = normalizePodcastSegmentTimes(startTime, endTime);
104+
const segment = segmentTimes
105+
? formatPodcastSegment(
106+
segmentTimes.startTime,
107+
segmentTimes.endTime,
108+
format,
109+
)
110+
: formatPodcastSegment(startTime, endTime, format);
111+
112+
if (!linkify || !segmentTimes) return segment;
113+
114+
const feedUrl = this.getEpisodeLinkTarget();
115+
116+
if (!feedUrl || feedUrl === "") {
117+
return segment;
118+
}
119+
120+
const url = encodePodnotesURI(
121+
this.podcast.title,
122+
feedUrl,
123+
segmentTimes.startTime,
124+
segmentTimes.endTime,
125+
);
126+
127+
return `[${segment}](${url.href})`;
128+
}
129+
130+
private getEpisodeLinkTarget(): string | undefined {
131+
const epIsLocal = isLocalFile(this.podcast);
132+
return !epIsLocal
133+
? this.podcast.feedUrl
134+
: downloadedEpisodes.getEpisode(this.podcast)?.filePath;
135+
}
136+
90137
start(): void {
91138
isPaused.update((_) => false);
92139
}

src/API/IAPI.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ export interface IAPI {
1212
linkify?: boolean,
1313
offsetSeconds?: number,
1414
): string;
15+
16+
getPodcastSegmentFormatted(
17+
format: string,
18+
startTime: number,
19+
endTime: number,
20+
linkify?: boolean,
21+
): string;
1522

1623
start(): void;
1724
stop(): void;

src/TemplateEngine.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import {
55
FeedNoteTemplateEngine,
66
FilePathTemplateEngine,
77
NoteTemplateEngine,
8+
TimestampTemplateEngine,
89
TranscriptTemplateEngine,
910
getFeedNoteWikilink,
1011
} from "./TemplateEngine";
1112
import type { Episode } from "./types/Episode";
1213
import type { PodcastFeed } from "./types/PodcastFeed";
13-
import { downloadedEpisodes, plugin } from "./store";
14+
import { currentEpisode, currentTime, downloadedEpisodes, plugin } from "./store";
1415
import { DEFAULT_SETTINGS } from "./constants";
1516

1617
// The illegal-character sanitizer is private; exercise it through
@@ -73,6 +74,48 @@ const demoEpisode: Episode = {
7374
episodeDate: new Date("2024-01-01"),
7475
};
7576

77+
describe("TimestampTemplateEngine segment tags", () => {
78+
beforeEach(() => {
79+
currentEpisode.set(demoEpisode);
80+
currentTime.set(125);
81+
downloadedEpisodes.set({});
82+
plugin.set({
83+
settings: {
84+
timestamp: { offset: 0 },
85+
},
86+
api: {
87+
getPodcastTimeFormatted: (
88+
format: string,
89+
linkify: boolean,
90+
offsetSeconds: number,
91+
) =>
92+
`time:${format}:${linkify ? "link" : "plain"}:${offsetSeconds}`,
93+
getPodcastSegmentFormatted: (
94+
format: string,
95+
startTime: number,
96+
endTime: number,
97+
linkify: boolean,
98+
) =>
99+
`segment:${format}:${startTime}-${endTime}:${linkify ? "link" : "plain"}`,
100+
},
101+
} as never);
102+
});
103+
104+
it("renders plain and linked segment ranges when segment context is provided", () => {
105+
expect(
106+
TimestampTemplateEngine("{{segment}} {{linksegment:mm:ss}}", {
107+
segment: { startTime: 115, endTime: 125 },
108+
}),
109+
).toBe("segment:HH:mm:ss:115-125:plain segment:mm:ss:115-125:link");
110+
});
111+
112+
it("falls back to current time behavior when segment tags are used without segment context", () => {
113+
expect(TimestampTemplateEngine("{{segment}} {{linksegment}}")).toBe(
114+
"time:HH:mm:ss:plain:0 time:HH:mm:ss:link:0",
115+
);
116+
});
117+
});
118+
76119
describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
77120
it("keeps {{url}} and {{artwork}} pointing at the episode itself", () => {
78121
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);

0 commit comments

Comments
 (0)