Files
study-bible-project/src/components/AudioPlayer.jsx
T
nmemmertandClaude Sonnet 5 f1026dc194
Build and Push Docker Image / build-and-push (push) Successful in 5m5s
Redesign audio controls into a real media player
Adds a shared AudioPlayer component with a seekable progress bar,
big play/pause button, and icon transport controls, replacing the
row of text buttons on both the book and chapter audio players.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:59:42 -04:00

178 lines
6.3 KiB
React

// A compact media-player control: big play/pause button, seekable progress
// bar, and transport buttons. Presentation only — playback state and handlers
// come from useAudio.js so the Home (whole book) and Reader (chapter) players
// can share this UI.
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
<path d="M5.5 3.6c0-.9 1-1.45 1.76-.97l9.4 5.9c.72.45.72 1.5 0 1.94l-9.4 5.9c-.76.48-1.76-.07-1.76-.97V3.6z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
<rect x="4.5" y="3.5" width="4" height="13" rx="1" />
<rect x="11.5" y="3.5" width="4" height="13" rx="1" />
</svg>
);
}
function StopIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
<rect x="4.5" y="4.5" width="11" height="11" rx="1.5" />
</svg>
);
}
function Seek10Icon({ direction }) {
const flip = direction === 'back' ? 'scale(-1,1) translate(-20,0)' : undefined;
return (
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
<g transform={flip}>
<path d="M4 8.5a6.5 6.5 0 1 1 1.2 5.3" strokeLinecap="round" />
<path d="M3 5.5v3.3h3.3" strokeLinecap="round" strokeLinejoin="round" />
</g>
<text x="10" y="12.2" fontSize="6.5" fontFamily="sans-serif" stroke="none" fill="currentColor" textAnchor="middle">10</text>
</svg>
);
}
function NextTrackIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
<path d="M5 4.3c0-.8.9-1.3 1.6-.9l7.4 4.7c.7.4.7 1.4 0 1.8l-7.4 4.7c-.7.4-1.6-.1-1.6-.9V4.3z" />
<rect x="14" y="3.7" width="1.6" height="12.6" rx="0.5" />
</svg>
);
}
export default function AudioPlayer({
title,
subtitle,
errorMessage,
status, // 'idle' | 'playing' | 'paused' | 'error'
currentTime = 0,
duration = 0,
onPlay,
onTogglePause,
onSeekBack,
onSeekForward,
onSeekTo,
onNext,
nextDisabled,
onStop,
controls, // optional extra controls (e.g. book/narrator selects), rendered top-right
}) {
const isActive = status === 'playing' || status === 'paused';
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-panel sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-slate-900 sm:text-base">{title}</h3>
<p className="mt-0.5 truncate text-xs text-slate-500 sm:text-sm">{subtitle}</p>
{status === 'error' && errorMessage && (
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
)}
</div>
{controls && <div className="flex flex-wrap items-center gap-2">{controls}</div>}
</div>
{isActive && (
<div className="mt-4">
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={Math.min(currentTime, duration || 0)}
onChange={(e) => onSeekTo?.(Number(e.target.value))}
disabled={!duration}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-sky-600 disabled:cursor-default"
style={{ background: `linear-gradient(to right, rgb(2 132 199) ${progressPct}%, rgb(226 232 240) ${progressPct}%)` }}
aria-label="Seek"
/>
<div className="mt-1 flex justify-between text-xs tabular-nums text-slate-400">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
)}
<div className="mt-3 flex items-center justify-center gap-3">
{isActive ? (
<>
<button
type="button"
onClick={onSeekBack}
title="Rewind 10 seconds"
aria-label="Rewind 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
>
<Seek10Icon direction="back" />
</button>
<button
type="button"
onClick={onTogglePause}
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
>
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
</button>
<button
type="button"
onClick={onSeekForward}
title="Fast-forward 10 seconds"
aria-label="Fast-forward 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
>
<Seek10Icon direction="forward" />
</button>
{onNext && (
<button
type="button"
onClick={onNext}
disabled={nextDisabled}
title="Next chapter"
aria-label="Next chapter"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100 disabled:opacity-30 disabled:hover:bg-transparent"
>
<NextTrackIcon />
</button>
)}
<button
type="button"
onClick={onStop}
title="Stop"
aria-label="Stop"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 hover:bg-slate-100"
>
<StopIcon />
</button>
</>
) : (
<button
type="button"
onClick={onPlay}
className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-sm hover:bg-sky-500"
aria-label="Play"
>
<PlayIcon />
</button>
)}
</div>
</div>
);
}