feat(media): expose podcast name + cover art to system Now Playing

- mpv: --force-media-title '<podcast> — <episode>' so macOS Now Playing shows
  the podcast name instead of the download-hash filename (mediaTitle PlayOption)
- media registry: artist is now the human podcast title (customName ||
  podcast.title), falling back to podcastId
- downloads: write the podcast cover as a <base>.jpg sibling so mpv's
  cover-art-auto=exact picks it up for artwork; delete it with the download
This commit is contained in:
2026-08-10 16:50:08 -04:00
parent d2c46631ef
commit 5dce21c038
3 changed files with 46 additions and 1 deletions

View File

@@ -189,6 +189,10 @@ async function play(episode: Episode): Promise<void> {
const vol = volume(); const vol = volume();
const spd = storeSpeed || speed(); const spd = storeSpeed || speed();
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// 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);
let startPos = 0; let startPos = 0;
@@ -200,6 +204,7 @@ async function play(episode: Episode): Promise<void> {
volume: vol, volume: vol,
speed: spd, speed: spd,
startPosition: startPos > 0 ? startPos : undefined, startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
}); });
setCurrentEpisode(episode); setCurrentEpisode(episode);
@@ -212,7 +217,7 @@ async function play(episode: Episode): Promise<void> {
const media = useMediaRegistry(); const media = useMediaRegistry();
media.setNowPlaying({ media.setNowPlaying({
title: episode.title, title: episode.title,
artist: episode.podcastId, artist: podcastTitle || episode.podcastId,
duration: episode.duration, duration: episode.duration,
}); });
media.setPlaybackState(true); media.setPlaybackState(true);
@@ -363,10 +368,18 @@ async function switchBackend(name: BackendName): Promise<void> {
// Resume playback if we were playing // Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) { if (wasPlaying && ep && ep.audioUrl) {
try { try {
const feedStore = useFeedStore();
const feed = feedStore
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
await backend.play(ep.audioUrl, { await backend.play(ep.audioUrl, {
startPosition: pos, startPosition: pos,
volume: vol, volume: vol,
speed: spd, speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
}); });
setIsPlaying(true); setIsPlaying(true);
startPolling(); startPolling();

View File

@@ -12,6 +12,7 @@ import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode"; import type { Episode } from "../types/episode";
import { downloadEpisode } from "../utils/episode-downloader"; import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir"; import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
import { useFeedStore } from "./feed";
const DOWNLOADS_FILE = "downloads.json"; const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2; const MAX_CONCURRENT = 2;
@@ -201,6 +202,27 @@ function createDownloadStore() {
speed: 0, speed: 0,
error: null, error: null,
}); });
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art.
const coverUrl = useFeedStore()
.feeds()
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
if (coverUrl && result.filePath) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
fetch(coverUrl)
.then(async (r) => {
if (!r.ok) return;
await Bun.write(
coverPath,
new Uint8Array(await r.arrayBuffer()),
);
})
.catch(() => {});
}
}
} else { } else {
updateDownload(item.episodeId, { updateDownload(item.episodeId, {
status: DownloadStatus.FAILED, status: DownloadStatus.FAILED,
@@ -306,6 +328,11 @@ function createDownloadStore() {
try { try {
const { unlink } = await import("fs/promises"); const { unlink } = await import("fs/promises");
await unlink(dl.filePath); await unlink(dl.filePath);
const dot = dl.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = dl.filePath.slice(0, dot) + ".jpg";
await unlink(coverPath);
}
} catch { } catch {
// File may already be gone // File may already be gone
} }

View File

@@ -47,6 +47,7 @@ export interface PlayOptions {
startPosition?: number; startPosition?: number;
volume?: number; volume?: number;
speed?: number; speed?: number;
mediaTitle?: string;
} }
// ── Utilities ──────────────────────────────────────────────────────── // ── Utilities ────────────────────────────────────────────────────────
@@ -109,6 +110,10 @@ export class MpvBackend implements AudioBackend {
`--speed=${opts?.speed ?? 1}`, `--speed=${opts?.speed ?? 1}`,
]; ];
if (opts?.mediaTitle) {
args.push(`--force-media-title=${opts.mediaTitle}`);
}
if (opts?.startPosition && opts.startPosition > 0) { if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`); args.push(`--start=${opts.startPosition}`);
} }