commit 8d63cb561f68f0760d7ea31643e8fa7c22a8b509 Author: Johan Date: Tue Sep 15 11:25:42 2026 +0100 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 Claude-Session: https://claude.ai/code/session_01LYvjTEDeh9T6NqBDdCALMq diff --git a/BarWidget.qml b/BarWidget.qml new file mode 100644 index 0000000..8423f89 --- /dev/null +++ b/BarWidget.qml @@ -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 })) } + } + } + } + } +} diff --git a/Model.js b/Model.js new file mode 100644 index 0000000..1b471d7 --- /dev/null +++ b/Model.js @@ -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)) +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..722bb73 --- /dev/null +++ b/manifest.json @@ -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 + } +}