Build and Push Docker Image / build-and-push (push) Successful in 5m5s
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>
178 lines
6.3 KiB
React
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>
|
|
);
|
|
}
|