diff --git a/src/components/PlaybackControls.tsx b/src/components/PlaybackControls.tsx index 95ae5eb..93e8256 100644 --- a/src/components/PlaybackControls.tsx +++ b/src/components/PlaybackControls.tsx @@ -1,7 +1,11 @@ +import type { BackendName } from "../utils/audio-player" + type PlaybackControlsProps = { isPlaying: boolean volume: number speed: number + backendName?: BackendName + hasAudioUrl?: boolean onToggle: () => void onPrev: () => void onNext: () => void @@ -9,6 +13,14 @@ type PlaybackControlsProps = { onSpeedChange: (value: number) => void } +const BACKEND_LABELS: Record = { + mpv: "mpv", + ffplay: "ffplay", + afplay: "afplay", + system: "system", + none: "none", +} + export function PlaybackControls(props: PlaybackControlsProps) { return ( @@ -29,6 +41,22 @@ export function PlaybackControls(props: PlaybackControlsProps) { Speed {props.speed}x + {props.backendName && props.backendName !== "none" && ( + + via + {BACKEND_LABELS[props.backendName]} + + )} + {props.backendName === "none" && ( + + No audio player found + + )} + {props.hasAudioUrl === false && ( + + No audio URL + + )} ) } diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 13ed79d..e7ff8b0 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,12 +1,13 @@ -import { createSignal } from "solid-js" import { useKeyboard } from "@opentui/solid" import { PlaybackControls } from "./PlaybackControls" import { Waveform } from "./Waveform" import { createWaveform } from "../utils/waveform" +import { useAudio } from "../hooks/useAudio" import type { Episode } from "../types/episode" type PlayerProps = { focused: boolean + episode?: Episode | null onExit?: () => void } @@ -21,17 +22,27 @@ const SAMPLE_EPISODE: Episode = { } export function Player(props: PlayerProps) { - const [isPlaying, setIsPlaying] = createSignal(false) - const [position, setPosition] = createSignal(0) - const [volume, setVolume] = createSignal(0.7) - const [speed, setSpeed] = createSignal(1) + const audio = useAudio() const waveform = () => createWaveform(64) + // The episode to display — prefer a passed-in episode, then the + // currently-playing episode, then fall back to the sample. + const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE + const dur = () => audio.duration() || episode().duration || 1 + useKeyboard((key: { name: string }) => { if (!props.focused) return if (key.name === "space") { - setIsPlaying((value: boolean) => !value) + if (audio.currentEpisode()) { + audio.togglePlayback() + } else { + // Nothing loaded yet — start playing the displayed episode + const ep = episode() + if (ep.audioUrl) { + audio.play(ep) + } + } return } if (key.name === "escape") { @@ -39,23 +50,34 @@ export function Player(props: PlayerProps) { return } if (key.name === "left") { - setPosition((value: number) => Math.max(0, value - 10)) + audio.seekRelative(-10) } if (key.name === "right") { - setPosition((value: number) => Math.min(SAMPLE_EPISODE.duration, value + 10)) + audio.seekRelative(10) } if (key.name === "up") { - setVolume((value: number) => Math.min(1, Number((value + 0.05).toFixed(2)))) + audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2)))) } if (key.name === "down") { - setVolume((value: number) => Math.max(0, Number((value - 0.05).toFixed(2)))) + audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2)))) } if (key.name === "s") { - setSpeed((value: number) => (value >= 2 ? 0.5 : Number((value + 0.25).toFixed(2)))) + const next = audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2)) + audio.setSpeed(next) } }) - const progressPercent = () => Math.round((position() / SAMPLE_EPISODE.duration) * 100) + const progressPercent = () => { + const d = dur() + if (d <= 0) return 0 + return Math.min(100, Math.round((audio.position() / d) * 100)) + } + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${m}:${String(s).padStart(2, "0")}` + } return ( @@ -64,15 +86,19 @@ export function Player(props: PlayerProps) { Now Playing - Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")} + {formatTime(audio.position())} / {formatTime(dur())} + {audio.error() && ( + {audio.error()} + )} + - {SAMPLE_EPISODE.title} + {episode().title} - {SAMPLE_EPISODE.description} + {episode().description} @@ -81,7 +107,7 @@ export function Player(props: PlayerProps) { {progressPercent()}% @@ -89,26 +115,35 @@ export function Player(props: PlayerProps) { setPosition(next)} + position={audio.position()} + duration={dur()} + isPlaying={audio.isPlaying()} + onSeek={(next: number) => audio.seek(next)} /> setIsPlaying((value: boolean) => !value)} - onPrev={() => setPosition(0)} - onNext={() => setPosition(SAMPLE_EPISODE.duration)} - onSpeedChange={setSpeed} - onVolumeChange={setVolume} + isPlaying={audio.isPlaying()} + volume={audio.volume()} + speed={audio.speed()} + backendName={audio.backendName()} + hasAudioUrl={!!episode().audioUrl} + onToggle={() => { + if (audio.currentEpisode()) { + audio.togglePlayback() + } else { + const ep = episode() + if (ep.audioUrl) audio.play(ep) + } + }} + onPrev={() => audio.seek(0)} + onNext={() => audio.seek(dur())} + onSpeedChange={(s: number) => audio.setSpeed(s)} + onVolumeChange={(v: number) => audio.setVolume(v)} /> - Enter dive | Esc up | Space play/pause | Left/Right seek + Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back ) } diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts new file mode 100644 index 0000000..12a4bdf --- /dev/null +++ b/src/hooks/useAudio.ts @@ -0,0 +1,348 @@ +/** + * Reactive SolidJS hook wrapping the AudioBackend. + * + * Provides signals for playback state and methods for controlling + * audio. Integrates with the event bus and app store. + * + * Usage: + * ```tsx + * const audio = useAudio() + * audio.play(episode) + * {audio.isPlaying() ? "Playing" : "Paused"} + * ``` + */ + +import { createSignal, onCleanup } from "solid-js" +import { + createAudioBackend, + detectPlayers, + type AudioBackend, + type BackendName, + type DetectedPlayer, +} from "../utils/audio-player" +import { emit, on } from "../utils/event-bus" +import { useAppStore } from "../stores/app" +import type { Episode } from "../types/episode" + +export interface AudioControls { + // Signals (reactive getters) + isPlaying: () => boolean + position: () => number + duration: () => number + volume: () => number + speed: () => number + backendName: () => BackendName + error: () => string | null + currentEpisode: () => Episode | null + availablePlayers: () => DetectedPlayer[] + + // Actions + play: (episode: Episode) => Promise + pause: () => Promise + resume: () => Promise + togglePlayback: () => Promise + stop: () => Promise + seek: (seconds: number) => Promise + seekRelative: (delta: number) => Promise + setVolume: (volume: number) => Promise + setSpeed: (speed: number) => Promise + switchBackend: (name: BackendName) => Promise +} + +// Singleton state — shared across all components that call useAudio() +let backend: AudioBackend | null = null +let pollTimer: ReturnType | null = null +let refCount = 0 + +const [isPlaying, setIsPlaying] = createSignal(false) +const [position, setPosition] = createSignal(0) +const [duration, setDuration] = createSignal(0) +const [volume, setVolume] = createSignal(0.7) +const [speed, setSpeed] = createSignal(1) +const [backendName, setBackendName] = createSignal("none") +const [error, setError] = createSignal(null) +const [currentEpisode, setCurrentEpisode] = createSignal(null) +const [availablePlayers, setAvailablePlayers] = createSignal([]) + +function ensureBackend(): AudioBackend { + if (!backend) { + const detected = detectPlayers() + setAvailablePlayers(detected) + backend = createAudioBackend() + setBackendName(backend.name) + } + return backend +} + +function startPolling(): void { + stopPolling() + pollTimer = setInterval(async () => { + if (!backend || !isPlaying()) return + try { + const pos = await backend.getPosition() + const dur = await backend.getDuration() + setPosition(pos) + if (dur > 0) setDuration(dur) + + // Check if backend stopped playing (track ended) + if (!backend.isPlaying() && isPlaying()) { + setIsPlaying(false) + stopPolling() + } + } catch { + // Backend may have been disposed + } + }, 500) +} + +function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } +} + +async function play(episode: Episode): Promise { + const b = ensureBackend() + setError(null) + + if (!episode.audioUrl) { + setError("No audio URL for this episode") + return + } + + try { + const appStore = useAppStore() + const storeSpeed = appStore.state().settings.playbackSpeed + const vol = volume() + const spd = storeSpeed || speed() + + await b.play(episode.audioUrl, { + volume: vol, + speed: spd, + }) + + setCurrentEpisode(episode) + setIsPlaying(true) + setPosition(0) + setSpeed(spd) + if (episode.duration) setDuration(episode.duration) + + startPolling() + emit("player.play", { episodeId: episode.id }) + } catch (err) { + setError(err instanceof Error ? err.message : "Playback failed") + setIsPlaying(false) + } +} + +async function pause(): Promise { + if (!backend) return + try { + await backend.pause() + setIsPlaying(false) + stopPolling() + const ep = currentEpisode() + if (ep) emit("player.pause", { episodeId: ep.id }) + } catch (err) { + setError(err instanceof Error ? err.message : "Pause failed") + } +} + +async function resume(): Promise { + if (!backend) return + try { + await backend.resume() + setIsPlaying(true) + startPolling() + const ep = currentEpisode() + if (ep) emit("player.play", { episodeId: ep.id }) + } catch (err) { + setError(err instanceof Error ? err.message : "Resume failed") + } +} + +async function togglePlayback(): Promise { + if (isPlaying()) { + await pause() + } else if (currentEpisode()) { + await resume() + } +} + +async function stop(): Promise { + if (!backend) return + try { + await backend.stop() + setIsPlaying(false) + setPosition(0) + setCurrentEpisode(null) + stopPolling() + emit("player.stop", {}) + } catch (err) { + setError(err instanceof Error ? err.message : "Stop failed") + } +} + +async function seek(seconds: number): Promise { + if (!backend) return + const clamped = Math.max(0, Math.min(seconds, duration())) + try { + await backend.seek(clamped) + setPosition(clamped) + } catch (err) { + setError(err instanceof Error ? err.message : "Seek failed") + } +} + +async function seekRelative(delta: number): Promise { + await seek(position() + delta) +} + +async function doSetVolume(vol: number): Promise { + const clamped = Math.max(0, Math.min(1, vol)) + if (backend) { + try { + await backend.setVolume(clamped) + } catch { + // Some backends can't change volume at runtime + } + } + setVolume(clamped) +} + +async function doSetSpeed(spd: number): Promise { + const clamped = Math.max(0.25, Math.min(3, spd)) + if (backend) { + try { + await backend.setSpeed(clamped) + } catch { + // Some backends can't change speed at runtime + } + } + setSpeed(clamped) + + // Sync back to app store + try { + const appStore = useAppStore() + appStore.updateSettings({ playbackSpeed: clamped }) + } catch { + // Store may not be available + } +} + +async function switchBackend(name: BackendName): Promise { + const wasPlaying = isPlaying() + const ep = currentEpisode() + const pos = position() + const vol = volume() + const spd = speed() + + // Stop current backend + if (backend) { + stopPolling() + backend.dispose() + backend = null + } + + // Create new backend + backend = createAudioBackend(name) + setBackendName(backend.name) + setAvailablePlayers(detectPlayers()) + + // Resume playback if we were playing + if (wasPlaying && ep && ep.audioUrl) { + try { + await backend.play(ep.audioUrl, { + startPosition: pos, + volume: vol, + speed: spd, + }) + setIsPlaying(true) + startPolling() + } catch (err) { + setError(err instanceof Error ? err.message : "Backend switch failed") + setIsPlaying(false) + } + } +} + +/** + * Reactive audio controls hook. + * + * Returns a singleton — all components share the same playback state. + * Registers event bus listeners and cleans them up with onCleanup. + */ +export function useAudio(): AudioControls { + // Initialize backend on first use + ensureBackend() + + // Sync initial speed from app store + if (refCount === 0) { + try { + const appStore = useAppStore() + const storeSpeed = appStore.state().settings.playbackSpeed + if (storeSpeed && storeSpeed !== speed()) { + setSpeed(storeSpeed) + } + } catch { + // Store may not be available yet + } + } + + refCount++ + + // Listen for event bus commands (e.g. from other components) + const unsubPlay = on("player.play", async (data) => { + // External play requests — currently just tracks episodeId. + // Episode lookup would require feed store integration. + }) + + const unsubStop = on("player.stop", async () => { + if (backend && isPlaying()) { + await backend.stop() + setIsPlaying(false) + setPosition(0) + setCurrentEpisode(null) + stopPolling() + } + }) + + onCleanup(() => { + refCount-- + unsubPlay() + unsubStop() + + if (refCount <= 0) { + stopPolling() + if (backend) { + backend.dispose() + backend = null + } + refCount = 0 + } + }) + + return { + isPlaying, + position, + duration, + volume, + speed, + backendName, + error, + currentEpisode, + availablePlayers, + + play, + pause, + resume, + togglePlayback, + stop, + seek, + seekRelative, + setVolume: doSetVolume, + setSpeed: doSetSpeed, + switchBackend, + } +} diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts new file mode 100644 index 0000000..b714a84 --- /dev/null +++ b/src/utils/audio-player.ts @@ -0,0 +1,745 @@ +/** + * Cross-platform 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. + */ + +import { platform } from "os" +import { existsSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" + +// ── Types ──────────────────────────────────────────────────────────── + +export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "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 +} + +// ── Utilities ──────────────────────────────────────────────────────── + +function which(cmd: string): string | null { + return Bun.which(cmd) +} + +function mpvSocketPath(): string { + return join(tmpdir(), `podtui-mpv-${process.pid}.sock`) +} + +// ── mpv Backend ────────────────────────────────────────────────────── +// Uses JSON IPC over a Unix socket for full bidirectional control. + +class MpvBackend implements AudioBackend { + readonly name: BackendName = "mpv" + private proc: ReturnType | null = null + private socketPath = mpvSocketPath() + private _playing = false + private _position = 0 + private _duration = 0 + private _volume = 100 + private _speed = 1 + private pollTimer: ReturnType | null = null + + 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 = [ + "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?.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) + + // Start polling position + this.startPolling() + + // Detect process exit + this.proc.exited.then(() => { + this._playing = false + this.stopPolling() + }).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)) + } + } + + private async ipc(command: unknown[]): Promise { + try { + const socket = await Bun.connect({ + unix: this.socketPath, + socket: { + data(_socket, data) { + // Response handling is done by reading below + }, + error(_socket, err) {}, + close() {}, + open() {}, + }, + }) + + const payload = JSON.stringify({ command }) + "\n" + socket.write(payload) + + // Read response with timeout + const response = await new Promise((resolve) => { + let buf = "" + const reader = setInterval(() => { + // Check if we got a response already + if (buf.includes("\n")) { + clearInterval(reader) + resolve(buf) + } + }, 10) + setTimeout(() => { + clearInterval(reader) + resolve(buf) + }, 200) + }) + + socket.end() + if (response) { + try { return JSON.parse(response.split("\n")[0]) } catch { return null } + } + return null + } catch { + return null + } + } + + /** Send a command over mpv's IPC and get the parsed response data. */ + private async ipcCommand(command: unknown[]): Promise { + try { + const conn = await Bun.connect({ + unix: this.socketPath, + socket: { + data() {}, + error() {}, + close() {}, + open() {}, + }, + }) + + const payload = JSON.stringify({ command }) + "\n" + conn.write(payload) + + // Give mpv a moment to process, then read via a fresh connection + await new Promise((r) => setTimeout(r, 30)) + conn.end() + + return null + } catch { + return null + } + } + + /** 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 */ + private async getProperty(name: string): Promise { + try { + return await new Promise((resolve) => { + let result = 0 + const timeout = setTimeout(() => resolve(result), 300) + + Bun.connect({ + unix: this.socketPath, + socket: { + data(_socket, data) { + try { + const text = Buffer.from(data).toString() + const parsed = JSON.parse(text.split("\n")[0]) + if (parsed?.data !== undefined) { + result = Number(parsed.data) || 0 + } + } catch { /* ignore parse errors */ } + clearTimeout(timeout) + resolve(result) + }, + error() { clearTimeout(timeout); resolve(0) }, + close() {}, + open(socket) { + socket.write(JSON.stringify({ command: ["get_property", name] }) + "\n") + }, + }, + }).catch(() => { clearTimeout(timeout); resolve(0) }) + }) + } catch { + return 0 + } + } + + private startPolling(): void { + this.stopPolling() + this.pollTimer = setInterval(async () => { + if (!this._playing || !this.proc) return + this._position = await this.getProperty("time-pos") + if (this._duration <= 0) { + this._duration = await this.getProperty("duration") + } + }, 500) + } + + private stopPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + } + + 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 { + this.stopPolling() + 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 { + return this._position + } + + async getDuration(): Promise { + if (this._duration <= 0) { + this._duration = await this.getProperty("duration") + } + return this._duration + } + + isPlaying(): boolean { + return this._playing + } + + dispose(): void { + this.stop() + } +} + +// ── 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 | null = null + private _playing = false + private _position = 0 + private _duration = 0 + private _volume = 100 + private _speed = 1 + private _url = "" + private startTime = 0 + private pollTimer: ReturnType | null = null + + async play(url: string, opts?: PlayOptions): Promise { + 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)) + } + + args.push("-i", this._url) + + this.proc = Bun.spawn(args, { + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }) + + this._playing = true + 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 { + // ffplay doesn't support pause via IPC; kill and remember position + if (this.proc) { + try { this.proc.kill() } catch {} + this.proc = null + } + this._playing = false + this.stopPolling() + } + + async resume(): Promise { + if (!this._url) return + this.spawnProcess() + } + + async stop(): Promise { + this.stopPolling() + if (this.proc) { + try { this.proc.kill() } catch {} + this.proc = null + } + this._playing = false + this._position = 0 + this._url = "" + } + + async seek(seconds: number): Promise { + 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() + } + } + + async setVolume(volume: number): Promise { + this._volume = Math.round(volume * 100) + // ffplay can't change volume at runtime; apply on next play + } + + async setSpeed(speed: number): Promise { + this._speed = speed + // ffplay doesn't support runtime speed changes + } + + async getPosition(): Promise { + return this._position + } + + async getDuration(): Promise { + 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 | null = null + private _playing = false + private _position = 0 + private _duration = 0 + private _volume = 1 + private _speed = 1 + private _url = "" + private startTime = 0 + private pollTimer: ReturnType | null = null + + async play(url: string, opts?: PlayOptions): Promise { + 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.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 { + if (this.proc) { + try { this.proc.kill() } catch {} + this.proc = null + } + this._playing = false + this.stopPolling() + } + + async resume(): Promise { + if (!this._url) return + this.spawnProcess() + } + + async stop(): Promise { + this.stopPolling() + if (this.proc) { + try { this.proc.kill() } catch {} + this.proc = null + } + this._playing = false + this._position = 0 + this._url = "" + } + + async seek(seconds: number): Promise { + this._position = seconds + if (this._playing && this._url) { + if (this.proc) { + try { this.proc.kill() } catch {} + this.proc = null + } + this.spawnProcess() + } + } + + async setVolume(volume: number): Promise { + this._volume = volume + } + + async setSpeed(speed: number): Promise { + this._speed = speed + } + + async getPosition(): Promise { + return this._position + } + + async getDuration(): Promise { + 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 { + 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 { this._playing = false } + async resume(): Promise { this._playing = true } + async stop(): Promise { this._playing = false } + async seek(): Promise {} + async setVolume(): Promise {} + async setSpeed(): Promise {} + async getPosition(): Promise { return 0 } + async getDuration(): Promise { return 0 } + isPlaying(): boolean { return this._playing } + dispose(): void { this._playing = false } +} + +// ── 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 = which("mpv") + if (mpvPath) { + players.push({ + name: "mpv", + path: mpvPath, + capabilities: { seek: true, volume: true, speed: true, positionTracking: true }, + }) + } + + 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 +} + +/** Create the best available audio backend. */ +export function createAudioBackend(preferred?: BackendName): AudioBackend { + if (preferred) { + const backend = createBackendByName(preferred) + 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() +} + +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() + } +} diff --git a/tasks/podcast-tui-app/README.md b/tasks/podcast-tui-app/README.md index 7fd7ee0..b00fde0 100644 --- a/tasks/podcast-tui-app/README.md +++ b/tasks/podcast-tui-app/README.md @@ -105,7 +105,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done - [x] 42 — Implement playback controls → `42-playback-controls.md` - [x] 43 — Build ASCII waveform visualization → `43-waveform-visualization.md` - [x] 44 — Add progress tracking and seek → `44-progress-tracking.md` -- [ ] 45 — Implement audio integration (system/external player) → `45-audio-integration.md` +- [x] 45 — Implement audio integration (system/external player) → `45-audio-integration.md` **Dependencies:** 07 -> 08 -> 09 -> 10 -> 11 -> 12