Build and Push Docker Image / build-and-push (push) Failing after 20s
Settings gets a Preferences section: theme, default Bible, the Bible new reading plans start in, verse layout, commentary, text size, reading width, audio narrator (the last three stay per device; text size and narrator are now remembered). A Home page setting (My Studies, Bible Reader or Reading Plans) decides where the app opens and where every Home button goes; already being there means My Studies. Back arrows are labelled Home. Tests cover every page leading back. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
213 lines
8.2 KiB
JavaScript
213 lines
8.2 KiB
JavaScript
// Chapter audio playback. The Home page's whole-book player and the Reader's
|
|
// chapter player share one <audio> element; audioModeRef tracks which owns it.
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { bookOptions, CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
|
|
|
|
export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'BSB') {
|
|
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
|
|
const [audioNarrator, setAudioNarratorState] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('audio-narrator');
|
|
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
|
|
} catch { return 'souer'; }
|
|
});
|
|
const setAudioNarrator = (id) => {
|
|
setAudioNarratorState(id);
|
|
try { localStorage.setItem('audio-narrator', id); } catch { /* storage unavailable */ }
|
|
};
|
|
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);
|
|
const audioNarratorRef = useRef(audioNarrator);
|
|
const audioStateRef = useRef(audioState);
|
|
audioBookRef.current = audioBook;
|
|
audioNarratorRef.current = audioNarrator;
|
|
audioStateRef.current = audioState;
|
|
// The Reader's player follows the translation on screen: ESV has its own
|
|
// recording; every other translation falls back to the BSB narrators.
|
|
const readerTranslationRef = useRef(readerTranslation);
|
|
readerTranslationRef.current = readerTranslation;
|
|
const readerAudioSource = () => (readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
|
|
|
|
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
|
|
const source = audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
|
|
let url;
|
|
let total;
|
|
if (source === 'ESV') {
|
|
const res = await fetch(`/api/bible/ESV/audio/${abbrev}/${chapterNum}`, { credentials: 'include' });
|
|
if (!res.ok) throw new Error('Failed to load ESV audio');
|
|
url = (await res.json()).url;
|
|
total = CHAPTER_COUNTS[abbrev] ?? chapterNum;
|
|
} else {
|
|
const res = await fetch(`https://bible.helloao.org/api/BSB/${abbrev}/${chapterNum}.json`);
|
|
if (!res.ok) throw new Error('Failed to load chapter audio');
|
|
const data = await res.json();
|
|
total = data.book?.numberOfChapters ?? chapterNum;
|
|
url = data.thisChapterAudioLinks?.[narrator];
|
|
}
|
|
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 });
|
|
return;
|
|
}
|
|
const nextChapter = audioStateRef.current.chapter + 1;
|
|
if (nextChapter > audioStateRef.current.total) {
|
|
setAudioState({ status: 'idle', chapter: 0, total: 0 });
|
|
return;
|
|
}
|
|
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
|
|
setAudioState({ status: 'error', chapter: 0, total: 0 }),
|
|
);
|
|
});
|
|
}
|
|
const audioEl = audioRef.current;
|
|
audioEl.src = url;
|
|
await audioEl.play();
|
|
if (audioModeRef.current === 'reader') {
|
|
setReaderAudioState({ status: 'playing', chapter: chapterNum, source });
|
|
} else {
|
|
setAudioState({ status: 'playing', chapter: chapterNum, total });
|
|
}
|
|
};
|
|
|
|
const handlePlayBookAudio = async () => {
|
|
audioModeRef.current = 'book';
|
|
try {
|
|
await playAudioChapter(audioBook, 1, audioNarrator);
|
|
} catch {
|
|
setAudioState({ status: 'error', chapter: 0, total: 0 });
|
|
}
|
|
};
|
|
|
|
// Starts book-mode playback at an arbitrary chapter (e.g. a reading plan's
|
|
// "today" chapter) instead of always chapter 1. Explicit args avoid relying
|
|
// on reader state that may not have re-rendered yet if called right after
|
|
// navigating there in the same event handler.
|
|
const handlePlayBookAudioFrom = async (abbrev, startChapter) => {
|
|
audioModeRef.current = 'book';
|
|
setAudioBook(abbrev);
|
|
try {
|
|
await playAudioChapter(abbrev, startChapter, audioNarrator);
|
|
} catch {
|
|
setAudioState({ status: 'error', chapter: 0, total: 0 });
|
|
}
|
|
};
|
|
|
|
const handleStopBookAudio = () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.src = '';
|
|
}
|
|
setAudioState({ status: 'idle', chapter: 0, total: 0 });
|
|
setReaderAudioState({ status: 'idle', chapter: 0 });
|
|
setAudioProgress({ currentTime: 0, duration: 0 });
|
|
};
|
|
|
|
const handleToggleBookAudioPause = () => {
|
|
if (!audioRef.current) return;
|
|
if (audioRef.current.paused) {
|
|
audioRef.current.play();
|
|
setAudioState((prev) => ({ ...prev, status: 'playing' }));
|
|
} else {
|
|
audioRef.current.pause();
|
|
setAudioState((prev) => ({ ...prev, status: 'paused' }));
|
|
}
|
|
};
|
|
|
|
const handleSeekAudio = (deltaSeconds) => {
|
|
if (!audioRef.current) return;
|
|
const el = audioRef.current;
|
|
const duration = Number.isFinite(el.duration) ? el.duration : Infinity;
|
|
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;
|
|
audioModeRef.current = 'book';
|
|
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
|
|
setAudioState({ status: 'error', chapter: 0, total: 0 }),
|
|
);
|
|
};
|
|
|
|
const handlePlayReaderAudio = async () => {
|
|
audioModeRef.current = 'reader';
|
|
try {
|
|
await playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator);
|
|
} catch {
|
|
setReaderAudioState({ status: 'error', chapter: 0 });
|
|
}
|
|
};
|
|
|
|
const handleToggleReaderAudioPause = () => {
|
|
if (!audioRef.current) return;
|
|
if (audioRef.current.paused) {
|
|
audioRef.current.play();
|
|
setReaderAudioState((prev) => ({ ...prev, status: 'playing' }));
|
|
} else {
|
|
audioRef.current.pause();
|
|
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
|
|
}
|
|
};
|
|
|
|
// Auto-switch the reader's audio to match the book/chapter currently being read.
|
|
useEffect(() => {
|
|
if (audioModeRef.current !== 'reader') return;
|
|
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
|
|
if (readerAudioState.chapter === readerChapter && readerAudioState.source === readerAudioSource()) return;
|
|
const wasPaused = readerAudioState.status === 'paused';
|
|
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
|
|
.then(() => {
|
|
if (wasPaused && audioRef.current) {
|
|
audioRef.current.pause();
|
|
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
|
|
}
|
|
})
|
|
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [readerBookAbbrev, readerChapter, readerTranslation]);
|
|
|
|
useEffect(() => () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.src = '';
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
audioBook, setAudioBook,
|
|
audioNarrator, setAudioNarrator,
|
|
audioState,
|
|
audioProgress,
|
|
handlePlayBookAudio,
|
|
handlePlayBookAudioFrom,
|
|
handleStopBookAudio,
|
|
handleToggleBookAudioPause,
|
|
handleSkipBookChapter,
|
|
handleSeekAudio,
|
|
handleSeekAudioTo,
|
|
readerAudioState,
|
|
handlePlayReaderAudio,
|
|
handleToggleReaderAudioPause,
|
|
};
|
|
}
|