From f566bc9ac4c295c5dc9603f66ce35e139a9e8c9d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 20:19:14 +0100 Subject: [PATCH 01/14] feat: style improvements --- src/ui/PodcastView/EpisodeList.svelte | 29 ++-- src/ui/PodcastView/EpisodeListHeader.svelte | 17 ++- src/ui/PodcastView/EpisodeListItem.svelte | 70 ++++++--- src/ui/PodcastView/EpisodePlayer.svelte | 154 +++++++++++++------- src/ui/PodcastView/PlaylistCard.svelte | 24 ++- src/ui/PodcastView/PodcastGrid.svelte | 20 ++- src/ui/PodcastView/PodcastGridCard.svelte | 13 ++ src/ui/PodcastView/PodcastView.svelte | 40 +++-- src/ui/PodcastView/TopBar.svelte | 57 ++++---- src/ui/common/Image.svelte | 16 +- src/ui/common/Progressbar.svelte | 19 ++- src/ui/settings/PlaylistItem.svelte | 92 +++++++++--- src/ui/settings/PlaylistManager.svelte | 15 +- src/ui/settings/PodcastQueryGrid.svelte | 10 +- src/ui/settings/PodcastResultCard.svelte | 57 ++++---- 15 files changed, 431 insertions(+), 202 deletions(-) diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index 16a418c..02b5aa3 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -96,6 +96,8 @@ align-items: stretch; justify-content: flex-start; width: 100%; + height: 100%; + overflow: hidden; } .podcast-episode-list { @@ -104,24 +106,32 @@ align-items: stretch; justify-content: flex-start; width: 100%; - height: 100%; - gap: 0.25rem; + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + } + + .podcast-episode-list p { + padding: 1.5rem; + text-align: center; + color: var(--text-muted); } .episode-list-menu { display: flex; flex-direction: row; - justify-content: right; + justify-content: flex-end; align-items: center; - gap: 1rem; + gap: 0.5rem; width: 100%; - padding-left: 0.5rem; - padding-right: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); } .episode-list-search { - width: 100%; - margin-bottom: 0.5rem; + flex: 1 1 auto; + min-width: 0; } .episode-list-loading { @@ -129,7 +139,8 @@ align-items: center; justify-content: center; gap: 0.75rem; - padding: 1rem 0; + padding: 2rem 1rem; color: var(--text-muted); + font-size: 0.9rem; } diff --git a/src/ui/PodcastView/EpisodeListHeader.svelte b/src/ui/PodcastView/EpisodeListHeader.svelte index f76e810..5475e68 100644 --- a/src/ui/PodcastView/EpisodeListHeader.svelte +++ b/src/ui/PodcastView/EpisodeListHeader.svelte @@ -14,12 +14,25 @@ .podcast-header { display: flex; flex-direction: column; - justify-content: space-around; + justify-content: center; align-items: center; - padding: 0.5rem; + gap: 0.75rem; + padding: 1rem; + } + + #podcast-artwork { + width: 5rem; + height: 5rem; + border-radius: 0.5rem; + object-fit: cover; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .podcast-heading { + margin: 0; + font-size: 1.125rem; + font-weight: 600; text-align: center; + color: var(--text-normal); } \ No newline at end of file diff --git a/src/ui/PodcastView/EpisodeListItem.svelte b/src/ui/PodcastView/EpisodeListItem.svelte index efb5664..e6a5a04 100644 --- a/src/ui/PodcastView/EpisodeListItem.svelte +++ b/src/ui/PodcastView/EpisodeListItem.svelte @@ -64,8 +64,8 @@ src={episode.artworkUrl} alt={episode.title} fadeIn={true} - width="5rem" - height="5rem" + width="100%" + height="100%" class="podcast-episode-thumbnail" /> @@ -83,61 +83,95 @@ display: flex; flex-direction: row; justify-content: flex-start; - align-items: flex-start; - padding: 0.5rem; - min-height: 5rem; + align-items: center; + padding: 0.625rem 0.75rem; + min-height: 4.5rem; width: 100%; - border: solid 1px var(--background-divider); + border: none; + border-bottom: 1px solid var(--background-modifier-border); gap: 0.75rem; background: transparent; text-align: left; + cursor: pointer; + transition: background-color 120ms ease; + } + + .podcast-episode-item:last-child { + border-bottom: none; } .podcast-episode-item:focus-visible { outline: 2px solid var(--interactive-accent); - outline-offset: 2px; + outline-offset: -2px; + border-radius: 0.25rem; } .podcast-episode-item:hover { - background-color: var(--background-divider); + background-color: var(--background-secondary-alt); + } + + .podcast-episode-item:active { + background-color: var(--background-modifier-border); } .strikeout { text-decoration: line-through; + opacity: 0.6; } .podcast-episode-information { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: center; align-items: flex-start; + gap: 0.25rem; flex: 1 1 auto; min-width: 0; } .episode-item-date { - color: gray; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.025em; + color: var(--text-muted); + } + + .episode-item-title { + font-size: 0.9rem; + line-height: 1.4; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; } .podcast-episode-thumbnail-container { - flex: 0 0 5rem; - width: 5rem; - height: 5rem; - max-width: 5rem; - max-height: 5rem; + flex: 0 0 3.5rem; + width: 3.5rem; + height: 3.5rem; display: flex; align-items: center; justify-content: center; background: var(--background-secondary); - border-radius: 15%; + border-radius: 0.375rem; overflow: hidden; } + @media (min-width: 400px) { + .podcast-episode-thumbnail-container { + flex: 0 0 4rem; + width: 4rem; + height: 4rem; + } + } + :global(.podcast-episode-thumbnail) { width: 100%; height: 100%; object-fit: cover; - border-radius: 15%; - cursor: pointer !important; + border-radius: 0.375rem; } diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 162b7da..59e382a 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -287,135 +287,187 @@ diff --git a/src/ui/PodcastView/PlaylistCard.svelte b/src/ui/PodcastView/PlaylistCard.svelte index 917ce7d..71f0283 100644 --- a/src/ui/PodcastView/PlaylistCard.svelte +++ b/src/ui/PodcastView/PlaylistCard.svelte @@ -31,16 +31,32 @@ flex-direction: column; align-items: center; justify-content: center; + gap: 0.25rem; width: 100%; - height: 100%; + aspect-ratio: 1; border: 1px solid var(--background-modifier-border); + border-radius: 0.5rem; text-align: center; overflow: hidden; - background: transparent; - padding: 0; + background: var(--background-secondary); + padding: 0.5rem; + cursor: pointer; + transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease; } .playlist-card:hover { - background-color: var(--background-modifier-border); + transform: scale(1.02); + border-color: var(--interactive-accent); + background-color: var(--background-secondary-alt); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + .playlist-card:active { + transform: scale(0.98); + } + + .playlist-card span { + font-size: 0.8rem; + color: var(--text-muted); } diff --git a/src/ui/PodcastView/PodcastGrid.svelte b/src/ui/PodcastView/PodcastGrid.svelte index 1ddad97..766ecb5 100644 --- a/src/ui/PodcastView/PodcastGrid.svelte +++ b/src/ui/PodcastView/PodcastGrid.svelte @@ -39,9 +39,23 @@ diff --git a/src/ui/PodcastView/PodcastGridCard.svelte b/src/ui/PodcastView/PodcastGridCard.svelte index ae32a7e..02e098a 100644 --- a/src/ui/PodcastView/PodcastGridCard.svelte +++ b/src/ui/PodcastView/PodcastGridCard.svelte @@ -24,11 +24,24 @@ :global(.podcast-image) { width: 100%; height: 100%; + aspect-ratio: 1; cursor: pointer !important; object-fit: cover; background-size: cover; background-position: center; background-repeat: no-repeat; border: 1px solid var(--background-modifier-border); + border-radius: 0.5rem; + transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; + } + + :global(.podcast-image:hover) { + transform: scale(1.02); + border-color: var(--interactive-accent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + :global(.podcast-image:active) { + transform: scale(0.98); } diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index b243ff7..ae4300b 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -361,32 +361,37 @@ display: flex; flex-direction: column; height: 100%; + overflow: hidden; } .feed-loading-banner { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.625rem; width: 100%; - padding: 0.5rem 0.75rem; + padding: 0.625rem 0.75rem; + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); box-sizing: border-box; } .feed-loading-spinner { display: inline-flex; + color: var(--interactive-accent); animation: spin 1s linear infinite; } .feed-loading-text { display: flex; flex-direction: column; - gap: 0.1rem; - font-size: 0.9rem; + gap: 0.125rem; + font-size: 0.85rem; + color: var(--text-normal); } .feed-loading-names { - opacity: 0.7; - font-size: 0.85rem; + font-size: 0.75rem; + color: var(--text-muted); } @keyframes spin { @@ -400,25 +405,34 @@ } .go-back { - display: flex; + display: inline-flex; align-items: center; - justify-content: center; - padding: 0.5rem; - gap: 0.5rem; - margin-right: auto; - opacity: 0.75; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + margin: 0.5rem 0.5rem 0; + font-size: 0.85rem; + color: var(--text-muted); cursor: pointer; background: none; border: none; + border-radius: 0.25rem; + transition: color 120ms ease, background-color 120ms ease; } .go-back:hover { - opacity: 1; + color: var(--text-normal); + background: var(--background-modifier-hover); + } + + .go-back:active { + background: var(--background-modifier-border); } .playlist-header-icon { display: flex; align-items: center; justify-content: center; + padding: 0.5rem; + color: var(--text-muted); } diff --git a/src/ui/PodcastView/TopBar.svelte b/src/ui/PodcastView/TopBar.svelte index feef28c..4bf505f 100644 --- a/src/ui/PodcastView/TopBar.svelte +++ b/src/ui/PodcastView/TopBar.svelte @@ -91,12 +91,12 @@ display: flex; 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); + justify-content: stretch; + gap: 0.375rem; + padding: 0.5rem; + min-height: 3rem; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); box-sizing: border-box; } @@ -104,52 +104,47 @@ display: flex; align-items: center; justify-content: center; - width: 100%; - padding: 0.4rem 0.25rem; + height: 2rem; + padding: 0 0.75rem; 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); + border: 1px solid transparent; + border-radius: 0.375rem; + background: transparent; + color: var(--text-muted); transition: background-color 120ms ease, border-color 120ms ease, - color 120ms ease, - box-shadow 120ms ease, - opacity 120ms ease; + color 120ms ease; } .topbar-menu-button:focus-visible { - outline: 2px solid var(--interactive-accent, #5c6bf7); - outline-offset: 2px; + outline: 2px solid var(--interactive-accent); + outline-offset: 1px; } .topbar-selectable { cursor: pointer; - color: var(--text-normal, #e6e6e6); - background: var(--background-secondary-alt, rgba(255, 255, 255, 0.02)); + color: var(--text-normal); } - .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:not(.topbar-selected) { + background: var(--background-modifier-hover); + } + + .topbar-selectable:active:not(.topbar-selected) { + background: var(--background-modifier-border); } .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); + color: var(--text-on-accent); + background: var(--interactive-accent); } .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; + color: var(--text-faint); + opacity: 0.5; } diff --git a/src/ui/common/Image.svelte b/src/ui/common/Image.svelte index 7b76f54..52fa25e 100644 --- a/src/ui/common/Image.svelte +++ b/src/ui/common/Image.svelte @@ -68,15 +68,21 @@ overflow: hidden; border: none; padding: 0; - background: transparent; + background: var(--background-secondary); } .pn_image_container--static { - border: none; - padding: 0; + cursor: default; + } + + .pn_image_container:not(.pn_image_container--static) { + cursor: pointer; } - .pn_image_container:not(.pn_image_container--static) img:hover { - cursor: pointer !important; + .pn_image_container img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; } diff --git a/src/ui/common/Progressbar.svelte b/src/ui/common/Progressbar.svelte index 09a522d..0051030 100644 --- a/src/ui/common/Progressbar.svelte +++ b/src/ui/common/Progressbar.svelte @@ -97,11 +97,22 @@ function handleKeyDown(event: KeyboardEvent) { .progress { position: relative; width: 100%; - height: 1rem; - background: var(--background-modifier-border, #ccc); + height: 0.5rem; + background: var(--background-modifier-border); border-radius: 9999px; overflow: hidden; cursor: pointer; + transition: height 120ms ease; + } + + .progress:hover, + .progress:focus-visible { + height: 0.625rem; + } + + .progress:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; } .progress__bar { @@ -109,6 +120,8 @@ function handleKeyDown(event: KeyboardEvent) { top: 0; left: 0; height: 100%; - background: var(--interactive-accent, #5c6bf7); + background: var(--interactive-accent); + border-radius: 9999px; + transition: width 50ms linear; } diff --git a/src/ui/settings/PlaylistItem.svelte b/src/ui/settings/PlaylistItem.svelte index ffd0668..f358a82 100644 --- a/src/ui/settings/PlaylistItem.svelte +++ b/src/ui/settings/PlaylistItem.svelte @@ -9,7 +9,7 @@ let clickedDelete: boolean = false; const dispatch = createEventDispatcher(); - function onClickedDelete(event: CustomEvent) { + function onClickedDelete() { if (clickedDelete) { dispatch("delete", { value: playlist }); return; @@ -22,7 +22,7 @@ }, 2000); } - function onClickedRepeat(event: CustomEvent) { + function onClickedRepeat() { dispatch("toggleRepeat", { value: playlist }); } @@ -31,33 +31,28 @@
- {playlist.name} - ({playlist.episodes.length}) + {playlist.name} + ({playlist.episodes.length})
- {#if showDeleteButton} - + aria-label={clickedDelete ? "Confirm deletion" : "Delete playlist"} + > + + {/if}
@@ -67,14 +62,37 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem; - border-bottom: 1px solid var(--background-modifier-border); + gap: 0.75rem; + padding: 0.625rem 0.75rem; width: 100%; + background: var(--background-secondary); + transition: background-color 120ms ease; + } + + .playlist-item:not(:last-child) { + border-bottom: 1px solid var(--background-modifier-border); + } + + .playlist-item:hover { + background: var(--background-secondary-alt); } .playlist-item-left { display: flex; align-items: center; + gap: 0.5rem; + min-width: 0; + } + + .playlist-name { + font-weight: 500; + font-size: 0.9rem; + color: var(--text-normal); + } + + .playlist-count { + font-size: 0.8rem; + color: var(--text-muted); } .playlist-item-controls { @@ -82,4 +100,30 @@ align-items: center; gap: 0.25rem; } + + .delete-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.375rem; + border: none; + border-radius: 0.25rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; + } + + .delete-button:hover { + background: var(--background-modifier-hover); + color: var(--text-error); + } + + .delete-button.confirm { + color: var(--text-success); + } + + .delete-button.confirm:hover { + color: var(--text-success); + } diff --git a/src/ui/settings/PlaylistManager.svelte b/src/ui/settings/PlaylistManager.svelte index 518a926..81de1ba 100644 --- a/src/ui/settings/PlaylistManager.svelte +++ b/src/ui/settings/PlaylistManager.svelte @@ -96,24 +96,23 @@ .playlist-manager-container { display: flex; flex-direction: column; - align-items: center; - justify-content: center; width: 100%; - height: 100%; - margin-bottom: 2rem; + margin-bottom: 1.5rem; } .playlist-list { display: flex; flex-direction: column; - align-items: center; - justify-content: center; width: 100%; - height: 100%; - overflow-y: auto; + border: 1px solid var(--background-modifier-border); + border-radius: 0.5rem; + overflow: hidden; } .add-playlist-container { + display: flex; + align-items: center; + gap: 0.5rem; margin-top: 1rem; } diff --git a/src/ui/settings/PodcastQueryGrid.svelte b/src/ui/settings/PodcastQueryGrid.svelte index b64c8a4..11d98cd 100644 --- a/src/ui/settings/PodcastQueryGrid.svelte +++ b/src/ui/settings/PodcastQueryGrid.svelte @@ -111,18 +111,18 @@ diff --git a/src/ui/settings/PodcastResultCard.svelte b/src/ui/settings/PodcastResultCard.svelte index e00d1ea..666b016 100644 --- a/src/ui/settings/PodcastResultCard.svelte +++ b/src/ui/settings/PodcastResultCard.svelte @@ -42,23 +42,29 @@ .podcast-result-card { display: flex; align-items: center; - padding: 16px; + gap: 0.875rem; + padding: 0.875rem; border: 1px solid var(--background-modifier-border); - border-radius: 8px; + border-radius: 0.5rem; background-color: var(--background-secondary); max-width: 100%; - transition: all 0.3s ease; - position: relative; + transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; + } + + .podcast-result-card:hover { + border-color: var(--interactive-accent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); } .podcast-artwork-container { - width: 70px; - height: 70px; + width: 4rem; + height: 4rem; flex-shrink: 0; - margin-right: 20px; overflow: hidden; - border-radius: 4px; + border-radius: 0.375rem; position: relative; + background: var(--background-modifier-border); } .podcast-artwork { @@ -70,23 +76,23 @@ left: 0; } - .podcast-result-card:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - transform: translateY(-2px); - } - .podcast-info { - flex-grow: 1; + flex: 1 1 auto; min-width: 0; - padding-right: 12px; } .podcast-title { - margin: 0 0 6px 0; - font-size: 16px; - font-weight: bold; - line-height: 1.3; - word-break: break-word; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + line-height: 1.4; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; } .podcast-actions { @@ -96,13 +102,12 @@ } :global(.podcast-actions button) { - padding: 4px; - width: 24px; - height: 24px; + padding: 0.375rem; + border-radius: 0.25rem; + transition: background-color 120ms ease; } - :global(.podcast-actions button svg) { - width: 16px; - height: 16px; + :global(.podcast-actions button:hover) { + background-color: var(--background-modifier-hover); } From 30a5a0aa602066d7a81e86b533e31de5f144b1e5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 20:31:57 +0100 Subject: [PATCH 02/14] fix: performance overhaul to prevent crashes - Fix blob URL memory leak with BlobUrlManager that tracks and revokes URLs - Fix episode key collision using composite key (podcastName::title) - Fix array bounds check crash in downloadedEpisodes.removeEpisode - Add network request timeouts (30s default) to prevent UI hangs - Fix interval leak in opml.ts TimerNotice - Add localStorage size limits (4MB) to FeedCacheService with LRU eviction - Add null safety checks throughout to prevent Svelte reactivity errors - Add unmount safety flag to prevent state updates after component destroy --- src/iTunesAPIConsumer.ts | 36 ++++-- src/main.ts | 4 + src/opml.ts | 25 +++-- src/parser/feedParser.ts | 4 +- src/services/FeedCacheService.ts | 58 +++++++++- src/store/index.ts | 93 +++++++++++++--- src/ui/PodcastView/EpisodeList.svelte | 10 +- src/ui/PodcastView/EpisodePlayer.svelte | 16 ++- src/ui/PodcastView/PodcastView.svelte | 10 +- .../createMediaUrlObjectFromFilePath.ts | 65 ++++++++++- src/utility/episodeKey.ts | 35 ++++++ src/utility/networkRequest.ts | 104 ++++++++++++++++++ 12 files changed, 412 insertions(+), 48 deletions(-) create mode 100644 src/utility/episodeKey.ts create mode 100644 src/utility/networkRequest.ts diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index 75d707e..68f7eff 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -1,5 +1,16 @@ -import { requestUrl } from "obsidian"; import type { PodcastFeed } from "./types/PodcastFeed"; +import { requestWithTimeout, NetworkError } from "./utility/networkRequest"; + +interface iTunesResult { + collectionName: string; + feedUrl: string; + artworkUrl100: string; + collectionId: string; +} + +interface iTunesSearchResponse { + results: iTunesResult[]; +} export async function queryiTunesPodcasts(query: string): Promise { const url = new URL("https://itunes.apple.com/search?"); @@ -8,13 +19,20 @@ export async function queryiTunesPodcasts(query: string): Promise url.searchParams.append("limit", "3"); url.searchParams.append("kind", "podcast"); - const res = await requestUrl({ url: url.href }); - const data = res.json.results; + try { + const response = await requestWithTimeout(url.href, { timeoutMs: 15000 }); + const data = response.json as iTunesSearchResponse; - return data.map((d: { collectionName: string, feedUrl: string, artworkUrl100: string, collectionId: string }) => ({ - title: d.collectionName, - url: d.feedUrl, - artworkUrl: d.artworkUrl100, - collectionId: d.collectionId - })); + return (data.results || []).map((d) => ({ + title: d.collectionName, + url: d.feedUrl, + artworkUrl: d.artworkUrl100, + collectionId: d.collectionId, + })); + } catch (error) { + if (error instanceof NetworkError) { + console.error(`iTunes search failed: ${error.message}`); + } + return []; + } } diff --git a/src/main.ts b/src/main.ts index 87f0d5f..1b3d73d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { hidePlayedEpisodes, volume, } from "src/store"; +import { blobUrlManager } from "src/utility/createMediaUrlObjectFromFilePath"; import { Plugin, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; @@ -374,6 +375,9 @@ export default class PodNotes extends Plugin implements IPodNotes { this.currentEpisodeController?.off(); this.hidePlayedEpisodesController?.off(); this.volumeUnsubscribe?.(); + + // Clean up any active blob URLs to prevent memory leaks + blobUrlManager.revokeAll(); } async loadSettings() { diff --git a/src/opml.ts b/src/opml.ts index 3347fcb..addfe99 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -7,7 +7,8 @@ import { get } from "svelte/store"; function TimerNotice(heading: string, initialMessage: string) { let currentMessage = initialMessage; const startTime = Date.now(); - let stopTime: number; + let stopTime: number | undefined; + let intervalId: ReturnType | undefined; const notice = new Notice(initialMessage, 0); function formatMsg(message: string): string { @@ -19,20 +20,30 @@ function TimerNotice(heading: string, initialMessage: string) { notice.setMessage(formatMsg(currentMessage)); } - const interval = setInterval(() => { - notice.setMessage(formatMsg(currentMessage)); - }, 1000); - function getTime(): string { return formatTime(stopTime ? stopTime - startTime : Date.now() - startTime); } + function clearTimer() { + if (intervalId !== undefined) { + clearInterval(intervalId); + intervalId = undefined; + } + } + + intervalId = setInterval(() => { + notice.setMessage(formatMsg(currentMessage)); + }, 1000); + return { update, - hide: () => notice.hide(), + hide: () => { + clearTimer(); + notice.hide(); + }, stop: () => { stopTime = Date.now(); - clearInterval(interval); + clearTimer(); }, }; } diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index a71d200..d32863a 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -1,6 +1,6 @@ import type { PodcastFeed } from "src/types/PodcastFeed"; -import { requestUrl } from "obsidian"; import type { Episode } from "src/types/Episode"; +import { requestWithTimeout } from "src/utility/networkRequest"; export default class FeedParser { private feed: PodcastFeed | undefined; @@ -125,7 +125,7 @@ export default class FeedParser { } private async parseFeed(feedUrl: string): Promise { - const req = await requestUrl({ url: feedUrl }); + const req = await requestWithTimeout(feedUrl, { timeoutMs: 30000 }); const dp = new DOMParser(); const body = dp.parseFromString(req.text, "text/xml"); diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts index c7a998a..f8bc9ba 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -15,6 +15,7 @@ type FeedCache = Record; const STORAGE_KEY = "podnotes:feed-cache:v1"; const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours. const MAX_EPISODES_PER_FEED = 75; +const MAX_CACHE_SIZE_BYTES = 4 * 1024 * 1024; // 4MB to leave room for other localStorage usage let cache: FeedCache | null = null; @@ -55,16 +56,67 @@ function loadCache(): FeedCache { } } +function evictOldestEntries(cacheData: FeedCache, targetSizeBytes: number): FeedCache { + const entries = Object.entries(cacheData); + + // Sort by updatedAt ascending (oldest first) + entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt); + + const result: FeedCache = {}; + let currentSize = 0; + + // Add entries from newest to oldest until we exceed target size + for (let i = entries.length - 1; i >= 0; i--) { + const [key, value] = entries[i]; + const entrySize = JSON.stringify({ [key]: value }).length; + + if (currentSize + entrySize <= targetSizeBytes) { + result[key] = value; + currentSize += entrySize; + } + } + + return result; +} + function persistCache(): void { const storage = getStorage(); - if (!storage) { + if (!storage || !cache) { return; } try { - storage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); + let serialized = JSON.stringify(cache); + + // If cache is too large, evict oldest entries + if (serialized.length > MAX_CACHE_SIZE_BYTES) { + console.warn( + `Feed cache size (${serialized.length} bytes) exceeds limit, evicting old entries`, + ); + cache = evictOldestEntries(cache, MAX_CACHE_SIZE_BYTES * 0.8); // Target 80% of max + serialized = JSON.stringify(cache); + } + + storage.setItem(STORAGE_KEY, serialized); } catch (error) { - console.error("Failed to persist feed cache:", error); + // Handle quota exceeded error specifically + if ( + error instanceof DOMException && + (error.name === "QuotaExceededError" || + error.name === "NS_ERROR_DOM_QUOTA_REACHED") + ) { + console.warn("localStorage quota exceeded, clearing feed cache"); + try { + // Clear cache and try again with empty cache + cache = {}; + storage.setItem(STORAGE_KEY, "{}"); + } catch { + // If we still can't write, just clear the item + storage.removeItem(STORAGE_KEY); + } + } else { + console.error("Failed to persist feed cache:", error); + } } } diff --git a/src/store/index.ts b/src/store/index.ts index d42dcd2..1e21001 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -8,6 +8,7 @@ import { ViewState } from "src/types/ViewState"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import { TFile } from "obsidian"; import type { LocalEpisode } from "src/types/LocalEpisode"; +import { getEpisodeKey } from "src/utility/episodeKey"; export const plugin = writable(); export const currentTime = writable(0); @@ -46,18 +47,51 @@ export const playedEpisodes = (() => { const store = writable<{ [key: string]: PlayedEpisode }>({}); const { subscribe, update, set } = store; + /** + * Gets played episode data, checking both composite key and legacy title-only key + * for backwards compatibility. + */ + function getPlayedEpisode( + playedEps: { [key: string]: PlayedEpisode }, + episode: Episode | null | undefined, + ): PlayedEpisode | undefined { + if (!episode) return undefined; + + const key = getEpisodeKey(episode); + // First try composite key + if (key && playedEps[key]) { + return playedEps[key]; + } + // Fall back to title-only for backwards compatibility + if (episode.title && playedEps[episode.title]) { + return playedEps[episode.title]; + } + return undefined; + } + return { subscribe, set, update, + /** + * Gets played episode data with backwards compatibility. + */ + get: (episode: Episode): PlayedEpisode | undefined => { + return getPlayedEpisode(get(store), episode); + }, setEpisodeTime: ( - episode: Episode, + episode: Episode | null | undefined, time: number, duration: number, finished: boolean, ) => { + if (!episode) return; + update((playedEpisodes) => { - playedEpisodes[episode.title] = { + const key = getEpisodeKey(episode); + if (!key) return playedEpisodes; + + playedEpisodes[key] = { title: episode.title, podcastName: episode.podcastName, time, @@ -68,29 +102,47 @@ export const playedEpisodes = (() => { return playedEpisodes; }); }, - markAsPlayed: (episode: Episode) => { + markAsPlayed: (episode: Episode | null | undefined) => { + if (!episode) return; + update((playedEpisodes) => { - const playedEpisode = playedEpisodes[episode.title] || episode; + const key = getEpisodeKey(episode); + if (!key) return playedEpisodes; - if (playedEpisode) { - playedEpisode.time = playedEpisode.duration; - playedEpisode.finished = true; - } + const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || { + title: episode.title, + podcastName: episode.podcastName, + time: 0, + duration: 0, + finished: false, + }; - playedEpisodes[episode.title] = playedEpisode; + playedEpisode.time = playedEpisode.duration; + playedEpisode.finished = true; + + playedEpisodes[key] = playedEpisode; return playedEpisodes; }); }, - markAsUnplayed: (episode: Episode) => { + markAsUnplayed: (episode: Episode | null | undefined) => { + if (!episode) return; + update((playedEpisodes) => { - const playedEpisode = playedEpisodes[episode.title] || episode; + const key = getEpisodeKey(episode); + if (!key) return playedEpisodes; - if (playedEpisode) { - playedEpisode.time = 0; - playedEpisode.finished = false; - } + const playedEpisode = getPlayedEpisode(playedEpisodes, episode) || { + title: episode.title, + podcastName: episode.podcastName, + time: 0, + duration: 0, + finished: false, + }; - playedEpisodes[episode.title] = playedEpisode; + playedEpisode.time = 0; + playedEpisode.finished = false; + + playedEpisodes[key] = playedEpisode; return playedEpisodes; }); }, @@ -313,11 +365,16 @@ export const downloadedEpisodes = (() => { const index = podcastEpisodes.findIndex( (e) => e.title === episode.title, ); - const filePath = podcastEpisodes[index].filePath; + // Guard against episode not found + if (index === -1) { + return downloadedEpisodes; + } + + const filePath = podcastEpisodes[index].filePath; podcastEpisodes.splice(index, 1); - if (removeFile) { + if (removeFile && filePath) { try { // @ts-ignore: app is not defined in the global scope anymore, but is still // available. Need to fix this later diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index 02b5aa3..31ca45f 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -6,6 +6,7 @@ import Icon from "../obsidian/Icon.svelte"; import Text from "../obsidian/Text.svelte"; import Loading from "./Loading.svelte"; + import { getEpisodeKey } from "src/utility/episodeKey"; export let episodes: Episode[] = []; export let showThumbnails: boolean = false; @@ -13,6 +14,13 @@ export let isLoading: boolean = false; let searchInputQuery: string = ""; + function isEpisodeFinished(episode: Episode | null | undefined, playedEps: typeof $playedEpisodes): boolean { + if (!episode) return false; + const key = getEpisodeKey(episode); + // Check composite key first, then fall back to title-only for backwards compat + return (key && playedEps[key]?.finished) || playedEps[episode.title]?.finished || false; + } + const dispatch = createEventDispatcher(); function forwardClickEpisode(event: CustomEvent<{ episode: Episode }>) { @@ -75,7 +83,7 @@

No episodes found.

{/if} {#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)} - {@const episodePlayed = $playedEpisodes[episode.title]?.finished} + {@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)} {#if !$hidePlayedEpisodes || !episodePlayed} { + isMounted = false; + }); $: loadingFeedNames = Array.from(loadingFeeds); $: loadingFeedSummary = @@ -146,6 +151,9 @@ } function setFeedLoading(feedTitle: string, isLoading: boolean) { + // Don't update state if component is unmounted + if (!isMounted) return; + const updatedLoadingFeeds = new Set(loadingFeeds); if (isLoading) { diff --git a/src/utility/createMediaUrlObjectFromFilePath.ts b/src/utility/createMediaUrlObjectFromFilePath.ts index b251a85..7ce1ab7 100644 --- a/src/utility/createMediaUrlObjectFromFilePath.ts +++ b/src/utility/createMediaUrlObjectFromFilePath.ts @@ -1,10 +1,65 @@ import { TFile } from "obsidian"; -export async function createMediaUrlObjectFromFilePath(filePath: string) { - const file = app.vault.getAbstractFileByPath(filePath); - if (!file || !(file instanceof TFile)) return ""; +/** + * Manages blob URLs to prevent memory leaks. + * Tracks created URLs and provides cleanup mechanism. + */ +class BlobUrlManager { + private activeUrls: Map = new Map(); - const binary = await app.vault.readBinary(file); + /** + * Creates a blob URL for a file path, cleaning up any previous URL for the same path. + */ + async createUrl(filePath: string): Promise { + // Revoke existing URL for this file path to prevent memory leak + this.revokeUrl(filePath); - return URL.createObjectURL(new Blob([binary], { type: "audio/mpeg" })); + const file = app.vault.getAbstractFileByPath(filePath); + if (!file || !(file instanceof TFile)) return ""; + + const binary = await app.vault.readBinary(file); + const url = URL.createObjectURL(new Blob([binary], { type: "audio/mpeg" })); + + this.activeUrls.set(filePath, url); + return url; + } + + /** + * Revokes a blob URL for a specific file path. + */ + revokeUrl(filePath: string): void { + const existingUrl = this.activeUrls.get(filePath); + if (existingUrl) { + URL.revokeObjectURL(existingUrl); + this.activeUrls.delete(filePath); + } + } + + /** + * Revokes all active blob URLs. Call this on plugin unload. + */ + revokeAll(): void { + for (const url of this.activeUrls.values()) { + URL.revokeObjectURL(url); + } + this.activeUrls.clear(); + } + + /** + * Returns the number of active blob URLs (for debugging). + */ + get activeCount(): number { + return this.activeUrls.size; + } +} + +// Singleton instance +export const blobUrlManager = new BlobUrlManager(); + +/** + * Creates a blob URL from a file path in the vault. + * Automatically cleans up previous URL for the same file. + */ +export async function createMediaUrlObjectFromFilePath(filePath: string): Promise { + return blobUrlManager.createUrl(filePath); } diff --git a/src/utility/episodeKey.ts b/src/utility/episodeKey.ts new file mode 100644 index 0000000..3cfa59c --- /dev/null +++ b/src/utility/episodeKey.ts @@ -0,0 +1,35 @@ +import type { Episode } from "src/types/Episode"; + +/** + * Generates a unique key for an episode. + * Uses podcastName + title to avoid collisions between episodes with the same title + * from different podcasts. + * + * Falls back to title-only for backwards compatibility with episodes that don't have podcastName. + */ +export function getEpisodeKey(episode: Episode | null | undefined): string { + if (!episode || !episode.title) { + return ""; + } + if (episode.podcastName) { + return `${episode.podcastName}::${episode.title}`; + } + // Fallback for legacy episodes without podcastName + return episode.title; +} + +/** + * Checks if an episode matches a given key. + * Handles both new composite keys and legacy title-only keys. + */ +export function episodeMatchesKey(episode: Episode | null | undefined, key: string): boolean { + if (!episode || !key) { + return false; + } + const compositeKey = getEpisodeKey(episode); + if (compositeKey === key) { + return true; + } + // Also check title-only for backwards compatibility + return episode.title === key; +} diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts new file mode 100644 index 0000000..5d5420e --- /dev/null +++ b/src/utility/networkRequest.ts @@ -0,0 +1,104 @@ +import { requestUrl, type RequestUrlResponse } from "obsidian"; + +const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds + +export class NetworkError extends Error { + constructor( + message: string, + public readonly url: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = "NetworkError"; + } +} + +export class TimeoutError extends NetworkError { + constructor(url: string, timeoutMs: number) { + super(`Request timed out after ${timeoutMs}ms`, url); + this.name = "TimeoutError"; + } +} + +/** + * Makes a network request with timeout protection. + * Throws TimeoutError if the request takes longer than the specified timeout. + * Throws NetworkError for other network-related failures. + */ +export async function requestWithTimeout( + url: string, + options: { + timeoutMs?: number; + method?: string; + headers?: Record; + body?: string; + } = {}, +): Promise { + const { timeoutMs = DEFAULT_TIMEOUT_MS, method, headers, body } = options; + + let timeoutId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new TimeoutError(url, timeoutMs)); + }, timeoutMs); + }); + + try { + const response = await Promise.race([ + requestUrl({ + url, + method, + headers, + body, + throw: false, // Don't throw on non-2xx status + }), + timeoutPromise, + ]); + + // Check for HTTP errors + if (response.status >= 400) { + throw new NetworkError( + `HTTP ${response.status}: ${response.text?.slice(0, 100) || "Unknown error"}`, + url, + ); + } + + return response; + } catch (error) { + if (error instanceof NetworkError) { + throw error; + } + throw new NetworkError( + error instanceof Error ? error.message : String(error), + url, + error, + ); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +/** + * Fetches JSON from a URL with timeout protection. + */ +export async function fetchJsonWithTimeout( + url: string, + options: { timeoutMs?: number } = {}, +): Promise { + const response = await requestWithTimeout(url, options); + return response.json as T; +} + +/** + * Fetches text from a URL with timeout protection. + */ +export async function fetchTextWithTimeout( + url: string, + options: { timeoutMs?: number } = {}, +): Promise { + const response = await requestWithTimeout(url, options); + return response.text; +} From 992c0cc96225ca339106e22e046f4c5be3fe2363 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 20:33:39 +0100 Subject: [PATCH 03/14] fix: add setter for playbackRate binding to prevent runtime error Svelte's two-way bind directive requires both getter and setter. --- src/ui/PodcastView/EpisodePlayer.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 0f4ff50..e3141f0 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -34,6 +34,10 @@ public get _playbackRate() { return this.playbackRate; } + + public set _playbackRate(_: number) { + // No-op: prevent two-way binding from overwriting our value + } } const offBinding = new CircumentForcedTwoWayBinding(); From 140720947930c762b04658a94d2b40b6f4019812 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:01:18 +0100 Subject: [PATCH 04/14] fix: artwork image now fills full container size Use padding-bottom trick instead of aspect-ratio for better compatibility. --- src/ui/PodcastView/EpisodePlayer.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index e3141f0..9d088af 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -320,14 +320,12 @@ .hover-container { width: 100%; - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; + height: 0; + padding-bottom: 100%; + display: block; position: relative; border: none; background: transparent; - padding: 0; cursor: pointer; border-radius: 0.75rem; overflow: hidden; @@ -343,11 +341,15 @@ transform: scale(0.98); } + .hover-container :global(.pn_image_container) { + position: absolute; + inset: 0; + } + :global(.podcast-artwork) { width: 100%; height: 100%; object-fit: cover; - position: absolute; transition: opacity 200ms ease; } From 53207a48f3efe42b92f8f71c79df1e607980c017 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:10:55 +0100 Subject: [PATCH 05/14] refactor: migrate from Moment.js to native date/time formatting - Replace Moment.js in formatSeconds with native time calculation - Add formatDate utility with Moment.js-compatible format tokens - Update TemplateEngine to use native formatDate --- src/TemplateEngine.ts | 9 ++--- src/utility/formatDate.ts | 69 ++++++++++++++++++++++++++++++++++++ src/utility/formatSeconds.ts | 32 +++++++++++++++-- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 src/utility/formatDate.ts diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index c3a7a01..7538547 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -4,6 +4,7 @@ import { plugin } from "src/store"; import { get } from "svelte/store"; import type { Episode } from "src/types/Episode"; import getUrlExtension from "./utility/getUrlExtension"; +import { formatDate } from "./utility/formatDate"; type TagValue = string | ((...args: string[]) => string); @@ -103,7 +104,7 @@ export function NoteTemplateEngine(template: string, episode: Episode) { addTag("url", episode.url); addTag("date", (format?: string) => episode.episodeDate - ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD") + ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", ); addTag( @@ -153,7 +154,7 @@ export function FilePathTemplateEngine(template: string, episode: Episode) { }); addTag("date", (format?: string) => episode.episodeDate - ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD") + ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", ); @@ -189,7 +190,7 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) { }); addTag("date", (format?: string) => episode.episodeDate - ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD") + ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", ); @@ -221,7 +222,7 @@ export function TranscriptTemplateEngine( }); addTag("date", (format?: string) => episode.episodeDate - ? window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD") + ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "", ); addTag("transcript", transcription); diff --git a/src/utility/formatDate.ts b/src/utility/formatDate.ts new file mode 100644 index 0000000..8ec83c1 --- /dev/null +++ b/src/utility/formatDate.ts @@ -0,0 +1,69 @@ +/** + * Formats a date using Moment.js-style format tokens for backward compatibility. + * Common tokens supported: + * - YYYY: 4-digit year, YY: 2-digit year + * - MMMM: full month, MMM: abbreviated month, MM: 2-digit month, M: month + * - DD: 2-digit day, D: day, Do: day with ordinal + * - dddd: full weekday, ddd: abbreviated weekday + * - HH: 24h hours, H: 24h hour, hh: 12h hours, h: 12h hour + * - mm: minutes, m: minute + * - ss: seconds, s: second + * - A: AM/PM, a: am/pm + */ +export function formatDate(date: Date, format: string): string { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + const weekday = date.getDay(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + + const pad = (n: number): string => n.toString().padStart(2, '0'); + + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + const monthNamesShort = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]; + const weekdayNames = [ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' + ]; + const weekdayNamesShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + const ordinal = (n: number): string => { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); + }; + + const hours12 = hours % 12 || 12; + const isPM = hours >= 12; + + // Order matters: longer tokens must be replaced before shorter ones + return format + .replace(/YYYY/g, year.toString()) + .replace(/YY/g, year.toString().slice(-2)) + .replace(/MMMM/g, monthNames[month]) + .replace(/MMM/g, monthNamesShort[month]) + .replace(/MM/g, pad(month + 1)) + .replace(/M/g, (month + 1).toString()) + .replace(/dddd/g, weekdayNames[weekday]) + .replace(/ddd/g, weekdayNamesShort[weekday]) + .replace(/Do/g, ordinal(day)) + .replace(/DD/g, pad(day)) + .replace(/D/g, day.toString()) + .replace(/HH/g, pad(hours)) + .replace(/H/g, hours.toString()) + .replace(/hh/g, pad(hours12)) + .replace(/h/g, hours12.toString()) + .replace(/mm/g, pad(minutes)) + .replace(/m/g, minutes.toString()) + .replace(/ss/g, pad(seconds)) + .replace(/s/g, seconds.toString()) + .replace(/A/g, isPM ? 'PM' : 'AM') + .replace(/a/g, isPM ? 'pm' : 'am'); +} diff --git a/src/utility/formatSeconds.ts b/src/utility/formatSeconds.ts index 78587df..92fc0a4 100644 --- a/src/utility/formatSeconds.ts +++ b/src/utility/formatSeconds.ts @@ -1,3 +1,31 @@ -export function formatSeconds(seconds: number, format: string) { - return window.moment().startOf('day').seconds(seconds).format(format); +/** + * Formats a duration in seconds to a time string. + * Supports common Moment.js-style format tokens for backward compatibility: + * - H, HH: hours (0-23, 00-23) + * - h, hh: hours (1-12, 01-12) + * - m, mm: minutes (0-59, 00-59) + * - s, ss: seconds (0-59, 00-59) + * - A: AM/PM, a: am/pm + */ +export function formatSeconds(totalSeconds: number, format: string): string { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = Math.floor(totalSeconds % 60); + + const hours12 = hours % 12 || 12; + const isPM = hours >= 12; + + const pad = (n: number): string => n.toString().padStart(2, '0'); + + return format + .replace(/HH/g, pad(hours)) + .replace(/H/g, hours.toString()) + .replace(/hh/g, pad(hours12)) + .replace(/h/g, hours12.toString()) + .replace(/mm/g, pad(minutes)) + .replace(/m/g, minutes.toString()) + .replace(/ss/g, pad(secs)) + .replace(/s/g, secs.toString()) + .replace(/A/g, isPM ? 'PM' : 'AM') + .replace(/a/g, isPM ? 'pm' : 'am'); } \ No newline at end of file From d10eeb7fb0e7ad9a1835e706e4421ed11be9bd5b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:11:02 +0100 Subject: [PATCH 06/14] feat: add podcast chapter support - Parse podcast:chapters URL from RSS feeds (Podcasting 2.0) - Fetch and parse chapter JSON data - Add ChapterList component with current chapter highlighting - Click chapters to seek to that timestamp --- src/parser/feedParser.ts | 3 + src/types/Chapter.ts | 24 ++++ src/types/Episode.ts | 2 + src/ui/PodcastView/ChapterList.svelte | 144 ++++++++++++++++++++++++ src/ui/PodcastView/EpisodePlayer.svelte | 23 ++++ src/utility/fetchChapters.ts | 29 +++++ 6 files changed, 225 insertions(+) create mode 100644 src/types/Chapter.ts create mode 100644 src/ui/PodcastView/ChapterList.svelte create mode 100644 src/utility/fetchChapters.ts diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index d32863a..7015c84 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -95,6 +95,7 @@ export default class FeedParser { const pubDateEl = item.querySelector("pubDate"); const itunesImageEl = item.querySelector("image"); const itunesTitleEl = item.getElementsByTagName("itunes:title")[0]; + const chaptersEl = item.getElementsByTagName("podcast:chapters")[0]; if (!titleEl || !streamUrlEl || !pubDateEl) { return null; @@ -109,6 +110,7 @@ export default class FeedParser { const artworkUrl = itunesImageEl?.getAttribute("href") || this.feed?.artworkUrl; const itunesTitle = itunesTitleEl?.textContent; + const chaptersUrl = chaptersEl?.getAttribute("url") || undefined; return { title, @@ -121,6 +123,7 @@ export default class FeedParser { episodeDate: pubDate, feedUrl: this.feed?.url || "", itunesTitle: itunesTitle || "", + chaptersUrl, }; } diff --git a/src/types/Chapter.ts b/src/types/Chapter.ts new file mode 100644 index 0000000..4d6455b --- /dev/null +++ b/src/types/Chapter.ts @@ -0,0 +1,24 @@ +/** + * Represents a chapter in a podcast episode. + * Based on the Podcasting 2.0 JSON Chapters format. + * @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/chapters/jsonChapters.md + */ +export interface Chapter { + /** Start time in seconds */ + startTime: number; + /** Optional end time in seconds */ + endTime?: number; + /** Chapter title */ + title: string; + /** Optional chapter artwork URL */ + img?: string; + /** Optional link URL */ + url?: string; + /** Whether this chapter should be hidden (ad, etc.) */ + toc?: boolean; +} + +export interface ChaptersData { + version: string; + chapters: Chapter[]; +} diff --git a/src/types/Episode.ts b/src/types/Episode.ts index 3e8d9ae..2299ace 100644 --- a/src/types/Episode.ts +++ b/src/types/Episode.ts @@ -9,4 +9,6 @@ export interface Episode { artworkUrl?: string; episodeDate?: Date; itunesTitle?: string; + /** URL to the podcast:chapters JSON file */ + chaptersUrl?: string; } diff --git a/src/ui/PodcastView/ChapterList.svelte b/src/ui/PodcastView/ChapterList.svelte new file mode 100644 index 0000000..de7cec6 --- /dev/null +++ b/src/ui/PodcastView/ChapterList.svelte @@ -0,0 +1,144 @@ + + +{#if chapters.length > 0} +
+ + + {#if isExpanded} +
    + {#each chapters as chapter, index} +
  • + +
  • + {/each} +
+ {/if} +
+{/if} + + diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 9d088af..3f1d1a2 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -13,15 +13,18 @@ downloadedEpisodes, } from "src/store"; import { formatSeconds } from "src/utility/formatSeconds"; + import { fetchChapters } from "src/utility/fetchChapters"; import { onDestroy, onMount } from "svelte"; import Icon from "../obsidian/Icon.svelte"; import Button from "../obsidian/Button.svelte"; import Slider from "../obsidian/Slider.svelte"; import Loading from "./Loading.svelte"; import EpisodeList from "./EpisodeList.svelte"; + import ChapterList from "./ChapterList.svelte"; import Progressbar from "../common/Progressbar.svelte"; import spawnEpisodeContextMenu from "./spawnEpisodeContextMenu"; import type { Episode } from "src/types/Episode"; + import type { Chapter } from "src/types/Chapter"; import { ViewState } from "src/types/ViewState"; import { createMediaUrlObjectFromFilePath } from "src/utility/createMediaUrlObjectFromFilePath"; import Image from "../common/Image.svelte"; @@ -47,6 +50,16 @@ let isHoveringArtwork: boolean = false; let isLoading: boolean = true; let playerVolume: number = 1; + let chapters: Chapter[] = []; + + // Fetch chapters when episode changes + $: if ($currentEpisode?.chaptersUrl) { + fetchChapters($currentEpisode.chaptersUrl).then((c) => { + chapters = c; + }); + } else { + chapters = []; + } function togglePlayback() { isPaused.update((value) => !value); @@ -98,6 +111,10 @@ volume.set(newVolume); } + function onChapterSeek(event: CustomEvent<{ time: number }>) { + currentTime.set(event.detail.time); + } + function onMetadataLoaded() { isLoading = false; @@ -289,6 +306,12 @@ + + { + if (!chaptersUrl) { + return []; + } + + try { + const response = await requestWithTimeout(chaptersUrl, { timeoutMs: 10000 }); + const data: ChaptersData = JSON.parse(response.text); + + if (!data.chapters || !Array.isArray(data.chapters)) { + return []; + } + + // Filter out hidden chapters (toc === false) and sort by start time + return data.chapters + .filter((chapter) => chapter.toc !== false) + .sort((a, b) => a.startTime - b.startTime); + } catch (error) { + console.warn("Failed to fetch chapters:", error); + return []; + } +} From 692279d50e4565a136e54d6371440828425f2f56 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:30:07 +0100 Subject: [PATCH 07/14] fix: detach leaves on plugin unload to prevent duplicate views --- src/main.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main.ts b/src/main.ts index 1b3d73d..8a97229 100644 --- a/src/main.ts +++ b/src/main.ts @@ -378,6 +378,9 @@ export default class PodNotes extends Plugin implements IPodNotes { // Clean up any active blob URLs to prevent memory leaks blobUrlManager.revokeAll(); + + // Detach all leaves of this view type to prevent duplicates on reload + this.app.workspace.detachLeavesOfType(VIEW_TYPE); } async loadSettings() { From 9d67c41ba518a5878c13a1989bf40248daa47cf5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:30:13 +0100 Subject: [PATCH 08/14] fix: move chapter fetching from reactive block to subscription Async operations in Svelte reactive statements can cause runtime errors. --- src/ui/PodcastView/EpisodePlayer.svelte | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ui/PodcastView/EpisodePlayer.svelte b/src/ui/PodcastView/EpisodePlayer.svelte index 3f1d1a2..6709e1b 100644 --- a/src/ui/PodcastView/EpisodePlayer.svelte +++ b/src/ui/PodcastView/EpisodePlayer.svelte @@ -51,15 +51,7 @@ let isLoading: boolean = true; let playerVolume: number = 1; let chapters: Chapter[] = []; - - // Fetch chapters when episode changes - $: if ($currentEpisode?.chaptersUrl) { - fetchChapters($currentEpisode.chaptersUrl).then((c) => { - chapters = c; - }); - } else { - chapters = []; - } + let lastChaptersUrl: string | undefined = undefined; function togglePlayback() { isPaused.update((value) => !value); @@ -154,8 +146,20 @@ srcPromise = getSrc($currentEpisode); }); - const unsubCurrentEpisode = currentEpisode.subscribe(_ => { + const unsubCurrentEpisode = currentEpisode.subscribe((episode) => { srcPromise = getSrc($currentEpisode); + + // Fetch chapters when episode changes + const chaptersUrl = episode?.chaptersUrl; + if (chaptersUrl && chaptersUrl !== lastChaptersUrl) { + lastChaptersUrl = chaptersUrl; + fetchChapters(chaptersUrl).then((c) => { + chapters = c; + }); + } else if (!chaptersUrl) { + lastChaptersUrl = undefined; + chapters = []; + } }); const unsubVolume = volume.subscribe((value) => { From 8a20a33e29f89feaec997f914636eb26543fc861 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:30:19 +0100 Subject: [PATCH 09/14] fix: use get() for store access inside async functions $store syntax should only be used at component top-level or templates. --- src/ui/PodcastView/PodcastView.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 2352271..c5fed59 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -108,7 +108,8 @@ const cacheTtlMs = Math.max(1, feedCacheSettings?.ttlHours ?? 6) * 60 * 60 * 1000; - const cachedEpisodesInFeed = $episodeCache[feed.title]; + const currentCache = get(episodeCache); + const cachedEpisodesInFeed = currentCache[feed.title]; if ( useCache && @@ -146,7 +147,8 @@ `Failed to fetch episodes for ${feed.title}:`, error, ); - return $downloadedEpisodes[feed.title] || []; + const downloaded = get(downloadedEpisodes); + return downloaded[feed.title] || []; } } @@ -236,7 +238,8 @@ currentSearchQuery = query; if (selectedFeed) { - const episodesInFeed = $episodeCache[selectedFeed.title] ?? []; + const cache = get(episodeCache); + const episodesInFeed = cache[selectedFeed.title] ?? []; displayedEpisodes = searchEpisodes(query, episodesInFeed); return; } From 2965af282fa8c1cc519bee70f26309b8d74c131b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:30:26 +0100 Subject: [PATCH 10/14] fix: podcast grid now scrolls properly - Use min-content for row sizing instead of 1fr - Add flex constraints and overflow-y for scroll containment --- src/ui/PodcastView/PodcastGrid.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/PodcastView/PodcastGrid.svelte b/src/ui/PodcastView/PodcastGrid.svelte index 766ecb5..5d3160e 100644 --- a/src/ui/PodcastView/PodcastGrid.svelte +++ b/src/ui/PodcastView/PodcastGrid.svelte @@ -40,9 +40,12 @@ .podcast-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); - grid-auto-rows: 1fr; + grid-auto-rows: min-content; gap: 0.5rem; padding: 0.5rem; + flex: 1; + min-height: 0; + overflow-y: auto; } @media (min-width: 400px) { From fb20e253e2221504c634b688a80fd080ed03e0b3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:39:04 +0100 Subject: [PATCH 11/14] fix(TranscriptionService): fix bugs and add tests - Fix saveTranscription to handle existing files gracefully instead of throwing - Fix nested folder creation by creating directories recursively - Limit chunk transcription concurrency to 3 to avoid API rate limits - Add comprehensive test coverage for TranscriptionService --- src/services/TranscriptionService.test.ts | 303 ++++++++++++++++++++++ src/services/TranscriptionService.ts | 40 ++- 2 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 src/services/TranscriptionService.test.ts diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts new file mode 100644 index 0000000..01b1032 --- /dev/null +++ b/src/services/TranscriptionService.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { TranscriptionService } from "./TranscriptionService"; +import type { Episode } from "src/types/Episode"; +import type PodNotes from "src/main"; + +const mockEpisode: Episode = { + title: "Test Episode", + streamUrl: "https://example.com/episode.mp3", + url: "https://example.com/episode", + description: "Test description", + content: "Test content", + podcastName: "Test Podcast", + feedUrl: "https://example.com/feed.xml", + artworkUrl: "https://example.com/artwork.jpg", + episodeDate: new Date("2024-01-01"), +}; + +function createMockPlugin(overrides: { + openAIApiKey?: string; + podcast?: Episode | null; + existingTranscriptPath?: string | null; +} = {}): PodNotes { + const { + openAIApiKey = "test-api-key", + podcast = mockEpisode, + existingTranscriptPath = null, + } = overrides; + + return { + settings: { + openAIApiKey, + transcript: { + path: "Transcripts/{{podcast}}/{{title}}.md", + template: "# {{title}}\n\n{{transcript}}", + }, + download: { + path: "Downloads", + }, + }, + api: { + podcast, + }, + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => { + if (existingTranscriptPath && path === existingTranscriptPath) { + return { path }; + } + return null; + }), + readBinary: vi.fn(), + create: vi.fn(), + createFolder: vi.fn(), + }, + workspace: { + getLeaf: vi.fn(() => ({ + openFile: vi.fn(), + })), + }, + }, + } as unknown as PodNotes; +} + +describe("TranscriptionService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("formatTime", () => { + test("formats time correctly for seconds", () => { + const formatTime = (ms: number): string => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + return `${hours.toString().padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`; + }; + + expect(formatTime(0)).toBe("00:00:00"); + expect(formatTime(1000)).toBe("00:00:01"); + expect(formatTime(60000)).toBe("00:01:00"); + expect(formatTime(3600000)).toBe("01:00:00"); + expect(formatTime(3661000)).toBe("01:01:01"); + }); + }); + + describe("getMimeType", () => { + test("returns correct mime types for audio formats", () => { + const getMimeType = (fileExtension: string): string => { + switch (fileExtension.toLowerCase()) { + case "mp3": + return "audio/mp3"; + case "m4a": + return "audio/mp4"; + case "ogg": + return "audio/ogg"; + case "wav": + return "audio/wav"; + case "flac": + return "audio/flac"; + default: + return "audio/mpeg"; + } + }; + + expect(getMimeType("mp3")).toBe("audio/mp3"); + expect(getMimeType("MP3")).toBe("audio/mp3"); + expect(getMimeType("m4a")).toBe("audio/mp4"); + expect(getMimeType("ogg")).toBe("audio/ogg"); + expect(getMimeType("wav")).toBe("audio/wav"); + expect(getMimeType("flac")).toBe("audio/flac"); + expect(getMimeType("unknown")).toBe("audio/mpeg"); + }); + }); + + describe("shouldConvertToWav", () => { + test("returns true for m4a files", () => { + const shouldConvertToWav = (extension: string, mimeType: string): boolean => { + const normalizedExtension = extension.toLowerCase(); + return normalizedExtension === "m4a" || mimeType === "audio/mp4"; + }; + + expect(shouldConvertToWav("m4a", "audio/mp4")).toBe(true); + expect(shouldConvertToWav("M4A", "audio/mp4")).toBe(true); + expect(shouldConvertToWav("mp3", "audio/mp4")).toBe(true); + expect(shouldConvertToWav("mp3", "audio/mpeg")).toBe(false); + }); + }); + + describe("getEpisodeKey", () => { + test("generates unique key from podcast name and title", () => { + const getEpisodeKey = (episode: Episode): string => { + return `${episode.podcastName}:${episode.title}`; + }; + + expect(getEpisodeKey(mockEpisode)).toBe("Test Podcast:Test Episode"); + }); + }); + + describe("createBinaryChunkFiles", () => { + const CHUNK_SIZE_BYTES = 20 * 1024 * 1024; + + function createBinaryChunkFiles( + buffer: ArrayBuffer, + basename: string, + extension: string, + mimeType: string, + ): File[] { + if (buffer.byteLength <= CHUNK_SIZE_BYTES) { + return [ + new File([buffer], `${basename}.${extension}`, { + type: mimeType, + }), + ]; + } + + const files: File[] = []; + for ( + let offset = 0, index = 0; + offset < buffer.byteLength; + offset += CHUNK_SIZE_BYTES, index++ + ) { + const chunk = buffer.slice(offset, offset + CHUNK_SIZE_BYTES); + files.push( + new File([chunk], `${basename}.part${index}.${extension}`, { + type: mimeType, + }), + ); + } + + return files; + } + + test("returns single file when buffer is smaller than chunk size", () => { + const smallBuffer = new ArrayBuffer(1024); + const files = createBinaryChunkFiles(smallBuffer, "test", "mp3", "audio/mpeg"); + + expect(files).toHaveLength(1); + expect(files[0].name).toBe("test.mp3"); + expect(files[0].type).toBe("audio/mpeg"); + expect(files[0].size).toBe(1024); + }); + + test("returns multiple files when buffer exceeds chunk size", () => { + const largeBuffer = new ArrayBuffer(CHUNK_SIZE_BYTES * 2 + 1024); + const files = createBinaryChunkFiles(largeBuffer, "test", "mp3", "audio/mpeg"); + + expect(files).toHaveLength(3); + expect(files[0].name).toBe("test.part0.mp3"); + expect(files[1].name).toBe("test.part1.mp3"); + expect(files[2].name).toBe("test.part2.mp3"); + }); + }); + + describe("writeWavHeader", () => { + const WAV_HEADER_SIZE = 44; + const PCM_BYTES_PER_SAMPLE = 2; + + function writeString(view: DataView, offset: number, str: string): void { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + } + + function writeWavHeader( + view: DataView, + sampleRate: number, + numChannels: number, + sampleCount: number, + ): void { + const blockAlign = numChannels * PCM_BYTES_PER_SAMPLE; + const byteRate = sampleRate * blockAlign; + const dataSize = sampleCount * blockAlign; + writeString(view, 0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeString(view, 8, "WAVE"); + writeString(view, 12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, numChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, PCM_BYTES_PER_SAMPLE * 8, true); + writeString(view, 36, "data"); + view.setUint32(40, dataSize, true); + } + + test("writes correct RIFF header", () => { + const buffer = new ArrayBuffer(WAV_HEADER_SIZE); + const view = new DataView(buffer); + + writeWavHeader(view, 44100, 2, 44100); + + const riff = String.fromCharCode( + view.getUint8(0), + view.getUint8(1), + view.getUint8(2), + view.getUint8(3), + ); + expect(riff).toBe("RIFF"); + + const wave = String.fromCharCode( + view.getUint8(8), + view.getUint8(9), + view.getUint8(10), + view.getUint8(11), + ); + expect(wave).toBe("WAVE"); + + const fmt = String.fromCharCode( + view.getUint8(12), + view.getUint8(13), + view.getUint8(14), + view.getUint8(15), + ); + expect(fmt).toBe("fmt "); + + const data = String.fromCharCode( + view.getUint8(36), + view.getUint8(37), + view.getUint8(38), + view.getUint8(39), + ); + expect(data).toBe("data"); + }); + + test("writes correct sample rate and channels", () => { + const buffer = new ArrayBuffer(WAV_HEADER_SIZE); + const view = new DataView(buffer); + + writeWavHeader(view, 44100, 2, 1000); + + expect(view.getUint16(22, true)).toBe(2); + expect(view.getUint32(24, true)).toBe(44100); + expect(view.getUint16(34, true)).toBe(16); + }); + }); + + describe("TranscriptionService instantiation", () => { + test("creates instance with plugin reference", () => { + const mockPlugin = createMockPlugin(); + const service = new TranscriptionService(mockPlugin); + + expect(service).toBeInstanceOf(TranscriptionService); + }); + }); + + describe("transcribeCurrentEpisode validation", () => { + test("shows notice when no API key is configured", async () => { + const mockPlugin = createMockPlugin({ openAIApiKey: "" }); + const service = new TranscriptionService(mockPlugin); + + await service.transcribeCurrentEpisode(); + }); + + test("shows notice when no episode is playing", async () => { + const mockPlugin = createMockPlugin({ podcast: null }); + const service = new TranscriptionService(mockPlugin); + + await service.transcribeCurrentEpisode(); + }); + }); +}); diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 2679324..b1e8c9c 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -57,6 +57,7 @@ export class TranscriptionService { private readonly WAV_HEADER_SIZE = 44; private readonly PCM_BYTES_PER_SAMPLE = 2; private readonly MAX_CONCURRENT_TRANSCRIPTIONS = 2; + private readonly MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS = 3; private pendingEpisodes: Episode[] = []; private activeTranscriptions = new Set(); @@ -418,6 +419,7 @@ export class TranscriptionService { const client = await this.getClient(); const transcriptions: string[] = new Array(files.length); let completedChunks = 0; + let nextIndex = 0; const updateProgress = () => { const progress = ((completedChunks / files.length) * 100).toFixed(1); @@ -428,8 +430,12 @@ export class TranscriptionService { updateProgress(); - await Promise.all( - files.map(async (file, index) => { + const worker = async () => { + while (true) { + const index = nextIndex++; + if (index >= files.length) return; + const file = files[index]; + let retries = 0; while (retries < this.MAX_RETRIES) { try { @@ -454,12 +460,20 @@ export class TranscriptionService { } else { await new Promise((resolve) => setTimeout(resolve, 1000 * retries), - ); // Exponential backoff + ); } } } - }), + } + }; + + const workerCount = Math.min( + this.MAX_CONCURRENT_CHUNK_TRANSCRIPTIONS, + files.length, ); + const workers = Array.from({ length: workerCount }, () => worker()); + + await Promise.all(workers); return transcriptions.join(" "); } @@ -481,13 +495,20 @@ export class TranscriptionService { const vault = this.plugin.app.vault; - // Ensure the directory exists + // Ensure the directory exists (create nested folders recursively) const directory = transcriptPath.substring( 0, transcriptPath.lastIndexOf("/"), ); - if (directory && !vault.getAbstractFileByPath(directory)) { - await vault.createFolder(directory); + if (directory) { + const parts = directory.split("/"); + let current = ""; + for (const part of parts) { + current = current ? `${current}/${part}` : part; + if (!vault.getAbstractFileByPath(current)) { + await vault.createFolder(current); + } + } } const file = vault.getAbstractFileByPath(transcriptPath); @@ -495,8 +516,11 @@ export class TranscriptionService { if (!file) { const newFile = await vault.create(transcriptPath, transcriptContent); await this.plugin.app.workspace.getLeaf().openFile(newFile); + } else if (file instanceof TFile) { + // File already exists - open it without overwriting + await this.plugin.app.workspace.getLeaf().openFile(file); } else { - throw new Error("Expected a file but got a folder"); + throw new Error("Expected a file but found a folder at transcript path."); } } From a30eca1cae6bdebf69be0fe42f0c30c6aebd9e94 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 21:39:11 +0100 Subject: [PATCH 12/14] fix(FeedParser): fix metadata handling and add tests - Fix getFeed() to set internal feed state for consistent episode metadata - Add findImageElement() to properly handle itunes:image namespace tags - Fix findItemByTitle() to use case-insensitive matching and avoid double parsing - Ensure getEpisodes() loads feed metadata before parsing episodes - Add comprehensive test coverage for FeedParser --- src/parser/feedParser.test.ts | 582 ++++++++++++++++++++++++++++++++++ src/parser/feedParser.ts | 63 ++-- 2 files changed, 623 insertions(+), 22 deletions(-) create mode 100644 src/parser/feedParser.test.ts diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts new file mode 100644 index 0000000..5084f5e --- /dev/null +++ b/src/parser/feedParser.test.ts @@ -0,0 +1,582 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import FeedParser from "./feedParser"; +import type { PodcastFeed } from "src/types/PodcastFeed"; + +vi.mock("src/utility/networkRequest", () => ({ + requestWithTimeout: vi.fn(), +})); + +import { requestWithTimeout } from "src/utility/networkRequest"; + +const mockRequestWithTimeout = vi.mocked(requestWithTimeout); + +const sampleRssFeed = ` + + + Test Podcast + https://example.com + + https://example.com/artwork.jpg + + + Episode 1 + + https://example.com/episode1 + First episode description + Mon, 01 Jan 2024 00:00:00 GMT + Episode 1 iTunes Title + + + Episode 2 + + https://example.com/episode2 + Second episode description + Tue, 02 Jan 2024 00:00:00 GMT + + +`; + +const sampleRssFeedWithItunesImage = ` + + + Test Podcast With iTunes Image + https://example.com + + + Episode 1 + + Mon, 01 Jan 2024 00:00:00 GMT + + + +`; + +const invalidRssFeed = ` + + + Missing title and link + +`; + +const rssFeedWithInvalidItem = ` + + + Test Podcast + https://example.com + + Valid Episode + + Mon, 01 Jan 2024 00:00:00 GMT + + + Invalid Episode - Missing enclosure + Tue, 02 Jan 2024 00:00:00 GMT + + + Invalid Episode - Missing pubDate + + + +`; + +describe("FeedParser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getFeed", () => { + test("parses feed title and URL correctly", async () => { + mockRequestWithTimeout.mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const feed = await parser.getFeed("https://example.com/feed.xml"); + + expect(feed.title).toBe("Test Podcast"); + expect(feed.url).toBe("https://example.com/feed.xml"); + }); + + test("parses artwork URL from image element", async () => { + mockRequestWithTimeout.mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const feed = await parser.getFeed("https://example.com/feed.xml"); + + expect(feed.artworkUrl).toBe("https://example.com/artwork.jpg"); + }); + + test("parses artwork URL from itunes:image href attribute", async () => { + mockRequestWithTimeout.mockResolvedValueOnce({ + text: sampleRssFeedWithItunesImage, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const feed = await parser.getFeed("https://example.com/feed.xml"); + + expect(feed.artworkUrl).toBe("https://example.com/itunes-artwork.jpg"); + }); + + test("throws error for invalid RSS feed without title", async () => { + mockRequestWithTimeout.mockResolvedValueOnce({ + text: invalidRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + + await expect(parser.getFeed("https://example.com/feed.xml")).rejects.toThrow( + "Invalid RSS feed", + ); + }); + }); + + describe("getEpisodes", () => { + test("parses all valid episodes from feed", async () => { + // getEpisodes now calls getFeed first, then parseFeed again + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes).toHaveLength(2); + expect(episodes[0].title).toBe("Episode 1"); + expect(episodes[1].title).toBe("Episode 2"); + }); + + test("parses episode properties correctly and populates feed metadata", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + const episode = episodes[0]; + expect(episode.title).toBe("Episode 1"); + expect(episode.streamUrl).toBe("https://example.com/episode1.mp3"); + expect(episode.url).toBe("https://example.com/episode1"); + expect(episode.description).toBe("First episode description"); + expect(episode.episodeDate).toEqual(new Date("Mon, 01 Jan 2024 00:00:00 GMT")); + expect(episode.itunesTitle).toBe("Episode 1 iTunes Title"); + // Feed metadata should now be populated + expect(episode.podcastName).toBe("Test Podcast"); + expect(episode.feedUrl).toBe("https://example.com/feed.xml"); + }); + + test("filters out invalid episodes missing required fields", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: rssFeedWithInvalidItem, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: rssFeedWithInvalidItem, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes).toHaveLength(1); + expect(episodes[0].title).toBe("Valid Episode"); + }); + + test("uses feed artwork when episode has no artwork", async () => { + const mockFeed: PodcastFeed = { + title: "Test Podcast", + url: "https://example.com/feed.xml", + artworkUrl: "https://example.com/feed-artwork.jpg", + }; + + mockRequestWithTimeout.mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + // When constructed with a feed, it skips calling getFeed + const parser = new FeedParser(mockFeed); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes[1].artworkUrl).toBe("https://example.com/feed-artwork.jpg"); + }); + + test("uses episode artwork from itunes:image when available", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeedWithItunesImage, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeedWithItunesImage, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes[0].artworkUrl).toBe("https://example.com/episode1-artwork.jpg"); + }); + }); + + describe("findItemByTitle", () => { + test("finds episode by exact title match", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episode = await parser.findItemByTitle( + "Episode 1", + "https://example.com/feed.xml", + ); + + expect(episode.title).toBe("Episode 1"); + expect(episode.streamUrl).toBe("https://example.com/episode1.mp3"); + }); + + test("throws error when episode not found", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + + await expect( + parser.findItemByTitle("Non-existent Episode", "https://example.com/feed.xml"), + ).rejects.toThrow("Could not find episode"); + }); + + test("finds episode with case-insensitive matching", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episode = await parser.findItemByTitle( + "EPISODE 1", + "https://example.com/feed.xml", + ); + + expect(episode.title).toBe("Episode 1"); + }); + + test("finds episode with whitespace trimming", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episode = await parser.findItemByTitle( + " Episode 1 ", + "https://example.com/feed.xml", + ); + + expect(episode.title).toBe("Episode 1"); + }); + + test("fills in missing episode data from feed", async () => { + const feedWithMissingEpisodeData = ` + + + Feed Title + https://example.com + + https://example.com/feed-artwork.jpg + + + Episode Without Artwork + + Mon, 01 Jan 2024 00:00:00 GMT + + +`; + + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: feedWithMissingEpisodeData, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: feedWithMissingEpisodeData, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episode = await parser.findItemByTitle( + "Episode Without Artwork", + "https://example.com/feed.xml", + ); + + expect(episode.artworkUrl).toBe("https://example.com/feed-artwork.jpg"); + expect(episode.podcastName).toBe("Feed Title"); + expect(episode.feedUrl).toBe("https://example.com/feed.xml"); + }); + }); + + describe("constructor", () => { + test("accepts optional feed parameter", () => { + const mockFeed: PodcastFeed = { + title: "Test Podcast", + url: "https://example.com/feed.xml", + artworkUrl: "https://example.com/artwork.jpg", + }; + + const parser = new FeedParser(mockFeed); + expect(parser).toBeInstanceOf(FeedParser); + }); + + test("works without feed parameter", () => { + const parser = new FeedParser(); + expect(parser).toBeInstanceOf(FeedParser); + }); + }); + + describe("edge cases", () => { + test("handles empty feed with no items", async () => { + const emptyFeed = ` + + + Empty Podcast + https://example.com + +`; + + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: emptyFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: emptyFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes).toHaveLength(0); + }); + + test("handles missing optional fields gracefully", async () => { + const minimalFeed = ` + + + Minimal Podcast + https://example.com + + Minimal Episode + + Mon, 01 Jan 2024 00:00:00 GMT + + +`; + + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: minimalFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: minimalFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes).toHaveLength(1); + expect(episodes[0].description).toBe(""); + expect(episodes[0].content).toBe(""); + // url falls back to feed.url when episode link is missing + expect(episodes[0].url).toBe("https://example.com/feed.xml"); + }); + + test("handles CDATA content in description", async () => { + const cdataFeed = ` + + + CDATA Podcast + https://example.com + + CDATA Episode + + HTML description

]]>
+ Mon, 01 Jan 2024 00:00:00 GMT +
+
+
`; + + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: cdataFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: cdataFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes[0].description).toBe("

HTML description

"); + }); + + test("getFeed sets internal feed state for subsequent calls", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: sampleRssFeed, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + await parser.getFeed("https://example.com/feed.xml"); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + // Episodes should have feed metadata populated + expect(episodes[0].podcastName).toBe("Test Podcast"); + expect(episodes[0].feedUrl).toBe("https://example.com/feed.xml"); + }); + }); +}); diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index 7015c84..56be610 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -10,43 +10,50 @@ export default class FeedParser { } public async findItemByTitle(title: string, url: string): Promise { + // Ensure feed metadata is loaded first + if (!this.feed || this.feed.url !== url) { + await this.getFeed(url); + } + const body = await this.parseFeed(url); const items = body.querySelectorAll("item"); + const target = title.trim().toLowerCase(); - const item = Array.from(items).find((item) => { - const parsed = this.parseItem(item); - const isMatch = parsed && parsed.title === title; - - return isMatch; - }); - - if (!item) { - throw new Error("Could not find episode"); - } + // Parse all items once and find by case-insensitive match + const episodes = Array.from(items) + .map((item) => this.parseItem(item)) + .filter((ep): ep is Episode => !!ep); - const episode = this.parseItem(item); - const feed = await this.getFeed(url); + const episode = episodes.find( + (ep) => ep.title.trim().toLowerCase() === target, + ); if (!episode) { - throw new Error("Episode is invalid."); + throw new Error("Could not find episode"); } - if (!episode.artworkUrl) { - episode.artworkUrl = feed.artworkUrl; + // Fill in any missing fields from feed metadata + if (!episode.artworkUrl && this.feed) { + episode.artworkUrl = this.feed.artworkUrl; } - if (!episode.podcastName) { - episode.podcastName = feed.title; + if (!episode.podcastName && this.feed) { + episode.podcastName = this.feed.title; } - if (!episode.feedUrl) { - episode.feedUrl = feed.url; + if (!episode.feedUrl && this.feed) { + episode.feedUrl = this.feed.url; } return episode; } public async getEpisodes(url: string): Promise { + // Ensure feed metadata is loaded and cached + if (!this.feed || this.feed.url !== url) { + await this.getFeed(url); + } + const body = await this.parseFeed(url); return this.parsePage(body); @@ -57,7 +64,7 @@ export default class FeedParser { const titleEl = body.querySelector("title"); const linkEl = body.querySelector("link"); - const itunesImageEl = body.querySelector("image"); + const itunesImageEl = this.findImageElement(body); if (!titleEl || !linkEl) { throw new Error("Invalid RSS feed"); @@ -69,11 +76,23 @@ export default class FeedParser { itunesImageEl?.querySelector("url")?.textContent || ""; - return { + const feed: PodcastFeed = { title, url, artworkUrl, }; + + this.feed = feed; + return feed; + } + + private findImageElement(doc: Document | Element): Element | null { + // Try iTunes-specific first (handles ) + const itunesImage = doc.getElementsByTagName("itunes:image")[0]; + if (itunesImage) return itunesImage; + + // Fallback to generic element + return doc.querySelector("image"); } protected parsePage(page: Document): Episode[] { @@ -93,7 +112,7 @@ export default class FeedParser { const descriptionEl = item.querySelector("description"); const contentEl = item.querySelector("*|encoded"); const pubDateEl = item.querySelector("pubDate"); - const itunesImageEl = item.querySelector("image"); + const itunesImageEl = this.findImageElement(item); const itunesTitleEl = item.getElementsByTagName("itunes:title")[0]; const chaptersEl = item.getElementsByTagName("podcast:chapters")[0]; From 9e8cd18b68cd576acc449432cb6e17dcb9119f37 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 24 Nov 2025 22:05:36 +0100 Subject: [PATCH 13/14] fix: fix crash when navigating away from Latest Episodes during fetch Gate latestEpisodesStore subscription by viewState to prevent async fetch completions from updating displayedEpisodes after EpisodeList is destroyed. Also use getEpisodeKey for stable {#each} keys. --- src/ui/PodcastView/EpisodeList.svelte | 2 +- src/ui/PodcastView/PodcastView.svelte | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ui/PodcastView/EpisodeList.svelte b/src/ui/PodcastView/EpisodeList.svelte index 31ca45f..5db185c 100644 --- a/src/ui/PodcastView/EpisodeList.svelte +++ b/src/ui/PodcastView/EpisodeList.svelte @@ -82,7 +82,7 @@ {#if episodes.length === 0 && !isLoading}

No episodes found.

{/if} - {#each episodes as episode (episode.url || episode.streamUrl || `${episode.title}-${episode.episodeDate ?? ""}`)} + {#each episodes as episode, index (getEpisodeKey(episode) ?? `${episode.title}-${episode.episodeDate ?? ""}-${index}`)} {@const episodePlayed = isEpisodeFinished(episode, $playedEpisodes)} {#if !$hidePlayedEpisodes || !episodePlayed} { const unsubscribePlaylists = playlists.subscribe((pl) => { - displayedPlaylists = [$queue, $favorites, $localFiles, ...Object.values(pl)]; + displayedPlaylists = [get(queue), get(favorites), get(localFiles), ...Object.values(pl)]; }); const unsubscribeSavedFeeds = savedFeeds.subscribe((storeValue) => { @@ -78,11 +78,20 @@ } }); + let currentViewState = get(viewState); + const unsubscribeViewState = viewState.subscribe((vs) => { + currentViewState = vs; + }); + const unsubscribeLatestEpisodes = latestEpisodesStore.subscribe( (episodes) => { latestEpisodes = episodes; - if (!selectedFeed && !selectedPlaylist) { + if ( + currentViewState === ViewState.EpisodeList && + !selectedFeed && + !selectedPlaylist + ) { displayedEpisodes = currentSearchQuery ? searchEpisodes(currentSearchQuery, episodes) : episodes; @@ -92,6 +101,7 @@ return () => { unsubscribeLatestEpisodes(); + unsubscribeViewState(); unsubscribeSavedFeeds(); unsubscribePlaylists(); }; @@ -297,7 +307,7 @@ Date: Mon, 24 Nov 2025 22:11:12 +0100 Subject: [PATCH 14/14] fix: playlist card sizing to properly contain icon and count text --- src/ui/PodcastView/PlaylistCard.svelte | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ui/PodcastView/PlaylistCard.svelte b/src/ui/PodcastView/PlaylistCard.svelte index 71f0283..5872607 100644 --- a/src/ui/PodcastView/PlaylistCard.svelte +++ b/src/ui/PodcastView/PlaylistCard.svelte @@ -33,13 +33,12 @@ justify-content: center; gap: 0.25rem; width: 100%; - aspect-ratio: 1; + min-height: 5rem; + padding: 0.75rem 0.5rem; border: 1px solid var(--background-modifier-border); border-radius: 0.5rem; text-align: center; - overflow: hidden; background: var(--background-secondary); - padding: 0.5rem; cursor: pointer; transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease; }