From 2e323d283f50be5d6118c996115728b7ebe2c68b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sat, 7 Feb 2026 00:45:14 -0500 Subject: [PATCH] needs testing --- src/utils/audio-player.ts | 101 +++++++++++++++++++++++++++---- src/utils/audio-stream-reader.ts | 6 +- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index ae00f1b..b0388d2 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -54,7 +54,21 @@ export interface PlayOptions { // ── Utilities ──────────────────────────────────────────────────────── function which(cmd: string): string | null { - return Bun.which(cmd) + 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 { @@ -345,6 +359,7 @@ class FfplayBackend implements AudioBackend { readonly name: BackendName = "ffplay" private proc: ReturnType | null = null private _playing = false + private _paused = false private _position = 0 private _duration = 0 private _volume = 100 @@ -377,6 +392,10 @@ class FfplayBackend implements AudioBackend { 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, { @@ -386,6 +405,7 @@ class FfplayBackend implements AudioBackend { }) this._playing = true + this._paused = false this.startTime = Date.now() this.startPolling() @@ -413,10 +433,10 @@ class FfplayBackend implements AudioBackend { } async pause(): Promise { - // ffplay doesn't support pause via IPC; kill and remember position if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null + 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() @@ -424,6 +444,15 @@ class FfplayBackend implements AudioBackend { async resume(): Promise { 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() } @@ -434,6 +463,7 @@ class FfplayBackend implements AudioBackend { this.proc = null } this._playing = false + this._paused = false this._position = 0 this._url = "" } @@ -447,6 +477,11 @@ class FfplayBackend implements AudioBackend { 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 + } } } @@ -454,20 +489,36 @@ class FfplayBackend implements AudioBackend { 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._playing && this._url) { + 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 { this._speed = speed - // ffplay doesn't support runtime speed changes; no restart possible - // since ffplay has no speed CLI flag. Speed only affects position tracking. + 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 { @@ -494,6 +545,7 @@ class AfplayBackend implements AudioBackend { readonly name: BackendName = "afplay" private proc: ReturnType | null = null private _playing = false + private _paused = false private _position = 0 private _duration = 0 private _volume = 1 @@ -534,6 +586,7 @@ class AfplayBackend implements AudioBackend { }) this._playing = true + this._paused = false this.startTime = Date.now() this.startPolling() @@ -562,8 +615,9 @@ class AfplayBackend implements AudioBackend { async pause(): Promise { if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null + 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() @@ -571,6 +625,15 @@ class AfplayBackend implements AudioBackend { async resume(): Promise { 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() } @@ -581,6 +644,7 @@ class AfplayBackend implements AudioBackend { this.proc = null } this._playing = false + this._paused = false this._position = 0 this._url = "" } @@ -593,32 +657,47 @@ class AfplayBackend implements AudioBackend { 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 { this._volume = volume // Restart the process with new volume to apply immediately - if (this._playing && this._url) { + 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 { this._speed = speed // Restart the process with new rate to apply immediately - if (this._playing && this._url) { + 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 + } } } diff --git a/src/utils/audio-stream-reader.ts b/src/utils/audio-stream-reader.ts index 6a1f837..594dec4 100644 --- a/src/utils/audio-stream-reader.ts +++ b/src/utils/audio-stream-reader.ts @@ -85,6 +85,9 @@ export class AudioStreamReader { const args = [ "ffmpeg", "-loglevel", "quiet", + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "5", ] // Seek before input for network efficiency @@ -97,8 +100,7 @@ export class AudioStreamReader { // Apply speed via atempo filter if not 1x. // ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes. if (speed !== 1 && speed > 0) { - const atempoFilters = buildAtempoChain(speed) - args.push("-af", atempoFilters) + args.push("-af", buildAtempoChain(speed)) } args.push(