refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges

Two fronts of the modularization, both gated green before this commit.

QML leaves the flat qml/ directory for seven modules: cards, controls,
rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml
stay at the root. build.rs matches the tree exactly (55 files), relative
imports and asset paths were re-anchored for the new depth.

The QbzBridge God-object starts breaking up: QbzSession, QbzShell,
QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge]
singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted
in order from Main.qml. Bridge files stay directly under src/ because
cxx-qt-build only accepts rust_files from one directory (QTBUG-93443).

bridge.rs keeps 5.5 domains (library, album/artist, settings,
integrations, search, playlist) and dies once they are split out.

cc
This commit is contained in:
vicrodh committed 2026-07-28 11:08:20 -06:00
1 parent 580b72084f
commit 9a4fec6b70
72 files changed
+2152 -1690

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
// AmbientField — the app-wide dynamic background ("modo Cider", phase 14).
//
// The QML rendering of the Slint ambient WGSL scene (crates/qbz-ui/ui/
// shaders/ambient.wgsl): FOUR album-colored blobs on the shader's EXACT
// wandering orbits (10-18s, t scaled 0.75), additive-blended over a black
// base so near blobs fuse like the metaball field, plus the shader's
// vertical edge shade. Colors come from the ambient_qt album triad
// (QbzShell.ambientPrimary/Secondary/Accent, recomputed on track change —
// the field crossfades because Canvas repaints are continuous, ~0.6s of
// visible blend per the blob softness).
//
// POC-NOTEs (vs the WGSL original, ambient_qt.rs has the full list):
// - No fbm domain warp and no r²/d² metaball potential — plain radial
// gradients with 'lighter' compositing approximate the fusion.
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the
// shader's breathe is a constant 1.0 there anyway) and no grain dither.
// - Rendered with QPainter (renderTarget Image) so it works on the
// software/offscreen path too; 30fps cap + paused when the window is
// inactive (the spec's cost controls §6).
import QtQuick
import com.blitzfc.qbz
Canvas {
id: root
// The album triad (hex strings from ambient_qt).
property color primary: QbzShell.ambientPrimary
property color secondary: QbzShell.ambientSecondary
property color accent: QbzShell.ambientAccent
// Set false by the host to freeze the clock (inactive / hidden).
property bool running: true
renderTarget: Canvas.Image
renderStrategy: Canvas.Cooperative
// The shader clock (t = time * 0.75). Accumulated at ~30fps.
property real t: Math.random() * 40.0
Timer {
interval: 33
repeat: true
running: root.running && root.visible && root.Window.active
onTriggered: {
root.t += interval / 1000.0 * 0.75
root.requestPaint()
}
}
onPrimaryChanged: requestPaint()
onSecondaryChanged: requestPaint()
onAccentChanged: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
// One blob: radial gradient, center color at `a` alpha fading to
// transparent (the shader's wide smoothstep falloff).
function blob(ctx, cx, cy, r, c, a) {
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
g.addColorStop(1.0, Qt.rgba(c.r, c.g, c.b, 0.0))
ctx.fillStyle = g
ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r)
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var W = width
var H = height
if (W <= 0 || H <= 0) return
// Base: the shader's field->0 color (black).
ctx.fillStyle = "#000000"
ctx.fillRect(0, 0, W, H)
var t = root.t
// The shader's orbit constants (ambient.wgsl cA..cD, aspect folded
// back out — window-fraction centers are the plain expressions).
var cA = [0.32 + 0.30 * Math.sin(t * 0.40), 0.42 + 0.30 * Math.cos(t * 0.33)]
var cB = [0.66 + 0.30 * Math.sin(t * 0.35 + 2.1), 0.56 + 0.32 * Math.cos(t * 0.29 + 1.3)]
var cC = [0.50 + 0.34 * Math.cos(t * 0.31 + 4.0), 0.36 + 0.30 * Math.sin(t * 0.45 + 3.2)]
var cD = [0.46 + 0.32 * Math.sin(t * 0.27 + 5.3), 0.64 + 0.28 * Math.cos(t * 0.49 + 0.7)]
// rr = 0.34 of the field; the shader's brightness lands ~0.92 with
// the 0.42-1.12 shape spread — the alpha spread below reproduces
// that mid-to-low brightness read under the host's dim scrim.
var rr = 0.36 * Math.max(W, H)
var c4 = Qt.rgba(
(primary.r + accent.r) / 2, (primary.g + accent.g) / 2, (primary.b + accent.b) / 2, 1.0)
ctx.globalCompositeOperation = "lighter"
blob(ctx, cA[0] * W, cA[1] * H, rr, primary, 0.50)
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, secondary, 0.46)
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, accent, 0.44)
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
ctx.globalCompositeOperation = "source-over"
// Vertical shade: 16% darker at the very top/bottom edges (the
// shader's vshade — chrome sits on calmer color).
var v = ctx.createLinearGradient(0, 0, 0, H)
v.addColorStop(0.0, Qt.rgba(0, 0, 0, 0.16))
v.addColorStop(0.5, Qt.rgba(0, 0, 0, 0.0))
v.addColorStop(1.0, Qt.rgba(0, 0, 0, 0.16))
ctx.fillStyle = v
ctx.fillRect(0, 0, W, H)
}
}
+330
View File
@@ -0,0 +1,330 @@
// App shell — the QML port of crates/qbz-ui/ui/shell/AppShell.slint's
// chrome: HeaderBar (top, 42px) / { Sidebar | content frame | queue
// column } / NowPlayingBarSmall (bottom).
//
// The window chrome is surface-card throughout; the content area is a
// ROUNDED surface-main panel inset 8px left/right/bottom (0 top — it
// butts the header), the "Slack-style bezel on all four corners" of
// AppShell.slint:358-390. The recovery affordance and logout live in the
// header (offline badge flyout + app menu), like the Slint shell.
//
// POC-NOTE: the artwork-derived ambient background (AppearanceState
// "Ambient"/"Blurred art" modes) is not implemented — the Slint default
// is Off, which is what this static dark treatment matches.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// OPAQUE base (the Slint root background is opaque surface-main too —
// invisible while the ambient field mounts over it; this IS the D4
// no-track fallback). The translucent chrome comes from each chrome
// piece's OWN background going surface-card @ 0.5 above the field.
color: theme.surfaceCard
// App-wide dynamic background (phase 14): active when the mode pref is
// on AND a track is loaded (D4: no track -> opaque theme restored).
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// The bottom-most visual layer (AppShell.slint:206-242, declared FIRST
// so every chrome surface paints above it): the ambient album-colored
// field + the dark dim scrim that keeps chrome text legible
// (QBZ_BG_DIM-tunable, default 0.35).
AmbientField {
anchors.fill: parent
visible: root.ambientOn
running: root.ambientOn
}
Rectangle {
anchors.fill: parent
visible: root.ambientOn
color: "#000000"
opacity: QbzShell.ambientDim
}
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
property var hostWindow: null
QbzTheme { id: theme }
HeaderBar {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: theme.headerHeight
hostWindow: root.hostWindow
// Square corners (phase 12: the window is opaque; any rounding is
// the compositor's business).
}
NowPlayingBar {
id: npb
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// Mode-aware height (AppShell.slint:396): Small collapses to one
// header-tall row; New/Classic/Large keep the full 112px.
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : 112
}
Sidebar {
id: sidebar
anchors.left: parent.left
anchors.top: header.bottom
anchors.bottom: npb.top
// Animated 3-state width lives inside the component.
}
// 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: (QbzShell.queueOpen || QbzShell.lyricsOpen) ? theme.queuePanelWidth : 0
clip: true
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
Behavior on width {
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
}
QueuePanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: QbzShell.lyricsOpen
? (QbzShell.queueOpen ? parent.height / 2 : 0)
: parent.height
visible: QbzShell.queueOpen
}
Rectangle {
visible: QbzShell.queueOpen && QbzShell.lyricsOpen
anchors.left: parent.left
anchors.right: parent.right
y: QbzShell.lyricsOpen && QbzShell.queueOpen ? parent.height / 2 : 0
height: 1
color: theme.borderSubtle
}
LyricsPanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: QbzShell.lyricsOpen
? (QbzShell.queueOpen ? parent.height / 2 - 1 : parent.height)
: 0
visible: QbzShell.lyricsOpen
}
}
// Content frame — the rounded, inset surface-main panel (Radius.md,
// 8px gaps left/right/bottom, flush to the header).
Rectangle {
id: contentFrame
anchors.left: sidebar.right
anchors.right: queueColumn.left
anchors.top: header.bottom
anchors.bottom: npb.top
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.bottomMargin: 8
radius: theme.radiusMd
// Frosted content panel while the ambient background is active
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10 hairline).
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
border.width: root.ambientOn ? 1 : 0
border.color: theme.frostBorder
clip: true
Loader {
anchors.fill: parent
source: QbzShell.currentView === "home" ? "../views/HomeView.qml"
: QbzShell.currentView === "library" ? "../views/LibraryView.qml"
: QbzShell.currentView === "album" ? "../views/AlbumView.qml"
: QbzShell.currentView === "artist" ? "../views/ArtistView.qml"
: QbzShell.currentView === "settings" ? "../settings/SettingsView.qml"
: QbzShell.currentView === "search" ? "../views/SearchView.qml"
: QbzShell.currentView === "playlist" ? "../views/PlaylistView.qml" : ""
}
}
// --- Large NPB (mode 3) cover dock (phase 18) -------------------------
// The L's vertical arm: the square now-playing cover pinned flush to
// the window bottom-left over the sidebar (SidebarNowPlayingDock.slint,
// AppShell.slint:747). Only while Large is ACTIVE (mode 3 + sidebar
// open). The spectrum band above the cover is NOT rendered — it needs
// the FFT visualizer tap, which the POC does not wire (POC-NOTE).
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
Rectangle {
visible: root.largeActive
x: 16
y: parent.height - 221
width: 208
height: 221
color: "transparent"
// Drop-shadow approximation (the Slint dock's 24px blur).
Rectangle {
x: 0
y: 17
width: 208
height: 208
radius: theme.radiusMd
color: "#66000000"
}
Rectangle {
x: 0
y: 13
width: 208
height: 208
radius: theme.radiusMd
color: ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: theme.radiusMd
}
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: 66
height: 66
anchors.centerIn: parent
tintName: "muted"
}
}
}
// Search cortinilla (phase 15): the live-search dropdown overlay, LAST
// child so it renders above every surface (Cortinilla.slint's mount).
Cortinilla {
anchors.fill: parent
headerBar: header
}
// --- Shared text modal (phase 16) ---------------------------------------
// The AppShell-level modal layer (the Slint AppShell modals mount at
// window level, ADR-009): the scrim covers the WHOLE window — sidebar /
// header / NPB included — and the panel centers on the WINDOW, not on
// the content frame. Views reach it via `openTextModal(title, body)`.
property bool textModalOpen: false
property string textModalTitle: ""
property string textModalBody: ""
function openTextModal(title, body) {
textModalTitle = title
textModalBody = body
textModalOpen = true
}
Rectangle {
visible: root.textModalOpen
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.textModalOpen = false
}
Rectangle {
anchors.centerIn: parent
width: Math.min(root.width - 80, 560)
height: Math.min(root.height - 120, 460)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
anchors.fill: parent
anchors.margins: 24
spacing: 14
Row {
width: parent.width
Text {
width: parent.width - 28
text: root.textModalTitle
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: 28
height: 28
color: tmCloseArea.containsMouse ? theme.surfaceHover : "transparent"
radius: 6
QbzIcon {
name: "x"
width: 18
height: 18
anchors.centerIn: parent
tintName: tmCloseArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: tmCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.textModalOpen = false
}
}
}
Flickable {
width: parent.width
height: parent.height - 42
clip: true
contentWidth: width
contentHeight: tmText.implicitHeight
Text {
id: tmText
width: parent.width
text: root.textModalBody
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
}
}
}
}
// --- Drag ghost (DragGhost.slint, phase 17) -----------------------------
// The dark pill that follows the cursor while tracks are dragged onto
// the sidebar: "N tracks" for a group, or title + "artist · album" for
// one. Visual only (never blocks the pointer).
Rectangle {
visible: QbzShell.dragActive
x: QbzShell.dragX + 10
y: QbzShell.dragY + 14
width: ghostCol.width + 28
height: ghostCol.height + 16
radius: 8
color: "#e01e1e28"
border.width: 1
border.color: "#1fffffff"
Column {
id: ghostCol
anchors.centerIn: parent
spacing: 2
Text {
text: QbzShell.dragCount > 1
? QbzShell.dragCount + " " + QbzSession.tr("tracks", QbzSession.trRev)
: QbzShell.dragTitle
color: "#ffffff"
font.pixelSize: 12
font.weight: theme.weightMedium
}
Text {
visible: QbzShell.dragCount === 1 && QbzShell.dragSubtitle !== ""
text: QbzShell.dragSubtitle
color: "#8cffffff"
font.pixelSize: 10
}
}
}
}
+361
View File
@@ -0,0 +1,361 @@
// Search "cortinilla" — the live as-you-type dropdown under the header
// search box (search/Cortinilla.slint, phase 15). A plain Item overlay
// mounted as a LAST child of AppShell (NOT a Popup — a Popup grabs the
// pointer and would kill typing in the header field, the exact reason the
// Slint uses a plain Rectangle too).
//
// Payload: QbzBridge.cortinillaJson (search_qt.rs CortinillaData: query /
// top / sections with controller flat indices). Keyboard selection rides
// selectedIndex + cortinillaScrollY (content-space top-y of the selected
// row — scrolled into view here). Row activation bubbles to the
// QbzBridge.cortinilla* invokables; the field clear goes through
// root.header.clearSearch().
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Item {
id: root
anchors.fill: parent
visible: QbzBridge.cortinillaOpen
// The host HeaderBar (for clearSearch on row activation).
property var headerBar: null
QbzTheme { id: theme }
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzBridge.cortinillaJson)
} catch (e) {
return ({})
}
}
readonly property var sections: doc.sections || []
readonly property bool hasTop: doc.top !== undefined && doc.top !== null && (doc.top.id || "") !== ""
readonly property bool empty: !hasTop && sections.length === 0 && !QbzBridge.cortinillaLoading
// The centered header search box width (HeaderBar's responsive rule) —
// the click-through gaps below leave it free.
readonly property int searchBoxWidth: root.width < 960 ? 179 : 256
readonly property int panelWidth: 320
// Cap so the panel NEVER covers the now-playing bar (Cortinilla.slint).
readonly property int scrollCap: root.height - 42 - 6 - 42 - 16 - 12
// --- Idle auto-close (4.5s without hover/activity, Cortinilla.slint) --
property bool panelHovered: false
Timer {
id: idleClose
interval: 4500
repeat: false
running: root.visible && !root.panelHovered
onTriggered: QbzBridge.cortinillaDismiss()
}
Connections {
target: QbzBridge
// Activity restarts the countdown (keystroke or arrow move).
function onCortinillaJsonChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
function onSelectedIndexChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
// Keyboard scroll-into-view: the controller publishes the selected
// row's content-space top-y; nudge the Flickable so it is visible.
function onCortinillaScrollYChanged() {
var y = QbzBridge.cortinillaScrollY
if (y < bodyFlick.contentY) {
bodyFlick.contentY = Math.max(0, y)
} else if (y + 56 > bodyFlick.contentY + bodyFlick.height) {
bodyFlick.contentY = y + 56 - bodyFlick.height
}
}
}
Connections {
target: QbzShell
// A page change dismisses (AppShell's tracked-view close hook).
function onCurrentViewChanged() { QbzBridge.cortinillaDismiss() }
}
// --- Click-outside scrims (any click outside the panel dismisses) ----
// 1) everything BELOW the header.
MouseArea {
visible: root.visible
x: 0
y: 42
width: root.width
height: root.height - 42
onClicked: QbzBridge.cortinillaDismiss()
}
// 2) header strip LEFT of the search box.
MouseArea {
visible: root.visible
x: 0
y: 0
width: (root.width - root.searchBoxWidth) / 2
height: 42
onClicked: QbzBridge.cortinillaDismiss()
}
// 3) header strip RIGHT of the search box.
MouseArea {
visible: root.visible
x: (root.width + root.searchBoxWidth) / 2
y: 0
width: root.width - x
height: 42
onClicked: QbzBridge.cortinillaDismiss()
}
// --- The panel ----------------------------------------------------------
Rectangle {
id: panel
x: (root.width - root.panelWidth) / 2
y: 42 + 6
width: root.panelWidth
height: Math.min(bodyColumn.height + 12, root.scrollCap + 12)
radius: theme.radiusSm
color: theme.surfaceMain
border.width: 1
border.color: theme.borderMuted
clip: true
MouseArea {
// Hover surface for the idle-close pause; clicks stay inside.
anchors.fill: parent
hoverEnabled: true
onContainsMouseChanged: root.panelHovered = containsMouse
}
Flickable {
id: bodyFlick
anchors.fill: parent
anchors.topMargin: 6
anchors.bottomMargin: 6
contentWidth: width
contentHeight: bodyColumn.height
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: bodyColumn
width: bodyFlick.width
// --- Loading skeleton (no cached instant-paint — one clean
// apply, Cortinilla.slint) -------------------------------
Column {
visible: QbzBridge.cortinillaLoading
width: parent.width
topPadding: 4
property bool pulse: false
Timer {
interval: 700
running: parent.visible
repeat: true
onTriggered: parent.pulse = !parent.pulse
}
Repeater {
model: 5
delegate: Item {
width: bodyColumn.width
height: 56
Rectangle {
x: 10
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
radius: 4
color: theme.surfaceElevated
opacity: parent.parent.pulse ? 0.48 : 0.16
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
Column {
x: 62
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
width: 150
height: 11
radius: 3
color: theme.surfaceElevated
opacity: parent.parent.parent.pulse ? 0.48 : 0.16
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
Rectangle {
width: 96
height: 9
radius: 3
color: theme.surfaceElevated
opacity: parent.parent.parent.pulse ? 0.48 : 0.16
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
}
}
}
}
// --- Loaded content ----------------------------------------
Column {
visible: !QbzBridge.cortinillaLoading
width: parent.width
// No results.
Item {
visible: root.empty
width: parent.width
height: 28
Text {
x: 14
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("No results for", QbzSession.trRev) + " “" + (root.doc.query || "") + "”"
color: theme.textMuted
font.pixelSize: 12
}
}
// Top result (flat-index 0).
Column {
visible: root.hasTop
width: parent.width
leftPadding: 6
rightPadding: 6
topPadding: 4
Text {
x: 10
height: 22
text: QbzSession.tr("Top result", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
CortRow { row: root.hasTop ? root.doc.top : ({}) }
}
// Sections.
Repeater {
model: root.sections
delegate: Column {
required property var modelData
width: bodyColumn.width
leftPadding: 6
rightPadding: 6
topPadding: 4
Item {
width: parent.width
height: 24
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: modelData.title
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
}
Text {
visible: modelData.hasMore === true
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("View more", QbzSession.trRev)
color: vmArea.containsMouse ? theme.textPrimary : theme.accent
font.pixelSize: 11
font.weight: theme.weightMedium
MouseArea {
id: vmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.headerBar) root.headerBar.clearSearch()
QbzBridge.cortinillaViewMore(modelData.kind)
}
}
}
}
Repeater {
model: modelData.rows
delegate: CortRow { row: modelData }
}
}
}
}
}
}
QbzScrollBar {
target: bodyFlick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// --- One result row (CortinillaResultRow) ------------------------------
component CortRow: Rectangle {
property var row: ({})
width: parent ? parent.width : 0
height: 56
radius: theme.radiusSm
readonly property bool active: QbzBridge.selectedIndex === (row.flatIndex ?? -2)
color: (active || rowArea.containsMouse) ? theme.surfaceHover : "transparent"
// Accent bar on the keyboard-active row (distinct from plain hover).
Rectangle {
visible: parent.active
x: 0
y: 4
width: 3
height: parent.height - 8
radius: 2
color: theme.accent
}
Rectangle {
x: 10
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
// Artists read better as a circle; everything else is a tile.
radius: row.kind === "artist" ? 20 : 4
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: row.artPath || ""
radius: row.kind === "artist" ? 20 : 4
}
}
Column {
x: 62
width: parent.width - 62 - 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: row.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.subtitle || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.headerBar) root.headerBar.clearSearch()
QbzBridge.cortinillaRowClicked(row.flatIndex)
}
}
}
}
+663
View File
@@ -0,0 +1,663 @@
// Top header bar — QML port of crates/qbz-ui/ui/shell/HeaderBar.slint.
//
// Left: the three "sacred" nav buttons (sidebar cycle, back, forward) and
// — while the sidebar is fully closed — the compact icon-only section nav
// (nav-in-sidebar is ON in this POC, so the full text tabs never mount).
// Center: the search field, absolutely centered on the window (VISUAL
// replica — the cortinilla/live-search is out of scope; POC-NOTE).
// Right: the tri-state offline status badge with its flyout (recovery
// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block
// + Log Out + Close; the other Slint menu items are POC-NOTE stubs).
//
// POC-NOTE: the custom window-chrome parts (drag surface, drawn
// min/max/close WindowControls) are skipped — the POC keeps NATIVE window
// decorations.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
// surface-card @ 0.5 while the ambient background is active (phase 14,
// HeaderBar.slint's with-alpha(app-background-surface-alpha)).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// The host ApplicationWindow (custom chrome); null in previews.
property var hostWindow: null
QbzTheme { id: theme }
// Custom-chrome drag surface (declared FIRST so every interactive
// element above wins hit-testing): press-and-move starts the system
// move; double-click toggles maximize. The system grab starts only
// after a real movement so plain clicks/double-clicks still work.
MouseArea {
anchors.fill: parent
// Inert under the system titlebar (the native chrome owns
// drag/double-click) — phase 12 titlebar toggle.
enabled: !QbzShell.systemTitleBar
property bool dragStarted: false
onPressed: dragStarted = false
onPositionChanged: {
if (pressed && !dragStarted && root.hostWindow) {
dragStarted = true
root.hostWindow.startSystemMove()
}
}
onDoubleClicked: {
if (root.hostWindow) {
root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
}
// Slint OfflineState.badge-state: 0 hidden / 1 hard offline / 2 manual /
// 3 logged out (wins over the others).
readonly property int badgeState: QbzSession.offlineSession ? 3
: QbzSession.offlineMode === 2 ? 2
: QbzSession.offlineMode === 1 ? 1 : 0
// --- Left controls ---------------------------------------------------
Row {
id: leftControls
x: theme.spacingMd
y: (root.height - height) / 2
height: 36
spacing: 6
QbzNavButton {
name: "panel-left"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.cycleSidebar()
}
QbzNavButton {
name: "chevron-left"
anchors.verticalCenter: parent.verticalCenter
btnEnabled: QbzShell.canBack
onClicked: QbzShell.navigateBack()
}
QbzNavButton {
name: "chevron-right"
anchors.verticalCenter: parent.verticalCenter
btnEnabled: QbzShell.canForward
onClicked: QbzShell.navigateForward()
}
// Compact section nav — only while the sidebar is fully closed
// (Cerrado), so the sections stay reachable. POC-NOTE: these open
// dropdown menus in Slint; here they are inert visual replicas.
Row {
visible: QbzShell.sidebarState === 2
height: parent.height
spacing: 2
Item { width: 6; height: 1 }
QbzIconButton { activeBackground: true
name: "compass"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
visible: !QbzSession.offline
}
QbzIconButton { activeBackground: true
name: "music-library-2"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
visible: !QbzSession.offline
}
QbzIconButton { activeBackground: true
name: "hard-drive"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
}
QbzIconButton { activeBackground: true
name: "qbz-symbolic"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
}
// Thin separator + the playlists flyout button.
Rectangle {
width: 1
height: 18
anchors.verticalCenter: parent.verticalCenter
color: theme.borderSubtle
}
QbzIconButton { activeBackground: true
name: "list-music"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
}
}
}
// --- Search (absolutely centered; LIVE — phase 15) ---------------------
// The Slint HeaderBar search-scope: typing drives the cortinilla (220ms
// debounce, >= 2 chars), arrows move the keyboard selection, Enter runs
// the row / full search, Esc dismisses. The × clears + closes.
function clearSearch() {
searchInput.text = ""
QbzBridge.cortinillaDismiss()
}
Rectangle {
id: searchBox
x: (root.width - width) / 2
y: (root.height - height) / 2
width: root.width < 960 ? 179 : 256
height: 32
radius: 6
border.width: 1
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
color: theme.surfaceElevated
QbzIcon {
name: "search"
width: 14
height: 14
x: 10
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 30
anchors.rightMargin: 8
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
horizontalAlignment: text === "" && !activeFocus ? Text.AlignHCenter : Text.AlignLeft
clip: true
onTextEdited: {
if (text.trim().length < 2) {
liveDebounce.stop()
QbzBridge.cortinillaDismiss()
} else {
liveDebounce.restart()
}
}
// 220ms live debounce (CORTINILLA_DEBOUNCE — one load per pause,
// not one per keystroke).
Timer {
id: liveDebounce
interval: 220
repeat: false
onTriggered: QbzBridge.searchLive(searchInput.text)
}
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
// a keyboard selection -> activate the row; open + none -> full
// search; closed -> plain submit (also Search > All).
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Down) {
if (QbzBridge.cortinillaOpen) {
QbzBridge.cortinillaMoveSelection(1)
event.accepted = true
}
} else if (event.key === Qt.Key_Up) {
if (QbzBridge.cortinillaOpen) {
QbzBridge.cortinillaMoveSelection(-1)
event.accepted = true
}
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (QbzBridge.cortinillaOpen) {
if (QbzBridge.selectedIndex >= 0) {
root.clearSearch()
QbzBridge.cortinillaRowClicked(QbzBridge.selectedIndex)
} else {
root.clearSearch()
QbzBridge.cortinillaSearchAll()
}
} else {
// Capture BEFORE clearing (clearSearch wipes the input).
var q = searchInput.text
root.clearSearch()
QbzBridge.searchSubmit(q)
}
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
if (QbzBridge.cortinillaOpen) {
QbzBridge.cortinillaDismiss()
event.accepted = true
}
}
}
}
// Placeholder (centered when empty + unfocused, left once typing).
Text {
visible: searchInput.text === "" && !searchInput.activeFocus
anchors.fill: parent
anchors.leftMargin: 30
text: QbzSession.tr("Search", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
// Right-edge affordances: the Enter hint while the cortinilla is
// open (Slint: it lives in the box, opposite the magnifier), else
// the × clear.
Text {
visible: QbzBridge.cortinillaOpen
anchors.right: parent.right
anchors.rightMargin: 10
height: parent.height
text: "⏎ " + QbzSession.tr("Enter", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
Rectangle {
visible: !QbzBridge.cortinillaOpen && searchInput.text !== ""
anchors.right: parent.right
anchors.rightMargin: 5
width: 22
height: 22
anchors.verticalCenter: parent.verticalCenter
radius: 11
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: 12
height: 12
anchors.centerIn: parent
tintName: clearArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clearSearch()
}
}
}
// --- Right controls: status badge + app menu --------------------------
Row {
id: rightControls
// Shifts left of the drawn window controls (3x34 + 2x2 = 106px).
x: root.width - width - theme.spacingMd + 2 - (QbzShell.systemTitleBar ? 0 : 110)
y: (root.height - height) / 2
height: 36
spacing: 4
// Offline status badge (OfflineStatusBadge) — ghost chrome like
// NavTab: transparent, hover -> surface-hover, radius sm.
Rectangle {
visible: root.badgeState !== 0
height: 30
width: badgeRow.implicitWidth
anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm
color: badgeArea.containsMouse ? theme.surfaceHover : "transparent"
readonly property string stateTintName: root.badgeState === 1 ? "warning"
: root.badgeState === 2 ? "accent" : "muted"
Row {
id: badgeRow
height: parent.height
leftPadding: 9
rightPadding: 11
spacing: 6
QbzIcon {
name: root.badgeState === 3 ? "user" : "cloud-off"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: parent.parent.stateTintName
}
Text {
height: parent.height
text: root.badgeState === 3 ? QbzSession.tr("Logged out", QbzSession.trRev)
: root.badgeState === 2 ? QbzSession.tr("Offline (manual)", QbzSession.trRev)
: QbzSession.tr("Offline (hard)", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: badgeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: badgeFlyout.open()
}
// Status flyout — the former banner texts + actions.
Popup {
id: badgeFlyout
x: parent.width - 320
y: parent.height + 6
width: 320
padding: 14
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
spacing: 12
Row {
spacing: 8
width: parent.width
QbzIcon {
name: root.badgeState === 3 ? "user" : "cloud-off"
width: 15
height: 15
tintName: "muted"
}
Text {
width: parent.width - 23
text: root.badgeState === 3
? (QbzSession.connectivity === 2
? QbzSession.tr("You're signed out. Sign-in needs a connection.", QbzSession.trRev)
: QbzSession.connectivity === 1
? QbzSession.tr("Connection available — sign back in to Qobuz.", QbzSession.trRev)
: QbzSession.tr("You're signed out — sign back in to Qobuz.", QbzSession.trRev))
: root.badgeState === 2
? QbzSession.tr("Offline mode is enabled. Disable it in Settings to use Qobuz.", QbzSession.trRev)
: QbzSession.tr("No internet connection — your local library and downloads keep working.", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
// Sign in — logged-out state only; disabled only when the
// link is CONFIRMED down.
Item {
visible: root.badgeState === 3
width: parent.width
height: visible ? 32 : 0
Rectangle {
anchors.right: parent.right
width: signInText.implicitWidth + 28
height: 32
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
opacity: QbzSession.connectivity === 2 ? 0.4 : 1.0
color: signInArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Text {
id: signInText
anchors.centerIn: parent
text: QbzSession.tr("Sign in", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: signInArea
anchors.fill: parent
enabled: QbzSession.connectivity !== 2
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzSession.recoveryLogin()
badgeFlyout.close()
}
}
}
}
// POC-NOTE: the manual-offline quick toggle (state 2)
// needs OfflineModeActions.set-offline — not wired; the
// status text above still renders.
}
}
}
QbzIconButton { activeBackground: true
name: "menu"
anchors.verticalCenter: parent.verticalCenter
onClicked: appMenu.open()
}
}
// --- Drawn window controls (WindowControls.slint, right placement:
// minimize · maximize · close; close gets the danger-red hover) ------
Row {
visible: !QbzShell.systemTitleBar
x: root.width - width - 8
y: (root.height - height) / 2
height: 26
spacing: 2
Rectangle {
width: 34
height: 26
radius: theme.radiusSm
color: wcMinArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "minus"
width: 14
height: 14
anchors.centerIn: parent
tintName: "secondary"
}
MouseArea {
id: wcMinArea
anchors.fill: parent
hoverEnabled: true
onClicked: if (root.hostWindow) root.hostWindow.showMinimized()
}
}
Rectangle {
width: 34
height: 26
radius: theme.radiusSm
color: wcMaxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.hostWindow && root.hostWindow.visibility === Window.Maximized
? "minimize-2" : "maximize-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "secondary"
}
MouseArea {
id: wcMaxArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (root.hostWindow) {
root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
}
}
Rectangle {
width: 34
height: 26
radius: theme.radiusSm
color: wcCloseArea.containsMouse ? "#e81123" : "transparent"
QbzIcon {
name: "x"
width: 14
height: 14
anchors.centerIn: parent
tintName: wcCloseArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: wcCloseArea
anchors.fill: parent
hoverEnabled: true
// POC-NOTE: the Slint app hides-to-tray on close; the POC
// has no tray — close quits.
onClicked: Qt.quit()
}
}
}
// --- App menu (user block + Settings + Log Out + Close) ---------------
// POC-NOTE: the Slint menu also carries Open Music Link / Keyboard
// Shortcuts / Documentation / Report an Issue / What's New / About QBZ
// — omitted here (no views/dialogs to open yet).
Popup {
id: appMenu
x: root.width - 234 - theme.spacingMd
y: theme.headerHeight - 4
width: 234
padding: 0
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
width: parent.width
topPadding: 6
bottomPadding: 6
// Signed-in user — name and subscription tier.
Column {
width: parent.width
leftPadding: 14
rightPadding: 14
topPadding: 6
bottomPadding: 10
spacing: 2
Text {
text: QbzSession.sessionUserName === ""
? QbzSession.tr("Guest", QbzSession.trRev) : QbzSession.sessionUserName
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
text: QbzSession.sessionSubscription === ""
? QbzSession.tr("Not signed in", QbzSession.trRev) : QbzSession.sessionSubscription
color: theme.textMuted
font.pixelSize: 12
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 4 }
// One menu row (MenuItem).
component AppMenuItem: Rectangle {
property string name: ""
property string label: ""
property bool checkedItem: false
signal clicked()
width: parent ? parent.width : 0
height: 34
color: miArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 18
spacing: 10
QbzIcon {
name: parent.parent.name
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
Text {
id: miLabel
height: parent.height
text: parent.parent.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
Item {
visible: parent.parent.checkedItem
width: visible ? parent.width - 15 - miLabel.implicitWidth - 14 - 2 * parent.spacing : 0
height: 1
}
QbzIcon {
visible: parent.parent.checkedItem
name: "check"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: miArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
AppMenuItem {
name: "search"
label: QbzSession.tr("Intelligent search", QbzSession.trRev)
checkedItem: QbzBridge.intelligentSearch
onClicked: QbzBridge.toggleIntelligentSearch()
}
AppMenuItem {
name: "layout-grid"
label: QbzSession.tr("Ambient background", QbzSession.trRev)
checkedItem: QbzShell.ambientMode > 0
onClicked: {
QbzShell.toggleAmbientBackground()
// Applies LIVE (pure QML layering); the check flips now.
}
}
AppMenuItem {
name: "layout-grid"
label: QbzSession.tr("Use system title bar", QbzSession.trRev)
checkedItem: QbzShell.systemTitleBarPref
onClicked: {
QbzShell.toggleSystemTitleBar()
// Keep the menu open so the check state reads; the
// applied mode changes on the next launch (1:1 Slint).
}
}
Text {
width: parent.width
leftPadding: 37
text: QbzSession.tr("Takes effect after restarting QBZ.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
}
AppMenuItem {
name: "settings-2"
label: QbzSession.tr("Settings", QbzSession.trRev)
onClicked: {
appMenu.close()
QbzShell.navigateTo("settings")
}
}
AppMenuItem {
name: "log-out"
label: QbzSession.tr("Log Out", QbzSession.trRev)
onClicked: {
appMenu.close()
QbzSession.logout()
}
}
AppMenuItem {
name: "x"
label: QbzSession.tr("Close", QbzSession.trRev)
// POC-NOTE: the Slint app hides to tray / follows the
// platform close behavior; the POC just quits.
onClicked: Qt.quit()
}
}
}
}
+338
View File
@@ -0,0 +1,338 @@
// 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
// (QbzPlayer.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
import "../theme"
Rectangle {
id: root
color: "transparent"
clip: true
readonly property var doc: JSON.parse(QbzShell.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 = (QbzPlayer.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: QbzSession.tr("LYRICS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
visible: QbzPlayer.npTitle !== ""
width: parent.width
text: QbzPlayer.npTitle + " - " + QbzPlayer.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: QbzShell.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: QbzSession.tr("Fetching lyrics...", QbzSession.trRev)
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: QbzSession.tr("No lyrics available", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
Column {
width: parent.width
spacing: 2
Text {
width: parent.width
text: QbzSession.tr("Missing lyrics? You can contribute them to LRCLIB:", QbzSession.trRev)
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: QbzSession.tr("Lyrics unavailable offline", QbzSession.trRev)
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 && QbzPlayer.npDurationSecs > 0)
QbzPlayer.seek(modelData.timeMs / 1000 / QbzPlayer.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: QbzSession.tr("Lyrics source", QbzSession.trRev) + ":"
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
}
}
}
+16
View File
@@ -0,0 +1,16 @@
// NowPlayingBar SHELL — the mode seam (NowPlayingBar.slint, phase 18):
// mounts NowPlayingBarSmall for mode 2 (Small) and the full PlayerBar for
// modes 0 (New) / 1 (Classic) / 3 (Large). AppShell pins the height
// mode-aware (42px Small / 112px otherwise).
import QtQuick
import com.blitzfc.qbz
Item {
id: root
Loader {
anchors.fill: parent
source: QbzShell.npbMode === 2 ? "NowPlayingBarSmall.qml" : "PlayerBar.qml"
}
}
@@ -0,0 +1,452 @@
// Bottom now-playing / transport bar — QML port of
// crates/qbz-ui/ui/shell/PlayerBarSmall.slint (Mode C "Small"). Mounted by
// the NowPlayingBar seam when npbMode == 2 (phase 18).
//
// 3px full-width top seekbar (3-color: background / cache / progress),
// then the symmetric 3-column controls row (~39px):
// LEFT — 37px album art + 2-line title/artist + fixed time column
// CENTER — transport cluster (info · shuffle · prev · play · next ·
// repeat · +)
// RIGHT — AudioStamp · Connect · Cast · Settings · ViewMode · Lyrics ·
// Volume · Queue
// Total height 42px (ShellState.npb-small-height; npb-small-extra is 0 on
// Linux).
//
// POC-NOTE: playback is not wired (phase 4) — the bar renders its EMPTY
// state (NowPlayingState.has-track == false): idle music-glyph art,
// "Nothing playing" title, transport disabled (opacity 0.3), no time
// column, no AudioStamp. Connect / Cast / Settings / ViewMode / Lyrics are
// inert visual replicas (their flyouts are out of scope); Volume, Queue,
// Shuffle, Repeat, Mute are live against the POC NowPlayingModel.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
// surface-card @ 0.5 while the ambient background is active (phase 14,
// PlayerBarSmall.slint).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
QbzTheme { id: theme }
// Responsive side fraction — IDENTICAL to PlayerBarSmall's mechanism:
// LEFT and RIGHT columns share this fraction so the CENTER transport
// column lands PLAY on the window centre.
// width < 1366 -> 39% | 22% | 39%
// 1366..<1920 -> 30% | 40% | 30%
// width >= 1920 -> 25% | 50% | 25%
property real sideFrac: root.width >= 1920 ? 0.25 : (root.width >= 1366 ? 0.30 : 0.39)
property real colCentre: 1.0 - 2.0 * root.sideFrac
// WIDE = inline horizontal volume slider; NARROW = button-only.
property bool volumeWide: root.width >= 1366
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
return m + ":" + (s < 10 ? "0" : "") + s
}
// Square compact icon button (BarControls.IconButton): disabled = dim
// 0.3 + non-clickable; active = accent tint.
// Thin vertical separator (PlayerBarSmall's Sep): a 1px line,
// border-subtle, ~60% of the row height, vertically centred; the
// horizontal gaps are the caller's.
component Sep: Item {
property int gapLeft: 6
property int gapRight: 6
width: gapLeft + 1 + gapRight
height: parent ? parent.height : 0
Rectangle {
x: gapLeft
width: 1
height: 24
anchors.verticalCenter: parent.verticalCenter
color: theme.borderSubtle
}
}
Column {
anchors.fill: parent
spacing: 0
// === A. TOP full-width seekbar (the content/bar divider) ========
Item {
width: parent.width
height: 3
Rectangle {
id: seekTrack
width: parent.width
height: 3
radius: 2
color: theme.surfaceElevated
// Buffered / cache line.
Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: theme.textMuted
opacity: 0.35
}
// Playback progress line.
Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
height: parent.height
radius: 2
color: theme.accent
}
// Hover thumb.
Rectangle {
width: 12
height: 12
radius: 6
color: theme.textPrimary
x: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1) - width / 2
anchors.verticalCenter: parent.verticalCenter
opacity: seekArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 100 } }
}
}
// Tall, easy-to-grab hit area over the thin line.
MouseArea {
id: seekArea
width: parent.width
height: 18
anchors.verticalCenter: seekTrack.verticalCenter
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1))
}
}
// === B. CONTROLS ROW — symmetric 3 columns =======================
Item {
width: parent.width
height: parent.height - 3
// ---- LEFT column: art + title/artist + time column ---------
Item {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: (root.width - 6) * root.sideFrac
clip: true
Row {
anchors.fill: parent
spacing: 0
// Song card: 37px art (3px surface-card border, flush to
// the window's left edge) + tight 2-line text block.
// Width = the column minus the Sep + time column, so the
// title elides at exactly the free space.
Item {
height: parent.height
width: parent.width - (QbzPlayer.npHasTrack ? 59 : 0)
Rectangle {
id: artBox
width: 37
height: 37
anchors.verticalCenter: parent.verticalCenter
radius: 6
border.width: 3
border.color: theme.surfaceCard
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: 6
}
// Idle (no track): a STATIC album glyph
// placeholder (50% of the art size, muted).
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: 18.5
height: 18.5
anchors.centerIn: parent
tintName: "muted"
}
}
// Title + artist — constrained to the art's inner
// height (37 − 2×3 = 31px) and vertically centred
// (SongCard text-center).
Column {
id: metaBlock
width: Math.max(0, parent.width - 37 - 9)
height: 31
anchors.left: artBox.right
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: QbzPlayer.npHasTrack
? QbzPlayer.npTitle : QbzSession.tr("Nothing playing", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
visible: QbzPlayer.npHasTrack
width: parent.width
text: QbzPlayer.npArtist
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
}
// Separator: song-card | time column (8px toward the
// card, 10px toward the timer).
Sep {
visible: QbzPlayer.npHasTrack
gapLeft: 8
gapRight: 10
}
// Time column — fixed 40px 2-row block (elapsed /
// total), monospace for stable digit positions.
Column {
visible: QbzPlayer.npHasTrack
width: 40
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
text: root.fmt(QbzPlayer.npElapsedSecs)
font.family: "monospace"
font.pixelSize: 10
color: theme.textMuted
}
Text {
text: root.fmt(QbzPlayer.npDurationSecs)
font.family: "monospace"
font.pixelSize: 10
color: theme.textMuted
}
}
}
}
// ---- CENTER column: transport, window-centred --------------
Item {
anchors.left: parent.left
anchors.leftMargin: (root.width - 6) * root.sideFrac
anchors.top: parent.top
anchors.bottom: parent.bottom
width: (root.width - 6) * root.colCentre
clip: true
Row {
anchors.centerIn: parent
spacing: 2
QbzIconButton {
name: "info"
btnEnabled: QbzPlayer.npHasTrack
// POC-NOTE: track-info panel is out of scope.
}
QbzIconButton {
name: "shuffle"
active: QbzPlayer.npShuffle
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.toggleShuffle()
}
QbzIconButton {
name: "skip-back"
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.previous()
}
// Play/pause — the Classic plain glyph (play-circle:
// false in Small): 34px, 20px glyph, text-primary,
// accent on hover.
Rectangle {
width: 34
height: 34
radius: theme.radiusSm
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: (playArea.containsMouse && QbzPlayer.npHasTrack)
? theme.surfaceHover : "transparent"
QbzIcon {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 20
height: 20
anchors.centerIn: parent
tintName: playArea.containsMouse ? "accent" : "primary"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack) QbzPlayer.togglePlay()
}
}
QbzIconButton {
name: "skip-forward"
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.next()
}
QbzIconButton {
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
active: QbzPlayer.npRepeatMode !== 0
btnEnabled: QbzPlayer.npHasTrack
onClicked: QbzPlayer.cycleRepeat()
}
QbzIconButton {
name: "plus"
btnEnabled: QbzPlayer.npHasTrack
// POC-NOTE: the grouped "Add to…" flyout is out of
// scope.
}
}
}
// ---- RIGHT column: the control SET, right-aligned ----------
// Order (PlayerBarSmall mount order): AudioStamp · Connect ·
// Cast · Settings · ViewMode · Lyrics · Volume · Queue.
Item {
anchors.right: parent.right
anchors.rightMargin: 6
anchors.top: parent.top
anchors.bottom: parent.bottom
width: (root.width - 6) * root.sideFrac
clip: true
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: parent.height
spacing: 1
// AudioStamp — tier + detail line, width-clamped.
// POC-NOTE: row 2 (backend/mode LEDs) is not rendered —
// the POC model carries no output-backend state.
Column {
visible: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Row {
spacing: 5
layoutDirection: Qt.RightToLeft
Text {
text: QbzPlayer.npQualityLabel
font.pixelSize: 9
font.weight: theme.weightSemibold
color: theme.textPrimary
elide: Text.ElideRight
}
Text {
text: QbzPlayer.npQualityTier === "hires" ? "HI-RES"
: QbzPlayer.npQualityTier === "mp3" ? "MP3"
: QbzPlayer.npQualityTier === "lossless" ? "LOSSLESS" : "CD"
font.pixelSize: 9
font.weight: theme.weightBold
font.letterSpacing: 0.3
color: theme.textSecondary
}
}
}
// Separator: AudioStamp | icon cluster (10px toward the
// stamp, 8px toward the buttons).
Sep {
visible: QbzPlayer.npHasTrack
gapLeft: 10
gapRight: 8
}
// POC-NOTE: Connect / Cast / Settings / ViewMode /
// Lyrics are inert visual replicas — their flyouts
// (device pickers, audio toggles, NPB-mode menu) are
// out of scope for the POC.
QbzIconButton { name: "monitor-speaker" }
QbzIconButton { name: "cast" }
QbzIconButton { name: "settings-2" }
QbzIconButton {
id: smallViewBtn
name: "layout-grid"
active: smallViewMenu.opened
onClicked: smallViewMenu.openBelowRight(smallViewBtn)
}
QbzIconButton {
name: "mic-vocal"
active: QbzShell.lyricsOpen
onClicked: QbzShell.toggleLyrics()
}
Item { width: 4; height: 1 }
// Volume — mute icon + (WIDE) inline horizontal slider.
QbzIconButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
active: QbzPlayer.npMuted
onClicked: QbzPlayer.toggleMute()
}
Item {
visible: root.volumeWide
width: 72
height: 32
Rectangle {
id: volTrack
width: parent.width
height: 4
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Rectangle {
width: parent.width * QbzPlayer.npVolume
height: parent.height
radius: 2
color: theme.accent
}
Rectangle {
width: 16
height: 16
radius: 8
color: theme.textPrimary
x: parent.width * QbzPlayer.npVolume - width / 2
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: QbzPlayer.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
onPositionChanged: if (pressed)
QbzPlayer.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
}
}
Item { width: 4; height: 1 }
// Queue panel toggle.
QbzIconButton {
name: "list-ordered"
active: QbzShell.queueOpen
onClicked: QbzShell.toggleQueue()
}
}
}
}
}
// The Now-Playing-view mode menu (phase 18 — shared with PlayerBar).
ViewModeMenu { id: smallViewMenu }
}
+577
View File
@@ -0,0 +1,577 @@
// PlayerBar — the full 112px now-playing bar for modes 0 (New), 1
// (Classic) and 3 (Large) — QML port of crates/qbz-ui/ui/shell/
// PlayerBar.slint (phase 18). Mode 2 (Small) mounts NowPlayingBarSmall
// instead (the NowPlayingBar seam picks the body).
//
// Composition (1:1): a full-width SeekBar with times (6px margins; in
// Large the left edge insets by the sidebar width so the seek arm clears
// the docked cover), then the controls row in three responsive symmetric
// columns (the side columns share a fraction so PLAY lands on the window
// centre at every width):
// width < 1366 -> 39% | 22% | 39%
// 1366..<1920 -> 30% | 40% | 30%
// width >= 1920 -> 25% | 50% | 25%
// Classic uses a fixed 32.4/35.2/32.4 split (col-side 0.324).
//
// New (0): LEFT SongCard · CENTER transport (filled disc) · RIGHT cluster
// Classic (1): LEFT transport · CENTER glass SongCard (<=560px) · RIGHT cluster
// Large (3): LEFT SongCard WITHOUT the cover (it lives in the sidebar
// dock, shifted right to clear it) · CENTER transport ·
// RIGHT small AudioStamp + cluster.
//
// Wired: transport (shuffle/prev/play/next/repeat), seek, volume+mute,
// lyrics, queue, the Now-Playing-view flyout (npbSetMode), open
// album/artist via the SongCard meta links. Inert (POC-NOTEs): Connect
// (device flyout), track-info, the dot-LED output badges use the real
// backend state but the volume LOCK (ALSA hw / remote) is not enforced.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
readonly property bool isClassic: QbzShell.npbMode === 1
QbzTheme { id: theme }
// Responsive zones (PlayerBar.slint). Classic fixes col-side at 0.324;
// New/Large use the width-driven side fraction. Both keep the two side
// columns EQUAL so the centre column stays dead centre.
property real sideFrac: root.width >= 1920 ? 0.25 : (root.width >= 1366 ? 0.30 : 0.39)
property real colSide: isClassic ? 0.324 : sideFrac
property real colCentre: 1.0 - 2.0 * colSide
// The docked-cover width the Large seek arm + song card must clear
// (sidebar rendered width; 240 when open).
readonly property int dockWidth: largeActive ? 240 : 0
readonly property var settingsDoc: parseSettings()
function parseSettings() {
try {
return JSON.parse(QbzBridge.settingsJson)
} catch (e) {
return ({})
}
}
readonly property bool backendPipewire: settingsDoc.backendIsPipewire === true
readonly property bool dacPassthrough: settingsDoc.dacPassthrough === true
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
return m + ":" + (s < 10 ? "0" : "") + s
}
// --- Shared bits -------------------------------------------------------
// Square compact icon button (BarControls.IconButton).
// Dot LED (circle-dot on / circle off) + constant-colour label — the
// New-mode output indicators (SongCard dot-leds).
component DotLed: Row {
property string label: ""
property bool on: false
spacing: 4
height: 12
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: parent.on ? theme.accent : "transparent"
border.width: 1
border.color: parent.on ? theme.accent : theme.textMuted
}
Text {
text: parent.label
color: theme.textMuted
font.pixelSize: 9
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
}
// The audio stamp (quality badge + dot LEDs) — New mode keeps the full
// stamp inside the SongCard; Large mounts a compact one on the right
// column's left wall.
component AudioStamp: Row {
property bool showLeds: true
spacing: 8
height: 30
layoutDirection: Qt.RightToLeft
Row {
spacing: 5
layoutDirection: Qt.RightToLeft
anchors.verticalCenter: parent.verticalCenter
Text {
visible: QbzPlayer.npQualityLabel !== ""
text: QbzPlayer.npQualityLabel
font.pixelSize: 9
font.weight: theme.weightSemibold
color: theme.textPrimary
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
QualityMini {
// The stamp shows the cd glyph for ANY non-hires tier with
// a label (not just "cd") — map that exactly.
tier: QbzPlayer.npQualityTier === "hires" ? "hires"
: (QbzPlayer.npQualityLabel !== "" ? "cd" : "")
anchors.verticalCenter: parent.verticalCenter
}
}
Column {
visible: showLeds
anchors.verticalCenter: parent.verticalCenter
spacing: 1
DotLed { label: "PIPEWIRE"; on: root.backendPipewire }
DotLed { label: "DACPASS"; on: root.dacPassthrough }
}
}
// SongCard (SongCard.slint): cover + title + context meta + the in-card
// audio stamp. `glass` renders the Classic contained card.
component SongCard: Rectangle {
property bool showArt: true
property bool showBadges: true
property bool glass: false
property int artSize: glass ? 60 : 74
width: 200
height: glass ? 64 : 74
radius: glass ? 8 : 0
color: ambientOn ? "transparent" : (glass ? theme.surfaceElevated : "transparent")
Row {
anchors.left: parent.left
anchors.right: stamp.left
anchors.rightMargin: 4
height: parent.height
spacing: 0
Item {
visible: showArt
width: showArt ? artSize : 0
height: parent.height
Rectangle {
width: artSize
height: artSize
anchors.centerIn: parent
radius: 6
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: 6
}
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: artSize * 0.5
height: artSize * 0.5
anchors.centerIn: parent
tintName: "muted"
}
// Fetch overlay (resolving/downloading).
Rectangle {
visible: QbzPlayer.npHasTrack && QbzPlayer.npLoading
anchors.fill: parent
radius: 6
color: "#aa000000"
QbzSpinner {
width: artSize * 0.5
height: artSize * 0.5
anchors.centerIn: parent
size: artSize * 0.5
}
}
}
}
Item { width: showArt ? 11 : 0; height: 1 }
Column {
width: parent.width - (showArt ? artSize + 11 : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: QbzPlayer.npHasTrack ? QbzPlayer.npTitle : QbzSession.tr("Nothing playing", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Row {
visible: QbzPlayer.npHasTrack
spacing: 7
height: 18
// Context-stack icon ("Show track playing context" pref).
Rectangle {
visible: QbzPlayer.showContextIcon
width: 16
height: 18
radius: 3
color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "layers"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: ctxArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: ctxArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzBridge.openAlbum(QbzPlayer.npAlbumId)
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npArtist
color: artistArea.containsMouse && QbzPlayer.npArtistId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npArtistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npArtistId !== "") QbzBridge.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
width: 3
height: 3
radius: 1.5
color: theme.textMuted
anchors.verticalCenter: parent.verticalCenter
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npAlbum
color: albumArea.containsMouse && QbzPlayer.npAlbumId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: albumArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npAlbumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzBridge.openAlbum(QbzPlayer.npAlbumId)
}
}
}
}
}
AudioStamp {
id: stamp
visible: showBadges && QbzPlayer.npHasTrack
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
}
}
// Transport cluster (TransportControls.slint): shuffle · prev · play ·
// next · repeat. play-circle = the filled 44px accent disc (New/Large);
// classic-actions = plain glyphs, left-aligned (Classic).
component TransportControls: Row {
property bool playCircle: true
spacing: playCircle ? 12 : 4
height: 44
QbzIconButton {
name: "shuffle"
active: QbzPlayer.npShuffle
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleShuffle()
}
QbzIconButton {
name: "skip-back"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.previous()
}
Rectangle {
width: playCircle ? 44 : 32
height: playCircle ? 44 : 32
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
opacity: QbzPlayer.npHasTrack ? 1.0 : 0.3
color: playCircle ? (playArea.containsMouse && QbzPlayer.npHasTrack ? theme.accentHover : theme.accent)
: ((playArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent")
QbzIcon {
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
width: playCircle ? 20 : 16
height: playCircle ? 20 : 16
anchors.centerIn: parent
tintName: playCircle ? "black" : "primary"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npHasTrack) QbzPlayer.togglePlay()
}
}
QbzIconButton {
name: "skip-forward"
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.next()
}
QbzIconButton {
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
active: QbzPlayer.npRepeatMode > 0
btnEnabled: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.cycleRepeat()
}
}
// ============================ the bar =================================
Column {
anchors.fill: parent
spacing: 0
Item { width: 1; height: 6 }
// Full-width seek bar (times at the ends). In Large the seek arm is
// LEFT-INSET by the docked-cover width so it begins right of the
// dock (AppShell.slint's large-active padding).
Item {
width: parent.width
height: 20
Text {
x: root.dockWidth > 0 ? root.dockWidth + 6 : 6
anchors.verticalCenter: parent.verticalCenter
visible: QbzPlayer.npHasTrack
text: root.fmt(QbzPlayer.npElapsedSecs)
color: theme.textMuted
font.pixelSize: 11
}
Rectangle {
id: seekTrack
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: (root.dockWidth > 0 ? root.dockWidth + 6 : 6) + (QbzPlayer.npHasTrack ? 46 : 0)
anchors.rightMargin: 6 + (QbzPlayer.npHasTrack ? 46 : 0)
height: 4
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: theme.borderMuted
}
Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
height: parent.height
radius: 2
color: theme.accent
}
Rectangle {
visible: QbzPlayer.npHasTrack
width: 12
height: 12
radius: 6
color: theme.textPrimary
x: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1) - width / 2
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
visible: QbzPlayer.npHasTrack
text: "-" + root.fmt(Math.max(0, QbzPlayer.npDurationSecs - QbzPlayer.npElapsedSecs))
color: theme.textMuted
font.pixelSize: 11
}
MouseArea {
anchors.left: seekTrack.left
anchors.right: seekTrack.right
anchors.top: parent.top
anchors.bottom: parent.bottom
cursorShape: QbzPlayer.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: if (QbzPlayer.npHasTrack) QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1))
onPositionChanged: if (pressed && QbzPlayer.npHasTrack) QbzPlayer.seek(Math.min(Math.max(mouseX / width, 0), 1))
}
}
Item { width: 1; height: 6 }
// --- Controls: the responsive symmetric zones -----------------------
Item {
width: parent.width
height: parent.height - 38
// LEFT column.
Item {
anchors.left: parent.left
anchors.leftMargin: 6
width: (parent.width - 12) * root.colSide
height: parent.height
clip: true
// New (0) AND Large (3): the song card (Large drops the cover
// — it lives in the dock — and shifts right to clear it).
SongCard {
visible: !root.isClassic
x: root.largeActive ? root.dockWidth + 8 : 0
width: parent.width - x
anchors.verticalCenter: parent.verticalCenter
showArt: !root.largeActive
showBadges: !root.largeActive
}
// Classic: transport cluster hugging the left edge (plain
// play glyph, Tauri arrangement).
TransportControls {
visible: root.isClassic
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
playCircle: false
}
}
// CENTRE column.
Item {
x: 6 + (parent.width - 12) * root.colSide
width: (parent.width - 12) * root.colCentre
height: parent.height
clip: true
// New (0) AND Large (3): centred transport, PLAY on the
// window centre.
TransportControls {
visible: !root.isClassic
anchors.centerIn: parent
playCircle: true
}
// Classic: the contained glass song card (<=560px cap).
SongCard {
visible: root.isClassic
glass: true
width: Math.min(parent.width, 560)
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
}
}
// RIGHT column — secondary actions + volume, clustered at the
// right wall.
Item {
anchors.right: parent.right
anchors.rightMargin: 6
width: (parent.width - 12) * root.colSide
height: parent.height
clip: true
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 1
// Large: the SMALL AudioStamp just LEFT of the icon
// cluster at a fixed 12px gap (the badges move out of the
// song card in Large).
AudioStamp {
visible: root.largeActive && QbzPlayer.npHasTrack
showLeds: false
anchors.verticalCenter: parent.verticalCenter
}
Item { visible: root.largeActive; width: 12; height: 1 }
// Qobuz Connect — inert (device flyout out of scope).
QbzIconButton { name: "element-connect" }
// Now-Playing view — the mode flyout (phase 18).
QbzIconButton {
name: "layout-grid"
active: viewMenu.opened
onClicked: viewMenu.openBelowRight(viewBtn)
id: viewBtn
}
// Lyrics.
QbzIconButton {
name: "mic-vocal"
active: QbzShell.lyricsOpen
onClicked: QbzShell.toggleLyrics()
}
Item { width: 6; height: 1 }
// Volume.
QbzIconButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
active: QbzPlayer.npMuted
onClicked: QbzPlayer.toggleMute()
}
Item {
width: 81
height: 32
Rectangle {
id: volTrack
width: parent.width
height: 4
radius: 2
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Rectangle {
width: parent.width * QbzPlayer.npVolume
height: parent.height
radius: 2
color: theme.accent
}
Rectangle {
width: 16
height: 16
radius: 8
color: theme.textPrimary
x: parent.width * QbzPlayer.npVolume - width / 2
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: QbzPlayer.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
onPositionChanged: if (pressed)
QbzPlayer.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
}
}
Item { width: 6; height: 1 }
// Queue panel toggle.
QbzIconButton {
name: "list-ordered"
active: QbzShell.queueOpen
onClicked: QbzShell.toggleQueue()
}
}
}
}
Item { width: 1; height: 6 }
}
// The Now-Playing-view mode menu (shared with the Small bar).
ViewModeMenu { id: viewMenu }
}
+694
View File
@@ -0,0 +1,694 @@
// 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: QbzQueue.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
import "../controls"
import "../theme"
Rectangle {
id: root
color: "transparent"
clip: true
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
readonly property var doc: JSON.parse(QbzQueue.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) QbzShell.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).
// 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 && QbzPlayer.npTrackId !== "" && QbzPlayer.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
RoundedImage {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
radius: 4
}
}
// 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: function (mouse) { rowMenu.openAtCursor(menuArea, mouse.x, mouse.y) }
}
QbzContextMenu {
id: rowMenu
menuWidth: 196
Repeater {
model: [
{ "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" },
{ "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" },
{ "label": row.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "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") QbzQueue.queueRemoveUpcoming(rowIndex)
else if (a === "remove-after") QbzQueue.queueRemoveAllAfter(rowIndex)
else if (a === "favorite") {
row.isFavorite = !row.isFavorite
QbzQueue.queueToggleFavorite("track", row.id)
}
}
}
}
}
}
}
}
MouseArea {
id: qrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (inQueue) QbzQueue.queuePlayUpcoming(rowIndex)
else QbzQueue.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: QbzSession.tr("Queue", QbzSession.trRev)
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: QbzSession.tr("History", QbzSession.trRev)
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: QbzShell.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: QbzSession.tr("NOW PLAYING", QbzSession.trRev)
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
RoundedImage {
anchors.fill: parent
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
radius: 4
}
}
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
QbzQueue.queueToggleFavorite("track", root.currentRow.id)
}
}
}
}
}
}
}
// UP NEXT section.
Column {
visible: (doc.upcomingTotal || 0) > 0
width: parent.width - 20
spacing: 8
Text {
text: QbzSession.tr("UP NEXT", QbzSession.trRev)
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"
? QbzSession.tr("Next in queue", QbzSession.trRev) : QbzSession.tr("Next up", QbzSession.trRev)
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)))
QbzQueue.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
QbzIconButton { btnSize: 30
name: "chevron-left"
iconSize: 15
btnEnabled: (doc.page || 0) > 0
onClicked: QbzQueue.queueSetPage((doc.page || 0) - 1)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Page {} of {}", QbzSession.trRev).replace("{}", (doc.page || 0) + 1).replace("{}", doc.pageCount || 1)
color: theme.textMuted
font.pixelSize: 12
}
QbzIconButton { btnSize: 30
name: "chevron-right"
iconSize: 15
btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1
onClicked: QbzQueue.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: QbzSession.tr("Your queue is empty", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzSession.tr("Play an album or track to get started", QbzSession.trRev)
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: QbzSession.tr("RECENTLY PLAYED", QbzSession.trRev)
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: QbzSession.tr("Nothing played yet", QbzSession.trRev)
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
? QbzSession.tr("Showing {} of {} track.", QbzSession.trRev)
: QbzSession.tr("Showing {} of {} tracks.", QbzSession.trRev))
.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).
QbzIconButton { btnSize: 30
name: "trash-list"
onClicked: QbzQueue.queueClear()
}
// Action 2 — Save as playlist (INERT: opens a picker modal
// upstream — POC-NOTE).
QbzIconButton { btnSize: 30; name: "add-to-list" }
// Action 3 — infinite play (INERT engine — POC-NOTE).
QbzIconButton { btnSize: 30; name: "infinity" }
// Action 4 — sleep timer (INERT engine — POC-NOTE).
QbzIconButton { btnSize: 30; 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: QbzQueue.queueSetSearch(text)
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search queue", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
}
}
}
+634
View File
@@ -0,0 +1,634 @@
// Left navigation sidebar — QML port of crates/qbz-ui/ui/shell/Sidebar.slint.
//
// Three states (ShellState.sidebar-state): 0 = open 240px (icon + label),
// 1 = mini 64px (icons only), 2 = closed 0px. Width animates 160ms
// ease-in-out; the header's panel-left button cycles (QbzShell.cycleSidebar).
//
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
// labels, surface-hover on hover, Discover + Library HIDDEN while offline
// (ADR-010 mount-site gating).
//
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
// flyouts are out of scope — rows here navigate (only "home" exists, so
// only Discover is live) and carry the SidebarDirectRow active treatment
// (surface-hover bg + primary text/icon) for the current section.
// POC-NOTE: the playlist/folder tree below the nav is out of scope; the
// "PLAYLISTS" header + toolbar render for parity and the Slint empty state
// shows ("No playlists yet.").
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool mini: QbzShell.sidebarState === 1
// The current section — Discover (home) is the only real view.
property string activeNav: "discover"
// Playlist tree state (phase 7).
property bool searchOpen: false
property bool playlistsCollapsed: false
property string activePlaylistId: ""
// Flattened entries from the bridge + the url-keyed cover map.
readonly property var entries: parseEntries(QbzShell.sidebarJson)
property var coverMap: ({})
function parseEntries(json) {
var e = JSON.parse(json)
// Collect every cover url and dispatch the artwork window.
var urls = []
for (var i = 0; i < e.length; i++) {
for (var j = 0; j < e[i].covers.length; j++) urls.push(e[i].covers[j])
}
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
return e
}
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
QbzTheme { id: theme }
width: QbzShell.sidebarState === 2 ? 0
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
: theme.sidebarOpenWidth
// surface-card @ 0.5 while the ambient background is active (phase 14).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Square edges; clip keeps content from spilling while the width
// animates (same as the Slint root).
clip: true
Behavior on width {
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
}
// One section-nav row (SidebarNavRow / SidebarDirectRow metrics).
component NavRow: Rectangle {
id: navRow
property string navId: ""
property string name: ""
property string label: ""
property bool active: root.activeNav === navId
signal clicked()
width: parent ? parent.width : 0
height: 34
radius: 6
color: (navArea.containsMouse || active) ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: root.mini ? 0 : 8
anchors.rightMargin: root.mini ? 0 : 8
spacing: 10
Item {
width: root.mini ? parent.width : 16
height: parent.height
QbzIcon {
name: navRow.name
width: 16
height: 16
anchors.centerIn: parent
tintName: (navArea.containsMouse || navRow.active)
? "primary" : "secondary"
}
}
Text {
visible: !root.mini
height: parent.height
width: parent.width - (root.mini ? 0 : 26)
text: navRow.label
color: (navArea.containsMouse || navRow.active)
? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: navArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: navRow.clicked()
}
}
Column {
anchors.fill: parent
anchors.leftMargin: root.mini ? 8 : theme.spacingMd
anchors.rightMargin: root.mini ? 8 : theme.spacingMd
anchors.topMargin: root.mini ? 8 : theme.spacingMd
anchors.bottomMargin: root.mini ? 8 : theme.spacingMd
spacing: theme.spacingMd
// ---- Section nav -------------------------------------------
Column {
id: navColumn
width: parent.width
spacing: 2
// Qobuz-only sections — HIDDEN entirely while offline (ADR-010).
NavRow {
navId: "discover"
name: "compass"
label: QbzSession.tr("Discover", QbzSession.trRev)
visible: !QbzSession.offline
onClicked: {
root.activeNav = "discover"
QbzShell.navigateTo("home")
}
}
NavRow {
navId: "library"
name: "music-library-2"
label: QbzSession.tr("Library", QbzSession.trRev)
visible: !QbzSession.offline
onClicked: {
root.activeNav = "library"
QbzShell.navigateTo("library")
}
}
NavRow {
navId: "local"
name: "hard-drive"
label: QbzSession.tr("Local Library", QbzSession.trRev)
onClicked: root.activeNav = "local"
}
NavRow {
navId: "myqbz"
name: "qbz-symbolic"
// Slint: MyQbzBrandingState.label, default "My QBZ".
label: QbzSession.tr("My QBZ", QbzSession.trRev)
onClicked: root.activeNav = "myqbz"
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// ---- Playlists header toolbar (hidden in the mini state) -----
Row {
visible: !root.mini
width: parent.width
height: 22
spacing: 4
// Title (hidden while the search input is open).
Text {
visible: !root.searchOpen
width: parent.width - 4 * 26
height: parent.height
text: QbzSession.tr("PLAYLISTS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.letterSpacing: 1
verticalAlignment: Text.AlignVCenter
}
// Inline search input (filters entries, recursive).
Rectangle {
visible: root.searchOpen
width: parent.width - 4 * 26
height: 22
radius: 4
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: searchEdit
anchors.fill: parent
anchors.leftMargin: 6
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzShell.sidebarSearch(text)
Text {
visible: searchEdit.text === ""
anchors.fill: parent
text: QbzSession.tr("Search playlists", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
// Search toggle.
Rectangle {
width: 22
height: 22
radius: 4
color: (searchBtnArea.containsMouse || root.searchOpen) ? theme.surfaceHover : "transparent"
QbzIcon {
name: "search"
width: 15
height: 15
anchors.centerIn: parent
tintName: (searchBtnArea.containsMouse || root.searchOpen) ? "primary" : "muted"
}
MouseArea {
id: searchBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.searchOpen = !root.searchOpen
if (!root.searchOpen) {
searchEdit.text = ""
QbzShell.sidebarSearch("")
} else {
searchEdit.forceActiveFocus()
}
}
}
}
// New playlist (+) — single core call (create_playlist), then
// reload the tree. Slint opens a naming modal; the POC creates
// with the default name (POC-NOTE).
Rectangle {
width: 22
height: 22
radius: 4
color: plusArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "plus"
width: 16
height: 16
anchors.centerIn: parent
tintName: plusArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: plusArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.createPlaylist()
}
}
// Sort / more (...) menu.
Rectangle {
width: 22
height: 22
radius: 4
color: sortBtnArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "ellipsis"
width: 15
height: 15
anchors.centerIn: parent
tintName: sortBtnArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: sortBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: sortMenu.open()
}
Popup {
id: sortMenu
// Opens to the RIGHT of the "..." (overlay into the
// content area) — owner request in Sidebar.slint.
x: parent.width + 18
y: 26
width: 200
padding: 6
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceCard
radius: 8
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Column {
spacing: 1
Row {
leftPadding: 8
topPadding: 4
bottomPadding: 2
spacing: 6
QbzIcon { name: "arrow-up-down"; width: 13; height: 13; tintName: "muted" }
Text {
text: QbzSession.tr("Sort by", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
}
}
Repeater {
model: [
{ "opt": "name", "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev) },
{ "opt": "recent", "label": QbzSession.tr("Recent", QbzSession.trRev) },
{ "opt": "tracks", "label": QbzSession.tr("# of tracks", QbzSession.trRev) },
{ "opt": "playcount", "label": QbzSession.tr("Play Count", QbzSession.trRev) },
{ "opt": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 30
radius: 5
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 6
Text {
width: parent.width - 20
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: QbzShell.sidebarSortBy === modelData.opt ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: QbzShell.sidebarSortBy === modelData.opt
name: QbzShell.sidebarSortAsc ? "chevron-up" : "chevron-down"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: sortOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzShell.sidebarSetSort(modelData.opt)
sortMenu.close()
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// New folder / Manage / Import — visual stubs
// (POC-NOTE: folder management + importer are out
// of scope).
Repeater {
model: [
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
{ "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev) },
{ "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 30
radius: 5
color: stubArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: stubArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
}
}
}
}
}
// Collapse chevron (down = expanded, right = collapsed).
Rectangle {
width: 22
height: 22
radius: 4
color: collapseArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.playlistsCollapsed ? "chevron-right" : "chevron-down"
width: 15
height: 15
anchors.centerIn: parent
tintName: collapseArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: collapseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.playlistsCollapsed = !root.playlistsCollapsed
}
}
}
// ---- Playlist tree (folders + playlists, sidebar_qt.rs) --------
Item {
visible: !root.playlistsCollapsed
width: parent.width
height: parent.height - y - 16
Flickable {
id: plFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: plColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: plColumn
width: parent.width
spacing: 2
Repeater {
model: root.entries
delegate: Rectangle {
required property var modelData
property bool isFolder: modelData.kind === "folder"
property bool isActive: !isFolder && modelData.id === root.activePlaylistId
// Track-drag drop target (Sidebar.slint SidebarRow):
// the row self-detects the drop from the shared
// window-coord pointer and claims/releases it.
property bool dropHot: false
function recomputeDrop() {
if (isFolder || !QbzShell.dragActive) {
if (dropHot) { QbzShell.dragSetOver(""); dropHot = false }
return
}
const tl = mapToItem(null, 0, 0)
const hot = QbzShell.dragX >= tl.x && QbzShell.dragX <= tl.x + width
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= tl.y + height
if (hot && !dropHot) QbzShell.dragSetOver(modelData.id)
else if (!hot && dropHot) QbzShell.dragSetOver("")
dropHot = hot
}
Connections {
target: QbzShell
function onDragXChanged() { recomputeDrop() }
function onDragYChanged() { recomputeDrop() }
function onDragActiveChanged() { recomputeDrop() }
}
width: plColumn.width
height: root.mini && modelData.indent ? 0 : 32
visible: !(root.mini && modelData.indent)
radius: 6
// success-bg + success-border while hot (Theme
// success family, #3fae6a at 10% / 30%).
color: dropHot ? "#3fae6a1a"
: ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent")
border.width: dropHot ? 1 : 0
border.color: "#3fae6a4d"
Row {
anchors.fill: parent
anchors.leftMargin: root.mini ? 0 : (modelData.indent ? 24 : 8)
anchors.rightMargin: root.mini ? 0 : 6
spacing: 8
// Leading icon slot (24px fixed).
Item {
width: 24
height: parent.height
// Folder icon (accent).
QbzIcon {
visible: isFolder
name: modelData.expanded && !root.mini ? "folder-open" : "folder"
width: 15
height: 15
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -4.5
tintName: "accent"
}
// 2x2 micro-collage (or list-music glyph).
Rectangle {
visible: !isFolder && modelData.covers.length > 0
width: 20
height: 20
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -2
radius: 3
color: theme.surfaceElevated
clip: true
Grid {
anchors.fill: parent
columns: 2
Repeater {
model: modelData.covers.slice(0, 4)
delegate: Image {
required property string modelData
width: 10
height: 10
source: root.coverMap[modelData] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
}
}
QbzIcon {
visible: !isFolder && modelData.covers.length === 0
name: "list-music"
width: 15
height: 15
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -4.5
tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
}
}
// Name (hidden in mini).
Text {
visible: !root.mini
width: parent.width - 24 - (isFolder ? 22 : 0)
height: parent.height
text: modelData.name
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: isFolder ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Folder count (hover) + chevron.
Text {
visible: !root.mini && isFolder && modelData.count > 0
height: parent.height
text: modelData.count
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
opacity: rowArea.containsMouse ? 1.0 : 0.0
}
QbzIcon {
visible: !root.mini && isFolder
name: modelData.expanded ? "chevron-down" : "chevron-right"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: rowArea.containsMouse ? "primary" : "muted"
}
}
// Active-row redundant shape cue: 3px accent bar.
Rectangle {
visible: isActive
x: 0
width: 3
height: parent.height - 12
anchors.verticalCenter: parent.verticalCenter
radius: 1.5
color: theme.accent
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (isFolder) {
QbzShell.sidebarToggleFolder(modelData.id)
} else {
root.activePlaylistId = modelData.id
QbzBridge.openPlaylist(modelData.id)
}
}
}
}
}
// Empty state.
Text {
visible: !root.mini && root.entries.length === 0 && !QbzSession.offline
width: plColumn.width
text: QbzSession.tr("No playlists yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
}
// Fill the rest.
Item { width: 1; height: 0; }
}
}
+114
View File
@@ -0,0 +1,114 @@
// Now-Playing-view mode menu (PlayerBar.slint layout-menu, phase 18) —
// one QbzContextMenu shared by the full PlayerBar and the Small bar:
// New / Classic / Small / Large (with the current mode checked), then the
// inert window-mode rows (Miniplayer / Immersive / Kiosk — the Slint has
// them; the POC has no such surfaces, POC-NOTE).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
QbzContextMenu {
id: root
menuWidth: 196
Repeater {
model: [
{ "label": QbzSession.tr("New", QbzSession.trRev), "icon": "panel-left", "mode": 0 },
{ "label": QbzSession.tr("Classic", QbzSession.trRev), "icon": "panel-right-close", "mode": 1 },
{ "label": QbzSession.tr("Small", QbzSession.trRev), "icon": "rows-3", "mode": 2 },
{ "label": QbzSession.tr("Large", QbzSession.trRev), "icon": "layout-grid", "mode": 3 },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: miArea.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 - 22
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
QbzIcon {
visible: QbzShell.npbMode === modelData.mode
name: "check"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: miArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.close()
QbzShell.npbSetMode(modelData.mode)
}
}
}
}
// Divider + the window-mode rows (Miniplayer / Immersive / Kiosk —
// present in the Slint menu; inert in the POC (no such surfaces).
Item {
width: parent ? parent.width : 0
height: 7
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 1
color: theme.borderSubtle
}
}
Repeater {
model: [
{ "label": QbzSession.tr("Miniplayer", QbzSession.trRev), "icon": "picture-in-picture-2" },
{ "label": QbzSession.tr("Immersive", QbzSession.trRev), "icon": "maximize-2" },
{ "label": QbzSession.tr("Kiosk mode", QbzSession.trRev), "icon": "hard-drive" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
opacity: 0.45
color: "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
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
// Inert (POC-NOTE): no miniplayer/immersive/kiosk surfaces.
}
}
}