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
|
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)
|
### 2. Standalone tarball (all platforms)
|
||||||
|
|
||||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
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([
|
const tar = Bun.spawnSync([
|
||||||
"tar",
|
"tar",
|
||||||
"-czf",
|
"-czf",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { dirname, join } from "path";
|
||||||
import type { Socket, Subprocess } from "bun";
|
import type { Socket, Subprocess } from "bun";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
@@ -48,6 +48,7 @@ export interface PlayOptions {
|
|||||||
volume?: number;
|
volume?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
mediaTitle?: string;
|
mediaTitle?: string;
|
||||||
|
coverArtPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Utilities ────────────────────────────────────────────────────────
|
// ── Utilities ────────────────────────────────────────────────────────
|
||||||
@@ -74,6 +75,23 @@ function mpvSocketPath(): string {
|
|||||||
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
|
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 ──────────────────────────────────────────────────────
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
||||||
|
|
||||||
@@ -101,7 +119,7 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"mpv",
|
resolveMpvBinary() ?? "mpv",
|
||||||
"--no-video",
|
"--no-video",
|
||||||
"--no-terminal",
|
"--no-terminal",
|
||||||
"--really-quiet",
|
"--really-quiet",
|
||||||
@@ -114,6 +132,12 @@ export class MpvBackend implements AudioBackend {
|
|||||||
args.push(`--force-media-title=${opts.mediaTitle}`);
|
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) {
|
if (opts?.startPosition && opts.startPosition > 0) {
|
||||||
args.push(`--start=${opts.startPosition}`);
|
args.push(`--start=${opts.startPosition}`);
|
||||||
}
|
}
|
||||||
@@ -376,7 +400,7 @@ export interface DetectedPlayer {
|
|||||||
export function detectPlayers(): DetectedPlayer[] {
|
export function detectPlayers(): DetectedPlayer[] {
|
||||||
const players: DetectedPlayer[] = [];
|
const players: DetectedPlayer[] = [];
|
||||||
|
|
||||||
const mpvPath = which("mpv");
|
const mpvPath = resolveMpvBinary();
|
||||||
if (mpvPath) {
|
if (mpvPath) {
|
||||||
players.push({
|
players.push({
|
||||||
name: "mpv",
|
name: "mpv",
|
||||||
@@ -410,13 +434,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
|||||||
if (backend) return backend;
|
if (backend) return backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
return which("mpv") ? new MpvBackend() : new NoopBackend();
|
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBackendByName(name: BackendName): AudioBackend | null {
|
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "mpv":
|
case "mpv":
|
||||||
return which("mpv") ? new MpvBackend() : null;
|
return resolveMpvBinary() ? new MpvBackend() : null;
|
||||||
case "none":
|
case "none":
|
||||||
return new NoopBackend();
|
return new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user