All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
251 lines
7.4 KiB
TypeScript
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;
|