Files
qbz/crates/qbz-qt/qml/views/local/LocalTrackRow.qml
T
vicrodh 313a7e7e4e fix(qt): the local album page gets its zebra, across the whole row
The shared TrackRow has had a `zebra` flag all along, off by default, and the
Qobuz album page turns it on. The local one never did — and turning it on at
the call site would not have been enough either, because LocalTrackRow wraps
the shared row without forwarding the flag.

Forwarding it is still not the fix. TrackRow tints its own background
rectangle, which is `parent.width` wide, and this wrapper hands the shared row
`root.width - 26 - 46`: a 26px gutter for the source glyph and 46px for the
context menu the wrapper draws itself. A stripe painted from in there stops
72px short and leaves both controls outside it — which is what the smoke
caught. The stripe is therefore painted by the wrapper, full width, declared
before the shared row so it lands underneath and takes no pointer events.
Same colour literal and same parity rule as rows/TrackRow.qml, so a local list
and a Qobuz list stripe identically.

cc
2026-08-18 22:15:33 -06:00

265 lines
13 KiB
QML

// Local track row — the SHARED rows/TrackRow.qml with the local arms set,
// plus the three things the shared row has no arm for:
//
// * the always-visible SOURCE glyph the Slint renders from
// `show-source: true` (LocalLibraryView.slint:1489) — drawn OUTSIDE the
// shared row so TrackRow keeps one column layout for every surface;
// * the multi-select checkbox (Slint TrackRow's `multi-select-mode`
// swaps the number cell for it) — overlaid on the number cell here for
// the same reason: no fork of the shared row, no shared-file edit;
// * the LOCAL context menu.
//
// THE MENU (this is the `force-local-menu` arm of the unified Slint menu —
// TrackRow.slint:68 -> TrackMenuState -> AppShell.slint:1009-1021 ->
// TrackContextMenu.slint). For a local / Plex / offline row on a library
// surface Slint resolves to: qobuz-actions OFF, favorite OFF, Track info OFF
// (show-track-info rides the Qobuz block), mixtape ON, add-to-playlist ON,
// Go to album / Go to artist ON (`local-goto-actions`). So the full local set
// is: Play now, Play next, Play later, Add to queue, Add to mixtape, Add to
// playlist, Go to album, Go to artist.
//
// Add to playlist is LIVE (QbzPlaylistPicker). It goes through
// `QbzLocal.bulkAction("track", [id], "add-to-playlist")` — the LOCAL-mode
// arm — and not through the picker singleton directly, because only
// `local_bulk::resolve_blocking` can turn a Tracks-table row id into the
// `LocalTrack` that `local_picker_ref_for_track` needs; a Plex row's ref is
// "plex:<rating key>" and its numeric display id means a different track the
// moment anything reads it as a catalog id. The bulk entry point takes a JSON
// id array, so a single row is a one-element array — same code path, no
// second resolver.
//
// Add to mixtape rides the SAME entry point for the same reason
// (`local_bulk.rs::apply` "add-to-mixtape" -> `myqbz_add_qt::open_items(
// track_items_from_local(rows))`), which restores the .slint's order:
// Play now · Play next · Play later · Add to queue · Add to mixtape ·
// Add to playlist · Go to album · Go to artist. Every row that IS shown is
// wired; nothing here renders and no-ops.
//
// The shared row's own ⋯ is turned OFF (`showMenu: false`) because its
// entries are the Qobuz set: its Go-to-album/artist call QbzAlbum.openAlbum /
// QbzArtist.openArtist with CATALOG ids, and a local row carries a local
// group key + a bare artist NAME. The button is re-drawn here, in the exact
// same gutter (the shared row is narrowed by precisely the 32px cell + 14px
// gap it no longer draws), and both it and a right-click open this menu.
//
// Artwork follows the Slint gate (AppearanceState.local-library-track-
// artwork, default OFF) — decoding a cover per row is exactly what froze
// this surface at 16k tracks.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../rows"
import "../../theme"
Item {
id: root
property var item: ({})
property int number: 0
property bool showAlbum: true
property bool showArtwork: false
property string artSource: ""
/// The LocalLibraryView root (shared skeleton pulse + artwork gate).
property var view: null
property bool selectMode: false
property bool checked: false
/// Alternating row tint, forwarded to the shared row. Off by default there
/// (rows/TrackRow.qml), which is why the local album page had none while
/// the Qobuz one (AlbumView.qml) did — the flag was simply never handed
/// through this wrapper.
property bool zebra: false
signal playRequested()
signal enqueueRequested(string mode)
signal toggleSelect()
QbzTheme { id: theme }
height: 50
// The shared TrackRow takes its artwork from `item.artPath`, and a LOCAL
// track row publishes an empty one (local_rows.rs:284) — the cover comes
// from the id-keyed artMap instead. Patch it in rather than fork the
// shared row; the copy only happens when a cover is actually present.
readonly property var rowItem: root.artSource === ""
? root.item
: Object.assign({}, root.item, { "artPath": root.artSource })
// Local rows have a local album GROUP KEY and a bare artist NAME; both
// routes are name/key routes, so each entry is gated on its own datum.
readonly property bool canGoAlbum: (item.albumId || "") !== ""
readonly property bool canGoArtist: (item.artist || "") !== ""
// Zebra is painted HERE, not forwarded to the shared row. TrackRow tints
// its own background rectangle, which is `parent.width` wide — and this
// wrapper hands it `root.width - 26 - 46`, holding back a 26px gutter for
// the source glyph and 46px for the context menu it draws itself. Tinting
// from in there therefore stopped 72px short and left those two controls
// sitting outside the stripe. Declared before the shared row so it paints
// underneath, and with no pointer area of its own.
Rectangle {
anchors.fill: parent
radius: 8
// Same literal and same parity rule as rows/TrackRow.qml, so a local
// list and a Qobuz list stripe identically.
color: (root.zebra && root.number % 2 === 0) ? "#07ffffff" : "transparent"
}
TrackRow {
id: sharedRow
// 26px source-glyph gutter + the 46px (32 cell + 14 gap) the shared
// row would have spent on its own ⋯ — so every other column lands
// exactly where it did before the menu moved out.
width: root.width - 26 - 46
item: root.rowItem
number: root.number
showArtwork: root.showArtwork
showAlbum: root.showAlbum
showFavorite: false
showDownload: false
showMenu: false
// Local rows are NOT drag sources: item.id is a local DB row id and
// the sidebar drop handler forwards it as a Qobuz catalog id.
draggable: false
qualityStyle: "icon"
// Multi-select is the SHARED row's arm now (rows/TrackRow.qml:116,
// the port of TrackRow.slint:58 `multi-select-mode`). This file used
// to overlay its own SelectCheck on the number cell because the
// shared row had no such arm — it does now, and the overlay was
// strictly worse: it drew a checkbox but left `sharedRow.selectMode`
// false, so the row BODY kept its normal gestures and a single click
// anywhere off the 14px disc PLAYED the track instead of selecting
// it. TrackRow.slint:174 is explicit that in select mode "the WHOLE
// row body is a selection target", and LocalAlbumRow.qml:55 already
// does that for the albums tab. Forwarding the three members gets the
// reference behaviour for free: body-click toggles, double-click no
// longer plays, the number/play cell stands down, and the checkbox is
// the 14px accent disc the .slint actually specifies.
selectMode: root.selectMode
checked: root.checked
onToggleSelect: root.toggleSelect()
onPlayRequested: root.playRequested()
onEnqueueRequested: function (m) { root.enqueueRequested(m) }
}
// ⋯ menu button — the shared row's own gutter, re-drawn with the local
// action set behind it.
Rectangle {
id: moreBtn
x: root.width - 70
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: "ellipsis"
width: 16
height: 16
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { root.openRowMenu(moreArea, mouse.x, mouse.y) }
}
}
// Source glyph (local / offline cache / Plex) — TrackRow.slint:709-727.
// Through controls/SourceIcon.qml, never QbzIcon: the Plex and Qobuz marks
// are MULTI-COLOUR and a tint flattens them to a silhouette. This row used
// to draw an accent-tinted `hard-drive` for Plex (a blue hard drive) and
// `cloud-download` for an offline copy, where the .slint draws the two
// brand marks.
SourceIcon {
visible: (root.item.source || "") !== ""
// `sourceRaw` FIRST (contract §D.1): it carries `qobuz_purchase`, which
// `source` folds into "offline" so the source chips still filter it.
// A purchased track's row is gold; a plain download's is not.
kind: root.item.sourceRaw || root.item.source || ""
// .slint:722 — local 15, the marks 16. `localTint` muted is the rule
// SourceGlyph.slint:6-8 calls load-bearing (never accent).
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
// The glyph's CENTRE stays where the 14px icon's was (x = width-20,
// centre = width-13) now that the marks are 15-16px wide.
x: Math.round(root.width - 13 - width / 2)
anchors.verticalCenter: parent.verticalCenter
}
// Right-click anywhere on the row opens the SAME menu at the pointer
// (TrackRow.slint's `open-track-menu(..., at-pointer: true)`). Declared
// last so it sits on top, and RIGHT-only so every left click still falls
// through to the row body / checkbox / ⋯ underneath.
//
// Suppressed in select mode — TrackRow.slint:201 gates the same gesture on
// `!root.multi-select-mode` ("a right-click toggles nothing there"), and
// the shared row's own trArea already does it (rows/TrackRow.qml:926).
// Disabled rather than hidden so the press falls through to the row body
// instead of landing on nothing.
MouseArea {
id: rcArea
anchors.fill: parent
enabled: !root.selectMode
acceptedButtons: Qt.RightButton
onClicked: function (mouse) { root.openRowMenu(rcArea, mouse.x, mouse.y) }
}
// LAZY. This is a QtQuick.Controls Popup with a Repeater over its
// entries, and it was built EAGERLY for every row — so a long list
// constructed one whole popup per visible row, and rebuilt them all
// on every scroll step, to show a menu only ever opened on click.
// Same lazy-Loader idiom this file already uses for the clipboard
// helper and the info modal.
Loader {
id: rowMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 220
entries: {
var m = [
{ "label": QbzSession.tr("Play now", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "add-to-mixtape" },
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "add-to-playlist" },
]
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
return m
}
onPicked: function (a) {
if (a === "play") root.playRequested()
// LOCAL-mode adds: one-element selection through the bulk entry
// point, which owns the only source-aware row -> ref resolver
// (see the file header).
else if (a === "add-to-playlist" || a === "add-to-mixtape") {
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
}
else if (a === "go-album") {
QbzLocal.openAlbum(root.item.albumId)
QbzShell.navigateTo("localalbum")
} else if (a === "go-artist") {
// Local/Plex artists have no catalog id — a NAME route into
// the Local Library Artists tab (local_album_actions.rs).
QbzLocal.openArtistByName(root.item.artist)
QbzShell.navigateTo("local")
} else {
root.enqueueRequested(a)
}
}
}
}
/// Build the popup on first use, then open it.
function openRowMenu(anchor, x, y) {
rowMenuLoader.active = true
rowMenuLoader.item.openAtCursor(anchor, x, y)
}
}