Files
qbz/crates/qbz-qt/qml/shell/PlayerBar.qml
T
vicrodh a1c771c39e fix(qbz-qt): row buttons, cover triggers, light-theme contrast, flyouts, genre filter
**Track-row buttons did nothing.** Declaration order is z-order in QML, and the
row-wide MouseArea added for right-click was declared last — so it covered the
heart, cloud and menu buttons and swallowed their presses.
propagateComposedEvents cannot rescue that: a press is not a composed event,
which is why only the row's own double-click still worked.

**Covers appeared only after a scroll.** Three compounding defects, not one. The
window report fired on contentY and on model change, both of which happen while
the tab body is still hidden behind its loading flag, so the report that
mattered hit the not-visible early return and was discarded with nothing to
re-fire it. The debounce reset on every change, so a burst of mount-time reports
kept pushing its trailing edge out. And a single pending slot meant that when
two surfaces are mounted at once — the artist rail and that artist's grid — the
second overwrote the first, so one pane was never requested at all. Eviction had
the same shape: each report deleted everything outside its own array, so the two
panes deleted each other's covers.

**Latest release had no cover** because the artist page's collector skipped it,
along with Appears On and the playlists strip.

**Light themes.** The mechanism is Slint's, from 2.0.2 and issue #638: the
on-surface arm on circle actions, header-light deciding the header foreground
only when a dark backdrop is actually painted, and the circular backing behind
the track-row play glyph. Ported as specified rather than approximated. Two
icons rendered in their SVG's own colour because they pointed at the raw asset
instead of a baked tint — a sweep found the rest of that class.

**Navigation flyouts** exist, one component serving both the sidebar and the
header nav modes.

**Filter by genre and the Discover sort now work**, everywhere the control is
drawn. The genre ids were already a parameter on the discover fetch that the
port always passed empty.

cc
2026-07-28 19:55:33 -06:00

