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:
477
BarWidget.qml
Normal file
477
BarWidget.qml
Normal 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 })) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user