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