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
20 changes: 19 additions & 1 deletion docs/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
```

Expand All @@ -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`.
8 changes: 7 additions & 1 deletion docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Note: This feature requires an OpenAI API key to be set in the settings.
14 changes: 12 additions & 2 deletions docs/docs/timestamps.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand All @@ -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.
116 changes: 116 additions & 0 deletions src/API/API.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
55 changes: 51 additions & 4 deletions src/API/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -31,6 +36,7 @@ export class API implements IAPI {
}

public set currentTime(value: number) {
activePlaybackSegment.set(null);
currentTime.update((_) => value);
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions src/API/IAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 44 additions & 1 deletion src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading