audio playback fixes
This commit is contained in:
@@ -436,16 +436,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
if (newAction) {
|
if (newAction) {
|
||||||
if (flags.audio && audioControls?.switchBackend) {
|
if (flags.audio && audioControls?.switchBackend) {
|
||||||
// Re-detect: clear env so detection picks the best real backend.
|
|
||||||
delete process.env.PODTUI_AUDIO_BACKEND;
|
|
||||||
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
|
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
|
||||||
|
delete process.env.PODTUI_AUDIO_BACKEND;
|
||||||
await audioControls.switchBackend("mpv").catch(() => {});
|
await audioControls.switchBackend("mpv").catch(() => {});
|
||||||
if (
|
|
||||||
!audioControls.backendName() ||
|
|
||||||
audioControls.backendName() === "none"
|
|
||||||
) {
|
|
||||||
await audioControls.switchBackend("afplay").catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
actions.push(newAction);
|
actions.push(newAction);
|
||||||
saveActions(actions);
|
saveActions(actions);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function ensureBackend(): AudioBackend {
|
|||||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||||
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
// onCleanup — where `backend.dispose()` would otherwise kill the spawned
|
||||||
// player (mpv/ffplay/afplay). Without this hook those child processes
|
// player (mpv). Without this hook those child processes
|
||||||
// survive the host and keep playing audio after the TUI has quit. The
|
// survive the host and keep playing audio after the TUI has quit. The
|
||||||
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
// `exit` event fires synchronously on `process.exit(N)`; the signal
|
||||||
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
// handlers cover Ctrl-C / kill, which otherwise terminate without running
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* regardless of which component is focused. Uses the event bus to
|
* regardless of which component is focused. Uses the event bus to
|
||||||
* decouple key detection from audio control logic.
|
* decouple key detection from audio control logic.
|
||||||
*
|
*
|
||||||
* Keys are only handled when an episode is loaded (or for play/pause,
|
* Volume and speed are app-level settings — adjustable with or without
|
||||||
* always). This prevents accidental volume/seek changes when there's
|
* an episode loaded (they apply to the next playback and persist). Seek
|
||||||
* nothing playing.
|
* is playback-dependent, so it still requires a loaded episode.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid";
|
||||||
import { emit } from "../utils/event-bus"
|
import { emit } from "../utils/event-bus";
|
||||||
|
|
||||||
export type MediaKeyAction =
|
export type MediaKeyAction =
|
||||||
| "media.toggle"
|
| "media.toggle"
|
||||||
@@ -19,7 +19,7 @@ export type MediaKeyAction =
|
|||||||
| "media.volumeDown"
|
| "media.volumeDown"
|
||||||
| "media.seekForward"
|
| "media.seekForward"
|
||||||
| "media.seekBackward"
|
| "media.seekBackward"
|
||||||
| "media.speedCycle"
|
| "media.speedCycle";
|
||||||
|
|
||||||
/** Key-to-action mappings for multimedia controls */
|
/** Key-to-action mappings for multimedia controls */
|
||||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
||||||
@@ -33,15 +33,15 @@ const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
|||||||
// bus approach — the audio hook only processes event-bus events, and
|
// bus approach — the audio hook only processes event-bus events, and
|
||||||
// Player.tsx calls audio methods directly. We therefore guard with
|
// Player.tsx calls audio methods directly. We therefore guard with
|
||||||
// a "playerFocused" flag passed via options.
|
// a "playerFocused" flag passed via options.
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface MultimediaKeysOptions {
|
export interface MultimediaKeysOptions {
|
||||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||||
playerFocused?: () => boolean
|
playerFocused?: () => boolean;
|
||||||
/** When true, skip handling (text input has focus) */
|
/** When true, skip handling (text input has focus) */
|
||||||
inputFocused?: () => boolean
|
inputFocused?: () => boolean;
|
||||||
/** Whether an episode is currently loaded */
|
/** Whether an episode is currently loaded */
|
||||||
hasEpisode?: () => boolean
|
hasEpisode?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,48 +51,45 @@ export interface MultimediaKeysOptions {
|
|||||||
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||||
useKeyboard((key) => {
|
useKeyboard((key) => {
|
||||||
// Don't intercept when a text input owns the keyboard
|
// Don't intercept when a text input owns the keyboard
|
||||||
if (options.inputFocused?.()) return
|
if (options.inputFocused?.()) return;
|
||||||
|
|
||||||
// Don't intercept when Player component handles its own keys
|
// Don't intercept when Player component handles its own keys
|
||||||
if (options.playerFocused?.()) return
|
if (options.playerFocused?.()) return;
|
||||||
|
|
||||||
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
// Ctrl/Meta combos are app-level shortcuts, not media keys
|
||||||
if (key.ctrl || key.meta) return
|
if (key.ctrl || key.meta) return;
|
||||||
|
|
||||||
switch (key.name) {
|
switch (key.name) {
|
||||||
case "space":
|
case "space":
|
||||||
// Toggle play/pause — always valid (may start a loaded episode)
|
// Toggle play/pause — always valid (may start a loaded episode)
|
||||||
emit("media.toggle", {})
|
emit("media.toggle", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "up":
|
case "up":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.volumeUp", {});
|
||||||
emit("media.volumeUp", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
case "down":
|
case "down":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.volumeDown", {});
|
||||||
emit("media.volumeDown", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
case "left":
|
case "left":
|
||||||
if (!options.hasEpisode?.()) return
|
if (!options.hasEpisode?.()) return;
|
||||||
emit("media.seekBackward", {})
|
emit("media.seekBackward", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "right":
|
case "right":
|
||||||
if (!options.hasEpisode?.()) return
|
if (!options.hasEpisode?.()) return;
|
||||||
emit("media.seekForward", {})
|
emit("media.seekForward", {});
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "s":
|
case "s":
|
||||||
if (!options.hasEpisode?.()) return
|
emit("media.speedCycle", {});
|
||||||
emit("media.speedCycle", {})
|
break;
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Not a media key — do nothing
|
// Not a media key — do nothing
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ type PlaybackControlsProps = {
|
|||||||
|
|
||||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||||
mpv: "mpv",
|
mpv: "mpv",
|
||||||
ffplay: "ffplay",
|
|
||||||
afplay: "afplay",
|
|
||||||
system: "system",
|
|
||||||
none: "none",
|
none: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,10 +57,12 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
|||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Vol</text>
|
<text fg={theme.textMuted}>Vol</text>
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
|
<text fg={theme.textMuted}>↑↓</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
<text fg={theme.textMuted}>s</text>
|
||||||
</box>
|
</box>
|
||||||
{props.backendName && props.backendName !== "none" && (
|
{props.backendName && props.backendName !== "none" && (
|
||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ export function PlayerPage() {
|
|||||||
<RealtimeWaveform
|
<RealtimeWaveform
|
||||||
visualizerConfig={(() => {
|
visualizerConfig={(() => {
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
|
// bars is width-derived in RealtimeWaveform; pass only the
|
||||||
|
// audio-processing params here.
|
||||||
return {
|
return {
|
||||||
bars: viz.bars,
|
|
||||||
noiseReduction: viz.noiseReduction,
|
noiseReduction: viz.noiseReduction,
|
||||||
lowCutOff: viz.lowCutOff,
|
lowCutOff: viz.lowCutOff,
|
||||||
highCutOff: viz.highCutOff,
|
highCutOff: viz.highCutOff,
|
||||||
@@ -109,7 +110,7 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{"P play/pause N next B prev </ seek · h back"}
|
{"P play/pause N next B prev ◀▶ seek h back"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
import {
|
import {
|
||||||
loadCavaCore,
|
loadCavaCore,
|
||||||
type CavaCore,
|
type CavaCore,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,17 +53,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
// Frequency bar values (0.0–1.0 per bar)
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
// Track whether cavacore is available
|
|
||||||
const [available, setAvailable] = createSignal(false);
|
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
let cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
let reader: AudioStreamReader | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let sampleBuffer: Float64Array | null = null;
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
|
||||||
// Bar count comes from the visualizer config (set in Settings); default 64.
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
// Single source of truth used for cavacore init, rendering, and seek clicks.
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
const numBars = () => props.visualizerConfig?.bars ?? 64;
|
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
|
||||||
|
// chrome (scrollbox border + box padding + waveform border + padding).
|
||||||
|
// Falls back to 64 before the renderer reports a real size.
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
const numBars = () => {
|
||||||
|
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
|
||||||
|
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
|
||||||
|
const width = dimensions().width;
|
||||||
|
if (!width) return 64;
|
||||||
|
return Math.max(
|
||||||
|
8,
|
||||||
|
Math.min(256, Math.floor((width * current) / total) - 8),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||||
|
|
||||||
@@ -70,11 +82,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
cava = loadCavaCore();
|
cava = loadCavaCore();
|
||||||
if (!cava) {
|
if (!cava) {
|
||||||
setAvailable(false);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailable(true);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +95,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
if (!url || !initCava() || !cava) return;
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides
|
// Initialize cavacore with current resolution + any overrides.
|
||||||
|
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||||
|
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: numBars(),
|
bars: numBars(),
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
@@ -140,7 +152,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const output = cava.execute(input);
|
const output = cava.execute(input);
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
// Copy bar values to a new array for the signal
|
||||||
setBarData(Array.from(output));
|
setBarData(Array.from(output as Float64Array));
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-platform audio playback engine for PodTUI.
|
* Audio playback engine for PodTUI.
|
||||||
*
|
*
|
||||||
* Backend priority:
|
* Single backend: mpv — full IPC control (seek, volume, speed, position
|
||||||
* 1. mpv — full IPC control (seek, volume, speed, position tracking)
|
* tracking), so speed/volume/seek changes apply instantly with no process
|
||||||
* 2. ffplay — basic control via process signals
|
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||||
* 3. afplay — macOS built-in (no seek/speed, volume only)
|
* surfaces "No audio player found" honestly rather than degrading through
|
||||||
* 4. system — open/xdg-open/start (fire-and-forget, no control)
|
* players that can't change speed/volume without restarting.
|
||||||
*
|
|
||||||
* All backends implement the AudioBackend interface so the Player
|
|
||||||
* component doesn't need to care which one is active.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
@@ -18,7 +15,7 @@ import { join } from "path";
|
|||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none";
|
export type BackendName = "mpv" | "none";
|
||||||
|
|
||||||
export interface AudioState {
|
export interface AudioState {
|
||||||
playing: boolean;
|
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 ────────────────────────────────────────────────────
|
// ── No-op Backend ────────────────────────────────────────────────────
|
||||||
|
|
||||||
class NoopBackend implements AudioBackend {
|
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;
|
return players;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,14 +442,7 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
|||||||
// An explicit `preferred` argument still wins.
|
// An explicit `preferred` argument still wins.
|
||||||
if (!preferred) {
|
if (!preferred) {
|
||||||
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
|
const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined;
|
||||||
if (
|
if (envPref && (envPref === "mpv" || envPref === "none")) {
|
||||||
envPref &&
|
|
||||||
(envPref === "mpv" ||
|
|
||||||
envPref === "ffplay" ||
|
|
||||||
envPref === "afplay" ||
|
|
||||||
envPref === "system" ||
|
|
||||||
envPref === "none")
|
|
||||||
) {
|
|
||||||
preferred = envPref;
|
preferred = envPref;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,25 +452,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
|||||||
if (backend) return backend;
|
if (backend) return backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect in priority order
|
return which("mpv") ? new MpvBackend() : new NoopBackend();
|
||||||
const players = detectPlayers();
|
|
||||||
if (players.length === 0) return new NoopBackend();
|
|
||||||
|
|
||||||
return createBackendByName(players[0].name) ?? new NoopBackend();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBackendByName(name: BackendName): AudioBackend | null {
|
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "mpv":
|
case "mpv":
|
||||||
return which("mpv") ? new MpvBackend() : null;
|
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":
|
case "none":
|
||||||
return new NoopBackend();
|
return new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
|
||||||
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
|
||||||
* keep spawned players (mpv/ffplay/afplay) from surviving the host, useAudio
|
* keep spawned players (mpv) from surviving the host, useAudio
|
||||||
* registers a `process.on("exit")` handler that synchronously disposes the
|
* registers a `process.on("exit")` handler that synchronously disposes the
|
||||||
* backend. The exit handler's whole job is "kill the child process", so this
|
* backend. The exit handler's whole job is "kill the child process", so this
|
||||||
* test pins the contract directly: a backend holding a real spawned subprocess
|
* test pins the contract directly: a backend holding a real spawned subprocess
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*
|
*
|
||||||
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
|
||||||
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
* player process, injected into the (private) `proc` slot of an MpvBackend —
|
||||||
* mpv/ffplay/afplay all share the identical kill-on-dispose pattern, so
|
* mpv is the only real backend, and it uses the kill-on-dispose
|
||||||
* exercising one is enough to guard the family.
|
* exercising one is enough to guard the family.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
|
|||||||
Reference in New Issue
Block a user