Add map and KJV audio playback UI
This commit is contained in:
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.26 - 2026-07-12
|
||||||
|
|
||||||
- Started Phase 5 storage/search foundation with a reusable verse search index for browser/local preview queries.
|
- Started Phase 5 storage/search foundation with a reusable verse search index for browser/local preview queries.
|
||||||
|
|||||||
Generated
+102
-71
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.26",
|
"version": "0.1.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.26",
|
"version": "0.1.29",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
@@ -213,9 +214,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -233,9 +231,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -253,9 +248,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -273,9 +265,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -293,9 +282,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -313,9 +299,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -333,9 +316,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -353,9 +333,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -526,9 +503,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -546,9 +520,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -566,9 +537,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -586,9 +554,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -606,9 +571,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -626,9 +588,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -814,9 +773,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -834,9 +790,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -854,9 +807,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -874,9 +824,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -894,9 +841,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"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": "^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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1217,9 +1206,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1241,9 +1227,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1265,9 +1248,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1289,9 +1269,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1464,6 +1447,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
@@ -1485,6 +1474,21 @@
|
|||||||
"react": "^19.2.7"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||||
@@ -1519,12 +1523,24 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
"@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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1535,6 +1551,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -1581,6 +1606,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.4",
|
"version": "8.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||||
|
|||||||
+4
-3
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.26",
|
"version": "0.1.29",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"dev:web": "vite --host 127.0.0.1",
|
"dev:web": "vite --host 0.0.0.0",
|
||||||
"dev:tauri": "tauri dev",
|
"dev:tauri": "tauri dev",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted",
|
"build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
|
|||||||
+236
-2
@@ -741,7 +741,9 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentary-card,
|
.commentary-card,
|
||||||
.dictionary-card {
|
.dictionary-card,
|
||||||
|
.map-card,
|
||||||
|
.audio-card {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,6 +789,211 @@ textarea:focus-visible {
|
|||||||
text-transform: uppercase;
|
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 {
|
.notes-workspace {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1381,11 +1588,28 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.theme-dark .original-language-verse,
|
.theme-dark .original-language-verse,
|
||||||
.theme-dark .english-strong-line,
|
.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;
|
border-color: #3f493f;
|
||||||
background: #20251f;
|
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 {
|
.theme-dark .margin-section-header {
|
||||||
background: #2b332a;
|
background: #2b332a;
|
||||||
}
|
}
|
||||||
@@ -1420,6 +1644,14 @@ textarea:focus-visible {
|
|||||||
.theme-dark .timeline-canvas small,
|
.theme-dark .timeline-canvas small,
|
||||||
.theme-dark .note-helper small,
|
.theme-dark .note-helper small,
|
||||||
.theme-dark .note-placeholder 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 .session-facts dt,
|
||||||
.theme-dark .about-list dt,
|
.theme-dark .about-list dt,
|
||||||
.theme-dark .modal-section label,
|
.theme-dark .modal-section label,
|
||||||
@@ -1431,6 +1663,8 @@ textarea:focus-visible {
|
|||||||
.theme-dark .strong-focus p,
|
.theme-dark .strong-focus p,
|
||||||
.theme-dark .commentary-list h3,
|
.theme-dark .commentary-list h3,
|
||||||
.theme-dark .dictionary-list h3,
|
.theme-dark .dictionary-list h3,
|
||||||
|
.theme-dark .place-list h3,
|
||||||
|
.theme-dark .route-list span,
|
||||||
.theme-dark .commentary-list p,
|
.theme-dark .commentary-list p,
|
||||||
.theme-dark .dictionary-list p,
|
.theme-dark .dictionary-list p,
|
||||||
.theme-dark .linked-line-text,
|
.theme-dark .linked-line-text,
|
||||||
|
|||||||
+352
-1
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
addTagToVerse,
|
addTagToVerse,
|
||||||
getCommentaryForVerse,
|
getCommentaryForVerse,
|
||||||
|
getAudioContextForVerse,
|
||||||
|
getAudioObjectUrl,
|
||||||
getDictionaryEntriesForTerms,
|
getDictionaryEntriesForTerms,
|
||||||
getLibraryState,
|
getLibraryState,
|
||||||
|
getMapContextForVerses,
|
||||||
getTranslationNotesForVerses,
|
getTranslationNotesForVerses,
|
||||||
getTranslations,
|
getTranslations,
|
||||||
searchVerses,
|
searchVerses,
|
||||||
@@ -18,7 +21,15 @@ import {
|
|||||||
timelineLimit,
|
timelineLimit,
|
||||||
} from './workspace'
|
} from './workspace'
|
||||||
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
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 {
|
import type {
|
||||||
StrongDisplayMode,
|
StrongDisplayMode,
|
||||||
StudySession,
|
StudySession,
|
||||||
@@ -221,7 +232,19 @@ function App() {
|
|||||||
const [splitPercent, setSplitPercent] = useState(50)
|
const [splitPercent, setSplitPercent] = useState(50)
|
||||||
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
|
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
|
||||||
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
|
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 [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
|
||||||
|
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const restoredSessionId = useRef('')
|
const restoredSessionId = useRef('')
|
||||||
@@ -229,6 +252,9 @@ function App() {
|
|||||||
const notesDragOpenTimeout = useRef<number | null>(null)
|
const notesDragOpenTimeout = useRef<number | null>(null)
|
||||||
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||||
const readerRef = useRef<HTMLElement | 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 hydratedNoteChapters = useRef(new Set<string>())
|
||||||
const workspaceHydrated = useRef(false)
|
const workspaceHydrated = useRef(false)
|
||||||
|
|
||||||
@@ -603,6 +629,70 @@ function App() {
|
|||||||
.filter((verse) => verse.book === book)
|
.filter((verse) => verse.book === book)
|
||||||
}, [libraryVerses, pageStartId, selectedVerse, verses])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!activeSession?.baseTranslation || pageVerses.length === 0) return
|
if (!activeSession?.baseTranslation || pageVerses.length === 0) return
|
||||||
const chapterKeys = [
|
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) {
|
function renderVerseText(verse: Verse) {
|
||||||
if (!activeSession?.strongsEnabled || !verse.strongs.length || strongDisplay === 'off') return verse.text
|
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') {
|
function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
|
||||||
if (!selectedVerse) return null
|
if (!selectedVerse) return null
|
||||||
return (
|
return (
|
||||||
@@ -1476,6 +1825,8 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
<div className="margin-resource-grid">
|
<div className="margin-resource-grid">
|
||||||
{renderStudyNotesSection()}
|
{renderStudyNotesSection()}
|
||||||
|
{renderAudioSection()}
|
||||||
|
{renderMapSection()}
|
||||||
{renderCommentarySection()}
|
{renderCommentarySection()}
|
||||||
{renderDictionarySection()}
|
{renderDictionarySection()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import JSZip from 'jszip'
|
||||||
import { buildVerseSearchIndex } from './searchIndex'
|
import { buildVerseSearchIndex } from './searchIndex'
|
||||||
import type {
|
import type {
|
||||||
|
AudioBibleContext,
|
||||||
|
AudioBibleSegment,
|
||||||
CommentaryEntry,
|
CommentaryEntry,
|
||||||
|
BiblePlace,
|
||||||
|
BibleRoute,
|
||||||
DictionaryEntry,
|
DictionaryEntry,
|
||||||
Footnote,
|
Footnote,
|
||||||
LibraryState,
|
LibraryState,
|
||||||
|
MapContext,
|
||||||
StrongLink,
|
StrongLink,
|
||||||
Tag,
|
Tag,
|
||||||
TranslationResource,
|
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 = {
|
type ImportedStrongEntry = {
|
||||||
strongs_number: string
|
strongs_number: string
|
||||||
language: 'hebrew' | 'greek'
|
language: 'hebrew' | 'greek'
|
||||||
@@ -102,6 +123,36 @@ type ImportedDictionaryEntry = {
|
|||||||
sources: string[]
|
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 = {
|
type BrowserLexiconEntry = {
|
||||||
lemma: string
|
lemma: string
|
||||||
transliteration: 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>>>()
|
const browserSearchIndexCache = new Map<string, Promise<ReturnType<typeof buildVerseSearchIndex>>>()
|
||||||
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
|
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
|
||||||
let browserDictionaryCache: Promise<Map<string, DictionaryEntry>> | 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 browserVerseSourceIds = new Map<number, string>()
|
||||||
|
const browserAudioZipCache = new Map<string, Promise<JSZip>>()
|
||||||
|
const browserAudioObjectUrlCache = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
const fallbackBrowserVerses: Verse[] = [
|
const fallbackBrowserVerses: Verse[] = [
|
||||||
{
|
{
|
||||||
@@ -416,6 +472,100 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise<Dic
|
|||||||
return entries
|
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> {
|
async function loadBrowserCatalog(): Promise<ImportedCatalog> {
|
||||||
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
|
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
|
||||||
return browserCatalogCache
|
return browserCatalogCache
|
||||||
@@ -499,6 +649,155 @@ async function loadBrowserDictionary(): Promise<Map<string, DictionaryEntry>> {
|
|||||||
return browserDictionaryCache
|
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>> {
|
async function loadLibreBibleDevDictionary(): Promise<Map<string, DictionaryEntry>> {
|
||||||
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
|
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
|
||||||
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,
|
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,
|
||||||
|
|||||||
@@ -73,6 +73,76 @@ export type DictionaryEntry = {
|
|||||||
sources: string[]
|
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 = {
|
export type LibraryState = {
|
||||||
verses: Verse[]
|
verses: Verse[]
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['desktop'],
|
||||||
fs: {
|
fs: {
|
||||||
allow: ['..', 'W:/libre-bible-data'],
|
allow: ['..', 'W:/libre-bible-data'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user