Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.
NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.
The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.
Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.
Three judgement calls, stated because they are not forced:
- Library gets TWO models, one per kind. Its rows are mixed, and one
anchor would let a Shift-click on a track sweep up the albums drawn
between it and the anchor.
- Ranges run over the VISIBLE rows. Local Library's search box is a
view filter, and a range over rows the user cannot see is not the
range they asked for — the same call `select-all` already made.
- The Local Library tree rail keeps plain toggles. Its selection lives
in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
an anchor off; teaching the Rust side about an anchor is its own
change, and the file says so where a reader would expect otherwise.
The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.
Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.
cc
271 lines
13 KiB
QML
271 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)
|
|
/// `modifiers` rides straight off the mouse event: Shift is what turns
|
|
/// a click into a range (controls/SelectionModel.qml).
|
|
signal toggleSelect(int modifiers)
|
|
|
|
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: function (mods) { root.toggleSelect(mods) }
|
|
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 || ""
|
|
// A dense ROW: the media marks draw monochrome and tinted, like the
|
|
// hard-drive beside them. Colour logos are for cards — a list of
|
|
// them fights the text it labels.
|
|
mono: true
|
|
// .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)
|
|
}
|
|
}
|