audio playback fixes

This commit is contained in:
2026-08-07 19:28:57 -04:00
parent 52e9ae0ab7
commit 91a831c5f9
8 changed files with 102 additions and 630 deletions

View File

@@ -1,14 +1,11 @@
/**
* Cross-platform audio playback engine for PodTUI.
* Audio playback engine for PodTUI.
*
* Backend priority:
* 1. mpv — full IPC control (seek, volume, speed, position tracking)
* 2. ffplay — basic control via process signals
* 3. afplay — macOS built-in (no seek/speed, volume only)
* 4. system — open/xdg-open/start (fire-and-forget, no control)
*
* All backends implement the AudioBackend interface so the Player
* component doesn't need to care which one is active.
* 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";
@@ -18,7 +15,7 @@ import { join } from "path";
// ── Types ────────────────────────────────────────────────────────────
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none";
export type BackendName = "mpv" | "none";
export interface AudioState {
playing: boolean;
@@ -381,467 +378,6 @@ export class MpvBackend implements AudioBackend {
}
}
// ── ffplay Backend ───────────────────────────────────────────────────
// ffplay has no IPC. We track duration from episode metadata and
// position via elapsed wall-clock time. Seek requires restarting.
class FfplayBackend implements AudioBackend {
readonly name: BackendName = "ffplay";
private proc: ReturnType<typeof Bun.spawn> | null = null;
private _playing = false;
private _paused = false;
private _position = 0;
private _duration = 0;
private _volume = 100;
private _speed = 1;
private _url = "";
private startTime = 0;
private pollTimer: ReturnType<typeof setInterval> | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
this._url = url;
this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1;
this._position = opts?.startPosition ?? 0;
this.spawnProcess();
}
private spawnProcess(): void {
const args = [
"ffplay",
"-nodisp",
"-autoexit",
"-loglevel",
"quiet",
"-volume",
String(this._volume),
];
if (this._position > 0) {
args.push("-ss", String(this._position));
}
if (this._speed !== 1) {
args.push("-af", `atempo=${this._speed}`);
}
args.push("-i", this._url);
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
this._playing = true;
this._paused = false;
this.startTime = Date.now();
this.startPolling();
this.proc.exited
.then(() => {
this._playing = false;
this.stopPolling();
})
.catch(() => {});
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(() => {
if (!this._playing) return;
const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed;
this._position = this._position + elapsed;
this.startTime = Date.now();
}, 500);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
async pause(): Promise<void> {
if (this.proc) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
try {
if (pid) process.kill(pid, "SIGSTOP");
} catch {}
this._paused = true;
}
this._playing = false;
this.stopPolling();
}
async resume(): Promise<void> {
if (!this._url) return;
if (this.proc && this._paused) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
try {
if (pid) process.kill(pid, "SIGCONT");
} catch {}
this._paused = false;
this._playing = true;
this.startTime = Date.now();
this.startPolling();
return;
}
this.spawnProcess();
}
async stop(): Promise<void> {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this._playing = false;
this._paused = false;
this._position = 0;
this._url = "";
}
async seek(seconds: number): Promise<void> {
this._position = seconds;
if (this._playing && this._url) {
// Restart at new position
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this.spawnProcess();
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
if (this._paused && pid) {
try {
process.kill(pid, "SIGSTOP");
} catch {}
this._playing = false;
}
}
}
async setVolume(volume: number): Promise<void> {
this._volume = Math.round(volume * 100);
// ffplay has no runtime IPC; volume will apply on next play/resume.
// Restart the process to apply immediately if currently playing.
if (this._url && (this._playing || this._paused)) {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this.spawnProcess();
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
if (this._paused && pid) {
try {
process.kill(pid, "SIGSTOP");
} catch {}
this._playing = false;
}
}
}
async setSpeed(speed: number): Promise<void> {
this._speed = speed;
if (this._url && (this._playing || this._paused)) {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this.spawnProcess();
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
if (this._paused && pid) {
try {
process.kill(pid, "SIGSTOP");
} catch {}
this._playing = false;
}
}
}
async getPosition(): Promise<number> {
return this._position;
}
async getDuration(): Promise<number> {
return this._duration;
}
isPlaying(): boolean {
return this._playing;
}
dispose(): void {
this.stop();
}
}
// ── afplay Backend (macOS) ───────────────────────────────────────────
// Built-in on macOS. Supports volume and rate but no seek or position.
class AfplayBackend implements AudioBackend {
readonly name: BackendName = "afplay";
private proc: ReturnType<typeof Bun.spawn> | null = null;
private _playing = false;
private _paused = false;
private _position = 0;
private _duration = 0;
private _volume = 1;
private _speed = 1;
private _url = "";
private startTime = 0;
private pollTimer: ReturnType<typeof setInterval> | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
this._url = url;
this._volume = opts?.volume ?? 1;
this._speed = opts?.speed ?? 1;
this._position = opts?.startPosition ?? 0;
this.spawnProcess();
}
private spawnProcess(): void {
// afplay supports --volume (0-1) and --rate
const args = [
"afplay",
"--volume",
String(this._volume),
"--rate",
String(this._speed),
];
if (this._position > 0) {
args.push(
"--time",
String(this._duration > 0 ? this._duration - this._position : 0),
);
}
args.push(this._url);
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
this._playing = true;
this._paused = false;
this.startTime = Date.now();
this.startPolling();
this.proc.exited
.then(() => {
this._playing = false;
this.stopPolling();
})
.catch(() => {});
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(() => {
if (!this._playing) return;
const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed;
this._position = this._position + elapsed;
this.startTime = Date.now();
}, 500);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
async pause(): Promise<void> {
if (this.proc) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
try {
if (pid) process.kill(pid, "SIGSTOP");
} catch {}
this._paused = true;
}
this._playing = false;
this.stopPolling();
}
async resume(): Promise<void> {
if (!this._url) return;
if (this.proc && this._paused) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
try {
if (pid) process.kill(pid, "SIGCONT");
} catch {}
this._paused = false;
this._playing = true;
this.startTime = Date.now();
this.startPolling();
return;
}
this.spawnProcess();
}
async stop(): Promise<void> {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this._playing = false;
this._paused = false;
this._position = 0;
this._url = "";
}
async seek(seconds: number): Promise<void> {
this._position = seconds;
if (this._playing && this._url) {
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this.spawnProcess();
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
if (this._paused && pid) {
try {
process.kill(pid, "SIGSTOP");
} catch {}
this._playing = false;
}
}
}
async setVolume(volume: number): Promise<void> {
this._volume = volume;
// Restart the process with new volume to apply immediately
if (this._url && (this._playing || this._paused)) {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this.spawnProcess();
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
if (this._paused && pid) {
try {
process.kill(pid, "SIGSTOP");
} catch {}
this._playing = false;
}
}
}
async setSpeed(speed: number): Promise<void> {
this._speed = speed;
// Restart the process with new rate to apply immediately
if (this._url && (this._playing || this._paused)) {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
} catch {}
this.proc = null;
}
this.spawnProcess();
const pid = (this.proc as unknown as { pid?: number } | null)?.pid;
if (this._paused && pid) {
try {
process.kill(pid, "SIGSTOP");
} catch {}
this._playing = false;
}
}
}
async getPosition(): Promise<number> {
return this._position;
}
async getDuration(): Promise<number> {
return this._duration;
}
isPlaying(): boolean {
return this._playing;
}
dispose(): void {
this.stop();
}
}
// ── System Backend (open/xdg-open) ───────────────────────────────────
// Fire-and-forget. Opens the URL in the default handler. No control.
class SystemBackend implements AudioBackend {
readonly name: BackendName = "system";
private _playing = false;
async play(url: string): Promise<void> {
const os = platform();
const cmd =
os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
Bun.spawn([cmd, url], {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
this._playing = true;
}
async pause(): Promise<void> {
this._playing = false;
}
async resume(): Promise<void> {
this._playing = true;
}
async stop(): Promise<void> {
this._playing = false;
}
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 this._playing;
}
dispose(): void {
this._playing = false;
}
}
// ── No-op Backend ────────────────────────────────────────────────────
class NoopBackend implements AudioBackend {
@@ -896,53 +432,6 @@ export function detectPlayers(): DetectedPlayer[] {
});
}
const ffplayPath = which("ffplay");
if (ffplayPath) {
players.push({
name: "ffplay",
path: ffplayPath,
capabilities: {
seek: true,
volume: true,
speed: false,
positionTracking: false,
},
});
}
const os = platform();
if (os === "darwin") {
const afplayPath = which("afplay");
if (afplayPath) {
players.push({
name: "afplay",
path: afplayPath,
capabilities: {
seek: true,
volume: true,
speed: true,
positionTracking: false,
},
});
}
}
// System open is always available as fallback
const openCmd =
os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
if (which(openCmd)) {
players.push({
name: "system",
path: which(openCmd),
capabilities: {
seek: false,
volume: false,
speed: false,
positionTracking: false,
},
});
}
return players;
}
@@ -953,14 +442,7 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
// An explicit `preferred` argument still wins.
if (!preferred) {
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
if (
envPref &&
(envPref === "mpv" ||
envPref === "ffplay" ||
envPref === "afplay" ||
envPref === "system" ||
envPref === "none")
) {
if (envPref && (envPref === "mpv" || envPref === "none")) {
preferred = envPref;
}
}
@@ -970,25 +452,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend;
}
// Auto-detect in priority order
const players = detectPlayers();
if (players.length === 0) return new NoopBackend();
return createBackendByName(players[0].name) ?? new NoopBackend();
return which("mpv") ? new MpvBackend() : new NoopBackend();
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv":
return which("mpv") ? new MpvBackend() : null;
case "ffplay":
return which("ffplay") ? new FfplayBackend() : null;
case "afplay":
return platform() === "darwin" && which("afplay")
? new AfplayBackend()
: null;
case "system":
return new SystemBackend();
case "none":
return new NoopBackend();
}