483 lines
22 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 inline AudioStamp + cluster.
//
// QUALITY STACK (which bar mounts which component — SongCard.slint /
// PlayerBar.slint / PlayerBarSmall.slint):
// New (0), Classic (1) -> shell/SongCardStamp.qml, mounted INSIDE the song
// card: QualityBadgeFull (icon + stacked
// tier/detail) over the dot LEDs.
// Large (3), Small (2) -> shell/AudioStamp.qml, the inline 2-row stamp.
// They are DIFFERENT components in Slint and they stay different here.
//
// BUTTON SET (phase 24 — 1:1 with the Tauri NowPlayingBar.svelte, via the
// Slint TransportControls/PlayerBar that descend from it):
// transport: info · shuffle · prev · play · next · repeat · "+" (Add to…)
// (+ the inline favorite heart in Classic, like Tauri)
// right: Cast · Connect · Lyrics · Now-Playing view · Audio settings
// (normalization) · [6px] · Mute · slider · −/+ steppers ·
// [6px] · Queue
// Tauri's Miniplayer + Full screen buttons live inside the Now-Playing view
// flyout (ViewModeMenu) since 2.0 — that ONE button holds both.
//
// Wired: transport (shuffle/prev/play/next/repeat), the add flyout
// (library / queue / play next / play later / album), seek, volume + mute +
// steppers, lyrics, queue, the Now-Playing-view flyout (npbSetMode),
// normalization, open album/artist via the SongCard meta links, and Track
// Info — the (i) button and the song-card title open shell/TrackInfoModal.qml
// (needs the QbzAlbum.openTrackInfo glue; see that file's header).
// Inert (TODO comments at the call sites): Cast, Connect (device flyouts),
// add-to-playlist, add-to-mixtape. The volume LOCK (ALSA hw / remote) is
// still not enforced.
//
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
// shell/FavToggle.qml in phase 25 — TransportControls is now shared with the
// Small bar instead of being duplicated there.
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 ({})
}
}
// (The old backendPipewire / dacPassthrough LED sources are gone: the
// stamp's LEDs now read np_output_backend_* / np_output_mode_* off
// QbzPlayer, which is where Slint reads them too — the settings document
// never knew whether the backend was actually ENGAGED.)
// AppearanceState.show-volume-steppers (PlayerBar.slint gates the −/+
// pair on it; Tauri always showed them).
readonly property bool showVolumeSteppers: settingsDoc.showVolumeSteppers === true
// Favorite state of the now-playing track (Slint:
// QueueState.now-playing-favorite). The queue document carries it on its
// `current` row; this re-parses ONLY when that document changes.
readonly property bool npFavorite: {
try {
var d = JSON.parse(QbzQueue.queueJson)
return !!(d && d.current && d.current.isFavorite)
} catch (e) {
return false
}
}
// Audio settings / normalization (Tauri's normalization button; Slint's
// "Audio settings" flyout). `normalization` IS settable through
// QbzBridge.settingsBool but is NOT published in settingsJson yet, so the
// toggle keeps its own value once the user touches it and falls back to
// the (currently absent) published field before that.
// TODO(glue): publish `normalization` in SettingsDoc — see the report.
property bool normTouched: false
property bool normLocal: false
readonly property bool normalizationOn: normTouched ? normLocal
: (settingsDoc.normalization === true)
function toggleNormalization() {
normLocal = !normalizationOn
normTouched = true
QbzBridge.settingsBool("normalization", normLocal)
}
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
return m + ":" + (s < 10 ? "0" : "") + s
}
// --- Track Info (album/TrackInfoModal.slint) ---------------------------
// The (i) button and the song-card title open the MODAL (scrim + centered
// card), which is what the .slint does: the bars fire
// media-action("track", id, "track-info") and AppShell mounts
// TrackInfoModal gated on TrackInfoState.open. Slint gates the trigger on
// NowPlayingState.source == "qobuz" (no DB row / metadata page for local /
// Plex / ephemeral); the Qt port publishes no np_source, so a numeric
// track id stands in for it. TODO(glue): publish np_source.
function openTrackInfo() {
if (!QbzPlayer.npHasTrack)
return
var id = QbzPlayer.npTrackId
if (!/^[0-9]+$/.test(id))
return
trackInfo.openFor(id)
}
TrackInfoModal { id: trackInfo }
// --- Shared bits -------------------------------------------------------
// SongCard / TransportControls / FavToggle were inline components here
// until phase 25; they now live in their own files (shell/SongCard.qml,
// shell/TransportControls.qml, shell/FavToggle.qml) and TransportControls
// is shared with the Small bar.
// ============================ 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
// Buffered / cache line — SeekBar.slint:58 paints it
// text-muted @0.35, NOT border-muted: border-muted is an
// alpha-over-surface token (white-based on dark themes,
// black-based on light), so it inverted against the
// surface-elevated rail on light themes. The Small bar
// already used the right pair.
Rectangle {
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
height: parent.height
radius: 2
color: theme.textMuted
opacity: 0.35
}
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
onTrackInfoRequested: root.openTrackInfo()
}
// Classic: transport cluster hugging the left edge (plain
// play glyph + inline favorite, the Tauri arrangement).
TransportControls {
visible: root.isClassic
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
playCircle: false
classicActions: true
favorite: root.npFavorite
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
}
// 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
favorite: root.npFavorite
onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo()
}
// 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
onTrackInfoRequested: root.openTrackInfo()
}
}
// 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 inline 2-row AudioStamp just LEFT of the icon
// cluster at a fixed 12px gap (the badges move OUT of the
// song card in Large). PlayerBar.slint mounts it with
// max-width: 140px.
AudioStamp {
visible: root.largeActive && QbzPlayer.npHasTrack
maxWidth: 140
anchors.verticalCenter: parent.verticalCenter
}
Item { visible: root.largeActive; width: 12; height: 1 }
// Cast (Chromecast / DLNA) — Tauri's first right-cluster
// button.
QbzIconButton {
name: "cast"
anchors.verticalCenter: parent.verticalCenter
// TODO(qt-bridge): no cast picker in the Qt port
// (Slint: CastState.picker-open + CastActions.open()).
// Rendered 1:1, inert.
}
// Qobuz Connect — inert (device flyout out of scope).
// monitor-speaker is the icon both Slint bars use.
QbzIconButton {
name: "monitor-speaker"
anchors.verticalCenter: parent.verticalCenter
// TODO(qt-bridge): no qconnect state/toggle exposed
// (Slint: NowPlayingState.qconnect-connected +
// qconnect-toggle()). Rendered 1:1, inert.
}
// Lyrics.
QbzIconButton {
name: "mic-vocal"
active: QbzShell.lyricsOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.toggleLyrics()
}
// Now-Playing view — the mode flyout (phase 18). Tauri's
// Miniplayer + Full screen buttons live inside it.
QbzIconButton {
id: viewBtn
name: "layout-grid"
active: viewMenu.opened
anchors.verticalCenter: parent.verticalCenter
onClicked: viewMenu.openBelowRight(viewBtn)
}
// Audio settings — Tauri's normalization toggle (2.0
// groups normalization + gapless behind this button).
// TODO(qt-bridge): the Slint two-toggle flyout is not
// ported; the click toggles normalization directly, which
// is exactly what the Tauri button did.
QbzIconButton {
name: "settings-2"
active: root.normalizationOn
anchors.verticalCenter: parent.verticalCenter
onClicked: root.toggleNormalization()
}
Item { width: 6; height: 1 }
// Volume: mute · slider · −/+ steppers (the steppers are
// the Tauri volume-step buttons, gated by the appearance
// preference like the Slint bar).
QbzIconButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
btnEnabled: !QbzPlayer.npVolumeLocked
active: QbzPlayer.npMuted
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleMute()
}
QbzSlider {
enabled: !QbzPlayer.npVolumeLocked
width: 81
anchors.verticalCenter: parent.verticalCenter
minimum: 0
// 0..1000: whole steps on a 0..100 scale quantize the
// volume to 1%; 0.1% steps drag fluidly.
maximum: 1000
value: Math.round(QbzPlayer.npVolume * 1000)
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
}
QbzIconButton {
visible: root.showVolumeSteppers
name: "minus"
iconSize: 15
anchors.verticalCenter: parent.verticalCenter
btnEnabled: !QbzPlayer.npVolumeLocked
onClicked: QbzPlayer.setVolume(Math.max(0.0, QbzPlayer.npVolume - 0.05))
}
QbzIconButton {
visible: root.showVolumeSteppers
name: "plus"
iconSize: 15
anchors.verticalCenter: parent.verticalCenter
btnEnabled: !QbzPlayer.npVolumeLocked
onClicked: QbzPlayer.setVolume(Math.min(1.0, QbzPlayer.npVolume + 0.05))
}
Item { width: 6; height: 1 }
// Queue panel toggle.
QbzIconButton {
name: "list-ordered"
active: QbzShell.queueOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.toggleQueue()
}
}
}
}
Item { width: 1; height: 6 }
}
// The Now-Playing-view mode menu (shared with the Small bar).
ViewModeMenu { id: viewMenu }
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
// add-menu), on the shared CardMenu surface. Same seven entries, same
// order, same icons.
CardMenu {
id: addMenu
menuWidth: 232
entries: {
var m = [
{
"label": root.npFavorite
? QbzSession.tr("Remove from library", QbzSession.trRev)
: QbzSession.tr("Add to library", QbzSession.trRev),
"icon": root.npFavorite ? "heart-filled" : "heart",
"action": "favorite"
},
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
]
if (QbzPlayer.npAlbumId !== "") {
m.push({
"label": QbzSession.tr("Add album to collection", QbzSession.trRev),
"icon": "disc-3",
"action": "album-favorite"
})
}
return m
}
onPicked: function (a) {
var id = QbzPlayer.npTrackId
if (a === "favorite") {
if (id !== "") QbzQueue.queueToggleFavorite("track", id)
} else if (a === "queue" || a === "later" || a === "next") {
if (id !== "") QbzPlayer.enqueueTrack(id, a)
} else if (a === "album-favorite") {
if (QbzPlayer.npAlbumId !== "")
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
}
// TODO(qt-bridge): "playlist" (add-to-playlist modal) and
// "mixtape" have no invokable in the Qt port yet — the rows are
// rendered 1:1 with the Slint flyout and do nothing for now.
}
}
}