Redesign audio controls into a real media player
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>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 11:59:42 -04:00
1 parent b9f9b97cba
commit f1026dc194
4 files changed
+265 -124

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
// 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>
);
}
+17
View File
@@ -8,6 +8,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
const [audioNarrator, setAudioNarrator] = useState('souer'); const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 }); const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 }); const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
const [audioProgress, setAudioProgress] = useState({ currentTime: 0, duration: 0 });
const audioRef = useRef(null); const audioRef = useRef(null);
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
const audioBookRef = useRef(audioBook); const audioBookRef = useRef(audioBook);
@@ -26,6 +27,12 @@ export function useAudio(readerBookAbbrev, readerChapter) {
if (!url) throw new Error('Audio not available for this narrator'); if (!url) throw new Error('Audio not available for this narrator');
if (!audioRef.current) { if (!audioRef.current) {
audioRef.current = new Audio(); audioRef.current = new Audio();
audioRef.current.addEventListener('timeupdate', () => {
setAudioProgress({ currentTime: audioRef.current.currentTime, duration: audioRef.current.duration || 0 });
});
audioRef.current.addEventListener('loadedmetadata', () => {
setAudioProgress((prev) => ({ ...prev, duration: audioRef.current.duration || 0 }));
});
audioRef.current.addEventListener('ended', () => { audioRef.current.addEventListener('ended', () => {
if (audioModeRef.current === 'reader') { if (audioModeRef.current === 'reader') {
setReaderAudioState({ status: 'idle', chapter: 0 }); setReaderAudioState({ status: 'idle', chapter: 0 });
@@ -67,6 +74,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
} }
setAudioState({ status: 'idle', chapter: 0, total: 0 }); setAudioState({ status: 'idle', chapter: 0, total: 0 });
setReaderAudioState({ status: 'idle', chapter: 0 }); setReaderAudioState({ status: 'idle', chapter: 0 });
setAudioProgress({ currentTime: 0, duration: 0 });
}; };
const handleToggleBookAudioPause = () => { const handleToggleBookAudioPause = () => {
@@ -87,6 +95,13 @@ export function useAudio(readerBookAbbrev, readerChapter) {
el.currentTime = Math.min(Math.max(el.currentTime + deltaSeconds, 0), duration); el.currentTime = Math.min(Math.max(el.currentTime + deltaSeconds, 0), duration);
}; };
const handleSeekAudioTo = (seconds) => {
if (!audioRef.current) return;
const el = audioRef.current;
const duration = Number.isFinite(el.duration) ? el.duration : 0;
el.currentTime = Math.min(Math.max(seconds, 0), duration);
};
const handleSkipBookChapter = () => { const handleSkipBookChapter = () => {
const nextChapter = audioStateRef.current.chapter + 1; const nextChapter = audioStateRef.current.chapter + 1;
if (nextChapter > audioStateRef.current.total) return; if (nextChapter > audioStateRef.current.total) return;
@@ -144,11 +159,13 @@ export function useAudio(readerBookAbbrev, readerChapter) {
audioBook, setAudioBook, audioBook, setAudioBook,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
audioState, audioState,
audioProgress,
handlePlayBookAudio, handlePlayBookAudio,
handleStopBookAudio, handleStopBookAudio,
handleToggleBookAudioPause, handleToggleBookAudioPause,
handleSkipBookChapter, handleSkipBookChapter,
handleSeekAudio, handleSeekAudio,
handleSeekAudioTo,
readerAudioState, readerAudioState,
handlePlayReaderAudio, handlePlayReaderAudio,
handleToggleReaderAudioPause, handleToggleReaderAudioPause,
+42 -79
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx'; import AuthStatus from '../components/AuthStatus.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import { import {
bookOptions, bookOptions,
CHAPTER_COUNTS, CHAPTER_COUNTS,
@@ -21,6 +22,7 @@ export default function HomePage() {
audioBook, setAudioBook, audioBook, setAudioBook,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
audioState, audioState,
audioProgress,
openBibleReader, openBibleReader,
openImportProject, openImportProject,
openNewProject, openNewProject,
@@ -33,6 +35,7 @@ export default function HomePage() {
handleToggleBookAudioPause, handleToggleBookAudioPause,
handleSkipBookChapter, handleSkipBookChapter,
handleSeekAudio, handleSeekAudio,
handleSeekAudioTo,
} = useApp(); } = useApp();
const [homeSearch, setHomeSearch] = useState(''); const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => { const [homeSort, setHomeSort] = useState(() => {
@@ -233,91 +236,51 @@ export default function HomePage() {
{/* Reading plan card */} {/* Reading plan card */}
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} /> <ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4">
<div className="flex-1"> <AudioPlayer
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3> title="Listen to BSB Audio"
<p className="mt-1 text-sm text-slate-600"> subtitle={
{audioState.status === 'idle' || audioState.status === 'error' audioState.status === 'idle' || audioState.status === 'error'
? 'Play a full book of the Berean Standard Bible.' ? 'Play a full book of the Berean Standard Bible.'
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`} : `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`
</p> }
{audioState.status === 'error' && ( errorMessage="Couldn't load audio for this book/narrator."
<p className="mt-1 text-sm text-rose-600">Couldn't load audio for this book/narrator.</p> status={audioState.status}
)} currentTime={audioProgress.currentTime}
</div> duration={audioProgress.duration}
<div className="flex flex-wrap items-center gap-2"> onPlay={handlePlayBookAudio}
<select onTogglePause={handleToggleBookAudioPause}
value={audioBook} onSeekBack={() => handleSeekAudio(-10)}
onChange={(e) => setAudioBook(e.target.value)} onSeekForward={() => handleSeekAudio(10)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'} onSeekTo={handleSeekAudioTo}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60" onNext={handleSkipBookChapter}
> nextDisabled={audioState.chapter >= audioState.total}
{bookOptions.map((book) => ( onStop={handleStopBookAudio}
<option key={book.abbrev} value={book.abbrev}>{book.name}</option> controls={(
))}
</select>
<select
value={audioNarrator}
onChange={(e) => setAudioNarrator(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
{audioState.status === 'playing' || audioState.status === 'paused' ? (
<> <>
<button <select
type="button" value={audioBook}
onClick={() => handleSeekAudio(-10)} onChange={(e) => setAudioBook(e.target.value)}
title="Rewind 10 seconds" disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
> >
-10s {bookOptions.map((book) => (
</button> <option key={book.abbrev} value={book.abbrev}>{book.name}</option>
<button ))}
type="button" </select>
onClick={() => handleSeekAudio(10)} <select
title="Fast-forward 10 seconds" value={audioNarrator}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" onChange={(e) => setAudioNarrator(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
> >
+10s <option value="david">David</option>
</button> <option value="hays">Hays</option>
<button <option value="souer">Souer</option>
type="button" </select>
onClick={handleToggleBookAudioPause}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
>
{audioState.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button
type="button"
onClick={handleSkipBookChapter}
disabled={audioState.chapter >= audioState.total}
title="Skip to next chapter"
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-60"
>
Next chapter
</button>
<button
type="button"
onClick={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
Stop
</button>
</> </>
) : (
<button
type="button"
onClick={handlePlayBookAudio}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
>
Play book
</button>
)} )}
</div> />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projectIndex {projectIndex
+29 -45
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js'; import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
import AuthStatus from '../components/AuthStatus.jsx'; import AuthStatus from '../components/AuthStatus.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx'; import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
@@ -33,6 +34,7 @@ export default function ReaderPage() {
readerSearchActive, setReaderSearchActive, readerSearchActive, setReaderSearchActive,
readerSearchScope, setReaderSearchScope, readerSearchScope, setReaderSearchScope,
readerAudioState, readerAudioState,
audioProgress,
bibleIndexStatus, bibleIndexStatus,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
_bibleIndexCacheRef, _bibleIndexCacheRef,
@@ -48,6 +50,7 @@ export default function ReaderPage() {
handleToggleReaderAudioPause, handleToggleReaderAudioPause,
handleStopBookAudio, handleStopBookAudio,
handleSeekAudio, handleSeekAudio,
handleSeekAudioTo,
setReaderCrossRefs, setReaderCrossRefs,
readerInkByPage, readerInkByPage,
updateReaderPageInk, updateReaderPageInk,
@@ -503,53 +506,34 @@ export default function ReaderPage() {
)} )}
{/* Audio player */} {/* Audio player */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel"> <div className="mb-4">
<div className="flex-1"> <AudioPlayer
<h3 className="text-sm font-semibold text-slate-900">Listen to this chapter</h3> title="Listen to this chapter"
<p className="mt-1 text-xs text-slate-500"> subtitle={
{readerAudioState.status === 'error' readerAudioState.status === 'error'
? "Couldn't load audio for this chapter/narrator." ? "Couldn't load audio for this chapter/narrator."
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`} : `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`
</p> }
</div> status={readerAudioState.status}
<div className="flex flex-wrap items-center gap-2"> currentTime={audioProgress.currentTime}
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} duration={audioProgress.duration}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"> onPlay={handlePlayReaderAudio}
<option value="david">David</option> onTogglePause={handleToggleReaderAudioPause}
<option value="hays">Hays</option> onSeekBack={() => handleSeekAudio(-10)}
<option value="souer">Souer</option> onSeekForward={() => handleSeekAudio(10)}
</select> onSeekTo={handleSeekAudioTo}
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? ( onNext={readerGoToNextChapter}
<> nextDisabled={readerChapter >= readerTotalChapters}
<button type="button" onClick={() => handleSeekAudio(-10)} title="Rewind 10 seconds" onStop={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"> controls={(
-10s <select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)}
</button> className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
<button type="button" onClick={() => handleSeekAudio(10)} title="Fast-forward 10 seconds" <option value="david">David</option>
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"> <option value="hays">Hays</option>
+10s <option value="souer">Souer</option>
</button> </select>
<button type="button" onClick={handleToggleReaderAudioPause}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
title="Skip to next chapter"
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-60">
Next chapter
</button>
<button type="button" onClick={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
Stop
</button>
</>
) : (
<button type="button" onClick={handlePlayReaderAudio}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
Play
</button>
)} )}
</div> />
</div> </div>
{/* Verses — normal scroll mode */} {/* Verses — normal scroll mode */}