- Alabanza style removed: it only has Spanish recordings, so it always played nothing under the (now-removed) English default. - Language dropdown removed entirely; translation is hard-coded to English (sbe) in Model.js, overriding any legacy saved state too. - Dropdown popups were rendering past the popup window's bottom edge and getting clipped, since the window is sized only to the collapsed rows. Reserve room for the open popup, but only while a dropdown is actually expanded, so the panel doesn't carry permanent dead space. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYvjTEDeh9T6NqBDdCALMq
490 lines
16 KiB
QML
490 lines
16 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|