From f1026dc1946effb4328299af281b8323816a18b8 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:59:42 -0400 Subject: [PATCH] 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 --- src/components/AudioPlayer.jsx | 177 +++++++++++++++++++++++++++++++++ src/hooks/useAudio.js | 17 ++++ src/pages/HomePage.jsx | 121 ++++++++-------------- src/pages/ReaderPage.jsx | 74 ++++++-------- 4 files changed, 265 insertions(+), 124 deletions(-) create mode 100644 src/components/AudioPlayer.jsx diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx new file mode 100644 index 0000000..5cfc3d7 --- /dev/null +++ b/src/components/AudioPlayer.jsx @@ -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 ( + + + + ); +} + +function PauseIcon() { + return ( + + + + + ); +} + +function StopIcon() { + return ( + + + + ); +} + +function Seek10Icon({ direction }) { + const flip = direction === 'back' ? 'scale(-1,1) translate(-20,0)' : undefined; + return ( + + + + + + 10 + + ); +} + +function NextTrackIcon() { + return ( + + + + + ); +} + +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 ( +
+
+
+

{title}

+

{subtitle}

+ {status === 'error' && errorMessage && ( +

{errorMessage}

+ )} +
+ {controls &&
{controls}
} +
+ + {isActive && ( +
+ 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" + /> +
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ )} + +
+ {isActive ? ( + <> + + + + {onNext && ( + + )} + + + ) : ( + + )} +
+
+ ); +} diff --git a/src/hooks/useAudio.js b/src/hooks/useAudio.js index dae0181..018e885 100644 --- a/src/hooks/useAudio.js +++ b/src/hooks/useAudio.js @@ -8,6 +8,7 @@ export function useAudio(readerBookAbbrev, readerChapter) { const [audioNarrator, setAudioNarrator] = useState('souer'); const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 }); const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 }); + const [audioProgress, setAudioProgress] = useState({ currentTime: 0, duration: 0 }); const audioRef = useRef(null); const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef 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 (!audioRef.current) { 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', () => { if (audioModeRef.current === 'reader') { setReaderAudioState({ status: 'idle', chapter: 0 }); @@ -67,6 +74,7 @@ export function useAudio(readerBookAbbrev, readerChapter) { } setAudioState({ status: 'idle', chapter: 0, total: 0 }); setReaderAudioState({ status: 'idle', chapter: 0 }); + setAudioProgress({ currentTime: 0, duration: 0 }); }; const handleToggleBookAudioPause = () => { @@ -87,6 +95,13 @@ export function useAudio(readerBookAbbrev, readerChapter) { 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 nextChapter = audioStateRef.current.chapter + 1; if (nextChapter > audioStateRef.current.total) return; @@ -144,11 +159,13 @@ export function useAudio(readerBookAbbrev, readerChapter) { audioBook, setAudioBook, audioNarrator, setAudioNarrator, audioState, + audioProgress, handlePlayBookAudio, handleStopBookAudio, handleToggleBookAudioPause, handleSkipBookChapter, handleSeekAudio, + handleSeekAudioTo, readerAudioState, handlePlayReaderAudio, handleToggleReaderAudioPause, diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 7fb1c6c..62b93b6 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import AuthStatus from '../components/AuthStatus.jsx'; +import AudioPlayer from '../components/AudioPlayer.jsx'; import { bookOptions, CHAPTER_COUNTS, @@ -21,6 +22,7 @@ export default function HomePage() { audioBook, setAudioBook, audioNarrator, setAudioNarrator, audioState, + audioProgress, openBibleReader, openImportProject, openNewProject, @@ -33,6 +35,7 @@ export default function HomePage() { handleToggleBookAudioPause, handleSkipBookChapter, handleSeekAudio, + handleSeekAudioTo, } = useApp(); const [homeSearch, setHomeSearch] = useState(''); const [homeSort, setHomeSort] = useState(() => { @@ -233,91 +236,51 @@ export default function HomePage() { {/* Reading plan card */} -
-
-

Listen to BSB Audio

-

- {audioState.status === 'idle' || audioState.status === 'error' +

+ b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`} -

- {audioState.status === 'error' && ( -

Couldn't load audio for this book/narrator.

- )} -
-
- - - {audioState.status === 'playing' || audioState.status === 'paused' ? ( + : `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}` + } + errorMessage="Couldn't load audio for this book/narrator." + status={audioState.status} + currentTime={audioProgress.currentTime} + duration={audioProgress.duration} + onPlay={handlePlayBookAudio} + onTogglePause={handleToggleBookAudioPause} + onSeekBack={() => handleSeekAudio(-10)} + onSeekForward={() => handleSeekAudio(10)} + onSeekTo={handleSeekAudioTo} + onNext={handleSkipBookChapter} + nextDisabled={audioState.chapter >= audioState.total} + onStop={handleStopBookAudio} + controls={( <> - )} -
+ />
{projectIndex diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index ba3e54e..af9f083 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import { useApp } from '../context/AppContext.js'; import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js'; import AuthStatus from '../components/AuthStatus.jsx'; +import AudioPlayer from '../components/AudioPlayer.jsx'; import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx'; import DrawCanvas from './DrawCanvas.jsx'; @@ -33,6 +34,7 @@ export default function ReaderPage() { readerSearchActive, setReaderSearchActive, readerSearchScope, setReaderSearchScope, readerAudioState, + audioProgress, bibleIndexStatus, audioNarrator, setAudioNarrator, _bibleIndexCacheRef, @@ -48,6 +50,7 @@ export default function ReaderPage() { handleToggleReaderAudioPause, handleStopBookAudio, handleSeekAudio, + handleSeekAudioTo, setReaderCrossRefs, readerInkByPage, updateReaderPageInk, @@ -503,53 +506,34 @@ export default function ReaderPage() { )} {/* Audio player */} -
-
-

Listen to this chapter

-

- {readerAudioState.status === 'error' +

+ -
-
- - {readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? ( - <> - - - - - - - ) : ( - + : `Audio follows ${readerBook?.name} ${readerChapter} as you browse.` + } + status={readerAudioState.status} + currentTime={audioProgress.currentTime} + duration={audioProgress.duration} + onPlay={handlePlayReaderAudio} + onTogglePause={handleToggleReaderAudioPause} + onSeekBack={() => handleSeekAudio(-10)} + onSeekForward={() => handleSeekAudio(10)} + onSeekTo={handleSeekAudioTo} + onNext={readerGoToNextChapter} + nextDisabled={readerChapter >= readerTotalChapters} + onStop={handleStopBookAudio} + controls={( + )} -
+ />
{/* Verses — normal scroll mode */}