Files
qbz/crates/qbz-qt/qml/views/LibraryView.qml
T
vicrodh 750c59034b feat(qt): Excel-style multi-select — Shift ranges across every surface
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
2026-08-22 02:03:10 -06:00

1421 lines
71 KiB
QML

// Library view — QML port of crates/qbz-ui/ui/favorites/FavoritesView.slint
// + the Library "All" mixed feed (library_all.rs semantics).
//
// Data: QbzLibrary.libraryJson (ONE JSON document — the full merged feed;
// tabs/search/sort/source-filters derive HERE in JS, measured per the
// phase brief) + libraryCountsJson (tab badges). Artwork is id-keyed
// through the libraryArtworkReady signal into `artMap` (never a
// wrong-cover race); windows are reported as artKeys via
// libraryArtworkWindow, and artMap entries far outside the viewport are
// pruned (the Slint eviction policy, QML-side).
//
// THIS FILE IS THE STATE + ORCHESTRATION HALF. The bodies live next door
// (track rule 2 — it used to be 1,881 lines):
// library/LibraryToolbar.qml the whole 56px chrome band
// library/FeedListRow.qml the All feed's mixed LIST row
// library/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar
// library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode
// Each takes `view: root`, the seam views/local/*.qml already uses.
//
// Still out of scope, and named rather than implied:
// - Offline: the generic OfflinePlaceholder replica mounts (the Slint
// offline RAIL of playable cached favorites needs the offline cache —
// not wired; PARITY-DEBT #17).
// - The Library "All" local SCOPE ("favorites" vs the whole local library)
// is PARITY-DEBT #6: the pref is read and PRESERVED on disk by
// library_prefs.rs, but neither the Settings row nor the `all` branch of
// the feed exists here yet.
//
// Filter by genre IS wired, on the All / Tracks / Albums toolbars — the three
// places the Slint FavoritesView draws it (FavoritesView.slint:609, :652,
// :864, `context: "library-all"`). It filters CLIENT-side over the feed,
// exactly like library_all.rs::derive: an item shows when its (lowercased)
// genre contains one of the selected genre names (+ their sub-genres), so rows
// with no genre at all (artist / label / playlist) drop out while a genre is
// selected. The selection lives in the shared per-context store and persists
// to genre_filter.json.
//
// Toolbar choices PERSIST (favorites_prefs.rs parity): albums view/sort/group,
// tracks group, playlists view, artists group/view and the All hard-drive
// toggle all go through `setPref` into the same favorites_ui.json the shipping
// Slint build writes. Search queries stay transient — deliberately, there too.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
import "library"
Rectangle {
id: root
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
// ============================ state ==================================
// Everything that logs through this category MUST defer the call (see the
// `Qt.callLater` in `visibleRows` and `parseFeed`). Declaring it first is
// NOT enough and moving it here did not silence the warning: a category is
// finalised the first time it is USED, so a `console.info` running during
// component creation pins it before Qt applies `defaultLogLevel`, which
// then warns ("cannot be changed after the component is completed") and —
// the part that matters — leaves the level UNSET, i.e. this logging on by
// default for every user. Deferring past creation is what actually fixes
// it. Both call sites below run during creation, which is why both defer.
LoggingCategory {
id: libTiming
name: "qbz.nav.timing"
defaultLogLevel: LoggingCategory.Warning
}
property string activeTab: "all"
readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson)
// Full merged feed (parsed once per publish — timed for the report).
readonly property var feed: parseFeed(QbzLibrary.libraryJson)
function parseFeed(json) {
var t = Date.now()
var f = JSON.parse(json)
// Was a bare console.log, i.e. printed on every publish for every user
// forever. It belongs to the same investigation as the derive timing
// right below it, so it now rides the same category and is silent
// unless that category is turned on.
var line = "[libtiming] JSON.parse feed: " + (Date.now() - t)
+ "ms (" + f.length + " items, " + json.length + " bytes)"
Qt.callLater(function () { console.info(libTiming, line) })
return f
}
// Decoded-cover map {artKey: file://path}, fed by the signal.
property var artMap: ({})
// --- skeleton pulse ---------------------------------------------------
// ONE 900ms Timer drives EVERY placeholder in this view (the loading
// grid/list AND the per-item cover tiles). GATING RULE: freeze on NOT
// VISIBLE — the view hidden, or the window minimized/hidden. NEVER on
// lost focus (a tiling desktop keeps windows visible and unfocused).
property bool skelPhase: false
// Covers requested for the current window that have not arrived yet, and
// the window's first index (the per-item placeholders count their cap
// from it, so the cap follows the viewport instead of the model).
property int artPending: 0
property int artWindowFirst: 0
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
Timer {
interval: 900
repeat: true
// Ticks only while something can actually be shimmering.
running: root.visible && root.windowShowing
&& (QbzLibrary.libraryLoading || root.artPending > 0)
onTriggered: root.skelPhase = !root.skelPhase
}
// All-tab derive state (LibraryAllState semantics).
property string search: ""
// Group modes (favorites.rs derive_tracks / derive_albums / artists).
// Grouping REORDERS rows; for the tracks list it also injects separator
// rows — see visibleItems / withGroupHeaders.
property string tracksGroup: "off" // "off" | "album" | "artist" | "name"
property string albumsGroup: "off" // "off" | "alpha" | "artist"
property string artistsGroup: "off" // "off" | "alpha"
property string sortBy: "date" // "date" | "title" | "artist"
property bool sortAsc: false // date: false = newest first
property bool showPurchases: true
property bool showFavorites: true
property bool showFollowing: true
property bool showLocal: true
property string viewMode: "grid" // All tab: "grid" | "list"
property string albumsView: "grid" // "grid" | "list"
property string playlistsView: "grid" // "grid" | "list"
property string artistsView: "grid" // "grid" | "sidepanel"
// --- persisted toolbar choices (library_prefs.rs) --------------------
// Seeded from the bridge document rather than BOUND to it: these are
// user-editable properties, and a binding would fight every click. The
// Rust setter never republishes the document, so there is no loop.
function applyPrefs() {
var p
try {
p = JSON.parse(QbzLibrary.libraryPrefsJson)
} catch (e) {
return
}
// The default "{}" reaches here before `boot` seeds the real one.
if (!p || p.albumsView === undefined) return
root.albumsView = p.albumsView
root.albumsSort = p.albumsSort
root.albumsGroup = p.albumsGroup
root.tracksGroup = p.tracksGroup
root.playlistsView = p.playlistsView
root.artistsGroup = p.artistsGroup
root.artistsView = p.artistsView
root.showLocal = p.allShowLocal === true
}
Component.onCompleted: root.applyPrefs()
Connections {
target: QbzLibrary
function onLibraryPrefsJsonChanged() { root.applyPrefs() }
}
/// Write one toolbar choice AND persist it. The toolbar calls this instead
/// of assigning the property, so the two can never drift apart.
function setPref(key, value) {
if (key === "albumsView") {
root.albumsView = value
// Multi-select is LIST-only (FavoritesView.slint:514); leaving
// list mode with a live selection would strand the bulk bar.
if (value !== "list") root.setAlbumsMultiSelect(false)
} else if (key === "albumsSort") root.albumsSort = value
else if (key === "albumsGroup") root.albumsGroup = value
else if (key === "tracksGroup") root.tracksGroup = value
else if (key === "playlistsView") root.playlistsView = value
else if (key === "artistsGroup") root.artistsGroup = value
else if (key === "artistsView") root.artistsView = value
else {
console.warn("[qbz-qt] LibraryView.setPref: unknown key " + key)
return
}
QbzLibrary.setLibraryPref(key, String(value))
}
/// The one SOURCE switch the reference persists (favorites_prefs.rs
/// `all_show_local`); the other three are session-local there too.
function setShowLocal(on) {
root.showLocal = on
QbzLibrary.setLibraryPref("allShowLocal", on ? "true" : "false")
}
// --- shared genre filter, "library-all" context ----------------------
// Read STRAIGHT off the bridge singleton, not off libGenrePopup: the
// popup is declared LAST (z-order) and a creation-time binding that
// dereferences a not-yet-created id registers NO dependency, so it would
// never re-evaluate. The popup instance is only touched from click
// handlers, which run long after creation.
readonly property var genreDoc: {
try {
return JSON.parse(QbzBridge.genreFilterJson)
} catch (e) {
return {}
}
}
readonly property int genreCount: (genreDoc.counts || {})["library-all"] || 0
// Selected genre NAMES (+ sub-genres), lowercased once here — the port's
// FeedItem keeps the display casing (the Slint model lowercases at build
// time instead, library_all.rs:540). Reading this inside visibleItems()
// is what makes the grid/list model re-derive when a chip is toggled.
readonly property var genreNames: {
var src = (genreDoc.names || {})["library-all"] || []
var out = []
for (var i = 0; i < src.length; i++)
out.push(String(src[i]).toLowerCase())
return out
}
/// The toolbar's genre buttons reach the popup through here — it is
/// declared LAST in this file and cannot be named from a child component.
function toggleGenrePopup() { libGenrePopup.toggle() }
/// The All tab's filters as the applied-filters tooltip wants them: the
/// selected genres, plus the four SOURCE switches — but only when one is
/// OFF, because all four on is the default and "everything is shown" is not
/// a filter worth naming. Genres come back in display casing here (the
/// lowercased copy above exists for matching, not for reading).
readonly property var filterSummaryGroups: {
var tr = QbzSession.trRev
var hidden = []
if (!root.showFavorites)
hidden.push(QbzSession.tr("Favorites", tr))
if (!root.showPurchases)
hidden.push(QbzSession.tr("Purchases", tr))
if (!root.showFollowing)
hidden.push(QbzSession.tr("Following", tr))
if (!root.showLocal)
hidden.push(QbzSession.tr("Local", tr))
var out = []
if (hidden.length > 0)
out.push({ group: QbzSession.tr("Hidden", tr), values: hidden })
return out
}
// Other-tab state.
property string tabSearch: ""
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc
property string playlistsSubTab: "favorites"
/// The All tab's search field debounces; the toolbar routes through here
/// so the timer stays next to the window report it feeds.
function setAllSearch(v) {
root.search = v
allSearchDebounce.restart()
}
// ONE shared per-tab query, reset on entry — that IS the reference.
// In Slint every entry into one of the five tabs runs
// navigate_favorites -> load_favorites -> apply_favorites, which assigns
// "" to that tab's search (favorites.rs:570/595/614/627/649) AND, because
// the ExpandableSearch lives inside an `if` branch, destroys and
// re-creates it COLLAPSED. So a tab is never re-entered with a stale
// filter and five independent queries would be wrong. The port has one
// feed and no per-tab reload, so the reset is explicit here.
// The All tab is deliberately NOT reset: LibraryAllState.search is never
// cleared anywhere in the reference, so its feed stays filtered behind a
// collapsed magnifier — quirk included.
onActiveTabChanged: {
tabSearch = ""
if (toolbar) toolbar.closeTabSearch()
// A selection cannot survive leaving its own tab: the bulk bar would
// still be armed over rows that are no longer on screen.
setTracksMultiSelect(false)
setAlbumsMultiSelect(false)
}
// Per-tab emptiness gate. Slint gates each per-tab toolbar (search AND
// genre/sort/sub-tab/view toggles) on the tab's FULL loaded set
// (FavoritesView.slint:593/636/707/722/747) — searching down to zero
// results must NOT hide it, so this counts the feed and never
// visibleItems(). Same keep-rules as visibleItems() minus the playlists
// sub-tab split, which makes the playlists count the favorites-OR-
// following the reference gate wants (counts.playlists is favorites
// only, library_qt.rs:479).
readonly property var tabTotals: {
var c = { "tracks": 0, "albums": 0, "artists": 0, "playlists": 0, "labels": 0 }
for (var i = 0; i < feed.length; i++) {
var it = feed[i]
if (it.kind === "track") { if (it.group === "favorites") c.tracks++ }
else if (it.kind === "album") { if (it.group === "favorites") c.albums++ }
else if (it.kind === "artist") c.artists++
else if (it.kind === "label") c.labels++
else if (it.kind === "playlist") c.playlists++
}
return c
}
readonly property bool tabHasItems:
activeTab === "all" || (tabTotals[activeTab] || 0) > 0
// ------------------------- derive (JS) ------------------------------
// THE model, derived once and held here. Both views bind to it and both
// window reports read IT — never `grid.model` / `list.model` back off
// the view, which is a hard crash, not a style point:
//
// QQuickItemView::model() (libQt6Quick 6.11.1, +0x145dfe..+0x145e2c)
// reads THREE things off the private and guards only two of them:
// 0x588 the QPointer's ExternalRefCountData guard -> null-checked,
// and its strongref at +4 is checked too
// 0x7e0 bit 0, the "this view owns a delegate model" flag
// 0x590 the QPointer's raw VALUE -> handed to
// QQmlDelegateModel::model() with NO test at all
// So the getter is safe against a QPointer whose guard says "gone" and
// unsafe against the window where the guard still says "alive" while
// the value slot no longer refers to a usable object. setModel() opens
// exactly that window: it tears the old QQmlDelegateModel down, and its
// own repositioning emits contentYChanged from inside the teardown,
// straight into onContentYChanged and straight back into the getter.
// `this` arrives null (gdb: rsi=0), +8 is the first member read, SIGSEGV
// at address 8 in QQmlDelegateModel::model()+5. Reproduced standalone
// under qml6 6.11.1 (same fault ip, same dmesg Code bytes); it is the
// same fault the app took on 2026-07-27/28/29 going Library -> Labels
// with the grid scrolled — the tab switch re-derives the model AND the
// clamp to the shorter list moves contentY. With contentY already 0
// nothing is emitted and nothing crashes, which is why it was
// intermittent rather than every switch.
//
// PRECONDITION, precisely, because the wrong one invites the wrong fix:
// the old delegate model is being DESTROYED, not momentarily unset. It
// does not come back a moment later. So the repair for any site with
// this shape is NOT "defer the readback one event-loop turn" (Qt.
// callLater / a 0ms Timer) — by then there is still no old model and,
// worse, the handler has been decoupled from the state it was meant to
// report. The only repair is to not ask the view for its model.
//
// onModelChanged is NOT the entry: it fires after the swap is complete
// and the same readback there survives. Bisected, not assumed.
//
// Only `.model` is unguarded. `count`, `contentHeight`, `currentIndex`,
// `indexAt()` and `itemAtIndex()` all null-check and are safe in the
// same handler — which is why the LocalLibrary tabs, which cache their
// array in `entries` and only ever ask the view for indices, never had
// this bug. Same shape here.
//
// Deriving once also replaces two independent passes over the feed (the
// grid and the list each used to call visibleItems() on every change)
// with one, and guarantees both views and the debounce Timer see the
// SAME array instance.
// Named `visibleRows`, not `visible` — root is a Rectangle and `visible`
// is taken.
// Timed like `parseFeed` above, and for the same reason: this runs a full
// pass plus a sort over the WHOLE merged feed, so it is the one derive in
// the app whose cost scales with the size of the user's library. The log
// rides the shared route-timing category — it is silent unless
// QT_LOGGING_RULES="qbz.nav.timing.info=true".
readonly property var visibleRows: {
var _t = Date.now()
var _rows = visibleItems()
var _line = "[libtiming] derive tab=" + activeTab + " feed=" + feed.length
+ " rows=" + _rows.length + " in " + (Date.now() - _t) + "ms"
Qt.callLater(function () { console.info(libTiming, _line) })
return _rows
}
/// A-Z bucket for a title: its first letter uppercased, "#" for anything
/// that is not a letter (a leading digit, "*", punctuation). Matches the
/// jump strip's own buckets, so a letter in the strip always has a
/// separator to land on.
function alphaKey(title) {
var t = (title || "").trim()
if (t.length === 0) return "#"
var c = t.charAt(0).toUpperCase()
return (c >= "A" && c <= "Z") ? c : "#"
}
/// Inject a `group-header` pseudo-row before each run of rows sharing a
/// group key. The rows are ALREADY ordered by that key (see the `by(...)`
/// sorts above), so this is one linear pass.
///
/// Album and artist grouping match the reference's separators. NAME
/// grouping gets them too — the reference draws only the A-Z jump strip
/// there and no separators, which the owner asked to close (2026-07-31):
/// a strip that jumps to a letter with nothing labelling it leaves the
/// user guessing where a bucket starts.
function withGroupHeaders(rows, mode) {
var out = []
var last = null
var n = 1
for (var i = 0; i < rows.length; i++) {
var r = rows[i]
var key = mode === "album" ? (r.album || "")
: mode === "artist" ? (r.artist || "")
: alphaKey(r.title)
if (key !== last) {
out.push({ "kind": "group-header", "title": key, "id": "hdr:" + mode + ":" + key })
last = key
}
// The visible track NUMBER keeps running across groups (the
// reference numbers 1,2,3… straight through its separators), so it
// cannot be the model index once headers share the model. Carried
// on a shallow COPY — the feed's row objects are shared with the
// grid and the artwork map and must not be mutated.
out.push(Object.assign({}, r, { "_no": n }))
n++
}
return out
}
function visibleItems() {
var items = []
var i
if (activeTab === "all") {
var needle = search.toLowerCase()
var anyGroup = showPurchases || showFavorites || showFollowing
// Genre gate (library_all.rs::derive): empty = no filter;
// otherwise the item's genre must contain one of the selected
// names, so genre-less kinds are excluded.
var genres = root.genreNames
for (i = 0; i < feed.length; i++) {
var it = feed[i]
var isLocal = it.source === "local" || it.source === "plex"
if (isLocal) {
if (!showLocal) continue
} else if (anyGroup) {
var g = it.group
var ok = (g === "purchases" && showPurchases)
|| (g === "favorites" && showFavorites)
|| (g === "following" && showFollowing)
if (!ok) continue
}
if (needle !== ""
&& it.title.toLowerCase().indexOf(needle) < 0
&& it.artist.toLowerCase().indexOf(needle) < 0) continue
if (genres.length > 0) {
var itemGenre = (it.genre || "").toLowerCase()
if (itemGenre === "") continue
var genreHit = false
for (var gi = 0; gi < genres.length; gi++) {
if (itemGenre.indexOf(genres[gi]) >= 0) { genreHit = true; break }
}
if (!genreHit) continue
}
items.push(it)
}
// Canonical ascending order per field, then reverse for the
// other direction; "date" keeps model order (newest-first),
// reversed for oldest (library_all.rs derive).
if (sortBy === "title") {
items.sort(function (a, b) { return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : 1 })
if (!sortAsc) items.reverse()
} else if (sortBy === "artist") {
items.sort(function (a, b) { return a.artist.toLowerCase() < b.artist.toLowerCase() ? -1 : 1 })
if (!sortAsc) items.reverse()
} else if (sortAsc) {
items = items.slice().reverse()
}
return items
}
var tabNeedle = tabSearch.toLowerCase()
function hit(it) {
return tabNeedle === ""
|| it.title.toLowerCase().indexOf(tabNeedle) >= 0
|| it.artist.toLowerCase().indexOf(tabNeedle) >= 0
}
var tabGenres = (activeTab === "tracks" || activeTab === "albums")
? root.genreNames : []
for (i = 0; i < feed.length; i++) {
var x = feed[i]
var keep = false
if (activeTab === "tracks") keep = x.kind === "track" && x.group === "favorites"
else if (activeTab === "albums") keep = x.kind === "album" && x.group === "favorites"
else if (activeTab === "artists") keep = x.kind === "artist"
else if (activeTab === "labels") keep = x.kind === "label"
else if (activeTab === "playlists") keep = x.kind === "playlist"
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites")
if (keep && tabGenres.length > 0) {
var tg = (x.genre || "").toLowerCase()
keep = false
for (var tgi = 0; tgi < tabGenres.length; tgi++) {
if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break }
}
}
if (keep && hit(x)) items.push(x)
}
if (activeTab === "albums" && albumsSort !== "default") {
var field = albumsSort.indexOf("artist") === 0 ? "artist" : "title"
items.sort(function (a, b) {
var av = field === "artist" ? a.artist.toLowerCase() : a.title.toLowerCase()
var bv = field === "artist" ? b.artist.toLowerCase() : b.title.toLowerCase()
return av < bv ? -1 : 1
})
if (albumsSort.slice(-4) === "desc") items.reverse()
}
// --- Group-by (favorites.rs::derive_tracks / derive_albums) ------
// Grouping REORDERS the rows so a group's entries sit together. The
// TRACKS list also gets separator rows (see withGroupHeaders); the
// albums and artists GRIDS do not — a uniform-cell GridView has no
// room for a full-width header, which is exactly why the reference
// scrolls those two PROPORTIONALLY from the A-Z strip instead of
// landing on a separator (FavoritesView.slint:1996, :2008).
var lc = function (s) { return (s || "").toLowerCase() }
var by = function (keys) {
items.sort(function (a, b) {
for (var k = 0; k < keys.length; k++) {
var av = lc(a[keys[k]]), bv = lc(b[keys[k]])
if (av !== bv) return av < bv ? -1 : 1
}
return 0
})
}
if (activeTab === "tracks" && tracksGroup !== "off") {
if (tracksGroup === "album") by(["album", "title"])
else if (tracksGroup === "artist") by(["artist", "album", "title"])
else if (tracksGroup === "name") by(["title"])
items = withGroupHeaders(items, tracksGroup)
} else if (activeTab === "albums" && albumsGroup !== "off") {
// "alpha" is the album title's initial, which title-order gives
// for free; "artist" groups by artist then title.
if (albumsGroup === "alpha") by(["title"])
else if (albumsGroup === "artist") by(["artist", "title"])
} else if (activeTab === "artists" && artistsGroup === "alpha") {
by(["title"])
}
return items
}
// ------------------------ A-Z jump strip -----------------------------
// FavoritesView.slint:1680-1691 (tracks, name grouping — index-accurate
// because the rows are uniform), :1988-1999 (albums, alpha grouping) and
// :2000-2011 (artists, alpha grouping).
//
// ONE deliberate improvement over the reference on the two GRIDS: it
// scrolls proportionally (`ord / (jumps-1) * maxScroll`) because a Slint
// Flickable cannot address a cell; a QML GridView can, so the strip lands
// on the letter's FIRST card instead of near it. Same strip, same
// buckets, an exact landing.
readonly property bool alphaActive:
(activeTab === "tracks" && tracksGroup === "name")
|| (activeTab === "albums" && albumsGroup === "alpha")
|| (activeTab === "artists" && artistsGroup === "alpha" && artistsView === "grid")
readonly property var alphaJumps: {
if (!root.alphaActive) return []
var rows = root.visibleRows
var out = []
var last = null
for (var i = 0; i < rows.length; i++) {
// The tracks list already carries a separator per bucket; jumping
// to the SEPARATOR (not the first track) is what puts the letter
// itself at the top of the viewport.
if (activeTab === "tracks") {
if (rows[i].kind !== "group-header") continue
out.push({ "letter": rows[i].title, "index": i })
continue
}
var key = root.alphaKey(rows[i].title)
if (key !== last) {
out.push({ "letter": key, "index": i })
last = key
}
}
return out
}
readonly property bool alphaVisible: root.alphaActive && root.alphaJumps.length > 0
function alphaJumpTo(index) {
if (activeTab === "tracks") list.positionViewAtIndex(index, ListView.Beginning)
else if (activeTab === "albums" && albumsView === "list") albumsList.positionAt(index)
else grid.positionViewAtIndex(index, GridView.Beginning)
}
// ------------------------ multi-select -------------------------------
// FavoritesView.slint:465-523 (the two toggles) + :1570 / :1734 (the two
// bars). The SELECTION lives here, in QML, exactly like the Local Library
// grid/table bars: "select-all" and "clear" never reach Rust, everything
// else goes down as a JSON id array (src/library_bulk.rs).
property bool tracksMultiSelect: false
property var tracksSelected: ({})
readonly property int tracksSelectedCount: Object.keys(root.tracksSelected).length
property bool albumsMultiSelect: false
property var albumsSelected: ({})
readonly property int albumsSelectedCount: Object.keys(root.albumsSelected).length
function setTracksMultiSelect(on) {
root.tracksMultiSelect = on
if (!on) root.tracksSelected = ({})
}
function setAlbumsMultiSelect(on) {
root.albumsMultiSelect = on
if (!on) root.albumsSelected = ({})
}
// A rebind needs a NEW object reference — mutating the map in place
// notifies nothing (the same rule artMap and every row's `favorite`
// property follow).
/// Excel-style selection — controls/SelectionModel.qml holds the anchor
/// and the Shift-range rule; this view keeps owning its two maps.
///
/// TWO models, because tracks and albums are two independent selections
/// with two independent anchors, and the rows here are MIXED: `visibleRows`
/// carries both kinds, so each range is taken over the visible rows OF ITS
/// OWN KIND. Ranging over the mixed list would let a shift-click on a
/// track sweep up the albums drawn between it and the anchor.
SelectionModel { id: trackSel }
SelectionModel { id: albumSel }
function _rowsOfKind(kind) {
return root.visibleRows.filter(function (r) { return r.kind === kind })
}
function toggleTrackSelected(id, mods) {
root.tracksSelected = trackSel.next(root.tracksSelected, id,
root._rowsOfKind("track"),
mods === undefined ? Qt.NoModifier : mods)
}
function toggleAlbumSelected(id, mods) {
root.albumsSelected = albumSel.next(root.albumsSelected, id,
root._rowsOfKind("album"),
mods === undefined ? Qt.NoModifier : mods)
}
/// Selected ids in VISIBLE order — never `Object.keys(map)`. Qobuz ids are
/// numeric strings, and a JS object iterates integer-like keys in ASCENDING
/// NUMERIC order, so the keys of the map are not the order the user sees
/// and "play next" would insert a re-sorted block.
function selectedIdsInOrder(kind, map) {
var rows = root.visibleRows
var out = []
for (var i = 0; i < rows.length; i++)
if (rows[i].kind === kind && map[rows[i].id] === true) out.push(rows[i].id)
return out
}
function tracksBulkAction(action) {
if (action === "select-all") {
var m = {}
var rows = root.visibleRows
for (var i = 0; i < rows.length; i++)
if (rows[i].kind === "track") m[rows[i].id] = true
root.tracksSelected = m
return
}
if (action === "clear") { root.tracksSelected = ({}); trackSel.anchorId = ""; return }
var ids = root.selectedIdsInOrder("track", root.tracksSelected)
if (ids.length === 0) return
QbzLibrary.libraryBulkAction("track", JSON.stringify(ids), action)
// The Slint clears after an enqueue and after remove-selected, and
// KEEPS the selection while a picker is still open (a failed write is
// retried from the same modal) — local_bulk.rs::apply says the same.
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.tracksSelected = ({})
}
function albumsBulkAction(action) {
if (action === "select-all") {
var m = {}
var rows = root.visibleRows
for (var i = 0; i < rows.length; i++)
if (rows[i].kind === "album") m[rows[i].id] = true
root.albumsSelected = m
return
}
if (action === "clear") { root.albumsSelected = ({}); albumSel.anchorId = ""; return }
var ids = root.selectedIdsInOrder("album", root.albumsSelected)
if (ids.length === 0) return
QbzLibrary.libraryBulkAction("album", JSON.stringify(ids), action)
root.albumsSelected = ({})
}
// --- Ctrl+A / Escape hotkeys interface (2026-08-03 hotkeys-port §4.6) --
// The duck-typed seam the AppShell router calls on QbzShell's
// selectAllRequested / exitMultiSelectRequested. Only the tracks and
// albums tabs have multi-select here; on any other tab selectAll() is a
// deliberate no-op (the capability reporter still counts this view, so
// Ctrl+A consumes without visible effect on the All/Artists/Playlists
// tabs — those have no selection model to fill).
readonly property bool multiSelectOn: root.tracksMultiSelect || root.albumsMultiSelect
function selectAll() {
if (root.activeTab === "tracks") {
if (!root.tracksMultiSelect) root.setTracksMultiSelect(true)
root.tracksBulkAction("select-all")
} else if (root.activeTab === "albums") {
if (!root.albumsMultiSelect) root.setAlbumsMultiSelect(true)
root.albumsBulkAction("select-all")
}
}
function exitMultiSelectMode() {
if (root.tracksMultiSelect) root.setTracksMultiSelect(false)
if (root.albumsMultiSelect) root.setAlbumsMultiSelect(false)
}
// --------------------- windowed artwork -----------------------------
// Report the mounted window as artKeys; prune far-away covers.
function reportWindow(visibleArray, first, last) {
if (visibleArray.length === 0) return
last = Math.min(last, visibleArray.length - 1)
if (first > last) return
var keys = []
var keep = {}
var span = last - first + 1
var keepLo = Math.max(0, first - span)
var keepHi = Math.min(visibleArray.length - 1, last + span)
// A group-header pseudo-row has no artwork of its own; skipping it here
// keeps `undefined` out of the keep-set and the pending count.
for (var i = keepLo; i <= keepHi; i++)
if (visibleArray[i].kind !== "group-header") keep[visibleArray[i].artKey] = true
var m = artMap
var pending = 0
for (i = first; i <= last; i++) {
if (visibleArray[i].kind === "group-header") continue
var k = visibleArray[i].artKey
if (visibleArray[i].imageUrl !== "") {
keys.push(k)
if (!m[k]) pending++
}
}
// Drives the skeleton Timer + the per-item cap (see the pulse block).
artWindowFirst = first
artPending = pending
var changed = false
for (var key in m) {
if (!keep[key]) { delete m[key]; changed = true }
}
if (changed) artMap = Object.assign({}, m)
QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.artMap
// One fewer shimmering tile — when the count hits 0 the pulse
// Timer stops on its own.
if (!m[key] && root.artPending > 0) root.artPending--
m[key] = path
// Rebind requires a NEW object reference (same-ref assignment
// is not a change in QML).
root.artMap = Object.assign({}, m)
}
// Favourite state settled in the store. Patched IN PLACE, and
// `feedChanged()` is deliberately NOT called — that is the WHOLE fix
// for "clicking a heart in Library > All throws me back to the top".
//
// feedChanged() -> `visibleRows` re-derives -> a NEW array reaches
// `model:` -> QQuickItemView::setModel() -> the view resets its
// scroll offset to 0 and rebuilds every delegate.
//
// Scrolled to row 40 in a 3,000-row feed, one heart click sent the
// user back to row 0. Nothing on screen needed the re-derive: the
// grid cards each listen to this same signal for their own key
// (cards/AlbumCard.qml, TrackCard.qml, PlaylistCard.qml,
// ArtistCard.qml) and the list rows do the same
// (library/FeedListRow.qml).
// The in-place mutation is for the delegates built LATER — when the
// user scrolls, re-filters or switches tab — and it reaches them
// because `visibleRows` pushes the very same row objects.
//
// Same reasoning as onPinChanged below; the two are now symmetric.
function onLibraryFavoriteChanged(key, value) {
var f = root.feed
for (var i = 0; i < f.length; i++) {
if (f[i].artKey === key) { f[i].isFavorite = value; break }
}
}
// Pin state settled in the store. The row is patched IN PLACE and the
// feed is deliberately NOT re-signalled: `feedChanged()` re-derives
// `visibleRows`, which swaps the GridView/ListView model and tears
// down every delegate — per pin click. The glyphs on screen do not
// need it, because each card listens to this same signal for its own
// key (cards/AlbumCard.qml); the mutation here is for the delegates
// built LATER, when the user scrolls or re-filters. `visibleRows`
// pushes the very same row objects, so both see the new value.
function onPinChanged(key, value) {
var f = root.feed
for (var i = 0; i < f.length; i++) {
if (f[i].artKey === key) { f[i].isPinned = value; break }
}
}
}
// Debounced window reporting (180ms, library_all.rs throttle).
//
// `pendingRows` is the array the band indexes into. It is null for every
// body that scrolls `visibleRows` itself (grid, list, albums list,
// playlists list) and non-null ONLY for a body whose mounted order is not
// `visibleRows` — today that is the Artists sidepanel rail, which sorts
// A-Z and interleaves letter headers, so its ListView index N is NOT
// `visibleRows[N]`. Reporting the rail against `visibleRows` would name
// the wrong artists; reporting nothing (what it did until now) left
// `artMap` empty for every row it mounts and every rail avatar fell to
// the placeholder.
Timer {
id: windowDebounce
interval: 180
onTriggered: root.reportWindow(
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast)
property int pendingFirst: 0
property int pendingLast: 0
property var pendingRows: null
}
/// `rows` is optional — omit it and the band is read off `visibleRows`.
function queueWindowReport(first, last, rows) {
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
windowDebounce.pendingRows = rows === undefined ? null : rows
windowDebounce.restart()
}
// All-tab search settle -> re-report the first window so the filtered
// head gets covers. Lives at ROOT scope (its callee already does) so it
// survives the search control it used to be parented to.
Timer {
id: allSearchDebounce
interval: 250
onTriggered: root.queueWindowReport(0, 59)
}
// ------------------- per-row play (PARITY-DEBT #5) -------------------
// Every track row in this view queues the list the user is LOOKING AT,
// anchored on the clicked row — it does NOT play one track and stop.
//
// Slint: playback.rs:3518-3524
// order_by_visible(FavoritesState.tracks-visible, // rendered order
// favorites::play_tracks(), // FAV_CURRENT
// clicked_id) -> play_tracks(tracks, idx)
// and, when order_by_visible returns None (the clicked row does not
// resolve inside the visible list), the single-track fallback at :3620.
//
// The visible ORDER is the whole point: it carries the tab, the search,
// the sort and the genre/source filters. In this port that order exists
// only here (the feed is derived in JS), so it goes down as a JSON id
// array and `library_qt::order_by_visible` resolves it against the feed.
// `visibleRows` is THE derived model both views bind to — never read
// `grid.model` / `list.model` back off a view (see the note on it).
function visibleTrackIds() {
var rows = root.visibleRows
var ids = []
for (var i = 0; i < rows.length; i++)
if (rows[i].kind === "track") ids.push(rows[i].id)
return ids
}
function playTrackInContext(trackId) {
QbzLibrary.libraryPlayVisible(JSON.stringify(visibleTrackIds()), trackId)
}
// Track context-menu model (TrackCard.slint track-menu) + dispatch —
// shared by the LIST rows here (the grid card carries its own copy in
// TrackCard.qml).
// `favorite` is passed in rather than read off `item`: the row owns the
// live state (see FeedListRow.favorite), `item.isFavorite` is only its
// seed.
function trackMenuModel(item, favorite) {
var m = [
{ "label": QbzSession.tr("Play", 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" },
]
if (item.artistId !== "") m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
if (item.albumId !== "") m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc", "action": "go-album" })
m.push({ "label": favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
"icon": favorite ? "heart-filled" : "heart", "action": "favorite" })
return m
}
// Takes the ROW, not the item — "favorite" has to go through the row's
// own property (a write to `item.isFavorite` notifies nothing).
function trackAction(row, a) {
var item = row.item
if (a === "play") root.playTrackInContext(item.id)
else if (a === "next") QbzPlayer.enqueueTrack(item.id, "next")
else if (a === "later") QbzPlayer.enqueueTrack(item.id, "later")
else if (a === "queue") QbzPlayer.enqueueTrack(item.id, "queue")
else if (a === "go-artist") QbzArtist.openArtist(item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(item.albumId)
else if (a === "favorite") row.toggleFavorite()
}
// ============================ view ===================================
// Offline gate (OfflinePlaceholder replica; the Slint offline RAIL is
// out of scope — see header note).
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
Column {
anchors.fill: parent
spacing: 0
visible: !QbzSession.offline
LibraryToolbar {
id: toolbar
width: parent.width
view: root
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// ===================== SCROLLING CONTENT =========================
Item {
id: content
width: parent.width
height: parent.height - 57
clip: true
// The tracks bulk bar is pinned above the list (the Slint scrolls
// it away with the page; here the list IS the scrolling view).
readonly property int barInset:
(root.activeTab === "tracks" && root.tracksMultiSelect) ? 52 : 0
// Loading skeleton — a deliberate ADDITION: the Slint mounts a
// bare centred 36px LoadingSpinner here (FavoritesView.slint:
// 956/1012), which says "busy" but not "this is the shape of
// what is coming". The placeholders take the exact footprint of
// the cells that will replace them (200x246 grid cells / 44-50px
// rows), so the first paint reads as the page filling in.
//
// COST: the layer only exists while `libraryLoading` — the
// Repeater models collapse to 0 otherwise, so nothing is mounted
// and nothing animates once the feed lands. The mounted count is
// capped to the viewport (never the model), and QbzSkeleton caps
// ANIMATED instances at 48; the pulse itself is one shared bool.
Item {
id: skelLayer
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
visible: QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
// Which shape the tab is about to render.
readonly property bool listShape:
(root.activeTab === "all" && root.viewMode === "list")
|| root.activeTab === "tracks"
|| (root.activeTab === "albums" && root.albumsView === "list")
|| (root.activeTab === "playlists" && root.playlistsView === "list")
readonly property int cols: Math.max(1, Math.floor(width / 220))
readonly property int rows: Math.max(1, Math.ceil(height / 266))
readonly property int rowHeight: root.activeTab === "tracks" ? 50
: root.activeTab === "albums" ? 64
: root.activeTab === "playlists" ? 60 : 44
Grid {
columns: skelLayer.cols
columnSpacing: 20
rowSpacing: 20
Repeater {
model: (skelLayer.visible && !skelLayer.listShape)
? Math.min(48, skelLayer.cols * skelLayer.rows) : 0
delegate: QbzSkeleton {
required property int index
variant: "card"
// The real cell exactly: GridView cellWidth 220 /
// cellHeight 266 = 200x246 + the 20px gaps.
width: 200
height: 246
phase: root.skelPhase
cellIndex: index
}
}
}
Column {
width: parent.width
spacing: 6
Repeater {
model: (skelLayer.visible && skelLayer.listShape)
? Math.min(48, Math.ceil(skelLayer.height / (skelLayer.rowHeight + 6))) : 0
delegate: QbzSkeleton {
required property int index
variant: "row"
width: skelLayer.width
height: skelLayer.rowHeight
phase: root.skelPhase
cellIndex: index
}
}
}
}
// Error + retry.
Column {
visible: !QbzLibrary.libraryLoading && QbzLibrary.libraryError !== ""
anchors.centerIn: parent
spacing: 10
Text {
text: QbzSession.tr("Couldn't load your Library.", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
text: QbzLibrary.libraryError
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Rectangle {
width: retryText.implicitWidth + 28
height: 32
radius: 6
anchors.horizontalCenter: parent.horizontalCenter
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: QbzSession.tr("Retry", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzLibrary.reloadLibrary()
}
}
}
readonly property bool ready:
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
// Which surface owns the tab right now — one predicate per body,
// so no two can mount at once.
readonly property bool showAlbumsList:
content.ready && root.activeTab === "albums" && root.albumsView === "list"
readonly property bool showPlaylistsList:
content.ready && root.activeTab === "playlists" && root.playlistsView === "list"
readonly property bool showArtistsPanel:
content.ready && root.activeTab === "artists" && root.artistsView === "sidepanel"
readonly property bool showList:
content.ready && ((root.activeTab === "all" && root.viewMode === "list")
|| root.activeTab === "tracks")
readonly property bool showGrid:
content.ready && !content.showList && !content.showAlbumsList
&& !content.showPlaylistsList && !content.showArtistsPanel
// ============ GRID (all tabs; All in grid mode) ==============
// HEIGHT-GATED, and that is the load-bearing part — see the
// `showGrid`/`showList`/... predicates above, which already say
// "one predicate per body, so no two can mount at once". They did
// not achieve that: `visible: false` stops a view from PAINTING,
// but a QQuickItemView that still has a viewport and a model keeps
// refilling it. Measured 2026-08-17 with a per-view creation
// counter: on the Albums tab the grid built 51 delegates and the
// hidden list built 47 alongside it; on Tracks the list built 47
// and the hidden GRID built 51 — the expensive direction, since a
// FeedGridCell carries a cover. Half of every Library mount was
// being spent on the body that was not showing.
//
// Collapsing the height to 0 is what actually stops the refill.
// The obvious alternative — `model: visible ? root.visibleRows :
// []` — is the one thing this file spends a paragraph warning
// against (see onLibraryFavoriteChanged): handing `model` a new
// value goes through QQuickItemView::setModel(), which resets the
// scroll offset to 0 and rebuilds every delegate. The height gate
// leaves `model` untouched.
GridView {
id: grid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 32
anchors.rightMargin: root.alphaVisible ? 52 : 32
anchors.topMargin: 16
height: grid.visible ? parent.height - 16 : 0
visible: content.showGrid && root.activeTab !== "tracks"
cellWidth: 220
cellHeight: 266
cacheBuffer: 266 * 2
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.visibleRows
onContentYChanged: root.gridWindowReport()
onModelChanged: root.gridWindowReport()
onWidthChanged: root.gridWindowReport()
onVisibleChanged: root.gridWindowReport()
// onHeightChanged IS THE ONE THAT WAS MISSING, and its absence
// is why this grid loaded exactly ONE ROW of covers and then
// nothing until the user scrolled.
//
// `height` is gated on `visible` two lines up — that is the
// 2026-08-17 fix that stops a hidden body from refilling, and
// it stays. But it means the height is 0 right up until the
// body appears, and QML gives no ordering between "the
// `visible` change handler runs" and "the `height` binding that
// depends on `visible` re-evaluates". When the handler wins,
// `gridWindowReport` reads height 0 and computes
// `lastRow = ceil(0/266) + 1 = 1`, i.e. a window of exactly
// `cols` items — one row, six covers on the owner's window, and
// that is precisely what the screencast shows. Nothing fires
// again until `onContentYChanged`, which is why a nudge of the
// scrollbar "fixed" it.
//
// The control case is in this same tree: `LibraryAlbumsList`
// reports on `onHeightChanged` and does not have the defect.
// The width already had its handler; the height never got one
// because before the visibility gate the height never changed.
onHeightChanged: root.gridWindowReport()
Component.onCompleted: root.gridWindowReport()
delegate: FeedGridCell {
required property var modelData
required property int index
view: root
item: modelData
// Viewport-relative so the skeleton's 48-instance cap
// follows the window, not the (possibly 10k-item) model.
cellIndex: Math.max(0, index - root.artWindowFirst)
}
}
// ============ LIST (All list mode + Tracks tab) ==============
// Height-gated for the reason spelled out on the grid above.
ListView {
id: list
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 32
anchors.rightMargin: root.alphaVisible ? 52 : 32
anchors.topMargin: 10 + content.barInset
height: list.visible ? parent.height - (10 + content.barInset) : 0
visible: content.showList
cacheBuffer: 44 * 10
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.visibleRows
onContentYChanged: root.listWindowReport()
onModelChanged: root.listWindowReport()
onVisibleChanged: root.listWindowReport()
// Same missing trigger, same `height: visible ? … : 0` gate,
// same one-window-of-stale-zero — see the grid above.
onHeightChanged: root.listWindowReport()
Component.onCompleted: root.listWindowReport()
delegate: Loader {
required property var modelData
required property int index
width: list.width
height: modelData && modelData.kind === "group-header"
? 34
: (root.activeTab === "tracks" ? 50 : 44)
Component {
id: listHeaderComp
Item {
Text {
x: 2
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 4
text: modelData.title
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
}
}
Component {
id: trackRowComp
TrackRow {
item: modelData
number: modelData._no || (index + 1)
selectMode: root.tracksMultiSelect
checked: root.tracksSelected[modelData.id] === true
onToggleSelect: function (mods) { root.toggleTrackSelected(modelData.id, mods) }
onPlayRequested: root.playTrackInContext(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// The source comes off the ROW: every feed row
// carries its own word (library_qt.rs:54 —
// "qobuz" | "local" | "plex"), so a Plex or local
// row can never be stored as a Qobuz id again.
// "plex" is folded into "local" because the store
// has no Plex source: `AddItem.source` is
// "qobuz" | "local" and `source_from_str`
// (myqbz_add_qt.rs:85-90) maps everything that is
// not "local" to Qobuz — passing "plex" verbatim
// would silently become "qobuz" again.
//
// Today this list only ever holds Qobuz rows (the
// Tracks tab keeps `kind === "track" && group ===
// "favorites"` and the local layer lands in group
// "local"), so this is a no-op until that filter
// changes — which is the point: it stays right
// when it does.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track",
"source": item.source === "local" || item.source === "plex"
? "local" : "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
Component {
id: feedRowComp
FeedListRow {
view: root
item: modelData
rowIndex: Math.max(0, index - root.artWindowFirst)
}
}
sourceComponent: (modelData && modelData.kind === "group-header")
? listHeaderComp
: (root.activeTab === "tracks" ? trackRowComp : feedRowComp)
}
}
// ============ Albums LIST mode ===============================
LibraryAlbumsList {
id: albumsList
visible: content.showAlbumsList
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: root.alphaVisible ? 52 : 32
anchors.topMargin: 16
view: root
rows: content.showAlbumsList ? root.visibleRows : []
}
// ============ Playlists LIST mode ============================
// FavoritesView.slint:1935 — the same ViewToggle the albums tab
// has, over primitives/PlaylistListRow.
ListView {
id: playlistsList
visible: content.showPlaylistsList
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
clip: true
spacing: 2
cacheBuffer: 60 * 8
boundsBehavior: Flickable.StopAtBounds
model: content.showPlaylistsList ? root.visibleRows : []
// Gated on visibility for the same reason gridWindowReport and
// listWindowReport are: `artMap` is ONE map, a report PRUNES
// outside its band, and this body still fires while hidden —
// `model` flips to [] on every tab switch (onModelChanged) and
// `anchors.fill` makes every window resize an onHeightChanged.
// Ungated, a resize on the Artists sidepanel evicted the rail's
// avatars and requested a `visibleRows` band the rail is not
// even showing (its order is A-Z, not feed order).
function report() {
if (!playlistsList.visible) return
var first = Math.max(0, Math.floor(playlistsList.contentY / 62) - 4)
var last = Math.ceil((playlistsList.contentY + playlistsList.height) / 62) + 4
root.queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
}
onContentYChanged: playlistsList.report()
onModelChanged: playlistsList.report()
onHeightChanged: playlistsList.report()
onVisibleChanged: playlistsList.report()
Component.onCompleted: playlistsList.report()
delegate: PlaylistListRow {
required property var modelData
required property int index
width: playlistsList.width
item: modelData
rowIndex: index
artSource: root.artMap[modelData.artKey] || ""
covers: modelData.playlistOwnImage === true ? [] : (modelData.covers || [])
}
}
// ============ Artists SIDEPANEL mode =========================
// Behind a Loader so LEAVING the mode really destroys it: the
// panel drops its Rust-side selection in Component.onDestruction,
// and it also stops rebuilding its rail on every feed change while
// the user is on another tab.
Loader {
anchors.fill: parent
active: content.showArtistsPanel
sourceComponent: LibraryArtistsPanel { view: root }
}
// Declared AFTER every scrolling body: declaration order IS
// z-order, and this bar overlays the top of the content area.
// ---- Tracks multi-select bar (FavoritesView.slint:1570) -----
QbzMultiSelectBar {
id: tracksBar
visible: root.activeTab === "tracks" && root.tracksMultiSelect && content.ready
anchors.top: parent.top
anchors.topMargin: 10
anchors.left: parent.left
anchors.leftMargin: 32
anchors.right: parent.right
anchors.rightMargin: 32
selectedCount: root.tracksSelectedCount
// The reference's inventory MINUS "Make available offline":
// this port brings up no offline cache at all (src/
// library_bulk.rs names the check), and a bulk bar must not
// render a control that no-ops. Everything else is wired.
// "heart-crack" is likewise not in this port's icon set, so
// "Remove from Library" uses the same heart + danger pair
// views/local/LocalTracksTab.qml already ships.
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "remove-selected", "label": QbzSession.tr("Remove from Library", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
]
onAction: function (id) { root.tracksBulkAction(id) }
}
// A-Z jump strip, right edge (FavoritesView.slint:1683 / :1992 /
// :2004 all pin it at `parent.width - self.width - 20px`).
QbzAlphaStrip {
visible: root.alphaVisible
anchors.right: parent.right
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 16
anchors.bottom: parent.bottom
jumps: root.alphaJumps
onJump: function (ordinal, index) { root.alphaJumpTo(index) }
}
// Thin auto-hiding scrollbars (ListScrollbar replica).
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: grid; scope: "library:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: root.alphaVisible ? 34 : 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: grid
visible: grid.visible && grid.contentHeight > grid.height
}
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: list; scope: "library:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: root.alphaVisible ? 34 : 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
visible: list.visible && list.contentHeight > list.height
}
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: playlistsList
visible: playlistsList.visible
&& playlistsList.contentHeight > playlistsList.height
}
}
}
// Both run from onContentYChanged, i.e. from INSIDE QQuickItemView::
// setModel() — see the visibleRows comment. Everything they touch on the
// view (width/height/contentY/cellWidth/cellHeight) is a plain qreal on
// the private; the array comes from root, never from the view.
//
// Both early-return while their body is hidden. `artMap` is ONE map and a
// report PRUNES everything outside the reported band, so a hidden body
// that still fires (the grid's rightMargin changes when `alphaVisible`
// flips; the list's `onModelChanged` fires on every tab switch because its
// `model` is bound to `visibleRows` unconditionally) would evict the
// covers of whichever body is actually on screen — the Artists sidepanel
// rail above all, since it is the one body whose band is NOT a
// `visibleRows` band. `onVisibleChanged` keeps the gate from swallowing
// the first report of a body that becomes visible after its model landed.
function gridWindowReport() {
if (!grid.visible) return
var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
var firstRow = Math.max(0, Math.floor(grid.contentY / grid.cellHeight) - 1)
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 1
var m = root.visibleRows
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1))
}
function listWindowReport() {
if (!list.visible) return
var first = Math.max(0, Math.floor(list.contentY / 44) - 4)
var last = Math.ceil((list.contentY + list.height) / 44) + 4
queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
}
// ============================ overlay =================================
// Declared LAST: declaration order IS z-order, so the popup sits above
// the toolbar and the grid and keeps its own presses. Hidden AND
// disabled while closed, so it never eats a click meant for the view.
// Its "library-all" context is independent from Discover's "discover"
// one — each button carries its own count.
GenreFilterPopup {
id: libGenrePopup
anchors.fill: parent
context: "library-all"
// Under the 56px toolbar, right-aligned like the Slint overlay.
anchorTop: 62
anchorRight: 32
}
}