From 3f74a8a3e3aca01584860829581258e8f0c8da83 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:44:44 +0200 Subject: [PATCH 01/56] refactor(transcription): let the promise settle once in waitForLifecycle A promise settles once, so the settled flag and the aborted re-checks were dead: the entry assertActive rules out an earlier abort, and the abort listener runs before any later settle. gzip 102900 -> 102866 (-34) --- src/services/TranscriptionService.ts | 37 +++++----------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 521433d0..b7779a99 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -501,39 +501,14 @@ export class TranscriptionService { const signal = this.lifetimeAbortController.signal; return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => signal.removeEventListener("abort", onAbort); - const onAbort = () => { - if (settled) return; - settled = true; - cleanup(); + const onAbort = () => reject(toError(this.getAbortReason(), "PodNotes transcription was aborted.")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - void operation.then( - (value) => { - if (settled) return; - if (signal.aborted) { - onAbort(); - return; - } - settled = true; - cleanup(); - resolve(value); - }, - (error) => { - if (settled) return; - if (signal.aborted) { - onAbort(); - return; - } - settled = true; - cleanup(); - reject(toError(error, "PodNotes transcription failed.")); - }, - ); - if (signal.aborted) onAbort(); + void operation + .then(resolve, (error: unknown) => + reject(toError(error, "PodNotes transcription failed.")), + ) + .finally(() => signal.removeEventListener("abort", onAbort)); }); } From 71af6a9da61f1016eb9d7306330f0193d1bd263e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:45:10 +0200 Subject: [PATCH 02/56] refactor(transcription): make the retry and concurrency limits module constants As instance fields their long names stayed in the bundle at every use; as constants the minifier inlines the numbers. gzip 102866 -> 102793 (-73) --- src/services/TranscriptionService.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index b7779a99..099fb05f 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -59,6 +59,10 @@ function chunkErrorPlaceholder(index: number): string { } const CHUNK_ERROR_PLACEHOLDER_PATTERN = /\[Error transcribing chunk \d+\]/g; +const MAX_RETRIES = 3; +const MAX_CONCURRENT_TRANSCRIPTIONS = 2; +const MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3; + interface TranscriptionJob { readonly episode: Episode; readonly source: @@ -71,9 +75,6 @@ export class TranscriptionService { private disposed = false; private readonly lifetimeAbortController = new AbortController(); private readonly activeNotices = new Set>(); - private MAX_RETRIES = 3; - private readonly MAX_CONCURRENT_TRANSCRIPTIONS = 2; - private readonly MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3; private pendingJobs: TranscriptionJob[] = []; private activeTranscriptions = new Set(); @@ -179,7 +180,7 @@ export class TranscriptionService { return; } while ( - this.activeTranscriptions.size < this.MAX_CONCURRENT_TRANSCRIPTIONS && + this.activeTranscriptions.size < MAX_CONCURRENT_TRANSCRIPTIONS && this.pendingJobs.length > 0 ) { const job = this.pendingJobs.shift(); @@ -395,7 +396,7 @@ export class TranscriptionService { const segments = await diarizeWithOpenAI({ apiKey: this.getApiKey(), chunkFiles, - maxRetries: this.MAX_RETRIES, + maxRetries: MAX_RETRIES, onProgress: updateNotice, signal: this.lifetimeAbortController.signal, }); @@ -430,7 +431,7 @@ export class TranscriptionService { const file = files[index]; let retries = 0; - while (retries < this.MAX_RETRIES) { + while (retries < MAX_RETRIES) { this.assertActive(); try { const result = await createTranscription( @@ -450,9 +451,9 @@ export class TranscriptionService { } catch (error) { this.assertActive(); retries++; - if (retries >= this.MAX_RETRIES) { + if (retries >= MAX_RETRIES) { console.error( - `Failed to transcribe chunk ${index} after ${this.MAX_RETRIES} attempts:`, + `Failed to transcribe chunk ${index} after ${MAX_RETRIES} attempts:`, error, ); transcriptions[index] = chunkErrorPlaceholder(index); @@ -467,7 +468,7 @@ export class TranscriptionService { } }; - const workerCount = Math.min(this.MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, files.length); + const workerCount = Math.min(MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, files.length); const workers = Array.from({ length: workerCount }, () => worker()); await Promise.all(workers); From 0b9c75240a215fdf359db1abafaad9b4f56c8071 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:46:28 +0200 Subject: [PATCH 03/56] refactor(parser): make the parser's stateless helpers module functions None of them reads instance state. As private methods their names stayed in the bundle at every this. call; as functions the minifier shortens or inlines them. FeedDocumentParser keeps parseEpisodes, its one public method. gzip 102793 -> 102710 (-83) --- src/parser/feedDocumentParser.ts | 277 +++++++++++++++---------------- 1 file changed, 136 insertions(+), 141 deletions(-) diff --git a/src/parser/feedDocumentParser.ts b/src/parser/feedDocumentParser.ts index ec1ea53c..2ac603a0 100644 --- a/src/parser/feedDocumentParser.ts +++ b/src/parser/feedDocumentParser.ts @@ -70,163 +70,158 @@ function isParserErrorDocument(document: Document): boolean { /** Pure XML parsing boundary with no network or Obsidian dependency. */ export default class FeedDocumentParser { parseEpisodes(xml: string): ParsedFeedEpisodes { - const document = this.parseXml(xml); + const document = parseXml(xml); return { - feed: this.extractFeed(document), - episodes: this.parsePage(document), + feed: extractFeed(document), + episodes: parsePage(document), }; } +} - private parseXml(xml: string): Document { - const document = new DOMParser().parseFromString(xml, "text/xml"); - if (isParserErrorDocument(document)) throw new Error("Invalid RSS feed"); - return document; - } +function parseXml(xml: string): Document { + const document = new DOMParser().parseFromString(xml, "text/xml"); + if (isParserErrorDocument(document)) throw new Error("Invalid RSS feed"); + return document; +} - private extractFeed(body: Document): ParsedFeedDocument { - const titleEl = body.querySelector("title"); - - // A feed must have a title. is intentionally not required: it is the - // human website, but many valid feeds omit it or only include an Atom self link. - if (!titleEl) throw new Error("Invalid RSS feed"); - - const channel = body.querySelector("channel") ?? body; - const imageEl = this.findChannelImageElement(channel); - const artworkUrl = - imageEl?.getAttribute("href") || imageEl?.querySelector("url")?.textContent || ""; - const siteUrl = this.findFeedLink(channel); - const description = this.findDirectChildText(channel, ["description", "itunes:summary"]); - const author = this.findDirectChildText(channel, [ - "itunes:author", - "author", - "managingEditor", - ]); - const guid = this.findDirectChildText(channel, ["podcast:guid", "id"]); +function extractFeed(body: Document): ParsedFeedDocument { + const titleEl = body.querySelector("title"); + + // A feed must have a title. is intentionally not required: it is the + // human website, but many valid feeds omit it or only include an Atom self link. + if (!titleEl) throw new Error("Invalid RSS feed"); + + const channel = body.querySelector("channel") ?? body; + const imageEl = findChannelImageElement(channel); + const artworkUrl = + imageEl?.getAttribute("href") || imageEl?.querySelector("url")?.textContent || ""; + const siteUrl = findFeedLink(channel); + const description = findDirectChildText(channel, ["description", "itunes:summary"]); + const author = findDirectChildText(channel, ["itunes:author", "author", "managingEditor"]); + const guid = findDirectChildText(channel, ["podcast:guid", "id"]); + + return { + title: titleEl.textContent || "", + ...(artworkUrl ? { artworkUrl } : {}), + ...(siteUrl ? { siteUrl } : {}), + ...(description ? { description } : {}), + ...(author ? { author } : {}), + ...(guid ? { guid } : {}), + }; +} - return { - title: titleEl.textContent || "", - ...(artworkUrl ? { artworkUrl } : {}), - ...(siteUrl ? { siteUrl } : {}), - ...(description ? { description } : {}), - ...(author ? { author } : {}), - ...(guid ? { guid } : {}), - }; - } +function findImageElement(document: Document | Element): Element | null { + const itunesImage = document.getElementsByTagName("itunes:image")[0]; + return itunesImage ?? document.querySelector("image"); +} - private findImageElement(document: Document | Element): Element | null { - const itunesImage = document.getElementsByTagName("itunes:image")[0]; - return itunesImage ?? document.querySelector("image"); - } +/** A nested item image must never become channel artwork. */ +function findChannelImageElement(scope: Element | Document): Element | null { + const directChildren = Array.from(scope.children); + const itunesImage = directChildren.find( + (element) => element.tagName.toLowerCase() === "itunes:image", + ); + if (itunesImage) return itunesImage; + return directChildren.find((element) => element.tagName.toLowerCase() === "image") ?? null; +} + +/** Resolve only the human website link, never an Atom self or hub endpoint. */ +function findFeedLink(scope: Document | Element): string { + const directLinks = Array.from(scope.children).filter((element) => { + const tag = element.tagName.toLowerCase(); + return tag === "link" || tag === "atom:link"; + }); - /** A nested item image must never become channel artwork. */ - private findChannelImageElement(scope: Element | Document): Element | null { - const directChildren = Array.from(scope.children); - const itunesImage = directChildren.find( - (element) => element.tagName.toLowerCase() === "itunes:image", - ); - if (itunesImage) return itunesImage; - return directChildren.find((element) => element.tagName.toLowerCase() === "image") ?? null; + for (const link of directLinks) { + const text = link.textContent?.trim(); + if (text && /^https?:\/\//i.test(text)) return text; } - /** Resolve only the human website link, never an Atom self or hub endpoint. */ - private findFeedLink(scope: Document | Element): string { - const directLinks = Array.from(scope.children).filter((element) => { - const tag = element.tagName.toLowerCase(); - return tag === "link" || tag === "atom:link"; - }); + for (const link of directLinks) { + const relation = link.getAttribute("rel"); + if (relation && relation !== "alternate") continue; + const href = link.getAttribute("href")?.trim(); + if (href && /^https?:\/\//i.test(href)) return href; + } - for (const link of directLinks) { - const text = link.textContent?.trim(); - if (text && /^https?:\/\//i.test(text)) return text; - } + return ""; +} - for (const link of directLinks) { - const relation = link.getAttribute("rel"); - if (relation && relation !== "alternate") continue; - const href = link.getAttribute("href")?.trim(); - if (href && /^https?:\/\//i.test(href)) return href; +/** Read direct-child metadata in explicit tag-priority order. */ +function findDirectChildText(scope: Document | Element, tagNames: string[]): string { + const children = Array.from(scope.children); + for (const tag of tagNames) { + const wanted = tag.toLowerCase(); + for (const child of children) { + if (child.tagName.toLowerCase() !== wanted) continue; + const text = child.textContent?.trim() ?? ""; + if (text) return text; } - - return ""; } + return ""; +} - /** Read direct-child metadata in explicit tag-priority order. */ - private findDirectChildText(scope: Document | Element, tagNames: string[]): string { - const children = Array.from(scope.children); - for (const tag of tagNames) { - const wanted = tag.toLowerCase(); - for (const child of children) { - if (child.tagName.toLowerCase() !== wanted) continue; - const text = child.textContent?.trim() ?? ""; - if (text) return text; - } +function findTranscripts(item: Element): TranscriptLink[] { + return Array.from(item.children).flatMap((child) => { + const url = child.getAttribute("url"); + if (child.tagName.toLowerCase() !== "podcast:transcript" || !url) return []; + const transcript: TranscriptLink = { url }; + for (const name of ["type", "language", "rel"] as const) { + const value = child.getAttribute(name); + if (value) transcript[name] = value; } - return ""; - } - - private findTranscripts(item: Element): TranscriptLink[] { - return Array.from(item.children).flatMap((child) => { - const url = child.getAttribute("url"); - if (child.tagName.toLowerCase() !== "podcast:transcript" || !url) return []; - const transcript: TranscriptLink = { url }; - for (const name of ["type", "language", "rel"] as const) { - const value = child.getAttribute(name); - if (value) transcript[name] = value; - } - return [transcript]; - }); - } - - private parsePage(page: Document): ParsedEpisodeDocument[] { - return Array.from(page.querySelectorAll("item")) - .map((item) => this.parseItem(item)) - .filter((episode): episode is ParsedEpisodeDocument => episode !== null); - } + return [transcript]; + }); +} - private parseItem(item: Element): ParsedEpisodeDocument | null { - const titleEl = item.querySelector("title"); - const streamUrlEl = item.querySelector("enclosure"); - if (!titleEl || !streamUrlEl) return null; - - const title = titleEl.textContent || ""; - const streamUrl = streamUrlEl.getAttribute("url") || ""; - const itemLink = item.querySelector("link")?.textContent || ""; - const description = item.querySelector("description")?.textContent || ""; - const content = item.querySelector("*|encoded")?.textContent || ""; - const episodeDate = decodeDate(item.querySelector("pubDate")?.textContent); - const artworkUrl = this.findImageElement(item)?.getAttribute("href") || ""; - const itunesTitle = item.getElementsByTagName("itunes:title")[0]?.textContent || ""; - const episodeNumber = parseEpisodeNumber( - item.getElementsByTagName("itunes:episode")[0]?.textContent, - title, - ); - const duration = parseDurationToSeconds( - item.getElementsByTagName("itunes:duration")[0]?.textContent, - ); - const chaptersUrl = - item.getElementsByTagName("podcast:chapters")[0]?.getAttribute("url") || ""; - const guid = this.findDirectChildText(item, ["guid"]); - const transcripts = this.findTranscripts(item); - const enclosureType = streamUrlEl.getAttribute("type"); +function parsePage(page: Document): ParsedEpisodeDocument[] { + return Array.from(page.querySelectorAll("item")) + .map((item) => parseItem(item)) + .filter((episode): episode is ParsedEpisodeDocument => episode !== null); +} - return { - title, - streamUrl, - ...(itemLink ? { itemLink } : {}), - description, - content, - ...(artworkUrl ? { artworkUrl } : {}), - ...(episodeDate ? { episodeDate } : {}), - ...(itunesTitle ? { itunesTitle } : {}), - ...(episodeNumber === undefined ? {} : { episodeNumber }), - ...(duration === undefined ? {} : { duration }), - ...(chaptersUrl ? { chaptersUrl } : {}), - ...(guid ? { guid } : {}), - ...(transcripts.length ? { transcripts } : {}), - mediaType: - getMediaTypeFromContentType(enclosureType) ?? - getUnambiguousMediaTypeFromPath(streamUrl) ?? - "audio", - }; - } +function parseItem(item: Element): ParsedEpisodeDocument | null { + const titleEl = item.querySelector("title"); + const streamUrlEl = item.querySelector("enclosure"); + if (!titleEl || !streamUrlEl) return null; + + const title = titleEl.textContent || ""; + const streamUrl = streamUrlEl.getAttribute("url") || ""; + const itemLink = item.querySelector("link")?.textContent || ""; + const description = item.querySelector("description")?.textContent || ""; + const content = item.querySelector("*|encoded")?.textContent || ""; + const episodeDate = decodeDate(item.querySelector("pubDate")?.textContent); + const artworkUrl = findImageElement(item)?.getAttribute("href") || ""; + const itunesTitle = item.getElementsByTagName("itunes:title")[0]?.textContent || ""; + const episodeNumber = parseEpisodeNumber( + item.getElementsByTagName("itunes:episode")[0]?.textContent, + title, + ); + const duration = parseDurationToSeconds( + item.getElementsByTagName("itunes:duration")[0]?.textContent, + ); + const chaptersUrl = item.getElementsByTagName("podcast:chapters")[0]?.getAttribute("url") || ""; + const guid = findDirectChildText(item, ["guid"]); + const transcripts = findTranscripts(item); + const enclosureType = streamUrlEl.getAttribute("type"); + + return { + title, + streamUrl, + ...(itemLink ? { itemLink } : {}), + description, + content, + ...(artworkUrl ? { artworkUrl } : {}), + ...(episodeDate ? { episodeDate } : {}), + ...(itunesTitle ? { itunesTitle } : {}), + ...(episodeNumber === undefined ? {} : { episodeNumber }), + ...(duration === undefined ? {} : { duration }), + ...(chaptersUrl ? { chaptersUrl } : {}), + ...(guid ? { guid } : {}), + ...(transcripts.length ? { transcripts } : {}), + mediaType: + getMediaTypeFromContentType(enclosureType) ?? + getUnambiguousMediaTypeFromPath(streamUrl) ?? + "audio", + }; } From 4cc96d44934a37dd8ac25cc47cc10bc0e619a183 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:47:13 +0200 Subject: [PATCH 04/56] refactor(player): make the saved-progress helpers module functions savedProgress, resumeTime and startDuration read only the played-episodes store, not the controller. As functions the minifier shortens their names at each call. gzip 102710 -> 102675 (-35) --- src/player/PlayerController.ts | 52 +++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 52a7e3d9..a7774894 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -83,6 +83,23 @@ const isTime = (value: number) => Number.isFinite(value) && value >= 0; const isOptionalTime = (value: number | undefined) => value === undefined || isTime(value); const finiteOr0 = (value: number) => (Number.isFinite(value) ? value : 0); +function savedProgress(episode: Episode): PlayedEpisode | undefined { + return getPlayedEpisode(get(playedEpisodes), episode); +} + +function resumeTime(episode: Episode): number { + const saved = savedProgress(episode); + if (!saved || saved.finished || (saved.duration > 0 && saved.time >= saved.duration)) { + return 0; + } + return saved.time; +} + +/** The length shown until the metadata loads. */ +function startDuration(episode: Episode): number { + return savedProgress(episode)?.duration || episode.duration || 0; +} + /** * Owns the one media element, in the main window, from plugin load to unload. * Views render `state` and call the intents. @@ -128,7 +145,7 @@ export class PlayerController { this.noteStoppedMove(); const { status, episode: shown, currentTime } = this.snapshot; if (status === "paused" && shown && !this.moved) { - const time = this.resumeTime(shown); + const time = resumeTime(shown); if (time !== currentTime) this.moveTo(time); return; } @@ -138,8 +155,8 @@ export class PlayerController { status: "restored", episode, mediaType: getEpisodeMediaType(episode), - currentTime: this.moved ? currentTime : this.resumeTime(episode), - duration: this.startDuration(episode), + currentTime: this.moved ? currentTime : resumeTime(episode), + duration: startDuration(episode), }); } @@ -201,7 +218,7 @@ export class PlayerController { } session.segmentEnd = endTime ?? null; session.preview = endTime !== undefined; - const at = time ?? (status === "ended" ? this.resumeTime(episode) : undefined); + const at = time ?? (status === "ended" ? resumeTime(episode) : undefined); if (time !== undefined) this.moved = true; if (at === undefined || this.moveTo(at)) this.start(); return true; @@ -216,7 +233,7 @@ export class PlayerController { ) { queue.prepend(outgoing); } - const resumeAt = sameEpisode ? this.restartTime(episode) : this.resumeTime(episode); + const resumeAt = sameEpisode ? this.restartTime(episode) : resumeTime(episode); this.load(episode, source, time ?? resumeAt, endTime); this.moved = time !== undefined || (sameEpisode && this.moved); return true; @@ -250,7 +267,7 @@ export class PlayerController { if (!this.session) { const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); this.load(episode, source, this.restartTime(episode)); - } else if (status !== "ended" || this.moveTo(this.resumeTime(episode))) { + } else if (status !== "ended" || this.moveTo(resumeTime(episode))) { this.start(); } } @@ -351,7 +368,7 @@ export class PlayerController { private load(episode: Episode, source: PlaybackSource, time: number, endTime?: number): void { this.session = null; - const duration = this.startDuration(episode); + const duration = startDuration(episode); const shown = this.snapshot.episode; // Reloading the shown episode, as on its first play after a restart, keeps the user's speed. const playbackRate = @@ -600,7 +617,7 @@ export class PlayerController { this.load( next, selectSource(next, get(downloadedEpisodes), this.plugin.app.vault), - this.resumeTime(next), + resumeTime(next), ); } @@ -635,10 +652,6 @@ export class PlayerController { this.moved = false; } - private savedProgress(episode: Episode): PlayedEpisode | undefined { - return getPlayedEpisode(get(playedEpisodes), episode); - } - /** * Where the shown episode starts on a new load: where it is while a session holds it * or after the user moved it, else at its saved progress, which a sync may have changed. @@ -646,20 +659,7 @@ export class PlayerController { private restartTime(episode: Episode): number { const { status, currentTime } = this.snapshot; const live = status !== "ended" && (!!this.session || this.moved); - return live ? currentTime : this.resumeTime(episode); - } - - private resumeTime(episode: Episode): number { - const saved = this.savedProgress(episode); - if (!saved || saved.finished || (saved.duration > 0 && saved.time >= saved.duration)) { - return 0; - } - return saved.time; - } - - /** The length shown until the metadata loads. */ - private startDuration(episode: Episode): number { - return this.savedProgress(episode)?.duration || episode.duration || 0; + return live ? currentTime : resumeTime(episode); } private setState(patch: Partial): void { From 80e7a5826e8b94af7100e7198c083d8612919f46 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:47:37 +0200 Subject: [PATCH 05/56] refactor(player): use isPlaying for the playing-or-loading checks Seven checks spelled out the two statuses that isPlaying already names. gzip 102675 -> 102658 (-17) --- src/player/PlayerController.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index a7774894..98de38d3 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -262,7 +262,7 @@ export class PlayerController { const { status, episode } = this.snapshot; if (!episode) return; this.requests++; - if (status === "playing" || status === "loading") return; + if (isPlaying(status)) return; this.endPreview(); if (!this.session) { const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); @@ -276,14 +276,14 @@ export class PlayerController { if (this.disposed) return; this.requests++; const { status } = this.snapshot; - if (status !== "playing" && status !== "loading") return; + if (!isPlaying(status)) return; this.el.pause(); this.settlePaused(); } toggle(): void { const { status } = this.snapshot; - if (status === "playing" || status === "loading") this.pause(); + if (isPlaying(status)) this.pause(); else this.resume(); } @@ -461,7 +461,7 @@ export class PlayerController { /** play() unpauses the element at once, so the next intent can act on the new status. */ private start(): void { const { status } = this.snapshot; - if (status === "playing" || status === "loading") return; + if (isPlaying(status)) return; this.setState({ status: this.holdsMedia() ? "playing" : "loading" }); this.playElement(); } @@ -474,7 +474,7 @@ export class PlayerController { // A play that has since started, such as the next load's, keeps playing. const { status } = this.snapshot; if (this.disposed || !this.el.paused) return; - if (status === "loading" || status === "playing") this.settlePaused(); + if (isPlaying(status)) this.settlePaused(); }); } @@ -554,14 +554,14 @@ export class PlayerController { const { status } = this.snapshot; if (!this.holdsMedia() || this.el.paused) return; // A play the player did not start, such as from a media key, is the user's. - if (status !== "playing" && status !== "loading") this.requests++; + if (!isPlaying(status)) this.requests++; this.setState({ status: "playing" }); } /** A reset pauses without this event (see onEmptied), so a pause while loading is a real one. */ private onPause(): void { const { status } = this.snapshot; - if ((status !== "playing" && status !== "loading") || !this.el.paused) return; + if (!isPlaying(status) || !this.el.paused) return; // A pause the player did not make and no natural end made, such as from a media key. if (!this.el.ended) this.requests++; this.settlePaused(); From fadb9e42469efd90b6d6fa4b086844d8811821c1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:48:00 +0200 Subject: [PATCH 06/56] refactor(player): compare episodes with isSameEpisode gzip 102658 -> 102653 (-5) --- src/player/PlayerController.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 98de38d3..53b5a043 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -6,7 +6,7 @@ import type { Episode, EpisodeMediaType } from "src/types/Episode"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; import { createMediaUrlObjectFromFilePath } from "src/utility/createMediaUrlObjectFromFilePath"; -import { getEpisodeKey, isSameStoredEpisode } from "src/utility/episodeKey"; +import { getEpisodeKey, isSameEpisode, isSameStoredEpisode } from "src/utility/episodeKey"; import { getPlayedEpisode } from "src/utility/episodeStatus"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { normalizePlaybackRate } from "src/utility/playbackRate"; @@ -150,7 +150,7 @@ export class PlayerController { return; } if (!episode || (status !== "empty" && status !== "restored")) return; - this.moved &&= !!shown && getEpisodeKey(shown) === getEpisodeKey(episode); + this.moved &&= !!shown && isSameEpisode(shown, episode); this.setState({ status: "restored", episode, @@ -197,7 +197,7 @@ export class PlayerController { const source = selectSource(episode, get(downloadedEpisodes), this.plugin.app.vault); this.noteStoppedMove(); const { status, episode: outgoing, mediaType } = this.snapshot; - const sameEpisode = !!outgoing && getEpisodeKey(outgoing) === getEpisodeKey(episode); + const sameEpisode = !!outgoing && isSameEpisode(outgoing, episode); this.select(episode); const session = this.session; @@ -372,7 +372,7 @@ export class PlayerController { const shown = this.snapshot.episode; // Reloading the shown episode, as on its first play after a restart, keeps the user's speed. const playbackRate = - shown && getEpisodeKey(shown) === getEpisodeKey(episode) + shown && isSameEpisode(shown, episode) ? this.snapshot.playbackRate : normalizePlaybackRate(this.plugin.settings.defaultPlaybackRate); if (!source.src) { From 264c71d6115fc9d912cd99dff06c90fed610f278 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:48:43 +0200 Subject: [PATCH 07/56] refactor(download): drop the media type hint that is always audio getEpisodeAudioBuffer reaches readVaultAudio and getAudioContainerHint only after it has checked that the episode, and a reused download, are audio, so the hint branches never saw another value. gzip 102653 -> 102626 (-27) --- src/downloadEpisode.ts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index 00752341..ae6cb1dd 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -594,7 +594,7 @@ export async function getEpisodeAudioBuffer( if (episodeMediaType !== "audio") { throw new Error("Transcription supports audio episodes only."); } - const audioContainerHint = getAudioContainerHint(episode, episodeMediaType); + const audioContainerHint = getAudioContainerHint(episode); if (isLocalFile(episode)) { const localFilePath = resolveLocalEpisodeFilePath(episode); @@ -604,7 +604,7 @@ export async function getEpisodeAudioBuffer( ); } - return readVaultAudio(localFilePath, episodeMediaType); + return readVaultAudio(localFilePath); } // Reuse a previously downloaded file only when the registry entry is the SAME @@ -626,7 +626,7 @@ export async function getEpisodeAudioBuffer( const existingFile = app.vault.getAbstractFileByPath(registered.filePath); if (existingFile instanceof TFile) { - return readVaultAudio(registered.filePath, registeredMediaType); + return readVaultAudio(registered.filePath); } } @@ -649,11 +649,7 @@ export async function getEpisodeAudioBuffer( } } -function getAudioContainerHint( - episode: Episode, - episodeMediaType: EpisodeMediaType, -): EpisodeMediaType | undefined { - if (episodeMediaType !== "audio") return undefined; +function getAudioContainerHint(episode: Episode): EpisodeMediaType | undefined { if (episode.mediaType === "audio") return "audio"; return isAudioContainerExtension(getUrlExtension(episode.streamUrl)) ? "audio" : undefined; @@ -661,7 +657,6 @@ function getAudioContainerHint( async function readVaultAudio( filePath: string, - mediaTypeHint?: EpisodeMediaType, ): Promise<{ buffer: ArrayBuffer; extension: string; basename: string }> { const { app } = get(plugin); const file = app.vault.getAbstractFileByPath(filePath); @@ -670,10 +665,7 @@ async function readVaultAudio( } const fileExtension = file.extension || getUrlExtension(file.path || filePath) || ""; - const explicitAudioContainer = - mediaTypeHint === "audio" && - (fileExtension.toLowerCase() === "mp4" || fileExtension.toLowerCase() === "webm"); - const mediaType = explicitAudioContainer + const mediaType = isAudioContainerExtension(fileExtension) ? "audio" : (getMediaTypeFromExtension(fileExtension) ?? getMediaTypeFromPath(file.path || filePath)); if (mediaType !== "audio") { @@ -683,10 +675,7 @@ async function readVaultAudio( const buffer = await app.vault.readBinary(file); return { buffer, - extension: - mediaTypeHint === "audio" && fileExtension.toLowerCase() === "mp4" - ? "m4a" - : fileExtension, + extension: fileExtension.toLowerCase() === "mp4" ? "m4a" : fileExtension, basename: file.basename || getFileBasename(file.path || filePath), }; } From b094c2d176e499dd2e99c4802b9f4bf461fb7f7e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:49:05 +0200 Subject: [PATCH 08/56] refactor(download): drop the unread byteLength of a whole-file download gzip 102626 -> 102612 (-14) --- src/downloadEpisode.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index ae6cb1dd..14d9c692 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -50,7 +50,6 @@ interface DownloadedFile { */ data: ArrayBuffer; contentType: string; - byteLength: number; } function downloadRequestError(error: unknown): Error { @@ -74,10 +73,9 @@ async function downloadFile(url: string): Promise { throw downloadRequestError(error); } - const data = response.arrayBuffer; const contentType = response.headers["content-type"] ?? response.headers["Content-Type"] ?? ""; - return { data, contentType, byteLength: data.byteLength }; + return { data: response.arrayBuffer, contentType }; } function parentFolderPath(filePath: string): string { From ec0e383e4d54a9f558152635422c217894ad5d3c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:49:23 +0200 Subject: [PATCH 09/56] refactor(download): drop the download notice's never-passed empty flag Every update replaces the notice body. gzip 102612 -> 102606 (-6) --- src/downloadEpisode.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index 14d9c692..eb396dbb 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -289,8 +289,8 @@ function createNoticeDoc(title: string) { return { doc, - update: (updateFn: (bodyEl: HTMLDivElement) => void, empty = true) => { - if (empty) bodyEl.empty(); + update: (updateFn: (bodyEl: HTMLDivElement) => void) => { + bodyEl.empty(); updateFn(bodyEl); }, }; From 731aee80f1a997b998971b272110aa3518414802 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:51:21 +0200 Subject: [PATCH 10/56] refactor(transcription): make the queue's episode key a module function It reads no service state. gzip 102606 -> 102590 (-16) --- src/services/TranscriptionService.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 099fb05f..6e9a9412 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -63,6 +63,10 @@ const MAX_RETRIES = 3; const MAX_CONCURRENT_TRANSCRIPTIONS = 2; const MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3; +function queueKey(episode: Episode): string { + return `${episode.podcastName}:${episode.title}`; +} + interface TranscriptionJob { readonly episode: Episode; readonly source: @@ -158,11 +162,10 @@ export class TranscriptionService { return; } - const episodeKey = this.getEpisodeKey(job.episode); + const episodeKey = queueKey(job.episode); const isAlreadyQueued = - this.pendingJobs.some( - (pending) => this.getEpisodeKey(pending.episode) === episodeKey, - ) || this.activeTranscriptions.has(episodeKey); + this.pendingJobs.some((pending) => queueKey(pending.episode) === episodeKey) || + this.activeTranscriptions.has(episodeKey); if (isAlreadyQueued) { new Notice("This episode is already queued or transcribing."); @@ -188,7 +191,7 @@ export class TranscriptionService { return; } - const episodeKey = this.getEpisodeKey(job.episode); + const episodeKey = queueKey(job.episode); this.activeTranscriptions.add(episodeKey); void this.transcribeEpisode(job).finally(() => { @@ -198,10 +201,6 @@ export class TranscriptionService { } } - private getEpisodeKey(episode: Episode): string { - return `${episode.podcastName}:${episode.title}`; - } - private async transcribeEpisode({ episode, source }: TranscriptionJob): Promise { this.assertActive(); const notice = TimerNotice(`Transcription: ${episode.title}`, "Preparing to transcribe..."); From 361942db699ffb4a85f1ff16dd3d6a9dc8eea93b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:52:01 +0200 Subject: [PATCH 11/56] refactor(download): match media signatures with find and every A missing mask is a full-byte mask, so one comparison covers both kinds of signature. gzip 102590 -> 102571 (-19) --- src/download/mediaSignatures.ts | 34 +++++++++------------------------ 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/download/mediaSignatures.ts b/src/download/mediaSignatures.ts index 6082e992..95a12a43 100644 --- a/src/download/mediaSignatures.ts +++ b/src/download/mediaSignatures.ts @@ -76,29 +76,13 @@ export function detectAudioFileExtension(data: ArrayBuffer): string | null { return isoBmffExtension; } - for (const { signature, mask, fileExtension } of audioSignatures) { - if (signature.length > arr.length) { - continue; - } - - let matches = true; - for (let i = 0; i < signature.length; i++) { - if (mask) { - if ((arr[i] & mask[i]) !== (signature[i] & mask[i])) { - matches = false; - break; - } - } else { - if (arr[i] !== signature[i]) { - matches = false; - break; - } - } - } - if (matches) { - return fileExtension; - } - } - - return null; + const match = audioSignatures.find( + ({ signature, mask }) => + signature.length <= arr.length && + signature.every((byte, i) => { + const bits = mask?.[i] ?? 0xff; + return (arr[i] & bits) === (byte & bits); + }), + ); + return match?.fileExtension ?? null; } From b45c588115ec1225f135b49409a61c14a4e56f1e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:54:28 +0200 Subject: [PATCH 12/56] refactor(itunes): build the iTunes request URL in fetchShows Both requests go to the same host, so fetchShows takes the endpoint and its query parameters instead of each caller appending them one by one. gzip 102571 -> 102537 (-34) --- src/iTunesAPIConsumer.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index c62b9c2e..aa738473 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -25,15 +25,15 @@ interface iTunesResponse { } export async function queryiTunesPodcasts(query: string): Promise { - const url = new URL("https://itunes.apple.com/search"); - url.searchParams.append("term", query); - url.searchParams.append("media", "podcast"); - url.searchParams.append("limit", "20"); - url.searchParams.append("kind", "podcast"); - + const found = await fetchShows("search", { + term: query, + media: "podcast", + limit: "20", + kind: "podcast", + }); // Search can list one feed under several shows; keep the best-ranked one. const shows = new Map(); - for (const show of await fetchShows(url)) { + for (const show of found) { if (!shows.has(show.feed.url)) shows.set(show.feed.url, show); } return [...shows.values()]; @@ -41,11 +41,8 @@ export async function queryiTunesPodcasts(query: string): Promise { - const url = new URL("https://itunes.apple.com/lookup"); - url.searchParams.append("id", id); - url.searchParams.append("entity", "podcast"); // An artist id returns the artist's shows, none of which is the linked show. - const shows = await fetchShows(url); + const shows = await fetchShows("lookup", { id, entity: "podcast" }); return shows.find(({ feed }) => feed.collectionId === id) ?? null; } @@ -75,9 +72,13 @@ export function parseApplePodcastsId(input: string): string | null { return id && /^\d+$/.test(id) ? id : null; } -async function fetchShows(url: URL): Promise { +async function fetchShows( + endpoint: "search" | "lookup", + params: Record, +): Promise { + const url = `https://itunes.apple.com/${endpoint}?${new URLSearchParams(params)}`; try { - const data = await fetchJsonWithTimeout(url.href, { + const data = await fetchJsonWithTimeout(url, { timeoutMs: ITUNES_REQUEST_TIMEOUT_MS, maxResponseBytes: MAX_ITUNES_RESPONSE_BYTES, acceptedStatuses: [200], From e605864c93fe90b8420b730789cd241dd9aaee41 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:57:51 +0200 Subject: [PATCH 13/56] refactor(player): trust the decoded default volume The settings codec clamps defaultVolume to a finite 0 to 1 when it reads data.json or an import, and setVolume is the only other writer and clamps too, so the constructor's own clamp never changed the value. gzip 102537 -> 102523 (-14) --- src/player/PlayerController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 53b5a043..e166ed49 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -133,7 +133,7 @@ export class PlayerController { currentTime: 0, duration: 0, playbackRate: normalizePlaybackRate(defaultPlaybackRate), - volume: Number.isFinite(defaultVolume) ? Math.min(1, Math.max(0, defaultVolume)) : 1, + volume: defaultVolume, }; this.store = writable(this.snapshot); this.state = { subscribe: this.store.subscribe }; From 391f1ffe7b833c0361fc8f1b5ca9d4f0dfd0906f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:58:43 +0200 Subject: [PATCH 14/56] refactor(transcription): keep the lifetime signal in a field Ten reads of this.lifetimeAbortController.signal, a property chain the minifier cannot shorten, become this.signal. gzip 102523 -> 102512 (-11) --- src/services/TranscriptionService.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 6e9a9412..6ab634a9 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -78,6 +78,7 @@ export class TranscriptionService { private plugin: PodNotes; private disposed = false; private readonly lifetimeAbortController = new AbortController(); + private readonly signal = this.lifetimeAbortController.signal; private readonly activeNotices = new Set>(); private pendingJobs: TranscriptionJob[] = []; private activeTranscriptions = new Set(); @@ -101,7 +102,7 @@ export class TranscriptionService { findPublishedTranscript(this.plugin.feeds, episode), ); } catch (error) { - if (this.disposed || this.lifetimeAbortController.signal.aborted) return; + if (this.disposed || this.signal.aborted) return; throw error; } @@ -230,7 +231,7 @@ export class TranscriptionService { notice.stop(); updateNotice(completion); } catch (error) { - if (this.disposed || this.lifetimeAbortController.signal.aborted) return; + if (this.disposed || this.signal.aborted) return; console.error("Transcription error:", error); notice.stop(); updateNotice( @@ -256,12 +257,7 @@ export class TranscriptionService { ): Promise<{ body: string; completion: string }> { updateNotice("Fetching the published transcript..."); const parsed = await this.waitForLifecycle( - fetchPublishedTranscript( - this.plugin.feeds, - episode, - transcript, - this.lifetimeAbortController.signal, - ), + fetchPublishedTranscript(this.plugin.feeds, episode, transcript, this.signal), ); const { timestamp, transcript: settings } = this.plugin.settings; const body = renderTranscriptBody(parsed, { @@ -379,7 +375,7 @@ export class TranscriptionService { audio, apiKey, onProgress: updateNotice, - signal: this.lifetimeAbortController.signal, + signal: this.signal, }), ); this.assertActive(); @@ -397,7 +393,7 @@ export class TranscriptionService { chunkFiles, maxRetries: MAX_RETRIES, onProgress: updateNotice, - signal: this.lifetimeAbortController.signal, + signal: this.signal, }); this.assertActive(); return segments; @@ -436,7 +432,7 @@ export class TranscriptionService { const result = await createTranscription( apiKey, { model: "whisper-1", file }, - this.lifetimeAbortController.signal, + this.signal, ); this.assertActive(); const text = (result as { text?: unknown } | null)?.text; @@ -478,7 +474,7 @@ export class TranscriptionService { private waitForRetry(delayMs: number): Promise { this.assertActive(); - const signal = this.lifetimeAbortController.signal; + const { signal } = this; return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -498,7 +494,7 @@ export class TranscriptionService { private waitForLifecycle(operation: Promise): Promise { this.assertActive(); - const signal = this.lifetimeAbortController.signal; + const { signal } = this; return new Promise((resolve, reject) => { const onAbort = () => @@ -570,14 +566,14 @@ export class TranscriptionService { } private assertActive(): void { - if (this.disposed || this.lifetimeAbortController.signal.aborted) { + if (this.disposed || this.signal.aborted) { throw this.getAbortReason(); } } private getAbortReason(): unknown { return ( - this.lifetimeAbortController.signal.reason ?? + this.signal.reason ?? new DOMException("PodNotes was unloaded during transcription.", "AbortError") ); } From fc39a8ae4ee13325692fb845c78817e9cc16aaee Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 16:09:33 +0200 Subject: [PATCH 15/56] refactor(ui): drop TimerNotice's redundant timer bookkeeping stop() runs once because stopTime is set once, clearTimeout ignores an absent id, and one render() replaces two one-caller helpers. gzip 102512 -> 102499 (-13) --- src/ui/TimerNotice.ts | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/ui/TimerNotice.ts b/src/ui/TimerNotice.ts index 44c3389a..6b67d0a3 100644 --- a/src/ui/TimerNotice.ts +++ b/src/ui/TimerNotice.ts @@ -3,42 +3,34 @@ import { Notice } from "obsidian"; export function TimerNotice(heading: string, initialMessage: string) { let currentMessage = initialMessage; const startTime = Date.now(); - let stopTime: number; - let interval: number | null = null; - let hideTimeout: number | null = null; + let stopTime: number | undefined; + let hideTimeout: number | undefined; let disposed = false; const notice = new Notice(initialMessage, 0); - function formatMsg(message: string): string { - return `${heading} (${getTime()}):\n\n${message}`; + function render() { + const elapsed = formatTime((stopTime ?? Date.now()) - startTime); + notice.setMessage(`${heading} (${elapsed}):\n\n${currentMessage}`); } function update(message: string) { if (disposed) return; currentMessage = message; - notice.setMessage(formatMsg(currentMessage)); + render(); } - interval = window.setInterval(() => { - notice.setMessage(formatMsg(currentMessage)); - }, 1000); - - function getTime(): string { - return formatTime(stopTime ? stopTime - startTime : Date.now() - startTime); - } + const interval = window.setInterval(render, 1000); function stop() { - if (interval === null) return; + if (stopTime !== undefined) return; stopTime = Date.now(); window.clearInterval(interval); - interval = null; } function scheduleHide(delayMs: number, onHide: () => void) { if (disposed) return; - if (hideTimeout !== null) window.clearTimeout(hideTimeout); + window.clearTimeout(hideTimeout); hideTimeout = window.setTimeout(() => { - hideTimeout = null; dispose(); onHide(); }, delayMs); @@ -48,10 +40,7 @@ export function TimerNotice(heading: string, initialMessage: string) { if (disposed) return; disposed = true; stop(); - if (hideTimeout !== null) { - window.clearTimeout(hideTimeout); - hideTimeout = null; - } + window.clearTimeout(hideTimeout); notice.hide(); } From 1af79d340676ef42d9e3cae5e3196cb300e55eba Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:46:45 +0200 Subject: [PATCH 16/56] refactor(ui): share the popout-following resize observer in layout.ts gzip 102843 -> 102814 (-29) --- src/ui/PodcastView/layout.ts | 68 +++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/src/ui/PodcastView/layout.ts b/src/ui/PodcastView/layout.ts index f8213ce9..84016416 100644 --- a/src/ui/PodcastView/layout.ts +++ b/src/ui/PodcastView/layout.ts @@ -13,28 +13,41 @@ export function layoutFor(width: number): Layout { } /** - * Reports the element's width now and on every resize. The observer comes from - * the element's own window and is made again when Obsidian moves the element to - * a popout: an observer from another window stops reporting there. + * Watches the element, and `also()` when there is one, with an observer from the element's own + * window, made again when Obsidian moves the element to a popout: an observer from another + * window stops reporting there. Calls `start` at once and after each move. Returns the stop. */ -export function observeWidth(onWidth: (width: number) => void): Attachment { - return (el) => { - let observer: ResizeObserver | undefined; - const observe = (win: Window) => { - observer?.disconnect(); - observer = new (win as ObservableWindow).ResizeObserver(([entry]) => - onWidth(entry.contentRect.width), - ); - observer.observe(el); - onWidth(el.clientWidth); - }; - observe(el.win); - const stopFollowing = el.onWindowMigrated(observe); - return () => { - stopFollowing(); - observer?.disconnect(); - }; +function followResizes( + el: HTMLElement, + onResize: ResizeObserverCallback, + start: () => void, + also?: () => Element | null, +): () => void { + let observer: ResizeObserver | undefined; + const observe = (win: Window) => { + observer?.disconnect(); + observer = new (win as ObservableWindow).ResizeObserver(onResize); + observer.observe(el); + const other = also?.(); + if (other) observer.observe(other); + start(); }; + observe(el.win); + const stopFollowing = el.onWindowMigrated(observe); + return () => { + stopFollowing(); + observer?.disconnect(); + }; +} + +/** Reports the element's width now and on every resize, also in a popout. */ +export function observeWidth(onWidth: (width: number) => void): Attachment { + return (el) => + followResizes( + el, + ([entry]) => onWidth(entry.contentRect.width), + () => onWidth(el.clientWidth), + ); } type Box = Pick; @@ -73,22 +86,11 @@ export function observeStatusBar( : 0, ); }; - let observer: ResizeObserver | undefined; - const observe = (win: Window) => { - observer?.disconnect(); - observer = new (win as ObservableWindow).ResizeObserver(report); - observer.observe(el); - const shown = bar(); - if (shown) observer.observe(shown); - report(); - }; - observe(el.win); - const stopFollowing = el.onWindowMigrated(observe); + const stopObserving = followResizes(el, report, report, bar); const moved = workspace.on("resize", report); const restyled = workspace.on("css-change", report); return () => { - stopFollowing(); - observer?.disconnect(); + stopObserving(); workspace.offref(moved); workspace.offref(restyled); }; From d0b44f853302a72c84c3bbad0f3f043c40745b58 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 15:52:11 +0200 Subject: [PATCH 17/56] refactor(ui): attach the video stage instead of using it as an action gzip 102814 -> 102762 (-52) --- src/ui/PodcastView/Player.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index 3c33c4f0..04a8d62c 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -233,7 +233,11 @@ {:else if !wide && shown} {@render panelView(shown)} {:else if video && inMainWindow} -
+
player.videoStage(frame).destroy} + bind:this={videoFrame} + >