Reader: ESV chapter audio
Build and Push Docker Image / build-and-push (push) Failing after 19s

The Reader's player follows the translation on screen: ESV plays its own
recording via a server route that resolves the ESV API's audio redirect;
other translations keep the BSB narrators.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 09:20:15 -04:00
1 parent e50a73d351
commit 3279dddae5
4 files changed
+51 -13

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ const App = () => {
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const [studyBuildBusy, setStudyBuildBusy] = useState(false);
const reader = useBibleReader(currentPage);
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
const readingPlans = useReadingPlans();
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => {
+4 -2
View File
@@ -9,7 +9,9 @@ export default function AudioSheet({ open, onClose, translation, bookName, chapt
title={`${bookName} ${chapter}`}
subtitle={audio.state.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: translation !== 'BSB'
: translation === 'ESV'
? 'ESV audio, read by David Cochran Heath. It follows the chapter as you browse.'
: translation !== 'BSB'
? `Audio is BSB only — no ${translation} audio is available, so it won't match the ${translation} text on screen.`
: 'Audio follows the chapter as you browse.'}
status={audio.state.status}
@@ -23,7 +25,7 @@ export default function AudioSheet({ open, onClose, translation, bookName, chapt
onNext={onNext}
nextDisabled={nextDisabled}
onStop={audio.stop}
controls={(
controls={translation === 'ESV' ? null : (
<select value={narrator} onChange={(e) => onNarrator(e.target.value)}
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
<option value="david">David</option>
+25 -10
View File
@@ -1,9 +1,9 @@
// 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 } from '../utils/bibleUtils.jsx';
import { bookOptions, CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useAudio(readerBookAbbrev, readerChapter) {
export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'BSB') {
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
@@ -17,13 +17,28 @@ export function useAudio(readerBookAbbrev, readerChapter) {
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 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();
const total = data.book?.numberOfChapters ?? chapterNum;
const url = data.thisChapterAudioLinks?.[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();
@@ -52,7 +67,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
audioEl.src = url;
await audioEl.play();
if (audioModeRef.current === 'reader') {
setReaderAudioState({ status: 'playing', chapter: chapterNum });
setReaderAudioState({ status: 'playing', chapter: chapterNum, source });
} else {
setAudioState({ status: 'playing', chapter: chapterNum, total });
}
@@ -149,7 +164,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
useEffect(() => {
if (audioModeRef.current !== 'reader') return;
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
if (readerAudioState.chapter === readerChapter) return;
if (readerAudioState.chapter === readerChapter && readerAudioState.source === readerAudioSource()) return;
const wasPaused = readerAudioState.status === 'paused';
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
.then(() => {
@@ -160,7 +175,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
})
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readerBookAbbrev, readerChapter]);
}, [readerBookAbbrev, readerChapter, readerTranslation]);
useEffect(() => () => {
if (audioRef.current) {