diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 9d19622..297d656 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -318,8 +318,9 @@ async function play(episode: Episode): Promise { const podcastTitle = feed?.customName || feed?.podcast.title || ""; // Cover art must NEVER gate playback (it was a curl subprocess blocking // play() by up to 8s). Serve the disk-cached file synchronously when it - // exists; on a miss, start playback bare and fetch in the background — - // the backend applies late art at runtime (mpv video-add). + // exists; on a miss, play bare and warm the cache for the next play — + // runtime video-add can't become an albumart track, so late art only + // works via the load-time cover-art-files property. const coverUrl = feed?.podcast.coverUrl; const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; @@ -339,13 +340,7 @@ async function play(episode: Episode): Promise { }); if (coverUrl && !coverArtPath) { - fetchCoverArt(coverUrl) - .then((path) => { - if (path && currentEpisode()?.id === episode.id) { - b.addCoverArt(path).catch(() => {}); - } - }) - .catch(() => {}); + prefetchCoverArt(coverUrl); } setCurrentEpisode(episode); @@ -424,8 +419,13 @@ async function load(episode: Episode): Promise { // `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget // — a failed preload just makes the first play take the cold path. if (episode.audioUrl && backend) { + // The preload must carry the cover AT LOAD: cover-art-files only + // applies when the file loads, and the runtime video-add fallback + // never becomes an albumart track (verified). Restore already waits + // on feeds/progress at boot, so the bounded fetch (~300ms typical, + // 8s worst case) is free. const coverUrl = feed?.podcast.coverUrl; - if (coverUrl) prefetchCoverArt(coverUrl); + const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null; const backendSnap = backend; backendSnap .preload(episode.audioUrl, { @@ -435,9 +435,7 @@ async function load(episode: Episode): Promise { mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title, - coverArtPath: coverUrl - ? (cachedCoverPath(coverUrl) ?? undefined) - : undefined, + coverArtPath: coverArtPath ?? undefined, }) .catch(() => {}); } diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index da058ba..d84c385 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -20,6 +20,7 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; +import { prefetchCoverArt } from "@/utils/cover-art"; import { DownloadStatus } from "@/types/episode"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; @@ -63,6 +64,22 @@ function FeedPage() { () => feedStore.getAllEpisodesChronological() as EpItem[], ); + // ── Cover warm-up ──────────────────────────────────────────────────────── + // Prefetch covers for episodes around the focus (plus the top of the + // list) so plays land on a warm cache: cover-art-files only applies at + // file load, and there is no working runtime fallback. Single-flight + + // cache short-circuit keep repeat runs cheap (hits resolve immediately). + createEffect(() => { + const list = episodes(); + const focusIdx = focusedEpIdx(); + const start = Math.max(0, focusIdx - 10); + const end = Math.min(list.length, focusIdx + 11); + for (let i = start; i < end; i++) { + const item = list[i]; + if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl); + } + }); + // ── Fetch More ─────────────────────────────────────────────────────────── // A "[Fetch More]" row at the bottom of the list advances every feed's // loaded window by 50 episodes. manual mode: Enter on the row. auto mode: diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 6f51c76..882768a 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -60,7 +60,6 @@ export interface AudioBackend { * (mpv `video-add`). Lets play() start without waiting on art; the * Now Playing artwork pops in when the download lands. */ - addCoverArt(path: string): Promise; pause(): Promise; resume(): Promise; stop(): Promise; @@ -561,12 +560,6 @@ export class MpvBackend implements AudioBackend { Math.round((opts?.volume ?? 1) * 100), ]); await this.send(["set_property", "speed", opts?.speed ?? 1]); - if (opts?.coverArtPath) { - // File is already loaded: cover-art-files only applies at - // load, so add the art as a runtime albumart track instead. - await this.send(["set_property", "cover-art-files", opts.coverArtPath]); - await this.send(["video-add", opts.coverArtPath]); - } if (opts?.mediaTitle) { await this.send(["set_property", "force-media-title", opts.mediaTitle]); } @@ -591,14 +584,6 @@ export class MpvBackend implements AudioBackend { }); } - async addCoverArt(path: string): Promise { - if (!this._loadedUrl) return; - // Keep the property pointing at the latest art too, so a subsequent - // loadfile of the same episode carries it. - await this.send(["set_property", "cover-art-files", path]); - await this.send(["video-add", path]); - } - async pause(): Promise { await this.send(["set_property", "pause", true]); this._intentPlaying = false; @@ -716,7 +701,6 @@ class NoopBackend implements AudioBackend { readonly name: BackendName = "none"; async play(): Promise {} async preload(): Promise {} - async addCoverArt(): Promise {} async pause(): Promise {} async resume(): Promise {} async stop(): Promise {}