diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 090761e..b762d91 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -13,6 +13,8 @@ */ import { createSignal, onCleanup } from "solid-js"; +import { unlinkSync } from "fs"; +import { fetchCoverArt, coverTempPath } from "../utils/cover-art"; import { createAudioBackend, detectPlayers, @@ -109,6 +111,11 @@ function registerExitTeardown(): void { } catch { /* best-effort at exit */ } + try { + unlinkSync(coverTempPath()); + } catch { + /* best-effort at exit */ + } }; process.on("exit", teardown); for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { @@ -173,6 +180,11 @@ function stopPolling(): void { } } +// ── Cover art for system Now Playing ───────────────────────────────────────── +// macOS shows the media session's albumart in the audio center; mpv reads it +// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the +// podcast cover to a temp file BEFORE playback starts, bounded to 3s. + async function play(episode: Episode): Promise { const b = ensureBackend(); setError(null); @@ -192,6 +204,9 @@ async function play(episode: Episode): Promise { const feedStore = useFeedStore(); const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); const podcastTitle = feed?.customName || feed?.podcast.title || ""; + const coverArtPath = feed?.podcast.coverUrl + ? await fetchCoverArt(feed.podcast.coverUrl) + : null; // Resume from saved progress if available and not completed const savedProgress = progressStore.get(episode.id); @@ -205,6 +220,7 @@ async function play(episode: Episode): Promise { speed: spd, startPosition: startPos > 0 ? startPos : undefined, mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title, + coverArtPath: coverArtPath ?? undefined, }); setCurrentEpisode(episode); @@ -373,6 +389,9 @@ async function switchBackend(name: BackendName): Promise { .feeds() .find((f) => f.podcast.id === ep.podcastId); const podcastTitle = feed?.customName || feed?.podcast.title || ""; + const coverArtPath = feed?.podcast.coverUrl + ? await fetchCoverArt(feed.podcast.coverUrl) + : null; await backend.play(ep.audioUrl, { startPosition: pos, volume: vol, @@ -380,6 +399,7 @@ async function switchBackend(name: BackendName): Promise { mediaTitle: podcastTitle ? `${podcastTitle} — ${ep.title}` : ep.title, + coverArtPath: coverArtPath ?? undefined, }); setIsPlaying(true); startPolling(); diff --git a/src/index.tsx b/src/index.tsx index 4b5cca0..37e2905 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -182,9 +182,18 @@ async function handlePlay(feeds: Feed[], arg: string): Promise { try { const { createAudioBackend } = await import("./utils/audio-player") + const { fetchCoverArt } = await import("./utils/cover-art") const backend = createAudioBackend() if (episodeResult.audioUrl) { - await backend.play(episodeResult.audioUrl) + // Stage the podcast cover so the system Now Playing shows + // artwork (mpv --cover-art-files), like the UI path does. + const coverArtPath = feedResult.podcast.coverUrl + ? await fetchCoverArt(feedResult.podcast.coverUrl) + : null + await backend.play(episodeResult.audioUrl, { + mediaTitle: `${feedResult.podcast.title} — ${episodeResult.title}`, + coverArtPath: coverArtPath ?? undefined, + }) console.log("Playback started (use the UI to control)") } else { console.log("No audio URL available for this episode") diff --git a/src/utils/cover-art.ts b/src/utils/cover-art.ts new file mode 100644 index 0000000..2cb4a51 --- /dev/null +++ b/src/utils/cover-art.ts @@ -0,0 +1,57 @@ +/** + * Cover-art staging for the system Now Playing session. + * + * macOS shows the media session's albumart in the audio center (Control + * Center / lock screen). mpv reads it from `--cover-art-files` (loads the + * file as an albumart video track), so the podcast cover is staged to a temp + * file BEFORE playback starts and passed to mpv. + * + * Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled + * `bun build --compile` binaries (Bun 1.3.8), timing out on any host — + * which would silently drop every cover in shipped builds. curl is present + * on macOS and Linux. Bounded: a slow cover server must never stall audio, + * so an 8s cap drops the art. + */ + +import { tmpdir } from "os"; +import { join } from "path"; +import { unlinkSync, statSync } from "fs"; + +export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg"); + +export async function fetchCoverArt(url: string): Promise { + const path = coverTempPath(); + try { + unlinkSync(path); + } catch { + /* no stale cover */ + } + try { + return await Promise.race([ + (async () => { + const proc = Bun.spawn([ + "curl", + "-sS", + "--fail", + "-m", + "8", + "--max-filesize", + "2097152", + "-o", + path, + url, + ]); + const code = await proc.exited; + if (code !== 0) return null; + try { + return statSync(path).size > 0 ? path : null; + } catch { + return null; + } + })(), + new Promise((resolve) => setTimeout(() => resolve(null), 8000)), + ]); + } catch { + return null; + } +}