Files
Johan 15fae9aaf9 Remove Alabanza/Language, fix dropdown popup clipping
- 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
2026-09-15 11:54:27 +01:00

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))
}