Files
dotjuice.thesinging.bible/BarWidget.qml
Johan 15fae9aaf9 Remove Alabanza/Language, fix dropdown popup clipping
- 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
2026-09-15 11:54:27 +01:00

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