- Alabanza style removed: it only has Spanish recordings, so it always played nothing under the (now-removed) English default. - Language dropdown removed entirely; translation is hard-coded to English (sbe) in Model.js, overriding any legacy saved state too. - Dropdown popups were rendering past the popup window's bottom edge and getting clipped, since the window is sized only to the collapsed rows. Reserve room for the open popup, but only while a dropdown is actually expanded, so the panel doesn't carry permanent dead space. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYvjTEDeh9T6NqBDdCALMq
253 lines
9.2 KiB
JavaScript
253 lines
9.2 KiB
JavaScript
// 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
|
|
}
|
|
|
|
// Language is hard-coded to English (sbe) — no Language dropdown, no
|
|
// per-user Spanish selection.
|
|
var FIXED_TRANSLATION = "sbe"
|
|
|
|
function defaultDesired(manifest) {
|
|
var v = enabledFirst(manifest.voices)
|
|
var s = enabledFirst(manifest.styles)
|
|
return {
|
|
translation: FIXED_TRANSLATION,
|
|
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
|
|
}
|
|
|
|
// Alabanza only has Spanish recordings (no sbe/*/alabanza in availability),
|
|
// so it's excluded here to avoid a style that silently plays nothing.
|
|
function optionsForStyles(manifest) {
|
|
return toOptions(manifest.styles).filter(function(o) { return o.value !== "alabanza" })
|
|
}
|
|
function optionsForVoices(manifest) { return toOptions(manifest.voices) }
|
|
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.voice || !data.style || !data.book || !data.chapter) return null
|
|
return {
|
|
// Forced regardless of what an older state.json saved (from before
|
|
// Language was hard-coded to English).
|
|
translation: FIXED_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))
|
|
}
|