Queue panel 1:1: History tab (click replays per queue.rs semantics), #442 'Next in queue'/'Next up' section markers from manual_next_count, 40-row pagination + live search + page clamp, row actions (play, remove, remove-all-after, heart), press-drag reorder via move_track, clear queue, plural-aware footer count, exact empty states. New Lyrics panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain with LRCLIB fallback, synced line view with line-level highlight + centered auto-follow + click-to-seek, exact status bodies. Both panels stack Feishin-style in the right column; NPB lyrics button wired. km
469 lines
19 KiB
QML
469 lines
19 KiB
QML
// Bottom now-playing / transport bar — QML port of
|
||
// crates/qbz-ui/ui/shell/PlayerBarSmall.slint (Mode C "Small", the only
|
||
// variant in this POC).
|
||
//
|
||
// 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
|
||
|
||
Rectangle {
|
||
id: root
|
||
color: theme.surfaceCard
|
||
|
||
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.
|
||
component BarIconBtn: Rectangle {
|
||
property string name: ""
|
||
property int iconSize: 16
|
||
property bool active: false
|
||
property bool btnEnabled: true
|
||
signal clicked()
|
||
|
||
width: 32
|
||
height: 32
|
||
radius: theme.radiusSm
|
||
opacity: btnEnabled ? 1.0 : 0.3
|
||
color: (biArea.containsMouse && btnEnabled) ? theme.surfaceHover : "transparent"
|
||
|
||
QbzIcon {
|
||
name: parent.name
|
||
width: parent.iconSize
|
||
height: parent.iconSize
|
||
anchors.centerIn: parent
|
||
tintName: parent.active ? "accent"
|
||
: (biArea.containsMouse && parent.btnEnabled)
|
||
? "primary" : "secondary"
|
||
}
|
||
MouseArea {
|
||
id: biArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: if (parent.btnEnabled) parent.clicked()
|
||
}
|
||
}
|
||
|
||
// 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(QbzBridge.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(QbzBridge.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(QbzBridge.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: QbzBridge.npHasTrack
|
||
onClicked: QbzBridge.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 - (QbzBridge.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
|
||
|
||
Image {
|
||
visible: QbzBridge.npHasTrack && QbzBridge.npArtworkPath !== ""
|
||
anchors.fill: parent
|
||
source: QbzBridge.npArtworkPath
|
||
fillMode: Image.PreserveAspectCrop
|
||
}
|
||
// Idle (no track): a STATIC album glyph
|
||
// placeholder (50% of the art size, muted).
|
||
QbzIcon {
|
||
visible: !QbzBridge.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: QbzBridge.npHasTrack
|
||
? QbzBridge.npTitle : QbzBridge.tr("Nothing playing")
|
||
color: theme.textPrimary
|
||
font.pixelSize: 13
|
||
font.weight: theme.weightMedium
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
visible: QbzBridge.npHasTrack
|
||
width: parent.width
|
||
text: QbzBridge.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: QbzBridge.npHasTrack
|
||
gapLeft: 8
|
||
gapRight: 10
|
||
}
|
||
|
||
// Time column — fixed 40px 2-row block (elapsed /
|
||
// total), monospace for stable digit positions.
|
||
Column {
|
||
visible: QbzBridge.npHasTrack
|
||
width: 40
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 1
|
||
Text {
|
||
text: root.fmt(QbzBridge.npElapsedSecs)
|
||
font.family: "monospace"
|
||
font.pixelSize: 10
|
||
color: theme.textMuted
|
||
}
|
||
Text {
|
||
text: root.fmt(QbzBridge.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
|
||
|
||
BarIconBtn {
|
||
name: "info"
|
||
btnEnabled: QbzBridge.npHasTrack
|
||
// POC-NOTE: track-info panel is out of scope.
|
||
}
|
||
BarIconBtn {
|
||
name: "shuffle"
|
||
active: QbzBridge.npShuffle
|
||
btnEnabled: QbzBridge.npHasTrack
|
||
onClicked: QbzBridge.toggleShuffle()
|
||
}
|
||
BarIconBtn {
|
||
name: "skip-back"
|
||
btnEnabled: QbzBridge.npHasTrack
|
||
onClicked: QbzBridge.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: QbzBridge.npHasTrack ? 1.0 : 0.3
|
||
color: (playArea.containsMouse && QbzBridge.npHasTrack)
|
||
? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: QbzBridge.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: QbzBridge.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: if (QbzBridge.npHasTrack) QbzBridge.togglePlay()
|
||
}
|
||
}
|
||
BarIconBtn {
|
||
name: "skip-forward"
|
||
btnEnabled: QbzBridge.npHasTrack
|
||
onClicked: QbzBridge.next()
|
||
}
|
||
BarIconBtn {
|
||
name: QbzBridge.npRepeatMode === 2 ? "repeat-1" : "repeat"
|
||
active: QbzBridge.npRepeatMode !== 0
|
||
btnEnabled: QbzBridge.npHasTrack
|
||
onClicked: QbzBridge.cycleRepeat()
|
||
}
|
||
BarIconBtn {
|
||
name: "plus"
|
||
btnEnabled: QbzBridge.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: QbzBridge.npHasTrack
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 1
|
||
Row {
|
||
spacing: 5
|
||
layoutDirection: Qt.RightToLeft
|
||
Text {
|
||
text: QbzBridge.npQualityLabel
|
||
font.pixelSize: 9
|
||
font.weight: theme.weightSemibold
|
||
color: theme.textPrimary
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
text: QbzBridge.npQualityTier === "hires" ? "HI-RES"
|
||
: QbzBridge.npQualityTier === "mp3" ? "MP3"
|
||
: QbzBridge.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: QbzBridge.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.
|
||
BarIconBtn { name: "monitor-speaker" }
|
||
BarIconBtn { name: "cast" }
|
||
BarIconBtn { name: "settings-2" }
|
||
BarIconBtn { name: "layout-grid" }
|
||
BarIconBtn {
|
||
name: "mic-vocal"
|
||
active: QbzBridge.lyricsOpen
|
||
onClicked: QbzBridge.toggleLyrics()
|
||
}
|
||
|
||
Item { width: 4; height: 1 }
|
||
|
||
// Volume — mute icon + (WIDE) inline horizontal slider.
|
||
BarIconBtn {
|
||
name: QbzBridge.npMuted ? "volume-x" : "volume-2"
|
||
active: QbzBridge.npMuted
|
||
onClicked: QbzBridge.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 * QbzBridge.npVolume
|
||
height: parent.height
|
||
radius: 2
|
||
color: theme.accent
|
||
}
|
||
Rectangle {
|
||
width: 16
|
||
height: 16
|
||
radius: 8
|
||
color: theme.textPrimary
|
||
x: parent.width * QbzBridge.npVolume - width / 2
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onPressed: QbzBridge.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
|
||
onPositionChanged: if (pressed)
|
||
QbzBridge.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
|
||
}
|
||
}
|
||
|
||
Item { width: 4; height: 1 }
|
||
|
||
// Queue panel toggle.
|
||
BarIconBtn {
|
||
name: "list-ordered"
|
||
active: QbzBridge.queueOpen
|
||
onClicked: QbzBridge.toggleQueue()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|