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

477
BarWidget.qml Normal file
View File

@@ -0,0 +1,477 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Bar pill + popup player for thesinging.bible. Mirrors the site's own
// control set (Book, Chapter, Style, Voice, Language) and transport (loop,
// prev, -10s, play/pause, +10s, next, shuffle). Audio comes straight from
// the site's public R2 bucket (https://audio.thesinging.bible/...), no
// auth needed. Playback goes through a single long-lived `mpv --idle` process
// controlled over its JSON IPC unix socket.
BarWidget {
id: root
moduleName: "dotjuice.thesinging.bible"
readonly property string audioBase: "https://audio.thesinging.bible"
readonly property string manifestUrl: "https://thesinging.bible/manifest.json"
readonly property string socketPath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/dotjuice-thesinging-bible.sock"
readonly property string stateDir: Quickshell.env("HOME") + "/.local/state/omarchy/plugins/dotjuice.thesinging.bible"
readonly property string statePath: stateDir + "/state.json"
property var manifest: null
property var selection: null
property var _pendingSavedState: null
property bool unavailable: false
property bool popupOpen: false
property bool mpvReady: false
property bool playing: false
property real positionSec: 0
property real durationSec: 0
property bool loopEnabled: false
readonly property int reqTimePos: 101
readonly property int reqDuration: 102
readonly property string trackLabel: !manifest || !selection ? "—"
: (unavailable ? "No audio available" : Model.trackLabel(manifest, selection))
readonly property string playIcon: playing ? "󰏤" : "󰐊"
readonly property var bookOptions: manifest ? Model.optionsForBooks(manifest) : []
readonly property var styleOptions: manifest ? Model.optionsForStyles(manifest) : []
readonly property var voiceOptions: manifest ? Model.optionsForVoices(manifest) : []
readonly property var translationOptions: manifest ? Model.optionsForTranslations(manifest) : []
readonly property var chapterOptions: (manifest && selection && !unavailable) ? Model.optionsForChapters(manifest, selection) : []
// PopupCard routes owner.close() back here; without this it would set its
// own `open` directly and desync from the popupOpen binding below.
function close() { root.popupOpen = false }
function formatTime(sec) { return Model.formatTime(sec) }
function sendMpv(obj) {
if (!mpvSocket.connected) return
mpvSocket.write(JSON.stringify(obj) + "\n")
mpvSocket.flush()
}
function handleMpvLine(line) {
var msg
try { msg = JSON.parse(line) } catch (e) { return }
if (msg.request_id === root.reqTimePos) {
if (typeof msg.data === "number") root.positionSec = msg.data
return
}
if (msg.request_id === root.reqDuration) {
if (typeof msg.data === "number") root.durationSec = msg.data
return
}
if (msg.event === "end-file") {
if (msg.reason === "eof") root.handleTrackEnded()
return
}
if (msg.event === "pause") { root.playing = false; return }
if (msg.event === "unpause") { root.playing = true; return }
}
function applySavedOrDefaultState() {
if (!root.manifest) return
var desired = root._pendingSavedState || Model.defaultDesired(root.manifest)
var resolved = Model.resolveSelection(root.manifest, desired)
root.selection = resolved || Model.defaultDesired(root.manifest)
root.unavailable = !resolved
root.positionSec = 0
root.durationSec = 0
}
function saveState() {
if (!root.selection) return
stateFile.setText(Model.serializeState(root.selection))
}
function loadCurrentTrack(autoplay) {
if (!root.manifest || !root.selection || root.unavailable) return
var url = Model.audioUrl(root.audioBase, root.selection)
root.positionSec = 0
root.durationSec = 0
root.sendMpv({ command: ["loadfile", url, "replace"] })
root.sendMpv({ command: ["set_property", "pause", !autoplay] })
root.playing = autoplay
root.saveState()
}
// Applies a desired {translation, voice, style, book, chapter} (usually
// the current selection with one field changed by a dropdown), resolving
// it to a real playable combo. Falls back to an "unavailable" state
// (paused, no audio) if this translation/voice/style has nothing at all.
function applyDesired(desired) {
var resolved = Model.resolveSelection(root.manifest, desired)
if (resolved) {
root.selection = resolved
root.unavailable = false
root.loadCurrentTrack(root.playing)
} else {
root.selection = { translation: desired.translation, voice: desired.voice, style: desired.style,
age: (root.selection ? root.selection.age : "adult"), book: desired.book, chapter: desired.chapter }
root.unavailable = true
root.playing = false
root.positionSec = 0
root.durationSec = 0
root.sendMpv({ command: ["stop"] })
}
}
function playPause() {
if (!root.selection || root.unavailable) return
if (!root.mpvReady) return
root.playing = !root.playing
root.sendMpv({ command: ["set_property", "pause", !root.playing] })
}
function seekBy(deltaSec) {
if (!root.selection || root.unavailable) return
root.sendMpv({ command: ["seek", deltaSec, "relative"] })
}
function seekAbsolute(sec) {
if (!root.selection || root.unavailable) return
root.sendMpv({ command: ["seek", sec, "absolute"] })
root.positionSec = sec
}
function goNext() {
if (!root.manifest || !root.selection || root.unavailable) return
root.selection = Model.neighborChapter(root.manifest, root.selection, 1)
root.loadCurrentTrack(root.playing)
}
function goPrev() {
if (!root.manifest || !root.selection || root.unavailable) return
root.selection = Model.neighborChapter(root.manifest, root.selection, -1)
root.loadCurrentTrack(root.playing)
}
function shuffleTrack() {
if (!root.manifest || !root.selection || root.unavailable) return
root.selection = Model.randomSelection(root.manifest, root.selection)
root.loadCurrentTrack(root.playing)
}
function handleTrackEnded() {
if (root.loopEnabled) { root.loadCurrentTrack(true); return }
root.goNext()
}
Component.onCompleted: {
mkdirProc.running = true
mpvProc.running = true
manifestProc.running = true
stateFile.reload()
}
Component.onDestruction: {
if (mpvSocket.connected) root.sendMpv({ command: ["quit"] })
if (mpvProc.running) mpvProc.running = false
}
Process {
id: mkdirProc
command: ["mkdir", "-p", root.stateDir]
}
Process {
id: mpvProc
command: ["mpv", "--no-video", "--idle=yes", "--really-quiet", "--input-ipc-server=" + root.socketPath]
onStarted: connectRetryTimer.restart()
onExited: function(exitCode) {
root.mpvReady = false
mpvSocket.connected = false
connectRetryTimer.stop()
}
}
Timer {
id: connectRetryTimer
interval: 300
repeat: true
onTriggered: {
if (mpvSocket.connected) { connectRetryTimer.stop(); return }
mpvSocket.connected = true
}
}
Timer {
id: pollTimer
interval: 500
repeat: true
running: root.mpvReady && root.playing
onTriggered: {
root.sendMpv({ command: ["get_property", "time-pos"], request_id: root.reqTimePos })
root.sendMpv({ command: ["get_property", "duration"], request_id: root.reqDuration })
}
}
Timer {
id: manifestRetryTimer
interval: 5000
onTriggered: if (!manifestProc.running) manifestProc.running = true
}
Process {
id: manifestProc
command: ["curl", "-fsS", "--max-time", "10", root.manifestUrl]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var parsed = Model.parseManifest(text)
if (!parsed) { manifestRetryTimer.restart(); return }
root.manifest = parsed
root.applySavedOrDefaultState()
}
}
}
Socket {
id: mpvSocket
path: root.socketPath
parser: SplitParser {
splitMarker: "\n"
onRead: function(line) { root.handleMpvLine(line) }
}
onConnectionStateChanged: {
root.mpvReady = connected
if (connected && root.selection && !root.unavailable) root.loadCurrentTrack(false)
}
}
FileView {
id: stateFile
path: root.statePath
printErrors: false
watchChanges: false
onLoaded: {
root._pendingSavedState = Model.parseStateFile(text())
if (root.manifest) root.applySavedOrDefaultState()
}
onLoadFailed: {
root._pendingSavedState = null
if (root.manifest) root.applySavedOrDefaultState()
}
}
visible: true
implicitWidth: pillRow.implicitWidth + Style.space(14)
implicitHeight: barSize
Row {
id: pillRow
anchors.centerIn: parent
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.playIcon
color: root.playing ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
visible: !root.vertical
text: root.trackLabel
color: root.bar.barForeground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.popupOpen = !root.popupOpen
onEntered: if (root.bar) root.bar.showTooltip(root, root.trackLabel)
onExited: if (root.bar) root.bar.hideTooltip(root)
}
PopupCard {
id: popup
anchorItem: root
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: popup.fittedContentWidth(Style.space(320))
contentHeight: popup.fittedContentHeight(popupColumn.implicitHeight)
Column {
id: popupColumn
anchors.fill: parent
spacing: Style.spacing.panelGap
Text {
textFormat: Text.PlainText
width: parent.width
text: !root.manifest ? "Loading…" : root.trackLabel + (root.selection && !root.unavailable
? " — " + (Model.optionsForStyles(root.manifest).filter(function(o) { return o.value === root.selection.style })[0] || { label: "" }).label
: "")
color: Color.popups.text
font.family: Style.font.family
font.pixelSize: Style.font.subtitle
font.bold: true
elide: Text.ElideRight
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.spacing.controlGap
Button {
iconText: "⟲"
selected: root.loopEnabled
foreground: Color.popups.text
tooltipText: "Loop current chapter"
onClicked: root.loopEnabled = !root.loopEnabled
}
Button {
iconText: "󰒮"
foreground: Color.popups.text
tooltipText: "Previous chapter"
enabled: !root.unavailable
opacity: enabled ? 1.0 : 0.4
onClicked: root.goPrev()
}
Button {
text: "-10s"
foreground: Color.popups.text
tooltipText: "Rewind 10 seconds"
enabled: !root.unavailable
opacity: enabled ? 1.0 : 0.4
onClicked: root.seekBy(-10)
}
Button {
iconText: root.playIcon
iconSize: Style.font.iconLarge
foreground: Color.popups.text
tooltipText: root.playing ? "Pause" : "Play"
enabled: !root.unavailable
opacity: enabled ? 1.0 : 0.4
onClicked: root.playPause()
}
Button {
text: "+10s"
foreground: Color.popups.text
tooltipText: "Forward 10 seconds"
enabled: !root.unavailable
opacity: enabled ? 1.0 : 0.4
onClicked: root.seekBy(10)
}
Button {
iconText: "󰒭"
foreground: Color.popups.text
tooltipText: "Next chapter"
enabled: !root.unavailable
opacity: enabled ? 1.0 : 0.4
onClicked: root.goNext()
}
Button {
iconText: "⇄"
foreground: Color.popups.text
tooltipText: "Play a random chapter"
enabled: !root.unavailable
opacity: enabled ? 1.0 : 0.4
onClicked: root.shuffleTrack()
}
}
Column {
width: parent.width
spacing: Style.spacing.xxs
PanelSlider {
id: seekSlider
width: parent.width
bar: root.bar
minimum: 0
maximum: Math.max(1, root.durationSec)
value: root.positionSec
onReleased: function(v) { root.seekAbsolute(v) }
}
Item {
width: parent.width
height: elapsedText.implicitHeight
Text {
id: elapsedText
anchors.left: parent.left
textFormat: Text.PlainText
text: root.formatTime(root.positionSec)
color: Qt.darker(Color.popups.text, 1.3)
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
Text {
anchors.right: parent.right
textFormat: Text.PlainText
text: root.formatTime(root.durationSec)
color: Qt.darker(Color.popups.text, 1.3)
font.family: Style.font.family
font.pixelSize: Style.font.caption
}
}
}
PanelSeparator {
width: parent.width
foreground: Color.popups.text
}
Column {
width: parent.width
spacing: Style.spacing.rowGap
Dropdown {
width: parent.width
label: "Book"
options: root.bookOptions
value: root.selection ? root.selection.book : ""
onChanged: function(v) { root.applyDesired(Object.assign({}, root.selection, { book: v, chapter: 1 })) }
}
Dropdown {
width: parent.width
label: "Chapter"
options: root.chapterOptions
value: root.selection ? String(root.selection.chapter) : ""
onChanged: function(v) { root.applyDesired(Object.assign({}, root.selection, { chapter: parseInt(v, 10) })) }
}
Dropdown {
width: parent.width
label: "Style"
options: root.styleOptions
value: root.selection ? root.selection.style : ""
onChanged: function(v) { root.applyDesired(Object.assign({}, root.selection, { style: v })) }
}
Dropdown {
width: parent.width
label: "Voice"
options: root.voiceOptions
value: root.selection ? root.selection.voice : ""
onChanged: function(v) { root.applyDesired(Object.assign({}, root.selection, { voice: v })) }
}
Dropdown {
width: parent.width
label: "Language"
options: root.translationOptions
value: root.selection ? root.selection.translation : ""
onChanged: function(v) { root.applyDesired(Object.assign({}, root.selection, { translation: v })) }
}
}
}
}
}

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

18
manifest.json Normal file
View File

@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"id": "dotjuice.thesinging.bible",
"name": "The Singing Bible",
"version": "1.0.0",
"author": "dotjuice",
"description": "Stream Bible chapters sung in your choice of language, voice, and musical style from thesinging.bible",
"kinds": ["bar-widget"],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Singing Bible",
"description": "Stream sung Bible chapters from thesinging.bible",
"category": "Audio",
"allowMultiple": false
}
}