audio playback fixes
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user