fix(cover): art reaches Now Playing on first play (restore + cold cache)

Two gaps left cover art missing for the most common play paths:
- Boot-restore preloaded the episode with cachedCoverPath??undefined racing
  the fire-and-forget prefetch; a cold preload + fast-path play re-applied
  art via video-add, which produces a NON-albumart track (verified) that
  mpv's Now Playing artwork logic ignores. load() now awaits the bounded
  fetch (covers ~300ms, 8s cap) so the cover is present at load-time.
- Removed the dead video-add re-apply (fast path + addCoverArt method +
  interface) and the play() late-add fallback; a cold-cache play now
  prefetches for next time instead.
- FeedPage prefetches covers for the focus window (single-flight, cached)
  so ordinary plays land on a warm cache.
Verified: preload with awaited cover -> albumart track present (2 tracks).
This commit is contained in:
2026-08-12 10:22:49 -04:00
parent deac6081ca
commit bd7d988741
3 changed files with 28 additions and 29 deletions

View File

@@ -318,8 +318,9 @@ async function play(episode: Episode): Promise<void> {
const podcastTitle = feed?.customName || feed?.podcast.title || ""; const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Cover art must NEVER gate playback (it was a curl subprocess blocking // 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 // 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 // exists; on a miss, play bare and warm the cache for the next play
// the backend applies late art at runtime (mpv video-add). // 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 coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
@@ -339,13 +340,7 @@ async function play(episode: Episode): Promise<void> {
}); });
if (coverUrl && !coverArtPath) { if (coverUrl && !coverArtPath) {
fetchCoverArt(coverUrl) prefetchCoverArt(coverUrl);
.then((path) => {
if (path && currentEpisode()?.id === episode.id) {
b.addCoverArt(path).catch(() => {});
}
})
.catch(() => {});
} }
setCurrentEpisode(episode); setCurrentEpisode(episode);
@@ -424,8 +419,13 @@ async function load(episode: Episode): Promise<void> {
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget // `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. // — a failed preload just makes the first play take the cold path.
if (episode.audioUrl && backend) { 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; const coverUrl = feed?.podcast.coverUrl;
if (coverUrl) prefetchCoverArt(coverUrl); const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
const backendSnap = backend; const backendSnap = backend;
backendSnap backendSnap
.preload(episode.audioUrl, { .preload(episode.audioUrl, {
@@ -435,9 +435,7 @@ async function load(episode: Episode): Promise<void> {
mediaTitle: podcastTitle mediaTitle: podcastTitle
? `${podcastTitle}${episode.title}` ? `${podcastTitle}${episode.title}`
: episode.title, : episode.title,
coverArtPath: coverUrl coverArtPath: coverArtPath ?? undefined,
? (cachedCoverPath(coverUrl) ?? undefined)
: undefined,
}) })
.catch(() => {}); .catch(() => {});
} }

View File

@@ -20,6 +20,7 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
@@ -63,6 +64,22 @@ function FeedPage() {
() => feedStore.getAllEpisodesChronological() as EpItem[], () => 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 ─────────────────────────────────────────────────────────── // ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's // 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: // loaded window by 50 episodes. manual mode: Enter on the row. auto mode:

View File

@@ -60,7 +60,6 @@ export interface AudioBackend {
* (mpv `video-add`). Lets play() start without waiting on art; the * (mpv `video-add`). Lets play() start without waiting on art; the
* Now Playing artwork pops in when the download lands. * Now Playing artwork pops in when the download lands.
*/ */
addCoverArt(path: string): Promise<void>;
pause(): Promise<void>; pause(): Promise<void>;
resume(): Promise<void>; resume(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
@@ -561,12 +560,6 @@ export class MpvBackend implements AudioBackend {
Math.round((opts?.volume ?? 1) * 100), Math.round((opts?.volume ?? 1) * 100),
]); ]);
await this.send(["set_property", "speed", opts?.speed ?? 1]); 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) { if (opts?.mediaTitle) {
await this.send(["set_property", "force-media-title", 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<void> {
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<void> { async pause(): Promise<void> {
await this.send(["set_property", "pause", true]); await this.send(["set_property", "pause", true]);
this._intentPlaying = false; this._intentPlaying = false;
@@ -716,7 +701,6 @@ class NoopBackend implements AudioBackend {
readonly name: BackendName = "none"; readonly name: BackendName = "none";
async play(): Promise<void> {} async play(): Promise<void> {}
async preload(): Promise<void> {} async preload(): Promise<void> {}
async addCoverArt(): Promise<void> {}
async pause(): Promise<void> {} async pause(): Promise<void> {}
async resume(): Promise<void> {} async resume(): Promise<void> {}
async stop(): Promise<void> {} async stop(): Promise<void> {}