diff --git a/README.md b/README.md index 143690c..5b634a9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ Linux (arm64/x64). Pick whichever fits your platform. brew install mikefreno/tap/podtui ``` +On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio +through a copy of mpv that lives **inside the bundle**, so macOS attributes +the Now Playing session to PodTui — the Control Center / lock-screen entry +shows the PodTui name and icon, and podcast cover art as its artwork — +rather than a blank placeholder for an unbundled binary. Installers can drop +`PodTui.app` into `/Applications`; the `podtui` entry point should point at +`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used. + ### 2. Standalone tarball (all platforms) Grab `podtui--.tar.gz` from the latest diff --git a/assets/App Icon/AppIcon.icns b/assets/App Icon/AppIcon.icns new file mode 100644 index 0000000..c2326d3 Binary files /dev/null and b/assets/App Icon/AppIcon.icns differ diff --git a/build.ts b/build.ts index b41e091..02d25c6 100644 --- a/build.ts +++ b/build.ts @@ -131,6 +131,91 @@ if (COMPILE) { } } + // macOS app bundle: PodTui.app. We run our audio backend (mpv) from + // INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now + // Playing session to PodTui — the source-app icon + name in Control + // Center / lock screen — instead of a blank placeholder for an + // unbundled binary. AudioPlayer's resolver prefers this sibling. + if (platform === "darwin") { + const appRoot = join(tarRoot, "PodTui.app"); + const macosDir = join(appRoot, "Contents", "MacOS"); + const resDir = join(appRoot, "Contents", "Resources"); + mkdirSync(macosDir, { recursive: true }); + mkdirSync(resDir, { recursive: true }); + + copyFileSync(outfile, join(macosDir, "podtui")); + for (const lib of [`libopentui.${libExt}`, cavacoreLib]) { + const s = join("dist", lib); + if (existsSync(s)) copyFileSync(s, join(macosDir, lib)); + } + + const mpvResolve = Bun.spawnSync(["which", "mpv"]); + const mpvPath = + mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : ""; + if (mpvPath) { + copyFileSync(mpvPath, join(macosDir, "mpv")); + } else { + console.warn( + "Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)", + ); + } + + const icnsSrc = join("assets", "App Icon", "AppIcon.icns"); + if (existsSync(icnsSrc)) { + copyFileSync(icnsSrc, join(resDir, "AppIcon.icns")); + } else { + console.warn( + "Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon", + ); + } + + // Keep CFBundleShortVersionString in sync with src/index.tsx VERSION. + Bun.write( + join(appRoot, "Contents", "Info.plist"), + ` + + + + CFBundleName + PodTui + CFBundleDisplayName + PodTui + CFBundleIdentifier + com.mikefreno.podtui + CFBundleExecutable + podtui + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + CFBundleShortVersionString + 0.3.1 + CFBundleVersion + 0.3.1 + LSMinimumSystemVersion + 12.0 + + +`, + ); + + // Ad-hoc sign so the bundle launches cleanly on fresh machines. + const sign = Bun.spawnSync([ + "codesign", + "--force", + "--deep", + "-s", + "-", + appRoot, + ]); + if (sign.exitCode !== 0) { + console.warn( + `Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`, + ); + } + console.log(`App bundle: ${appRoot}`); + } + const tar = Bun.spawnSync([ "tar", "-czf", diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 97dafb1..9eb2ff6 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -11,7 +11,7 @@ import { platform } from "os"; import { existsSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import type { Socket, Subprocess } from "bun"; // ── Types ──────────────────────────────────────────────────────────── @@ -48,6 +48,7 @@ export interface PlayOptions { volume?: number; speed?: number; mediaTitle?: string; + coverArtPath?: string; } // ── Utilities ──────────────────────────────────────────────────────── @@ -74,6 +75,23 @@ function mpvSocketPath(): string { return join(tmpdir(), `podtui-mpv-${process.pid}.sock`); } +/** + * mpv executable to use. Prefers a sibling `mpv` inside the app bundle + * (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle + * makes macOS attribute its Now Playing session to PodTui — source-app icon + * and name in Control Center — instead of a blank placeholder for an + * unbundled binary. Falls back to PATH so dev runs and Linux keep working. + */ +function resolveMpvBinary(): string | null { + try { + const bundled = join(dirname(process.execPath), "mpv"); + if (existsSync(bundled)) return bundled; + } catch { + /* process.execPath unusable — fall through to PATH */ + } + return which("mpv"); +} + // ── mpv Backend ────────────────────────────────────────────────────── // Uses JSON IPC over a Unix socket for full bidirectional control. @@ -101,7 +119,7 @@ export class MpvBackend implements AudioBackend { } const args = [ - "mpv", + resolveMpvBinary() ?? "mpv", "--no-video", "--no-terminal", "--really-quiet", @@ -114,6 +132,12 @@ export class MpvBackend implements AudioBackend { args.push(`--force-media-title=${opts.mediaTitle}`); } + if (opts?.coverArtPath) { + // Explicit cover file → albumart track → macOS Now Playing artwork + // (works for remote streams, not just local downloads). + args.push(`--cover-art-files=${opts.coverArtPath}`); + } + if (opts?.startPosition && opts.startPosition > 0) { args.push(`--start=${opts.startPosition}`); } @@ -376,7 +400,7 @@ export interface DetectedPlayer { export function detectPlayers(): DetectedPlayer[] { const players: DetectedPlayer[] = []; - const mpvPath = which("mpv"); + const mpvPath = resolveMpvBinary(); if (mpvPath) { players.push({ name: "mpv", @@ -410,13 +434,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend { if (backend) return backend; } - return which("mpv") ? new MpvBackend() : new NoopBackend(); + return resolveMpvBinary() ? new MpvBackend() : new NoopBackend(); } function createBackendByName(name: BackendName): AudioBackend | null { switch (name) { case "mpv": - return which("mpv") ? new MpvBackend() : null; + return resolveMpvBinary() ? new MpvBackend() : null; case "none": return new NoopBackend(); }