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 })) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
245
Model.js
Normal file
245
Model.js
Normal 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
18
manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user