diff --git a/.claude/skills/verify/features/episode-notes.md b/.claude/skills/verify/features/episode-notes.md index e711eee9..72ea49cd 100644 --- a/.claude/skills/verify/features/episode-notes.md +++ b/.claude/skills/verify/features/episode-notes.md @@ -1,7 +1,7 @@ # Episode notes A user creates a note for the playing episode. PodNotes fills -`Note creation template` and writes it to `Note creation file path`, by default +`Episode note template` and writes it to `Episode note path`, by default `PodNotes/{{podcast}}/{{title}}.md`, then opens it. The note links back to the episode with a `Resume in PodNotes` URI. @@ -18,7 +18,7 @@ episode with a `Resume in PodNotes` URI. - With an episode loaded, run `PodNotes: Create episode note` from the palette. - In an episode list, click the episode's `More options` button, or right-click the episode, then `Create note`. - On a podcast's page, click `...` (`Podcast options`), or right-click its Library card, then `Create podcast note`. -- Edit the path and template in Settings > PodNotes under `Episode notes`. +- Edit the path and template in Settings > PodNotes under `Notes`. ## Driving it with ui.mjs diff --git a/.claude/skills/verify/features/player.md b/.claude/skills/verify/features/player.md index cdfcd1ab..b049758f 100644 --- a/.claude/skills/verify/features/player.md +++ b/.claude/skills/verify/features/player.md @@ -47,5 +47,5 @@ Preconditions: - Audio keeps playing on every page, with the full player collapsed, in settings, and with the view closed. The one media element is `audio.podnotes-media` (or `video.podnotes-media`) in the main window, not inside the view. - After a restart the view opens on its last page, and the mini player shows the last episode paused at its saved position. Nothing loads or plays until you press play. A sync that lands before then updates the shown episode and position, unless you moved the position. - A video plays in the main window only. With the view in a popout, the player shows the artwork and the video keeps playing hidden in the main window. -- With `Keep a queue of episodes you switch away from` on (the default), switching episodes queues the previous one, and it plays again when the current one ends. +- With `Queue episodes I switch away from` on (the default), switching episodes queues the previous one, and it plays again when the current one ends. - Headless Obsidian plays to a null audio sink. Prove playback through `api.currentTime` advancing, not by sound. diff --git a/.claude/skills/verify/features/timestamps-uris.md b/.claude/skills/verify/features/timestamps-uris.md index 616ad0ba..1cac1f5c 100644 --- a/.claude/skills/verify/features/timestamps-uris.md +++ b/.claude/skills/verify/features/timestamps-uris.md @@ -10,7 +10,7 @@ moment. - `ts-capture` inserts the formatted current time at the cursor ("Capture Timestamp"). - `ts-segment` inserts a linked range ending now ("Capture Last 10 Seconds", "Capture Last 20 Seconds"). - `ts-undo` confirms every capture, from the player, the mini player, a command or the headset, in a Notice: `Timestamp added to `, or `Created ` when it created the episode note. Its Undo puts the note back as the capture found it, only while the note is still exactly as the capture left it; any later change, also a pending save, gets the Notice `The note isn't as the capture left it, so the timestamp stays`. A created note is kept. -- `ts-format` applies the `Capture timestamp format` and `Timestamp offset (s)` settings. +- `ts-format` applies the `Timestamp template` and `Timestamp offset (s)` settings. - `uri-open` reopens the linked episode at `time`, also after another episode played. - `uri-segment` plays a linked range and stops at its end (`endTime`). - `uri-errors` shows a Notice for a missing `url` or `episodeName`, or for a bad `time`. @@ -18,7 +18,7 @@ moment. ## How to get to it (user POV) - With a note open and an episode playing, run `PodNotes: Capture Timestamp` from the palette or a hotkey. -- Set the format in Settings > PodNotes > `Capture timestamp format`, for example `- {{linktime}} `. +- Set the format in Settings > PodNotes > `Timestamp template`, for example `- {{linktime}} `. - Click a captured link in Reading view or Live Preview. - Open an `obsidian://podnotes?...` URI from outside Obsidian. diff --git a/.claude/skills/verify/features/transcripts.md b/.claude/skills/verify/features/transcripts.md index f60a6eac..8d66aed4 100644 --- a/.claude/skills/verify/features/transcripts.md +++ b/.claude/skills/verify/features/transcripts.md @@ -4,7 +4,7 @@ A user transcribes the current episode. When its feed publishes a transcript (``), PodNotes writes it as timestamped paragraphs with no API key and no call to OpenAI or Deepgram. Otherwise, and always through `Transcribe current episode from audio`, it sends the audio to the configured -provider with the user's key. The note goes to `Transcript file path`, filled +provider with the user's key. The note goes to `Transcript path`, filled from `Transcript template`, and opens. ## Sub-features @@ -19,7 +19,7 @@ from `Transcript template`, and opens. ## How to get to it (user POV) - With an audio episode loaded, run `PodNotes: Transcribe current episode` or `PodNotes: Transcribe current episode from audio` from the palette. -- Set the path, template and API keys in Settings > PodNotes under `Transcripts`. Timestamps follow `Capture timestamp format` under `Episode notes`: links for `{{linktime}}`, plain times for `{{time}}`. +- Set the path, template and API keys in Settings > PodNotes under `Transcripts`. Timestamps follow `Timestamp template` under `Notes`: links for `{{linktime}}`, plain times for `{{time}}`. ## Driving it with ui.mjs diff --git a/obsidian-e2e.config.mjs b/obsidian-e2e.config.mjs index abac0ef7..f40a6327 100644 --- a/obsidian-e2e.config.mjs +++ b/obsidian-e2e.config.mjs @@ -59,6 +59,8 @@ export default { playlists: {}, skipBackwardLength: 15, skipForwardLength: 15, + headsetPreviousTrack: "captureTimestamp", + headsetNextTrack: "skipForward", timestamp: { template: "- {{linktime}} ", offset: 0, @@ -121,10 +123,6 @@ export default { speakerTemplate: "**{{speaker}}:** ", }, }, - feedCache: { - enabled: true, - ttlHours: 6, - }, }, buildCommand: "npm run build", // Emit legacy PODNOTES_E2E_* env aliases alongside the canonical OBSIDIAN_E2E_* diff --git a/scripts/persistence-size.test.ts b/scripts/persistence-size.test.ts index 19c585f4..d718c928 100644 --- a/scripts/persistence-size.test.ts +++ b/scripts/persistence-size.test.ts @@ -49,7 +49,6 @@ describe("persistence byte fixture", () => { load: async () => parsed, resolveUrl: (feed) => feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); expect(await feeds.channelAt({ url: sample.source.url })).toEqual(sample.feed); diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index 421d9e37..11df8aa0 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -78,7 +78,6 @@ describe("podNotesURIHandler with saved feeds", () => { load, resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const subscriptions = { [patron.title]: patron, [testEpisode.podcastName]: publicFeed }; diff --git a/src/constants.ts b/src/constants.ts index 9ea421de..028ef384 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -76,6 +76,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { playlists: ownRecord(), skipBackwardLength: 15, skipForwardLength: 15, + headsetPreviousTrack: "captureTimestamp", + headsetNextTrack: "skipForward", currentEpisode: undefined, timestamp: { @@ -96,7 +98,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { // user to fill and give Bases columns to sort and filter on. The raw // {{title}} (which may contain quotes/colons) and {{url}} (a feed URL, or for // a local file a vault link whose name may contain a quote) live in the BODY, - // where YAML rules don't apply — keeping the verbatim, unescaped tag values + // where YAML rules don't apply, keeping the verbatim, unescaped tag values // out of quoted frontmatter scalars so the frontmatter never fails to parse // (issue #160 review). {{podcastlink}} ties each episode to its feed note // (#163). See issue #160. @@ -145,8 +147,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { download: { // Must include a per-episode token ({{title}}). An empty or token-less path - // resolves every episode to the same name — e.g. "" -> ".mp3", a hidden - // dotfile at the vault root that Obsidian never indexes — so the first + // resolves every episode to the same name (e.g. "" -> ".mp3", a hidden + // dotfile at the vault root that Obsidian never indexes), so the first // download writes junk and the second throws "File already exists" (#183). path: "PodNotes/{{podcast}}/{{title}}", }, @@ -168,8 +170,4 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { speakerTemplate: DEFAULT_SPEAKER_TEMPLATE, }, }, - feedCache: { - enabled: true, - ttlHours: 6, - }, }; diff --git a/src/createFeedNote.test.ts b/src/createFeedNote.test.ts index 1a5188bf..9cb90dbb 100644 --- a/src/createFeedNote.test.ts +++ b/src/createFeedNote.test.ts @@ -171,7 +171,6 @@ describe("createFeedNote", () => { load, resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); feeds.setSubscriptions({ [saved.title]: saved }); diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 007eceba..f9eb4a9d 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -66,6 +66,16 @@ function makePlugin( const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); +function savedByThisVersion(released: Record): Record { + const data: Record = { + ...structuredClone(released), + headsetPreviousTrack: "captureTimestamp", + headsetNextTrack: "skipForward", + }; + delete data.feedCache; + return data; +} + async function saveInBackground(plugin: PodNotes): Promise { void plugin.saveSettings(); await settled(); @@ -376,7 +386,7 @@ describe("PodNotes persistence integration", () => { expect(saveData).toHaveBeenCalledTimes(1); expect(saveData.mock.calls[0][0]).toEqual({ - ...released, + ...savedByThisVersion(released), defaultVolume: 0.4, libraryId: "", }); @@ -411,7 +421,7 @@ describe("PodNotes persistence integration", () => { const { urlSecretId } = written.savedFeeds["PRIV2 Both"]; expect(urlSecretId).toMatch(/^podnotes-feed-url-[0-9a-f-]{36}$/); expect(written).toEqual({ - ...released2211, + ...savedByThisVersion(released2211), libraryId: "", savedFeeds: { "PRIV2 Both": { ...released2211.savedFeeds["PRIV2 Both"], urlSecretId } }, }); @@ -784,6 +794,46 @@ describe("PodNotes persistence integration", () => { expect(saveData.mock.lastCall?.[0].queue.episodes).toHaveLength(2); }); + it("drops a saved feed cache preference at the next save, even one an older device syncs back", async () => { + const saveData = vi.fn().mockResolvedValue(undefined); + const plugin = makePlugin(saveData, { dataFileExists: true }); + const older = { + ...structuredClone(released2211), + feedCache: { enabled: false, ttlHours: 1 }, + }; + Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(structuredClone(older)) }); + + await plugin.loadSettings(); + expect(saveData).not.toHaveBeenCalled(); + attachDataFileWriter(plugin); + plugin.settings.defaultVolume = 0.4; + await plugin.saveSettingsStrict(); + expect(saveData.mock.lastCall?.[0]).toEqual({ + ...savedByThisVersion(released2211), + defaultVolume: 0.4, + libraryId: "", + }); + + Object.assign(plugin, { + loadData: vi.fn().mockResolvedValue({ + ...structuredClone(older), + defaultVolume: 0.4, + skipForwardLength: 45, + }), + }); + await plugin.onExternalSettingsChange(); + plugin.settings.skipBackwardLength = 20; + await plugin.saveSettingsStrict(); + + expect(saveData.mock.lastCall?.[0]).toEqual({ + ...savedByThisVersion(released2211), + defaultVolume: 0.4, + skipForwardLength: 45, + skipBackwardLength: 20, + libraryId: "", + }); + }); + it("takes progress another device made even when the synced file had to repair another record", async () => { const plugin = makePlugin(vi.fn().mockResolvedValue(undefined)); const progress = (title: string, time: number) => ({ diff --git a/src/main.ts b/src/main.ts index 25027d9b..abe17448 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,6 +52,7 @@ import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { privateFeedAddresses, protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds"; import { redactPrivateFeedAddresses } from "./utility/privateFeedUrl"; import { FeedRepository, loadFeed } from "./services/FeedRepository"; +import { fetchChapters } from "./utility/fetchChapters"; import { onlineStatus } from "./services/online"; type WriteRefusal = @@ -153,7 +154,6 @@ export default class PodNotes extends Plugin implements IPodNotes { load: loadFeed, resolveUrl: (feed) => resolveFeedUrl(feed, this.feedUrls), storage: this.app, - cache: () => this.settings.feedCache, now: Date.now, }); @@ -237,9 +237,11 @@ export default class PodNotes extends Plugin implements IPodNotes { bindMediaSession(this.player, { mediaSession: window.navigator.mediaSession, MediaMetadata: window.MediaMetadata, + settings: () => this.settings, captureTimestamp: () => void this.capture.timestamp(), skipBackward: () => this.api.skipBackward(), skipForward: () => this.api.skipForward(), + chapters: fetchChapters, }), ); this.isReady = true; diff --git a/src/opml.ts b/src/opml.ts index 9c38cc12..cdf4d24f 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -70,9 +70,10 @@ async function importOPML(opml: string): Promise { notice.update("No new podcasts to import."); return; } - const progress = ((completedImports / total) * 100).toFixed(1); notice.update( - `Importing... ${completedImports}/${total} podcasts completed (${progress}%)`, + total === 1 + ? "Importing 1 podcast..." + : `Importing ${total} podcasts: ${completedImports} done.`, ); }; diff --git a/src/persistence/codecUtils.ts b/src/persistence/codecUtils.ts index c67c1a19..92fee6c1 100644 --- a/src/persistence/codecUtils.ts +++ b/src/persistence/codecUtils.ts @@ -166,6 +166,27 @@ export function mapRecord( return result; } +export function isOption( + options: Readonly>, + value: unknown, +): value is T { + return typeof value === "string" && Object.prototype.hasOwnProperty.call(options, value); +} + +export function readOption( + record: UnknownRecord, + key: string, + options: Readonly>, + fallback: T, + warnings: Set, +): T { + const value = record[key]; + if (value === undefined) return fallback; + if (isOption(options, value)) return value; + warn(warnings, key, "unknown value"); + return fallback; +} + export function isPlainObject(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index 800eb2d4..e0ca79bd 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -64,6 +64,21 @@ function settingsWithEveryEpisodeContainer(): IPodNotesSettings { }; } +function decodeFeedCacheAs2211(value: unknown): { enabled: boolean; ttlHours: number } { + const record = + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + const { enabled, ttlHours } = record; + return { + enabled: typeof enabled === "boolean" ? enabled : true, + ttlHours: + typeof ttlHours === "number" && Number.isFinite(ttlHours) && ttlHours > 0 + ? ttlHours + : 6, + }; +} + describe("PodNotes data schema", () => { it.each([ ["query", "https://private.example/feed?auth=ENCODE_SENTINEL"], @@ -534,14 +549,66 @@ describe("PodNotes data schema", () => { expect(decoded.warnings).toEqual(["currentEpisode.guid: expected a string"]); }); - it("loads a 2.21.1 library unchanged", () => { + it("loads a 2.21.1 library unchanged, and saves it with the headset defaults and no feed cache preference", () => { const decoded = decodePodNotesData(structuredClone(released2211)); + const { feedCache, ...rest } = released2211; expect(decoded.warnings).toEqual([]); expect(decoded.changed).toBe(false); + expect(feedCache).toEqual({ enabled: true, ttlHours: 6 }); expect( JSON.parse(JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields))), - ).toEqual({ ...released2211, libraryId: "" }); + ).toEqual({ + ...rest, + headsetPreviousTrack: "captureTimestamp", + headsetNextTrack: "skipForward", + libraryId: "", + }); + }); + + it("reads the headset mapping, and an action the button does not have as its default", () => { + const decoded = decodePodNotesData({ + ...structuredClone(released2211), + headsetPreviousTrack: "previousChapter", + headsetNextTrack: "nextInQueue", + }); + expect(decoded.warnings).toEqual([]); + expect([decoded.settings.headsetPreviousTrack, decoded.settings.headsetNextTrack]).toEqual([ + "previousChapter", + "nextInQueue", + ]); + + for (const action of ["skipForward", "toString", "__proto__", 3]) { + const repaired = decodePodNotesData({ + ...structuredClone(released2211), + headsetPreviousTrack: action, + }); + expect(repaired.settings.headsetPreviousTrack).toBe("captureTimestamp"); + expect(repaired.warnings).toEqual(["headsetPreviousTrack: unknown value"]); + } + }); + + it("reads a feed cache turned off as no setting at all, keeping the fields it does not know", () => { + const decoded = decodePodNotesData({ + ...structuredClone(released2211), + feedCache: { enabled: false, ttlHours: 1 }, + futureField: { kept: true }, + }); + const encoded = JSON.parse( + JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields)), + ); + + expect(decoded.warnings).toEqual([]); + expect(decoded.changed).toBe(false); + expect(decoded.unknownFields).toEqual({ futureField: { kept: true } }); + expect(encoded).not.toHaveProperty("feedCache"); + expect(encoded.futureField).toEqual({ kept: true }); + expect(decodeFeedCacheAs2211(encoded.feedCache)).toEqual({ enabled: true, ttlHours: 6 }); + expect(decodeFeedCacheAs2211(released2211.feedCache)).toEqual(released2211.feedCache); + expect(decodeFeedCacheAs2211({ enabled: false, ttlHours: 1 })).toEqual({ + enabled: false, + ttlHours: 1, + }); }); it.each([ diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index 11c7aba8..a79e1f88 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -16,9 +16,12 @@ export const PODNOTES_DATA_SCHEMA_VERSION = 2; /** Plaintext fields accepted only as migration input and never persisted again. */ const RETIRED_PLAINTEXT_SECRET_KEYS = new Set(["openAIApiKey", "diarizationApiKey"]); +const RETIRED_SETTING_KEYS = ["feedCache"]; + const KNOWN_TOP_LEVEL_KEYS = new Set([ ...Object.keys(DEFAULT_SETTINGS), ...RETIRED_PLAINTEXT_SECRET_KEYS, + ...RETIRED_SETTING_KEYS, "schemaVersion", ]); diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 33a6a0a2..70d96e82 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -6,6 +6,7 @@ import { migrateSkipLength, migrateTranscriptSettings, } from "src/settingsMigrations"; +import { NEXT_TRACK_ACTIONS, PREVIOUS_TRACK_ACTIONS } from "src/types/Headset"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { isPodNotesSecretId, type CredentialKind } from "src/types/Credentials"; import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; @@ -24,7 +25,7 @@ import { readClampedNumber, readFiniteNumber, readNullableString, - readPositiveNumber, + readOption, readString, type UnknownRecord, warn, @@ -87,6 +88,20 @@ export function decodeSettings( "skipForwardLength", warnings, ), + headsetPreviousTrack: readOption( + root, + "headsetPreviousTrack", + PREVIOUS_TRACK_ACTIONS, + DEFAULT_SETTINGS.headsetPreviousTrack, + warnings, + ), + headsetNextTrack: readOption( + root, + "headsetNextTrack", + NEXT_TRACK_ACTIONS, + DEFAULT_SETTINGS.headsetNextTrack, + warnings, + ), playlists: decodePlaylists(root.playlists, warnings), queue: decodePlaylist(root.queue, DEFAULT_SETTINGS.queue, warnings, "queue"), autoQueue: readBoolean(root, "autoQueue", DEFAULT_SETTINGS.autoQueue, warnings), @@ -114,7 +129,6 @@ export function decodeSettings( openAISecretId: decodeSecretId(root, "openAISecretId", warnings), deepgramSecretId: decodeSecretId(root, "deepgramSecretId", warnings), transcript: decodeTranscript(root.transcript, warnings), - feedCache: decodeFeedCache(root.feedCache, warnings), }; } @@ -231,27 +245,6 @@ function decodeTranscript(value: unknown, warnings: Set): IPodNotesSetti }; } -function decodeFeedCache(value: unknown, warnings: Set): IPodNotesSettings["feedCache"] { - const record = optionalRecord(value, warnings, "feedCache"); - return { - ...copySafeObject(record), - enabled: readBoolean( - record, - "enabled", - DEFAULT_SETTINGS.feedCache.enabled, - warnings, - "feedCache", - ), - ttlHours: readPositiveNumber( - record, - "ttlHours", - DEFAULT_SETTINGS.feedCache.ttlHours, - warnings, - "feedCache", - ), - }; -} - function decodeSkipLength( value: unknown, fallback: number, diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index d0da3b88..94866121 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -1585,6 +1585,17 @@ describe("volume and rate", () => { expect(host.saveSettings).toHaveBeenCalledTimes(1); }); + test("a new player starts at the volume saved last, which is what a restart reads", () => { + const { player, media, state } = setup({ defaultVolume: 0.35 }); + expect(state().volume).toBe(0.35); + + player.play(A); + metadata(media()); + + expect(media().volume).toBe(0.35); + expect(state().volume).toBe(0.35); + }); + test("a new episode starts at the default speed as it is now, at the player's volume", () => { const { player, host, media, state } = setup(); host.settings.defaultVolume = 0.4; diff --git a/src/player/mediaSession.test.ts b/src/player/mediaSession.test.ts index 6231652a..987de643 100644 --- a/src/player/mediaSession.test.ts +++ b/src/player/mediaSession.test.ts @@ -5,10 +5,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { API } from "src/API/API"; import { DEFAULT_SETTINGS } from "src/constants"; import { downloadedEpisodes, playedEpisodes, plugin, queue } from "src/store"; +import type { Chapter } from "src/types/Chapter"; import type { Episode } from "src/types/Episode"; import { getEpisodeKey } from "src/utility/episodeKey"; import { FakeDocument, type FakeMedia } from "../../tests/mocks/fakeMedia"; -import { bindMediaSession } from "./mediaSession"; +import { bindMediaSession, chapterStart } from "./mediaSession"; import { PlayerController } from "./PlayerController"; const A: Episode = { @@ -84,6 +85,7 @@ class FakeMediaSession { function setup({ mediaSession = new FakeMediaSession() as FakeMediaSession | null, Metadata = FakeMetadata as typeof FakeMetadata | null, + chapters = (_url: string): Promise => Promise.resolve([]), } = {}) { const doc = new FakeDocument(); const host = { @@ -99,9 +101,11 @@ function setup({ const unbind = bindMediaSession(player, { mediaSession: (mediaSession ?? undefined) as unknown as MediaSession | undefined, MediaMetadata: (Metadata ?? undefined) as unknown as typeof MediaMetadata | undefined, + settings: () => host.settings, captureTimestamp: capture, skipBackward: () => api.skipBackward(), skipForward: () => api.skipForward(), + chapters, }); unbinds.push(unbind); return { @@ -262,8 +266,9 @@ describe("actions", () => { expect(context.media().currentTime).toBe(115); }); - test("nexttrack skips 30 seconds, stopping short of the end and never going back", () => { + test("nexttrack skips forward by the skip length, stopping short of the end and never going back", () => { const context = setup(); + context.host.settings.skipForwardLength = 30; playing(context, 100, 200); context.session.act("nexttrack"); @@ -279,8 +284,9 @@ describe("actions", () => { expect(context.media().currentTime).toBe(199.9); }); - test("nexttrack skips the whole 30 seconds while the length is unknown", () => { + test("nexttrack skips the whole skip length while the episode's length is unknown", () => { const context = setup(); + context.host.settings.skipForwardLength = 30; context.player.play(A, { time: 10 }); context.session.act("nexttrack"); @@ -298,6 +304,232 @@ describe("actions", () => { }); }); +const B: Episode = { ...A, title: "B", streamUrl: "https://cdn.example.com/B.mp3" }; +const C: Episode = { ...A, title: "C", streamUrl: "https://cdn.example.com/C.mp3" }; +const WITH_CHAPTERS: Episode = { ...A, chaptersUrl: "https://cdn.example.com/A.json" }; +const chapter = (startTime: number): Chapter => ({ startTime, title: `At ${startTime}` }); +const CHAPTERS = [chapter(0), chapter(60), chapter(120)]; +const titles = () => get(queue).episodes.map(({ title }) => title); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("headset mapping", () => { + test("the default next track is forward by the skip length, previous track a capture", () => { + const context = setup(); + playing(context, 100); + + context.session.act("nexttrack"); + context.session.act("previoustrack"); + + expect(context.media().currentTime).toBe(115); + expect(context.capture).toHaveBeenCalledTimes(1); + }); + + test("a mapping changed after binding applies at the next press", () => { + const context = setup(); + playing(context, 100); + + context.host.settings.headsetPreviousTrack = "skipBackward"; + context.host.settings.headsetNextTrack = "captureTimestamp"; + context.session.act("previoustrack"); + context.session.act("nexttrack"); + + expect(context.media().currentTime).toBe(85); + expect(context.capture).toHaveBeenCalledTimes(1); + }); + + describe("Next in queue", () => { + test.each([true, false])( + "skips a queued copy of the current episode with auto-queue %s", + (autoQueue) => { + const context = setup(); + context.host.settings.headsetNextTrack = "nextInQueue"; + context.host.settings.autoQueue = autoQueue; + playing(context, 40); + queue.add(A); + queue.add(B); + queue.add(C); + + context.session.act("nexttrack"); + + expect([context.state().episode?.title, titles()]).toEqual(["B", ["C", "A"]]); + }, + ); + + test("leaves a queue containing only the current episode alone", () => { + const context = setup(); + context.host.settings.headsetNextTrack = "nextInQueue"; + playing(context, 40); + queue.add(A); + + context.session.act("nexttrack"); + + expect([context.state().episode?.title, context.state().currentTime, titles()]).toEqual( + ["A", 40, ["A"]], + ); + }); + + test("goes through the queue, queueing each episode switched away from last", () => { + const context = setup(); + context.host.settings.headsetNextTrack = "nextInQueue"; + playing(context, 40); + queue.add(B); + queue.add(C); + + context.session.act("nexttrack"); + expect([context.state().episode?.title, titles()]).toEqual(["B", ["C", "A"]]); + + context.session.act("nexttrack"); + expect([context.state().episode?.title, titles()]).toEqual(["C", ["A", "B"]]); + }); + + test("with auto-queue off, plays the queue in order and queues nothing", () => { + const context = setup(); + context.host.settings.headsetNextTrack = "nextInQueue"; + context.host.settings.autoQueue = false; + playing(context, 40); + queue.add(B); + queue.add(C); + + context.session.act("nexttrack"); + context.session.act("nexttrack"); + + expect([context.state().episode?.title, titles()]).toEqual(["C", []]); + }); + + test("does nothing with an empty queue, or when the player cannot play", () => { + const context = setup(); + context.host.settings.headsetNextTrack = "nextInQueue"; + playing(context, 40); + + context.session.act("nexttrack"); + expect([context.state().episode?.title, context.state().currentTime]).toEqual([ + "A", + 40, + ]); + + queue.add(B); + vi.spyOn(context.player, "play").mockReturnValue(false); + context.session.act("nexttrack"); + expect([context.state().episode?.title, titles()]).toEqual(["A", ["B"]]); + }); + }); + + describe("chapters", () => { + test("ignores a chapter request after switching away and back", async () => { + let resolve!: (chapters: Chapter[]) => void; + const context = setup({ chapters: () => new Promise((done) => (resolve = done)) }); + context.host.settings.headsetNextTrack = "nextChapter"; + playing(context, 30, 3600, WITH_CHAPTERS); + context.session.act("nexttrack"); + playing(context, 10, 3600, B); + playing(context, 45, 3600, WITH_CHAPTERS); + + resolve(CHAPTERS); + await flush(); + + expect(context.media().currentTime).toBe(45); + }); + + test("next and previous chapter seek to the episode's chapter starts", async () => { + const chapters = vi.fn(() => Promise.resolve(CHAPTERS)); + const context = setup({ chapters }); + context.host.settings.headsetPreviousTrack = "previousChapter"; + context.host.settings.headsetNextTrack = "nextChapter"; + playing(context, 30, 3600, WITH_CHAPTERS); + + context.session.act("nexttrack"); + await flush(); + expect(context.media().currentTime).toBe(60); + + reach(context.media(), 61); + context.session.act("previoustrack"); + await flush(); + expect(context.media().currentTime).toBe(0); + + reach(context.media(), 63); + context.session.act("previoustrack"); + await flush(); + expect(context.media().currentTime).toBe(60); + + reach(context.media(), 130); + context.session.act("nexttrack"); + await flush(); + expect(context.media().currentTime).toBe(130); + expect(chapters.mock.calls).toEqual([[WITH_CHAPTERS.chaptersUrl]]); + }); + + test("chapters that arrive after the episode changed move nothing", async () => { + let resolve!: (chapters: Chapter[]) => void; + const context = setup({ chapters: () => new Promise((done) => (resolve = done)) }); + context.host.settings.headsetNextTrack = "nextChapter"; + playing(context, 30, 3600, WITH_CHAPTERS); + + context.session.act("nexttrack"); + playing(context, 10, 3600, B); + resolve(CHAPTERS); + await flush(); + + expect([context.state().episode?.title, context.media().currentTime]).toEqual([ + "B", + 10, + ]); + }); + + test.each(["nexttrack", "previoustrack"])( + "%s with no chapters is a no-op and retries", + async (action) => { + const chapters = vi + .fn<(url: string) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce([]) + .mockResolvedValue(CHAPTERS); + const context = setup({ chapters }); + context.host.settings.headsetNextTrack = "nextChapter"; + context.host.settings.headsetPreviousTrack = "previousChapter"; + playing(context, 30, 3600, WITH_CHAPTERS); + + for (const expected of [30, 30, action === "nexttrack" ? 60 : 0]) { + context.session.act(action); + await flush(); + expect(context.media().currentTime).toBe(expected); + } + + playing(context, 30, 3600, B); + context.session.act(action); + await flush(); + expect(chapters).toHaveBeenCalledTimes(3); + expect(context.media().currentTime).toBe(30); + }, + ); + + test("chapters that arrive after unbinding move nothing", async () => { + let resolve!: (chapters: Chapter[]) => void; + const context = setup({ chapters: () => new Promise((done) => (resolve = done)) }); + context.host.settings.headsetNextTrack = "nextChapter"; + playing(context, 30, 3600, WITH_CHAPTERS); + + context.session.act("nexttrack"); + context.unbind(); + resolve(CHAPTERS); + await flush(); + + expect(context.media().currentTime).toBe(30); + }); + }); + + test.each([ + [1, 0, 60], + [1, 59.8, 120], + [1, 59.7, 60], + [1, 120, undefined], + [-1, 61.9, 0], + [-1, 62, 60], + [-1, 1, 0], + ] as const)("chapterStart(%i) from %f s is %s", (direction, time, expected) => { + expect(chapterStart(CHAPTERS, time, direction)).toBe(expected); + }); +}); + describe("lifetime", () => { test("with nothing shown, the session is empty", () => { const context = setup(); diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts index fc41df10..e9035245 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -1,21 +1,24 @@ import { get, type Unsubscriber } from "svelte/store"; +import { queue } from "src/store"; +import type { Chapter } from "src/types/Chapter"; import type { Episode } from "src/types/Episode"; -import { - END_MARGIN_S, - type PlayerController, - type PlayerState, - type PlayerStatus, -} from "./PlayerController"; +import type { NextTrackAction, PreviousTrackAction } from "src/types/Headset"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import { getEpisodeKey } from "src/utility/episodeKey"; +import type { PlayerController, PlayerState, PlayerStatus } from "./PlayerController"; interface MediaSessionHost { mediaSession: MediaSession | undefined; MediaMetadata: typeof MediaMetadata | undefined; + settings(): Pick; captureTimestamp(): void; skipBackward(): void; skipForward(): void; + chapters(url: string): Promise; } -const NEXT_TRACK_SKIP_S = 30; +const CHAPTER_RESTART_S = 2; +const CHAPTER_TOLERANCE_S = 0.25; const PLAYBACK_STATES: Record = { empty: "none", @@ -42,6 +45,22 @@ function attempt(call: () => void): boolean { const isPositive = (value: number) => Number.isFinite(value) && value > 0; +export function chapterStart( + chapters: readonly Chapter[], + time: number, + direction: 1 | -1, +): number | undefined { + if (direction === 1) { + return chapters.find(({ startTime }) => startTime > time + CHAPTER_TOLERANCE_S)?.startTime; + } + let start = 0; + for (const { startTime } of chapters) { + if (startTime > time - CHAPTER_RESTART_S) break; + start = startTime; + } + return start; +} + function positionOf({ currentTime, duration, playbackRate }: PlayerState) { if (!isPositive(duration) || !isPositive(playbackRate) || !Number.isFinite(currentTime)) { return undefined; @@ -50,30 +69,64 @@ function positionOf({ currentTime, duration, playbackRate }: PlayerState) { } export function bindMediaSession( - player: Pick, + player: Pick, host: MediaSessionHost, ): Unsubscriber { if (!host.mediaSession) return () => {}; const { mediaSession, MediaMetadata } = host; + let episodeKey = ""; + let activation = 0; + let chapters: { url: string; loading: Promise } | undefined; + + async function seekChapter(direction: 1 | -1): Promise { + const { episode } = get(player.state); + const url = episode?.chaptersUrl; + if (!url) return; + const requestedActivation = activation; + if (chapters?.url !== url) { + chapters = { url, loading: host.chapters(url).catch(() => []) }; + } + const asked = chapters; + const loaded = await asked.loading; + if (loaded.length === 0) { + if (chapters === asked) chapters = undefined; + return; + } + const now = get(player.state); + if (unbound || activation !== requestedActivation) return; + const start = chapterStart(loaded, now.currentTime, direction); + if (start !== undefined) player.seek(start); + } + + function playNextInQueue(): void { + const outgoing = get(player.state).episode; + const next = get(queue).episodes.find( + (episode) => getEpisodeKey(episode) !== getEpisodeKey(outgoing), + ); + if (!next || !player.play(next) || !outgoing) return; + const [first] = get(queue).episodes; + if (first && getEpisodeKey(first) === getEpisodeKey(outgoing)) { + queue.remove(first); + queue.add(first); + } + } + + const run: Record void> = { + captureTimestamp: () => host.captureTimestamp(), + skipBackward: () => host.skipBackward(), + skipForward: () => host.skipForward(), + previousChapter: () => void seekChapter(-1), + nextChapter: () => void seekChapter(1), + nextInQueue: playNextInQueue, + }; const actions: Array<[MediaSessionAction, MediaSessionActionHandler]> = [ ["play", () => player.resume()], ["pause", () => player.pause()], ["seekbackward", () => host.skipBackward()], ["seekforward", () => host.skipForward()], - ["previoustrack", () => host.captureTimestamp()], - [ - "nexttrack", - () => { - const { currentTime, duration } = get(player.state); - const target = currentTime + NEXT_TRACK_SKIP_S; - player.seek( - duration > 0 - ? Math.max(currentTime, Math.min(target, duration - END_MARGIN_S)) - : target, - ); - }, - ], + ["previoustrack", () => run[host.settings().headsetPreviousTrack]()], + ["nexttrack", () => run[host.settings().headsetNextTrack]()], [ "seekto", ({ seekTime }) => { @@ -130,6 +183,11 @@ export function bindMediaSession( } function show(state: PlayerState): void { + const key = getEpisodeKey(state.episode); + if (key !== episodeKey) { + episodeKey = key; + activation++; + } setHandlers(!!state.episode); showMetadata(state.episode); const playbackState = PLAYBACK_STATES[state.status]; diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index fe9277e5..2e1e6a88 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -86,7 +86,7 @@ export class CaptureService { return null; } if (!settings.timestamp.template) { - new Notice("Set a Capture timestamp format in settings to capture a timestamp."); + new Notice("Set a Timestamp template in settings to capture a timestamp."); return null; } const text = render(settings.timestamp.template); diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 6cdd4a96..4d6c114d 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -37,7 +37,6 @@ function doc(title: string, items: ParsedEpisodeDocument[]): ParsedFeedEpisodes function setup( options: { storage?: MemoryStorage; - enabled?: boolean; resolveUrl?: (feed: PodcastFeed) => string | null; } = {}, ) { @@ -54,7 +53,6 @@ function setup( load, resolveUrl: options.resolveUrl ?? ((feed) => feed.url), storage, - cache: () => ({ enabled: options.enabled ?? true, ttlHours: 6 }), now: () => clock, }); return { @@ -211,22 +209,6 @@ describe("FeedRepository", () => { expect(Object.keys(t.cached())).toEqual([moved.url]); }); - test("Clear cache drops every document but still applies a refresh in flight", async () => { - const t = setup(); - await loaded(t, { Alpha: alpha }, doc("Alpha", [item(1)])); - const refresh = t.repo.load(["Alpha"], "network"); - - t.repo.clear(); - expect(t.storage.loadLocalStorage(CACHE_KEY)).toBeNull(); - expect(t.feed("Alpha")).toMatchObject({ status: "loading", episodes: [] }); - t.requests[1].respond.resolve(doc("Alpha", [item(2)])); - await refresh; - - expect(t.load).toHaveBeenCalledTimes(2); - expect(t.titles("Alpha")).toEqual(["Episode 2"]); - expect(Object.keys(t.cached())).toEqual([alpha.url]); - }); - test("a cached slice of a long feed loads in full when opened, and only once", async () => { const first = setup(); const items = Array.from({ length: 76 }, (_, index) => item(76 - index)); @@ -397,21 +379,21 @@ describe("FeedRepository", () => { expect(Object.keys(t.cached()).sort()).toEqual([feeds[1].url, feeds[2].url]); }); - test("with the cache off nothing is read or written, and documents still expire", async () => { + test("a document is fetched again once it is more than six hours old", async () => { const seeded = setup(); await loaded(seeded, { Alpha: alpha }, doc("Alpha", [item(1)])); - const stored = seeded.storage.loadLocalStorage(CACHE_KEY); - const t = setup({ storage: seeded.storage, enabled: false }); - await loaded(t, { Alpha: alpha }, doc("Alpha", [item(2)])); - expect(t.titles("Alpha")).toEqual(["Episode 2"]); - expect(t.storage.loadLocalStorage(CACHE_KEY)).toBe(stored); + const t = setup({ storage: seeded.storage }); + t.advance(6 * HOUR); + t.repo.setSubscriptions({ Alpha: alpha }); + void t.repo.ensureAll(); + expect(t.load).not.toHaveBeenCalled(); + expect(t.feed("Alpha")).toMatchObject({ status: "ready", episodes: [expect.anything()] }); - await t.repo.ensureAll(); - expect(t.load).toHaveBeenCalledTimes(1); - t.advance(7 * HOUR); + t.advance(1); void t.repo.ensureAll(); - expect(t.load).toHaveBeenCalledTimes(2); + expect(t.load).toHaveBeenCalledTimes(1); + expect(t.feed("Alpha")).toMatchObject({ status: "loading", episodes: [expect.anything()] }); }); test("a private feed whose URL is not on this device is never fetched", async () => { @@ -654,32 +636,23 @@ describe("FeedRepository", () => { expect(t.repo.episodeMetadata("Alpha", second)?.transcripts).toEqual([]); }); - test.each([true, false])( - "removes the caches of earlier versions (cache on: %s)", - async (enabled) => { - const storage = new MemoryStorage(); - storage.saveLocalStorage("podnotes:feed-cache:v5", "{}"); - for (const version of [1, 2, 3, 4]) - legacyStorage.set(`podnotes:feed-cache:v${version}`, "{}"); - const t = setup({ storage, enabled }); - t.repo.setSubscriptions({}); + test("removes the caches of earlier versions", async () => { + const storage = new MemoryStorage(); + storage.saveLocalStorage("podnotes:feed-cache:v5", "{}"); + for (const version of [1, 2, 3, 4]) + legacyStorage.set(`podnotes:feed-cache:v${version}`, "{}"); + const t = setup({ storage }); + t.repo.setSubscriptions({}); - await t.repo.ensureAll(); + await t.repo.ensureAll(); - expect(storage.values.has("podnotes:feed-cache:v5")).toBe(false); - expect([...legacyStorage.keys()]).toEqual([]); - }, - ); + expect(storage.values.has("podnotes:feed-cache:v5")).toBe(false); + expect([...legacyStorage.keys()]).toEqual([]); + }); - test.each([ - { enabled: true, failing: "saveLocalStorage" }, - { enabled: false, failing: "saveLocalStorage" }, - { enabled: true, failing: "removeItem" }, - { enabled: false, failing: "removeItem" }, - { enabled: true, failing: "loadLocalStorage" }, - ])( - "a feed loads when its storage throws (cache on: $enabled, $failing throws)", - async ({ enabled, failing }) => { + test.each(["saveLocalStorage", "removeItem", "loadLocalStorage"])( + "a feed loads when its storage throws (%s throws)", + async (failing) => { vi.spyOn(console, "warn").mockImplementation(() => {}); const fail = () => { throw new Error("storage unavailable"); @@ -688,7 +661,7 @@ describe("FeedRepository", () => { if (failing === "saveLocalStorage") storage.saveLocalStorage = fail; else if (failing === "loadLocalStorage") storage.loadLocalStorage = fail; else vi.stubGlobal("window", { localStorage: { removeItem: fail } }); - const t = setup({ storage, enabled }); + const t = setup({ storage }); await loaded(t, { Alpha: alpha }, doc("Alpha", [item(1)])); diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 80a5ac48..5ac8820c 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -48,7 +48,6 @@ export interface FeedRepositoryDeps { /** The URL to fetch a feed from, or null when its private URL is not on this device. */ resolveUrl(feed: FeedAddress): string | null; storage: FeedCacheStorage; - cache(): { enabled: boolean; ttlHours: number }; now(): number; } @@ -91,7 +90,12 @@ export class FeedRepository implements Readable> private readonly state = writable>(new Map()); private hydrated = false; - constructor(private readonly deps: FeedRepositoryDeps) { + constructor( + private readonly deps: FeedRepositoryDeps & { + /** Ignored: feeds are always cached. Tests of the view still pass it. */ + cache?: unknown; + }, + ) { // Earlier versions' caches can hold private feed URLs, so they go as soon as // PodNotes loads, not when a feed first loads (on mobile the view may stay closed). try { @@ -180,13 +184,6 @@ export class FeedRepository implements Readable> return channelFeed((await this.documentAt(feed, "cached")).channel, feed.url); } - clear(): void { - for (const source of this.sources.values()) source.doc = undefined; - this.deps.storage.saveLocalStorage(CACHE_KEY, null); - this.emit(); - void this.ensureAll(); - } - private async documentAt(feed: FeedAddress, mode: LoadMode): Promise { this.hydrate(); const source = this.source(feed.url); @@ -227,9 +224,7 @@ export class FeedRepository implements Readable> } private isExpired(doc: FeedDocument): boolean { - return ( - this.deps.now() - doc.updatedAt > Math.max(1, this.deps.cache().ttlHours) * 3_600_000 - ); + return this.deps.now() - doc.updatedAt > CACHE_TTL_MS; } /** `sourceUrl` keys the document; `url` is what to fetch, the secret one for a private feed. */ @@ -308,9 +303,7 @@ export class FeedRepository implements Readable> this.hydrated = true; let cached: [string, FeedDocument][] = []; try { - if (this.deps.cache().enabled) { - cached = decodeCache(this.deps.storage.loadLocalStorage(CACHE_KEY)); - } + cached = decodeCache(this.deps.storage.loadLocalStorage(CACHE_KEY)); } catch (error) { console.warn("PodNotes: could not read the feed cache", error); } @@ -322,7 +315,7 @@ export class FeedRepository implements Readable> /** Writes only subscribed feeds, so a removed one leaves the cache with the next write. */ private persist(): void { - if (!this.hydrated || !this.deps.cache().enabled) return; + if (!this.hydrated) return; const entries: Record = {}; for (const url of this.urls) { const doc = this.sources.get(url)?.doc; @@ -392,6 +385,7 @@ function channelFeed(channel: ParsedFeedDocument, url: string): PodcastFeed { // v6 stores parsed documents instead of episodes projected for one subscription. // v1-v4 lived in the global localStorage, v5 in the vault-scoped one. const CACHE_KEY = "podnotes:feed-cache:v6"; +const CACHE_TTL_MS = 6 * 3_600_000; // Keep this >= MAX_EPISODE_LIST_LIMIT (src/constants.ts): Latest Episodes is built // from cached documents on a warm start. const MAX_ITEMS_PER_FEED = 75; diff --git a/src/services/library.test.ts b/src/services/library.test.ts index 1d148b25..89020231 100644 --- a/src/services/library.test.ts +++ b/src/services/library.test.ts @@ -31,7 +31,6 @@ function actionsOn(secrets: obsidian.SecretStorage) { load: loadFeed, resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); return { diff --git a/src/services/publishedTranscript/index.test.ts b/src/services/publishedTranscript/index.test.ts index cf2ef66b..02b102b4 100644 --- a/src/services/publishedTranscript/index.test.ts +++ b/src/services/publishedTranscript/index.test.ts @@ -60,7 +60,6 @@ function repository(items: ParsedEpisodeDocument[], storage = new MemoryStorage( load, resolveUrl: (feed) => (feed.urlSecretId ? SECRET_URL : feed.url), storage, - cache: () => ({ enabled: true, ttlHours: 6 }), now: () => Date.UTC(2026, 9, 12), }); feeds.setSubscriptions({ Show: show, Hidden: hidden }); diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index 9fe9d370..7ecac037 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -16,7 +16,7 @@ import type { IPodNotesSettings } from "./types/IPodNotesSettings"; /** * The download path used to default to "" (empty). With an empty template the - * Download command resolved every episode to ".mp3" at the vault root — a dotfile + * Download command resolved every episode to ".mp3" at the vault root: a dotfile * Obsidian never indexes, so the first download wrote junk and the second threw * "File already exists" (issue #183). Users who never customized the path have * this exact value persisted, so simply changing the default does not help them. @@ -27,7 +27,7 @@ export const LEGACY_EMPTY_DOWNLOAD_PATH = ""; * Upgrades the legacy empty download path to the current per-episode default. * * ONLY the exact legacy empty value (or an absent value) is migrated, so any - * non-empty path the user deliberately configured is preserved untouched — even + * non-empty path the user deliberately configured is preserved untouched, even * an unusual one. An empty path is always broken (it can only ever write the * ".mp3" dotfile), so replacing it is strictly an improvement. * @@ -63,10 +63,10 @@ type StoredNote = { path?: string | null; template?: string | null }; * Upgrades the legacy empty episode-note settings to the current Bases-friendly * default, preserving any configuration the user made. * - * The migration fires ONLY when the WHOLE note is the legacy default — both path - * and template empty/absent — i.e. the exact value a never-configured install has + * The migration fires ONLY when the WHOLE note is the legacy default (both path + * and template empty/absent), i.e. the exact value a never-configured install has * persisted. The moment the user has set EITHER field, the note feature has been - * engaged, so both fields are preserved verbatim — including a deliberately empty + * engaged, so both fields are preserved verbatim, including a deliberately empty * field a user relies on to keep "Create episode note" disabled (the command * gates on emptiness). This is the conservative reading of "migrate only when the * stored value is still the old default": a partially-configured note is not the diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index c240b4c1..ea3ce558 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -394,6 +394,48 @@ describe("parseImport", () => { } }); + it("carries the headset mapping, and imports an action a button does not have as its default", () => { + const settings = makeSettings({ + headsetPreviousTrack: "previousChapter", + headsetNextTrack: "nextChapter", + }); + const exported = serializeSettings(settings, {}, "3.0.0", NOW, new Map()); + const result = parseImport( + JSON.stringify({ + ...exported, + settings: { ...exported.settings, headsetNextTrack: "previousChapter" }, + }), + ); + expect(exported.settings).toMatchObject({ + headsetPreviousTrack: "previousChapter", + headsetNextTrack: "nextChapter", + }); + if (!result.ok) throw new Error(result.error); + const merged = mergeImportedSettings(makeSettings(), result.settings); + expect([merged.headsetPreviousTrack, merged.headsetNextTrack]).toEqual([ + "previousChapter", + "skipForward", + ]); + }); + + it("drops an older export's feed cache preference, even caching off, and keeps the rest", () => { + const result = parseImport( + JSON.stringify({ + type: SETTINGS_EXPORT_TYPE, + version: 2, + settings: { defaultVolume: 0.5, feedCache: { enabled: false, ttlHours: 1 } }, + }), + ); + expect(result).toMatchObject({ ok: true, settings: { defaultVolume: 0.5 } }); + if (!result.ok) return; + expect(result.settings).not.toHaveProperty("feedCache"); + const merged = mergeImportedSettings(makeSettings(), result.settings); + expect(merged).not.toHaveProperty("feedCache"); + expect(serializeSettings(merged, {}, "3.0.0", NOW, new Map()).settings).not.toHaveProperty( + "feedCache", + ); + }); + it("drops unknown keys", () => { const result = parseImport(JSON.stringify({ defaultVolume: 0.5, somethingElse: true })); expect(result.ok).toBe(true); diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index a1bf3ab5..b437f5a1 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -55,7 +55,7 @@ const SECRET_KEY_LABELS: Record = { /** * The human-facing labels for the secrets actually present (non-empty) in a * settings object. Used to keep the export toggle, the export notice, and the - * import confirmation honest about which keys are involved — so a Deepgram-only + * import confirmation honest about which keys are involved, so a Deepgram-only * user is never told only "OpenAI API key" (and vice versa). See issue #168. */ export function describeSecrets(secrets: CredentialValues): string[] { @@ -80,7 +80,6 @@ const NESTED_KEYS: readonly (keyof IPodNotesSettings)[] = [ "feedNote", "download", "transcript", - "feedCache", ]; export interface SettingsEnvelope { diff --git a/src/types/Headset.ts b/src/types/Headset.ts new file mode 100644 index 00000000..82ddc77a --- /dev/null +++ b/src/types/Headset.ts @@ -0,0 +1,15 @@ +export const PREVIOUS_TRACK_ACTIONS = { + captureTimestamp: "Capture timestamp", + skipBackward: "Back by the skip length", + previousChapter: "Previous chapter", +} as const; + +export const NEXT_TRACK_ACTIONS = { + skipForward: "Forward by the skip length", + nextInQueue: "Next in queue", + nextChapter: "Next chapter", + captureTimestamp: "Capture timestamp", +} as const; + +export type PreviousTrackAction = keyof typeof PREVIOUS_TRACK_ACTIONS; +export type NextTrackAction = keyof typeof NEXT_TRACK_ACTIONS; diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 884d507d..9e46fe66 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -5,11 +5,13 @@ import type { Playlist } from "./Playlist"; import type { Episode } from "./Episode"; import type DownloadedEpisode from "./DownloadedEpisode"; import type { DiarizationProviderId } from "src/services/diarization/types"; +import type { NextTrackAction, PreviousTrackAction } from "./Headset"; export interface IPodNotesSettings { savedFeeds: { [podcastName: string]: PodcastFeed }; podNotes: { [episodeName: string]: PodNote }; defaultPlaybackRate: number; + /** The player's volume as it was last set, which a new player starts at. */ defaultVolume: number; hidePlayedEpisodes: boolean; /** @@ -21,6 +23,8 @@ export interface IPodNotesSettings { playedEpisodes: { [episodeName: string]: PlayedEpisode }; skipBackwardLength: number; skipForwardLength: number; + headsetPreviousTrack: PreviousTrackAction; + headsetNextTrack: NextTrackAction; playlists: { [playlistName: string]: Playlist }; queue: Playlist; /** @@ -72,8 +76,4 @@ export interface IPodNotesSettings { speakerTemplate: string; }; }; - feedCache: { - enabled: boolean; - ttlHours: number; - }; } diff --git a/src/ui/PodcastView/menus.ts b/src/ui/PodcastView/menus.ts index 7cabc862..b5395b75 100644 --- a/src/ui/PodcastView/menus.ts +++ b/src/ui/PodcastView/menus.ts @@ -13,6 +13,7 @@ import { isSameEpisode, isSameStoredEpisode } from "src/utility/episodeKey"; import { episodeLinkTarget } from "src/utility/episodeLinkTarget"; import { isEpisodeFinished } from "src/utility/episodeStatus"; import { isLocalFile } from "src/utility/isLocalFile"; +import { PLAYBACK_RATES } from "src/utility/playbackRate"; import { isCredentialBearingUrl } from "src/utility/privateFeedUrl"; import type { ShellServices } from "./context"; import { availabilityOf, isCurrentEpisode } from "./episodeRow"; @@ -92,7 +93,7 @@ export function showMenu(entries: readonly MenuEntry[], at: MenuAnchor): void { /** The rate pill's speeds. The live rate, which the commands step by 0.1, may be none of them. */ export function rateMenu({ player }: ShellServices): MenuEntry[] { const live = get(player.state).playbackRate; - return [0.75, 1, 1.25, 1.5, 1.75, 2].map((rate) => ({ + return PLAYBACK_RATES.map((rate) => ({ title: `${rate}×`, checked: rate === live, onClick: () => player.setPlaybackRate(rate), diff --git a/src/ui/settings/PodNotesSettingsTab.notes.test.ts b/src/ui/settings/PodNotesSettingsTab.notes.test.ts deleted file mode 100644 index 6a9aa45b..00000000 --- a/src/ui/settings/PodNotesSettingsTab.notes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// @vitest-environment jsdom -import { derived, readable } from "svelte/store"; -import { afterEach, expect, test } from "vitest"; -import { App } from "obsidian"; -import { DEFAULT_SETTINGS } from "src/constants"; -import type PodNotes from "src/main"; -import { plugin as pluginStore } from "src/store"; -import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; - -let stop = () => {}; -afterEach(() => { - stop(); - pluginStore.set(undefined as never); -}); - -test("the note path field tells an open view the new path", () => { - const plugin = { - settings: structuredClone(DEFAULT_SETTINGS), - api: {}, - feeds: readable(new Map()), - saveSettings: () => Promise.resolve(), - } as unknown as PodNotes; - pluginStore.set(plugin); - const paths: string[] = []; - stop = derived(pluginStore, ($plugin) => $plugin.settings.note.path).subscribe((path) => - paths.push(path), - ); - const container = document.createElement("div"); - ( - new PodNotesSettingsTab(new App(), plugin) as unknown as { - addNoteSettings(container: HTMLElement): void; - } - ).addNoteSettings(container); - const field = [...container.querySelectorAll("div")] - .find((setting) => setting.firstElementChild?.textContent === "Note creation file path") - ?.querySelector("input"); - - field!.value = "Elsewhere/{{title}}.md"; - field!.dispatchEvent(new Event("input")); - - expect(paths).toEqual([DEFAULT_SETTINGS.note.path, "Elsewhere/{{title}}.md"]); -}); diff --git a/src/ui/settings/PodNotesSettingsTab.playback.test.ts b/src/ui/settings/PodNotesSettingsTab.playback.test.ts deleted file mode 100644 index d4cc3025..00000000 --- a/src/ui/settings/PodNotesSettingsTab.playback.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @vitest-environment jsdom -import { get } from "svelte/store"; -import { expect, test, vi } from "vitest"; -import type { App } from "obsidian"; - -import { DEFAULT_SETTINGS } from "src/constants"; -import type PodNotes from "src/main"; -import { PlayerController } from "src/player/PlayerController"; -import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; -import { FakeDocument } from "../../../tests/mocks/fakeMedia"; - -test("the Default Volume slider sets the playing volume and saves it", () => { - const doc = new FakeDocument(); - const plugin = { - settings: structuredClone(DEFAULT_SETTINGS), - app: { vault: {} }, - saveSettings: vi.fn().mockResolvedValue(undefined), - } as unknown as PodNotes; - plugin.player = new PlayerController(doc.asPlayerDocument(), plugin); - plugin.player.play({ - title: "A", - streamUrl: "https://pod.example.com/A.mp3", - url: "", - description: "", - content: "", - podcastName: "Show", - }); - const container = document.createElement("div"); - ( - new PodNotesSettingsTab({} as App, plugin) as unknown as { - addDefaultVolumeSetting: (container: HTMLElement) => void; - } - ).addDefaultVolumeSetting(container); - const slider = container.querySelector("input") as HTMLInputElement; - - slider.value = "0.25"; - slider.dispatchEvent(new Event("input")); - - expect(doc.media.volume).toBe(0.25); - expect(get(plugin.player.state).volume).toBe(0.25); - expect(plugin.settings.defaultVolume).toBe(0.25); - expect(plugin.saveSettings).toHaveBeenCalled(); -}); diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts new file mode 100644 index 00000000..f86eaf01 --- /dev/null +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -0,0 +1,584 @@ +// @vitest-environment jsdom +import { derived, get, readable } from "svelte/store"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as obsidian from "obsidian"; + +import { DEFAULT_SETTINGS } from "src/constants"; +import { API } from "src/API/API"; +import PodNotes from "src/main"; +import { decodePodNotesData } from "src/persistence/podNotesData"; +import { PlayerController } from "src/player/PlayerController"; +import { FeedUrlRepository } from "src/services/FeedUrlRepository"; +import { episodeListLimit, plugin as pluginStore } from "src/store"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; +import { attachDataFileWriter, dataFileText } from "../../../tests/mocks/dataFileWriter"; +import { FakeDocument } from "../../../tests/mocks/fakeMedia"; + +const openTabs = new Set(); + +function close(tab: PodNotesSettingsTab): void { + (tab as unknown as { close(): void }).close(); +} + +afterEach(() => { + for (const tab of openTabs) { + close(tab); + tab.containerEl.remove(); + } + openTabs.clear(); + vi.useRealTimers(); + vi.restoreAllMocks(); + pluginStore.set(undefined as never); +}); + +/** The typing pause runs on fake time. Saves settle on real time. */ +const fakeTypingTime = () => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + +/** Lets the saves already asked for finish without asking for one. */ +const settled = () => new Promise((resolve) => setImmediate(resolve)); + +function open(saved: Partial = {}) { + const settings = { ...structuredClone(DEFAULT_SETTINGS), ...structuredClone(saved) }; + let disk: Record = { ...structuredClone(settings), schemaVersion: 2 }; + let writes = 0; + const plugin = Object.create(PodNotes.prototype) as PodNotes; + Object.assign(plugin, { + settings, + api: new API(), + feeds: readable(new Map()), + credentials: { + storeValues: vi.fn(() => ({})), + status: () => "unset", + exportValues: vi.fn(() => ({ openAI: "sk-device" })), + }, + feedUrls: new FeedUrlRepository(new obsidian.SecretStorage()), + isReady: true, + persistenceUnknownFields: {}, + manifest: { dir: ".obsidian/plugins/podnotes", version: "3.0.0" }, + app: { + vault: { + adapter: { + exists: vi.fn().mockResolvedValue(true), + read: vi.fn(async () => dataFileText(disk)), + }, + getAbstractFileByPath: () => null, + create: vi.fn().mockResolvedValue(undefined), + }, + }, + loadData: vi.fn(async () => structuredClone(disk)), + saveData: async (snapshot: Record) => { + writes++; + disk = structuredClone(snapshot); + }, + }); + attachDataFileWriter(plugin); + plugin.player = new PlayerController(new FakeDocument().asPlayerDocument(), plugin); + pluginStore.set(plugin); + const tab = new PodNotesSettingsTab(plugin.app as never, plugin); + tab.display(); + document.body.append(tab.containerEl); + openTabs.add(tab); + + const row = (name: string) => { + const found = [...tab.containerEl.querySelectorAll(".setting-item")].find( + (setting) => setting.querySelector(".setting-item-name")?.textContent === name, + ); + if (!(found instanceof HTMLElement)) throw new Error(`No row named ${name}`); + return found; + }; + const control = (name: string, selector: string) => + row(name).querySelector(".setting-item-control")!.querySelector(selector) as T; + return { + plugin, + tab, + row, + field: (name: string) => + control(name, "input, textarea"), + select: (name: string) => control(name, "select"), + toggle: (name: string) => control(name, "input[type=checkbox]"), + sync: async (change: Partial) => { + disk = { ...disk, ...structuredClone(change) }; + await plugin.onExternalSettingsChange(); + }, + writes: async () => { + await settled(); + return writes; + }, + disk: async () => { + await settled(); + return disk as unknown as IPodNotesSettings; + }, + }; +} + +function type(field: HTMLInputElement | HTMLTextAreaElement, text: string): void { + for (const character of text) { + field.value += character; + field.dispatchEvent(new Event("input")); + } +} + +function retype(field: HTMLInputElement | HTMLTextAreaElement, text: string): void { + enter(field, ""); + type(field, text); +} + +/** Pastes `text` over the field, as a number field needs: it drops a lone "-". */ +function enter(field: HTMLInputElement | HTMLTextAreaElement, text: string): void { + field.value = text; + field.dispatchEvent(new Event("input")); +} + +const complete = (field: Element) => field.dispatchEvent(new Event("change")); + +const pressEnter = (field: Element) => + field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + +function choose(select: HTMLSelectElement, value: string): void { + select.value = value; + select.dispatchEvent(new Event("change")); +} + +describe("sections", () => { + test("each SPEC section lists its own rows, in order, and nothing removed", () => { + const { tab } = open(); + + expect( + tab + .getSettingDefinitions() + .map(({ heading, items }) => [ + heading, + items?.map((item) => ("name" in item ? item.name : "")), + ]), + ).toEqual([ + [ + "Playback", + [ + "Skip back (s)", + "Skip forward (s)", + "Default speed", + "Queue episodes I switch away from", + ], + ], + ["Headset and lock screen", ["Previous track", "Next track"]], + [ + "Notes", + [ + "Timestamp template", + "Timestamp offset (s)", + "Episode note path", + "Episode note template", + "Podcast note path", + "Podcast note template", + ], + ], + [ + "Transcripts", + [ + "OpenAI API key", + "Transcript path", + "Transcript template", + "Speaker diarization", + "Diarization provider", + "Deepgram API key", + "Speaker label format", + ], + ], + ["Downloads", ["Download path"]], + ["Library", ["Import OPML", "Export OPML", "Latest episodes per podcast"]], + ["Import and export", ["Import settings", "Include API keys", "Export settings"]], + ]); + }); + + test("each row renders under its own name, and the speaker rows apply only with diarization", () => { + const { row, toggle, select } = open(); + const disabled = (name: string) => row(name).classList.contains("is-disabled"); + + expect( + ["Diarization provider", "Deepgram API key", "Speaker label format"].map(disabled), + ).toEqual([true, true, true]); + + toggle("Speaker diarization").click(); + expect( + ["Diarization provider", "Deepgram API key", "Speaker label format"].map(disabled), + ).toEqual([false, true, false]); + + choose(select("Diarization provider"), "deepgram"); + expect(disabled("Deepgram API key")).toBe(false); + }); +}); + +describe("text fields", () => { + test("the timestamp preview follows a completed offset change", () => { + const { plugin, tab, row, field } = open(); + plugin.player.restore({ + title: "Episode", + podcastName: "Show", + streamUrl: "https://example.com/audio.mp3", + url: "", + description: "", + content: "", + }); + plugin.player.seek(95); + tab.update(); + enter(field("Timestamp template"), "{{time}}"); + complete(field("Timestamp template")); + const preview = () => + row("Timestamp template").querySelector(".setting-item-description")?.textContent; + expect(preview()).toBe("00:01:35"); + + enter(field("Timestamp offset (s)"), "3"); + complete(field("Timestamp offset (s)")); + + expect(preview()).toBe("00:01:32"); + }); + + test("each keystroke applies at once; the save waits for 500 ms without one and tells an open view", async () => { + fakeTypingTime(); + const { plugin, field, writes, disk } = open(); + const paths: string[] = []; + const stop = derived(pluginStore, ($plugin) => $plugin.settings.note.path).subscribe( + (path) => paths.push(path), + ); + const path = field("Episode note path"); + + try { + enter(path, ""); + for (const character of "UI3/My podcast note.md") { + vi.advanceTimersByTime(499); + type(path, character); + expect(plugin.settings.note.path).toBe(path.value); + } + vi.advanceTimersByTime(499); + expect(await writes()).toBe(0); + + vi.advanceTimersByTime(1); + expect(await writes()).toBe(1); + expect((await disk()).note.path).toBe("UI3/My podcast note.md"); + + complete(path); + expect(await writes()).toBe(1); + expect(paths).toEqual([DEFAULT_SETTINGS.note.path, "UI3/My podcast note.md"]); + } finally { + stop(); + } + }); + + test("completing an edit saves it at once and drops its pending save", async () => { + fakeTypingTime(); + const { plugin, field, writes, disk } = open(); + const template = field("Episode note template"); + + type(template, "\nmore"); + expect(plugin.settings.note.template).toBe(`${DEFAULT_SETTINGS.note.template}\nmore`); + complete(template); + + expect(await writes()).toBe(1); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(1_000); + expect(await writes()).toBe(1); + expect((await disk()).note.template).toBe(`${DEFAULT_SETTINGS.note.template}\nmore`); + }); + + test("blur saves a return to the original text even without a change event", async () => { + fakeTypingTime(); + const { field, writes, disk } = open(); + const path = field("Episode note path"); + type(path, "x"); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(1); + + enter(path, DEFAULT_SETTINGS.note.path); + path.dispatchEvent(new Event("blur")); + + expect(await writes()).toBe(2); + expect((await disk()).note.path).toBe(DEFAULT_SETTINGS.note.path); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(2); + }); + + test("a sync after the pause's save wins over the field when the edit completes", async () => { + fakeTypingTime(); + const { plugin, field, sync, writes, disk } = open(); + const path = field("Episode note path"); + + type(path, "x"); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(1); + await sync({ note: { ...DEFAULT_SETTINGS.note, path: "Synced/{{title}}.md" } }); + complete(path); + + expect(await writes()).toBe(1); + expect([plugin.settings.note.path, path.value]).toEqual([ + "Synced/{{title}}.md", + "Synced/{{title}}.md", + ]); + expect((await disk()).note.path).toBe("Synced/{{title}}.md"); + }); + + test("Enter completes a one-line edit; in a template it is a new line", async () => { + fakeTypingTime(); + const { field, writes, disk } = open(); + const path = field("Episode note path"); + const template = field("Episode note template"); + + type(path, "x"); + pressEnter(path); + expect(await writes()).toBe(1); + expect((await disk()).note.path).toBe(`${DEFAULT_SETTINGS.note.path}x`); + + type(template, "\n"); + pressEnter(template); + expect(await writes()).toBe(1); + vi.advanceTimersByTime(500); + expect(await writes()).toBe(2); + }); + + test("an edit in progress saves once when Settings closes", async () => { + const { tab, field, writes, disk } = open(); + + type(field("Timestamp template"), "at "); + close(tab); + + expect(await writes()).toBe(1); + expect((await disk()).timestamp.template).toBe(`${DEFAULT_SETTINGS.timestamp.template}at `); + }); + + test("an edit in progress saves once when its window unloads without closing the rows", async () => { + const { tab, field, writes, disk } = open(); + + type(field("Transcript path"), "x"); + window.dispatchEvent(new Event("pagehide")); + + expect(await writes()).toBe(1); + expect((await disk()).transcript.path).toBe(`${DEFAULT_SETTINGS.transcript.path}x`); + tab.hide(); + expect(await writes()).toBe(1); + }); + + test("an edit in progress saves once when the app goes to the background", async () => { + const { field, writes, disk } = open(); + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + + type(field("Download path"), "/x"); + document.dispatchEvent(new Event("visibilitychange")); + + expect(await writes()).toBe(1); + expect((await disk()).download.path).toBe(`${DEFAULT_SETTINGS.download.path}/x`); + visibility.mockReturnValue("visible"); + complete(field("Download path")); + expect(await writes()).toBe(1); + }); + + test("a field nobody edited never puts back the value it showed", async () => { + const { plugin, tab, sync, writes, disk } = open(); + + await sync({ note: { ...DEFAULT_SETTINGS.note, path: "Synced/{{title}}.md" } }); + close(tab); + + expect(await writes()).toBe(0); + expect(plugin.settings.note.path).toBe("Synced/{{title}}.md"); + expect((await disk()).note.path).toBe("Synced/{{title}}.md"); + }); + + test("an import wins over an edit in progress to the same field", async () => { + const { tab, field, disk } = open(); + + type(field("Episode note path"), "mine"); + await ( + tab as unknown as { + applyImportedSettings(imported: Partial): Promise; + } + ).applyImportedSettings({ + note: { path: "Imported/{{title}}.md", template: "# {{title}}" }, + }); + + expect((await disk()).note).toEqual({ + path: "Imported/{{title}}.md", + template: "# {{title}}", + }); + expect(field("Episode note path").value).toBe("Imported/{{title}}.md"); + }); + + test("diarization controls stay locked during import and unlock with the imported applicability", async () => { + const { plugin, tab, select } = open({ + transcript: { + ...DEFAULT_SETTINGS.transcript, + diarization: { ...DEFAULT_SETTINGS.transcript.diarization, enabled: true }, + }, + }); + let release!: () => void; + vi.spyOn(plugin, "saveSettingsStrict") + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) + .mockResolvedValue(undefined); + const importing = ( + tab as unknown as { + applyImportedSettings(imported: Partial): Promise; + } + ).applyImportedSettings({ defaultPlaybackRate: 1.25 }); + await Promise.resolve(); + expect(select("Diarization provider").disabled).toBe(true); + + plugin.setAutoQueue(!plugin.settings.autoQueue); + const disabledDuringImport = select("Diarization provider").disabled; + release(); + await importing; + + expect(disabledDuringImport).toBe(true); + expect(select("Diarization provider").disabled).toBe(false); + }); + + test("a number field applies a usable entry as typed and shows the saved value for one it cannot use once the edit completes", async () => { + fakeTypingTime(); + const { plugin, field, writes } = open(); + const skip = field("Skip back (s)"); + + for (const entry of ["", "0", "-5"]) { + enter(skip, entry); + vi.advanceTimersByTime(500); + expect([skip.value, plugin.settings.skipBackwardLength]).toEqual([entry, 15]); + pressEnter(skip); + expect(skip.value).toBe("15"); + } + expect(await writes()).toBe(0); + + enter(skip, "4"); + expect(plugin.settings.skipBackwardLength).toBe(4); + enter(skip, "42"); + complete(skip); + expect(plugin.settings.skipBackwardLength).toBe(42); + expect(await writes()).toBe(1); + }); + + test("the episode limit reaches the lists as typed and saves after the pause", async () => { + fakeTypingTime(); + const { plugin, field, writes } = open(); + const limit = field("Latest episodes per podcast"); + + enter(limit, "1"); + enter(limit, "12"); + expect([get(episodeListLimit), plugin.settings.episodeListLimit]).toEqual([12, 12]); + expect(await writes()).toBe(0); + + vi.advanceTimersByTime(500); + expect(await writes()).toBe(1); + }); + + test("a download path without {{title}} warns until it has one", () => { + const { row, field } = open(); + const path = field("Download path"); + const warning = () => + row("Download path").querySelector(".setting-item-error")?.textContent; + + retype(path, "PodNotes/{{podcast}}"); + expect(warning()).toBe( + "Without {{title}}, episodes share one file and downloads overwrite each other. Add it, for example PodNotes/{{podcast}}/{{title}}.", + ); + type(path, "/{{title}}"); + expect(warning()).toBeUndefined(); + }); +}); + +describe("Default speed", () => { + const options = (select: HTMLSelectElement) => + [...select.options].map((option) => option.textContent); + + test("offers the player's speeds and saves the one chosen, quarter steps included", async () => { + const { select, writes, disk } = open(); + const speed = select("Default speed"); + + expect(options(speed)).toEqual(["0.75×", "1×", "1.25×", "1.5×", "1.75×", "2×"]); + expect(speed.value).toBe("1"); + + choose(speed, "1.25"); + + expect(await writes()).toBe(1); + const saved = await disk(); + expect(saved.defaultPlaybackRate).toBe(1.25); + expect(decodePodNotesData(saved).settings.defaultPlaybackRate).toBe(1.25); + }); + + test("shows a saved speed the player does not offer, without saving it", async () => { + const { select, writes } = open({ defaultPlaybackRate: 1.3 }); + const speed = select("Default speed"); + + expect(options(speed)).toEqual(["0.75×", "1×", "1.25×", "1.3×", "1.5×", "1.75×", "2×"]); + expect(speed.value).toBe("1.3"); + expect(await writes()).toBe(0); + }); +}); + +describe("Headset and lock screen", () => { + test("offers each button's actions and saves the one chosen", async () => { + const { plugin, select, writes } = open(); + const labels = (name: string) => + [...select(name).options].map((option) => [option.value, option.textContent]); + + expect(labels("Previous track")).toEqual([ + ["captureTimestamp", "Capture timestamp"], + ["skipBackward", "Back by the skip length"], + ["previousChapter", "Previous chapter"], + ]); + expect(labels("Next track")).toEqual([ + ["skipForward", "Forward by the skip length"], + ["nextInQueue", "Next in queue"], + ["nextChapter", "Next chapter"], + ["captureTimestamp", "Capture timestamp"], + ]); + expect([select("Previous track").value, select("Next track").value]).toEqual([ + "captureTimestamp", + "skipForward", + ]); + + choose(select("Next track"), "nextChapter"); + + expect(plugin.settings.headsetNextTrack).toBe("nextChapter"); + expect(await writes()).toBe(1); + }); +}); + +describe("Queue episodes I switch away from", () => { + test("follows a change made on the Queue page while Settings is open, until it closes", async () => { + const { plugin, tab, toggle, writes } = open(); + const queueToggle = toggle("Queue episodes I switch away from"); + expect(queueToggle.checked).toBe(true); + + plugin.setAutoQueue(false); + expect(queueToggle.checked).toBe(false); + expect(await writes()).toBe(1); + + queueToggle.click(); + expect(plugin.settings.autoQueue).toBe(true); + expect(await writes()).toBe(2); + + close(tab); + plugin.setAutoQueue(false); + expect(queueToggle.checked).toBe(true); + }); +}); + +describe("Import and export", () => { + test("Include API keys starts off each time Settings opens", async () => { + const { plugin, tab, toggle, field, row } = open(); + + toggle("Include API keys").click(); + close(tab); + tab.display(); + expect(toggle("Include API keys").checked).toBe(false); + + retype(field("Export settings"), "Backup"); + row("Export settings").querySelector("button")!.click(); + + await vi.waitFor(() => expect(plugin.app.vault.create).toHaveBeenCalled()); + expect(plugin.credentials.exportValues).not.toHaveBeenCalled(); + const [fileName, contents] = vi.mocked(plugin.app.vault.create).mock.calls[0]; + expect(fileName).toBe("Backup.json"); + expect(JSON.parse(contents as string)).not.toHaveProperty("secrets"); + }); +}); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index c7a204f1..78d81b35 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -7,7 +7,10 @@ import { PluginSettingTab, SecretComponent, Setting, - type SettingDefinitionItem, + type SettingDefinitionGroup, + type SettingDefinitionRender, + type TextAreaComponent, + type TextComponent, } from "obsidian"; import type PodNotes from "../../main"; import { pickFile } from "../pickFile"; @@ -28,12 +31,14 @@ import { sanitizeEpisodeListLimit, savedFeeds, } from "src/store/index"; -import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; +import { DEFAULT_SETTINGS, MAX_EPISODE_LIST_LIMIT } from "src/constants"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; +import { NEXT_TRACK_ACTIONS, PREVIOUS_TRACK_ACTIONS } from "src/types/Headset"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { get } from "svelte/store"; import { exportOPML, importOPML } from "src/opml"; +import { isOption } from "src/persistence/codecUtils"; import { decodePodNotesData, PODNOTES_DATA_SCHEMA_VERSION } from "src/persistence/podNotesData"; import { privateFeedAddresses, protectPrivateFeeds } from "src/services/privateFeeds"; import { @@ -42,9 +47,9 @@ import { parseImport, serializeSettings, } from "src/settingsTransfer"; -import { DEFAULT_SPEAKER_TEMPLATE, type DiarizationProviderId } from "src/services/diarization"; import type { CredentialKind, CredentialValues } from "src/types/Credentials"; import { observePersistedStoreChanges } from "src/store/persistence"; +import { PLAYBACK_RATES } from "src/utility/playbackRate"; type SecretReferenceKey = "openAISecretId" | "deepgramSecretId"; type SettingsControl = @@ -52,6 +57,7 @@ type SettingsControl = | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; +type Diarization = IPodNotesSettings["transcript"]["diarization"]; interface SecretReferenceSaveResult { persistedId: string; @@ -61,35 +67,31 @@ interface SecretReferenceSaveResult { type ImportMutationResult = "failed" | "applied" | "complete"; -const SETTINGS_SEARCH_ALIASES = [ - "Keep a queue", - "Latest episodes per podcast", - "Default playback rate", - "Default volume", - "Skip backward length", - "Skip forward length", - "Capture timestamp format", - "Timestamp offset", - "Note creation file path", - "Note creation template", - "Feed note file path", - "Feed note template", - "Episode download path", - "Cache podcast feeds", - "Cache duration", - "Clear cached feeds", - "Import OPML", - "Export OPML", - "Import preferences", - "Export preferences", - "OpenAI API key", - "Transcript file path", - "Transcript template", - "Speaker diarization", - "Diarization provider", - "Deepgram API key", - "Speaker label format", -]; +interface TextField { + read(): string; + write(value: string): void; + placeholder?: string; + kind?: "number" | "area" | "template"; + min?: number; + max?: number; + preview?(value: string, setting: Setting): void; +} + +const DIARIZATION_PROVIDERS = { + openai: "OpenAI (gpt-4o-transcribe-diarize)", + deepgram: "Deepgram", +}; + +const TYPING_SAVE_DELAY_MS = 500; + +/** Search finds rows, not section headings. */ +const HEADSET_ALIASES = ["headset", "lock screen", "media keys"]; + +const group = (heading: string, items: SettingDefinitionRender[]): SettingDefinitionGroup => ({ + type: "group", + heading, + items, +}); function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; @@ -196,13 +198,6 @@ const CREDENTIAL_KIND_BY_REFERENCE: Record = deepgramSecretId: "deepgram", }; -/** - * Stack a Setting's control beneath its name, full width — the layout the - * template/path text areas need. Obsidian's Setting has no built-in vertical - * variant and the plugin ships no stylesheet (styles are injected per Svelte - * component), so this sets the few inline styles in one place instead of the - * seven hand-rolled copies this replaced. - */ function stackSettingVertically(setting: Setting): void { setting.settingEl.setCssStyles({ flexDirection: "column", @@ -211,16 +206,6 @@ function stackSettingVertically(setting: Setting): void { }); } -/** - * Render `markdown` into `el` as a small live preview, replacing any prior - * content. Shared by the path/template demo fields that echo what the configured - * template resolves to. - */ -function renderMarkdownPreview(app: App, markdown: string, el: HTMLElement): void { - el.empty(); - void MarkdownRenderer.render(app, markdown, el, "", new Component()); -} - export class PodNotesSettingsTab extends PluginSettingTab { plugin: PodNotes; @@ -231,505 +216,540 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; private settingsInteractionLockCount = 0; private settingsControlDisabledStates = new Map(); + private readonly drafts = new Set<() => void>(); + private readonly follows = new Set<() => void>(); + private includeApiKeys = false; constructor(app: App, plugin: PodNotes) { super(app, plugin); this.plugin = plugin; } - override getSettingDefinitions(): SettingDefinitionItem[] { + override getSettingDefinitions(): SettingDefinitionGroup[] { + const diarization = () => this.plugin.settings.transcript.diarization; + const pathPreview = + (engine: (template: string, episode: Episode) => string, suffix = "") => + (value: string, setting: Setting) => + setting.setDesc(engine(value, this.demoEpisode()) + suffix); + return [ - { - name: "Podcast preferences", - aliases: SETTINGS_SEARCH_ALIASES, - render: (setting) => { - const { settingEl } = setting; - settingEl.empty(); - settingEl.classList.remove("setting-item"); - this.renderSettings(settingEl); + group("Playback", [ + this.textRow("Skip back (s)", this.seconds("skipBackwardLength")), + this.textRow("Skip forward (s)", this.seconds("skipForwardLength")), + this.speedRow(), + { + name: "Queue episodes I switch away from", + desc: "When an episode ends, the next queued one plays.", + render: (setting) => { + let stop = () => {}; + setting.addToggle((toggle) => { + stop = this.follow(setting, ({ autoQueue }) => + toggle.setValue(autoQueue), + ); + toggle.onChange((on) => this.plugin.setAutoQueue(on)); + }); + return stop; + }, }, - }, + ]), + group("Headset and lock screen", [ + { + ...this.dropdownRow( + "Previous track", + PREVIOUS_TRACK_ACTIONS, + () => this.plugin.settings.headsetPreviousTrack, + (action) => { + this.plugin.settings.headsetPreviousTrack = action; + }, + ), + aliases: HEADSET_ALIASES, + }, + { + ...this.dropdownRow( + "Next track", + NEXT_TRACK_ACTIONS, + () => this.plugin.settings.headsetNextTrack, + (action) => { + this.plugin.settings.headsetNextTrack = action; + }, + ), + aliases: HEADSET_ALIASES, + }, + ]), + group("Notes", [ + this.textRow("Timestamp template", { + ...this.text((settings) => settings.timestamp, "template"), + kind: "area", + preview: (value, setting) => this.previewTimestamp(value, setting), + }), + this.textRow( + "Timestamp offset (s)", + { + kind: "number", + read: () => `${this.plugin.settings.timestamp.offset}`, + write: (value) => { + this.plugin.settings.timestamp.offset = Math.max( + 0, + Number.parseInt(value, 10) || 0, + ); + }, + }, + "Captures this many seconds earlier.", + ), + this.textRow("Episode note path", { + ...this.text((settings) => settings.note, "path"), + preview: pathPreview(FilePathTemplateEngine), + }), + this.textRow("Episode note template", { + ...this.text((settings) => settings.note, "template"), + kind: "template", + }), + this.textRow("Podcast note path", { + ...this.text((settings) => settings.feedNote, "path"), + preview: (value, setting) => + setting.setDesc(FeedFilePathTemplateEngine(value, this.demoFeed())), + }), + this.textRow("Podcast note template", { + ...this.text((settings) => settings.feedNote, "template"), + kind: "template", + }), + ]), + group("Transcripts", [ + this.secretRow("OpenAI API key", "openAISecretId"), + this.textRow("Transcript path", { + ...this.text((settings) => settings.transcript, "path"), + preview: pathPreview(FilePathTemplateEngine), + }), + this.textRow("Transcript template", { + ...this.text((settings) => settings.transcript, "template"), + kind: "template", + }), + { + name: "Speaker diarization", + desc: "Label transcript turns by speaker.", + render: (setting) => + void setting.addToggle((toggle) => + toggle.setValue(diarization().enabled).onChange((on) => { + diarization().enabled = on; + this.changed(); + }), + ), + }, + this.whenDiarizing( + this.dropdownRow( + "Diarization provider", + DIARIZATION_PROVIDERS, + () => diarization().provider, + (provider) => { + diarization().provider = provider; + }, + "Deepgram needs its own API key and keeps speaker labels consistent across long episodes.", + ), + ({ enabled }) => enabled, + ), + this.whenDiarizing( + this.secretRow("Deepgram API key", "deepgramSecretId"), + ({ enabled, provider }) => enabled && provider === "deepgram", + ), + this.whenDiarizing( + this.textRow( + "Speaker label format", + this.text((settings) => settings.transcript.diarization, "speakerTemplate"), + "{{speaker}} is the speaker's label.", + ), + ({ enabled }) => enabled, + ), + ]), + group("Downloads", [ + this.textRow("Download path", { + ...this.text((settings) => settings.download, "path"), + preview: (value, setting) => { + pathPreview(DownloadPathTemplateEngine, ".mp3")(value, setting); + setting.setErrorMessage( + /\{\{title(:[^}]*)?\}\}/i.test(value) + ? null + : "Without {{title}}, episodes share one file and downloads overwrite each other. Add it, for example PodNotes/{{podcast}}/{{title}}.", + ); + }, + }), + ]), + group("Library", [ + this.importRow("Import OPML", () => + pickFile(".opml", importOPML, { + tooLargeMessage: "That file is too large to be an OPML file.", + }), + ), + this.exportRow("Export OPML", "PodNotes_Export.opml", (fileName) => { + const feeds = Object.values(get(savedFeeds)); + if (feeds.length === 0) new Notice("No podcasts to export."); + else void exportOPML(this.app, feeds, fileName); + }), + this.textRow("Latest episodes per podcast", { + kind: "number", + min: 1, + max: MAX_EPISODE_LIST_LIMIT, + read: () => + `${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`, + write: (value) => { + if (value.trim() === "" || !Number.isFinite(Number(value))) return; + const limit = sanitizeEpisodeListLimit(value); + this.plugin.settings.episodeListLimit = limit; + episodeListLimit.set(limit); + }, + }), + ]), + group("Import and export", [ + this.importRow("Import settings", () => + pickFile(".json", (contents) => this.handleSettingsImport(contents)), + ), + { + name: "Include API keys", + desc: "Adds this device's OpenAI and Deepgram keys to the file as plain text. The file is in your vault, so it may sync to other devices and other plugins can read it.", + render: (setting) => { + this.includeApiKeys = false; + setting.addToggle((toggle) => + toggle.setValue(false).onChange((on) => { + this.includeApiKeys = on; + }), + ); + }, + }, + this.exportRow( + "Export settings", + "PodNotes_Settings.json", + (fileName) => void this.handleSettingsExport(fileName, this.includeApiKeys), + ), + ]), ]; } - private renderSettings(containerEl: HTMLElement): void { - containerEl.empty(); - - const settingsContainer = containerEl.createDiv(); - settingsContainer.classList.add("settings-container"); - - this.addQueueSettings(settingsContainer); - this.addEpisodeListSettings(settingsContainer); - this.addDefaultPlaybackRateSetting(settingsContainer); - this.addDefaultVolumeSetting(settingsContainer); - this.addSkipLengthSettings(settingsContainer); - this.addNoteSettings(settingsContainer); - this.addFeedNoteSettings(settingsContainer); - this.addDownloadSettings(settingsContainer); - this.addPerformanceSettings(settingsContainer); - this.addImportExportSettings(settingsContainer); - this.addTranscriptSettings(settingsContainer); + override hide(): void { + this.completeDrafts(); + for (const stop of this.follows) stop(); + super.hide(); } - private addQueueSettings(container: HTMLElement): void { - new Setting(container) - .setName("Keep a queue of episodes you switch away from") - .setDesc( - "When on, the episode you switch away from is kept at the top of the queue and playback automatically continues with the next queued episode when one ends. Turn this off to stop the queue from filling and advancing on its own — you can still add episodes to the queue manually.", - ) - .addToggle((toggle) => - toggle - .setValue(this.plugin.settings.autoQueue) - .onChange((value) => this.plugin.setAutoQueue(value)), - ); + private changed(): void { + plugin.set(this.plugin); + void this.plugin.saveSettings(); } - private addEpisodeListSettings(container: HTMLElement): void { - new Setting(container) - .setName("Latest episodes per podcast") - .setDesc( - `How many of each podcast's most recent episodes appear in the Latest Episodes list, and how far back its search reaches. Raise this to find older episodes (1-${MAX_EPISODE_LIST_LIMIT}; default ${DEFAULT_EPISODE_LIST_LIMIT}).`, - ) - .addText((textComponent) => { - textComponent.inputEl.type = "number"; - textComponent.inputEl.min = "1"; - textComponent.inputEl.max = `${MAX_EPISODE_LIST_LIMIT}`; - textComponent - .setValue(`${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`) - .setPlaceholder(`${DEFAULT_EPISODE_LIST_LIMIT}`) - .onChange(async (value) => { - // Don't commit while the field is empty or mid-edit (e.g. cleared, - // or a lone "-"): sanitizing "" would silently overwrite the saved - // limit with the default. Wait for a parseable number, and skip - // redundant saves so typing doesn't churn data.json each keystroke. - const trimmed = value.trim(); - if (trimmed === "" || !Number.isFinite(Number(trimmed))) return; - const sanitized = sanitizeEpisodeListLimit(trimmed); - if (sanitized === this.plugin.settings.episodeListLimit) return; - this.plugin.settings.episodeListLimit = sanitized; - episodeListLimit.set(sanitized); - await this.plugin.saveSettings(); - }); - // Reflect the clamped/sanitized value back once the user finishes - // editing, so an out-of-range or empty entry doesn't linger in the box. - textComponent.inputEl.addEventListener("blur", () => { - textComponent.setValue( - `${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`, - ); - }); - }); + private completeDrafts(): void { + for (const complete of this.drafts) complete(); } - private addDefaultPlaybackRateSetting(container: HTMLElement): void { - new Setting(container).setName("Default Playback Rate").addSlider((slider) => - slider - .setLimits(0.5, 4, 0.1) - .setValue(this.plugin.settings.defaultPlaybackRate) - .onChange((value) => { - this.plugin.settings.defaultPlaybackRate = value; - void this.plugin.saveSettings(); - }), - ); + private bindDraft( + input: HTMLInputElement | HTMLTextAreaElement, + apply: (value: string) => boolean, + show: () => void, + ): () => void { + let { win, doc } = input; + // The main window's timer: a popout's stops when it closes. + let pendingSave: number | undefined; + const save = () => { + window.clearTimeout(pendingSave); + pendingSave = undefined; + this.changed(); + }; + const hidden = () => doc.visibilityState === "hidden" && complete(); + const complete = () => { + if (!this.drafts.delete(complete)) return; + win.removeEventListener("pagehide", complete); + doc.removeEventListener("visibilitychange", hidden); + if (pendingSave !== undefined) save(); + show(); + }; + input.addEventListener("input", () => { + if (apply(input.value) || pendingSave !== undefined) { + window.clearTimeout(pendingSave); + pendingSave = window.setTimeout(save, TYPING_SAVE_DELAY_MS); + } + if (this.drafts.has(complete)) return; + ({ win, doc } = input); + this.drafts.add(complete); + win.addEventListener("pagehide", complete); + doc.addEventListener("visibilitychange", hidden); + }); + input.addEventListener("change", complete); + input.addEventListener("blur", complete); + return complete; } - private addDefaultVolumeSetting(container: HTMLElement): void { - new Setting(container) - .setName("Default Volume") - .setDesc("Set the default playback volume.") - .addSlider((slider) => - slider - .setLimits(0, 1, 0.05) - .setValue(this.plugin.settings.defaultVolume) - .onChange((value) => this.plugin.player.setVolume(value)), - ); + private follow(setting: Setting, show: (settings: IPodNotesSettings) => void): () => void { + const { win } = setting.settingEl; + const unsubscribe = plugin.subscribe(() => show(this.plugin.settings)); + const stop = () => { + if (!this.follows.delete(stop)) return; + win.removeEventListener("pagehide", stop); + unsubscribe(); + }; + this.follows.add(stop); + win.addEventListener("pagehide", stop); + return stop; } - private addSkipLengthSettings(container: HTMLElement): void { - new Setting(container).setName("Skip backward length (s)").addText((textComponent) => { - textComponent.inputEl.type = "number"; - textComponent - .setValue(`${this.plugin.settings.skipBackwardLength}`) - .onChange((value) => { - // Ignore empty/invalid input instead of persisting NaN, which - // would corrupt the playback position when skipping (PB-02/ST-01). - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) return; - this.plugin.settings.skipBackwardLength = parsed; - void this.plugin.saveSettings(); - }) - .setPlaceholder("seconds"); - }); - - new Setting(container).setName("Skip forward length (s)").addText((textComponent) => { - textComponent.inputEl.type = "number"; - textComponent - .setValue(`${this.plugin.settings.skipForwardLength}`) - .onChange((value) => { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) return; - this.plugin.settings.skipForwardLength = parsed; - void this.plugin.saveSettings(); - }) - .setPlaceholder("seconds"); - }); + private text( + section: (settings: IPodNotesSettings) => Record, + key: K, + ): TextField { + return { + read: () => section(this.plugin.settings)[key], + write: (value) => { + section(this.plugin.settings)[key] = value; + }, + placeholder: section(DEFAULT_SETTINGS)[key], + }; } - private addNoteSettings(settingsContainer: HTMLDivElement) { - const container = settingsContainer.createDiv(); - - new Setting(container).setName("Episode notes").setHeading(); - - const timestampSetting = new Setting(container) - .setName("Capture timestamp format") - .setHeading() - .addTextArea((textArea) => { - textArea.setValue(this.plugin.settings.timestamp.template); - textArea.setPlaceholder("- {{linktime}} "); - textArea.onChange((value) => { - this.plugin.settings.timestamp.template = value; - void this.plugin.saveSettings(); - updateTimestampDemo(value); - }); - textArea.inputEl.setCssStyles({ width: "100%" }); - }); - - stackSettingVertically(timestampSetting); - - const timestampFormatDemoEl = container.createDiv(); - - const updateTimestampDemo = (value: string) => { - // Without a loaded episode there is no time to render; show a hint - // instead of leaving the preview blank so the control isn't mistaken - // for broken (TS-04). - if (!this.plugin.api.podcast) { - timestampFormatDemoEl.setText("Play an episode to preview the timestamp format."); - return; - } - - const demoVal = TimestampTemplateEngine(value); - renderMarkdownPreview(this.app, demoVal, timestampFormatDemoEl); + private seconds(key: "skipBackwardLength" | "skipForwardLength"): TextField { + return { + kind: "number", + read: () => `${this.plugin.settings[key]}`, + write: (value) => { + const seconds = Number.parseInt(value, 10); + if (seconds > 0) this.plugin.settings[key] = seconds; + }, }; - - new Setting(container) - .setName("Timestamp offset (s)") - .setDesc( - "Subtract this many seconds when capturing a timestamp to compensate for reaction time.", - ) - .addText((textComponent) => { - textComponent.inputEl.type = "number"; - textComponent - .setValue(`${this.plugin.settings.timestamp.offset}`) - .onChange((value) => { - const parsedValue = Number.parseInt(value, 10); - this.plugin.settings.timestamp.offset = Number.isNaN(parsedValue) - ? 0 - : Math.max(0, parsedValue); - void this.plugin.saveSettings(); - updateTimestampDemo(this.plugin.settings.timestamp.template); - }) - .setPlaceholder("e.g. 5"); - }); - - updateTimestampDemo(this.plugin.settings.timestamp.template); - - const randomEpisode = getRandomEpisode(); - - const noteCreationFilePathSetting = new Setting(container) - .setName("Note creation file path") - .setHeading() - .addText((textComponent) => { - textComponent.setValue(this.plugin.settings.note.path); - textComponent.setPlaceholder("inputs/podcasts/{{podcast}} - {{title}}.md"); - textComponent.onChange((value) => { - this.plugin.settings.note.path = value; - void this.plugin.saveSettings(); - // An open episode list looks its notes up at the new path. - plugin.set(this.plugin); - - const demoVal = FilePathTemplateEngine(value, randomEpisode); - renderMarkdownPreview(this.app, demoVal, noteCreationFilePathDemoEl); - }); - textComponent.inputEl.setCssStyles({ width: "100%" }); - }); - - stackSettingVertically(noteCreationFilePathSetting); - - const noteCreationFilePathDemoEl = container.createDiv(); - - const noteCreationSetting = new Setting(container) - .setName("Note creation template") - .setHeading() - .addTextArea((textArea) => { - textArea.setValue(this.plugin.settings.note.template); - textArea.onChange((value) => { - this.plugin.settings.note.template = value; - void this.plugin.saveSettings(); - }); - - textArea.inputEl.setCssStyles({ width: "100%", height: "25vh" }); - // A Bases-friendly hint mirroring the shipped default: structured - // frontmatter properties Bases can query, with the raw title in the - // body where YAML rules don't apply. - textArea.setPlaceholder( - "---" + - "\ntype: podcastEpisode" + - '\npodcast: "{{podcastlink}}"' + - '\nurl: "{{url}}"' + - "\ndate: {{date:YYYY-MM-DD}}" + - "\ntags:" + - "\n - podcastEpisode" + - "\n---" + - "\n# {{title}}" + - "\n\n![]({{artwork}})" + - "\n\n{{description}}", - ); - }); - - stackSettingVertically(noteCreationSetting); } - private addFeedNoteSettings(settingsContainer: HTMLDivElement) { - const container = settingsContainer.createDiv(); - - new Setting(container).setName("Feed notes").setHeading(); - - const desc = container.createEl("p", { - text: - "Create a note for a whole podcast (the feed), not a single episode. " + - 'Run the "Create podcast feed note" command to pick a saved podcast. ' + - "Available tags: {{title}}, {{podcast}}, {{url}} (website), " + - "{{feedurl}} (RSS), {{artwork}}, {{author}}, {{description}}, {{date}}.", - }); - desc.setCssStyles({ - fontSize: "var(--font-ui-smaller)", - color: "var(--text-muted)", - }); - - const randomFeed = getRandomFeed(); - - const feedNotePathSetting = new Setting(container) - .setName("Feed note file path") - .setHeading() - .addText((textComponent) => { - textComponent.setValue(this.plugin.settings.feedNote.path); - textComponent.setPlaceholder("PodNotes/Podcasts/{{podcast}}.md"); - textComponent.onChange((value) => { - this.plugin.settings.feedNote.path = value; - void this.plugin.saveSettings(); - renderFeedPathDemo(value); - }); - textComponent.inputEl.setCssStyles({ width: "100%" }); - }); - - stackSettingVertically(feedNotePathSetting); - - const feedNotePathDemoEl = container.createDiv(); + private textRow(name: string, field: TextField, desc?: string): SettingDefinitionRender { + return { + name, + desc, + render: (setting) => { + let complete = () => {}; + let stopPreview = () => {}; + const bind = (control: TextComponent | TextAreaComponent) => { + control + .setValue(field.read()) + .setPlaceholder(field.placeholder ?? "") + .onChange((value) => field.preview?.(value, setting)); + complete = this.bindDraft( + control.inputEl, + (value) => { + const before = field.read(); + field.write(value); + return field.read() !== before; + }, + () => { + control.setValue(field.read()); + field.preview?.(field.read(), setting); + }, + ); + const preview = field.preview; + if (preview) { + stopPreview = this.follow(setting, () => + preview(control.inputEl.value, setting), + ); + } + }; + if (field.kind === "area" || field.kind === "template") { + stackSettingVertically(setting); + setting.addTextArea((area) => { + area.inputEl.setCssStyles({ + width: "100%", + height: field.kind === "template" ? "25vh" : "", + }); + bind(area); + }); + } else { + if (!field.kind) stackSettingVertically(setting); + setting.addText((text) => { + if (field.kind === "number") { + text.inputEl.type = "number"; + text.inputEl.min = field.min?.toString() ?? ""; + text.inputEl.max = field.max?.toString() ?? ""; + } else text.inputEl.setCssStyles({ width: "100%" }); + bind(text); + // Desktop fires no change on Enter. + text.inputEl.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) complete(); + }); + }); + } + return () => { + complete(); + stopPreview(); + }; + }, + }; + } - const renderFeedPathDemo = (value: string) => { - const demoVal = FeedFilePathTemplateEngine(value, randomFeed); - renderMarkdownPreview(this.app, demoVal, feedNotePathDemoEl); + private dropdownRow( + name: string, + options: Readonly>, + read: () => T, + write: (value: T) => void, + desc?: string, + ): SettingDefinitionRender { + return { + name, + desc, + render: (setting) => + void setting.addDropdown((dropdown) => + dropdown + .addOptions(options) + .setValue(read()) + .onChange((value) => { + if (!isOption(options, value)) return; + write(value); + this.changed(); + }), + ), }; + } - renderFeedPathDemo(this.plugin.settings.feedNote.path); + private speedRow(): SettingDefinitionRender { + return { + name: "Default speed", + render: (setting) => + void setting.addDropdown((dropdown) => { + const saved = this.plugin.settings.defaultPlaybackRate; + const rates = PLAYBACK_RATES.includes(saved) + ? PLAYBACK_RATES + : [...PLAYBACK_RATES, saved].sort((a, b) => a - b); + for (const rate of rates) dropdown.addOption(`${rate}`, `${rate}×`); + dropdown.setValue(`${saved}`).onChange((value) => { + this.plugin.settings.defaultPlaybackRate = Number(value); + this.changed(); + }); + }), + }; + } - const feedNoteTemplateSetting = new Setting(container) - .setName("Feed note template") - .setHeading() - .addTextArea((textArea) => { - textArea.setValue(this.plugin.settings.feedNote.template); - textArea.onChange((value) => { - this.plugin.settings.feedNote.template = value; - void this.plugin.saveSettings(); + private whenDiarizing( + row: SettingDefinitionRender, + applies: (diarization: Diarization) => boolean, + ): SettingDefinitionRender { + return { + ...row, + render: (setting, settingGroup) => { + const end = row.render(setting, settingGroup); + const stop = this.follow(setting, ({ transcript }) => { + const off = !applies(transcript.diarization); + setting.setDisabled(off || this.settingsInteractionLockCount > 0); + // Obsidian dims only disabled action rows. + setting.settingEl.setCssStyles({ opacity: off ? "0.5" : "" }); }); - textArea.inputEl.setCssStyles({ width: "100%", height: "25vh" }); - }); - - stackSettingVertically(feedNoteTemplateSetting); + return () => { + stop(); + if (typeof end === "function") end(); + }; + }, + }; } - private addDownloadSettings(container: HTMLDivElement) { - new Setting(container).setName("Downloads").setHeading(); - - const randomEpisode = getRandomEpisode(); - - const downloadPathSetting = new Setting(container) - .setName("Episode download path") - .setDesc( - "The path where the episode will be downloaded to. Avoid setting an extension, as it will be added automatically.", - ) - .setHeading() - .addText((textComponent) => { - textComponent.setValue(this.plugin.settings.download.path); - textComponent.setPlaceholder("inputs/podcasts/{{podcast}} - {{title}}"); - textComponent.onChange((value) => { - this.plugin.settings.download.path = value; - void this.plugin.saveSettings(); - refreshDownloadPathHints(value); + private secretRow(name: string, key: SecretReferenceKey): SettingDefinitionRender { + return { + name, + render: (setting) => { + const describe = () => + setting.setDesc( + this.plugin.credentials.status( + this.plugin.settings, + CREDENTIAL_KIND_BY_REFERENCE[key], + ) === "missing" + ? "The selected secret is not available on this device. Select an existing secret or create one." + : "", + ); + describe(); + setting.addComponent((element) => { + const secret = new SecretComponent(this.app, element).setValue( + this.plugin.settings[key], + ); + secret.onChange((value) => + this.handleSecretSelection(key, value, secret, describe), + ); + return secret; }); - textComponent.inputEl.setCssStyles({ width: "100%" }); - }); - - stackSettingVertically(downloadPathSetting); - - const downloadFilePathDemoEl = container.createDiv(); - const downloadFilePathWarningEl = container.createDiv(); - downloadFilePathWarningEl.setCssStyles({ color: "var(--text-error)" }); - - // A download path without a per-episode token ({{title}}) resolves every - // episode to the same file, so downloads overwrite each other or fail; an - // empty path resolves to ".mp3" at the vault root (#183). Warn inline. - const refreshDownloadPathHints = (value: string) => { - const demoVal = DownloadPathTemplateEngine(value, randomEpisode); - renderMarkdownPreview(this.app, `${demoVal}.mp3`, downloadFilePathDemoEl); - - // Match only the forms DownloadPathTemplateEngine actually resolves — - // {{title}} or {{title:...}}. A looser test (e.g. \s* after {{, or \b) - // would wrongly stay silent for "{{ title }}" / "{{title-ish}}", which the - // engine leaves literal so every episode still collides. - downloadFilePathWarningEl.toggle(!/\{\{title(:[^}]*)?\}\}/i.test(value)); - downloadFilePathWarningEl.setText( - "⚠ This path has no {{title}}, so multiple episodes can resolve to the same file — downloads will overwrite each other or fail. Add {{title}}, e.g. PodNotes/{{podcast}}/{{title}}.", - ); + }, }; - - refreshDownloadPathHints(this.plugin.settings.download.path); } - private addPerformanceSettings(container: HTMLDivElement) { - new Setting(container).setName("Performance").setHeading(); - - new Setting(container) - .setName("Cache podcast feeds") - .setDesc("Store recently downloaded feeds locally for faster startup.") - .addToggle((toggle) => - toggle.setValue(this.plugin.settings.feedCache.enabled).onChange(async (value) => { - this.plugin.settings.feedCache.enabled = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(container) - .setName("Cache duration (hours)") - .setDesc("Choose how long to reuse cached feeds before fetching again.") - .addSlider((slider) => - slider - .setLimits(1, 24, 1) - .setValue(this.plugin.settings.feedCache.ttlHours) - .onChange(async (value) => { - this.plugin.settings.feedCache.ttlHours = value; - await this.plugin.saveSettings(); - }), - ); - - new Setting(container) - .setName("Clear cached feeds") - .setDesc("Remove stored feed data. PodNotes will refetch feeds as needed.") - .addButton((button) => - button.setButtonText("Clear cache").onClick(() => { - this.plugin.feeds.clear(); - new Notice("Cleared cached podcast feeds."); - }), - ); + private importRow(name: string, pick: () => void): SettingDefinitionRender { + return { + name, + render: (setting) => + void setting.addButton((button) => button.setButtonText("Import").onClick(pick)), + }; } - private addImportExportSettings(containerEl: HTMLElement): void { - new Setting(containerEl).setName("Import/Export").setHeading(); - - new Setting(containerEl) - .setName("Import OPML") - .setDesc("Import podcasts from an OPML file.") - .addButton((button) => - button.setButtonText("Import").onClick(() => { - pickFile(".opml", importOPML, { - tooLargeMessage: "That file is too large to be an OPML file.", - }); - }), - ); - - let exportFilePath = "PodNotes_Export.opml"; - - new Setting(containerEl) - .setName("Export OPML") - .setDesc("Export saved podcast feeds to an OPML file.") - .addText((text) => - text - .setPlaceholder("Export file name") - .setValue(exportFilePath) - .onChange((value) => { - exportFilePath = value; - }), - ) - .addButton((button) => - button.setButtonText("Export").onClick(() => { - const feeds = Object.values(get(savedFeeds)); - if (feeds.length === 0) { - new Notice("No podcasts to export."); - return; - } - void exportOPML( - this.app, - feeds, - exportFilePath.endsWith(".opml") - ? exportFilePath - : `${exportFilePath}.opml`, + private exportRow( + name: string, + defaultName: string, + run: (fileName: string) => void, + ): SettingDefinitionRender { + const extension = defaultName.slice(defaultName.lastIndexOf(".")); + return { + name, + render: (setting) => { + let fileName = defaultName; + setting + .addText((text) => + text + .setPlaceholder(defaultName) + .setValue(fileName) + .onChange((value) => { + fileName = value; + }), + ) + .addButton((button) => + button.setButtonText("Export").onClick(() => { + const chosen = fileName.trim() || defaultName; + run(chosen.endsWith(extension) ? chosen : `${chosen}${extension}`); + }), ); - }), - ); - - this.addSettingsTransferControls(containerEl); + }, + }; } - private addSettingsTransferControls(containerEl: HTMLElement): void { - new Setting(containerEl).setName("Preferences & templates").setHeading(); - - new Setting(containerEl) - .setName("Import settings") - .setDesc( - "Import PodNotes preferences, templates, feeds, playlists, and playback progress from a settings file.", - ) - .addButton((button) => - button.setButtonText("Import").onClick(() => { - pickFile(".json", (contents) => this.handleSettingsImport(contents)); - }), - ); + private previewTimestamp(template: string, setting: Setting): void { + if (!this.plugin.api.podcast) { + setting.setDesc("Play an episode to preview the timestamp."); + return; + } + setting.descEl.empty(); + void MarkdownRenderer.render( + this.app, + TimestampTemplateEngine(template), + setting.descEl, + "", + new Component(), + ); + } - let exportFileName = "PodNotes_Settings.json"; - let includeSecret = false; - - new Setting(containerEl) - .setName("Include API keys") - .setDesc( - "When enabled, a separate plaintext secrets payload is added for the OpenAI and Deepgram keys available on this device. The file is stored in your vault, so it may sync to other devices and be read by other plugins.", - ) - .addToggle((toggle) => - toggle.setValue(includeSecret).onChange((value) => { - includeSecret = value; - }), - ); + private demoEpisode(): Episode { + for (const { episodes } of get(this.plugin.feeds).values()) { + if (episodes[0]) return episodes[0]; + } + return { + title: "demo", + podcastName: "demo", + description: "demo", + content: "demo", + url: "demo", + artworkUrl: "demo", + streamUrl: "demo", + episodeDate: new Date(), + feedUrl: "demo", + }; + } - new Setting(containerEl) - .setName("Export settings") - .setDesc( - "Export PodNotes preferences, templates, feeds, playlists, and playback progress.", - ) - .addText((text) => - text - .setPlaceholder("Export file name") - .setValue(exportFileName) - .onChange((value) => { - exportFileName = value; - }), - ) - .addButton((button) => - button.setButtonText("Export").onClick(() => { - const name = exportFileName.trim() || "PodNotes_Settings.json"; - const fileName = name.endsWith(".json") ? name : `${name}.json`; - void this.handleSettingsExport(fileName, includeSecret); - }), - ); + private demoFeed(): PodcastFeed { + return ( + Object.values(get(savedFeeds))[0] ?? { + title: "Demo Podcast", + url: "https://example.com/feed.xml", + artworkUrl: "https://example.com/artwork.jpg", + description: "A demo podcast feed.", + link: "https://example.com", + author: "Demo Author", + } + ); } private async handleSettingsExport(fileName: string, includeSecret: boolean): Promise { + this.completeDrafts(); try { const secrets = includeSecret ? this.plugin.credentials.exportValues(this.plugin.settings, { @@ -806,6 +826,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { imported: Partial, secrets: CredentialValues = {}, ): Promise { + this.completeDrafts(); const unlockSettingsInteractions = this.lockSettingsInteractions(); try { const result = await this.enqueueSettingsMutation(() => @@ -894,9 +915,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { } concurrentStoreChanges.dispose(); - // Re-hydrate the live stores so the running UI and the persistence - // bindings reflect the import. Keys without a store (templates, paths, - // skip lengths, feed cache) are applied via `merged` above. savedFeeds.set(merged.savedFeeds); playlists.set(merged.playlists); favorites.set(merged.favorites); @@ -914,7 +932,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { await this.plugin.saveSettingsStrict(); } catch (error) { new Notice( - "Imported PodNotes settings, but could not finish saving normalized live state. Change any setting to retry.", + "Imported PodNotes settings, but could not save all of them. Change any setting to save again.", 10000, ); console.error("PodNotes: failed to persist normalized imported settings", error); @@ -960,6 +978,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { control.disabled = wasDisabled; } this.settingsControlDisabledStates.clear(); + plugin.set(this.plugin); }; } @@ -1051,205 +1070,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { secret.setValue(result.persistedId); onLatestSettled(); } - - private addTranscriptSettings(container: HTMLDivElement) { - new Setting(container).setName("Transcripts").setHeading(); - - const randomEpisode = getRandomEpisode(); - - const openAISetting = new Setting(container).setName("OpenAI API key"); - const updateOpenAIDescription = () => { - openAISetting.setDesc( - this.plugin.credentials.status(this.plugin.settings, "openai") === "missing" - ? "The selected secret is not available on this device. Select an existing secret or create one." - : "Select an existing Obsidian secret or create one for transcription.", - ); - }; - updateOpenAIDescription(); - openAISetting.addComponent((element) => { - const secret = new SecretComponent(this.app, element).setValue( - this.plugin.settings.openAISecretId, - ); - secret.onChange(async (value) => { - await this.handleSecretSelection( - "openAISecretId", - value, - secret, - updateOpenAIDescription, - ); - }); - return secret; - }); - - new Setting(container) - .setName("Transcript file path") - .setDesc("The path where transcripts will be saved. Use {{}} for dynamic values.") - .addText((text) => { - text.setPlaceholder("transcripts/{{podcast}}/{{title}}.md") - .setValue(this.plugin.settings.transcript.path) - .onChange(async (value) => { - this.plugin.settings.transcript.path = value; - await this.plugin.saveSettings(); - updateTranscriptPathDemo(value); - }); - }); - - const transcriptPathDemoEl = container.createDiv(); - - const updateTranscriptPathDemo = (value: string) => { - const demoVal = FilePathTemplateEngine(value, randomEpisode); - transcriptPathDemoEl.empty(); - transcriptPathDemoEl.createEl("p", { text: `Example: ${demoVal}` }); - }; - - updateTranscriptPathDemo(this.plugin.settings.transcript.path); - - const transcriptTemplateSetting = new Setting(container) - .setName("Transcript template") - .setDesc("The template for the transcript file content.") - .setHeading() - .addTextArea((text) => { - text.setPlaceholder( - "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\nURL: {{url}}\n\n## Description\n\n{{description}}\n\n## Transcript\n\n{{transcript}}", - ) - .setValue(this.plugin.settings.transcript.template) - .onChange(async (value) => { - this.plugin.settings.transcript.template = value; - await this.plugin.saveSettings(); - }); - text.inputEl.setCssStyles({ width: "100%", height: "25vh" }); - }); - - stackSettingVertically(transcriptTemplateSetting); - - this.addDiarizationSettings(container); - } - - /** Opt-in speaker diarization controls (issue #168). */ - private addDiarizationSettings(container: HTMLElement): void { - const diarizationContainer = container.createDiv(); - const diarization = this.plugin.settings.transcript.diarization; - let updateVisibility = () => {}; - - new Setting(diarizationContainer) - .setName("Speaker diarization") - .setDesc( - "Label transcript segments by speaker. Routes the episode audio to a diarization-capable provider instead of plain Whisper.", - ) - .addToggle((toggle) => - toggle.setValue(diarization.enabled).onChange(async (value) => { - this.plugin.settings.transcript.diarization.enabled = value; - await this.plugin.saveSettings(); - updateVisibility(); - }), - ); - - const providerSetting = new Setting(diarizationContainer) - .setName("Diarization provider") - .setDesc( - "OpenAI reuses your OpenAI API key above (long episodes are chunked, so speaker labels can reset across chunks). Deepgram needs its own key and keeps speaker labels consistent across the whole episode.", - ) - .addDropdown((dropdown) => - dropdown - .addOption("openai", "OpenAI (gpt-4o-transcribe-diarize)") - .addOption("deepgram", "Deepgram") - .setValue(diarization.provider) - .onChange(async (value) => { - this.plugin.settings.transcript.diarization.provider = - value as DiarizationProviderId; - await this.plugin.saveSettings(); - updateVisibility(); - }), - ); - - const deepgramSetting = new Setting(diarizationContainer).setName("Deepgram API key"); - const updateDeepgramDescription = () => { - deepgramSetting.setDesc( - this.plugin.credentials.status(this.plugin.settings, "deepgram") === "missing" - ? "The selected secret is not available on this device. Select an existing secret or create one." - : "Select an Obsidian secret for Deepgram diarization, or create one at deepgram.com.", - ); - }; - updateDeepgramDescription(); - deepgramSetting.addComponent((element) => { - const secret = new SecretComponent(this.app, element).setValue( - this.plugin.settings.deepgramSecretId, - ); - secret.onChange(async (value) => { - await this.handleSecretSelection( - "deepgramSecretId", - value, - secret, - updateDeepgramDescription, - ); - }); - return secret; - }); - - const speakerSetting = new Setting(diarizationContainer) - .setName("Speaker label format") - .setDesc( - "Prefix added before each speaker's turn. Use {{speaker}} for the speaker label (OpenAI labels speakers A, B, …; Deepgram labels them 1, 2, …).", - ) - .addText((text) => - text - .setPlaceholder(DEFAULT_SPEAKER_TEMPLATE) - .setValue(diarization.speakerTemplate) - .onChange(async (value) => { - this.plugin.settings.transcript.diarization.speakerTemplate = value; - await this.plugin.saveSettings(); - }), - ); - - updateVisibility = () => { - // Read through the plugin: a sync replaces the settings it holds. - const { enabled, provider } = this.plugin.settings.transcript.diarization; - providerSetting.settingEl.toggle(enabled); - deepgramSetting.settingEl.toggle(enabled && provider === "deepgram"); - speakerSetting.settingEl.toggle(enabled); - }; - updateVisibility(); - } -} - -function getRandomEpisode(): Episode { - const fallbackDemoObj = { - description: "demo", - content: "demo", - podcastName: "demo", - title: "demo", - url: "demo", - artworkUrl: "demo", - streamUrl: "demo", - episodeDate: new Date(), - feedUrl: "demo", - }; - - const feedEpisodes = Array.from(get(get(plugin).feeds).values(), ({ episodes }) => episodes); - if (!feedEpisodes.length) return fallbackDemoObj; - - const randomFeed = feedEpisodes[Math.floor(Math.random() * feedEpisodes.length)]; - if (!randomFeed.length) return fallbackDemoObj; - - const randomEpisode = randomFeed[Math.floor(Math.random() * randomFeed.length)]; - - return randomEpisode; -} - -function getRandomFeed(): PodcastFeed { - const fallbackDemoFeed: PodcastFeed = { - title: "Demo Podcast", - url: "https://example.com/feed.xml", - artworkUrl: "https://example.com/artwork.jpg", - description: "A demo podcast feed.", - link: "https://example.com", - author: "Demo Author", - }; - - const feeds = Object.values(get(savedFeeds)); - if (!feeds.length) return fallbackDemoFeed; - - return feeds[Math.floor(Math.random() * feeds.length)]; } class ConfirmModal extends Modal { diff --git a/src/utility/playbackRate.ts b/src/utility/playbackRate.ts index f85e44de..2fdb8ef8 100644 --- a/src/utility/playbackRate.ts +++ b/src/utility/playbackRate.ts @@ -2,6 +2,7 @@ export const PLAYBACK_RATE_MIN = 0.5; export const PLAYBACK_RATE_MAX = 4; export const PLAYBACK_RATE_STEP = 0.1; export const DEFAULT_PLAYBACK_RATE = 1; +export const PLAYBACK_RATES: readonly number[] = [0.75, 1, 1.25, 1.5, 1.75, 2]; export function normalizePlaybackRate(value: unknown, fallback = DEFAULT_PLAYBACK_RATE): number { const numeric = typeof value === "number" ? value : Number(value); diff --git a/tests/e2e/focused-settings-save.test.ts b/tests/e2e/focused-settings-save.test.ts new file mode 100644 index 00000000..fb89aa1a --- /dev/null +++ b/tests/e2e/focused-settings-save.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; +import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; +import { openPodNotesSettings, reloadObsidian, restartObsidian } from "./journey/actions"; +import { typeInto } from "./journey/input"; +import { ui } from "./journey/targets"; +import { jsLiteral, RELOAD_OPTIONS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("focused-settings-save"); + +test.runIf(Boolean(process.env.OBSIDIAN_E2E_OBSIDIAN_HOME)).each(["quit", "reload"])( + "keeps a focused text edit after its debounce through app %s", + async (action) => { + const { obsidian, plugin } = getContext(); + await plugin.updateDataAndReload((data) => { + data.note = { path: "Initial/{{title}}.md", template: "# {{title}}" }; + }, RELOAD_OPTIONS); + await openPodNotesSettings(obsidian); + const path = `Focused/${action}/{{title}}.md`; + await typeInto(obsidian, ui.settingControl("Episode note path", "input"), path, { + replace: true, + }); + await obsidian.sleep(750); + expect( + await obsidian.dev.evalJson( + `document.activeElement?.value === ${jsLiteral(path)}`, + ), + "the field remains focused without blur or completion", + ).toBe(true); + + if (action === "quit") await restartObsidian(obsidian); + else await reloadObsidian(obsidian); + + expect((await plugin.data().read()).note?.path).toBe(path); + expect( + await obsidian.dev.evalJson("app.plugins.plugins.podnotes.settings.note.path"), + "the restarted plugin loads the focused edit", + ).toBe(path); + }, +); diff --git a/tests/e2e/journey-mobile.test.ts b/tests/e2e/journey-mobile.test.ts index a9ef7e44..1d792603 100644 --- a/tests/e2e/journey-mobile.test.ts +++ b/tests/e2e/journey-mobile.test.ts @@ -66,7 +66,7 @@ describe("mobile journeys", () => { await waitUntilShown(obsidian, ui.firstRun); onTestFinished(() => closeSettings(obsidian)); await click(obsidian, ui.firstRunSettings); - await waitUntilShown(obsidian, ui.settingHeading("Episode notes")); + await waitUntilShown(obsidian, ui.settingHeading("Notes")); }, 120_000); test("J-EDGE-13 a private feed URL moved to SecretStorage on a mobile start leaves no copy in the old feed cache", async () => { diff --git a/tests/e2e/journey-notes.test.ts b/tests/e2e/journey-notes.test.ts index 6e1ae700..444f7d8d 100644 --- a/tests/e2e/journey-notes.test.ts +++ b/tests/e2e/journey-notes.test.ts @@ -3,11 +3,8 @@ import { createPodNotesE2EHarness } from "./harness"; import { clickInEditor, closePopoutTab, - closeSettings, - dismissNotices, openInEditor, openInReadingView, - openPodNotesSettings, reloadAfterPopout, runFromPalette, showLibrary, @@ -80,7 +77,7 @@ function hms(seconds: number): string { } /** - * The default "Capture timestamp format", `- {{linktime}} `, for `episode` at `seconds`, + * The default "Timestamp template", `- {{linktime}} `, for `episode` at `seconds`, * or the segment from `seconds` to `end` that it captures as `- {{linksegment}} `. */ function capture( @@ -314,12 +311,9 @@ describe("episode note and capture journeys", () => { ); server.retitle(show, ORIGINAL, RETITLED); - await openPodNotesSettings(obsidian); - await click(obsidian, ui.settingControl("Clear cached feeds", "button")); - await closeSettings(obsidian); - await dismissNotices(obsidian); await showLibrary(obsidian); await click(obsidian, ui.podcastCard(show.title)); + await click(obsidian, ui.refreshEpisodes); await waitUntilShown(obsidian, ui.episode(RETITLED)); await click(obsidian, ui.episodeMenu(RETITLED)); diff --git a/tests/e2e/journey-resilience.test.ts b/tests/e2e/journey-resilience.test.ts index d09ee23e..1933844c 100644 --- a/tests/e2e/journey-resilience.test.ts +++ b/tests/e2e/journey-resilience.test.ts @@ -19,7 +19,7 @@ import { showList, showPodNotes, } from "./journey/actions"; -import { click, typeInto } from "./journey/input"; +import { click, pressKey, typeInto } from "./journey/input"; import { ui } from "./journey/targets"; import { eventually, @@ -246,9 +246,10 @@ describe("resilience journeys", () => { ); await openPodNotesSettings(obsidian); - await typeInto(obsidian, ui.settingControl("Skip backward length (s)", "input"), "42", { + await typeInto(obsidian, ui.settingControl("Skip back (s)", "input"), "42", { replace: true, }); + await pressKey(obsidian, "Tab"); const saved = await waitForLibrary( plugin, (library) => library.skipBackwardSeconds === 42, diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index 19f4bed1..fe8f792e 100644 --- a/tests/e2e/journey/actions.ts +++ b/tests/e2e/journey/actions.ts @@ -102,7 +102,7 @@ export async function openPodNotesSettings(obsidian: ObsidianClient): Promise { diff --git a/tests/e2e/journey/input.ts b/tests/e2e/journey/input.ts index b3b63dea..60a7e5c8 100644 --- a/tests/e2e/journey/input.ts +++ b/tests/e2e/journey/input.ts @@ -195,6 +195,7 @@ export async function typeText(obsidian: ObsidianClient, text: string): Promise< } const KEYS = { + Tab: { key: "Tab", windowsVirtualKeyCode: 9 }, Enter: { key: "Enter", windowsVirtualKeyCode: 13 }, Escape: { key: "Escape", windowsVirtualKeyCode: 27 }, ArrowUp: { key: "ArrowUp", windowsVirtualKeyCode: 38 }, diff --git a/tests/e2e/journey/targets.ts b/tests/e2e/journey/targets.ts index 6d9a450d..349c9cc0 100644 --- a/tests/e2e/journey/targets.ts +++ b/tests/e2e/journey/targets.ts @@ -155,6 +155,10 @@ export const ui = { ".workspace-leaf.mod-active .markdown-reading-view a", text, ), + settingsSearch: byCss( + "Settings search", + '.modal.mod-settings input[placeholder="Search settings..."]', + ), settingsTab: (name: string) => byCss(`settings tab ${name}`, ".modal.mod-settings .vertical-tab-nav-item", name), settingControl: (settingName: string, control: string): UiTarget => ({ diff --git a/tests/e2e/marketplace-review-runtime.test.ts b/tests/e2e/marketplace-review-runtime.test.ts index 9f4bc8a9..effa3998 100644 --- a/tests/e2e/marketplace-review-runtime.test.ts +++ b/tests/e2e/marketplace-review-runtime.test.ts @@ -14,85 +14,20 @@ const DOWNLOAD_URL = "https://e2e.podnotes.test/episode.mp3"; const getContext = createPodNotesE2EHarness("marketplace-review-runtime"); describe("marketplace review runtime paths", () => { - test("renders searchable declarative settings and completes export/import with persistence", async () => { + test("completes settings export/import with persistence", async () => { const { obsidian, plugin, sandbox } = getContext(); const exportName = "marketplace-settings-export.json"; const exportPath = sandbox.path(exportName); try { await openSettings(obsidian); - - const initial = await obsidian.dev.evalJson<{ - definitionCount: number; - headings: string[]; - indexedAliases: number; - renderedControls: number; - }>(` - (() => { - const tab = app.setting.pluginTabs.find((candidate) => candidate.id === ${jsLiteral(PLUGIN_ID)}); - const definitions = tab?.getSettingDefinitions?.() ?? []; - return { - definitionCount: definitions.length, - headings: Array.from(document.querySelectorAll(".modal.mod-settings .setting-item-heading .setting-item-name")) - .map((element) => element.textContent?.trim() ?? ""), - indexedAliases: definitions.flatMap((definition) => definition.aliases ?? []).length, - renderedControls: document.querySelectorAll(".modal.mod-settings .setting-item").length, - }; - })() - `); - - expect(initial.definitionCount).toBe(1); - expect(initial.indexedAliases).toBe(27); - expect(initial.renderedControls).toBeGreaterThan(25); - expect(initial.headings).toEqual( - expect.arrayContaining([ - "Episode notes", - "Feed notes", - "Downloads", - "Preferences & templates", - "Transcripts", - ]), - ); - expect(initial.headings).not.toContain("PodNotes"); - expect(initial.headings, "playlists live in the view").not.toContain("Playlists"); - expect(initial.headings, "podcast search lives in the view").not.toContain( - "Search Podcasts", - ); - expect(initial.headings.every((heading) => !/settings/i.test(heading))).toBe(true); - - await evalJsonAsync( - obsidian, - `(async () => { - const input = document.querySelector('.modal.mod-settings input[placeholder="Search settings..."]'); - if (!(input instanceof HTMLInputElement)) throw new Error("Settings search input not found."); - input.value = "Episode download path"; - input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); - return true; - })()`, - ); - await obsidian.waitFor( - async () => - await obsidian.dev.evalJson( - 'document.querySelector(".setting-search-results")?.textContent?.includes("Podcast preferences") ?? false', - ), - WAIT_OPTS, - ); - - await evalJsonAsync( - obsidian, - `(() => { - app.setting.close(); - return true; - })()`, - ); - await openSettings(obsidian); - await setSettingInputValue(obsidian, "Default Playback Rate", "1.5"); + await setSettingValue(obsidian, "Default speed", "1.5", "select"); await plugin.waitForData( (data) => data.defaultPlaybackRate === 1.5, WAIT_OPTS, ); - await setSettingInputValue(obsidian, "Export settings", exportPath); + await setSettingValue(obsidian, "Export settings", exportPath, "input"); await clickSettingButton(obsidian, "Export settings", "Export"); const exported = await sandbox.waitForContent( exportName, @@ -110,9 +45,9 @@ describe("marketplace review runtime paths", () => { settings: { defaultPlaybackRate: 1.5 }, }); - await setSettingInputValue(obsidian, "Default Playback Rate", "1.2"); + await setSettingValue(obsidian, "Default speed", "1.25", "select"); await plugin.waitForData( - (data) => data.defaultPlaybackRate === 1.2, + (data) => data.defaultPlaybackRate === 1.25, WAIT_OPTS, ); @@ -169,7 +104,7 @@ describe("marketplace review runtime paths", () => { WAIT_OPTS, ); await obsidian.waitFor( - async () => await settingInputHasValue(obsidian, "Default Playback Rate", "1.5"), + async () => await settingHasValue(obsidian, "Default speed", "1.5", "select"), WAIT_OPTS, ); expect(await obsidian.dev.runtimeErrors()).toEqual([]); @@ -540,16 +475,17 @@ async function openSettings(obsidian: Parameters[0]): Prom async () => await obsidian.dev.evalJson(` Array.from(document.querySelectorAll(".modal.mod-settings .setting-item-name")) - .some((element) => element.textContent?.trim() === "Default Playback Rate") + .some((element) => element.textContent?.trim() === "Default speed") `), WAIT_OPTS, ); } -async function setSettingInputValue( +async function setSettingValue( obsidian: Parameters[0], settingName: string, value: string, + control: "input" | "select", ): Promise { await evalJsonAsync( obsidian, @@ -557,31 +493,30 @@ async function setSettingInputValue( const setting = Array.from(document.querySelectorAll(".modal.mod-settings .setting-item")).find( (candidate) => candidate.querySelector(".setting-item-name")?.textContent?.trim() === ${jsLiteral(settingName)}, ); - const input = setting?.querySelector("input"); - if (!(input instanceof HTMLInputElement)) { - throw new Error(${jsLiteral(`Input not found for ${settingName}.`)}); - } - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; - if (!setter) throw new Error("Native input value setter not found."); - setter.call(input, ${jsLiteral(value)}); - input.dispatchEvent(new Event("input", { bubbles: true })); - input.dispatchEvent(new Event("change", { bubbles: true })); + const field = setting?.querySelector(${jsLiteral(control)}); + if (!field) throw new Error(${jsLiteral(`${control} not found for ${settingName}.`)}); + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(field), "value")?.set; + if (!setter) throw new Error("Native value setter not found."); + setter.call(field, ${jsLiteral(value)}); + field.dispatchEvent(new Event("input", { bubbles: true })); + field.dispatchEvent(new Event("change", { bubbles: true })); return true; })()`, ); } -async function settingInputHasValue( +async function settingHasValue( obsidian: Parameters[0], settingName: string, value: string, + control: "input" | "select", ): Promise { return await obsidian.dev.evalJson(` (() => { const setting = Array.from(document.querySelectorAll(".modal.mod-settings .setting-item")).find( (candidate) => candidate.querySelector(".setting-item-name")?.textContent?.trim() === ${jsLiteral(settingName)}, ); - return setting?.querySelector("input")?.value === ${jsLiteral(value)}; + return setting?.querySelector(${jsLiteral(control)})?.value === ${jsLiteral(value)}; })() `); } diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index 8c131141..6ecd9231 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import type { PluginHandle, SandboxApi } from "obsidian-e2e"; -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import type DownloadedEpisode from "../../src/types/DownloadedEpisode"; import type { Episode } from "../../src/types/Episode"; import type { LocalEpisode } from "../../src/types/LocalEpisode"; @@ -25,6 +25,9 @@ import { waitForPodNotesReady, } from "./runtime"; +import { closeSettings, openPodNotesSettings } from "./journey/actions"; +import { startFixtureServer } from "./journey/fixtures"; + const getContext = createPodNotesE2EHarness("podnotes-runtime"); describe("PodNotes runtime", () => { @@ -385,6 +388,83 @@ describe("PodNotes runtime", () => { } }); + test("previous track follows the mapping chosen in Settings, with no reload", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(obsidian, sandbox); + const episode = createLocalEpisode("E2E Remapped Headset Episode", audioPath); + const notePath = sandbox.path("remapped-headset-target.md"); + + await seedRuntimeData(plugin, sandbox, episode, { + currentEpisode: episode, + timestampTemplate: "- {{linktime}}", + }); + await installMediaSessionRecorder(obsidian); + try { + await waitForPodNotesReady(obsidian); + await openMarkdownFile(obsidian, notePath); + await setPlayback(obsidian, { currentTime: 95, paused: true }); + await invokeRecordedMediaSessionAction(obsidian, "previoustrack"); + await sandbox.waitForContent( + "remapped-headset-target.md", + (value) => value.includes(expectedTimestampLink("00:01:35", episode, 95)), + WAIT_OPTS, + ); + + await openPodNotesSettings(obsidian); + await chooseSettingOption(obsidian, "Previous track", "Back by the skip length"); + await closeSettings(obsidian); + await plugin.waitForData<{ headsetPreviousTrack?: string }>( + (data) => data.headsetPreviousTrack === "skipBackward", + WAIT_OPTS, + ); + const before = await sandbox.read("remapped-headset-target.md"); + + await invokeRecordedMediaSessionAction(obsidian, "previoustrack"); + + expect((await getPlaybackState(obsidian)).currentTime).toBe(80); + expect(await sandbox.read("remapped-headset-target.md")).toBe(before); + } finally { + await restoreMediaSessionRecorder(obsidian); + } + }); + + test("Previous chapter keeps the position when its chapter file cannot be loaded", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const server = await startFixtureServer([]); + onTestFinished(() => server.stop()); + const audioPath = await seedAudio(obsidian, sandbox); + const episode = { + ...createLocalEpisode("E2E Unavailable Chapters", audioPath), + chaptersUrl: server.feedUrl({ + title: "Missing chapters", + slug: "missing-chapters", + artwork: false, + episodes: [], + }), + }; + await seedRuntimeData(plugin, sandbox, episode, { currentEpisode: episode }); + await installMediaSessionRecorder(obsidian); + try { + await waitForPodNotesReady(obsidian); + await setPlayback(obsidian, { currentTime: 95, paused: true }); + await openPodNotesSettings(obsidian); + await chooseSettingOption(obsidian, "Previous track", "Previous chapter"); + await closeSettings(obsidian); + + await obsidian.waitFor( + async () => { + await invokeRecordedMediaSessionAction(obsidian, "previoustrack"); + return server.requests().length >= 2; + }, + { ...WAIT_OPTS, message: "failed chapter request to finish and retry" }, + ); + + expect((await getPlaybackState(obsidian)).currentTime).toBe(95); + } finally { + await restoreMediaSessionRecorder(obsidian); + } + }); + test("the Media Session shows the episode, and each of its actions drives the player", async () => { const { obsidian, plugin, sandbox } = getContext(); const audioPath = await seedAudio(obsidian, sandbox); @@ -428,7 +508,7 @@ describe("PodNotes runtime", () => { await invokeRecordedMediaSessionAction(obsidian, "seekbackward", { seekOffset: 99 }); expect((await getPlaybackState(obsidian)).currentTime).toBe(100); await invokeRecordedMediaSessionAction(obsidian, "nexttrack"); - expect((await getPlaybackState(obsidian)).currentTime).toBe(130); + expect((await getPlaybackState(obsidian)).currentTime).toBe(115); await invokeRecordedMediaSessionAction(obsidian, "play"); await waitForPlaybackState(obsidian, (state) => state.isPlaying); @@ -1072,6 +1152,27 @@ function episodeKey(episode: Episode): string { return `${episode.podcastName}::${episode.title}`; } +async function chooseSettingOption( + obsidian: Parameters[0], + settingName: string, + label: string, +): Promise { + await evalJsonAsync( + obsidian, + `(() => { + const row = [...document.querySelectorAll(".modal.mod-settings .setting-item")].find( + (candidate) => candidate.querySelector(".setting-item-name")?.textContent === ${jsLiteral(settingName)}, + ); + const select = row?.querySelector("select"); + const option = [...(select?.options ?? [])].find((each) => each.textContent === ${jsLiteral(label)}); + if (!select || !option) throw new Error(${jsLiteral(`No option ${label} in ${settingName}.`)}); + select.value = option.value; + select.dispatchEvent(new Event("change", { bubbles: true })); + return true; + })()`, + ); +} + async function setPlayback( obsidian: { dev: { evalJson: (code: string) => Promise } }, { currentTime, paused }: { currentTime: number; paused: boolean }, diff --git a/tests/e2e/quit-save.test.ts b/tests/e2e/quit-save.test.ts index 10c1e794..461c0142 100644 --- a/tests/e2e/quit-save.test.ts +++ b/tests/e2e/quit-save.test.ts @@ -30,5 +30,9 @@ test.runIf(isolatedInstance)( ); expect(onDisk?.defaultVolume).toBe(0.392); + expect( + await obsidian.dev.evalJson(`app.plugins.plugins.${PLUGIN_ID}.api.volume`), + "the restarted player's volume", + ).toBe(0.392); }, ); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts new file mode 100644 index 00000000..b2d7283e --- /dev/null +++ b/tests/e2e/settings-tab.test.ts @@ -0,0 +1,280 @@ +import type { ObsidianClient } from "obsidian-e2e"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; +import { + closeSettings, + openPodNotesSettings, + showLibrary, + showList, + showPodNotes, +} from "./journey/actions"; +import { pubDate, startFixtureServer, type FixtureFeed } from "./journey/fixtures"; +import { click, pressKey, typeInto, typeText } from "./journey/input"; +import { feedCacheHolds, recordWrites, seedLibrary } from "./journey/library"; +import { PLAYER_VIEW, ui } from "./journey/targets"; +import { eventually, waitUntilShown } from "./journey/wait"; +import { jsLiteral, RELOAD_OPTIONS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("settings-tab"); + +const FEED: FixtureFeed = { + title: "Settings Cache Feed", + slug: "settings-cache", + artwork: false, + episodes: [{ title: "Cached Episode", seconds: 6, pubDate: pubDate(1), guid: "cached" }], +}; + +async function searchResults( + obsidian: ObsidianClient, + query: string, + previous: readonly string[], +): Promise { + await typeInto(obsidian, ui.settingsSearch, query, { replace: true }); + const read = () => + obsidian.dev.evalJson(`(() => { + const group = [...document.querySelectorAll(".modal.mod-settings .setting-search-result-group")].find( + (candidate) => candidate.querySelector(".setting-search-result-tab-label")?.textContent === "PodNotes", + ); + return [...(group?.querySelectorAll(".setting-search-result-item .vertical-tab-nav-item-title") ?? [])].map( + (item) => item.textContent, + ); + })()`); + let last: string[] = []; + await obsidian.waitFor( + async () => + (last = await read()).length > 0 && JSON.stringify(last) !== JSON.stringify(previous), + { timeoutMs: 5_000, intervalMs: 100, message: `Settings search results for ${query}` }, + ); + return last; +} + +async function listedNow(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson( + `[...document.querySelectorAll(".modal.mod-settings .setting-search-result-item .vertical-tab-nav-item-title")].map((item) => item.textContent)`, + ); +} + +async function settingValue(obsidian: ObsidianClient, name: string): Promise { + return await obsidian.dev.evalJson(`(() => { + const row = [...document.querySelectorAll(".modal.mod-settings .setting-item")].find( + (candidate) => candidate.querySelector(".setting-item-name")?.textContent === ${jsLiteral(name)}, + ); + const toggle = row?.querySelector(".checkbox-container"); + return toggle ? toggle.classList.contains("is-enabled") : (row?.querySelector("input, textarea")?.value ?? null); + })()`); +} + +describe("PodNotes settings tab", () => { + test("search finds a row of each section by name, and none of the removed rows", async () => { + const { obsidian } = getContext(); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + expect( + await obsidian.dev.evalJson( + `[...document.querySelectorAll(".modal.mod-settings .setting-item-heading .setting-item-name")].map(element => element.textContent.trim())`, + ), + ).toEqual([ + "Playback", + "Headset and lock screen", + "Notes", + "Transcripts", + "Downloads", + "Library", + "Import and export", + ]); + + const found: Record = {}; + let previous: string[] = []; + for (const query of [ + "Skip back", + "headset", + "Timestamp template", + "Deepgram", + "Download path", + "OPML", + "Include API keys", + ]) { + previous = found[query] = await searchResults(obsidian, query, previous); + } + expect(found).toMatchObject({ + "Skip back": expect.arrayContaining(["Skip back (s)"]), + headset: expect.arrayContaining(["Previous track", "Next track"]), + "Timestamp template": expect.arrayContaining(["Timestamp template"]), + Deepgram: expect.arrayContaining(["Deepgram API key"]), + "Download path": expect.arrayContaining(["Download path"]), + OPML: expect.arrayContaining(["Import OPML", "Export OPML"]), + "Include API keys": expect.arrayContaining(["Include API keys"]), + }); + + const removed = [ + "Default volume", + "Cache podcast feeds", + "Cache duration", + "Clear cached feeds", + ]; + for (const name of removed) { + await typeInto(obsidian, ui.settingsSearch, name, { replace: true }); + await obsidian.sleep(800); + expect(await listedNow(obsidian), name).not.toContain(name); + } + }); + + test("a typing burst writes data.json once after it pauses, and completing it writes nothing more", async () => { + const { obsidian, plugin } = getContext(); + const typed = "UI3/Typed note path.md"; + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + const path = ui.settingControl("Episode note path", "input"); + + const typing = await recordWrites(obsidian); + await typeInto(obsidian, path, "", { replace: true }); + for (const character of typed) await typeText(obsidian, character); + await plugin.waitForData((data) => data.note?.path === typed, { + timeoutMs: 5_000, + intervalMs: 100, + }); + await obsidian.sleep(1_000); + expect(await typing.stop(), "writes for the burst").toMatchObject({ + attempted: 1, + completed: 1, + failed: 0, + }); + + const completing = await recordWrites(obsidian); + await pressKey(obsidian, "Tab"); + await obsidian.sleep(1_000); + expect(await completing.stop(), "writes for completing a saved edit").toMatchObject({ + attempted: 0, + }); + }); + + test("Enter completes an edit while its field keeps focus", async () => { + const { obsidian } = getContext(); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + const saved = await settingValue(obsidian, "Skip back (s)"); + + await typeInto(obsidian, ui.settingControl("Skip back (s)", "input"), "-", { + replace: true, + }); + await pressKey(obsidian, "Enter"); + + expect( + await obsidian.dev.evalJson(`(() => { + const field = document.activeElement; + return [field?.closest(".setting-item")?.querySelector(".setting-item-name")?.textContent, field?.value]; + })()`), + "the focused field shows the saved value for an entry it cannot use", + ).toEqual(["Skip back (s)", saved]); + }); + + test("leaving a field saves a return to its original text before the debounce", async () => { + const { obsidian, plugin } = getContext(); + const initial = "Initial/{{title}}.md"; + const edited = "Paused/{{title}}.md"; + await plugin.updateDataAndReload((data) => { + data.note = { path: initial, template: "# {{title}}" }; + }, RELOAD_OPTIONS); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + const path = ui.settingControl("Episode note path", "input"); + await typeInto(obsidian, path, edited, { replace: true }); + await plugin.waitForData((data) => data.note?.path === edited, { + timeoutMs: 5_000, + intervalMs: 100, + }); + + await typeInto(obsidian, path, initial, { replace: true }); + await pressKey(obsidian, "Tab"); + await obsidian.sleep(100); + + expect((await plugin.data().read()).note?.path).toBe(initial); + }); + + test("an edit still in its field is saved when Settings closes", async () => { + const { obsidian, plugin } = getContext(); + await openPodNotesSettings(obsidian); + await typeInto( + obsidian, + ui.settingControl("Transcript path", "input"), + "Kept/{{title}}.md", + { + replace: true, + }, + ); + + await closeSettings(obsidian); + + await plugin.waitForData( + (data) => data.transcript?.path === "Kept/{{title}}.md", + { timeoutMs: 5_000, intervalMs: 100 }, + ); + }); + + test("the queue toggle follows the Queue page menu while Settings is open", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [FEED] }); + await showPodNotes(obsidian); + await showList(obsidian, "Queue"); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + expect(await settingValue(obsidian, "Queue episodes I switch away from")).toBe(true); + + // Settings covers the view, so its menu button is clicked the way a popout user reaches it. + await obsidian.dev.evalJson( + `document.querySelector(${jsLiteral(`${PLAYER_VIEW} button[aria-label="Queue options"]`)}).click(), true`, + ); + await click(obsidian, ui.menuItem("Queue episodes I switch away from")); + + expect( + await eventually( + obsidian, + () => settingValue(obsidian, "Queue episodes I switch away from"), + false, + ), + ).toBe(false); + await plugin.waitForData((data) => data.autoQueue === false, { + timeoutMs: 5_000, + intervalMs: 100, + }); + }); + + test("feeds are cached even when caching was saved as off, and the next save drops that", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [FEED] }); + await plugin.updateDataAndReload((data) => { + data.feedCache = { enabled: false, ttlHours: 1 }; + }, RELOAD_OPTIONS); + expect(await plugin.data<{ feedCache?: unknown }>().read()).toMatchObject({ + feedCache: { enabled: false, ttlHours: 1 }, + }); + + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(FEED.title)); + await waitUntilShown(obsidian, ui.episode("Cached Episode")); + await obsidian.waitFor(() => feedCacheHolds(obsidian, "Cached Episode"), { + timeoutMs: 10_000, + intervalMs: 200, + message: "the feed in the cache", + }); + + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + await typeInto(obsidian, ui.settingControl("Skip back (s)", "input"), "20", { + replace: true, + }); + await pressKey(obsidian, "Tab"); + const saved = await plugin.waitForData( + (data) => data.skipBackwardLength === 20, + { timeoutMs: 5_000, intervalMs: 100 }, + ); + expect(saved).not.toHaveProperty("feedCache"); + }); +}); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 4aa2f8cf..948accc7 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -373,16 +373,16 @@ export class DropdownComponent extends BaseInteractiveElement { return this.element as HTMLSelectElement; } - addOptions(options: Record) { - this.selectEl.innerHTML = ""; - - Object.entries(options).forEach(([value, label]) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - this.selectEl.appendChild(option); - }); + addOption(value: string, label: string) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + this.selectEl.appendChild(option); + return this; + } + addOptions(options: Record) { + for (const [value, label] of Object.entries(options)) this.addOption(value, label); return this; } @@ -402,6 +402,45 @@ export class DropdownComponent extends BaseInteractiveElement { } } +/** As Obsidian's: setValue runs the change callback when the value changes. */ +export class ToggleComponent extends BaseInteractiveElement { + private on = false; + private changeCallback?: (on: boolean) => void; + + constructor(container: HTMLElement) { + super("input", container); + this.toggleEl.type = "checkbox"; + this.toggleEl.addEventListener("change", () => this.setValue(this.toggleEl.checked)); + } + + get toggleEl() { + return this.element as HTMLInputElement; + } + + getValue() { + return this.on; + } + + setValue(on: boolean) { + this.toggleEl.checked = on; + if (this.on !== on) { + this.on = on; + this.changeCallback?.(on); + } + return this; + } + + setDisabled(disabled: boolean) { + this.toggleEl.disabled = disabled; + return this; + } + + onChange(callback: (on: boolean) => void) { + this.changeCallback = callback; + return this; + } +} + export class SliderComponent extends BaseInteractiveElement { constructor(container: HTMLElement) { super("input", container); @@ -435,8 +474,8 @@ export class SliderComponent extends BaseInteractiveElement { } export class TextComponent extends BaseInteractiveElement { - constructor(container: HTMLElement) { - super("input", container); + constructor(container: HTMLElement, tag: "input" | "textarea" = "input") { + super(tag, container); } get inputEl() { @@ -473,15 +512,25 @@ export class SecretComponent extends TextComponent { } } +interface Disableable { + setDisabled(disabled: boolean): unknown; +} + export class Setting { settingEl: HTMLElement; + infoEl: HTMLElement; nameEl: HTMLElement; + descEl: HTMLElement; + controlEl: HTMLElement; + errorEl: HTMLElement | null = null; + private components: Disableable[] = []; constructor(containerEl: HTMLElement) { - this.settingEl = document.createElement("div"); - this.nameEl = document.createElement("div"); - this.settingEl.appendChild(this.nameEl); - containerEl.appendChild(this.settingEl); + this.settingEl = containerEl.createDiv({ cls: "setting-item" }); + this.infoEl = this.settingEl.createDiv({ cls: "setting-item-info" }); + this.nameEl = this.infoEl.createDiv({ cls: "setting-item-name" }); + this.descEl = this.infoEl.createDiv({ cls: "setting-item-description" }); + this.controlEl = this.settingEl.createDiv({ cls: "setting-item-control" }); } setName(name: string) { @@ -489,44 +538,88 @@ export class Setting { return this; } - setDesc() { + setDesc(desc: string) { + this.descEl.textContent = desc; return this; } setHeading() { + this.settingEl.classList.add("setting-item-heading"); return this; } - addText(callback: (component: TextComponent) => void) { - callback(new TextComponent(this.settingEl)); + setDisabled(disabled: boolean) { + this.settingEl.classList.toggle("is-disabled", disabled); + for (const component of this.components) component.setDisabled(disabled); return this; } - addTextArea(callback: (component: TextComponent) => void) { - callback(new TextComponent(this.settingEl)); + setErrorMessage(message: string | null) { + if (message) { + this.errorEl ??= this.controlEl.createDiv({ cls: "setting-item-error" }); + this.errorEl.textContent = message; + } else { + this.errorEl?.remove(); + this.errorEl = null; + } return this; } - addSlider(callback: (component: SliderComponent) => void) { - callback(new SliderComponent(this.settingEl)); + private add(component: T, callback: (component: T) => unknown) { + this.components.push(component); + callback(component); return this; } - addButton(callback: (component: ButtonComponent) => void) { - callback(new ButtonComponent(this.settingEl)); + addText(callback: (component: TextComponent) => unknown) { + return this.add(new TextComponent(this.controlEl), callback); + } + + addTextArea(callback: (component: TextComponent) => unknown) { + return this.add(new TextComponent(this.controlEl, "textarea"), callback); + } + + addToggle(callback: (component: ToggleComponent) => unknown) { + return this.add(new ToggleComponent(this.controlEl), callback); + } + + addDropdown(callback: (component: DropdownComponent) => unknown) { + return this.add(new DropdownComponent(this.controlEl), callback); + } + + addSlider(callback: (component: SliderComponent) => unknown) { + callback(new SliderComponent(this.controlEl)); return this; } - addComponent(callback: (container: HTMLElement) => T) { - callback(this.settingEl); + addButton(callback: (component: ButtonComponent) => unknown) { + return this.add(new ButtonComponent(this.controlEl), callback); + } + + addComponent(callback: (container: HTMLElement) => T) { + this.components.push(callback(this.controlEl)); return this; } } +interface SettingDefinition { + type?: "group"; + heading?: string; + items?: SettingDefinition[]; + name?: string; + desc?: string; + render?: (setting: Setting, group: unknown) => void | (() => void); +} + +/** + * Renders getSettingDefinitions() as Obsidian 1.14 does: every render runs the + * cleanups of the rows rendered before, and closing the tab runs them before hide(). + */ export class PluginSettingTab { app: App; plugin: Plugin; containerEl: HTMLElement; + private cleanups: Array<() => void> = []; constructor(app: App, plugin: Plugin) { this.app = app; @@ -534,9 +627,40 @@ export class PluginSettingTab { this.containerEl = document.createElement("div"); } - display(): void {} - update(): void {} + getSettingDefinitions(): SettingDefinition[] { + return []; + } + + display(): void { + this.cleanUp(); + this.containerEl.empty(); + for (const definition of this.getSettingDefinitions()) { + if (definition.heading) + new Setting(this.containerEl).setName(definition.heading).setHeading(); + for (const row of definition.items ?? [definition]) { + const setting = new Setting(this.containerEl).setName(row.name ?? ""); + if (row.desc) setting.setDesc(row.desc); + const cleanup = row.render?.(setting, definition); + if (cleanup) this.cleanups.push(cleanup); + } + } + } + + update(): void { + this.display(); + } + hide(): void {} + + close(): void { + this.cleanUp(); + this.containerEl.empty(); + this.hide(); + } + + private cleanUp(): void { + for (const cleanup of this.cleanups.splice(0)) cleanup(); + } } export const MarkdownRenderer = { diff --git a/tests/mocks/services.ts b/tests/mocks/services.ts index 322b847d..59f7c213 100644 --- a/tests/mocks/services.ts +++ b/tests/mocks/services.ts @@ -102,7 +102,6 @@ export function createTestServices( load: async () => feedDocument("", []), resolveUrl: (feed) => feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const online = writable(true);