133 lines
3.8 KiB
TypeScript
133 lines
3.8 KiB
TypeScript
import { useKeyboard } from "@opentui/solid"
|
|
import { PlaybackControls } from "./PlaybackControls"
|
|
import { MergedWaveform } from "./MergedWaveform"
|
|
import { useAudio } from "../hooks/useAudio"
|
|
import type { Episode } from "../types/episode"
|
|
|
|
type PlayerProps = {
|
|
focused: boolean
|
|
episode?: Episode | null
|
|
onExit?: () => void
|
|
}
|
|
|
|
const SAMPLE_EPISODE: Episode = {
|
|
id: "sample-ep",
|
|
podcastId: "sample-podcast",
|
|
title: "A Tour of the Productive Mind",
|
|
description: "A short guided session on building creative focus.",
|
|
audioUrl: "",
|
|
duration: 2780,
|
|
pubDate: new Date(),
|
|
}
|
|
|
|
export function Player(props: PlayerProps) {
|
|
const audio = useAudio()
|
|
|
|
// 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") {
|
|
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") {
|
|
props.onExit?.()
|
|
return
|
|
}
|
|
if (key.name === "left") {
|
|
audio.seekRelative(-10)
|
|
}
|
|
if (key.name === "right") {
|
|
audio.seekRelative(10)
|
|
}
|
|
if (key.name === "up") {
|
|
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))))
|
|
}
|
|
if (key.name === "down") {
|
|
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))))
|
|
}
|
|
if (key.name === "s") {
|
|
const next = audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2))
|
|
audio.setSpeed(next)
|
|
}
|
|
})
|
|
|
|
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}>
|
|
<box flexDirection="row" justifyContent="space-between">
|
|
<text>
|
|
<strong>Now Playing</strong>
|
|
</text>
|
|
<text fg="gray">
|
|
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
|
|
</text>
|
|
</box>
|
|
|
|
{audio.error() && (
|
|
<text fg="red">{audio.error()}</text>
|
|
)}
|
|
|
|
<box border padding={1} flexDirection="column" gap={1}>
|
|
<text fg="white">
|
|
<strong>{episode().title}</strong>
|
|
</text>
|
|
<text fg="gray">{episode().description}</text>
|
|
|
|
<MergedWaveform
|
|
audioUrl={episode().audioUrl}
|
|
position={audio.position()}
|
|
duration={dur()}
|
|
isPlaying={audio.isPlaying()}
|
|
onSeek={(next: number) => audio.seek(next)}
|
|
/>
|
|
</box>
|
|
|
|
<PlaybackControls
|
|
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">Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back</text>
|
|
</box>
|
|
)
|
|
}
|