diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bb0a8..228c6b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.1.29 - 2026-07-15 + +- Changed the Vite dev server to bind to `0.0.0.0` and allow the `desktop` MagicDNS host so the hot view can be reached from Pinky at `http://desktop:5174/`. + +## 0.1.28 - 2026-07-12 + +- Added a KJV Audio Bible card to the study margin with selected-verse, full-chapter, and from-selected playback. +- Added browser-side ZIP extraction for the LibreBible KJV audio package so MP3s can play from upstream book ZIPs without vendoring the audio files. +- Added audio volume, speed, stop controls, loading/playback status, and CC BY 4.0 attribution display. + +## 0.1.27 - 2026-07-12 + +- Added browser/local preview consumption for LibreBible contextual map packages. +- Added a study-margin context map panel that shows mapped places for the visible page and highlights selected-verse places. +- Added related UBS Bible route suggestions based on visible place context. + ## 0.1.26 - 2026-07-12 - Started Phase 5 storage/search foundation with a reusable verse search index for browser/local preview queries. diff --git a/package-lock.json b/package-lock.json index b2ba22a..f932829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "libre-study", - "version": "0.1.26", + "version": "0.1.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libre-study", - "version": "0.1.26", + "version": "0.1.29", "dependencies": { "@tauri-apps/api": "^2.11.1", + "jszip": "^3.10.1", "react": "^19.2.7", "react-dom": "^19.2.7" }, @@ -213,9 +214,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -233,9 +231,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -253,9 +248,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -273,9 +265,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -293,9 +282,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -313,9 +299,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -333,9 +316,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -353,9 +333,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -526,9 +503,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -546,9 +520,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -566,9 +537,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -586,9 +554,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -606,9 +571,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -626,9 +588,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -814,9 +773,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -834,9 +790,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -854,9 +807,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -874,9 +824,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -894,9 +841,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1024,6 +968,12 @@ } } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1074,6 +1024,45 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1217,9 +1206,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1241,9 +1227,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1265,9 +1248,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1289,9 +1269,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1415,6 +1392,12 @@ } } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1464,6 +1447,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1485,6 +1474,21 @@ "react": "^19.2.7" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1519,12 +1523,24 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1535,6 +1551,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1581,6 +1606,12 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/package.json b/package.json index 2497ff1..404d0f1 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "libre-study", "private": true, - "version": "0.1.26", + "version": "0.1.29", "type": "module", "scripts": { - "dev": "vite --host 127.0.0.1", - "dev:web": "vite --host 127.0.0.1", + "dev": "vite --host 0.0.0.0", + "dev:web": "vite --host 0.0.0.0", "dev:tauri": "tauri dev", "build": "tsc -b && vite build", "build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted", @@ -16,6 +16,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.11.1", + "jszip": "^3.10.1", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/src/App.css b/src/App.css index 8e5cc3c..5c0e4f9 100644 --- a/src/App.css +++ b/src/App.css @@ -741,7 +741,9 @@ textarea:focus-visible { } .commentary-card, -.dictionary-card { +.dictionary-card, +.map-card, +.audio-card { grid-column: 1 / -1; } @@ -787,6 +789,211 @@ textarea:focus-visible { text-transform: uppercase; } +.map-card { + gap: 8px; +} + +.context-map { + position: relative; + min-height: 190px; + overflow: hidden; + border: 1px solid #d9cfbd; + border-radius: 6px; + background: + linear-gradient(135deg, rgba(47, 111, 94, 0.12), transparent 34%), + linear-gradient(0deg, rgba(214, 168, 79, 0.18), transparent 42%), + #eef1e6; +} + +.map-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(68, 76, 65, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(68, 76, 65, 0.12) 1px, transparent 1px); + background-size: 25% 25%; +} + +.map-marker { + position: absolute; + z-index: 2; + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 2px solid #fffef8; + border-radius: 50%; + background: #2f6f5e; + color: #fffef8; + font-size: 11px; + font-weight: 900; + line-height: 1; + transform: translate(-50%, -50%); + box-shadow: 0 2px 7px rgba(22, 26, 20, 0.26); +} + +.map-marker.selected { + width: 30px; + height: 30px; + background: #d6a84f; + color: #141510; +} + +.place-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.place-list section { + display: grid; + gap: 2px; + min-width: 0; + padding: 7px; + border: 1px solid #e4dccd; + border-radius: 5px; + background: #fbf8ef; +} + +.place-list h3 { + margin: 0; + overflow: hidden; + color: #252821; + font-size: 12px; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.place-list p { + display: grid; + gap: 2px; + margin: 0; + color: #657061; + font-size: 11px; + line-height: 1.25; +} + +.place-list small { + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.route-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; + color: #657061; + font-size: 11px; + font-weight: 800; +} + +.route-list span { + display: inline-flex; + gap: 5px; + align-items: center; + max-width: 100%; + padding: 4px 6px; + border: 1px solid #d8cfbf; + border-radius: 999px; + background: #fbf8ef; + color: #252821; + font-size: 11px; + overflow-wrap: anywhere; +} + +.route-list small { + color: #657061; + font-size: 10px; +} + +.map-attribution { + margin: 0; + color: #657061; + font-size: 11px; + line-height: 1.35; +} + +.audio-card { + gap: 8px; +} + +.audio-controls { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.audio-controls button { + min-width: 0; + min-height: 30px; + padding: 5px 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.audio-controls .secondary-button { + border-color: #d8cfbf; + background: #fbf8ef; + color: #252821; +} + +.audio-sliders { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(90px, 0.45fr); + gap: 8px; + align-items: end; +} + +.audio-sliders label { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 6px; + align-items: center; + min-width: 0; + color: #657061; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.audio-sliders select { + grid-column: 2 / 4; +} + +.audio-sliders input[type='range'] { + padding: 0; +} + +.audio-status { + display: flex; + gap: 8px; + align-items: center; + min-width: 0; + margin: 0; + color: #3f443d; + font-size: 12px; + line-height: 1.35; +} + +.audio-status strong { + flex: 0 0 auto; + color: #657061; + font-size: 11px; + text-transform: uppercase; +} + +.audio-status span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .notes-workspace { display: flex; flex-direction: column; @@ -1381,11 +1588,28 @@ textarea:focus-visible { .theme-dark .original-language-verse, .theme-dark .english-strong-line, -.theme-dark .resource-card { +.theme-dark .resource-card, +.theme-dark .place-list section, +.theme-dark .route-list span, +.theme-dark .audio-controls .secondary-button { border-color: #3f493f; background: #20251f; } +.theme-dark .context-map { + border-color: #3f493f; + background: + linear-gradient(135deg, rgba(58, 122, 105, 0.22), transparent 34%), + linear-gradient(0deg, rgba(199, 150, 63, 0.2), transparent 42%), + #1c241e; +} + +.theme-dark .map-grid { + background-image: + linear-gradient(rgba(199, 213, 190, 0.13) 1px, transparent 1px), + linear-gradient(90deg, rgba(199, 213, 190, 0.13) 1px, transparent 1px); +} + .theme-dark .margin-section-header { background: #2b332a; } @@ -1420,6 +1644,14 @@ textarea:focus-visible { .theme-dark .timeline-canvas small, .theme-dark .note-helper small, .theme-dark .note-placeholder small, +.theme-dark .place-list p, +.theme-dark .place-list small, +.theme-dark .route-list, +.theme-dark .route-list small, +.theme-dark .map-attribution, +.theme-dark .audio-sliders label, +.theme-dark .audio-status, +.theme-dark .audio-status strong, .theme-dark .session-facts dt, .theme-dark .about-list dt, .theme-dark .modal-section label, @@ -1431,6 +1663,8 @@ textarea:focus-visible { .theme-dark .strong-focus p, .theme-dark .commentary-list h3, .theme-dark .dictionary-list h3, +.theme-dark .place-list h3, +.theme-dark .route-list span, .theme-dark .commentary-list p, .theme-dark .dictionary-list p, .theme-dark .linked-line-text, diff --git a/src/App.tsx b/src/App.tsx index 1c9efde..beb96e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]) const [dictionaryEntries, setDictionaryEntries] = useState([]) + const [mapContext, setMapContext] = useState({ + selectedPlaces: [], + pagePlaces: [], + relatedRoutes: [], + }) + const [audioContext, setAudioContext] = useState(null) + const [audioResourcesLoading, setAudioResourcesLoading] = useState(false) + const [audioStatus, setAudioStatus] = useState('Ready') + const [audioPlayingSegmentId, setAudioPlayingSegmentId] = useState(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(null) const restoredSessionId = useRef('') @@ -229,6 +252,9 @@ function App() { const notesDragOpenTimeout = useRef(null) const remoteWorkspaceSaveTimeout = useRef(null) const readerRef = useRef(null) + const audioRef = useRef(null) + const audioQueueRef = useRef([]) + const audioQueueIndexRef = useRef(0) const hydratedNoteChapters = useRef(new Set()) 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 ( +
+
+ + + {places.length + ? `${places.length} places` + : mapResourcesLoading + ? 'Loading' + : 'None'} + +
+ {places.length ? ( + <> +
+ +
+ {places.slice(0, 8).map((place) => ( +
+

{place.placeName}

+

+ {place.modernLocationName || 'Unresolved location'} + {place.confidenceLabel} confidence +

+
+ ))} +
+ {routes.length ? ( +
+ Related routes + {routes.map((route) => ( + + {route.title} + {route.mapNumber} + + ))} +
+ ) : null} +

+ Places: OpenBible.info. Routes: UBS Project MARBLE. Coordinates are contextual and may be approximate. +

+ + ) : ( +

+ {mapResourcesLoading ? 'Loading contextual map data...' : 'No mapped places matched this page.'} +

+ )} +
+ ) + } + + 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 ( +
+
+ + + {audioResourcesLoading + ? 'Loading' + : audioContext + ? audioContext.shortTitle + : 'None'} + +
+ {audioContext && selectedSegment ? ( + <> +
+ + + + +
+
+ + +
+

+ {audioPlayingSegmentId ? 'Playing' : 'Status'} + {audioStatus} +

+

{audioContext.attribution}

+
+ ) + } + + 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) { + 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() { )}
{renderStudyNotesSection()} + {renderAudioSection()} + {renderMapSection()} {renderCommentarySection()} {renderDictionarySection()}
diff --git a/src/studyClient.ts b/src/studyClient.ts index 032486b..fab3a5a 100644 --- a/src/studyClient.ts +++ b/src/studyClient.ts @@ -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>>() let browserCommentaryCache: Promise> | null = null let browserDictionaryCache: Promise> | null = null +let browserVersePlacesCache: Promise> | null = null +let browserRoutesCache: Promise | null = null +let browserAudioPlaylistCache: Promise> | null = null const browserVerseSourceIds = new Map() +const browserAudioZipCache = new Map>() +const browserAudioObjectUrlCache = new Map>() const fallbackBrowserVerses: Verse[] = [ { @@ -416,6 +472,100 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise { + 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 { + 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 { + 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 { browserCatalogCache ??= fetchJson(`${libreBibleDevRoot}/catalog.json`) return browserCatalogCache @@ -499,6 +649,155 @@ async function loadBrowserDictionary(): Promise> { return browserDictionaryCache } +async function loadBrowserVersePlaces(): Promise> { + browserVersePlacesCache ??= loadLibreBibleDevVersePlaces() + return browserVersePlacesCache +} + +async function loadLibreBibleDevVersePlaces(): Promise> { + const importedEntries = await fetchJsonl( + `${libreBibleDevRoot}/biblical-places-openbible/verse-places.jsonl`, + ) + const places = new Map() + + 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 { + browserRoutesCache ??= loadLibreBibleDevRoutes() + return browserRoutesCache +} + +async function loadBrowserAudioPlaylists(): Promise> { + browserAudioPlaylistCache ??= loadLibreBibleDevAudioPlaylists() + return browserAudioPlaylistCache +} + +async function loadLibreBibleDevAudioPlaylists(): Promise> { + const importedEntries = await fetchJsonl( + `${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 { + 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 { + const importedEntries = await fetchJsonl( + `${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 { + 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() + 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> { const importedEntries = await fetchJsonl( `${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`, diff --git a/src/types.ts b/src/types.ts index 9e15dcf..66484c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,76 @@ export type DictionaryEntry = { sources: string[] } +export type BiblePlace = { + id: string + verseId: string + readable: string + placeId: string + placeName: string + placeTypes: string[] + modernLocationId: string + modernLocationName: string + lon: number | null + lat: number | null + confidence: number | null + confidenceLabel: string +} + +export type BibleRoute = { + id: string + routeNumber: number + title: string + sectionNumber: string + sectionTitle: string + mapNumber: string + geojsonFile: string + bounds: { + minLon: number + minLat: number + maxLon: number + maxLat: number + } | null +} + +export type MapContext = { + selectedPlaces: BiblePlace[] + pagePlaces: BiblePlace[] + relatedRoutes: BibleRoute[] +} + +export type AudioBibleSegment = { + id: string + resourceId: string + translationId: string + verseId: string + reference: string + audioZipUrl: string + audioZipFile: string + audioEntryPath: string + attribution: string + licenseName: string +} + +export type AudioChapterPlaylist = { + id: string + resourceId: string + translationId: string + book: string + chapter: number + reference: string + segments: AudioBibleSegment[] +} + +export type AudioBibleContext = { + resourceId: string + title: string + shortTitle: string + licenseName: string + attribution: string + selectedSegment: AudioBibleSegment | null + chapterPlaylist: AudioChapterPlaylist | null +} + export type LibraryState = { verses: Verse[] tags: Tag[] diff --git a/vite.config.ts b/vite.config.ts index 840a3e8..a7a1233 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { + allowedHosts: ['desktop'], fs: { allow: ['..', 'W:/libre-bible-data'], },