Add map and KJV audio playback UI
This commit is contained in:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user