From 08a50883f110e90842aa218670de2f87738b45d2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 02:48:19 +0200 Subject: [PATCH 01/10] feat(ui): rebuild the settings tab as native searchable sections Each SPEC 9 section is a native settings group, and every row is its own render definition, so Obsidian's settings search finds each row by name. The 27-entry alias list and the single "Podcast preferences" wrapper go. - Remove Default volume (the player remembers volume) and the feed cache rows (Cache podcast feeds, Cache duration, Clear cache). - Text fields write data.json once per completed edit instead of once per keystroke. Typing changes only the field; the edit applies on change, when the row or tab closes, or when its window hides or unloads. A field with no edit never writes, so it cannot undo a synced or imported value. - Default speed is a dropdown of the player's speed menu. A saved speed it does not offer is shown as an extra option and is not rewritten. Rates keep hundredths so 1.25 and 1.75 survive a reload. - The auto-queue toggle is "Queue episodes I switch away from" and follows changes from the Queue page while Settings is open. - Diarization rows stay listed and searchable, disabled while they do not apply. - Path previews and the download warning use the row's description and native error message. Restating descriptions are removed. - Plain-word import notice and OPML progress, and no long dashes in the settings migration docstrings. --- src/constants.ts | 6 +- src/opml.ts | 5 +- src/persistence/codecUtils.ts | 8 + src/services/CaptureService.ts | 2 +- src/settingsMigrations.ts | 10 +- src/settingsTransfer.ts | 2 +- .../PodNotesSettingsTab.notes.test.ts | 42 - .../PodNotesSettingsTab.playback.test.ts | 43 - src/ui/settings/PodNotesSettingsTab.test.ts | 387 ++++++ src/ui/settings/PodNotesSettingsTab.ts | 1174 +++++++---------- src/utility/playbackRate.ts | 11 +- tests/e2e/journey-mobile.test.ts | 2 +- tests/e2e/journey-notes.test.ts | 8 +- tests/e2e/journey-resilience.test.ts | 5 +- tests/e2e/journey/actions.ts | 2 +- tests/e2e/marketplace-review-runtime.test.ts | 103 +- tests/mocks/obsidian.ts | 181 ++- 17 files changed, 1066 insertions(+), 925 deletions(-) delete mode 100644 src/ui/settings/PodNotesSettingsTab.notes.test.ts delete mode 100644 src/ui/settings/PodNotesSettingsTab.playback.test.ts create mode 100644 src/ui/settings/PodNotesSettingsTab.test.ts diff --git a/src/constants.ts b/src/constants.ts index 9ea421de..55b19db9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -96,7 +96,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 +145,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}}", }, 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..bddd9bae 100644 --- a/src/persistence/codecUtils.ts +++ b/src/persistence/codecUtils.ts @@ -166,6 +166,14 @@ export function mapRecord( return result; } +/** Whether `value` is one of the keys of `options`, and not one it inherits. */ +export function isOption( + options: Readonly>, + value: unknown, +): value is T { + return typeof value === "string" && Object.prototype.hasOwnProperty.call(options, value); +} + export function isPlainObject(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 296097b3..4e3ffd2a 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -66,7 +66,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/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.ts b/src/settingsTransfer.ts index a1bf3ab5..74562579 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[] { 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..7e75d8de --- /dev/null +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -0,0 +1,387 @@ +// @vitest-environment jsdom +import { derived, readable } from "svelte/store"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as obsidian from "obsidian"; + +import { DEFAULT_SETTINGS } from "src/constants"; +import PodNotes from "src/main"; +import { decodePodNotesData } from "src/persistence/podNotesData"; +import { PlayerController } from "src/player/PlayerController"; +import { FeedUrlRepository } from "src/services/FeedUrlRepository"; +import { 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(); + +/** Obsidian closing Settings: the rows' cleanups, then hide(). The mock tab does it. */ +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.restoreAllMocks(); + pluginStore.set(undefined as never); +}); + +/** The tab of a plugin whose saves go through the real writer to a counted data.json. */ +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: {}, + 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]"), + /** A change synced in from another device. */ + sync: async (change: Partial) => { + disk = { ...disk, ...structuredClone(change) }; + await plugin.onExternalSettingsChange(); + }, + /** data.json writes, once every save asked for has run. */ + writes: async () => { + await plugin.flushSaves(); + return writes; + }, + disk: async () => { + await plugin.flushSaves(); + 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")); + +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", + ], + ], + [ + "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("typing saves nothing; the completed edit saves once and tells an open view", async () => { + 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"); + + retype(path, "UI3/My podcast note.md"); + expect(await writes()).toBe(0); + expect(plugin.settings.note.path).toBe(DEFAULT_SETTINGS.note.path); + + complete(path); + expect(await writes()).toBe(1); + expect((await disk()).note.path).toBe("UI3/My podcast note.md"); + stop(); + expect(paths).toEqual([DEFAULT_SETTINGS.note.path, "UI3/My podcast note.md"]); + }); + + 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")); + tab.hide(); + + expect(await writes()).toBe(1); + expect((await disk()).transcript.path).toBe(`${DEFAULT_SETTINGS.transcript.path}x`); + }); + + 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")); + visibility.mockReturnValue("visible"); + complete(field("Download path")); + + expect(await writes()).toBe(1); + expect((await disk()).download.path).toBe(`${DEFAULT_SETTINGS.download.path}/x`); + }); + + 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("a number field keeps the saved value for an entry it cannot use", async () => { + const { plugin, field, writes } = open(); + const skip = field("Skip back (s)"); + + for (const entry of ["", "0", "-5"]) { + enter(skip, entry); + complete(skip); + } + expect(skip.value).toBe("15"); + expect(await writes()).toBe(0); + + enter(skip, "42"); + complete(skip); + expect(plugin.settings.skipBackwardLength).toBe(42); + 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("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..b75ad515 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,13 @@ import { sanitizeEpisodeListLimit, savedFeeds, } from "src/store/index"; -import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; +import { DEFAULT_SETTINGS } from "src/constants"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; 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 +46,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 +56,7 @@ type SettingsControl = | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; +type Diarization = IPodNotesSettings["transcript"]["diarization"]; interface SecretReferenceSaveResult { persistedId: string; @@ -61,35 +66,26 @@ 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; + /** Takes the completed text. A value it rejects leaves the setting as it was. */ + write(value: string): void; + placeholder?: string; + /** "area" is a short multi-line field, "template" a long one under the row's name. */ + kind?: "number" | "area" | "template"; + preview?(value: string, setting: Setting): void; +} + +const DIARIZATION_PROVIDERS = { + openai: "OpenAI (gpt-4o-transcribe-diarize)", + deepgram: "Deepgram", +}; + +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; @@ -197,11 +193,9 @@ const CREDENTIAL_KIND_BY_REFERENCE: Record = }; /** - * 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. + * Stack a Setting's control beneath its name, full width, as the template text + * areas need. Obsidian's Setting has no vertical variant and the plugin ships no + * stylesheet, so this sets the few inline styles. */ function stackSettingVertically(setting: Setting): void { setting.settingEl.setCssStyles({ @@ -211,16 +205,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 +215,490 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; private settingsInteractionLockCount = 0; private settingsControlDisabledStates = new Map(); + /** Completes the edit of each field that has one. */ + private readonly drafts = new Set<() => void>(); + /** Stops each rendered row that follows the settings. */ + 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) => { + toggle.onChange((on) => this.plugin.setAutoQueue(on)); + // The Queue page menu changes it too. + stop = this.follow(setting, ({ autoQueue }) => + toggle.setValue(autoQueue), + ); + }); + return stop; + }, }, - }, + ]), + 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); + // Only these forms resolve: without one, every episode gets the same file. + 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", + 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)), - ); + /** Tells open views and the rows that follow settings, then saves. */ + 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(); - }), - ); + /** + * Typing changes only the field. The edit applies once it completes: on change + * (blur or Enter), when its row or the tab closes, or when its window hides or + * unloads, which closes a Settings popout without its rows' cleanups. A field + * with no edit never writes, so it cannot undo a newer value. + */ + private bindDraft( + input: HTMLInputElement | HTMLTextAreaElement, + apply: (value: string) => void, + ): () => void { + let { win, doc } = input; + const hidden = () => doc.visibilityState === "hidden" && complete(); + const complete = () => { + if (!this.drafts.delete(complete)) return; + win.removeEventListener("pagehide", complete); + doc.removeEventListener("visibilitychange", hidden); + apply(input.value); + }; + input.addEventListener("input", () => { + 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); + 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)), - ); + /** Shows the settings in `setting` now and after each change, until the row ends. */ + 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"); - }); + /** A string setting of whichever settings object is live, with its default as placeholder. */ + 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(); - - const renderFeedPathDemo = (value: string) => { - const demoVal = FeedFilePathTemplateEngine(value, randomFeed); - renderMarkdownPreview(this.app, demoVal, feedNotePathDemoEl); + private textRow(name: string, field: TextField, desc?: string): SettingDefinitionRender { + return { + name, + desc, + render: (setting) => { + let complete = () => {}; + 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); + if (field.read() !== before) this.changed(); + control.setValue(field.read()); + field.preview?.(field.read(), setting); + }); + }; + if (field.kind === "template") { + stackSettingVertically(setting); + setting.addTextArea((area) => { + area.inputEl.setCssStyles({ width: "100%", height: "25vh" }); + bind(area); + }); + } else if (field.kind === "area") { + setting.addTextArea(bind); + } else { + setting.addText((text) => { + if (field.kind === "number") text.inputEl.type = "number"; + bind(text); + }); + } + field.preview?.(field.read(), setting); + return complete; + }, }; - - renderFeedPathDemo(this.plugin.settings.feedNote.path); - - 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(); - }); - textArea.inputEl.setCssStyles({ width: "100%", height: "25vh" }); - }); - - stackSettingVertically(feedNoteTemplateSetting); } - 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); - }); - 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}}.", - ); + 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(); + }), + ), }; - - 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."); + /** The player's speeds, and a saved speed that is none of them. Nothing saves until a change. */ + 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(); + }); }), - ); + }; } - 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.", - }); - }), - ); + /** A diarization row, disabled while it does not apply. */ + 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 }) => + setting.setDisabled(!applies(transcript.diarization)), + ); + return () => { + stop(); + if (typeof end === "function") end(); + }; + }, + }; + } - 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 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; + }); + }, + }; + } - this.addSettingsTransferControls(containerEl); + private importRow(name: string, pick: () => void): SettingDefinitionRender { + return { + name, + render: (setting) => + void setting.addButton((button) => button.setButtonText("Import").onClick(pick)), + }; } - private addSettingsTransferControls(containerEl: HTMLElement): void { - new Setting(containerEl).setName("Preferences & templates").setHeading(); + /** A file name, the default when left empty, with the default's extension added if missing. */ + 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}`); + }), + ); + }, + }; + } - 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; - }), - ); + /** An episode for the path examples: the first loaded one, or a stand-in. */ + 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 +775,8 @@ export class PodNotesSettingsTab extends PluginSettingTab { imported: Partial, secrets: CredentialValues = {}, ): Promise { + // An edit not completed yet would otherwise apply over the import when its row re-renders. + this.completeDrafts(); const unlockSettingsInteractions = this.lockSettingsInteractions(); try { const result = await this.enqueueSettingsMutation(() => @@ -896,7 +867,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { // 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. + // skip lengths) are applied via `merged` above. savedFeeds.set(merged.savedFeeds); playlists.set(merged.playlists); favorites.set(merged.favorites); @@ -914,7 +885,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); @@ -1051,205 +1022,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 b139abc3..f6fcfdd4 100644 --- a/src/utility/playbackRate.ts +++ b/src/utility/playbackRate.ts @@ -2,6 +2,8 @@ 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; +/** The speeds the player's speed menu offers. */ +export const PLAYBACK_RATES = [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); @@ -15,13 +17,14 @@ export function normalizePlaybackRate(value: unknown, fallback = DEFAULT_PLAYBAC } export function adjustPlaybackRate(currentRate: number, delta: number): number { - return normalizePlaybackRate(roundToTenths(currentRate + delta)); + return normalizePlaybackRate(roundToHundredths(currentRate + delta)); } function clampPlaybackRate(value: number): number { - return Math.min(PLAYBACK_RATE_MAX, Math.max(PLAYBACK_RATE_MIN, roundToTenths(value))); + return Math.min(PLAYBACK_RATE_MAX, Math.max(PLAYBACK_RATE_MIN, roundToHundredths(value))); } -function roundToTenths(value: number): number { - return Math.round(value * 10) / 10; +// Hundredths, so the rate menu's quarter steps such as 1.25 survive. +function roundToHundredths(value: number): number { + return Math.round(value * 100) / 100; } 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 f2d6b457..116eaedf 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, @@ -272,12 +269,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..47d42f7b 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, "Enter"); const saved = await waitForLibrary( plugin, (library) => library.skipBackwardSeconds === 42, diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index 5ea8e732..5b99556a 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/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/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 8e06b7d9..6223cd67 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,41 @@ 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 {} + + /** Test only: Obsidian closing Settings, or opening another tab. */ + close(): void { + this.cleanUp(); + this.containerEl.empty(); + this.hide(); + } + + private cleanUp(): void { + for (const cleanup of this.cleanups.splice(0)) cleanup(); + } } export const MarkdownRenderer = { From 879e066fea80cad2296ec82cd82cefce34633589 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 02:52:28 +0200 Subject: [PATCH 02/10] feat(feeds): always cache feeds and retire the cache preference Feeds are cached for six hours whatever the saved preference said, and Refresh still fetches at once. Users who turned caching off get it back. feedCache leaves the settings type, defaults, codec and settings transfer. A saved feedCache, including enabled:false, decodes without a warning or a startup rewrite, is not kept as an unknown field, and the next save drops it, also when an older device syncs it back. Releases up to 2.21.1 read the missing field as caching on for six hours. Clear cache had no caller left, so FeedRepository.clear goes too. --- obsidian-e2e.config.mjs | 4 - scripts/persistence-size.test.ts | 1 - src/URIHandler.feeds.test.ts | 1 - src/constants.ts | 4 - src/createFeedNote.test.ts | 1 - src/main.persistence.test.ts | 51 +++++++++++- src/main.ts | 1 - src/persistence/podNotesData.test.ts | 48 +++++++++++- src/persistence/podNotesData.ts | 8 ++ src/persistence/settingsCodec.ts | 23 ------ src/services/FeedRepository.test.ts | 77 ++++++------------- src/services/FeedRepository.ts | 27 +++---- src/services/library.test.ts | 1 - .../publishedTranscript/index.test.ts | 1 - src/settingsTransfer.test.ts | 18 +++++ src/settingsTransfer.ts | 1 - src/types/IPodNotesSettings.ts | 4 - tests/mocks/services.ts | 1 - 18 files changed, 157 insertions(+), 115 deletions(-) diff --git a/obsidian-e2e.config.mjs b/obsidian-e2e.config.mjs index abac0ef7..921c472d 100644 --- a/obsidian-e2e.config.mjs +++ b/obsidian-e2e.config.mjs @@ -121,10 +121,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 55b19db9..d21a6818 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -168,8 +168,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..f94d9956 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -66,6 +66,13 @@ function makePlugin( const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); +/** A released library as this version writes it back: without the retired feed cache preference. */ +function withoutFeedCache(released: Record): Record { + const data: Record = structuredClone(released); + delete data.feedCache; + return data; +} + async function saveInBackground(plugin: PodNotes): Promise { void plugin.saveSettings(); await settled(); @@ -376,7 +383,7 @@ describe("PodNotes persistence integration", () => { expect(saveData).toHaveBeenCalledTimes(1); expect(saveData.mock.calls[0][0]).toEqual({ - ...released, + ...withoutFeedCache(released), defaultVolume: 0.4, libraryId: "", }); @@ -411,7 +418,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, + ...withoutFeedCache(released2211), libraryId: "", savedFeeds: { "PRIV2 Both": { ...released2211.savedFeeds["PRIV2 Both"], urlSecretId } }, }); @@ -784,6 +791,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({ + ...withoutFeedCache(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({ + ...withoutFeedCache(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 6afb708b..ad7f8626 100644 --- a/src/main.ts +++ b/src/main.ts @@ -153,7 +153,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, }); diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index ef870281..d0c60e77 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -64,6 +64,25 @@ function settingsWithEveryEpisodeContainer(): IPodNotesSettings { }; } +/** + * How 2.21.1 read the feed cache preference, frozen from its settingsCodec: an + * absent or malformed field is caching on for six hours. Downgrades read this. + */ +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 +553,39 @@ 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 without only its 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, libraryId: "" }); + }); + + 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, + }); }); const storedDownload = (downloadedByPodNotes: unknown) => ({ diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index 11c7aba8..e61181ce 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -16,9 +16,17 @@ 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"]); +/** + * Settings PodNotes no longer has. They are not kept as unknown fields, so the next + * save drops them. Feeds are always cached, and earlier releases read a missing + * `feedCache` as caching on with a six-hour lifetime. + */ +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..4db9de54 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -24,7 +24,6 @@ import { readClampedNumber, readFiniteNumber, readNullableString, - readPositiveNumber, readString, type UnknownRecord, warn, @@ -114,7 +113,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 +229,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/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 6cdd4a96..89f599b2 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 }); await t.repo.ensureAll(); - expect(t.load).toHaveBeenCalledTimes(1); - t.advance(7 * HOUR); + expect(t.load).not.toHaveBeenCalled(); + expect(t.feed("Alpha")?.status).toBe("ready"); + + 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..c419778e 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,8 @@ 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"; +/** Refresh fetches a feed sooner. */ +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/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index c240b4c1..2b736def 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -394,6 +394,24 @@ describe("parseImport", () => { } }); + 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 74562579..b437f5a1 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -80,7 +80,6 @@ const NESTED_KEYS: readonly (keyof IPodNotesSettings)[] = [ "feedNote", "download", "transcript", - "feedCache", ]; export interface SettingsEnvelope { diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 884d507d..7ccfe174 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -72,8 +72,4 @@ export interface IPodNotesSettings { speakerTemplate: string; }; }; - feedCache: { - enabled: boolean; - ttlHours: number; - }; } 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); From 9084e62d2f1038249ab3351f730f9485ce6f81c3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 02:56:18 +0200 Subject: [PATCH 03/10] feat(player): map the headset's previous and next track buttons Settings has a Headset and lock screen section with Previous track (Capture timestamp, Back by the skip length, Previous chapter) and Next track (Forward by the skip length, Next in queue, Next chapter, Capture timestamp). Each press reads the mapping, so a change made in Settings or synced in applies at once. The defaults keep today's buttons, except that next track now skips by the skip-forward length (15 s by default) through the API's skip, which stops short of the end and never goes back, instead of a fixed 30 s. Next in queue plays the queue's first episode. With auto-queue on, the episode switched away from goes to the end of the queue, so repeated presses go through it instead of switching between two. Chapter actions load the episode's chapters once per URL, ask again after a failed or empty load, and move nothing when the episode changed meanwhile. The two settings are validated where data.json and imports are read; old data without them gets the defaults with no rewrite. --- obsidian-e2e.config.mjs | 2 + src/constants.ts | 2 + src/main.persistence.test.ts | 21 ++- src/main.ts | 3 + src/persistence/codecUtils.ts | 14 ++ src/persistence/podNotesData.test.ts | 31 +++- src/persistence/settingsCodec.ts | 16 ++ src/player/mediaSession.test.ts | 189 +++++++++++++++++++- src/player/mediaSession.ts | 96 +++++++--- src/settingsTransfer.test.ts | 24 +++ src/types/Headset.ts | 17 ++ src/types/IPodNotesSettings.ts | 4 + src/ui/settings/PodNotesSettingsTab.test.ts | 30 ++++ src/ui/settings/PodNotesSettingsTab.ts | 28 +++ 14 files changed, 444 insertions(+), 33 deletions(-) create mode 100644 src/types/Headset.ts diff --git a/obsidian-e2e.config.mjs b/obsidian-e2e.config.mjs index 921c472d..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, diff --git a/src/constants.ts b/src/constants.ts index d21a6818..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: { diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index f94d9956..750fb1ad 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -66,9 +66,16 @@ function makePlugin( const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); -/** A released library as this version writes it back: without the retired feed cache preference. */ -function withoutFeedCache(released: Record): Record { - const data: Record = structuredClone(released); +/** + * A released library as this version writes it back: with the headset mapping's defaults, + * and without the retired feed cache preference. + */ +function savedByThisVersion(released: Record): Record { + const data: Record = { + ...structuredClone(released), + headsetPreviousTrack: "captureTimestamp", + headsetNextTrack: "skipForward", + }; delete data.feedCache; return data; } @@ -383,7 +390,7 @@ describe("PodNotes persistence integration", () => { expect(saveData).toHaveBeenCalledTimes(1); expect(saveData.mock.calls[0][0]).toEqual({ - ...withoutFeedCache(released), + ...savedByThisVersion(released), defaultVolume: 0.4, libraryId: "", }); @@ -418,7 +425,7 @@ describe("PodNotes persistence integration", () => { const { urlSecretId } = written.savedFeeds["PRIV2 Both"]; expect(urlSecretId).toMatch(/^podnotes-feed-url-[0-9a-f-]{36}$/); expect(written).toEqual({ - ...withoutFeedCache(released2211), + ...savedByThisVersion(released2211), libraryId: "", savedFeeds: { "PRIV2 Both": { ...released2211.savedFeeds["PRIV2 Both"], urlSecretId } }, }); @@ -806,7 +813,7 @@ describe("PodNotes persistence integration", () => { plugin.settings.defaultVolume = 0.4; await plugin.saveSettingsStrict(); expect(saveData.mock.lastCall?.[0]).toEqual({ - ...withoutFeedCache(released2211), + ...savedByThisVersion(released2211), defaultVolume: 0.4, libraryId: "", }); @@ -823,7 +830,7 @@ describe("PodNotes persistence integration", () => { await plugin.saveSettingsStrict(); expect(saveData.mock.lastCall?.[0]).toEqual({ - ...withoutFeedCache(released2211), + ...savedByThisVersion(released2211), defaultVolume: 0.4, skipForwardLength: 45, skipBackwardLength: 20, diff --git a/src/main.ts b/src/main.ts index ad7f8626..b9be340a 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 = @@ -236,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/persistence/codecUtils.ts b/src/persistence/codecUtils.ts index bddd9bae..3d60d33b 100644 --- a/src/persistence/codecUtils.ts +++ b/src/persistence/codecUtils.ts @@ -174,6 +174,20 @@ export function isOption( 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 d0c60e77..332a3919 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -553,7 +553,7 @@ describe("PodNotes data schema", () => { expect(decoded.warnings).toEqual(["currentEpisode.guid: expected a string"]); }); - it("loads a 2.21.1 library unchanged, and saves it without only its feed cache preference", () => { + 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; @@ -562,7 +562,34 @@ describe("PodNotes data schema", () => { expect(feedCache).toEqual({ enabled: true, ttlHours: 6 }); expect( JSON.parse(JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields))), - ).toEqual({ ...rest, 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", () => { diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 4db9de54..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,6 +25,7 @@ import { readClampedNumber, readFiniteNumber, readNullableString, + readOption, readString, type UnknownRecord, warn, @@ -86,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), diff --git a/src/player/mediaSession.test.ts b/src/player/mediaSession.test.ts index 6231652a..c532680a 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,183 @@ 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("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("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("no chapters loaded is a no-op the next press retries, and no chapters URL never asks", async () => { + const chapters = vi + .fn<(url: string) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce([]) + .mockResolvedValue(CHAPTERS); + const context = setup({ chapters }); + context.host.settings.headsetNextTrack = "nextChapter"; + playing(context, 30, 3600, WITH_CHAPTERS); + + for (const expected of [30, 30, 60]) { + context.session.act("nexttrack"); + await flush(); + expect(context.media().currentTime).toBe(expected); + } + + playing(context, 30, 3600, B); + context.session.act("nexttrack"); + 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..3d3e1d28 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -1,21 +1,28 @@ 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; + /** Read on each press, so a mapping changed in Settings or synced in needs no rebind. */ + settings(): Pick; captureTimestamp(): void; skipBackward(): void; skipForward(): void; + /** The episode's chapters, in order, or none when they cannot be loaded. */ + chapters(url: string): Promise; } -const NEXT_TRACK_SKIP_S = 30; +/** Previous chapter restarts the current one until this far into it. */ +const CHAPTER_RESTART_S = 2; +/** Next chapter skips a start this close ahead, where a seek to a chapter can land. */ +const CHAPTER_TOLERANCE_S = 0.25; const PLAYBACK_STATES: Record = { empty: "none", @@ -42,6 +49,23 @@ function attempt(call: () => void): boolean { const isPositive = (value: number) => Number.isFinite(value) && value > 0; +/** Where next (1) or previous (-1) chapter goes from `time`, or nowhere. */ +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 +74,60 @@ function positionOf({ currentTime, duration, playbackRate }: PlayerState) { } export function bindMediaSession( - player: Pick, + player: Pick, host: MediaSessionHost, ): Unsubscriber { if (!host.mediaSession) return () => {}; const { mediaSession, MediaMetadata } = host; + /** One episode's chapters, loading or loaded. */ + 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; + if (chapters?.url !== url) { + chapters = { url, loading: host.chapters(url).catch(() => []) }; + } + const asked = chapters; + const loaded = await asked.loading; + // None loaded, as offline: the next press asks again. + if (loaded.length === 0 && chapters === asked) chapters = undefined; + const now = get(player.state); + if (unbound || !now.episode || getEpisodeKey(now.episode) !== getEpisodeKey(episode)) + return; + const start = chapterStart(loaded, now.currentTime, direction); + if (start !== undefined) player.seek(start); + } + + /** Plays the queue's first episode. The one switched away from goes last, so presses go through the queue. */ + function playNextInQueue(): void { + const [next] = get(queue).episodes; + const outgoing = get(player.state).episode; + 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 }) => { diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 2b736def..ea3ce558 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -394,6 +394,30 @@ 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({ diff --git a/src/types/Headset.ts b/src/types/Headset.ts new file mode 100644 index 00000000..e414b30b --- /dev/null +++ b/src/types/Headset.ts @@ -0,0 +1,17 @@ +/** What the headset's and lock screen's previous-track button does, with its label in Settings. */ +export const PREVIOUS_TRACK_ACTIONS = { + captureTimestamp: "Capture timestamp", + skipBackward: "Back by the skip length", + previousChapter: "Previous chapter", +} as const; + +/** What the headset's and lock screen's next-track button does, with its label in Settings. */ +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 7ccfe174..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; /** diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 7e75d8de..44655888 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -154,6 +154,7 @@ describe("sections", () => { "Queue episodes I switch away from", ], ], + ["Headset and lock screen", ["Previous track", "Next track"]], [ "Notes", [ @@ -346,6 +347,35 @@ describe("Default speed", () => { }); }); +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(); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index b75ad515..bf6afc45 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -34,6 +34,7 @@ import { import { DEFAULT_SETTINGS } 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"; @@ -81,6 +82,9 @@ const DIARIZATION_PROVIDERS = { deepgram: "Deepgram", }; +/** Search finds rows, not section headings. */ +const HEADSET_ALIASES = ["headset", "lock screen", "media keys"]; + const group = (heading: string, items: SettingDefinitionRender[]): SettingDefinitionGroup => ({ type: "group", heading, @@ -254,6 +258,30 @@ export class PodNotesSettingsTab extends PluginSettingTab { }, }, ]), + 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"), From 0774eddd25c261151f4d6edbee07e34f33fcfec9 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 03:07:44 +0200 Subject: [PATCH 04/10] fix(ui): dim inactive diarization rows and stack the timestamp template Obsidian dims only disabled action rows, so a disabled diarization dropdown or key button looked usable. The timestamp template field sits under its name at full width, as on a phone it otherwise stopped short of the other fields. --- src/ui/settings/PodNotesSettingsTab.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index bf6afc45..e857ad4f 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -72,7 +72,7 @@ interface TextField { /** Takes the completed text. A value it rejects leaves the setting as it was. */ write(value: string): void; placeholder?: string; - /** "area" is a short multi-line field, "template" a long one under the row's name. */ + /** Multi-line fields under the row's name: "area" short, "template" tall. */ kind?: "number" | "area" | "template"; preview?(value: string, setting: Setting): void; } @@ -531,14 +531,15 @@ export class PodNotesSettingsTab extends PluginSettingTab { field.preview?.(field.read(), setting); }); }; - if (field.kind === "template") { + if (field.kind === "area" || field.kind === "template") { stackSettingVertically(setting); setting.addTextArea((area) => { - area.inputEl.setCssStyles({ width: "100%", height: "25vh" }); + area.inputEl.setCssStyles({ + width: "100%", + height: field.kind === "template" ? "25vh" : "", + }); bind(area); }); - } else if (field.kind === "area") { - setting.addTextArea(bind); } else { setting.addText((text) => { if (field.kind === "number") text.inputEl.type = "number"; @@ -603,9 +604,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { ...row, render: (setting, settingGroup) => { const end = row.render(setting, settingGroup); - const stop = this.follow(setting, ({ transcript }) => - setting.setDisabled(!applies(transcript.diarization)), - ); + const stop = this.follow(setting, ({ transcript }) => { + const off = !applies(transcript.diarization); + setting.setDisabled(off); + // Obsidian dims only disabled action rows. + setting.settingEl.setCssStyles({ opacity: off ? "0.5" : "" }); + }); return () => { stop(); if (typeof end === "function") end(); From e6df2e39e38fe80c8fe6f266c0fdd1227ee02474 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 03:08:03 +0200 Subject: [PATCH 05/10] test(e2e): cover settings search, one write per edit, and the headset mapping - Settings search finds a row of each section by name, a diarization row while diarization is off, and none of the removed rows. - Typing 22 characters into Episode note path writes data.json nothing until Tab completes the edit, then once. An edit still in its field is saved when Settings closes. - The queue toggle follows the Queue page menu while Settings is open. - A saved feedCache enabled:false still caches feeds, and the next save drops it. - Previous track follows a remap in Settings with no reload, and next track defaults to the skip-forward length. - A restarted player starts at the volume saved last. --- src/player/PlayerController.test.ts | 11 ++ tests/e2e/journey-notes.test.ts | 2 +- tests/e2e/journey-resilience.test.ts | 2 +- tests/e2e/journey/input.ts | 2 +- tests/e2e/journey/targets.ts | 4 + tests/e2e/podnotes-runtime.test.ts | 66 +++++++- tests/e2e/quit-save.test.ts | 4 + tests/e2e/settings-tab.test.ts | 228 +++++++++++++++++++++++++++ 8 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/settings-tab.test.ts 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/tests/e2e/journey-notes.test.ts b/tests/e2e/journey-notes.test.ts index 116eaedf..8262f6ce 100644 --- a/tests/e2e/journey-notes.test.ts +++ b/tests/e2e/journey-notes.test.ts @@ -77,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( diff --git a/tests/e2e/journey-resilience.test.ts b/tests/e2e/journey-resilience.test.ts index 47d42f7b..1933844c 100644 --- a/tests/e2e/journey-resilience.test.ts +++ b/tests/e2e/journey-resilience.test.ts @@ -249,7 +249,7 @@ describe("resilience journeys", () => { await typeInto(obsidian, ui.settingControl("Skip back (s)", "input"), "42", { replace: true, }); - await pressKey(obsidian, "Enter"); + await pressKey(obsidian, "Tab"); const saved = await waitForLibrary( plugin, (library) => library.skipBackwardSeconds === 42, diff --git a/tests/e2e/journey/input.ts b/tests/e2e/journey/input.ts index 3ebee9fa..dc6f60a7 100644 --- a/tests/e2e/journey/input.ts +++ b/tests/e2e/journey/input.ts @@ -194,7 +194,7 @@ export async function typeText(obsidian: ObsidianClient, text: string): Promise< await cdp(obsidian, "Input.insertText", { text }); } -const KEY_CODES = { Enter: 13, Escape: 27, ArrowUp: 38, ArrowDown: 40 } as const; +const KEY_CODES = { Tab: 9, Enter: 13, Escape: 27, ArrowUp: 38, ArrowDown: 40 } as const; /** Presses and releases `key` on whatever has focus. */ export async function pressKey( diff --git a/tests/e2e/journey/targets.ts b/tests/e2e/journey/targets.ts index 0edd9b6b..8f790319 100644 --- a/tests/e2e/journey/targets.ts +++ b/tests/e2e/journey/targets.ts @@ -120,6 +120,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/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index 993fad4a..889d70a8 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -25,6 +25,8 @@ import { waitForPodNotesReady, } from "./runtime"; +import { closeSettings, openPodNotesSettings } from "./journey/actions"; + const getContext = createPodNotesE2EHarness("podnotes-runtime"); describe("PodNotes runtime", () => { @@ -385,6 +387,46 @@ 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("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 +470,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 +1114,28 @@ function episodeKey(episode: Episode): string { return `${episode.podcastName}::${episode.title}`; } +/** Picks the option labelled `label` in the dropdown of the setting named `settingName`. */ +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..e68d68fc --- /dev/null +++ b/tests/e2e/settings-tab.test.ts @@ -0,0 +1,228 @@ +import type { ObsidianClient } from "obsidian-e2e"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; +import { + closeSettings, + openPodNotesSettings, + runCommand, + 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" }], +}; + +/** + * The names of the PodNotes rows Settings search lists for `query`. Results update a + * moment after typing, so this waits for a list other than the previous query's. + */ +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; +} + +/** Every row name Settings search lists right now, from any tab. */ +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 runCommand(obsidian, "app:open-settings"); + onTestFinished(() => closeSettings(obsidian)); + + 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 text edit writes data.json once, when it completes", 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"); + await typeInto(obsidian, path, "", { replace: true }); + + const typing = await recordWrites(obsidian); + for (const character of typed) await typeText(obsidian, character); + await obsidian.sleep(1_000); + expect(await typing.stop(), "writes while typing").toMatchObject({ attempted: 0 }); + + const completing = await recordWrites(obsidian); + await pressKey(obsidian, "Tab"); + await plugin.waitForData((data) => data.note?.path === typed, { + timeoutMs: 5_000, + intervalMs: 100, + }); + await obsidian.sleep(500); + expect(await completing.stop(), "writes for the completed edit").toMatchObject({ + attempted: 1, + completed: 1, + failed: 0, + }); + }); + + 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"); + }); +}); From 9f35b313d29141c072e52622ccd4e2aa8e01f48d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 03:08:03 +0200 Subject: [PATCH 06/10] docs(verify): use the settings tab's new row names --- .claude/skills/verify/features/episode-notes.md | 4 ++-- .claude/skills/verify/features/player.md | 2 +- .claude/skills/verify/features/timestamps-uris.md | 4 ++-- .claude/skills/verify/features/transcripts.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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 513e7c60..3ee4763f 100644 --- a/.claude/skills/verify/features/player.md +++ b/.claude/skills/verify/features/player.md @@ -46,6 +46,6 @@ 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. - The skip buttons have no accessible name. Address them by position inside `.controls-container`. - 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 8e72f3b3..e9041a78 100644 --- a/.claude/skills/verify/features/timestamps-uris.md +++ b/.claude/skills/verify/features/timestamps-uris.md @@ -9,7 +9,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-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`. @@ -17,7 +17,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 From e78a24b6b2ac3cff94cb2e841f6cdc53a4b1c899 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 03:10:54 +0200 Subject: [PATCH 07/10] test: isolate each completion path and the cache lifetime boundary Each completion test now reads data.json before any other path could complete the edit, and the six-hour boundary no longer waits on a fetch it does not expect, so a planted defect fails on its assertion instead of a timeout. --- src/services/FeedRepository.test.ts | 4 ++-- src/ui/settings/PodNotesSettingsTab.test.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 89f599b2..4d6c114d 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -386,9 +386,9 @@ describe("FeedRepository", () => { const t = setup({ storage: seeded.storage }); t.advance(6 * HOUR); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + void t.repo.ensureAll(); expect(t.load).not.toHaveBeenCalled(); - expect(t.feed("Alpha")?.status).toBe("ready"); + expect(t.feed("Alpha")).toMatchObject({ status: "ready", episodes: [expect.anything()] }); t.advance(1); void t.repo.ensureAll(); diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 44655888..d74f5604 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -237,10 +237,11 @@ describe("text fields", () => { type(field("Transcript path"), "x"); window.dispatchEvent(new Event("pagehide")); - tab.hide(); 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 () => { @@ -249,11 +250,12 @@ describe("text fields", () => { type(field("Download path"), "/x"); document.dispatchEvent(new Event("visibilitychange")); - visibility.mockReturnValue("visible"); - complete(field("Download path")); 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 () => { From c0a6d3df9795d1141ca37d7e14e91dc856abec8f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 03:52:44 +0200 Subject: [PATCH 08/10] fix(ui): preserve settings locks and reject stale headset actions --- src/main.persistence.test.ts | 4 - src/persistence/codecUtils.ts | 1 - src/persistence/podNotesData.test.ts | 4 - src/persistence/podNotesData.ts | 5 -- src/player/mediaSession.test.ts | 91 ++++++++++++++++----- src/player/mediaSession.ts | 28 ++++--- src/services/FeedRepository.ts | 1 - src/types/Headset.ts | 2 - src/ui/settings/PodNotesSettingsTab.test.ts | 64 +++++++++++++-- src/ui/settings/PodNotesSettingsTab.ts | 59 ++++++------- src/utility/playbackRate.ts | 4 +- tests/e2e/podnotes-runtime.test.ts | 41 +++++++++- tests/e2e/settings-tab.test.ts | 21 +++-- tests/mocks/obsidian.ts | 1 - 14 files changed, 224 insertions(+), 102 deletions(-) diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 750fb1ad..f9eb4a9d 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -66,10 +66,6 @@ function makePlugin( const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); -/** - * A released library as this version writes it back: with the headset mapping's defaults, - * and without the retired feed cache preference. - */ function savedByThisVersion(released: Record): Record { const data: Record = { ...structuredClone(released), diff --git a/src/persistence/codecUtils.ts b/src/persistence/codecUtils.ts index 3d60d33b..92fee6c1 100644 --- a/src/persistence/codecUtils.ts +++ b/src/persistence/codecUtils.ts @@ -166,7 +166,6 @@ export function mapRecord( return result; } -/** Whether `value` is one of the keys of `options`, and not one it inherits. */ export function isOption( options: Readonly>, value: unknown, diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index 332a3919..54621383 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -64,10 +64,6 @@ function settingsWithEveryEpisodeContainer(): IPodNotesSettings { }; } -/** - * How 2.21.1 read the feed cache preference, frozen from its settingsCodec: an - * absent or malformed field is caching on for six hours. Downgrades read this. - */ function decodeFeedCacheAs2211(value: unknown): { enabled: boolean; ttlHours: number } { const record = typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index e61181ce..a79e1f88 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -16,11 +16,6 @@ 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"]); -/** - * Settings PodNotes no longer has. They are not kept as unknown fields, so the next - * save drops them. Feeds are always cached, and earlier releases read a missing - * `feedCache` as caching on with a six-hour lifetime. - */ const RETIRED_SETTING_KEYS = ["feedCache"]; const KNOWN_TOP_LEVEL_KEYS = new Set([ diff --git a/src/player/mediaSession.test.ts b/src/player/mediaSession.test.ts index c532680a..987de643 100644 --- a/src/player/mediaSession.test.ts +++ b/src/player/mediaSession.test.ts @@ -338,6 +338,36 @@ describe("headset mapping", () => { }); 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"; @@ -385,6 +415,21 @@ describe("headset mapping", () => { }); 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 }); @@ -430,28 +475,32 @@ describe("headset mapping", () => { ]); }); - test("no chapters loaded is a no-op the next press retries, and no chapters URL never asks", async () => { - const chapters = vi - .fn<(url: string) => Promise>() - .mockRejectedValueOnce(new Error("offline")) - .mockResolvedValueOnce([]) - .mockResolvedValue(CHAPTERS); - const context = setup({ chapters }); - context.host.settings.headsetNextTrack = "nextChapter"; - playing(context, 30, 3600, WITH_CHAPTERS); - - for (const expected of [30, 30, 60]) { - context.session.act("nexttrack"); + 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(context.media().currentTime).toBe(expected); - } - - playing(context, 30, 3600, B); - context.session.act("nexttrack"); - await flush(); - expect(chapters).toHaveBeenCalledTimes(3); - expect(context.media().currentTime).toBe(30); - }); + expect(chapters).toHaveBeenCalledTimes(3); + expect(context.media().currentTime).toBe(30); + }, + ); test("chapters that arrive after unbinding move nothing", async () => { let resolve!: (chapters: Chapter[]) => void; diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts index 3d3e1d28..e9035245 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -10,18 +10,14 @@ import type { PlayerController, PlayerState, PlayerStatus } from "./PlayerContro interface MediaSessionHost { mediaSession: MediaSession | undefined; MediaMetadata: typeof MediaMetadata | undefined; - /** Read on each press, so a mapping changed in Settings or synced in needs no rebind. */ settings(): Pick; captureTimestamp(): void; skipBackward(): void; skipForward(): void; - /** The episode's chapters, in order, or none when they cannot be loaded. */ chapters(url: string): Promise; } -/** Previous chapter restarts the current one until this far into it. */ const CHAPTER_RESTART_S = 2; -/** Next chapter skips a start this close ahead, where a seek to a chapter can land. */ const CHAPTER_TOLERANCE_S = 0.25; const PLAYBACK_STATES: Record = { @@ -49,7 +45,6 @@ function attempt(call: () => void): boolean { const isPositive = (value: number) => Number.isFinite(value) && value > 0; -/** Where next (1) or previous (-1) chapter goes from `time`, or nowhere. */ export function chapterStart( chapters: readonly Chapter[], time: number, @@ -79,31 +74,35 @@ export function bindMediaSession( ): Unsubscriber { if (!host.mediaSession) return () => {}; const { mediaSession, MediaMetadata } = host; - /** One episode's chapters, loading or loaded. */ + 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; - // None loaded, as offline: the next press asks again. - if (loaded.length === 0 && chapters === asked) chapters = undefined; - const now = get(player.state); - if (unbound || !now.episode || getEpisodeKey(now.episode) !== getEpisodeKey(episode)) + 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); } - /** Plays the queue's first episode. The one switched away from goes last, so presses go through the queue. */ function playNextInQueue(): void { - const [next] = get(queue).episodes; 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)) { @@ -184,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/FeedRepository.ts b/src/services/FeedRepository.ts index c419778e..5ac8820c 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -385,7 +385,6 @@ 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"; -/** Refresh fetches a feed sooner. */ 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. diff --git a/src/types/Headset.ts b/src/types/Headset.ts index e414b30b..82ddc77a 100644 --- a/src/types/Headset.ts +++ b/src/types/Headset.ts @@ -1,11 +1,9 @@ -/** What the headset's and lock screen's previous-track button does, with its label in Settings. */ export const PREVIOUS_TRACK_ACTIONS = { captureTimestamp: "Capture timestamp", skipBackward: "Back by the skip length", previousChapter: "Previous chapter", } as const; -/** What the headset's and lock screen's next-track button does, with its label in Settings. */ export const NEXT_TRACK_ACTIONS = { skipForward: "Forward by the skip length", nextInQueue: "Next in queue", diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index d74f5604..e78414db 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -4,6 +4,7 @@ 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"; @@ -16,7 +17,6 @@ import { FakeDocument } from "../../../tests/mocks/fakeMedia"; const openTabs = new Set(); -/** Obsidian closing Settings: the rows' cleanups, then hide(). The mock tab does it. */ function close(tab: PodNotesSettingsTab): void { (tab as unknown as { close(): void }).close(); } @@ -31,7 +31,6 @@ afterEach(() => { pluginStore.set(undefined as never); }); -/** The tab of a plugin whose saves go through the real writer to a counted data.json. */ function open(saved: Partial = {}) { const settings = { ...structuredClone(DEFAULT_SETTINGS), ...structuredClone(saved) }; let disk: Record = { ...structuredClone(settings), schemaVersion: 2 }; @@ -39,7 +38,7 @@ function open(saved: Partial = {}) { const plugin = Object.create(PodNotes.prototype) as PodNotes; Object.assign(plugin, { settings, - api: {}, + api: new API(), feeds: readable(new Map()), credentials: { storeValues: vi.fn(() => ({})), @@ -91,12 +90,10 @@ function open(saved: Partial = {}) { control(name, "input, textarea"), select: (name: string) => control(name, "select"), toggle: (name: string) => control(name, "input[type=checkbox]"), - /** A change synced in from another device. */ sync: async (change: Partial) => { disk = { ...disk, ...structuredClone(change) }; await plugin.onExternalSettingsChange(); }, - /** data.json writes, once every save asked for has run. */ writes: async () => { await plugin.flushSaves(); return writes; @@ -203,6 +200,30 @@ describe("sections", () => { }); 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("typing saves nothing; the completed edit saves once and tells an open view", async () => { const { plugin, field, writes, disk } = open(); const paths: string[] = []; @@ -288,6 +309,39 @@ describe("text fields", () => { 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 keeps the saved value for an entry it cannot use", async () => { const { plugin, field, writes } = open(); const skip = field("Skip back (s)"); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index e857ad4f..36344010 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -31,7 +31,7 @@ import { sanitizeEpisodeListLimit, savedFeeds, } from "src/store/index"; -import { DEFAULT_SETTINGS } 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"; @@ -69,11 +69,11 @@ type ImportMutationResult = "failed" | "applied" | "complete"; interface TextField { read(): string; - /** Takes the completed text. A value it rejects leaves the setting as it was. */ write(value: string): void; placeholder?: string; - /** Multi-line fields under the row's name: "area" short, "template" tall. */ kind?: "number" | "area" | "template"; + min?: number; + max?: number; preview?(value: string, setting: Setting): void; } @@ -196,11 +196,6 @@ const CREDENTIAL_KIND_BY_REFERENCE: Record = deepgramSecretId: "deepgram", }; -/** - * Stack a Setting's control beneath its name, full width, as the template text - * areas need. Obsidian's Setting has no vertical variant and the plugin ships no - * stylesheet, so this sets the few inline styles. - */ function stackSettingVertically(setting: Setting): void { setting.settingEl.setCssStyles({ flexDirection: "column", @@ -219,9 +214,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; private settingsInteractionLockCount = 0; private settingsControlDisabledStates = new Map(); - /** Completes the edit of each field that has one. */ private readonly drafts = new Set<() => void>(); - /** Stops each rendered row that follows the settings. */ private readonly follows = new Set<() => void>(); private includeApiKeys = false; @@ -248,11 +241,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { render: (setting) => { let stop = () => {}; setting.addToggle((toggle) => { - toggle.onChange((on) => this.plugin.setAutoQueue(on)); - // The Queue page menu changes it too. stop = this.follow(setting, ({ autoQueue }) => toggle.setValue(autoQueue), ); + toggle.onChange((on) => this.plugin.setAutoQueue(on)); }); return stop; }, @@ -371,7 +363,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { ...this.text((settings) => settings.download, "path"), preview: (value, setting) => { pathPreview(DownloadPathTemplateEngine, ".mp3")(value, setting); - // Only these forms resolve: without one, every episode gets the same file. setting.setErrorMessage( /\{\{title(:[^}]*)?\}\}/i.test(value) ? null @@ -393,6 +384,8 @@ export class PodNotesSettingsTab extends PluginSettingTab { }), this.textRow("Latest episodes per podcast", { kind: "number", + min: 1, + max: MAX_EPISODE_LIST_LIMIT, read: () => `${sanitizeEpisodeListLimit(this.plugin.settings.episodeListLimit)}`, write: (value) => { @@ -434,7 +427,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { super.hide(); } - /** Tells open views and the rows that follow settings, then saves. */ private changed(): void { plugin.set(this.plugin); void this.plugin.saveSettings(); @@ -444,12 +436,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { for (const complete of this.drafts) complete(); } - /** - * Typing changes only the field. The edit applies once it completes: on change - * (blur or Enter), when its row or the tab closes, or when its window hides or - * unloads, which closes a Settings popout without its rows' cleanups. A field - * with no edit never writes, so it cannot undo a newer value. - */ private bindDraft( input: HTMLInputElement | HTMLTextAreaElement, apply: (value: string) => void, @@ -473,7 +459,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { return complete; } - /** Shows the settings in `setting` now and after each change, until the row ends. */ private follow(setting: Setting, show: (settings: IPodNotesSettings) => void): () => void { const { win } = setting.settingEl; const unsubscribe = plugin.subscribe(() => show(this.plugin.settings)); @@ -487,7 +472,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { return stop; } - /** A string setting of whichever settings object is live, with its default as placeholder. */ private text( section: (settings: IPodNotesSettings) => Record, key: K, @@ -518,6 +502,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { desc, render: (setting) => { let complete = () => {}; + let stopPreview = () => {}; const bind = (control: TextComponent | TextAreaComponent) => { control .setValue(field.read()) @@ -530,6 +515,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { 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); @@ -541,13 +532,20 @@ export class PodNotesSettingsTab extends PluginSettingTab { bind(area); }); } else { + if (!field.kind) stackSettingVertically(setting); setting.addText((text) => { - if (field.kind === "number") text.inputEl.type = "number"; + 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); }); } - field.preview?.(field.read(), setting); - return complete; + return () => { + complete(); + stopPreview(); + }; }, }; } @@ -576,7 +574,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } - /** The player's speeds, and a saved speed that is none of them. Nothing saves until a change. */ private speedRow(): SettingDefinitionRender { return { name: "Default speed", @@ -595,7 +592,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } - /** A diarization row, disabled while it does not apply. */ private whenDiarizing( row: SettingDefinitionRender, applies: (diarization: Diarization) => boolean, @@ -606,7 +602,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { const end = row.render(setting, settingGroup); const stop = this.follow(setting, ({ transcript }) => { const off = !applies(transcript.diarization); - setting.setDisabled(off); + setting.setDisabled(off || this.settingsInteractionLockCount > 0); // Obsidian dims only disabled action rows. setting.settingEl.setCssStyles({ opacity: off ? "0.5" : "" }); }); @@ -653,7 +649,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } - /** A file name, the default when left empty, with the default's extension added if missing. */ private exportRow( name: string, defaultName: string, @@ -698,7 +693,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { ); } - /** An episode for the path examples: the first loaded one, or a stand-in. */ private demoEpisode(): Episode { for (const { episodes } of get(this.plugin.feeds).values()) { if (episodes[0]) return episodes[0]; @@ -807,7 +801,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { imported: Partial, secrets: CredentialValues = {}, ): Promise { - // An edit not completed yet would otherwise apply over the import when its row re-renders. this.completeDrafts(); const unlockSettingsInteractions = this.lockSettingsInteractions(); try { @@ -897,9 +890,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) are applied via `merged` above. savedFeeds.set(merged.savedFeeds); playlists.set(merged.playlists); favorites.set(merged.favorites); @@ -963,6 +953,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { control.disabled = wasDisabled; } this.settingsControlDisabledStates.clear(); + plugin.set(this.plugin); }; } diff --git a/src/utility/playbackRate.ts b/src/utility/playbackRate.ts index f6fcfdd4..ca7c2a7a 100644 --- a/src/utility/playbackRate.ts +++ b/src/utility/playbackRate.ts @@ -2,8 +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; -/** The speeds the player's speed menu offers. */ -export const PLAYBACK_RATES = [0.75, 1, 1.25, 1.5, 1.75, 2]; +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); @@ -24,7 +23,6 @@ function clampPlaybackRate(value: number): number { return Math.min(PLAYBACK_RATE_MAX, Math.max(PLAYBACK_RATE_MIN, roundToHundredths(value))); } -// Hundredths, so the rate menu's quarter steps such as 1.25 survive. function roundToHundredths(value: number): number { return Math.round(value * 100) / 100; } diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index 889d70a8..6b854af2 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"; @@ -26,6 +26,7 @@ import { } from "./runtime"; import { closeSettings, openPodNotesSettings } from "./journey/actions"; +import { startFixtureServer } from "./journey/fixtures"; const getContext = createPodNotesE2EHarness("podnotes-runtime"); @@ -427,6 +428,43 @@ describe("PodNotes runtime", () => { } }); + 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); @@ -1114,7 +1152,6 @@ function episodeKey(episode: Episode): string { return `${episode.podcastName}::${episode.title}`; } -/** Picks the option labelled `label` in the dropdown of the setting named `settingName`. */ async function chooseSettingOption( obsidian: Parameters[0], settingName: string, diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index e68d68fc..5ec3e388 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -4,7 +4,6 @@ import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; import { closeSettings, openPodNotesSettings, - runCommand, showLibrary, showList, showPodNotes, @@ -25,10 +24,6 @@ const FEED: FixtureFeed = { episodes: [{ title: "Cached Episode", seconds: 6, pubDate: pubDate(1), guid: "cached" }], }; -/** - * The names of the PodNotes rows Settings search lists for `query`. Results update a - * moment after typing, so this waits for a list other than the previous query's. - */ async function searchResults( obsidian: ObsidianClient, query: string, @@ -53,7 +48,6 @@ async function searchResults( return last; } -/** Every row name Settings search lists right now, from any tab. */ 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)`, @@ -73,8 +67,21 @@ async function settingValue(obsidian: ObsidianClient, name: string): Promise { test("search finds a row of each section by name, and none of the removed rows", async () => { const { obsidian } = getContext(); - await runCommand(obsidian, "app:open-settings"); + 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[] = []; diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 6223cd67..e564c010 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -652,7 +652,6 @@ export class PluginSettingTab { hide(): void {} - /** Test only: Obsidian closing Settings, or opening another tab. */ close(): void { this.cleanUp(); this.containerEl.empty(); From 85044ec4f8fe9c87d7bcd12c1db1aa6f0174a0ee Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 05:27:37 +0200 Subject: [PATCH 09/10] test(e2e): reproduce focused settings loss on quit and reload --- tests/e2e/focused-settings-save.test.ts | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/e2e/focused-settings-save.test.ts 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); + }, +); From 23217bb12d8691d368a1ce602c80aca0dc7a20d0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 05:49:12 +0200 Subject: [PATCH 10/10] fix(settings): persist focused edits after a short typing pause --- src/ui/settings/PodNotesSettingsTab.test.ts | 139 ++++++++++++++++++-- src/ui/settings/PodNotesSettingsTab.ts | 43 ++++-- tests/e2e/settings-tab.test.ts | 63 +++++++-- 3 files changed, 213 insertions(+), 32 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index e78414db..f86eaf01 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { derived, readable } from "svelte/store"; +import { derived, get, readable } from "svelte/store"; import { afterEach, describe, expect, test, vi } from "vitest"; import * as obsidian from "obsidian"; @@ -9,7 +9,7 @@ import PodNotes from "src/main"; import { decodePodNotesData } from "src/persistence/podNotesData"; import { PlayerController } from "src/player/PlayerController"; import { FeedUrlRepository } from "src/services/FeedUrlRepository"; -import { plugin as pluginStore } from "src/store"; +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"; @@ -27,10 +27,17 @@ afterEach(() => { 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 }; @@ -95,11 +102,11 @@ function open(saved: Partial = {}) { await plugin.onExternalSettingsChange(); }, writes: async () => { - await plugin.flushSaves(); + await settled(); return writes; }, disk: async () => { - await plugin.flushSaves(); + await settled(); return disk as unknown as IPodNotesSettings; }, }; @@ -125,6 +132,9 @@ function enter(field: HTMLInputElement | HTMLTextAreaElement, text: string): voi 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")); @@ -224,7 +234,8 @@ describe("text fields", () => { expect(preview()).toBe("00:01:32"); }); - test("typing saves nothing; the completed edit saves once and tells an open view", async () => { + 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( @@ -232,15 +243,96 @@ describe("text fields", () => { ); const path = field("Episode note path"); - retype(path, "UI3/My podcast note.md"); - expect(await writes()).toBe(0); - expect(plugin.settings.note.path).toBe(DEFAULT_SETTINGS.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((await disk()).note.path).toBe("UI3/My podcast note.md"); - stop(); - expect(paths).toEqual([DEFAULT_SETTINGS.note.path, "UI3/My podcast note.md"]); + 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 () => { @@ -342,23 +434,42 @@ describe("text fields", () => { expect(select("Diarization provider").disabled).toBe(false); }); - test("a number field keeps the saved value for an entry it cannot use", async () => { + 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); - complete(skip); + vi.advanceTimersByTime(500); + expect([skip.value, plugin.settings.skipBackwardLength]).toEqual([entry, 15]); + pressEnter(skip); + expect(skip.value).toBe("15"); } - 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"); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 36344010..78d81b35 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -82,6 +82,8 @@ const DIARIZATION_PROVIDERS = { deepgram: "Deepgram", }; +const TYPING_SAVE_DELAY_MS = 500; + /** Search finds rows, not section headings. */ const HEADSET_ALIASES = ["headset", "lock screen", "media keys"]; @@ -438,17 +440,30 @@ export class PodNotesSettingsTab extends PluginSettingTab { private bindDraft( input: HTMLInputElement | HTMLTextAreaElement, - apply: (value: string) => void, + 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); - apply(input.value); + 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); @@ -456,6 +471,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { doc.addEventListener("visibilitychange", hidden); }); input.addEventListener("change", complete); + input.addEventListener("blur", complete); return complete; } @@ -508,13 +524,18 @@ export class PodNotesSettingsTab extends PluginSettingTab { .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); - if (field.read() !== before) this.changed(); - control.setValue(field.read()); - field.preview?.(field.read(), 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, () => @@ -540,6 +561,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { 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 () => { diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 5ec3e388..b2d7283e 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -119,31 +119,76 @@ describe("PodNotes settings tab", () => { } }); - test("a text edit writes data.json once, when it completes", async () => { + 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"); - await typeInto(obsidian, path, "", { replace: true }); const typing = await recordWrites(obsidian); + await typeInto(obsidian, path, "", { replace: true }); for (const character of typed) await typeText(obsidian, character); - await obsidian.sleep(1_000); - expect(await typing.stop(), "writes while typing").toMatchObject({ attempted: 0 }); - - const completing = await recordWrites(obsidian); - await pressKey(obsidian, "Tab"); await plugin.waitForData((data) => data.note?.path === typed, { timeoutMs: 5_000, intervalMs: 100, }); - await obsidian.sleep(500); - expect(await completing.stop(), "writes for the completed edit").toMatchObject({ + 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 () => {