Files
qbz/crates/qbz-qt/qml/QueuePanel.qml
T
vicrodh b10ff98620 feat(qbz-qt): phase 9 — Queue homologation + Lyrics side panel
Queue panel 1:1: History tab (click replays per queue.rs semantics),
#442 'Next in queue'/'Next up' section markers from manual_next_count,
40-row pagination + live search + page clamp, row actions (play,
remove, remove-all-after, heart), press-drag reorder via move_track,
clear queue, plural-aware footer count, exact empty states. New Lyrics
panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain
with LRCLIB fallback, synced line view with line-level highlight +
centered auto-follow + click-to-seek, exact status bodies. Both panels
stack Feishin-style in the right column; NPB lyrics button wired.

km
2026-07-27 18:22:22 -06:00

735 lines
31 KiB
QML

// Right-side Queue panel — QML port of
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
//
// Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the
// #442 "Next in queue" / "Next up" section markers, 40-row pagination,
// live search filter, row actions (play / remove / remove-all-after /
// heart), drag reorder (basic press-drag), footer (count line + Clear /
// stubs + search field), History tab (thumbnail rows, click replays as a
// fresh single-track queue), exact empty states. Data: QbzBridge.queueJson
// (queue_qt.rs QueueDoc).
//
// POC-NOTEs: save-as-playlist (opens a picker modal upstream — inert),
// infinite-play + sleep timer engines, stop-after marker, row menu's
// "Add to playlist" / "Track info" entries, ephemeral rows, the ghost
// drag pill (rows move with a plain displaced animation instead).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
id: root
color: "transparent"
clip: true
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
readonly property var doc: JSON.parse(QbzBridge.queueJson)
readonly property var currentRow: doc.current || null
readonly property var upcoming: doc.upcoming || []
readonly property var historyRows: doc.history || []
readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0
// url-keyed cover map (shared artwork pipeline).
property var coverMap: ({})
// Drag-reorder state (basic press-drag; ghost is the row's own y).
property int dragFrom: -1
QbzTheme { id: theme }
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: dispatchCovers()
onDocChanged: dispatchCovers()
function dispatchCovers() {
var urls = []
if (currentRow && currentRow.artUrl) urls.push(currentRow.artUrl)
var i
for (i = 0; i < upcoming.length; i++) if (upcoming[i].artUrl) urls.push(upcoming[i].artUrl)
for (i = 0; i < historyRows.length; i++) if (historyRows[i].artUrl) urls.push(historyRows[i].artUrl)
if (urls.length > 0) QbzBridge.sidebarArtworkWindow(JSON.stringify(urls))
}
component QueueTab: Text {
property bool active: false
signal clicked()
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
color: active ? theme.textPrimary
: tabArea.containsMouse ? theme.textSecondary : theme.textMuted
verticalAlignment: Text.AlignVCenter
height: parent ? parent.height : 0
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Row action icon button (the panel's small IconButton).
component PanelIconBtn: Rectangle {
property string name: ""
property int iconSize: 17
property bool active: false
property bool btnEnabled: true
signal clicked()
width: 30
height: 30
radius: theme.radiusSm
color: (piArea.containsMouse && btnEnabled) ? theme.surfaceHover : "transparent"
QbzIcon {
name: parent.name
width: parent.iconSize
height: parent.iconSize
anchors.centerIn: parent
tintName: !parent.btnEnabled ? "muted"
: parent.active ? "accent"
: piArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: piArea
anchors.fill: parent
enabled: parent.btnEnabled
hoverEnabled: true
cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: parent.clicked()
}
}
// One UP NEXT / History row (QueueRow.slint).
component QueueRow: Rectangle {
property var row: ({})
property int rowIndex: 0
property bool showNumber: true
property bool inQueue: true
readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
readonly property bool isActive: inQueue && QbzBridge.npTrackId !== "" && QbzBridge.npTrackId === row.id
height: 44
radius: theme.radiusSm
color: hovered ? theme.surfaceHover
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
border.width: root.dragFrom === rowIndex ? 1 : 0
border.color: theme.accent
opacity: root.dragFrom === rowIndex ? 0.4 : 1.0
Row {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
anchors.rightMargin: theme.spacingXs
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
// Leading: track number (UP NEXT) or thumbnail (History).
Text {
visible: showNumber
width: 22
anchors.verticalCenter: parent.verticalCenter
text: rowIndex + 1 + (doc.page || 0) * 40
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
Rectangle {
visible: !showNumber
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
// Title + artist.
Column {
width: parent.width - (showNumber ? 22 : 34) - durText.width - 32 - 3 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
spacing: 5
Text {
text: row.title
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.parent.width - (row.explicit ? 21 : 0))
}
Rectangle {
visible: row.explicit
width: 16
height: 16
radius: 3
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Text {
anchors.centerIn: parent
text: "E"
color: theme.textMuted
font.pixelSize: 9
font.weight: theme.weightSemibold
}
}
}
Text {
width: parent.width
text: row.artist
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
Text {
id: durText
anchors.verticalCenter: parent.verticalCenter
text: row.duration
color: theme.textMuted
font.pixelSize: 11
}
// ⋯ menu trigger (UP NEXT rows only; always visible, 32px hit).
Rectangle {
visible: inQueue
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: menuArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "ellipsis"
width: 16
height: 16
tintName: menuArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: menuArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: rowMenu.open()
}
Popup {
id: rowMenu
x: width - 196
y: 44
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
Repeater {
model: [
{ "label": QbzBridge.tr("Remove from queue"), "icon": "trash-2", "action": "remove" },
{ "label": QbzBridge.tr("Remove all after"), "icon": "list-x", "action": "remove-after" },
{ "label": row.isFavorite ? QbzBridge.tr("Remove from Library") : QbzBridge.tr("Add to Library"), "icon": row.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: rmiArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: rmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
rowMenu.close()
var a = modelData.action
if (a === "remove") QbzBridge.queueRemoveUpcoming(rowIndex)
else if (a === "remove-after") QbzBridge.queueRemoveAllAfter(rowIndex)
else if (a === "favorite") {
row.isFavorite = !row.isFavorite
QbzBridge.queueToggleFavorite("track", row.id)
}
}
}
}
}
}
}
}
}
MouseArea {
id: qrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (inQueue) QbzBridge.queuePlayUpcoming(rowIndex)
else QbzBridge.queuePlayHistory(rowIndex)
}
}
}
Column {
anchors.fill: parent
spacing: 0
// --- Header: tabs + close button -------------------------------
Item {
width: parent.width
height: 44
Row {
anchors.left: parent.left
anchors.leftMargin: theme.spacingMd
anchors.right: closeBtn.left
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
height: 28
spacing: 14
QueueTab {
text: QbzBridge.tr("Queue")
active: root.tab === 0
onClicked: root.tab = 0
}
Text {
text: "|"
color: theme.textDisabled
font.pixelSize: theme.fontBody
height: parent.height
verticalAlignment: Text.AlignVCenter
}
QueueTab {
text: QbzBridge.tr("History")
active: root.tab === 1
onClicked: root.tab = 1
}
}
Rectangle {
id: closeBtn
anchors.right: parent.right
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: theme.radiusSm
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: 15
height: 15
anchors.centerIn: parent
tintName: closeArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.toggleQueue()
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Body --------------------------------------------------------
Item {
width: parent.width
height: parent.height - 45 - footerBlock.height
// ===== Queue tab =====
Flickable {
visible: root.tab === 0
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: queueBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: queueBody
width: parent.width
padding: 10
spacing: 12
// NOW PLAYING section.
Column {
visible: root.currentRow !== null
width: parent.width - 20
spacing: 10
Text {
text: QbzBridge.tr("NOW PLAYING")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Rectangle {
width: parent.width
height: 44
radius: theme.radiusSm
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
Rectangle {
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceCard
clip: true
Image {
anchors.fill: parent
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - 34 - 28 - 2 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.currentRow ? root.currentRow.title : ""
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.currentRow ? root.currentRow.artist : ""
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
// Favorite toggle (wired).
Rectangle {
width: 28
height: 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: npFavArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: root.currentRow && root.currentRow.isFavorite ? "heart-filled" : "heart"
width: 17
height: 17
tintName: root.currentRow && root.currentRow.isFavorite ? "favorite" : (npFavArea.containsMouse ? "primary" : "muted")
}
MouseArea {
id: npFavArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.currentRow) {
root.currentRow.isFavorite = !root.currentRow.isFavorite
QbzBridge.queueToggleFavorite("track", root.currentRow.id)
}
}
}
}
}
}
}
// UP NEXT section.
Column {
visible: (doc.upcomingTotal || 0) > 0
width: parent.width - 20
spacing: 8
Text {
text: QbzBridge.tr("UP NEXT")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
model: root.upcoming
delegate: Column {
required property var modelData
required property int index
width: parent ? parent.width : 0
// #442 section header above the row.
Row {
visible: modelData.section !== ""
height: 22
spacing: 6
QbzIcon {
visible: modelData.section === "next-in-queue"
name: "list-start"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.section === "next-in-queue"
? QbzBridge.tr("Next in queue") : QbzBridge.tr("Next up")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
QueueRow {
width: parent.width
row: modelData
rowIndex: index
showNumber: true
inQueue: true
// Basic press-drag reorder (POC: no ghost pill).
MouseArea {
anchors.fill: parent
onPressAndHold: root.dragFrom = index
onReleased: {
if (root.dragFrom >= 0) {
var target = Math.max(0, Math.min(root.upcoming.length - 1,
Math.floor((parent.mapToItem(null, mouseX, mouseY).y - queueBody.mapToItem(null, 0, 0).y - 40) / 44)))
QbzBridge.queueMoveTrack((doc.page || 0) * 40 + root.dragFrom, (doc.page || 0) * 40 + target)
root.dragFrom = -1
}
}
}
}
}
}
// Paginator (plain text controls, no pills).
Row {
visible: (doc.pageCount || 1) > 1
width: parent.width
anchors.horizontalCenter: parent.horizontalCenter
spacing: theme.spacingMd
Item { width: (parent.width - paginator.width) / 2; height: 1 }
Row {
id: paginator
spacing: theme.spacingMd
PanelIconBtn {
name: "chevron-left"
iconSize: 15
btnEnabled: (doc.page || 0) > 0
onClicked: QbzBridge.queueSetPage((doc.page || 0) - 1)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Page {} of {}").replace("{}", (doc.page || 0) + 1).replace("{}", doc.pageCount || 1)
color: theme.textMuted
font.pixelSize: 12
}
PanelIconBtn {
name: "chevron-right"
iconSize: 15
btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1
onClicked: QbzBridge.queueSetPage((doc.page || 0) + 1)
}
}
Item { width: (parent.width - paginator.width) / 2; height: 1 }
}
}
// Empty state — no current track and no upcoming.
Column {
visible: root.queueEmpty
width: parent.width - 20
topPadding: 48 - 10
spacing: 6
Text {
width: parent.width
text: QbzBridge.tr("Your queue is empty")
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzBridge.tr("Play an album or track to get started")
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
}
// ===== History tab =====
Flickable {
visible: root.tab === 1
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: historyBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: historyBody
width: parent.width
padding: 10
spacing: 8
Text {
text: QbzBridge.tr("RECENTLY PLAYED")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
model: root.historyRows
delegate: QueueRow {
width: parent.width
row: modelData
rowIndex: index
showNumber: false
inQueue: false
}
}
Text {
visible: root.historyRows.length === 0
width: parent.width
topPadding: 48 - 10
text: QbzBridge.tr("Nothing played yet")
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
}
}
}
// --- Footer: count + actions + inline search (Queue tab) ---------
Column {
id: footerBlock
visible: root.tab === 0 && !root.queueEmpty
width: parent.width
spacing: 0
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Text {
visible: (doc.upcomingTotal || 0) > 0
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
topPadding: 8
text: (doc.upcomingTotal === 1
? QbzBridge.tr("Showing {} of {} track.")
: QbzBridge.tr("Showing {} of {} tracks."))
.replace("{}", doc.pageEnd || 0).replace("{}", doc.upcomingTotal || 0)
color: theme.textMuted
font.pixelSize: 11
}
Row {
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
topPadding: 10
bottomPadding: 10
spacing: theme.spacingXs
// Action 1 — Clear queue (wired).
PanelIconBtn {
name: "trash-list"
onClicked: QbzBridge.queueClear()
}
// Action 2 — Save as playlist (INERT: opens a picker modal
// upstream — POC-NOTE).
PanelIconBtn { name: "add-to-list" }
// Action 3 — infinite play (INERT engine — POC-NOTE).
PanelIconBtn { name: "infinity" }
// Action 4 — sleep timer (INERT engine — POC-NOTE).
PanelIconBtn { name: "clock" }
// Inline queue search field (wired).
Rectangle {
width: parent.width - 4 * 30 - 4 * theme.spacingXs
height: 30
radius: theme.radiusSm
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 7
spacing: 6
QbzIcon {
name: "search"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
width: parent.width - 20
height: parent.height
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzBridge.queueSetSearch(text)
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzBridge.tr("Search queue")
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
}
}
}