From 35e647ea4f028186d06b7f292e797c9ebb4d6c57 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 23 Nov 2025 22:01:43 +0100 Subject: [PATCH 01/22] feat: Add transcription queue for episodes (#132) --- src/services/TranscriptionService.ts | 80 +++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 87d8b83..2679324 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -56,18 +56,15 @@ export class TranscriptionService { private readonly CHUNK_SIZE_BYTES = 20 * 1024 * 1024; private readonly WAV_HEADER_SIZE = 44; private readonly PCM_BYTES_PER_SAMPLE = 2; - private isTranscribing = false; + private readonly MAX_CONCURRENT_TRANSCRIPTIONS = 2; + private pendingEpisodes: Episode[] = []; + private activeTranscriptions = new Set(); constructor(plugin: PodNotes) { this.plugin = plugin; } async transcribeCurrentEpisode(): Promise { - if (this.isTranscribing) { - new Notice("A transcription is already in progress."); - return; - } - if (!this.plugin.settings.openAIApiKey?.trim()) { new Notice( "Please add your OpenAI API key in the transcript settings first.", @@ -81,7 +78,6 @@ export class TranscriptionService { return; } - // Check if transcription file already exists const transcriptPath = FilePathTemplateEngine( this.plugin.settings.transcript.path, currentEpisode, @@ -95,13 +91,72 @@ export class TranscriptionService { return; } - this.isTranscribing = true; - const notice = TimerNotice("Transcription", "Preparing to transcribe..."); + const episodeKey = this.getEpisodeKey(currentEpisode); + const isAlreadyQueued = + this.pendingEpisodes.some( + (episode) => this.getEpisodeKey(episode) === episodeKey, + ) || this.activeTranscriptions.has(episodeKey); + + if (isAlreadyQueued) { + new Notice("This episode is already queued or transcribing."); + return; + } + + this.pendingEpisodes.push(currentEpisode); + new Notice( + `Queued "${currentEpisode.title}" for transcription. It will start automatically.`, + ); + this.drainQueue(); + } + + private drainQueue(): void { + while ( + this.activeTranscriptions.size < this.MAX_CONCURRENT_TRANSCRIPTIONS && + this.pendingEpisodes.length > 0 + ) { + const nextEpisode = this.pendingEpisodes.shift(); + if (!nextEpisode) { + return; + } + + const episodeKey = this.getEpisodeKey(nextEpisode); + this.activeTranscriptions.add(episodeKey); + + void this.transcribeEpisode(nextEpisode).finally(() => { + this.activeTranscriptions.delete(episodeKey); + this.drainQueue(); + }); + } + } + + private getEpisodeKey(episode: Episode): string { + return `${episode.podcastName}:${episode.title}`; + } + + private async transcribeEpisode(episode: Episode): Promise { + const notice = TimerNotice( + `Transcription: ${episode.title}`, + "Preparing to transcribe...", + ); try { + const transcriptPath = FilePathTemplateEngine( + this.plugin.settings.transcript.path, + episode, + ); + const existingFile = + this.plugin.app.vault.getAbstractFileByPath(transcriptPath); + if (existingFile instanceof TFile) { + notice.stop(); + notice.update( + `Transcript already exists - skipped (${transcriptPath}).`, + ); + return; + } + notice.update("Downloading episode..."); const downloadPath = await downloadEpisode( - currentEpisode, + episode, this.plugin.settings.download.path, ); const podcastFile = @@ -127,16 +182,17 @@ export class TranscriptionService { const transcription = await this.transcribeChunks(files, notice.update); notice.update("Saving transcription..."); - await this.saveTranscription(currentEpisode, transcription); + await this.saveTranscription(episode, transcription); notice.stop(); notice.update("Transcription completed and saved."); } catch (error) { console.error("Transcription error:", error); const message = error instanceof Error ? error.message : String(error); + notice.stop(); notice.update(`Transcription failed: ${message}`); } finally { - this.isTranscribing = false; + notice.stop(); setTimeout(() => notice.hide(), 5000); } } From 79e5b80ea4669ba2d50b37a37e9828a955fcc1b8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 23 Nov 2025 22:06:45 +0100 Subject: [PATCH 02/22] feat: Add volume control to player (#133) --- src/API/API.ts | 12 +++++ src/API/IAPI.ts | 1 + src/constants.ts | 1 + src/main.ts | 22 +++++++++ src/store/index.ts | 1 + src/types/IPodNotesSettings.ts | 1 + src/ui/PodcastView/EpisodePlayer.svelte | 61 +++++++++++++++++++------ src/ui/obsidian/Slider.svelte | 2 +- src/ui/settings/PodNotesSettingsTab.ts | 17 +++++++ 9 files changed, 103 insertions(+), 15 deletions(-) diff --git a/src/API/API.ts b/src/API/API.ts index 659cfef..c39811d 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -8,11 +8,15 @@ import { duration, isPaused, plugin, + volume as volumeStore, } from "src/store"; import { get } from "svelte/store"; import encodePodnotesURI from "src/utility/encodePodnotesURI"; import { isLocalFile } from "src/utility/isLocalFile"; +const clampVolume = (value: number): number => + Math.min(1, Math.max(0, value)); + export class API implements IAPI { public get podcast(): Episode { return get(currentEpisode); @@ -34,6 +38,14 @@ export class API implements IAPI { return !get(isPaused); } + public get volume(): number { + return get(volumeStore); + } + + public set volume(value: number) { + volumeStore.set(clampVolume(value)); + } + /** * Gets the current time in the given moment format. * @param format Moment format. diff --git a/src/API/IAPI.ts b/src/API/IAPI.ts index 48f3412..ac68698 100644 --- a/src/API/IAPI.ts +++ b/src/API/IAPI.ts @@ -5,6 +5,7 @@ export interface IAPI { readonly isPlaying: boolean; readonly length: number; currentTime: number; + volume: number; getPodcastTimeFormatted( format: string, diff --git a/src/constants.ts b/src/constants.ts index 5d51db2..0d5354d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { savedFeeds: {}, podNotes: {}, defaultPlaybackRate: 1, + defaultVolume: 1, playedEpisodes: {}, favorites: { ...FAVORITES_SETTINGS, diff --git a/src/main.ts b/src/main.ts index 7955843..22fb81b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { playlists, queue, savedFeeds, + volume, } from "src/store"; import { Plugin, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; @@ -40,6 +41,7 @@ import getContextMenuHandler from "./getContextMenuHandler"; import getUniversalPodcastLink from "./getUniversalPodcastLink"; import type { IconType } from "./types/IconType"; import { TranscriptionService } from "./services/TranscriptionService"; +import type { Unsubscriber } from "svelte/store"; export default class PodNotes extends Plugin implements IPodNotes { public api!: IAPI; @@ -65,6 +67,7 @@ export default class PodNotes extends Plugin implements IPodNotes { [podcastName: string]: DownloadedEpisode[]; }>; private transcriptionService?: TranscriptionService; + private volumeUnsubscribe?: Unsubscriber; private maxLayoutReadyAttempts = 10; private layoutReadyAttempts = 0; @@ -84,6 +87,9 @@ export default class PodNotes extends Plugin implements IPodNotes { if (this.settings.currentEpisode) { currentEpisode.set(this.settings.currentEpisode); } + volume.set( + Math.min(1, Math.max(0, this.settings.defaultVolume ?? 1)), + ); this.playedEpisodeController = new EpisodeStatusController( playedEpisodes, @@ -104,6 +110,21 @@ export default class PodNotes extends Plugin implements IPodNotes { ).on(); this.api = new API(); + this.volumeUnsubscribe = volume.subscribe((value) => { + const clamped = Math.min(1, Math.max(0, value)); + + if (clamped !== value) { + volume.set(clamped); + return; + } + + if (clamped === this.settings.defaultVolume) { + return; + } + + this.settings.defaultVolume = clamped; + void this.saveSettings(); + }); this.addCommand({ id: "podnotes-show-leaf", @@ -337,6 +358,7 @@ export default class PodNotes extends Plugin implements IPodNotes { this.localFilesController?.off(); this.downloadedEpisodesController?.off(); this.currentEpisodeController?.off(); + this.volumeUnsubscribe?.(); } async loadSettings() { diff --git a/src/store/index.ts b/src/store/index.ts index 2acee09..05281a2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,6 +12,7 @@ import type { LocalEpisode } from "src/types/LocalEpisode"; export const plugin = writable(); export const currentTime = writable(0); export const duration = writable(0); +export const volume = writable(1); export const currentEpisode = (() => { const store = writable(); diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 78802cf..1e56c4f 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -9,6 +9,7 @@ export interface IPodNotesSettings { savedFeeds: { [podcastName: string]: PodcastFeed }; podNotes: { [episodeName: string]: PodNote }; defaultPlaybackRate: number; + defaultVolume: number; playedEpisodes: { [episodeName: string]: PlayedEpisode }; skipBackwardLength: number; skipForwardLength: number; diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 63b021a..82432b7 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -5,6 +5,7 @@ currentEpisode, isPaused, plugin, + volume, playedEpisodes, queue, playlists, @@ -36,9 +37,11 @@ const offBinding = new CircumentForcedTwoWayBinding(); //#endregion + const clampVolume = (value: number): number => Math.min(1, Math.max(0, value)); let isHoveringArtwork: boolean = false; let isLoading: boolean = true; + let playerVolume: number = 1; function togglePlayback() { isPaused.update((value) => !value); @@ -84,6 +87,12 @@ offBinding.playbackRate = event.detail.value; } + function onVolumeChange(event: CustomEvent<{ value: number }>) { + const newVolume = clampVolume(event.detail.value); + + volume.set(newVolume); + } + function onMetadataLoaded() { isLoading = false; @@ -125,10 +134,15 @@ srcPromise = getSrc($currentEpisode); }); + const unsubVolume = volume.subscribe((value) => { + playerVolume = clampVolume(value); + }); + return () => { unsub(); unsubDownloadedSource(); unsubCurrentEpisode(); + unsubVolume(); }; }); @@ -223,6 +237,7 @@ bind:currentTime={playerTime} bind:paused={$isPaused} bind:playbackRate={offBinding._playbackRate} + bind:volume={playerVolume} on:ended={onEpisodeEnded} on:loadedmetadata={onMetadataLoaded} on:play|preventDefault @@ -259,18 +274,29 @@ on:click={$plugin.api.skipForward.bind($plugin.api)} style={{ margin: "0", - cursor: "pointer", - }} - /> - + cursor: "pointer", + }} + /> + -
- {offBinding.playbackRate}x - +
+
+ Volume: {Math.round(playerVolume * 100)}% + +
+ +
+ {offBinding.playbackRate}x + +
diff --git a/src/ui/obsidian/Slider.svelte b/src/ui/obsidian/Slider.svelte index 55aa9e2..d485242 100644 --- a/src/ui/obsidian/Slider.svelte +++ b/src/ui/obsidian/Slider.svelte @@ -28,7 +28,7 @@ }); function updateSliderAttributes(sldr: SliderComponent) { - if (value) sldr.setValue(value); + if (value !== undefined) sldr.setValue(value); if (limits) { if (limits.length === 2) { sldr.setLimits(limits[0], limits[1], 1); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 2336f87..ac58689 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -66,6 +66,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }); this.addDefaultPlaybackRateSetting(settingsContainer); + this.addDefaultVolumeSetting(settingsContainer); this.addSkipLengthSettings(settingsContainer); this.addNoteSettings(settingsContainer); this.addDownloadSettings(settingsContainer); @@ -94,6 +95,22 @@ export class PodNotesSettingsTab extends PluginSettingTab { ); } + 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.settings.defaultVolume = value; + this.plugin.saveSettings(); + }) + .setDynamicTooltip(), + ); + } + private addSkipLengthSettings(container: HTMLElement): void { new Setting(container) .setName("Skip backward length (s)") From 26336033cda5cf985d08534193399960ca213d52 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 23 Nov 2025 22:53:03 +0100 Subject: [PATCH 03/22] fix: mount views with svelte api (#134) --- src/ui/PodcastView/index.ts | 10 +++++++--- src/ui/settings/PodNotesSettingsTab.ts | 20 ++++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index c5b14a0..821b600 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -3,13 +3,14 @@ import type { WorkspaceLeaf } from "obsidian"; import type { IPodNotes } from "../../types/IPodNotes"; import { VIEW_TYPE } from "../../constants"; import PodcastView from "./PodcastView.svelte"; +import { mount, unmount } from "svelte"; export class MainView extends ItemView { constructor(leaf: WorkspaceLeaf, private plugin: IPodNotes) { super(leaf); } - private podcastView: PodcastView | null = null; + private podcastView: Record | null = null; override getViewType(): string { return VIEW_TYPE; @@ -24,13 +25,16 @@ export class MainView extends ItemView { } protected override async onOpen(): Promise { - this.podcastView = new PodcastView({ + this.podcastView = mount(PodcastView, { target: this.contentEl, }); } protected override async onClose(): Promise { - this.podcastView?.$destroy(); + if (this.podcastView) { + await unmount(this.podcastView); + this.podcastView = null; + } this.contentEl.empty(); } diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index ac58689..d163cee 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -9,6 +9,7 @@ import { import type PodNotes from "../../main"; import PodcastQueryGrid from "./PodcastQueryGrid.svelte"; import PlaylistManager from "./PlaylistManager.svelte"; +import { mount, unmount } from "svelte"; import { DownloadPathTemplateEngine, FilePathTemplateEngine, @@ -23,8 +24,8 @@ import { clearFeedCache } from "src/services/FeedCacheService"; export class PodNotesSettingsTab extends PluginSettingTab { plugin: PodNotes; - private podcastQueryGrid!: PodcastQueryGrid; - private playlistManager!: PlaylistManager; + private podcastQueryGrid: Record | null = null; + private playlistManager: Record | null = null; private settingsTab: PodNotesSettingsTab; @@ -51,7 +52,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setDesc("Search for podcasts by name or custom feed URL."); const queryGridContainer = settingsContainer.createDiv(); - this.podcastQueryGrid = new PodcastQueryGrid({ + this.podcastQueryGrid = mount(PodcastQueryGrid, { target: queryGridContainer, }); @@ -61,7 +62,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setDesc("Add playlists to gather podcast episodes."); const playlistManagerContainer = settingsContainer.createDiv(); - this.playlistManager = new PlaylistManager({ + this.playlistManager = mount(PlaylistManager, { target: playlistManagerContainer, }); @@ -76,8 +77,15 @@ export class PodNotesSettingsTab extends PluginSettingTab { } override hide(): void { - this.podcastQueryGrid?.$destroy(); - this.playlistManager?.$destroy(); + if (this.podcastQueryGrid) { + void unmount(this.podcastQueryGrid); + this.podcastQueryGrid = null; + } + + if (this.playlistManager) { + void unmount(this.playlistManager); + this.playlistManager = null; + } } private addDefaultPlaybackRateSetting(container: HTMLElement): void { From 87222b6acf35609729fcaa6b52f563f78681e021 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 07:21:53 +0100 Subject: [PATCH 04/22] fix: Reserve image space and use native lazy load (#138) * Reserve image space and use native lazy load * Fix image loading attribute type --- src/ui/PodcastView/EpisodeListItem.svelte | 2 + src/ui/common/Image.svelte | 15 +++++--- src/ui/common/ImageLoader.svelte | 46 ++++++++++++++++------- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte index 54d440f..2e4f727 100644 --- a/src/ui/PodcastView/EpisodeListItem.svelte +++ b/src/ui/PodcastView/EpisodeListItem.svelte @@ -38,6 +38,8 @@ src={episode.artworkUrl} alt={episode.title} fadeIn={true} + width="5rem" + height="5rem" class="podcast-episode-thumbnail" />
diff --git a/src/ui/common/Image.svelte b/src/ui/common/Image.svelte index fbcfb6c..2d1b850 100644 --- a/src/ui/common/Image.svelte +++ b/src/ui/common/Image.svelte @@ -6,11 +6,12 @@ export let fadeIn: boolean = false; export let opacity: number = 0; // Falsey value so condition isn't triggered if not set. export let interactive: boolean = false; + export let loading: "lazy" | "eager" | null | undefined = undefined; export {_class as class}; let _class = ""; let loaded = false; - let loading = true; + let isLoading = true; let failed = false; const dispatcher = createEventDispatcher(); @@ -20,7 +21,7 @@ } -{#if loading || loaded} +{#if isLoading || loaded} {#if interactive} {:else} @@ -44,11 +46,12 @@ draggable="false" {src} {alt} + {loading} class={_class} style:opacity={opacity ? opacity : !fadeIn ? 1 : loaded ? 1 : 0} style:transition={fadeIn ? "opacity 0.5s ease-out" : ""} - on:load={() => {loaded = true; loading = false;}} - on:error={() => {failed = true; loading = false;}} + on:load={() => {loaded = true; isLoading = false;}} + on:error={() => {failed = true; isLoading = false;}} /> {/if} diff --git a/src/ui/common/ImageLoader.svelte b/src/ui/common/ImageLoader.svelte index 5ea1908..ee66b3f 100644 --- a/src/ui/common/ImageLoader.svelte +++ b/src/ui/common/ImageLoader.svelte @@ -4,26 +4,46 @@ export let alt: string; export let fadeIn: boolean = false; export let interactive: boolean = false; + export let width: string | number | undefined; + export let height: string | number | undefined; + export let aspectRatio: string | undefined = "1 / 1"; export { _class as class }; let _class: string = ""; - import IntersectionObserver from "./IntersectionObserver.svelte"; import Image from "./Image.svelte"; import { createEventDispatcher } from "svelte"; const dispatcher = createEventDispatcher(); + + const toDimension = (value?: string | number) => + typeof value === "number" ? `${value}px` : value; + + $: resolvedWidth = toDimension(width); + $: resolvedHeight = toDimension(height); + $: resolvedAspectRatio = resolvedHeight ? undefined : aspectRatio; - - {#if intersecting} - dispatcher('click', { event })} - class={_class} - /> - {/if} - +
+ dispatcher('click', { event })} + class={_class} + /> +
+ + From 65e3e543f2562bf2ffad20be62e46ac978a731a4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 07:22:39 +0100 Subject: [PATCH 05/22] fix: fix Obsidian listener accumulation (#141) --- src/ui/obsidian/Slider.svelte | 47 ++++++++++++++++++++++------------- src/ui/obsidian/Text.svelte | 47 +++++++++++++++++++++++------------ 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/ui/obsidian/Slider.svelte b/src/ui/obsidian/Slider.svelte index d485242..f0413ae 100644 --- a/src/ui/obsidian/Slider.svelte +++ b/src/ui/obsidian/Slider.svelte @@ -2,7 +2,7 @@ import { SliderComponent } from "obsidian"; import type { CSSObject } from "src/types/CSSObject"; import extractStylesFromObj from "src/utility/extractStylesFromObj"; - import { afterUpdate, createEventDispatcher, onMount } from "svelte"; + import { createEventDispatcher, onDestroy, onMount } from "svelte"; export let value: number; export let limits: [min: number, max: number] | [min: number, max: number, step: number]; @@ -13,36 +13,49 @@ const dispatch = createEventDispatcher(); let slider: SliderComponent; - let styles: CSSObject; + let styles: CSSObject = {}; + let changeHandler: ((event: Event) => void) | null = null; // This is not a complete implementation. I implemented what I needed. onMount(() => { slider = new SliderComponent(sliderRef); - updateSliderAttributes(slider); + changeHandler = (event: Event) => { + const newValue = Number((event.target as HTMLInputElement).value); + dispatch("change", { value: newValue }); + }; + + slider.sliderEl.addEventListener("input", changeHandler); }); - afterUpdate(() => { - updateSliderAttributes(slider); + onDestroy(() => { + if (slider?.sliderEl && changeHandler) { + slider.sliderEl.removeEventListener("input", changeHandler); + } }); - function updateSliderAttributes(sldr: SliderComponent) { - if (value !== undefined) sldr.setValue(value); - if (limits) { - if (limits.length === 2) { - sldr.setLimits(limits[0], limits[1], 1); + $: if (slider) { + updateSliderAttributes(slider, value, limits, styles); + } + + function updateSliderAttributes( + sldr: SliderComponent, + currentValue: number, + currentLimits: [min: number, max: number] | [min: number, max: number, step: number], + currentStyles: CSSObject + ) { + if (currentValue !== undefined) sldr.setValue(currentValue); + if (currentLimits) { + if (currentLimits.length === 2) { + sldr.setLimits(currentLimits[0], currentLimits[1], 1); } else { - sldr.setLimits(limits[0], limits[1], limits[2]); + sldr.setLimits(currentLimits[0], currentLimits[1], currentLimits[2]); } } - if (styles) { - sldr.sliderEl.setAttr("style", extractStylesFromObj(styles)); + if (currentStyles) { + sldr.sliderEl.setAttr("style", extractStylesFromObj(currentStyles)); } - - sldr.onChange((value: number) => { - dispatch("change", { value }); - }); } diff --git a/src/ui/obsidian/Text.svelte b/src/ui/obsidian/Text.svelte index ecd4bbb..8a04cdf 100644 --- a/src/ui/obsidian/Text.svelte +++ b/src/ui/obsidian/Text.svelte @@ -2,7 +2,7 @@ import { TextComponent } from "obsidian"; import type { CSSObject } from "src/types/CSSObject"; import extractStylesFromObj from "src/utility/extractStylesFromObj"; - import { afterUpdate, createEventDispatcher, onMount } from "svelte"; + import { createEventDispatcher, onDestroy, onMount } from "svelte"; export let value: string = ""; export let disabled: boolean = false; @@ -17,33 +17,48 @@ let text: TextComponent; let styles: CSSObject = {}; + let textChangeHandler: ((event: Event) => void) | null = null; onMount(() => { text = new TextComponent(textRef); - updateTextComponentAttributes(text); + textChangeHandler = (event: Event) => { + const newValue = (event.target as HTMLInputElement).value; + value = newValue; + dispatch("change", { value: newValue }); + }; + + text.inputEl.addEventListener("input", textChangeHandler); }); - afterUpdate(() => { - updateTextComponentAttributes(text); + onDestroy(() => { + if (text?.inputEl && textChangeHandler) { + text.inputEl.removeEventListener("input", textChangeHandler); + } }); - function updateTextComponentAttributes(component: TextComponent) { - if (value !== undefined) component.setValue(value); - if (disabled) component.setDisabled(disabled); - if (placeholder) component.setPlaceholder(placeholder); - if (type) component.inputEl.type = type; - if (styles) { - text.inputEl.setAttr("style", extractStylesFromObj(styles)); + $: if (text) { + updateTextComponentAttributes(text, value, disabled, placeholder, type, styles); + } + + function updateTextComponentAttributes( + component: TextComponent, + currentValue: string, + isDisabled: boolean, + currentPlaceholder: string, + currentType: "text" | "password" | "email" | "number" | "tel" | "url", + currentStyles: CSSObject + ) { + if (currentValue !== undefined) component.setValue(currentValue); + if (isDisabled) component.setDisabled(isDisabled); + if (currentPlaceholder) component.setPlaceholder(currentPlaceholder); + if (currentType) component.inputEl.type = currentType; + if (currentStyles) { + component.inputEl.setAttr("style", extractStylesFromObj(currentStyles)); } if (component?.inputEl) { el = component.inputEl; } - - component.onChange((newValue: string) => { - value = newValue; - dispatch("change", { value: newValue }); - }); } From 7ffec6210281aa6ef0a2b5bbb73cf7943e5b9c06 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 07:24:13 +0100 Subject: [PATCH 06/22] feaet: Optimize latest episodes aggregation (#142) --- src/store/index.ts | 195 +++++++++++++++++++++++++- src/ui/PodcastView/PodcastView.svelte | 97 +++++++------ 2 files changed, 242 insertions(+), 50 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 05281a2..6d082f5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,4 @@ -import { get, writable } from "svelte/store"; +import { get, readable, writable } from "svelte/store"; import type PodNotes from "src/main"; import type { Episode } from "src/types/Episode"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; @@ -102,6 +102,199 @@ export const savedFeeds = writable<{ [podcastName: string]: PodcastFeed }>({}); export const episodeCache = writable<{ [podcastName: string]: Episode[] }>({}); +const LATEST_EPISODES_PER_FEED = 10; + +type LatestEpisodesByFeed = Map; +type FeedEpisodeSources = Map; +type LatestEpisodePointer = { + feedTitle: string; + index: number; + episode: Episode; +}; + +function getEpisodeTimestamp(episode?: Episode): number { + if (!episode?.episodeDate) return 0; + + return Number(episode.episodeDate); +} + +function getLatestEpisodesForFeed(episodes: Episode[]): Episode[] { + if (!episodes?.length) return []; + + return episodes + .slice(0, LATEST_EPISODES_PER_FEED) + .sort((a, b) => getEpisodeTimestamp(b) - getEpisodeTimestamp(a)); +} + +function shallowEqualEpisodes(a?: Episode[], b?: Episode[]): boolean { + if (!a || !b || a.length !== b.length) return false; + + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + + return true; +} + +function pushEpisodePointer( + heap: LatestEpisodePointer[], + pointer: LatestEpisodePointer, +): void { + heap.push(pointer); + let idx = heap.length - 1; + + while (idx > 0) { + const parent = Math.floor((idx - 1) / 2); + if ( + getEpisodeTimestamp(heap[parent].episode) >= + getEpisodeTimestamp(heap[idx].episode) + ) { + break; + } + + heap[idx] = heap[parent]; + heap[parent] = pointer; + idx = parent; + } +} + +function popEpisodePointer( + heap: LatestEpisodePointer[], +): LatestEpisodePointer | undefined { + if (heap.length === 0) return undefined; + + const top = heap[0]; + const last = heap.pop(); + + if (last && heap.length > 0) { + heap[0] = last; + let idx = 0; + + while (true) { + const left = idx * 2 + 1; + const right = idx * 2 + 2; + let largest = idx; + + if ( + left < heap.length && + getEpisodeTimestamp(heap[left].episode) > + getEpisodeTimestamp(heap[largest].episode) + ) { + largest = left; + } + + if ( + right < heap.length && + getEpisodeTimestamp(heap[right].episode) > + getEpisodeTimestamp(heap[largest].episode) + ) { + largest = right; + } + + if (largest === idx) break; + + const temp = heap[idx]; + heap[idx] = heap[largest]; + heap[largest] = temp; + idx = largest; + } + } + + return top; +} + +// Use a max-heap to merge the latest episodes from each feed without +// resorting the entire cache every time a single feed updates. +function mergeLatestEpisodes(latestByFeed: LatestEpisodesByFeed): Episode[] { + const heap: LatestEpisodePointer[] = []; + + for (const [feedTitle, episodes] of latestByFeed.entries()) { + if (!episodes.length) continue; + + pushEpisodePointer(heap, { + feedTitle, + index: 0, + episode: episodes[0], + }); + } + + const merged: Episode[] = []; + while (heap.length > 0) { + const pointer = popEpisodePointer(heap); + if (!pointer) break; + + merged.push(pointer.episode); + + const feedEpisodes = latestByFeed.get(pointer.feedTitle); + const nextIndex = pointer.index + 1; + if (feedEpisodes && nextIndex < feedEpisodes.length) { + pushEpisodePointer(heap, { + feedTitle: pointer.feedTitle, + index: nextIndex, + episode: feedEpisodes[nextIndex], + }); + } + } + + return merged; +} + +export const latestEpisodes = readable([], (set) => { + let latestByFeed: LatestEpisodesByFeed = new Map(); + let feedSources: FeedEpisodeSources = new Map(); + + const unsubscribe = episodeCache.subscribe((cache) => { + let changed = false; + const nextSources: FeedEpisodeSources = new Map(); + const nextLatestByFeed: LatestEpisodesByFeed = new Map(); + + for (const [feedTitle, episodes] of Object.entries(cache)) { + nextSources.set(feedTitle, episodes); + const previousSource = feedSources.get(feedTitle); + const previousLatest = latestByFeed.get(feedTitle); + + if (previousSource === episodes && previousLatest) { + nextLatestByFeed.set(feedTitle, previousLatest); + continue; + } + + const latestForFeed = getLatestEpisodesForFeed(episodes); + nextLatestByFeed.set(feedTitle, latestForFeed); + + if (!changed) { + changed = + !previousLatest || + !shallowEqualEpisodes(previousLatest, latestForFeed); + } + } + + if (!changed) { + for (const feedTitle of feedSources.keys()) { + if (!nextSources.has(feedTitle)) { + changed = true; + break; + } + } + } + + feedSources = nextSources; + + if (!changed && nextLatestByFeed.size === latestByFeed.size) { + latestByFeed = nextLatestByFeed; + return; + } + + latestByFeed = nextLatestByFeed; + set(mergeLatestEpisodes(latestByFeed)); + }); + + return () => { + latestByFeed.clear(); + feedSources.clear(); + unsubscribe(); + }; +}); + export const downloadedEpisodes = (() => { const store = writable<{ [podcastName: string]: DownloadedEpisode[] }>({}); const { subscribe, update, set } = store; diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 64c1c22..0097ee7 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -1,37 +1,38 @@ From c94c0a03369eef689713cf9e6191838d9a679215 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 07:38:17 +0100 Subject: [PATCH 19/22] feat: Improve nav cues and native lazy images (#152) --- src/ui/PodcastView/EpisodeListItem.svelte | 10 ++- src/ui/PodcastView/TopBar.svelte | 89 ++++++++++++++++------- src/ui/PodcastView/TopBar.test.ts | 27 +++++-- src/ui/common/Image.svelte | 2 +- src/ui/common/ImageLoader.svelte | 4 +- src/ui/common/IntersectionObserver.svelte | 61 ---------------- 6 files changed, 93 insertions(+), 100 deletions(-) delete mode 100644 src/ui/common/IntersectionObserver.svelte diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte index 5063358..efb5664 100644 --- a/src/ui/PodcastView/EpisodeListItem.svelte +++ b/src/ui/PodcastView/EpisodeListItem.svelte @@ -120,9 +120,11 @@ } .podcast-episode-thumbnail-container { - flex: 0 0 4rem; - width: 4rem; - height: 4rem; + flex: 0 0 5rem; + width: 5rem; + height: 5rem; + max-width: 5rem; + max-height: 5rem; display: flex; align-items: center; justify-content: center; @@ -134,8 +136,8 @@ :global(.podcast-episode-thumbnail) { width: 100%; height: 100%; - border-radius: 15%; object-fit: cover; + border-radius: 15%; cursor: pointer !important; } diff --git a/src/ui/PodcastView/TopBar.svelte b/src/ui/PodcastView/TopBar.svelte index e71534d..feef28c 100644 --- a/src/ui/PodcastView/TopBar.svelte +++ b/src/ui/PodcastView/TopBar.svelte @@ -6,6 +6,19 @@ export let canShowEpisodeList: boolean = false; export let canShowPlayer: boolean = false; + const gridTooltip = "Browse podcast grid"; + const disabledEpisodeTooltip = + "Select a podcast or playlist to view its episodes."; + const disabledPlayerTooltip = + "Start playing an episode to open the player."; + + $: episodeTooltip = canShowEpisodeList + ? "View episode list" + : disabledEpisodeTooltip; + $: playerTooltip = canShowPlayer + ? "Open player" + : disabledPlayerTooltip; + function handleClickMenuItem(newState: ViewState) { if (viewState === newState) return; @@ -29,6 +42,7 @@ `} aria-label="Podcast grid" aria-pressed={viewState === ViewState.PodcastGrid} + title={gridTooltip} > @@ -38,11 +52,16 @@ class={` topbar-menu-button ${viewState === ViewState.EpisodeList ? "topbar-selected" : ""} - ${canShowEpisodeList ? "topbar-selectable" : ""} + ${canShowEpisodeList ? "topbar-selectable" : "topbar-disabled"} `} - aria-label="Episode list" + aria-label={ + canShowEpisodeList + ? "Episode list" + : "Episode list (select a podcast or playlist first)" + } aria-pressed={viewState === ViewState.EpisodeList} disabled={!canShowEpisodeList} + title={episodeTooltip} > @@ -52,11 +71,16 @@ class={` topbar-menu-button ${viewState === ViewState.Player ? "topbar-selected" : ""} - ${canShowPlayer ? "topbar-selectable" : ""} + ${canShowPlayer ? "topbar-selectable" : "topbar-disabled"} `} - aria-label="Player" + aria-label={ + canShowPlayer + ? "Player" + : "Player (start playing an episode to open the player)" + } aria-pressed={viewState === ViewState.Player} disabled={!canShowPlayer} + title={playerTooltip} > @@ -68,9 +92,12 @@ flex-direction: row; align-items: center; justify-content: space-between; + gap: 0.5rem; + padding: 0.25rem 0.5rem; height: 50px; min-height: 50px; border-bottom: 1px solid var(--background-divider); + box-sizing: border-box; } .topbar-menu-button { @@ -78,14 +105,18 @@ align-items: center; justify-content: center; width: 100%; - height: 100%; - color: var(--text-muted, #8a8f98); - opacity: 1; - border: none; - background: none; - padding: 0; - transition: color 120ms ease, background-color 120ms ease, - box-shadow 120ms ease, opacity 120ms ease; + padding: 0.4rem 0.25rem; + flex: 1 1 0; + border: 1px solid var(--background-modifier-border, #3a3a3a); + border-radius: 8px; + background: var(--background-secondary, transparent); + color: var(--text-muted, #8a8a8a); + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + box-shadow 120ms ease, + opacity 120ms ease; } .topbar-menu-button:focus-visible { @@ -93,24 +124,32 @@ outline-offset: 2px; } - .topbar-menu-button:disabled { - color: var(--text-faint, #6b6b6b); - opacity: 0.45; - cursor: not-allowed; - } - .topbar-selectable { cursor: pointer; - color: var(--text-normal, #dfe2e7); + color: var(--text-normal, #e6e6e6); + background: var(--background-secondary-alt, rgba(255, 255, 255, 0.02)); + } + + .topbar-menu-button:hover.topbar-selectable:not(.topbar-selected) { + background: var(--background-modifier-hover, rgba(255, 255, 255, 0.06)); + border-color: var(--interactive-accent, #5c6bf7); + color: var(--text-normal, #e6e6e6); } - .topbar-selectable:hover { - background-color: var(--background-divider); + .topbar-selected, + .topbar-selected:hover { + color: var(--text-on-accent, #ffffff); + background: var(--interactive-accent, #5c6bf7); + border-color: var(--interactive-accent, #5c6bf7); + box-shadow: 0 0 0 1px var(--interactive-accent, #5c6bf7); } - .topbar-selected { - color: var(--interactive-accent, #5c6bf7); - background-color: var(--background-secondary, var(--background-divider)); - box-shadow: inset 0 -2px var(--interactive-accent, #5c6bf7); + .topbar-disabled, + .topbar-menu-button:disabled { + cursor: not-allowed; + color: var(--text-faint, #a0a0a0); + background: var(--background-modifier-border, #3a3a3a); + border-style: dashed; + opacity: 1; } diff --git a/src/ui/PodcastView/TopBar.test.ts b/src/ui/PodcastView/TopBar.test.ts index cf441cd..07a5b8c 100644 --- a/src/ui/PodcastView/TopBar.test.ts +++ b/src/ui/PodcastView/TopBar.test.ts @@ -15,14 +15,19 @@ describe("TopBar", () => { }); const grid = getByLabelText("Podcast grid"); - const episode = getByLabelText("Episode list"); - const player = getByLabelText("Player"); + const episode = getByLabelText(/Episode list/); + const player = getByLabelText(/Player/); expect(grid.className).toContain("topbar-selected"); expect(episode.className).toContain("topbar-selectable"); expect(episode).not.toBeDisabled(); expect(player).toBeDisabled(); expect(player.className).not.toContain("topbar-selectable"); + expect(player.className).toContain("topbar-disabled"); + expect(player.getAttribute("title")).toBe( + "Start playing an episode to open the player." + ); + expect(episode.getAttribute("title")).toBe("View episode list"); }); test("activates episode list when clicked", async () => { @@ -34,8 +39,8 @@ describe("TopBar", () => { }, }); - const episodeButton = getByLabelText("Episode list"); - const playerButton = getByLabelText("Player"); + const episodeButton = getByLabelText(/Episode list/); + const playerButton = getByLabelText(/Player/); await fireEvent.click(episodeButton); @@ -52,12 +57,20 @@ describe("TopBar", () => { }, }); - const episodeButton = getByLabelText("Episode list"); - const playerButton = getByLabelText("Player"); + const episodeButton = getByLabelText(/Episode list/); + const playerButton = getByLabelText(/Player/); expect(episodeButton).toBeDisabled(); expect(playerButton).toBeDisabled(); expect(episodeButton.className).not.toContain("topbar-selectable"); + expect(episodeButton.className).toContain("topbar-disabled"); + expect(playerButton.className).toContain("topbar-disabled"); + expect(episodeButton.getAttribute("title")).toBe( + "Select a podcast or playlist to view its episodes." + ); + expect(playerButton.getAttribute("title")).toBe( + "Start playing an episode to open the player." + ); }); test("activates player control when clicked", async () => { @@ -69,7 +82,7 @@ describe("TopBar", () => { }, }); - const playerButton = getByLabelText("Player"); + const playerButton = getByLabelText(/Player/); await fireEvent.click(playerButton); diff --git a/src/ui/common/Image.svelte b/src/ui/common/Image.svelte index 2d1b850..7b76f54 100644 --- a/src/ui/common/Image.svelte +++ b/src/ui/common/Image.svelte @@ -6,7 +6,7 @@ export let fadeIn: boolean = false; export let opacity: number = 0; // Falsey value so condition isn't triggered if not set. export let interactive: boolean = false; - export let loading: "lazy" | "eager" | null | undefined = undefined; + export let loading: "lazy" | "eager" | null | undefined = "lazy"; export {_class as class}; let _class = ""; diff --git a/src/ui/common/ImageLoader.svelte b/src/ui/common/ImageLoader.svelte index ee66b3f..d08bcb3 100644 --- a/src/ui/common/ImageLoader.svelte +++ b/src/ui/common/ImageLoader.svelte @@ -1,5 +1,4 @@ - - - -
- -
From 22ba238c2e21e839915f7c18378f092dd573e5f1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 07:38:33 +0100 Subject: [PATCH 20/22] feat: Improve podcast loading feedback (#151) --- src/ui/PodcastView/EpisodeList.svelte | 19 +++- .../PodcastView.integration.test.ts | 87 ++++++++++++++++++- src/ui/PodcastView/PodcastView.svelte | 5 +- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index ffaa9ea..16a418c 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -5,10 +5,12 @@ import { hidePlayedEpisodes, playedEpisodes } from "src/store"; import Icon from "../obsidian/Icon.svelte"; import Text from "../obsidian/Text.svelte"; + import Loading from "./Loading.svelte"; export let episodes: Episode[] = []; export let showThumbnails: boolean = false; export let showListMenu: boolean = true; + export let isLoading: boolean = false; let searchInputQuery: string = ""; const dispatch = createEventDispatcher(); @@ -63,7 +65,13 @@ {/if}
- {#if episodes.length === 0} + {#if isLoading} +
+ + Fetching episodes... +
+ {/if} + {#if episodes.length === 0 && !isLoading}

No episodes found.

{/if} {#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)} @@ -115,4 +123,13 @@ width: 100%; margin-bottom: 0.5rem; } + + .episode-list-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1rem 0; + color: var(--text-muted); + } diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 5d7895a..0341fc0 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { get } from "svelte/store"; import { afterEach, @@ -144,4 +144,89 @@ describe("PodcastView integration flow", () => { expect.objectContaining({ path: expectedPath }), ); }); + + test("shows loading state while fetching and streams episodes per feed", async () => { + const secondFeed: PodcastFeed = { + title: "Second Podcast", + url: "https://pod.example.com/feed-two.xml", + artworkUrl: "https://pod.example.com/art-two.jpg", + }; + + const firstEpisode: Episode = { + title: "Episode A", + streamUrl: "https://pod.example.com/a.mp3", + url: "https://pod.example.com/a", + description: "Episode A description", + content: "

Episode A content

", + podcastName: testFeed.title, + artworkUrl: testFeed.artworkUrl, + episodeDate: new Date("2024-02-01T00:00:00.000Z"), + }; + + const secondEpisode: Episode = { + title: "Episode B", + streamUrl: "https://pod.example.com/b.mp3", + url: "https://pod.example.com/b", + description: "Episode B description", + content: "

Episode B content

", + podcastName: secondFeed.title, + artworkUrl: secondFeed.artworkUrl, + episodeDate: new Date("2024-01-15T00:00:00.000Z"), + }; + + let resolveFirstFeed!: (value: Episode[]) => void; + let resolveSecondFeed!: (value: Episode[]) => void; + + mockGetEpisodes + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstFeed = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondFeed = resolve; + }), + ); + + plugin.set({ + settings: { + feedCache: { + enabled: false, + ttlHours: 6, + }, + }, + } as never); + + savedFeeds.set({ + [testFeed.title]: testFeed, + [secondFeed.title]: secondFeed, + }); + viewState.set(ViewState.EpisodeList); + + render(PodcastView); + + await screen.findByText("Fetching episodes..."); + + resolveFirstFeed([firstEpisode]); + + expect( + await screen.findByText(firstEpisode.title), + ).toBeInTheDocument(); + expect(screen.getByText("Fetching episodes...")).toBeInTheDocument(); + expect(screen.queryByText(secondEpisode.title)).toBeNull(); + + resolveSecondFeed([secondEpisode]); + + expect( + await screen.findByText(secondEpisode.title), + ).toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByText("Fetching episodes..."), + ).not.toBeInTheDocument(), + ); + }); }); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 411750a..b243ff7 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -40,6 +40,7 @@ let displayedEpisodes: Episode[] = []; let displayedPlaylists: Playlist[] = []; let latestEpisodes: Episode[] = []; + let isFetchingEpisodes: boolean = false; let loadingFeeds: Set = new Set(); let currentSearchQuery: string = ""; let loadingFeedNames: string[] = []; @@ -50,6 +51,7 @@ loadingFeedNames.length > 3 ? `${loadingFeedNames.slice(0, 3).join(", ")} +${loadingFeedNames.length - 3} more` : loadingFeedNames.join(", "); + $: isFetchingEpisodes = loadingFeedNames.length > 0; onMount(() => { const unsubscribePlaylists = playlists.subscribe((pl) => { @@ -177,9 +179,9 @@ event: CustomEvent<{ feed: PodcastFeed }> ) { const { feed } = event.detail; - displayedEpisodes = []; selectedFeed = feed; + displayedEpisodes = []; viewState.set(ViewState.EpisodeList); setFeedLoading(feed.title, true); @@ -284,6 +286,7 @@ Date: Mon, 24 Nov 2025 07:54:01 +0100 Subject: [PATCH 21/22] fix: guard obsidian inputs from feedback loops (#154) --- src/ui/obsidian/Slider.svelte | 14 +++++++++++- src/ui/obsidian/Text.svelte | 43 ++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/ui/obsidian/Slider.svelte b/src/ui/obsidian/Slider.svelte index f0413ae..6595534 100644 --- a/src/ui/obsidian/Slider.svelte +++ b/src/ui/obsidian/Slider.svelte @@ -15,6 +15,7 @@ let slider: SliderComponent; let styles: CSSObject = {}; let changeHandler: ((event: Event) => void) | null = null; + let isProgrammaticUpdate = false; // This is not a complete implementation. I implemented what I needed. @@ -22,6 +23,8 @@ slider = new SliderComponent(sliderRef); changeHandler = (event: Event) => { + if (isProgrammaticUpdate) return; + const newValue = Number((event.target as HTMLInputElement).value); dispatch("change", { value: newValue }); }; @@ -45,7 +48,16 @@ currentLimits: [min: number, max: number] | [min: number, max: number, step: number], currentStyles: CSSObject ) { - if (currentValue !== undefined) sldr.setValue(currentValue); + const sliderValue = + typeof sldr.getValue === "function" + ? sldr.getValue() + : Number(sldr.sliderEl?.value); + + if (currentValue !== undefined && sliderValue !== currentValue) { + isProgrammaticUpdate = true; + sldr.setValue(currentValue); + isProgrammaticUpdate = false; + } if (currentLimits) { if (currentLimits.length === 2) { sldr.setLimits(currentLimits[0], currentLimits[1], 1); diff --git a/src/ui/obsidian/Text.svelte b/src/ui/obsidian/Text.svelte index 4c9a679..d30dec5 100644 --- a/src/ui/obsidian/Text.svelte +++ b/src/ui/obsidian/Text.svelte @@ -19,6 +19,7 @@ let styles: CSSObject = {}; let inputHandler: ((event: Event) => void) | null = null; let changeHandler: ((value: string) => void) | null = null; + let isProgrammaticUpdate = false; onMount(() => { text = new TextComponent(textRef); @@ -36,6 +37,8 @@ } function handleInput(event: Event) { + if (isProgrammaticUpdate) return; + const input = event.target as HTMLInputElement | null; const newValue = input?.value ?? ""; @@ -44,6 +47,8 @@ } function handleChange(newValue: string) { + if (isProgrammaticUpdate) return; + value = newValue; dispatch("change", { value: newValue }); } @@ -66,14 +71,40 @@ currentType: "text" | "password" | "email" | "number" | "tel" | "url", currentStyles: CSSObject ) { - if (currentValue !== undefined) component.setValue(currentValue); - if (isDisabled) component.setDisabled(isDisabled); - if (currentPlaceholder) component.setPlaceholder(currentPlaceholder); - if (currentType) component.inputEl.type = currentType; - if (currentStyles) { - component.inputEl.setAttr("style", extractStylesFromObj(currentStyles)); + const componentValue = + typeof component.getValue === "function" + ? component.getValue() + : component.inputEl?.value; + + if (currentValue !== undefined && componentValue !== currentValue) { + isProgrammaticUpdate = true; + component.setValue(currentValue); + isProgrammaticUpdate = false; } + if (component?.inputEl) { + if (component.inputEl.disabled !== isDisabled) { + component.setDisabled(isDisabled); + } + + if ( + currentPlaceholder && + component.inputEl.placeholder !== currentPlaceholder + ) { + component.setPlaceholder(currentPlaceholder); + } + + if (component.inputEl.type !== currentType) { + component.inputEl.type = currentType; + } + + if (currentStyles) { + component.inputEl.setAttr( + "style", + extractStylesFromObj(currentStyles), + ); + } + el = component.inputEl; } } From b73a70e01c1a1ec49b02feef40ad8e68ac2879b7 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 10:11:40 +0100 Subject: [PATCH 22/22] fix: settings corruption --- src/main.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 27f464c..87f0d5f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -74,6 +74,10 @@ export default class PodNotes extends Plugin implements IPodNotes { private maxLayoutReadyAttempts = 10; private layoutReadyAttempts = 0; + private isReady = false; + private pendingSave: IPodNotesSettings | null = null; + private saveScheduled = false; + private saveChain: Promise = Promise.resolve(); override async onload() { plugin.set(this); @@ -320,6 +324,8 @@ export default class PodNotes extends Plugin implements IPodNotes { ); this.registerEvent(getContextMenuHandler(this.app)); + + this.isReady = true; } onLayoutReady(): void { @@ -381,6 +387,45 @@ export default class PodNotes extends Plugin implements IPodNotes { } async saveSettings() { - await this.saveData(this.settings); + if (!this.isReady) return; + + this.pendingSave = this.cloneSettings(); + + if (this.saveScheduled) { + return this.saveChain; + } + + this.saveScheduled = true; + + this.saveChain = this.saveChain + .then(async () => { + while (this.pendingSave) { + const snapshot = this.pendingSave; + this.pendingSave = null; + await this.saveData(snapshot); + } + }) + .catch((error) => { + console.error("PodNotes: failed to save settings", error); + }) + .finally(() => { + this.saveScheduled = false; + + // If a save was requested while we were saving, run again. + if (this.pendingSave) { + void this.saveSettings(); + } + }); + + return this.saveChain; + } + + private cloneSettings(): IPodNotesSettings { + // structuredClone is available in Obsidian's Electron runtime; fallback for safety. + if (typeof structuredClone === "function") { + return structuredClone(this.settings); + } + + return JSON.parse(JSON.stringify(this.settings)) as IPodNotesSettings; } }