/** * 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; pause(): Promise; resume(): Promise; stop(): Promise; seek(seconds: number): Promise; setVolume(volume: number): Promise; setSpeed(speed: number): Promise; getPosition(): Promise; getDuration(): Promise; 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 { 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 { 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 { 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 { try { return await new Promise((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 { await this.send(["set_property", "pause", true]); this._playing = false; } async resume(): Promise { await this.send(["set_property", "pause", false]); this._playing = true; } async stop(): Promise { 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 { await this.send(["set_property", "time-pos", seconds]); this._position = seconds; } async setVolume(volume: number): Promise { const v = Math.round(volume * 100); await this.send(["set_property", "volume", v]); this._volume = v; } async setSpeed(speed: number): Promise { await this.send(["set_property", "speed", speed]); this._speed = speed; } async getPosition(): Promise { // 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 { 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 {} async pause(): Promise {} async resume(): Promise {} async stop(): Promise {} async seek(): Promise {} async setVolume(): Promise {} async setSpeed(): Promise {} async getPosition(): Promise { return 0; } async getDuration(): Promise { 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(); } }