feat(audio): podcast cover art in system Now Playing, CLI --play included
The cover was previously only wired through the UI hook; --play skipped it. Also: Bun's fetch hangs in compiled binaries (Bun 1.3.8) — every cover download silently failed in shipped builds, timing out against any host. Cover staging now shells out to curl (present on macOS/Linux, 8s bound), shared via src/utils/cover-art.ts so the UI and CLI paths stage the same temp file and pass it to mpv as --cover-art-files (albumart track).
This commit is contained in:
@@ -13,6 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
|
import { unlinkSync } from "fs";
|
||||||
|
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
@@ -109,6 +111,11 @@ function registerExitTeardown(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* best-effort at exit */
|
/* best-effort at exit */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
unlinkSync(coverTempPath());
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
process.on("exit", teardown);
|
process.on("exit", teardown);
|
||||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
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<void> {
|
async function play(episode: Episode): Promise<void> {
|
||||||
const b = ensureBackend();
|
const b = ensureBackend();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -192,6 +204,9 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
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
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
const savedProgress = progressStore.get(episode.id);
|
||||||
@@ -205,6 +220,7 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title,
|
mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
@@ -373,6 +389,9 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
.feeds()
|
.feeds()
|
||||||
.find((f) => f.podcast.id === ep.podcastId);
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const coverArtPath = feed?.podcast.coverUrl
|
||||||
|
? await fetchCoverArt(feed.podcast.coverUrl)
|
||||||
|
: null;
|
||||||
await backend.play(ep.audioUrl, {
|
await backend.play(ep.audioUrl, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
@@ -380,6 +399,7 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
mediaTitle: podcastTitle
|
mediaTitle: podcastTitle
|
||||||
? `${podcastTitle} — ${ep.title}`
|
? `${podcastTitle} — ${ep.title}`
|
||||||
: ep.title,
|
: ep.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
startPolling();
|
startPolling();
|
||||||
|
|||||||
@@ -182,9 +182,18 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { createAudioBackend } = await import("./utils/audio-player")
|
const { createAudioBackend } = await import("./utils/audio-player")
|
||||||
|
const { fetchCoverArt } = await import("./utils/cover-art")
|
||||||
const backend = createAudioBackend()
|
const backend = createAudioBackend()
|
||||||
if (episodeResult.audioUrl) {
|
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)")
|
console.log("Playback started (use the UI to control)")
|
||||||
} else {
|
} else {
|
||||||
console.log("No audio URL available for this episode")
|
console.log("No audio URL available for this episode")
|
||||||
|
|||||||
57
src/utils/cover-art.ts
Normal file
57
src/utils/cover-art.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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<null>((resolve) => setTimeout(() => resolve(null), 8000)),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user