Add dotjuice.thesinging.bible Omarchy shell plugin
Bar widget + popup player streaming Bible-singing audio from thesinging.bible via mpv, with the site's own Book/Chapter/Style/ Voice/Language selectors and full transport controls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYvjTEDeh9T6NqBDdCALMq
This commit is contained in:
245
Model.js
Normal file
245
Model.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// Pure JS logic for the thesinging.bible player. No QML in here so it can be
|
||||
// reasoned about (and, if ever needed, unit tested) independently of the
|
||||
// widget wiring in BarWidget.qml.
|
||||
//
|
||||
// Data model, mirrored from https://thesinging.bible/manifest.json:
|
||||
// translations[], voices[], ages[], styles[] -- each {id, name, enabled}
|
||||
// books[] -- {num, slug, name, section, chapters}
|
||||
// availability["t/v/a/s"]["NN-slug"] -> [chapter numbers available]
|
||||
//
|
||||
// Only ~32 of the possible translation x voice x age x style combos are
|
||||
// actually populated (source: manifest fetched 2026-09-15). The UI lets a
|
||||
// listener pick any style/voice/language regardless, so every selection
|
||||
// change is routed through resolveSelection() below to land on a real combo
|
||||
// (falling back to child voice-age, then to the first available book/chapter
|
||||
// for that style, then to "nothing available") rather than assuming the
|
||||
// picked combo has audio.
|
||||
|
||||
function bookKey(book) {
|
||||
return String(book.num).padStart(2, "0") + "-" + book.slug
|
||||
}
|
||||
|
||||
function findBook(manifest, key) {
|
||||
if (!manifest || !manifest.books) return null
|
||||
for (var i = 0; i < manifest.books.length; i++) {
|
||||
if (bookKey(manifest.books[i]) === key) return manifest.books[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function comboKey(t, v, a, s) {
|
||||
return t + "/" + v + "/" + a + "/" + s
|
||||
}
|
||||
|
||||
function chaptersFor(manifest, t, v, a, s, bookKeyStr) {
|
||||
var combo = manifest && manifest.availability ? manifest.availability[comboKey(t, v, a, s)] : null
|
||||
if (!combo) return []
|
||||
var chapters = combo[bookKeyStr]
|
||||
return chapters ? chapters : []
|
||||
}
|
||||
|
||||
function parseManifest(raw) {
|
||||
try {
|
||||
var data = JSON.parse(String(raw || ""))
|
||||
if (!data || !data.translations || !data.voices || !data.styles || !data.books || !data.availability) return null
|
||||
return data
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function enabledFirst(list) {
|
||||
if (!list) return null
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].enabled !== false) return list[i]
|
||||
}
|
||||
return list.length ? list[0] : null
|
||||
}
|
||||
|
||||
function defaultDesired(manifest) {
|
||||
var t = enabledFirst(manifest.translations)
|
||||
var v = enabledFirst(manifest.voices)
|
||||
var s = enabledFirst(manifest.styles)
|
||||
return {
|
||||
translation: t ? t.id : "",
|
||||
voice: v ? v.id : "",
|
||||
style: s ? s.id : "",
|
||||
book: manifest.books && manifest.books.length ? bookKey(manifest.books[0]) : "01-genesis",
|
||||
chapter: 1
|
||||
}
|
||||
}
|
||||
|
||||
// Age isn't a user-facing control (mirrors the real site): try adult first,
|
||||
// fall back to child, for the exact book+chapter requested.
|
||||
function resolveAge(manifest, t, v, s, bookKeyStr, chapter) {
|
||||
if (chaptersFor(manifest, t, v, "adult", s, bookKeyStr).indexOf(chapter) !== -1) return "adult"
|
||||
if (chaptersFor(manifest, t, v, "child", s, bookKeyStr).indexOf(chapter) !== -1) return "child"
|
||||
return null
|
||||
}
|
||||
|
||||
// First book (in manifest order) with any content at all for this
|
||||
// translation/voice/style, trying adult before child. Used when the exact
|
||||
// requested book has nothing for the new combo.
|
||||
function firstAvailableBookAndChapter(manifest, t, v, s) {
|
||||
var ages = ["adult", "child"]
|
||||
for (var a = 0; a < ages.length; a++) {
|
||||
var combo = manifest.availability[comboKey(t, v, ages[a], s)]
|
||||
if (!combo) continue
|
||||
for (var i = 0; i < manifest.books.length; i++) {
|
||||
var key = bookKey(manifest.books[i])
|
||||
var chapters = combo[key]
|
||||
if (chapters && chapters.length) return { age: ages[a], book: key, chapter: chapters[0] }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Takes a desired {translation, voice, style, book, chapter} and returns a
|
||||
// fully valid {translation, voice, style, age, book, chapter}, or null if
|
||||
// this translation/voice/style has no audio at all. Never throws.
|
||||
function resolveSelection(manifest, desired) {
|
||||
if (!manifest || !desired) return null
|
||||
var t = desired.translation, v = desired.voice, s = desired.style
|
||||
var book = desired.book, chapter = desired.chapter
|
||||
|
||||
var age = resolveAge(manifest, t, v, s, book, chapter)
|
||||
if (age) return { translation: t, voice: v, style: s, age: age, book: book, chapter: chapter }
|
||||
|
||||
var ages = ["adult", "child"]
|
||||
for (var a = 0; a < ages.length; a++) {
|
||||
var combo = manifest.availability[comboKey(t, v, ages[a], s)]
|
||||
if (combo && combo[book] && combo[book].length) {
|
||||
return { translation: t, voice: v, style: s, age: ages[a], book: book, chapter: combo[book][0] }
|
||||
}
|
||||
}
|
||||
|
||||
var fb = firstAvailableBookAndChapter(manifest, t, v, s)
|
||||
if (fb) return { translation: t, voice: v, style: s, age: fb.age, book: fb.book, chapter: fb.chapter }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function audioUrl(baseUrl, sel) {
|
||||
return baseUrl.replace(/\/$/, "") + "/" + sel.translation + "/" + sel.voice + "/" + sel.age + "/" + sel.style +
|
||||
"/" + sel.book + "/" + String(sel.chapter).padStart(3, "0") + ".mp3"
|
||||
}
|
||||
|
||||
function trackLabel(manifest, sel) {
|
||||
var book = findBook(manifest, sel.book)
|
||||
return (book ? book.name : sel.book) + " " + sel.chapter
|
||||
}
|
||||
|
||||
// direction: +1 (next) or -1 (prev). Walks chapters within the current book
|
||||
// first, then wraps to the next/previous book (in manifest order) that has
|
||||
// any content for the current combo.
|
||||
function neighborChapter(manifest, sel, direction) {
|
||||
var combo = manifest.availability[comboKey(sel.translation, sel.voice, sel.age, sel.style)]
|
||||
var chapters = combo && combo[sel.book] ? combo[sel.book] : []
|
||||
var idx = chapters.indexOf(sel.chapter)
|
||||
if (idx === -1) idx = 0
|
||||
var nextIdx = idx + direction
|
||||
|
||||
if (nextIdx >= 0 && nextIdx < chapters.length) {
|
||||
return { translation: sel.translation, voice: sel.voice, style: sel.style, age: sel.age, book: sel.book, chapter: chapters[nextIdx] }
|
||||
}
|
||||
|
||||
var bIdx = -1
|
||||
for (var i = 0; i < manifest.books.length; i++) {
|
||||
if (bookKey(manifest.books[i]) === sel.book) { bIdx = i; break }
|
||||
}
|
||||
if (bIdx === -1) return sel
|
||||
|
||||
var n = manifest.books.length
|
||||
for (var step = 1; step <= n; step++) {
|
||||
var candidateIdx = ((bIdx + direction * step) % n + n) % n
|
||||
var candidateKey = bookKey(manifest.books[candidateIdx])
|
||||
var resolved = resolveSelection(manifest, {
|
||||
translation: sel.translation, voice: sel.voice, style: sel.style,
|
||||
book: candidateKey, chapter: direction > 0 ? 1 : 1
|
||||
})
|
||||
if (resolved && resolved.book === candidateKey) {
|
||||
if (direction < 0) {
|
||||
var combo2 = manifest.availability[comboKey(resolved.translation, resolved.voice, resolved.age, resolved.style)]
|
||||
var chs = combo2 && combo2[resolved.book] ? combo2[resolved.book] : []
|
||||
if (chs.length) resolved.chapter = chs[chs.length - 1]
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
function randomSelection(manifest, sel) {
|
||||
var candidates = []
|
||||
var ages = ["adult", "child"]
|
||||
for (var a = 0; a < ages.length; a++) {
|
||||
var combo = manifest.availability[comboKey(sel.translation, sel.voice, ages[a], sel.style)]
|
||||
if (!combo) continue
|
||||
for (var key in combo) {
|
||||
var chapters = combo[key]
|
||||
for (var i = 0; i < chapters.length; i++) candidates.push({ age: ages[a], book: key, chapter: chapters[i] })
|
||||
}
|
||||
}
|
||||
if (!candidates.length) return sel
|
||||
var pick = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
return { translation: sel.translation, voice: sel.voice, style: sel.style, age: pick.age, book: pick.book, chapter: pick.chapter }
|
||||
}
|
||||
|
||||
function toOptions(list) {
|
||||
var out = []
|
||||
if (!list) return out
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].enabled === false) continue
|
||||
out.push({ value: list[i].id, label: list[i].name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function optionsForBooks(manifest) {
|
||||
var out = []
|
||||
for (var i = 0; i < manifest.books.length; i++) {
|
||||
out.push({ value: bookKey(manifest.books[i]), label: manifest.books[i].name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function optionsForStyles(manifest) { return toOptions(manifest.styles) }
|
||||
function optionsForVoices(manifest) { return toOptions(manifest.voices) }
|
||||
function optionsForTranslations(manifest) { return toOptions(manifest.translations) }
|
||||
|
||||
function optionsForChapters(manifest, sel) {
|
||||
var chapters = chaptersFor(manifest, sel.translation, sel.voice, sel.age, sel.style, sel.book)
|
||||
var out = []
|
||||
for (var i = 0; i < chapters.length; i++) out.push({ value: String(chapters[i]), label: String(chapters[i]) })
|
||||
return out
|
||||
}
|
||||
|
||||
function parseStateFile(raw) {
|
||||
try {
|
||||
var data = JSON.parse(String(raw || ""))
|
||||
if (!data || typeof data !== "object") return null
|
||||
if (!data.translation || !data.voice || !data.style || !data.book || !data.chapter) return null
|
||||
return {
|
||||
translation: String(data.translation),
|
||||
voice: String(data.voice),
|
||||
style: String(data.style),
|
||||
book: String(data.book),
|
||||
chapter: parseInt(data.chapter, 10) || 1
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeState(sel) {
|
||||
return JSON.stringify({
|
||||
translation: sel.translation, voice: sel.voice, style: sel.style, book: sel.book, chapter: sel.chapter
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
var s = Math.max(0, Math.floor(sec || 0))
|
||||
var m = Math.floor(s / 60)
|
||||
var r = s % 60
|
||||
return m + ":" + (r < 10 ? "0" + r : String(r))
|
||||
}
|
||||
Reference in New Issue
Block a user