feat(macos): ship PodTui.app bundle so Now Playing shows our icon
mpv owns the macOS Now Playing session (it plays the audio), and an unbundled binary renders as a blank placeholder for the source-app icon. macOS has no public API for a third party to claim session ownership (MPNowPlayingSession is iOS-only; the private MRMediaRemoteSetNowPlayingApplication was removed from the shared cache), so instead we make the OWNING process carry our bundle: the darwin tarball now includes PodTui.app with mpv copied into Contents/MacOS. AudioPlayer resolves the sibling mpv first (falling back to PATH), LaunchServices attributes the process to com.mikefreno.podtui, and Control Center shows the PodTui icon + name with podcast cover art. AppIcon.icns generated from the Xcode icon-composer exports.
This commit is contained in:
@@ -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-<platform>-<arch>.tar.gz` from the latest
|
||||
|
||||
BIN
assets/App Icon/AppIcon.icns
Normal file
BIN
assets/App Icon/AppIcon.icns
Normal file
Binary file not shown.
85
build.ts
85
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"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>PodTui</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PodTui</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.mikefreno.podtui</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>podtui</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.3.1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`,
|
||||
);
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user