Add map and KJV audio playback UI
This commit is contained in:
+352
-1
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
addTagToVerse,
|
||||
getCommentaryForVerse,
|
||||
getAudioContextForVerse,
|
||||
getAudioObjectUrl,
|
||||
getDictionaryEntriesForTerms,
|
||||
getLibraryState,
|
||||
getMapContextForVerses,
|
||||
getTranslationNotesForVerses,
|
||||
getTranslations,
|
||||
searchVerses,
|
||||
@@ -18,7 +21,15 @@ import {
|
||||
timelineLimit,
|
||||
} from './workspace'
|
||||
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
|
||||
import type {
|
||||
AudioBibleContext,
|
||||
AudioBibleSegment,
|
||||
CommentaryEntry,
|
||||
DictionaryEntry,
|
||||
MapContext,
|
||||
TranslationResource,
|
||||
Verse,
|
||||
} from './types'
|
||||
import type {
|
||||
StrongDisplayMode,
|
||||
StudySession,
|
||||
@@ -221,7 +232,19 @@ function App() {
|
||||
const [splitPercent, setSplitPercent] = useState(50)
|
||||
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
|
||||
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
|
||||
const [mapContext, setMapContext] = useState<MapContext>({
|
||||
selectedPlaces: [],
|
||||
pagePlaces: [],
|
||||
relatedRoutes: [],
|
||||
})
|
||||
const [audioContext, setAudioContext] = useState<AudioBibleContext | null>(null)
|
||||
const [audioResourcesLoading, setAudioResourcesLoading] = useState(false)
|
||||
const [audioStatus, setAudioStatus] = useState('Ready')
|
||||
const [audioPlayingSegmentId, setAudioPlayingSegmentId] = useState<string | null>(null)
|
||||
const [audioVolume, setAudioVolume] = useState(0.85)
|
||||
const [audioSpeed, setAudioSpeed] = useState(1)
|
||||
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
|
||||
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const restoredSessionId = useRef('')
|
||||
@@ -229,6 +252,9 @@ function App() {
|
||||
const notesDragOpenTimeout = useRef<number | null>(null)
|
||||
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||
const readerRef = useRef<HTMLElement | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const audioQueueRef = useRef<AudioBibleSegment[]>([])
|
||||
const audioQueueIndexRef = useRef(0)
|
||||
const hydratedNoteChapters = useRef(new Set<string>())
|
||||
const workspaceHydrated = useRef(false)
|
||||
|
||||
@@ -603,6 +629,70 @@ function App() {
|
||||
.filter((verse) => verse.book === book)
|
||||
}, [libraryVerses, pageStartId, selectedVerse, verses])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadMapContext() {
|
||||
if (!selectedVerse || pageVerses.length === 0) {
|
||||
setMapContext({ selectedPlaces: [], pagePlaces: [], relatedRoutes: [] })
|
||||
return
|
||||
}
|
||||
|
||||
setMapResourcesLoading(true)
|
||||
try {
|
||||
const nextContext = await getMapContextForVerses(selectedVerse, pageVerses)
|
||||
if (!cancelled) setMapContext(nextContext)
|
||||
} catch (reason) {
|
||||
if (!cancelled) setError(String(reason))
|
||||
} finally {
|
||||
if (!cancelled) setMapResourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadMapContext()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pageVerses, selectedVerse])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadAudioContext() {
|
||||
if (!selectedVerse) {
|
||||
setAudioContext(null)
|
||||
return
|
||||
}
|
||||
|
||||
setAudioResourcesLoading(true)
|
||||
try {
|
||||
const nextContext = await getAudioContextForVerse(
|
||||
activeSession?.baseTranslation ?? defaultTranslationId,
|
||||
selectedVerse,
|
||||
)
|
||||
if (!cancelled) setAudioContext(nextContext)
|
||||
} catch (reason) {
|
||||
if (!cancelled) {
|
||||
setAudioContext(null)
|
||||
setError(String(reason))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setAudioResourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAudioContext()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeSession?.baseTranslation, selectedVerse])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return
|
||||
audioRef.current.volume = audioVolume
|
||||
audioRef.current.playbackRate = audioSpeed
|
||||
}, [audioSpeed, audioVolume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession?.baseTranslation || pageVerses.length === 0) return
|
||||
const chapterKeys = [
|
||||
@@ -985,6 +1075,69 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
async function playAudioQueue(segments: AudioBibleSegment[], startIndex = 0) {
|
||||
if (!segments.length) return
|
||||
audioQueueRef.current = segments
|
||||
audioQueueIndexRef.current = startIndex
|
||||
await playQueuedAudioSegment(startIndex)
|
||||
}
|
||||
|
||||
async function playQueuedAudioSegment(index: number) {
|
||||
const segment = audioQueueRef.current[index]
|
||||
const audio = audioRef.current
|
||||
if (!segment || !audio) return
|
||||
|
||||
setAudioResourcesLoading(true)
|
||||
setAudioStatus(`Loading ${segment.reference}`)
|
||||
try {
|
||||
const url = await getAudioObjectUrl(segment)
|
||||
audio.pause()
|
||||
audio.src = url
|
||||
audio.volume = audioVolume
|
||||
audio.playbackRate = audioSpeed
|
||||
await audio.play()
|
||||
setAudioPlayingSegmentId(segment.id)
|
||||
setAudioStatus(`Playing ${segment.reference}`)
|
||||
recordTimeline({
|
||||
type: 'setting',
|
||||
label: `Audio: ${segment.reference}`,
|
||||
reference: segment.reference,
|
||||
detail: segment.audioZipFile,
|
||||
})
|
||||
} catch (reason) {
|
||||
setAudioPlayingSegmentId(null)
|
||||
setAudioStatus(`Audio unavailable: ${String(reason)}`)
|
||||
} finally {
|
||||
setAudioResourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
const audio = audioRef.current
|
||||
audioQueueRef.current = []
|
||||
audioQueueIndexRef.current = 0
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
setAudioPlayingSegmentId(null)
|
||||
setAudioStatus('Stopped')
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
const nextIndex = audioQueueIndexRef.current + 1
|
||||
if (nextIndex < audioQueueRef.current.length) {
|
||||
audioQueueIndexRef.current = nextIndex
|
||||
playQueuedAudioSegment(nextIndex).catch((reason) => setAudioStatus(String(reason)))
|
||||
return
|
||||
}
|
||||
|
||||
audioQueueRef.current = []
|
||||
audioQueueIndexRef.current = 0
|
||||
setAudioPlayingSegmentId(null)
|
||||
setAudioStatus('Ready')
|
||||
}
|
||||
|
||||
function renderVerseText(verse: Verse) {
|
||||
if (!activeSession?.strongsEnabled || !verse.strongs.length || strongDisplay === 'off') return verse.text
|
||||
|
||||
@@ -1355,6 +1508,202 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function renderMapSection() {
|
||||
const places = mapContext.pagePlaces
|
||||
const selectedPlaces = mapContext.selectedPlaces
|
||||
const routes = mapContext.relatedRoutes
|
||||
const bounds = mapBounds(places)
|
||||
|
||||
return (
|
||||
<article className="resource-card map-card" aria-label="Contextual maps">
|
||||
<header className="margin-section-header">
|
||||
<button type="button" className="collapse-button">Context Map</button>
|
||||
<span>
|
||||
{places.length
|
||||
? `${places.length} places`
|
||||
: mapResourcesLoading
|
||||
? 'Loading'
|
||||
: 'None'}
|
||||
</span>
|
||||
</header>
|
||||
{places.length ? (
|
||||
<>
|
||||
<div className="context-map" aria-label="Visible-page place map">
|
||||
<div className="map-grid" aria-hidden="true" />
|
||||
{places.map((place) => {
|
||||
const selected = selectedPlaces.some((item) => item.placeId === place.placeId)
|
||||
const point = mapPoint(place, bounds)
|
||||
return (
|
||||
<button
|
||||
key={place.id}
|
||||
type="button"
|
||||
className={selected ? 'map-marker selected' : 'map-marker'}
|
||||
style={{ left: `${point.x}%`, top: `${point.y}%` }}
|
||||
title={`${place.placeName}: ${place.modernLocationName}`}
|
||||
>
|
||||
<span>{place.placeName.slice(0, 1)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="place-list">
|
||||
{places.slice(0, 8).map((place) => (
|
||||
<section key={place.id}>
|
||||
<h3>{place.placeName}</h3>
|
||||
<p>
|
||||
<span>{place.modernLocationName || 'Unresolved location'}</span>
|
||||
<small>{place.confidenceLabel} confidence</small>
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{routes.length ? (
|
||||
<div className="route-list">
|
||||
<strong>Related routes</strong>
|
||||
{routes.map((route) => (
|
||||
<span key={route.id}>
|
||||
{route.title}
|
||||
<small>{route.mapNumber}</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="map-attribution">
|
||||
Places: OpenBible.info. Routes: UBS Project MARBLE. Coordinates are contextual and may be approximate.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="resource-disabled">
|
||||
{mapResourcesLoading ? 'Loading contextual map data...' : 'No mapped places matched this page.'}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAudioSection() {
|
||||
const selectedSegment = audioContext?.selectedSegment ?? null
|
||||
const chapterSegments = audioContext?.chapterPlaylist?.segments ?? []
|
||||
const selectedIndex = selectedSegment
|
||||
? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<article className="resource-card audio-card" aria-label="Audio Bible">
|
||||
<header className="margin-section-header">
|
||||
<button type="button" className="collapse-button">Audio Bible</button>
|
||||
<span>
|
||||
{audioResourcesLoading
|
||||
? 'Loading'
|
||||
: audioContext
|
||||
? audioContext.shortTitle
|
||||
: 'None'}
|
||||
</span>
|
||||
</header>
|
||||
{audioContext && selectedSegment ? (
|
||||
<>
|
||||
<div className="audio-controls">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playAudioQueue([selectedSegment]).catch((reason) => setAudioStatus(String(reason)))}
|
||||
>
|
||||
Play verse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
playAudioQueue(chapterSegments, 0).catch((reason) => setAudioStatus(String(reason)))
|
||||
}
|
||||
>
|
||||
Play chapter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
playAudioQueue(chapterSegments, selectedIndex).catch((reason) => setAudioStatus(String(reason)))
|
||||
}
|
||||
>
|
||||
From verse
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={stopAudio}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<div className="audio-sliders">
|
||||
<label>
|
||||
Volume
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={audioVolume}
|
||||
onChange={(event) => setAudioVolume(Number(event.target.value))}
|
||||
/>
|
||||
<span>{Math.round(audioVolume * 100)}%</span>
|
||||
</label>
|
||||
<label>
|
||||
Speed
|
||||
<select
|
||||
value={audioSpeed}
|
||||
onChange={(event) => setAudioSpeed(Number(event.target.value))}
|
||||
>
|
||||
<option value="0.75">0.75x</option>
|
||||
<option value="1">1x</option>
|
||||
<option value="1.25">1.25x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="audio-status">
|
||||
<strong>{audioPlayingSegmentId ? 'Playing' : 'Status'}</strong>
|
||||
<span>{audioStatus}</span>
|
||||
</p>
|
||||
<p className="map-attribution">{audioContext.attribution}</p>
|
||||
<audio ref={audioRef} preload="none" onEnded={handleAudioEnded} />
|
||||
</>
|
||||
) : (
|
||||
<p className="resource-disabled">
|
||||
{audioResourcesLoading
|
||||
? 'Loading audio metadata...'
|
||||
: 'No audio package is attached to this translation yet.'}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function mapBounds(places: MapContext['pagePlaces']) {
|
||||
const points = places.filter((place) => place.lon != null && place.lat != null)
|
||||
if (points.length === 0) {
|
||||
return { minLon: 0, maxLon: 1, minLat: 0, maxLat: 1 }
|
||||
}
|
||||
|
||||
const minLon = Math.min(...points.map((place) => place.lon ?? 0))
|
||||
const maxLon = Math.max(...points.map((place) => place.lon ?? 0))
|
||||
const minLat = Math.min(...points.map((place) => place.lat ?? 0))
|
||||
const maxLat = Math.max(...points.map((place) => place.lat ?? 0))
|
||||
const lonPad = Math.max(0.2, (maxLon - minLon) * 0.18)
|
||||
const latPad = Math.max(0.2, (maxLat - minLat) * 0.18)
|
||||
|
||||
return {
|
||||
minLon: minLon - lonPad,
|
||||
maxLon: maxLon + lonPad,
|
||||
minLat: minLat - latPad,
|
||||
maxLat: maxLat + latPad,
|
||||
}
|
||||
}
|
||||
|
||||
function mapPoint(place: MapContext['pagePlaces'][number], bounds: ReturnType<typeof mapBounds>) {
|
||||
const lonRange = bounds.maxLon - bounds.minLon || 1
|
||||
const latRange = bounds.maxLat - bounds.minLat || 1
|
||||
return {
|
||||
x: Math.max(5, Math.min(95, (((place.lon ?? bounds.minLon) - bounds.minLon) / lonRange) * 100)),
|
||||
y: Math.max(5, Math.min(95, 100 - (((place.lat ?? bounds.minLat) - bounds.minLat) / latRange) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
|
||||
if (!selectedVerse) return null
|
||||
return (
|
||||
@@ -1476,6 +1825,8 @@ function App() {
|
||||
)}
|
||||
<div className="margin-resource-grid">
|
||||
{renderStudyNotesSection()}
|
||||
{renderAudioSection()}
|
||||
{renderMapSection()}
|
||||
{renderCommentarySection()}
|
||||
{renderDictionarySection()}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user