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) and transport (loop, prev, -10s, // play/pause, +10s, next, shuffle). Language is hard-coded to English — // no Language dropdown, see Model.js FIXED_TRANSLATION. 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 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 { id: bookDropdown 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 { id: chapterDropdown 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 { id: styleDropdown 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 { id: voiceDropdown 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 })) } } // The card's window is sized to popupColumn.implicitHeight, which // only covers the collapsed rows — Dropdown's own popup is an // overlay that isn't counted, so with nothing after it the last // dropdown's open list renders past the window's bottom edge and // is clipped away. Reserve room for the tallest possible popup // (Dropdown.qml caps at 8 rows) only while one of these four is // actually open, so the panel stays compact the rest of the time // instead of always carrying that dead space at the bottom. Item { width: 1 height: (bookDropdown.popupOpen || chapterDropdown.popupOpen || styleDropdown.popupOpen || voiceDropdown.popupOpen) ? (Style.spacing.popupRowHeight * 8 + 7 * Style.spacing.labelGap + Style.spacing.xxs) : 0 } } } } }