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:
72 files changed
+2152
-1690
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user