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:
2026-09-15 11:25:42 +01:00
commit 8d63cb561f
3 changed files with 740 additions and 0 deletions

245
Model.js Normal file
View 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))
}