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