Redesign audio controls into a real media player
Build and Push Docker Image / build-and-push (push) Successful in 5m5s
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:
1 parent
b9f9b97cba
commit
f1026dc194
4 files changed
+265
-124
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 */}
|
||||||
|
|||||||
Reference in new issue
Block a user