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
This commit is contained in:
vicrodh committed 2026-07-27 18:22:22 -06:00
1 parent 86de977f3c
commit b10ff98620
63 files changed
+1865 -125

No files matched your search

+1
View File
@@ -6626,6 +6626,7 @@ dependencies = [
"qbz-i18n",
"qbz-library",
"qbz-log",
"qbz-lyrics",
"qbz-models",
"qbz-offline-cache",
"qbz-qobuz",
+1
View File
@@ -31,6 +31,7 @@ qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-library = { path = "../qbz-library" }
qbz-lyrics = { path = "../qbz-lyrics" }
qbz-text-utils = { path = "../qbz-text-utils" }
qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
+1
View File
@@ -48,6 +48,7 @@ fn main() {
"qml/HeaderBar.qml",
"qml/NowPlayingBarSmall.qml",
"qml/QueuePanel.qml",
"qml/LyricsPanel.qml",
"qml/HomeView.qml",
"qml/LibraryView.qml",
"qml/QbzTheme.qml",
+28 -5
View File
@@ -59,15 +59,16 @@ Rectangle {
// Animated 3-state width lives inside the component.
}
// Right-side queue column — a layout sibling of the content (it shrinks
// the content, no overlay), animated 0 <-> 300 like the Slint AppShell
// column (160ms ease-in-out).
// Right-side panel column — Queue and/or Lyrics, stacked vertically in
// a shared 300px column (Feishin-style, AppShell.slint:684-707). Each
// is toggled from its bar button and closed from its own X; the column
// is visible when either is open, animated 0 <-> 300 (160ms).
Rectangle {
id: queueColumn
anchors.right: parent.right
anchors.top: header.bottom
anchors.bottom: npb.top
width: QbzBridge.queueOpen ? theme.queuePanelWidth : 0
width: (QbzBridge.queueOpen || QbzBridge.lyricsOpen) ? theme.queuePanelWidth : 0
clip: true
color: theme.surfaceCard
@@ -76,9 +77,31 @@ Rectangle {
}
QueuePanel {
anchors.fill: parent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: QbzBridge.lyricsOpen
? (QbzBridge.queueOpen ? parent.height / 2 : 0)
: parent.height
visible: QbzBridge.queueOpen
}
Rectangle {
visible: QbzBridge.queueOpen && QbzBridge.lyricsOpen
anchors.left: parent.left
anchors.right: parent.right
y: QbzBridge.lyricsOpen && QbzBridge.queueOpen ? parent.height / 2 : 0
height: 1
color: theme.borderSubtle
}
LyricsPanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: QbzBridge.lyricsOpen
? (QbzBridge.queueOpen ? parent.height / 2 - 1 : parent.height)
: 0
visible: QbzBridge.lyricsOpen
}
}
// Content frame — the rounded, inset surface-main panel (Radius.md,
+337
View File
@@ -0,0 +1,337 @@
// Right-side Lyrics panel — QML port of
// crates/qbz-ui/ui/shell/LyricsSidebar.slint.
//
// Header (LYRICS overline + "{title} - {artist}" meta + display-settings
// trigger + close X), body by status (loading / no-lyrics / offline-miss
// / error / the lines view), source attribution line. Synced rendering:
// LINE-level highlight + auto-follow driven by the 1 Hz position feed
// (QbzBridge.npElapsedSecs; the Slint engine ticks ~30 Hz and can fill
// word-level karaoke — both noted POC-NOTEs). Lines with timestamps are
// click-to-seek (LyricsLinesView behavior).
//
// POC-NOTEs: display settings flyout (size/dimming/uppercase/font/active
// color — Slint defaults hardcoded here: 13/15px, accent, auto-follow
// ON, no dimming of surrounding lines), the translate toggle, word-level
// karaoke, the LRCLIB contribute link is rendered but opens via the
// system browser like upstream.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
id: root
color: "transparent"
clip: true
readonly property var doc: JSON.parse(QbzBridge.lyricsJson)
readonly property var lines: doc.lines || []
readonly property bool synced: doc.synced === true
// Active line (line-level sync, 1 Hz cadence).
readonly property int activeIndex: {
if (!synced) return -1
var nowMs = (QbzBridge.npElapsedSecs || 0) * 1000
var idx = -1
for (var i = 0; i < lines.length; i++) {
if (lines[i].timeMs !== null && lines[i].timeMs <= nowMs) idx = i
}
return idx
}
QbzTheme { id: theme }
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
onActiveIndexChanged: followActive()
function followActive() {
if (root.activeIndex < 0 || linesColumn.children.length === 0) return
var row = linesColumn.children[Math.min(root.activeIndex, linesColumn.children.length - 1)]
var target = row.y + row.height / 2 - linesFlick.height / 2
target = Math.max(0, Math.min(target, linesFlick.contentHeight - linesFlick.height))
if (Math.abs(linesFlick.contentY - target) > 30) {
followAnim2.to = target
followAnim2.restart()
}
}
Column {
anchors.fill: parent
spacing: 0
// --- Header: overline/meta + controls trigger + close ----------
Item {
width: parent.width
height: 56
Column {
anchors.left: parent.left
anchors.leftMargin: theme.spacingSm
anchors.right: controlsRow.left
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: QbzBridge.tr("LYRICS")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
visible: QbzBridge.npTitle !== ""
width: parent.width
text: QbzBridge.npTitle + " - " + QbzBridge.npArtist
color: theme.textSecondary
font.pixelSize: 12
elide: Text.ElideRight
}
}
Row {
id: controlsRow
anchors.right: parent.right
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Display settings (IconButton) — opens the flyout stub.
Rectangle {
width: 32
height: 32
radius: theme.radiusSm
color: dsArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "sliders-horizontal"
width: 16
height: 16
tintName: dsArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: dsArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: dsFlyout.open()
}
}
// Close X (Slint-convention, homologated with Queue).
Rectangle {
width: 28
height: 28
radius: theme.radiusSm
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 15
height: 15
tintName: closeArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.toggleLyrics()
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Body by status ----------------------------------------------
Item {
width: parent.width
height: parent.height - 57 - sourceLine.height
// Loading.
Column {
visible: doc.status === 1
anchors.centerIn: parent
spacing: 10
QbzSpinner { size: 24; anchors.horizontalCenter: parent.horizontalCenter }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Fetching lyrics...")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// No lyrics (idle / not-found / ready-with-zero-lines).
Column {
visible: doc.status === 0 || doc.status === 3 || (doc.status === 2 && root.lines.length === 0)
anchors.centerIn: parent
width: parent.width - 32
spacing: 12
Text {
width: parent.width
text: QbzBridge.tr("No lyrics available")
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Column {
width: parent.width
spacing: 2
Text {
width: parent.width
text: QbzBridge.tr("Missing lyrics? You can contribute them to LRCLIB:")
color: theme.textMuted
font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "lrclibup.boidu.dev"
color: lrcArea.containsMouse ? theme.accentHover : theme.accent
font.pixelSize: 10
MouseArea {
id: lrcArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Qt.openUrlExternally("https://lrclibup.boidu.dev/")
}
}
}
}
// Offline with nothing cached.
Text {
visible: doc.status === 5
anchors.centerIn: parent
width: parent.width - 32
text: QbzBridge.tr("Lyrics unavailable offline")
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
// Error.
Text {
visible: doc.status === 4
anchors.centerIn: parent
width: parent.width - 32
text: doc.error || ""
color: "#e0564f"
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
// Lyrics lines (status 2, lines > 0).
Flickable {
id: linesFlick
visible: doc.status === 2 && root.lines.length > 0
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: linesColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: linesColumn
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
topPadding: theme.spacingMd
bottomPadding: 48
spacing: 10
Repeater {
model: root.lines
delegate: Text {
required property var modelData
required property int index
property bool isActive: index === root.activeIndex
width: linesColumn.width
text: modelData.text
// Synced: active line = accent, 15px; the rest
// secondary 13px. Unsynced: uniform secondary.
color: !root.synced ? theme.textSecondary
: isActive ? theme.accent : theme.textSecondary
font.pixelSize: root.synced && isActive ? 15 : 13
font.weight: root.synced && isActive ? theme.weightSemibold : theme.weightRegular
wrapMode: Text.WordWrap
MouseArea {
anchors.fill: parent
enabled: modelData.timeMs !== null
cursorShape: modelData.timeMs !== null ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (modelData.timeMs !== null && QbzBridge.npDurationSecs > 0)
QbzBridge.seek(modelData.timeMs / 1000 / QbzBridge.npDurationSecs)
}
}
}
}
}
}
}
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
NumberAnimation {
id: followAnim2
target: linesFlick
property: "contentY"
duration: 250
easing.type: Easing.InOutQuad
}
// --- Source attribution ------------------------------------------
Row {
id: sourceLine
visible: doc.status === 2 && root.lines.length > 0
width: parent.width
leftPadding: theme.spacingSm
rightPadding: theme.spacingSm
topPadding: 3
bottomPadding: 5
spacing: 3
Text {
text: QbzBridge.tr("Lyrics source") + ":"
color: theme.textMuted
font.pixelSize: 9
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: doc.provider === "lrclib" ? "LRCLIB"
: doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
color: theme.accent
font.pixelSize: 9
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
}
}
// --- Display-settings flyout (visual stub — POC-NOTE: the prefs) ----
Popup {
id: dsFlyout
x: parent.width - 260
y: 64
width: 260
padding: 14
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceCard
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Text {
width: parent ? parent.width : 0
text: "QBZ Qt POC — display settings are not wired yet (Slint defaults apply)"
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
+5 -1
View File
@@ -404,7 +404,11 @@ Rectangle {
BarIconBtn { name: "cast" }
BarIconBtn { name: "settings-2" }
BarIconBtn { name: "layout-grid" }
BarIconBtn { name: "mic-vocal" }
BarIconBtn {
name: "mic-vocal"
active: QbzBridge.lyricsOpen
onClicked: QbzBridge.toggleLyrics()
}
Item { width: 4; height: 1 }
+408 -45
View File
@@ -1,20 +1,21 @@
// Right-side Queue panel — QML port of
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
//
// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card,
// the UP NEXT list, and the empty states. Phase 4: the Queue tab renders
// the REAL queue (QbzBridge.queueModel — one JSON-encoded row per QVariant,
// see the nesting POC-NOTE in playback_qt.rs `publish_queue`).
// 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:
// - The footer (count line + clear / save-as-playlist / infinite /
// sleep-timer actions + inline queue search) is omitted — phase 4 brings
// no queue mutations beyond play/next/previous.
// - The History tab stays empty (no local play-history store in the POC).
// - Row context menus, drag-reorder, pagination and the now-playing
// favorite toggle are out of scope.
// 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 {
@@ -24,22 +25,39 @@ Rectangle {
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
// Parsed queue rows: [{id, title, artist, duration, artPath, current}].
readonly property var rows: {
var out = []
for (var i = 0; i < QbzBridge.queueModel.length; i++) {
try { out.push(JSON.parse(QbzBridge.queueModel[i])) } catch (e) {}
}
return out
}
readonly property var currentRow: rows.length > 0 && rows[0].current ? rows[0] : null
readonly property var upcomingRows: currentRow ? rows.slice(1) : rows
readonly property bool queueEmpty: rows.length === 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 }
// One tab — plain text, no pill (QBZ design rule): active brightest,
// inactive dim, brighten on hover.
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()
@@ -60,17 +78,54 @@ Rectangle {
}
}
// One queue row (QueueRow.slint): index or thumbnail, title, artist,
// duration.
// 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: qrArea.containsMouse ? theme.surfaceHover
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
@@ -80,11 +135,12 @@ Rectangle {
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
text: rowIndex + 1 + (doc.page || 0) * 40
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
@@ -99,22 +155,42 @@ Rectangle {
clip: true
Image {
anchors.fill: parent
source: row.artPath
source: root.coverMap[row.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
// Title + artist.
Column {
width: parent.width - (showNumber ? 22 : 34) - 9 * 2 - durationText.implicitWidth
width: parent.width - (showNumber ? 22 : 34) - durText.width - 32 - 3 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
spacing: 5
Text {
width: parent.width
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
@@ -125,19 +201,107 @@ Rectangle {
}
}
Text {
id: durationText
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
// POC-NOTE: click-to-play / context menu out of scope.
onClicked: {
if (inQueue) QbzBridge.queuePlayUpcoming(rowIndex)
else QbzBridge.queuePlayHistory(rowIndex)
}
}
}
@@ -177,7 +341,6 @@ Rectangle {
onClicked: root.tab = 1
}
}
// Close button (mirrors the LyricsSidebar X).
Rectangle {
id: closeBtn
anchors.right: parent.right
@@ -212,7 +375,7 @@ Rectangle {
// --- Body --------------------------------------------------------
Item {
width: parent.width
height: parent.height - 45
height: parent.height - 45 - footerBlock.height
// ===== Queue tab =====
Flickable {
@@ -241,7 +404,6 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// Highlighted current-track card.
Rectangle {
width: parent.width
height: 44
@@ -263,13 +425,13 @@ Rectangle {
clip: true
Image {
anchors.fill: parent
source: root.currentRow ? root.currentRow.artPath : ""
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - 34 - 9
width: parent.width - 34 - 28 - 2 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
@@ -288,13 +450,40 @@ Rectangle {
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: root.upcomingRows.length > 0
visible: (doc.upcomingTotal || 0) > 0
width: parent.width - 20
spacing: 8
Text {
@@ -304,16 +493,91 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
model: root.upcomingRows
delegate: QueueRow {
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 {
@@ -342,10 +606,18 @@ Rectangle {
}
// ===== History tab =====
Column {
Flickable {
visible: root.tab === 1
anchors.fill: parent
anchors.margins: 10
clip: true
contentWidth: width
contentHeight: historyBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: historyBody
width: parent.width
padding: 10
spacing: 8
Text {
@@ -355,9 +627,18 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// POC-NOTE: history is always empty until the local
// play-history store is wired.
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")
@@ -368,4 +649,86 @@ Rectangle {
}
}
}
// --- 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
}
}
}
}
}
}
}
}
@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#4285f4" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#4285f4" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#4285f4"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#000000" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#ef4444" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#ef4444" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-mic-vocal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 7.601-5.994 8.19a1 1 0 0 0 .1 1.298l.817.818a1 1 0 0 0 1.314.087L15.09 12" />
<path d="M16.5 21.174C15.5 20.5 14.372 20 13 20c-2.058 0-3.928 2.356-6 2-2.072-.356-2.775-3.369-1.5-4.5" />
<circle cx="16" cy="7" r="5" />
</svg>

After

Width:  |  Height:  |  Size: 520 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#888888" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#888888" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#888888"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#cccccc" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#cccccc" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#cccccc"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

@@ -0,0 +1,12 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#fbbf24" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#fbbf24" stroke-width="0.4800000000000001">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 12H3" />
<path d="M16 6H3" />
<path d="M16 18H3" />
<path d="m19 10 4 4" />
<path d="m23 10-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.17.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#fbbf24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 667 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>

After

Width:  |  Height:  |  Size: 396 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

+83
View File
@@ -123,6 +123,17 @@ pub mod qbz_bridge {
// Now-playing track id (playing-row indicator in track lists).
#[qproperty(QString, np_track_id)]
// --- Lyrics panel (phase 9) ----------------------------------------
#[qproperty(bool, lyrics_open)]
// One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/
// provider/error).
#[qproperty(QString, lyrics_json)]
// --- Queue panel (phase 9) -----------------------------------------
// One JSON document (queue_qt.rs QueueDoc: current/upcoming/history
// + pagination + #442 section markers). Supersedes `queueModel`.
#[qproperty(QString, queue_json)]
// --- Sidebar playlist tree ---------------------------------------
// One JSON document: the flattened entries (folders + playlists,
// expand/sort/search applied Rust-side — sidebar_qt.rs).
@@ -230,6 +241,32 @@ pub mod qbz_bridge {
#[qinvokable]
fn enqueue_artist_top(self: Pin<&mut QbzBridge>);
/// NPB lyrics button / lyrics panel close X.
#[qinvokable]
fn toggle_lyrics(self: Pin<&mut QbzBridge>);
/// Queue panel: tabs/pagination/search.
#[qinvokable]
fn queue_set_page(self: Pin<&mut QbzBridge>, page: i32);
#[qinvokable]
fn queue_set_search(self: Pin<&mut QbzBridge>, query: QString);
/// Row actions.
#[qinvokable]
fn queue_play_upcoming(self: Pin<&mut QbzBridge>, index: i32);
#[qinvokable]
fn queue_remove_upcoming(self: Pin<&mut QbzBridge>, index: i32);
#[qinvokable]
fn queue_remove_all_after(self: Pin<&mut QbzBridge>, index: i32);
#[qinvokable]
fn queue_move_track(self: Pin<&mut QbzBridge>, from: i32, to: i32);
#[qinvokable]
fn queue_play_history(self: Pin<&mut QbzBridge>, index: i32);
#[qinvokable]
fn queue_toggle_favorite(self: Pin<&mut QbzBridge>, kind: QString, id: QString);
/// Footer: Clear queue.
#[qinvokable]
fn queue_clear(self: Pin<&mut QbzBridge>);
/// Sidebar navigation: record a content view ("home" | "library")
/// and lazy-load its data on first visit.
#[qinvokable]
@@ -374,6 +411,9 @@ pub struct QbzBridgeRust {
artist_json: QString,
view_param_id: QString,
np_track_id: QString,
queue_json: QString,
lyrics_open: bool,
lyrics_json: QString,
}
impl Default for QbzBridgeRust {
@@ -431,6 +471,9 @@ impl Default for QbzBridgeRust {
artist_json: QString::from("{}"),
view_param_id: QString::default(),
np_track_id: QString::default(),
queue_json: QString::from("{}"),
lyrics_open: false,
lyrics_json: QString::from("{}"),
}
}
}
@@ -534,6 +577,46 @@ impl qbz_bridge::QbzBridge {
crate::play_album(album_id.to_string());
}
pub fn queue_set_page(self: Pin<&mut Self>, page: i32) {
crate::queue_set_page(page);
}
pub fn queue_set_search(self: Pin<&mut Self>, query: QString) {
crate::queue_set_search(query.to_string());
}
pub fn queue_play_upcoming(self: Pin<&mut Self>, index: i32) {
crate::queue_play_upcoming(index);
}
pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) {
crate::queue_remove_upcoming(index);
}
pub fn queue_remove_all_after(self: Pin<&mut Self>, index: i32) {
crate::queue_remove_all_after(index);
}
pub fn queue_move_track(self: Pin<&mut Self>, from: i32, to: i32) {
crate::queue_move_track(from, to);
}
pub fn queue_play_history(self: Pin<&mut Self>, index: i32) {
crate::queue_play_history(index);
}
pub fn queue_toggle_favorite(self: Pin<&mut Self>, kind: QString, id: QString) {
crate::queue_toggle_favorite(kind.to_string(), id.to_string());
}
pub fn queue_clear(self: Pin<&mut Self>) {
crate::queue_clear();
}
pub fn toggle_lyrics(self: Pin<&mut Self>) {
crate::toggle_lyrics();
}
pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) {
crate::play_album_shuffled(album_id.to_string());
}
+2 -12
View File
@@ -218,14 +218,6 @@ fn build_sections(
out
}
/// The last built sections (clone), for debug/probe entry points.
static LAST_SECTIONS: std::sync::Mutex<Vec<HomeSection>> = std::sync::Mutex::new(Vec::new());
pub fn with_sections<R>(f: impl FnOnce(&[HomeSection]) -> R) -> R {
let guard = LAST_SECTIONS.lock().unwrap();
f(&guard)
}
/// Fetch the discover index (UNFILTERED — genre filter out of scope) and
/// the personalized rails concurrently (mirrors home.rs's `join!`), then
/// map everything into plain rows.
@@ -246,14 +238,12 @@ where
release_watch.len(),
top_artists.len(),
);
let built = build_sections(
Ok(build_sections(
response.containers,
favorite_albums,
release_watch,
top_artists,
);
*LAST_SECTIONS.lock().unwrap() = built.clone();
Ok(built)
))
}
// ---------------------------------------------------------------------------
+186
View File
@@ -0,0 +1,186 @@
//! Lyrics side panel data layer — drives the `qbz-lyrics` crate
//! (LyricsService: Qobuz primary + LRCLIB/OVH fallback chain + per-user
//! SQLite cache, all self-contained) from the playback track-change hook.
//!
//! Lifecycle (mirrors `lyrics::init_for_user`): the service is built
//! LAZILY on first use from the core's QobuzClient clone, and the per-user
//! cache (`~/.cache/qbz/users/<uid>/lyrics`) binds on every session
//! activation. Fetches dedupe in-flight per track inside the crate.
//!
//! POC-NOTEs:
//! - Word-level karaoke fill (the wsync word engine), translations
//! (Qobuz v10 language targets + the translate toggle), the display
//! settings flyout (size/dimming/uppercase/font/color prefs — the
//! Slint defaults are hardcoded), MusicBrainz/AI providers: out of scope.
//! - Highlight cadence is the 1 Hz playback poll (vs the Slint engine's
//! ~30 Hz) — line-level highlight only.
use std::sync::{Arc, OnceLock};
use qbz_app::shell::AppRuntime;
use qbz_app::user_data::UserDataPaths;
use qbz_core::LoggingAdapter;
use qbz_lyrics::service::{LyricsOutcome, LyricsRequest, LyricsService};
use qbz_models::QueueTrack;
use cxx_qt_lib::QString;
use serde::Serialize;
/// LyricsState.status semantics (LyricsSidebar renders by these).
#[derive(Clone, Serialize)]
pub struct LyricsLine {
#[serde(rename = "timeMs")]
pub time_ms: Option<i64>,
#[serde(rename = "endMs")]
pub end_ms: Option<i64>,
pub text: String,
}
#[derive(Default, Serialize)]
pub struct LyricsDoc {
/// 0 idle / 1 loading / 2 ready / 3 not-found / 4 error / 5 offline-miss.
pub status: i32,
pub lines: Vec<LyricsLine>,
pub synced: bool,
pub provider: String,
pub error: String,
}
static SERVICE: OnceLock<Arc<LyricsService>> = OnceLock::new();
/// The user id the cache is currently bound for ("" = unbound).
static CACHE_USER: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// Build (once) the service from the core's client clone, and bind the
/// per-user cache on first use / user change.
async fn service(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Option<Arc<LyricsService>> {
let service = match SERVICE.get() {
Some(s) => Arc::clone(s),
None => {
let client_lock = runtime.core().client();
let client = {
let guard = client_lock.read().await;
guard.as_ref().cloned()
}?;
let service = Arc::new(LyricsService::with_qobuz_client(Arc::new(client)));
let _ = SERVICE.set(Arc::clone(&service));
service
}
};
// Bind the per-user cache (idempotent per user). The std MutexGuard
// must NOT be held across the await (it would make the future !Send).
let needs_init = UserDataPaths::load_last_user_id()
.map(|uid| {
let bound = CACHE_USER.lock().unwrap();
*bound != uid.to_string()
})
.unwrap_or(false);
if needs_init {
if let Some(uid) = UserDataPaths::load_last_user_id() {
if let Some(dir) = dirs::cache_dir().map(|d| d.join("qbz").join("users").join(uid.to_string())) {
match service.init_at(&dir).await {
Ok(()) => {
log::info!("[qbz-qt] lyrics cache bound for user {uid}");
*CACHE_USER.lock().unwrap() = uid.to_string();
}
Err(e) => log::error!("[qbz-qt] lyrics cache init failed: {e}"),
}
}
}
}
Some(service)
}
fn publish(doc: LyricsDoc) {
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
crate::ui(move |mut b| {
b.as_mut().set_lyrics_json(QString::from(json.as_str()));
});
}
/// Publish the loading state (status 1) — called on every track change.
pub fn publish_loading() {
publish(LyricsDoc {
status: 1,
..Default::default()
});
}
/// Publish the idle/empty state (track cleared).
pub fn publish_idle() {
publish(LyricsDoc::default());
}
/// Fetch lyrics for the new current track (called from
/// `refresh_now_playing` on every track change; the panel re-requests on
/// open via the same path — a cached doc returns immediately).
pub async fn load_for_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track: &QueueTrack,
) {
publish_loading();
let Some(service) = service(runtime).await else {
publish(LyricsDoc {
status: 4,
error: "lyrics service unavailable".to_string(),
..Default::default()
});
return;
};
let request = LyricsRequest {
track_id: Some(track.id),
source: qbz_lyrics::service::LyricsSourceKind::Qobuz,
title: track.title.clone(),
artist: track.artist.clone(),
album: Some(track.album.clone()),
duration_secs: Some(track.duration_secs),
offline: crate::offline_fwd::engine().is_offline(),
language: None,
};
let track_id = track.id;
match service.get(request).await {
Ok(response) => {
// Stale-response guard (F2 parity): a slow fetch for a track
// that is no longer current is discarded by the caller's own
// generation check — see playback_qt (it awaits sequentially
// per track change, so this mostly covers rapid skips).
let _ = track_id;
match response.outcome {
LyricsOutcome::Found(result) => {
let doc = result.doc;
let n = doc.lines.len();
let provider = doc.provider.as_str().to_string();
let synced = doc.synced;
let lines: Vec<LyricsLine> = doc
.lines
.iter()
.map(|l| LyricsLine {
time_ms: l.time_ms,
end_ms: l.end_ms,
text: l.text.clone(),
})
.collect();
publish(LyricsDoc {
status: if lines.is_empty() { 3 } else { 2 },
synced,
provider: provider.clone(),
lines,
..Default::default()
});
log::info!("[qbz-qt] lyrics: {n} lines ({provider}, synced={synced}) for track {track_id}");
}
LyricsOutcome::NotFound => publish(LyricsDoc {
status: 3,
..Default::default()
}),
LyricsOutcome::NotAvailableOffline => publish(LyricsDoc {
status: 5,
..Default::default()
}),
}
}
Err(e) => publish(LyricsDoc {
status: 4,
error: e,
..Default::default()
}),
}
}
+74 -14
View File
@@ -17,7 +17,9 @@ mod now_playing;
mod offline_fwd;
mod album_qt;
mod artist_qt;
mod lyrics_qt;
mod playback_qt;
mod queue_qt;
mod sidebar_qt;
use std::pin::Pin;
@@ -466,6 +468,78 @@ pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: Stri
}
}
// ============================ Lyrics panel (phase 9) ======================
/// NPB lyrics button / panel close: toggle the column section and — like
/// LyricsState.panel-opened — re-request the current track's lyrics on
/// open (cached docs return immediately). The static is the source of
/// truth (the ui() hop is queued, not synchronous).
static LYRICS_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn toggle_lyrics() {
let open = !LYRICS_OPEN.swap(!LYRICS_OPEN.load(std::sync::atomic::Ordering::SeqCst), std::sync::atomic::Ordering::SeqCst);
ui(move |mut b| {
b.as_mut().set_lyrics_open(open);
});
if open {
let runtime = app();
spawn(async move {
if let Some(track) = runtime.core().current_track().await {
lyrics_qt::load_for_track(&runtime, &track).await;
}
});
}
}
// ============================ Queue panel (phase 9) =======================
pub(crate) fn queue_set_page(page: i32) {
queue_qt::set_page(page);
let runtime = app();
spawn(async move { queue_qt::publish(&runtime).await });
}
pub(crate) fn queue_set_search(query: String) {
queue_qt::set_search(&query);
let runtime = app();
spawn(async move { queue_qt::publish(&runtime).await });
}
pub(crate) fn queue_play_upcoming(index: i32) {
let runtime = app();
spawn(async move { queue_qt::play_upcoming(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_remove_upcoming(index: i32) {
let runtime = app();
spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_remove_all_after(index: i32) {
let runtime = app();
spawn(async move { queue_qt::remove_all_after(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_move_track(from: i32, to: i32) {
let runtime = app();
spawn(async move { queue_qt::move_track(&runtime, from.max(0) as usize, to.max(0) as usize).await });
}
pub(crate) fn queue_play_history(index: i32) {
let runtime = app();
spawn(async move { queue_qt::play_history(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_clear() {
let runtime = app();
spawn(async move { queue_qt::clear_queue(&runtime).await });
}
pub(crate) fn queue_toggle_favorite(kind: String, id: String) {
let runtime = app();
spawn(async move { queue_qt::toggle_favorite(&runtime, &kind, &id).await });
}
/// AlbumView header Shuffle.
pub(crate) fn play_album_shuffled(album_id: String) {
let runtime = app();
@@ -802,20 +876,6 @@ fn publish_home_sections(sections: &[home_qt::HomeSection]) {
});
}
/// Build the bridge's `queueModel` QVariantList from per-row JSON strings
/// (each QVariant carries one QString — see the nesting POC-NOTE in
/// playback_qt.rs `publish_queue`).
pub(crate) fn json_rows_to_qvariant_list(
rows: Vec<String>,
) -> cxx_qt_lib::QList<cxx_qt_lib::QVariant> {
let mut list = cxx_qt_lib::QList::<cxx_qt_lib::QVariant>::default();
for row in rows {
list.append(<QString as cxx_qt_lib::QVariantValue>::construct(&QString::from(
row.as_str(),
)));
}
list
}
fn main() {
qbz_log::install("info");
+15 -48
View File
@@ -345,6 +345,10 @@ pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
}
}
pub(crate) async fn play_queue_track_public(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
play_queue_track(runtime, track_id).await;
}
async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
if let Err(e) = runtime
.core()
@@ -426,6 +430,7 @@ pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
crate::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(""));
});
crate::lyrics_qt::publish_idle();
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
@@ -459,6 +464,13 @@ pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
// Artwork through the same cache pipeline as Home (attach + background
// download + republish — single url here).
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
// Lyrics for the new track (loading state, then the doc).
crate::lyrics_qt::publish_loading();
let runtime = runtime.clone();
let track = track.clone();
crate::spawn(async move {
crate::lyrics_qt::load_for_track(&runtime, &track).await;
});
}
/// Quality tier + exact label from a queue track's bit depth / sample rate
@@ -478,55 +490,10 @@ fn quality_badge(track: &QueueTrack) -> (String, String) {
(tier, label)
}
/// Publish the real queue into `queueModel` (now-playing row first, then
/// up-next rows) as one QVariant per row.
///
/// POC-NOTE: rows are JSON-encoded strings inside the QVariants — the same
/// cxx-qt-lib nesting limitation as homeSectionsJson (a QVariant cannot
/// hold a QVariantMap in 0.7.3); QML parses each row.
/// Publish the queue panel document (queue_qt.rs — the full QueueState
/// port: sections, history, pagination, search).
pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state().await;
#[derive(serde::Serialize)]
struct QueueRow {
id: String,
title: String,
artist: String,
duration: String,
#[serde(rename = "artPath")]
art_path: String,
current: bool,
}
let mut rows: Vec<QueueRow> = Vec::new();
let mut push = |track: &QueueTrack, current: bool| {
rows.push(QueueRow {
id: track.id.to_string(),
title: track.title.clone(),
artist: track.artist.clone(),
duration: fmt_mmss(track.duration_secs),
art_path: crate::artwork_qt::cached_path(
&track.artwork_url.clone().unwrap_or_default(),
),
current,
});
};
if let Some(current) = state.current_track.as_ref() {
push(current, true);
}
for track in &state.upcoming {
push(track, false);
}
let json_rows: Vec<String> = rows
.iter()
.map(|r| serde_json::to_string(r).unwrap_or_default())
.collect();
crate::ui(move |mut b| {
b.as_mut()
.set_queue_model(crate::json_rows_to_qvariant_list(json_rows));
});
}
fn fmt_mmss(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
crate::queue_qt::publish(runtime).await;
}
// ---------------------------------------------------------------------------
+287
View File
@@ -0,0 +1,287 @@
//! Queue panel controller — Slint-free port of the `QueueState` assembly
//! side of `crates/qbz/src/queue.rs`: NOW PLAYING card, UP NEXT with the
//! #442 "Next in queue" / "Next up" section markers (from the core's
//! `manual_next_count`), 40-row pagination, live search filter, and the
//! History tab. Mutations go straight to the core queue API; every one
//! ends with a republish.
//!
//! POC-NOTEs:
//! - QConnect remote reorder, the playlist picker (save-as-playlist opens
//! a modal picker upstream), infinite-play engine, sleep timer, stop-after
//! marker, ephemeral rows, coverflow: out of scope.
//! - Row favorite seeds from the phase-5 library feed (Slint uses
//! fav_cache — same truth).
use std::sync::Mutex;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use cxx_qt_lib::QString;
use qbz_models::QueueTrack;
use serde::Serialize;
use std::sync::Arc;
/// queue.rs PAGE_SIZE.
pub const PAGE_SIZE: usize = 40;
#[derive(Clone, Default, Serialize)]
pub struct QueueRow {
pub id: String,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
pub album: String,
pub duration: String,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
pub explicit: bool,
#[serde(rename = "artUrl")]
pub art_url: String,
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// #442 section header drawn ABOVE this row: "" | "next-in-queue" |
/// "next-up" (only on the unfiltered list).
pub section: String,
}
#[derive(Default, Serialize)]
pub struct QueueDoc {
#[serde(rename = "hasCurrent")]
pub has_current: bool,
pub current: Option<QueueRow>,
pub upcoming: Vec<QueueRow>,
#[serde(rename = "upcomingTotal")]
pub upcoming_total: usize,
#[serde(rename = "upcomingRemaining")]
pub upcoming_remaining: usize,
pub history: Vec<QueueRow>,
pub page: usize,
#[serde(rename = "pageCount")]
pub page_count: usize,
#[serde(rename = "pageStart")]
pub page_start: usize,
#[serde(rename = "pageEnd")]
pub page_end: usize,
pub shuffle: bool,
#[serde(rename = "repeatMode")]
pub repeat_mode: i32,
}
/// Panel view state (search + page), session-scope like the Slint view.
#[derive(Default)]
struct ViewState {
search: String,
page: usize,
}
static VIEW: Mutex<ViewState> = Mutex::new(ViewState {
search: String::new(),
page: 0,
});
/// queue.rs `display_title` (version suffix).
fn display_title(track: &QueueTrack) -> String {
match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
}
}
fn fmt_duration(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn row_from(track: &QueueTrack) -> QueueRow {
let is_favorite = crate::library_qt::with_library(|d| {
d.feed
.iter()
.any(|i| i.kind == "track" && i.id == track.id.to_string() && i.is_favorite)
})
.unwrap_or(false);
let tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if track.hires => "hires",
None => "",
}
.to_string();
QueueRow {
id: track.id.to_string(),
title: display_title(track),
artist: track.artist.clone(),
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
album: track.album.clone(),
duration: fmt_duration(track.duration_secs),
quality_tier: tier,
quality_detail: crate::home_qt::quality_detail_from_parts(
track.bit_depth,
track.sample_rate,
),
explicit: track.parental_warning,
art_url: track.artwork_url.clone().unwrap_or_default(),
is_favorite,
section: String::new(),
}
}
/// queue.rs `paginate` (unit-tested upstream; port).
fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize) {
let page_count = total.div_ceil(PAGE_SIZE).max(1);
let page = requested_page.min(page_count - 1);
let start = page * PAGE_SIZE;
let end = (start + PAGE_SIZE).min(total);
(page, page_count, start, end)
}
/// Build + publish the whole panel document.
pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state_full().await;
let (search, requested_page) = {
let view = VIEW.lock().unwrap();
(view.search.clone(), view.page)
};
let query = search.trim().to_lowercase();
// --- UP NEXT (search-filtered, paged, #442 markers) ------------------
let filtered: Vec<&QueueTrack> = if query.is_empty() {
state.upcoming.iter().collect()
} else {
state
.upcoming
.iter()
.filter(|t| {
display_title(t).to_lowercase().contains(&query)
|| t.artist.to_lowercase().contains(&query)
})
.collect()
};
let upcoming_total = filtered.len();
let (page, page_count, start, end) = paginate(upcoming_total, requested_page);
VIEW.lock().unwrap().page = page;
let page_rows: Vec<QueueRow> = filtered[start..end]
.iter()
.enumerate()
.map(|(i, t)| {
let mut r = row_from(t);
if query.is_empty() {
let g = start + i;
if state.manual_next_count > 0 && g == 0 {
r.section = "next-in-queue".into();
} else if g == state.manual_next_count {
r.section = "next-up".into();
}
}
r
})
.collect();
let remaining = state
.current_index
.map(|idx| state.total_tracks.saturating_sub(idx + 1))
.unwrap_or(state.total_tracks);
let history: Vec<QueueRow> = state.history.iter().map(row_from).collect();
let current = state.current_track.as_ref().map(row_from);
let doc = QueueDoc {
has_current: current.is_some(),
current,
upcoming: page_rows,
upcoming_total,
upcoming_remaining: remaining,
history,
page,
page_count,
page_start: if upcoming_total == 0 { 0 } else { start + 1 },
page_end: end,
shuffle: state.shuffle,
repeat_mode: match state.repeat {
qbz_models::RepeatMode::Off => 0,
qbz_models::RepeatMode::All => 1,
qbz_models::RepeatMode::One => 2,
},
};
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
crate::ui(move |mut b| {
b.as_mut().set_queue_json(QString::from(json.as_str()));
});
}
// ---------------------------------------------------------------------------
// View state + mutations (every mutation republishes)
// ---------------------------------------------------------------------------
pub fn set_search(query: &str) {
{
let mut view = VIEW.lock().unwrap();
view.search = query.to_string();
view.page = 0;
}
}
pub fn set_page(page: i32) {
VIEW.lock().unwrap().page = page.max(0) as usize;
}
pub async fn play_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
let upcoming_index = start + page_index;
if let Some(track) = runtime.core().play_upcoming_at(upcoming_index).await {
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
}
}
pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
let upcoming_index = start + page_index;
runtime.core().remove_upcoming_track(upcoming_index).await;
publish(runtime).await;
}
pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
let upcoming_index = start + page_index;
runtime.core().remove_upcoming_after(upcoming_index).await;
publish(runtime).await;
}
/// Drag reorder with QUEUE-WIDE indices (the QML list is unfiltered when
/// drag is enabled, so page-local == page*40 + row).
pub async fn move_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, from: usize, to: usize) {
runtime.core().move_track(from, to).await;
publish(runtime).await;
}
/// History replay (queue.rs play_history: a fresh single-track queue).
pub async fn play_history(runtime: &Arc<AppRuntime<LoggingAdapter>>, index: usize) {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.history.get(index).cloned() else {
log::warn!("[qbz-qt] queue: play_history {index} out of range");
return;
};
runtime.core().set_queue(vec![track.clone()], Some(0)).await;
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
}
pub async fn clear_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
// queue.rs clear_queue: drops everything INCLUDING the current track
// (keep_current: false) and stops playback.
runtime.core().clear_queue(false).await;
let _ = runtime.core().stop();
publish(runtime).await;
crate::playback_qt::refresh_now_playing(runtime).await;
}
/// Toggle a heart for a queue row (Qobuz favorite), then republish.
pub async fn toggle_favorite(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
kind: &str,
id: &str,
) {
let _ = crate::library_qt::toggle_favorite(runtime, kind, id).await;
publish(runtime).await;
}