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 [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,
|
||||
|
||||
+42
-79
@@ -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 */}
|
||||
<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="flex-1">
|
||||
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{audioState.status === 'idle' || audioState.status === 'error'
|
||||
<div className="mb-4">
|
||||
<AudioPlayer
|
||||
title="Listen to BSB Audio"
|
||||
subtitle={
|
||||
audioState.status === 'idle' || audioState.status === 'error'
|
||||
? 'Play a full book of the Berean Standard Bible.'
|
||||
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
|
||||
</p>
|
||||
{audioState.status === 'error' && (
|
||||
<p className="mt-1 text-sm text-rose-600">Couldn't load audio for this book/narrator.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={audioBook}
|
||||
onChange={(e) => setAudioBook(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"
|
||||
>
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
</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' ? (
|
||||
: `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={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSeekAudio(-10)}
|
||||
title="Rewind 10 seconds"
|
||||
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"
|
||||
<select
|
||||
value={audioBook}
|
||||
onChange={(e) => setAudioBook(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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSeekAudio(10)}
|
||||
title="Fast-forward 10 seconds"
|
||||
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"
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
+10s
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<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 className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projectIndex
|
||||
|
||||
+29
-45
@@ -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 */}
|
||||
<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="flex-1">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Listen to this chapter</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{readerAudioState.status === 'error'
|
||||
<div className="mb-4">
|
||||
<AudioPlayer
|
||||
title="Listen to this chapter"
|
||||
subtitle={
|
||||
readerAudioState.status === 'error'
|
||||
? "Couldn't load audio for this chapter/narrator."
|
||||
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)}
|
||||
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">
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
</select>
|
||||
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
|
||||
<>
|
||||
<button type="button" onClick={() => handleSeekAudio(-10)} title="Rewind 10 seconds"
|
||||
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">
|
||||
-10s
|
||||
</button>
|
||||
<button type="button" onClick={() => handleSeekAudio(10)} title="Fast-forward 10 seconds"
|
||||
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">
|
||||
+10s
|
||||
</button>
|
||||
<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>
|
||||
: `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={(
|
||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)}
|
||||
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">
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Verses — normal scroll mode */}
|
||||
|
||||
Reference in new issue
Block a user