feat(qt): A-B loop from the player bar's + menu

Mark A, mark B, loop between them until cleared; local playback only,
never persisted, cleared on track change or a seek outside the section.
A 100 ms watcher reads the millisecond position and reissues the local
seek; the seekbar shows the section and its two marks.

cc
This commit is contained in:
vicrodh committed 2026-09-13 03:50:29 -06:00
1 parent 4824c672fa
commit 9174b33b1b
14 files changed
+476 -4

No files matched your search

+20 -2
View File
@@ -532,6 +532,20 @@ Rectangle {
// idiom: a raw JSON.parse in a binding throws on the pre-publish frame and
// takes the whole bar down with it.
readonly property var settingsDoc: parseSettings()
// A-B loop entries for the "+" menu (see PlayerBar.abEntries): local
// playback only.
function abEntries() {
if (QbzPlayer.npIsRemote || QbzPlayer.npCastActive)
return []
var m = [{ "sep": true }]
if (QbzPlayer.abState === 0)
m.push({ "label": QbzSession.tr("Set loop start (A)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" })
else if (QbzPlayer.abState === 1)
m.push({ "label": QbzSession.tr("Set loop end (B)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" })
if (QbzPlayer.abState !== 0)
m.push({ "label": QbzSession.tr("Clear loop", QbzSession.trRev), "icon": "x", "action": "ab-clear" })
return m
}
function parseSettings() {
try {
return JSON.parse(QbzBridge.settingsJson)
@@ -568,7 +582,7 @@ Rectangle {
m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
if (root.npEphemeral)
return m
return m.concat(root.abEntries())
// "Add to playlist" is SECOND (TransportControls.slint:143 — the
// full bar carries the rationale). Same `npSource` gate as the
// mixtape entry: the picker's Qobuz arm takes catalog ids and an
@@ -597,7 +611,7 @@ Rectangle {
"action": "album-favorite"
})
}
return m
return m.concat(root.abEntries())
}
onPicked: function (a) {
var id = QbzPlayer.npTrackId
@@ -612,6 +626,10 @@ Rectangle {
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
} else if (a === "ab-mark") {
QbzPlayer.abMark()
} else if (a === "ab-clear") {
QbzPlayer.abClear()
} else if (a === "mixtape") {
// MyQBZ AddItem, built here from the now-playing state:
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
+46 -2
View File
@@ -126,6 +126,20 @@ Rectangle {
// AppearanceState.show-volume-steppers (PlayerBar.slint gates the −/+
// pair on it; Tauri always showed them).
readonly property bool showVolumeSteppers: settingsDoc.showVolumeSteppers === true
// A-B loop entries for the "+" menu: one contextual verb + clear. Local
// playback only — the loop cannot follow a cast / Connect renderer.
function abEntries() {
if (QbzPlayer.npIsRemote || QbzPlayer.npCastActive)
return []
var m = [{ "sep": true }]
if (QbzPlayer.abState === 0)
m.push({ "label": QbzSession.tr("Set loop start (A)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" })
else if (QbzPlayer.abState === 1)
m.push({ "label": QbzSession.tr("Set loop end (B)", QbzSession.trRev), "icon": "repeat", "action": "ab-mark" })
if (QbzPlayer.abState !== 0)
m.push({ "label": QbzSession.tr("Clear loop", QbzSession.trRev), "icon": "x", "action": "ab-clear" })
return m
}
// Favorite state of the now-playing track (Slint:
// QueueState.now-playing-favorite). The queue document carries it on its
@@ -426,6 +440,32 @@ Rectangle {
x: parent.width * seekRow.visualProgress - width / 2
anchors.verticalCenter: parent.verticalCenter
}
// A-B loop: the tinted section + two ticks. Static bindings only
// (no animation), so nothing here touches the repaint pulse.
Rectangle {
visible: QbzPlayer.abState === 2 && QbzPlayer.npDurationSecs > 0
x: parent.width * (QbzPlayer.abStartSecs / Math.max(QbzPlayer.npDurationSecs, 1))
width: Math.max(2, parent.width
* ((QbzPlayer.abEndSecs - QbzPlayer.abStartSecs) / Math.max(QbzPlayer.npDurationSecs, 1)))
height: parent.height
radius: 2
color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.28)
}
Repeater {
model: QbzPlayer.abState === 0 ? []
: (QbzPlayer.abState === 1 ? [QbzPlayer.abStartSecs]
: [QbzPlayer.abStartSecs, QbzPlayer.abEndSecs])
Rectangle {
required property int modelData
visible: QbzPlayer.npDurationSecs > 0
width: 2
height: 10
radius: 1
color: theme.accent
x: seekTrack.width * (modelData / Math.max(QbzPlayer.npDurationSecs, 1)) - 1
anchors.verticalCenter: parent.verticalCenter
}
}
}
Text {
anchors.right: parent.right
@@ -887,7 +927,7 @@ Rectangle {
m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
m.push({ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" })
if (root.npEphemeral)
return m
return m.concat(root.abEntries())
// "Add to playlist" sits SECOND in TransportControls.slint:143 —
// spliced in there rather than appended, because the flyout's
// order is part of the parity. It rides the SAME `npSource` gate
@@ -919,7 +959,7 @@ Rectangle {
"action": "album-favorite"
})
}
return m
return m.concat(root.abEntries())
}
onPicked: function (a) {
var id = QbzPlayer.npTrackId
@@ -934,6 +974,10 @@ Rectangle {
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
} else if (a === "ab-mark") {
QbzPlayer.abMark()
} else if (a === "ab-clear") {
QbzPlayer.abClear()
} else if (a === "mixtape") {
// MyQBZ AddItem, built here from the now-playing state:
// `npArtworkPath` is a file:// CACHE path, so it is NOT the