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.
448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
/**
|
|
* Audio playback engine for PodTUI.
|
|
*
|
|
* Single backend: mpv — full IPC control (seek, volume, speed, position
|
|
* tracking), so speed/volume/seek changes apply instantly with no process
|
|
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
|
* surfaces "No audio player found" honestly rather than degrading through
|
|
* players that can't change speed/volume without restarting.
|
|
*/
|
|
|
|
import { platform } from "os";
|
|
import { existsSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { dirname, join } from "path";
|
|
import type { Socket, Subprocess } from "bun";
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────
|
|
|
|
export type BackendName = "mpv" | "none";
|
|
|
|
export interface AudioState {
|
|
playing: boolean;
|
|
position: number;
|
|
duration: number;
|
|
volume: number;
|
|
speed: number;
|
|
backend: BackendName;
|
|
error: string | null;
|
|
}
|
|
|
|
export interface AudioBackend {
|
|
readonly name: BackendName;
|
|
play(url: string, opts?: PlayOptions): Promise<void>;
|
|
pause(): Promise<void>;
|
|
resume(): Promise<void>;
|
|
stop(): Promise<void>;
|
|
seek(seconds: number): Promise<void>;
|
|
setVolume(volume: number): Promise<void>;
|
|
setSpeed(speed: number): Promise<void>;
|
|
getPosition(): Promise<number>;
|
|
getDuration(): Promise<number>;
|
|
isPlaying(): boolean;
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface PlayOptions {
|
|
startPosition?: number;
|
|
volume?: number;
|
|
speed?: number;
|
|
mediaTitle?: string;
|
|
coverArtPath?: string;
|
|
}
|
|
|
|
// ── Utilities ────────────────────────────────────────────────────────
|
|
|
|
function which(cmd: string): string | null {
|
|
const resolved = Bun.which(cmd);
|
|
if (resolved) return resolved;
|
|
|
|
if (platform() === "darwin") {
|
|
const candidates = [
|
|
`/opt/homebrew/bin/${cmd}`,
|
|
`/usr/local/bin/${cmd}`,
|
|
`/usr/bin/${cmd}`,
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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.
|
|
|
|
export class MpvBackend implements AudioBackend {
|
|
readonly name: BackendName = "mpv";
|
|
private proc: Subprocess | null = null;
|
|
private socketPath = mpvSocketPath();
|
|
private _playing = false;
|
|
private _position = 0;
|
|
private _duration = 0;
|
|
private _volume = 100;
|
|
private _speed = 1;
|
|
|
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
|
await this.stop();
|
|
|
|
// Clean up stale socket
|
|
try {
|
|
if (existsSync(this.socketPath)) {
|
|
const { unlinkSync } = await import("fs");
|
|
unlinkSync(this.socketPath);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
const args = [
|
|
resolveMpvBinary() ?? "mpv",
|
|
"--no-video",
|
|
"--no-terminal",
|
|
"--really-quiet",
|
|
`--input-ipc-server=${this.socketPath}`,
|
|
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
|
|
`--speed=${opts?.speed ?? 1}`,
|
|
];
|
|
|
|
if (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) {
|
|
args.push(`--start=${opts.startPosition}`);
|
|
}
|
|
|
|
args.push(url);
|
|
|
|
this.proc = Bun.spawn(args, {
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
stdin: "ignore",
|
|
});
|
|
|
|
this._playing = true;
|
|
this._position = opts?.startPosition ?? 0;
|
|
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
|
this._speed = opts?.speed ?? 1;
|
|
|
|
// Wait for socket to appear (mpv creates it async)
|
|
await this.waitForSocket(2000);
|
|
|
|
// Position is fetched live from mpv on each getPosition() call (see
|
|
// below) — the UI polls it, so no internal poll timer is needed.
|
|
|
|
// Detect process exit
|
|
this.proc.exited
|
|
.then(() => {
|
|
this._playing = false;
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
private async waitForSocket(timeoutMs: number): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (existsSync(this.socketPath)) return;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
}
|
|
|
|
/** Send a fire-and-forget command (no response needed) */
|
|
private async send(command: unknown[]): Promise<void> {
|
|
try {
|
|
const conn = await Bun.connect({
|
|
unix: this.socketPath,
|
|
socket: {
|
|
data() {},
|
|
error() {},
|
|
close() {},
|
|
open() {},
|
|
},
|
|
});
|
|
conn.write(JSON.stringify({ command }) + "\n");
|
|
// Don't wait, just schedule a close
|
|
setTimeout(() => {
|
|
try {
|
|
conn.end();
|
|
} catch {}
|
|
}, 50);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a property value from mpv via IPC.
|
|
*
|
|
* Resolves the parsed numeric value, or `undefined` when the read fails
|
|
* (socket error, timeout, unparseable response, or the property being
|
|
* unavailable — e.g. `time-pos` before playback starts). Failure is
|
|
* distinct from a legitimate `0` so callers can keep the last known
|
|
* value instead of snapping the position clock to zero on a transient
|
|
* error; the next poll retries.
|
|
*
|
|
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
|
|
* onto the same connection, so we line-buffer and only settle on the
|
|
* line that carries the command response (`request_id` set). The socket
|
|
* is closed once the response is handled — leaving it open leaks an fd
|
|
* per poll, while closing it before mpv processes the request drops the
|
|
* reply.
|
|
*/
|
|
private async getProperty(name: string): Promise<number | undefined> {
|
|
try {
|
|
return await new Promise<number | undefined>((resolve) => {
|
|
let settled = false;
|
|
let sock: Socket | null = null;
|
|
let buf = "";
|
|
const done = (value: number | undefined) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
try {
|
|
sock?.end();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
resolve(value);
|
|
};
|
|
const timeout = setTimeout(() => done(undefined), 300);
|
|
|
|
Bun.connect({
|
|
unix: this.socketPath,
|
|
socket: {
|
|
open(socket) {
|
|
sock = socket;
|
|
socket.write(
|
|
JSON.stringify({ command: ["get_property", name] }) + "\n",
|
|
);
|
|
},
|
|
data(_socket, data) {
|
|
buf += Buffer.from(data).toString();
|
|
let nl = buf.indexOf("\n");
|
|
while (nl !== -1) {
|
|
const line = buf.slice(0, nl);
|
|
buf = buf.slice(nl + 1);
|
|
nl = buf.indexOf("\n");
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
// Events carry no request_id; only settle on
|
|
// the actual command response.
|
|
if (parsed?.request_id === undefined) continue;
|
|
if (parsed?.data !== undefined) {
|
|
done(Number(parsed.data) || 0);
|
|
} else {
|
|
done(undefined);
|
|
}
|
|
return;
|
|
} catch {
|
|
/* skip malformed lines */
|
|
}
|
|
}
|
|
},
|
|
error() {
|
|
done(undefined);
|
|
},
|
|
close() {
|
|
done(undefined);
|
|
},
|
|
},
|
|
}).catch(() => done(undefined));
|
|
});
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async pause(): Promise<void> {
|
|
await this.send(["set_property", "pause", true]);
|
|
this._playing = false;
|
|
}
|
|
|
|
async resume(): Promise<void> {
|
|
await this.send(["set_property", "pause", false]);
|
|
this._playing = true;
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (this.proc) {
|
|
try {
|
|
this.proc.kill();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
this.proc = null;
|
|
}
|
|
this._playing = false;
|
|
this._position = 0;
|
|
|
|
// Clean up socket
|
|
try {
|
|
if (existsSync(this.socketPath)) {
|
|
const { unlinkSync } = await import("fs");
|
|
unlinkSync(this.socketPath);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async seek(seconds: number): Promise<void> {
|
|
await this.send(["set_property", "time-pos", seconds]);
|
|
this._position = seconds;
|
|
}
|
|
|
|
async setVolume(volume: number): Promise<void> {
|
|
const v = Math.round(volume * 100);
|
|
await this.send(["set_property", "volume", v]);
|
|
this._volume = v;
|
|
}
|
|
|
|
async setSpeed(speed: number): Promise<void> {
|
|
await this.send(["set_property", "speed", speed]);
|
|
this._speed = speed;
|
|
}
|
|
|
|
async getPosition(): Promise<number> {
|
|
// Live-fetch `time-pos` so the position clock is as fresh as the
|
|
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
|
|
// failure, keep the last known value rather than returning 0.
|
|
if (this._playing && this.proc) {
|
|
const pos = await this.getProperty("time-pos");
|
|
if (pos !== undefined) this._position = pos;
|
|
}
|
|
return this._position;
|
|
}
|
|
|
|
async getDuration(): Promise<number> {
|
|
if (this._duration <= 0) {
|
|
const dur = await this.getProperty("duration");
|
|
if (dur !== undefined && dur > 0) this._duration = dur;
|
|
}
|
|
return this._duration;
|
|
}
|
|
|
|
isPlaying(): boolean {
|
|
return this._playing;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.stop();
|
|
}
|
|
}
|
|
|
|
// ── No-op Backend ────────────────────────────────────────────────────
|
|
|
|
class NoopBackend implements AudioBackend {
|
|
readonly name: BackendName = "none";
|
|
async play(): Promise<void> {}
|
|
async pause(): Promise<void> {}
|
|
async resume(): Promise<void> {}
|
|
async stop(): Promise<void> {}
|
|
async seek(): Promise<void> {}
|
|
async setVolume(): Promise<void> {}
|
|
async setSpeed(): Promise<void> {}
|
|
async getPosition(): Promise<number> {
|
|
return 0;
|
|
}
|
|
async getDuration(): Promise<number> {
|
|
return 0;
|
|
}
|
|
isPlaying(): boolean {
|
|
return false;
|
|
}
|
|
dispose(): void {}
|
|
}
|
|
|
|
// ── Detection & Factory ──────────────────────────────────────────────
|
|
|
|
export interface DetectedPlayer {
|
|
name: BackendName;
|
|
path: string | null;
|
|
capabilities: {
|
|
seek: boolean;
|
|
volume: boolean;
|
|
speed: boolean;
|
|
positionTracking: boolean;
|
|
};
|
|
}
|
|
|
|
/** Detect all available audio players on this system. */
|
|
export function detectPlayers(): DetectedPlayer[] {
|
|
const players: DetectedPlayer[] = [];
|
|
|
|
const mpvPath = resolveMpvBinary();
|
|
if (mpvPath) {
|
|
players.push({
|
|
name: "mpv",
|
|
path: mpvPath,
|
|
capabilities: {
|
|
seek: true,
|
|
volume: true,
|
|
speed: true,
|
|
positionTracking: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
return players;
|
|
}
|
|
|
|
/** Create the best available audio backend. */
|
|
export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
|
// Testability hook: allow forcing a backend (incl. "none") via env so the
|
|
// harness can run silently during replay and opt into real playback per turn.
|
|
// An explicit `preferred` argument still wins.
|
|
if (!preferred) {
|
|
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
|
|
if (envPref && (envPref === "mpv" || envPref === "none")) {
|
|
preferred = envPref;
|
|
}
|
|
}
|
|
|
|
if (preferred) {
|
|
const backend = createBackendByName(preferred);
|
|
if (backend) return backend;
|
|
}
|
|
|
|
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
|
|
}
|
|
|
|
function createBackendByName(name: BackendName): AudioBackend | null {
|
|
switch (name) {
|
|
case "mpv":
|
|
return resolveMpvBinary() ? new MpvBackend() : null;
|
|
case "none":
|
|
return new NoopBackend();
|
|
}
|
|
}
|