start player
This commit is contained in:
@@ -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<BackendName, string> = {
|
||||
mpv: "mpv",
|
||||
ffplay: "ffplay",
|
||||
afplay: "afplay",
|
||||
system: "system",
|
||||
none: "none",
|
||||
}
|
||||
|
||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
|
||||
@@ -29,6 +41,22 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
<text fg="gray">Speed</text>
|
||||
<text fg="white">{props.speed}x</text>
|
||||
</box>
|
||||
{props.backendName && props.backendName !== "none" && (
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg="gray">via</text>
|
||||
<text fg="cyan">{BACKEND_LABELS[props.backendName]}</text>
|
||||
</box>
|
||||
)}
|
||||
{props.backendName === "none" && (
|
||||
<box marginLeft={2}>
|
||||
<text fg="yellow">No audio player found</text>
|
||||
</box>
|
||||
)}
|
||||
{props.hasAudioUrl === false && (
|
||||
<box marginLeft={2}>
|
||||
<text fg="yellow">No audio URL</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<box flexDirection="column" gap={1}>
|
||||
@@ -64,15 +86,19 @@ export function Player(props: PlayerProps) {
|
||||
<strong>Now Playing</strong>
|
||||
</text>
|
||||
<text fg="gray">
|
||||
Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")}
|
||||
{formatTime(audio.position())} / {formatTime(dur())}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{audio.error() && (
|
||||
<text fg="red">{audio.error()}</text>
|
||||
)}
|
||||
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg="white">
|
||||
<strong>{SAMPLE_EPISODE.title}</strong>
|
||||
<strong>{episode().title}</strong>
|
||||
</text>
|
||||
<text fg="gray">{SAMPLE_EPISODE.description}</text>
|
||||
<text fg="gray">{episode().description}</text>
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
@@ -81,7 +107,7 @@ export function Player(props: PlayerProps) {
|
||||
<box
|
||||
width={`${progressPercent()}%`}
|
||||
height={1}
|
||||
backgroundColor={isPlaying() ? "#6fa8ff" : "#7d8590"}
|
||||
backgroundColor={audio.isPlaying() ? "#6fa8ff" : "#7d8590"}
|
||||
/>
|
||||
</box>
|
||||
<text fg="gray">{progressPercent()}%</text>
|
||||
@@ -89,26 +115,35 @@ export function Player(props: PlayerProps) {
|
||||
|
||||
<Waveform
|
||||
data={waveform()}
|
||||
position={position()}
|
||||
duration={SAMPLE_EPISODE.duration}
|
||||
isPlaying={isPlaying()}
|
||||
onSeek={(next: number) => setPosition(next)}
|
||||
position={audio.position()}
|
||||
duration={dur()}
|
||||
isPlaying={audio.isPlaying()}
|
||||
onSeek={(next: number) => audio.seek(next)}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying()}
|
||||
volume={volume()}
|
||||
speed={speed()}
|
||||
onToggle={() => 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)}
|
||||
/>
|
||||
|
||||
<text fg="gray">Enter dive | Esc up | Space play/pause | Left/Right seek</text>
|
||||
<text fg="gray">Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
348
src/hooks/useAudio.ts
Normal file
348
src/hooks/useAudio.ts
Normal file
@@ -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)
|
||||
* <text>{audio.isPlaying() ? "Playing" : "Paused"}</text>
|
||||
* ```
|
||||
*/
|
||||
|
||||
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<void>
|
||||
pause: () => Promise<void>
|
||||
resume: () => Promise<void>
|
||||
togglePlayback: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
seek: (seconds: number) => Promise<void>
|
||||
seekRelative: (delta: number) => Promise<void>
|
||||
setVolume: (volume: number) => Promise<void>
|
||||
setSpeed: (speed: number) => Promise<void>
|
||||
switchBackend: (name: BackendName) => Promise<void>
|
||||
}
|
||||
|
||||
// Singleton state — shared across all components that call useAudio()
|
||||
let backend: AudioBackend | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | 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<BackendName>("none")
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
|
||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (isPlaying()) {
|
||||
await pause()
|
||||
} else if (currentEpisode()) {
|
||||
await resume()
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await seek(position() + delta)
|
||||
}
|
||||
|
||||
async function doSetVolume(vol: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
745
src/utils/audio-player.ts
Normal file
745
src/utils/audio-player.ts
Normal file
@@ -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<void>
|
||||
pause(): Promise<void>
|
||||
resume(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
seek(seconds: number): Promise<void>
|
||||
setVolume(volume: number): Promise<void>
|
||||
setSpeed(speed: number): Promise<void>
|
||||
getPosition(): Promise<number>
|
||||
getDuration(): Promise<number>
|
||||
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<typeof Bun.spawn> | null = null
|
||||
private socketPath = mpvSocketPath()
|
||||
private _playing = false
|
||||
private _position = 0
|
||||
private _duration = 0
|
||||
private _volume = 100
|
||||
private _speed = 1
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
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<void> {
|
||||
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<unknown> {
|
||||
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<string>((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<unknown> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
try {
|
||||
return await new Promise<number>((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<void> {
|
||||
await this.send(["set_property", "pause", true])
|
||||
this._playing = false
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
await this.send(["set_property", "pause", false])
|
||||
this._playing = true
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
await this.send(["set_property", "time-pos", seconds])
|
||||
this._position = seconds
|
||||
}
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
const v = Math.round(volume * 100)
|
||||
await this.send(["set_property", "volume", v])
|
||||
this._volume = v
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
await this.send(["set_property", "speed", speed])
|
||||
this._speed = speed
|
||||
}
|
||||
|
||||
async getPosition(): Promise<number> {
|
||||
return this._position
|
||||
}
|
||||
|
||||
async getDuration(): Promise<number> {
|
||||
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<typeof Bun.spawn> | 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<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))
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
if (!this._url) return
|
||||
this.spawnProcess()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<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()
|
||||
}
|
||||
}
|
||||
|
||||
async setVolume(volume: number): Promise<void> {
|
||||
this._volume = Math.round(volume * 100)
|
||||
// ffplay can't change volume at runtime; apply on next play
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
this._speed = speed
|
||||
// ffplay doesn't support runtime speed changes
|
||||
}
|
||||
|
||||
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 _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.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) {
|
||||
try { this.proc.kill() } catch {}
|
||||
this.proc = null
|
||||
}
|
||||
this._playing = false
|
||||
this.stopPolling()
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (!this._url) return
|
||||
this.spawnProcess()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this._volume = volume
|
||||
}
|
||||
|
||||
async setSpeed(speed: number): Promise<void> {
|
||||
this._speed = speed
|
||||
}
|
||||
|
||||
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 {
|
||||
readonly name: BackendName = "none"
|
||||
async play(): Promise<void> {}
|
||||
async pause(): Promise<void> {}
|
||||
async resume(): Promise<void> {}
|
||||
async stop(): Promise<void> {}
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user