Add map and KJV audio playback UI

This commit is contained in:
2026-07-15 11:54:52 -05:00
parent a1a902c938
commit 02ffa1dfc4
8 changed files with 1080 additions and 77 deletions
+299
View File
@@ -1,10 +1,16 @@
import { invoke } from '@tauri-apps/api/core'
import JSZip from 'jszip'
import { buildVerseSearchIndex } from './searchIndex'
import type {
AudioBibleContext,
AudioBibleSegment,
CommentaryEntry,
BiblePlace,
BibleRoute,
DictionaryEntry,
Footnote,
LibraryState,
MapContext,
StrongLink,
Tag,
TranslationResource,
@@ -62,6 +68,21 @@ type ImportedCatalog = {
}>
}
type ImportedAudioChapterPlaylist = {
id: string
resource_id: string
translation_id: string
book_id: string
book: string
chapter: number
reference: string
audio_zip_file: string
audio_zip_url: string
segment_ids: string[]
audio_entry_paths: string[]
segment_count: number
}
type ImportedStrongEntry = {
strongs_number: string
language: 'hebrew' | 'greek'
@@ -102,6 +123,36 @@ type ImportedDictionaryEntry = {
sources: string[]
}
type ImportedVersePlace = {
verse_id: string
readable: string
place_id: string
place_name: string
place_types: string[]
modern_location_id: string
modern_location_name: string
lon: number | null
lat: number | null
confidence: number | null
confidence_label: string
}
type ImportedBibleRoute = {
id: string
route_number: number
title: string
section_number: string
section_title: string
map_number: string
geojson_file: string
bounds: {
min_lon: number
min_lat: number
max_lon: number
max_lat: number
} | null
}
type BrowserLexiconEntry = {
lemma: string
transliteration: string
@@ -123,7 +174,12 @@ const browserChapterStudyNotesCache = new Map<string, Promise<Map<string, Footno
const browserSearchIndexCache = new Map<string, Promise<ReturnType<typeof buildVerseSearchIndex>>>()
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
let browserDictionaryCache: Promise<Map<string, DictionaryEntry>> | null = null
let browserVersePlacesCache: Promise<Map<string, BiblePlace[]>> | null = null
let browserRoutesCache: Promise<BibleRoute[]> | null = null
let browserAudioPlaylistCache: Promise<Map<string, ImportedAudioChapterPlaylist>> | null = null
const browserVerseSourceIds = new Map<number, string>()
const browserAudioZipCache = new Map<string, Promise<JSZip>>()
const browserAudioObjectUrlCache = new Map<string, Promise<string>>()
const fallbackBrowserVerses: Verse[] = [
{
@@ -416,6 +472,100 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise<Dic
return entries
}
export async function getMapContextForVerses(
selectedVerse: Verse,
pageVerses: Verse[],
): Promise<MapContext> {
if (inTauri()) {
return { selectedPlaces: [], pagePlaces: [], relatedRoutes: [] }
}
const versePlaces = await loadBrowserVersePlaces()
const selectedVerseId = browserVerseKey(selectedVerse)
const pageVerseIds = new Set(pageVerses.map((verse) => browserVerseKey(verse)).filter(Boolean))
const selectedPlaces = uniquePlaces(versePlaces.get(selectedVerseId) ?? [])
const pagePlaces = uniquePlaces(
[...pageVerseIds].flatMap((verseId) => versePlaces.get(verseId) ?? []),
)
const relatedRoutes = await relatedRoutesForPlaces(pagePlaces)
return {
selectedPlaces,
pagePlaces,
relatedRoutes,
}
}
export async function getAudioContextForVerse(
translationId: string,
verse: Verse,
): Promise<AudioBibleContext | null> {
if (inTauri()) return null
const catalog = await loadBrowserCatalog()
const resource = catalog.resources.find(
(item) =>
item.resource_type === 'audio_bible' &&
item.id === 'kjv-audio-eliranwong' &&
translationId === defaultTranslationId,
)
if (!resource) return null
const verseId = browserVerseKey(verse)
const [bookId, chapter] = verseId.split('.')
if (!bookId || !chapter) return null
const playlists = await loadBrowserAudioPlaylists()
const playlist = playlists.get(`${bookId}.${chapter}`) ?? null
const selectedIndex = playlist?.segment_ids.findIndex((id) => id.endsWith(`:${verseId}`)) ?? -1
const licenseName = resource.license?.name ?? ''
const attribution = resource.license?.attribution ?? ''
return {
resourceId: resource.id,
title: resource.title,
shortTitle: resource.short_title ?? resource.title,
licenseName,
attribution,
selectedSegment:
playlist && selectedIndex >= 0
? mapAudioSegment(playlist, selectedIndex, licenseName, attribution)
: null,
chapterPlaylist: playlist
? {
id: playlist.id,
resourceId: playlist.resource_id,
translationId: playlist.translation_id,
book: playlist.book,
chapter: playlist.chapter,
reference: playlist.reference,
segments: playlist.segment_ids.map((_, index) =>
mapAudioSegment(playlist, index, licenseName, attribution),
),
}
: null,
}
}
export async function getAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
const cacheKey = `${segment.audioZipUrl}#${segment.audioEntryPath}`
if (!browserAudioObjectUrlCache.has(cacheKey)) {
browserAudioObjectUrlCache.set(
cacheKey,
loadAudioZip(segment.audioZipUrl).then(async (zip) => {
const file = zip.file(segment.audioEntryPath)
if (!file) {
throw new Error(`Audio entry not found in ${segment.audioZipFile}: ${segment.audioEntryPath}`)
}
const blob = await file.async('blob')
return URL.createObjectURL(blob)
}),
)
}
return browserAudioObjectUrlCache.get(cacheKey)!
}
async function loadBrowserCatalog(): Promise<ImportedCatalog> {
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
return browserCatalogCache
@@ -499,6 +649,155 @@ async function loadBrowserDictionary(): Promise<Map<string, DictionaryEntry>> {
return browserDictionaryCache
}
async function loadBrowserVersePlaces(): Promise<Map<string, BiblePlace[]>> {
browserVersePlacesCache ??= loadLibreBibleDevVersePlaces()
return browserVersePlacesCache
}
async function loadLibreBibleDevVersePlaces(): Promise<Map<string, BiblePlace[]>> {
const importedEntries = await fetchJsonl<ImportedVersePlace>(
`${libreBibleDevRoot}/biblical-places-openbible/verse-places.jsonl`,
)
const places = new Map<string, BiblePlace[]>()
for (const entry of importedEntries) {
const mapped: BiblePlace = {
id: `${entry.verse_id}:${entry.place_id}`,
verseId: entry.verse_id,
readable: entry.readable,
placeId: entry.place_id,
placeName: entry.place_name,
placeTypes: entry.place_types,
modernLocationId: entry.modern_location_id,
modernLocationName: entry.modern_location_name,
lon: entry.lon,
lat: entry.lat,
confidence: entry.confidence,
confidenceLabel: entry.confidence_label,
}
const current = places.get(mapped.verseId) ?? []
current.push(mapped)
places.set(mapped.verseId, current)
}
return places
}
async function loadBrowserRoutes(): Promise<BibleRoute[]> {
browserRoutesCache ??= loadLibreBibleDevRoutes()
return browserRoutesCache
}
async function loadBrowserAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
browserAudioPlaylistCache ??= loadLibreBibleDevAudioPlaylists()
return browserAudioPlaylistCache
}
async function loadLibreBibleDevAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
const importedEntries = await fetchJsonl<ImportedAudioChapterPlaylist>(
`${libreBibleDevRoot}/kjv-audio-eliranwong/chapter-playlists.jsonl`,
)
return new Map(importedEntries.map((entry) => [`${entry.book_id}.${entry.chapter}`, entry]))
}
function mapAudioSegment(
playlist: ImportedAudioChapterPlaylist,
index: number,
licenseName: string,
attribution: string,
): AudioBibleSegment {
const id = playlist.segment_ids[index]
const verseId = id?.split(':').at(-1) ?? ''
const verseNumber = Number(verseId.split('.').at(-1) ?? index + 1)
return {
id,
resourceId: playlist.resource_id,
translationId: playlist.translation_id,
verseId,
reference: `${playlist.book} ${playlist.chapter}:${verseNumber}`,
audioZipUrl: playlist.audio_zip_url,
audioZipFile: playlist.audio_zip_file,
audioEntryPath: playlist.audio_entry_paths[index],
attribution,
licenseName,
}
}
async function loadAudioZip(url: string): Promise<JSZip> {
if (!browserAudioZipCache.has(url)) {
browserAudioZipCache.set(
url,
fetch(url)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Unable to load audio package: ${response.status} ${response.statusText}`)
}
return response.arrayBuffer()
})
.then((data) => JSZip.loadAsync(data)),
)
}
return browserAudioZipCache.get(url)!
}
async function loadLibreBibleDevRoutes(): Promise<BibleRoute[]> {
const importedEntries = await fetchJsonl<ImportedBibleRoute>(
`${libreBibleDevRoot}/bible-routes-ubs-marble/routes.jsonl`,
)
return importedEntries.map((entry) => ({
id: entry.id,
routeNumber: entry.route_number,
title: entry.title,
sectionNumber: entry.section_number,
sectionTitle: entry.section_title,
mapNumber: entry.map_number,
geojsonFile: entry.geojson_file,
bounds: entry.bounds
? {
minLon: entry.bounds.min_lon,
minLat: entry.bounds.min_lat,
maxLon: entry.bounds.max_lon,
maxLat: entry.bounds.max_lat,
}
: null,
}))
}
async function relatedRoutesForPlaces(places: BiblePlace[]): Promise<BibleRoute[]> {
if (places.length === 0) return []
const routes = await loadBrowserRoutes()
const placeTerms = new Set(
places.flatMap((place) => [place.placeName, place.modernLocationName])
.map(normalizeTerm)
.filter((term) => term.length >= 4),
)
const matches = routes
.map((route) => {
const haystack = normalizeTerm(`${route.title} ${route.sectionTitle}`)
const score = [...placeTerms].filter((term) => haystack.includes(term)).length
return { route, score }
})
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score || left.route.routeNumber - right.route.routeNumber)
return matches.slice(0, 6).map((entry) => entry.route)
}
function uniquePlaces(places: BiblePlace[]) {
const unique = new Map<string, BiblePlace>()
for (const place of places) {
if (place.lon == null || place.lat == null) continue
unique.set(place.placeId, place)
}
return [...unique.values()].sort(
(left, right) =>
(right.confidence ?? 0) - (left.confidence ?? 0) ||
left.placeName.localeCompare(right.placeName),
)
}
async function loadLibreBibleDevDictionary(): Promise<Map<string, DictionaryEntry>> {
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,