Files
stack/apps/web/src/components/speech/AudioPlayer.tsx
Jason Woltje 74d6c1092e
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(#403): add audio playback component for TTS output
Implements AudioPlayer inline component with play/pause, progress bar,
speed control (0.5x-2x), download, and duration display. Adds
TextToSpeechButton "Read aloud" component that synthesizes text via
the speech API and integrates AudioPlayer for playback. Includes
useTextToSpeech hook with API integration, audio caching, and
playback state management. All 32 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 03:05:39 -06:00

251 lines
7.4 KiB
TypeScript

/**
* AudioPlayer Component
* Inline audio player for TTS content with play/pause, progress,
* speed control, download, and duration display.
*
* Follows PDA-friendly design: no aggressive colors, calm interface.
*/
import { useState, useRef, useEffect, useCallback } from "react";
import type { ReactElement } from "react";
/** Playback speed options */
const SPEED_OPTIONS = [1, 1.5, 2, 0.5] as const;
export interface AudioPlayerProps {
/** URL of the audio to play (blob URL or HTTP URL). If null, nothing renders. */
src: string | null;
/** Whether to auto-play when src changes */
autoPlay?: boolean;
/** Callback when play state changes */
onPlayStateChange?: (isPlaying: boolean) => void;
/** Optional className for the container */
className?: string;
}
/**
* Format seconds into M:SS display
*/
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${String(mins)}:${String(secs).padStart(2, "0")}`;
}
/**
* AudioPlayer displays an inline audio player with controls for
* play/pause, progress tracking, speed adjustment, and download.
*/
export function AudioPlayer({
src,
autoPlay = false,
onPlayStateChange,
className = "",
}: AudioPlayerProps): ReactElement | null {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [speedIndex, setSpeedIndex] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
/**
* Set up audio element when src changes
*/
useEffect((): (() => void) | undefined => {
if (!src) return undefined;
const audio = new Audio(src);
audioRef.current = audio;
const onLoadedMetadata = (): void => {
if (isFinite(audio.duration)) {
setDuration(audio.duration);
}
};
const onTimeUpdate = (): void => {
setCurrentTime(audio.currentTime);
};
const onEnded = (): void => {
setIsPlaying(false);
setCurrentTime(0);
onPlayStateChange?.(false);
};
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("ended", onEnded);
if (autoPlay) {
void audio.play().then(() => {
setIsPlaying(true);
onPlayStateChange?.(true);
});
}
return (): void => {
audio.pause();
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("ended", onEnded);
audioRef.current = null;
};
}, [src, autoPlay, onPlayStateChange]);
/**
* Toggle play/pause
*/
const togglePlayPause = useCallback(async (): Promise<void> => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
onPlayStateChange?.(false);
} else {
await audio.play();
setIsPlaying(true);
onPlayStateChange?.(true);
}
}, [isPlaying, onPlayStateChange]);
/**
* Cycle through speed options
*/
const cycleSpeed = useCallback((): void => {
const nextIndex = (speedIndex + 1) % SPEED_OPTIONS.length;
setSpeedIndex(nextIndex);
const audio = audioRef.current;
if (audio) {
audio.playbackRate = SPEED_OPTIONS[nextIndex] ?? 1;
}
}, [speedIndex]);
/**
* Handle progress bar click for seeking
*/
const handleProgressClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>): void => {
const audio = audioRef.current;
if (!audio || !duration) return;
const rect = event.currentTarget.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const fraction = clickX / rect.width;
audio.currentTime = fraction * duration;
setCurrentTime(audio.currentTime);
},
[duration]
);
/**
* Handle download
*/
const handleDownload = useCallback((): void => {
if (!src) return;
const link = document.createElement("a");
link.href = src;
link.download = "speech-audio.mp3";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [src]);
// Don't render if no source
if (!src) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const currentSpeed = SPEED_OPTIONS[speedIndex] ?? 1;
return (
<div
role="region"
aria-label="Audio player"
className={`flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 ${className}`}
>
{/* Play/Pause Button */}
<button
type="button"
onClick={() => void togglePlayPause()}
aria-label={isPlaying ? "Pause audio" : "Play audio"}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-500 text-white transition-colors hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
{isPlaying ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<polygon points="6,4 20,12 6,20" />
</svg>
)}
</button>
{/* Time Display */}
<span className="min-w-[3.5rem] text-xs text-gray-500 tabular-nums">
{formatTime(currentTime)}
{duration > 0 && <span className="text-gray-400"> / {formatTime(duration)}</span>}
</span>
{/* Progress Bar */}
<div
role="progressbar"
aria-label="Audio progress"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-1.5 flex-1 cursor-pointer rounded-full bg-gray-200"
onClick={handleProgressClick}
>
<div
className="absolute left-0 top-0 h-full rounded-full bg-blue-400 transition-all"
style={{ width: `${String(Math.min(progress, 100))}%` }}
/>
</div>
{/* Speed Control */}
<button
type="button"
onClick={cycleSpeed}
aria-label="Playback speed"
className="min-w-[2.5rem] rounded px-1.5 py-0.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
{String(currentSpeed)}x
</button>
{/* Download Button */}
<button
type="button"
onClick={handleDownload}
aria-label="Download audio"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
);
}
export default AudioPlayer;