Files
study-bible-project/src/hooks/useAudio.js
T
nmemmertandClaude Sonnet 5.5 8c4f54e033
Build and Push Docker Image / build-and-push (push) Failing after 20s
Preferences in Settings and a home page the user chooses
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>
2026-09-29 10:55:07 -04:00

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,
};
}