Files
qbz/crates/qbz-qt/qml/views/LocalLibraryView.qml
T
vicrodh ad07d8c14e feat(qt): Library Explorer columns select a run with Shift
A facet column already took Ctrl as "add or remove one" and a plain click
as "only this". Shift now picks the run from the last clicked value and
Ctrl+Shift adds that run, the file-list rule (SelectionModel.nextExclusive),
each column over its values as listed. A column whose values are rebuilt by
a change upstream starts without an anchor.

cc
2026-09-14 10:31:48 -06:00

1753 lines
80 KiB
QML

// Local Library — composition root for the QML port of
// crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint (the shipping
// behaviour; that file is the ONLY reference).
//
// This file owns FOUR things and nothing else:
// 1. state — the Slint LocalLibraryState/LibAlbumFilterState fields;
// 2. the derived documents (search / sort / filter / grouping / A-Z), in
// JS, because QbzLocal publishes ONE JSON document per surface (the
// library_qt.rs transport rationale);
// 3. the fixed chrome (title row, tab bar, toolbar, divider) and which tab
// body is mounted;
// 4. the ARTWORK WINDOW REGISTRY (`_windows` + `artMap`, below). This one
// is here — and pushes the file past the 500-line guideline — because it
// is the one policy that CANNOT be split per surface: several cover
// surfaces are mounted at once (rail + grid, subfolders + track rows)
// and eviction is only correct when it is decided against all of them
// together. Splitting it per body is exactly the bug it replaced.
// Every body, rail, pane, popup and row lives in qml/views/local/.
//
// CHROME (identical to FavoritesView / LibraryView, per the Slint header):
// row 1 "Local Library" title + settings gear + Plex sync
// row 2 segmented tab bar (Albums / Artists / Folders / Tracks, with
// count badges) on the left, the per-tab toolbar floated right
// 1px divider, then ONE content area.
//
// PERFORMANCE (this is the surface with the documented Slint freeze at 16K+
// tracks):
// - Tracks is SERVER-paginated (500/page, appended on scroll);
// - every list/grid is a windowing view (ListView over a chunk model),
// never a Repeater over the full array — including the grouped arms and
// the artist rail;
// - the folder tree is a FLAT array windowed by a ListView, levels fetched
// lazily on expand;
// - artwork is id-keyed (artKey), requested only for the MOUNTED window,
// resolved to a 256px thumbnail in Rust, and evicted from `artMap` once
// it falls a full window away from the viewport.
//
// The local ALBUM detail is no longer inline: it is a routed page,
// views/LocalAlbumView.qml, exactly as the Slint routes to
// album/LocalAlbumView.slint.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
import "local"
Rectangle {
id: root
// Transparent while the ambient background is active (the frosted
// content panel shows through — AppShell's contentFrame owns the fill).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (QML clips are rectangular).
radius: 12
QbzTheme { id: theme }
// Force construction before Rust publishes the first bounded page. The
// hand-written singleton owns the resident LRU; keeping this reference at
// the composition root also gives every Tracks delegate one stable model.
readonly property var nativeTracksModel: QbzLocalTracks
readonly property var nativeAlbumsModel: QbzLocalAlbums
readonly property var nativeArtistsModel: QbzLocalArtists
readonly property var nativeArtistAlbumsModel: QbzLocalArtistAlbums
// ============================ state ==================================
// One app-wide order drives this bar, both nav flyouts and logged-off
// startup. Rust seeds the key synchronously before the full settings
// snapshot, so the first frame never flashes a different default.
readonly property var localTabOrder: {
try {
var stored = JSON.parse(QbzBridge.settingsJson).localTabOrder
if (Array.isArray(stored) && stored.length > 0) return stored
} catch (e) { /* construction fallback below */ }
return ["genres", "albums", "artists", "folders", "tracks"]
}
readonly property string localDefaultTab:
localTabOrder.length > 0 ? localTabOrder[0] : "genres"
readonly property string genreFiltersPosition: {
try {
var value = JSON.parse(QbzBridge.settingsJson).genreFiltersPosition || "top"
if (["top", "right", "left", "bottom"].indexOf(value) >= 0) return value
} catch (e) { /* construction fallback below */ }
return "top"
}
property string activeTab: localDefaultTab
// Router requests go through the same history path as the in-view bar.
// The sequence makes reselecting a flyout tab after an in-view change work.
property var tabNavigationRequest: ({})
onTabNavigationRequestChanged: {
if (tabNavigationRequest.tab) activateTab(tabNavigationRequest.tab)
}
function activateTab(tab) {
if (tab === activeTab) return
if (localTabOrder.indexOf(tab) < 0 && !(tab === "ephemeral" && ephemeralActive)) return
if (_navigationReady && !_restoringNavigationState && QbzShell.currentView === "local")
QbzShell.recordLocalTab(tab, navigationStateJson)
activeTab = tab
}
// Session-only file reachability. Keys are `source:id`; values are the
// user-facing reason. This is deliberately not persisted: a moved file or
// unavailable NAS must remain retryable and a rescan remains authoritative.
property var localTrackErrors: ({})
function localTrackErrorKey(source, id) {
return (source || "local") + ":" + String(id || "")
}
// Albums tab.
property string albumsSearch: ""
property string albumsSort: "artist-asc"
property string albumsGroup: "off" // off | alpha | artist
property string albumsView: "grid" // grid | list
property bool albumsMultiSelect: false
property var albumsSelected: ({})
readonly property int albumsSelectedCount: root.albumsNativeViewActive
? QbzLocal.localAlbumsNativeSelectedCount
: Object.keys(albumsSelected).length
// Folders tab.
property string foldersMode: "tree" // flat | tree
property string foldersSearch: ""
property string foldersSort: "artist-asc"
property string foldersGroup: "off"
property string foldersGridView: "grid"
property string treeSearch: ""
property bool treeSelectMode: false
property string selectedFolder: ""
property string folderDetailSearch: ""
property string folderDetailView: "grid"
property int treeRailWidth: 272
// Artists tab.
property string artistsSearch: ""
property string artistsSort: "name-asc"
property string selectedArtist: ""
// Tracks tab.
property string tracksSearch: ""
// off | album | artist | name — PERSISTED, so it is owned by the bridge
// exactly like the Tracks sort and the album-identity mode, not by a QML
// default. It used to be a plain `property string tracksGroup: "off"`:
// `locallibrary_ui.json` carried the user's choice across restarts and
// nothing ever read it back into the UI (PARITY-DEBT #13).
readonly property string tracksGroup: QbzLocal.localTracksGroup
property bool tracksMultiSelect: false
property var tracksSelected: ({})
readonly property int tracksSelectedCount: QbzLocal.localTracksNativeActive
? QbzLocal.localTracksNativeSelectedCount
: Object.keys(tracksSelected).length
// Genres column browser. Selections are maps so Ctrl/Cmd toggles stay
// O(1), and an empty map is the explicit All state.
property string genresSearch: ""
property string genreYearsSearch: ""
property string genreArtistsSearch: ""
property string genreAlbumsSearch: ""
property var selectedGenres: ({})
property var selectedGenreYears: ({})
property var selectedGenreArtists: ({})
property var selectedGenreAlbums: ({})
// Which leading Library Explorer facet is mounted. Genre remains the
// default; "both" deliberately adds a fourth column for larger screens.
// Seeded from locallibrary_ui.json; navigation-state restoration may
// temporarily reassign it while rebuilding a Back/Forward snapshot.
property string explorerColumns: QbzLocal.localExplorerColumns // genre | year | both
readonly property int selectedGenreCount: Object.keys(selectedGenres).length
readonly property int selectedGenreYearCount: Object.keys(selectedGenreYears).length
readonly property int selectedGenreArtistCount: Object.keys(selectedGenreArtists).length
readonly property int selectedGenreAlbumCount: Object.keys(selectedGenreAlbums).length
property bool genresBrowserCollapsed: false
property string genresView: "details" // grid | list | details
property string genresSort: "title-asc"
// Routed history state. Scroll is handled independently by ScrollMemory;
// this document restores the controls that determine what that offset
// means before the tab asks for data.
property bool _restoringNavigationState: false
property bool _navigationReady: false
readonly property string navigationStateJson: JSON.stringify({
activeTab: root.activeTab,
albumsSearch: root.albumsSearch,
albumsSort: root.albumsSort,
albumsGroup: root.albumsGroup,
albumsView: root.albumsView,
albumsFilter: root.albumsFilter,
artistsFilter: root.artistsFilter,
tracksFilter: root.tracksFilter,
genresFilter: root.genresFilter,
foldersMode: root.foldersMode,
foldersSearch: root.foldersSearch,
foldersSort: root.foldersSort,
foldersGroup: root.foldersGroup,
foldersGridView: root.foldersGridView,
treeSearch: root.treeSearch,
selectedFolder: root.selectedFolder,
folderDetailSearch: root.folderDetailSearch,
folderDetailView: root.folderDetailView,
treeRailWidth: root.treeRailWidth,
artistsSearch: root.artistsSearch,
artistsSort: root.artistsSort,
selectedArtist: root.selectedArtist,
tracksSearch: root.tracksSearch,
genresSearch: root.genresSearch,
genreYearsSearch: root.genreYearsSearch,
genreArtistsSearch: root.genreArtistsSearch,
genreAlbumsSearch: root.genreAlbumsSearch,
selectedGenres: root.selectedGenres,
selectedGenreYears: root.selectedGenreYears,
selectedGenreArtists: root.selectedGenreArtists,
selectedGenreAlbums: root.selectedGenreAlbums,
explorerColumns: root.explorerColumns,
genresBrowserCollapsed: root.genresBrowserCollapsed,
genresView: root.genresView,
genresSort: root.genresSort
})
onNavigationStateJsonChanged: {
if (root._navigationReady && !root._restoringNavigationState
&& QbzShell.currentView === "local")
QbzShell.reportNavState("local", root.navigationStateJson)
}
function restoreNavigationState() {
var state = QbzShell.restoreStateScope === "local" && QbzShell.stateRestore !== ""
? QbzShell.stateRestore : QbzShell.localNavigationState()
if (state === "") return
var saved
try { saved = JSON.parse(state) }
catch (e) { QbzShell.restoreStateScope = ""; return }
root._restoringNavigationState = true
function text(name, fallback) {
return typeof saved[name] === "string" ? saved[name] : fallback
}
root.activeTab = text("activeTab", root.activeTab)
root.albumsSearch = text("albumsSearch", root.albumsSearch)
root.albumsSort = text("albumsSort", root.albumsSort)
root.albumsGroup = text("albumsGroup", root.albumsGroup)
root.albumsView = text("albumsView", root.albumsView)
if (saved.albumsFilter && typeof saved.albumsFilter === "object")
root.albumsFilter = saved.albumsFilter
else if (saved.filter && typeof saved.filter === "object")
root.albumsFilter = saved.filter
if (saved.artistsFilter && typeof saved.artistsFilter === "object")
root.artistsFilter = saved.artistsFilter
if (saved.tracksFilter && typeof saved.tracksFilter === "object")
root.tracksFilter = saved.tracksFilter
if (saved.genresFilter && typeof saved.genresFilter === "object")
root.genresFilter = saved.genresFilter
// Older history entries kept four independent funnels. Adopt the one
// belonging to the restored tab as the shared quality/format/source
// state, while preserving the Albums/Genres-only favorite predicate.
var restoredFilter = root.activeTab === "tracks" ? root.tracksFilter
: root.activeTab === "artists" ? root.artistsFilter
: root.activeTab === "genres" ? root.genresFilter
: root.albumsFilter
if (root.albumsFilter.favorite === true
|| root.genresFilter.favorite === true) {
restoredFilter = Object.assign({}, restoredFilter)
restoredFilter.favorite = true
}
root.synchronizeFilters(root.activeTab, restoredFilter, false)
root.foldersMode = text("foldersMode", root.foldersMode)
root.foldersSearch = text("foldersSearch", root.foldersSearch)
root.foldersSort = text("foldersSort", root.foldersSort)
root.foldersGroup = text("foldersGroup", root.foldersGroup)
root.foldersGridView = text("foldersGridView", root.foldersGridView)
root.treeSearch = text("treeSearch", root.treeSearch)
root.selectedFolder = text("selectedFolder", root.selectedFolder)
root.folderDetailSearch = text("folderDetailSearch", root.folderDetailSearch)
root.folderDetailView = text("folderDetailView", root.folderDetailView)
if (typeof saved.treeRailWidth === "number") root.treeRailWidth = saved.treeRailWidth
root.artistsSearch = text("artistsSearch", root.artistsSearch)
root.artistsSort = text("artistsSort", root.artistsSort)
root.selectedArtist = text("selectedArtist", root.selectedArtist)
root.tracksSearch = text("tracksSearch", root.tracksSearch)
root.genresSearch = text("genresSearch", root.genresSearch)
root.genreYearsSearch = text("genreYearsSearch", root.genreYearsSearch)
root.genreArtistsSearch = text("genreArtistsSearch", root.genreArtistsSearch)
root.genreAlbumsSearch = text("genreAlbumsSearch", root.genreAlbumsSearch)
if (saved.selectedGenres && typeof saved.selectedGenres === "object")
root.selectedGenres = saved.selectedGenres
if (saved.selectedGenreYears && typeof saved.selectedGenreYears === "object")
root.selectedGenreYears = saved.selectedGenreYears
if (saved.selectedGenreArtists && typeof saved.selectedGenreArtists === "object")
root.selectedGenreArtists = saved.selectedGenreArtists
if (saved.selectedGenreAlbums && typeof saved.selectedGenreAlbums === "object")
root.selectedGenreAlbums = saved.selectedGenreAlbums
var savedExplorerColumns = text("explorerColumns", root.explorerColumns)
if (["genre", "year", "both"].indexOf(savedExplorerColumns) >= 0)
root.explorerColumns = savedExplorerColumns
if (typeof saved.genresBrowserCollapsed === "boolean")
root.genresBrowserCollapsed = saved.genresBrowserCollapsed
root.genresView = text("genresView", root.genresView)
root.genresSort = text("genresSort", root.genresSort)
QbzShell.restoreStateScope = ""
root._restoringNavigationState = false
QbzShell.reportNavState("local", root.navigationStateJson)
}
Connections {
target: QbzShell
function onStateRestoreChanged() {
// Back/Forward between tabs keeps this component mounted: there
// is no Component.onCompleted to consume the destination state.
if (root._navigationReady && QbzShell.currentView === "local"
&& QbzShell.restoreStateScope === "local" && QbzShell.stateRestore !== "") {
root.restoreNavigationState()
root.loadActiveTab()
}
}
}
Timer {
id: navigationSeed
interval: 0
onTriggered: {
// Initial persisted state, pending artist and flyout landing tab
// are one arrival, not separate history entries.
root._navigationReady = true
QbzShell.reportNavState("local", root.navigationStateJson)
}
}
// One quality/format/source funnel follows the user across Albums,
// Artists, Genres and Tracks. Favorites remains an Albums/Genres-only
// predicate, but it survives visits to tabs where that chip is absent.
property bool filterOpen: false
// SEEDED FROM THE BRIDGE, not defaulted to `{}`. This view is DESTROYED on
// every navigation away (see the bridge's `albums_filter` doc), so a
// view-local default meant the funnel reset the moment the user visited
// Discover — and never survived a restart at all. `QbzLocal.albumsFilter`
// outlives the view and mirrors `ui_prefs.json`.
property var albumsFilter: root.parseFilter(QbzLocal.albumsFilter)
property var artistsFilter: root.commonFilter(albumsFilter)
property var tracksFilter: root.commonFilter(albumsFilter)
property var genresFilter: Object.assign({}, albumsFilter)
// Live heart fan-out. Album JSON/native pages are immutable snapshots;
// this tiny id map updates every mounted card and the Favorites-only
// predicate without republishing thousands of album rows (and therefore
// without moving the scroll position).
property var favoriteOverrides: ({})
readonly property bool albumsNativeViewActive:
QbzLocal.localAlbumsNativeActive
&& albumsFilter.favorite !== true
&& activeTab === "albums"
readonly property var filter: activeTab === "artists" ? artistsFilter
: activeTab === "tracks" ? tracksFilter
: activeTab === "genres" ? genresFilter
: albumsFilter
function parseFilter(json) {
if (!json || json === "") return ({})
try { return JSON.parse(json) || ({}) } catch (e) { return ({}) }
}
function commonFilter(value) {
var out = Object.assign({}, value || ({}))
delete out.favorite
return out
}
function synchronizeFilters(sourceTab, value, persist) {
var wasFavoriteOnly = albumsFilter.favorite === true
var favorite = sourceTab === "albums" || sourceTab === "genres"
? value.favorite === true : wasFavoriteOnly
var common = commonFilter(value)
var albumValue = Object.assign({}, common)
var genreValue = Object.assign({}, common)
if (favorite) {
albumValue.favorite = true
genreValue.favorite = true
}
albumsFilter = albumValue
artistsFilter = Object.assign({}, common)
tracksFilter = Object.assign({}, common)
genresFilter = genreValue
if (!persist) return
QbzLocal.setAlbumsFilterJson(Object.keys(albumValue).length === 0
? "" : JSON.stringify(albumValue))
// This setter resets the Tracks query, so call it only while Tracks
// is the visible consumer. A later tab switch applies the already
// shared state before loading page one.
if (activeTab === "tracks")
QbzLocal.tracksSetFilterJson(JSON.stringify(common))
if (!wasFavoriteOnly && favorite && activeTab === "albums")
QbzLocal.loadTab("albums-legacy")
}
// A LATER republish still wins: the gates and the saved funnel are
// published together when a media server is connected or removed, and the
// pruning that happens there (a tick whose chip is now hidden) has to
// reach a view that is already open. Guarded on a real difference so
// writing the property back does not bounce.
Connections {
target: QbzLocal
function onAlbumsFilterChanged() {
var next = root.parseFilter(QbzLocal.albumsFilter)
if (JSON.stringify(next) !== JSON.stringify(root.albumsFilter))
root.synchronizeFilters("albums", next, false)
}
function onLocalAlbumFavoriteChanged(id, favorite) {
var next = Object.assign({}, root.favoriteOverrides)
next[id] = favorite
root.favoriteOverrides = next
}
function onLocalTrackAvailability(source, id, message) {
var next = Object.assign({}, root.localTrackErrors)
var key = root.localTrackErrorKey(source, id)
if (message === "") delete next[key]
else next[key] = message
root.localTrackErrors = next
}
}
readonly property int filterCount: {
var n = 0
for (var k in filter) if (filter[k]) n++
return n
}
/// The ONE writer. Both mutators funnel through it, so the four tabs
/// cannot drift into different source/format/quality states.
function setFilter(f) {
synchronizeFilters(activeTab, f, true)
}
function toggleFilter(key) {
var f = Object.assign({}, filter)
f[key] = !f[key]
if (!f[key]) delete f[key]
setFilter(f)
}
function clearFilter() { setFilter({}) }
/// The funnel's state as the applied-filters tooltip wants it: grouped
/// in the popup's own order, each holding the labels of the keys that are
/// on. Built HERE because this is where the state lives — the toolbar only
/// draws the trigger, and `LocalFilterPopup` only draws chips.
///
/// The labels are the popup's, verbatim, so the bubble reads exactly like
/// the control the user set. Two are not translated there either: FLAC /
/// ALAC / APE / WAV / MP3 / AAC and "Plex" are proper names.
readonly property var filterSummaryGroups: {
function pick(keys, labels) {
var out = []
for (var i = 0; i < keys.length; i++)
if (root.filter[keys[i]] === true)
out.push(labels[i])
return out
}
var tr = QbzSession.trRev
return [
{ group: QbzSession.tr("Favorites", tr),
values: pick(["favorite"], [QbzSession.tr("Favorites only", tr)]) },
{ group: QbzSession.tr("Quality", tr),
values: pick(["dsd", "hires", "cd", "lossy"],
["DSD", QbzSession.tr("Hi-Res", tr), QbzSession.tr("CD", tr),
QbzSession.tr("Lossy", tr)]) },
{ group: QbzSession.tr("Format", tr),
values: pick(["flac", "alac", "ape", "wav", "mp3", "aac", "other"],
["FLAC", "ALAC", "APE", "WAV", "MP3", "AAC",
QbzSession.tr("Other", tr)]) },
{ group: QbzSession.tr("Source", tr),
values: pick(["local", "offline", "plex", "jellyfin", "subsonic"],
[QbzSession.tr("Local", tr),
QbzSession.tr("Offline cache", tr), "Plex",
"Jellyfin", "Subsonic"]) }
]
}
// Per-row artwork on the track lists — the Slint gates this on
// AppearanceState.local-library-track-artwork for the freeze reason and
// its default is OFF.
readonly property bool trackArtwork: QbzLocal.localTrackArtwork
readonly property bool ephemeralActive: QbzLocal.localEphemeralActive
/// Badge count for the ephemeral tab. Reads the SAME document the pane
/// renders, so the number on the tab and the rows behind it are one fact.
readonly property int ephemeralTrackCount:
ephemeral && ephemeral.trackCount ? ephemeral.trackCount : 0
/// The ephemeral tab's LABEL — the name of the thing you opened, with no
/// verb in front of it. Computed in Rust (`local_ephemeral::display_label`)
/// and read here, so this view and `NavFlyout` cannot name it differently:
/// the flyout does not parse the ephemeral document and should not start.
///
/// A verb was tried and rejected: "Now Playing" / "Playing: …" is false the
/// moment a folder sits open while something else plays, and the tab would
/// contradict the now-playing bar on the same screen.
readonly property string ephemeralLabel: QbzLocal.localEphemeralLabel
/// The ephemeral tab is the only one that can VANISH while the user stands
/// on it (the folder is closed, the disc is ejected). Without this the view
/// keeps `activeTab === "ephemeral"` with no tab in the bar and no Loader
/// active: a blank content area and no way back except another tab. The
/// user's first ordered surface is the same fallback every other Local
/// Library entry point uses.
onEphemeralActiveChanged: {
if (!ephemeralActive && activeTab === "ephemeral")
activeTab = root.localDefaultTab
}
/// Opening something must LAND you on it. While the pane was an arm of the
/// Folders tab this was implicit — the arm simply took the tab over.
///
/// This watches the open SEQUENCE, not `ephemeralActive`. The flag is
/// already `true` when you open a SECOND folder over a first, so a handler
/// on the flag fires exactly once in the life of the app and then never
/// again — which is precisely the reported symptom: the view stayed on
/// whatever tab you were reading. A sequence changes on every open.
///
/// It also excludes the boot restore by construction:
/// `local_ephemeral::scan` bumps it only when a runtime is passed, and only
/// `open`/`open_path` pass one. A remembered folder therefore never steals
/// the tab you actually navigated to.
Connections {
target: QbzLocal
function onLocalEphemeralOpenSeqChanged() { root.activateTab("ephemeral") }
}
function loadTabForView(tab) {
QbzLocal.loadTab(tab === "albums" && root.albumsFilter.favorite === true
? "albums-legacy" : tab)
}
// ---------------------------- documents ------------------------------
function parseDoc(json, fallback) {
if (json === "") return fallback
try {
return JSON.parse(json)
} catch (e) {
console.warn("[qbz-qt] local: bad document — " + e)
return fallback
}
}
readonly property var counts: parseDoc(QbzLocal.localCountsJson, ({}))
readonly property var albums: parseDoc(QbzLocal.localAlbumsJson, [])
readonly property var artists: parseDoc(QbzLocal.localArtistsJson, [])
readonly property var folders: parseDoc(QbzLocal.localFoldersJson, [])
readonly property var tree: parseDoc(QbzLocal.localTreeJson, [])
readonly property var tracks: parseDoc(QbzLocal.localTracksJson, [])
readonly property var folderDetail: parseDoc(QbzLocal.localDetailJson, null)
readonly property var ephemeral: parseDoc(QbzLocal.localEphemeralJson, null)
// Decoded-cover map {artKey: "file://…"} — fed by the id-keyed signal.
property var artMap: ({})
property var _artKeep: ({})
// Keep the high-density path unchanged until it passes the CPU budget.
// This remains reactive when the window moves between displays.
readonly property bool immediateArtwork: QbzLocal.artworkImmediateEnabled()
&& Screen.devicePixelRatio <= 1
readonly property bool artTimingEnabled: QbzLocal.artworkTimingEnabled()
property var _artTraceWindows: ({})
property int _artTraceSequence: 0
function traceArtReport(rows, first, last, surface) {
if (!root.artTimingEnabled) return
var keys = []
for (var i = Math.max(0, first); i <= Math.min(last, rows.length - 1); i++)
if (rows[i] && rows[i].artKey) keys.push(rows[i].artKey)
var signature = keys.join("|")
var old = root._artTraceWindows[surface]
if (old && old.signature === signature) return
if (old) {
for (var j = 0; j < old.keys.length; j++)
QbzLocal.artworkTiming("window-end", old.keys[j], old.id)
}
var id = surface + ":" + (++root._artTraceSequence)
root._artTraceWindows[surface] = {id: id, keys: keys, signature: signature}
for (var k = 0; k < keys.length; k++)
QbzLocal.artworkTiming("report", keys[k], id)
}
function traceArt(stage, key) {
if (!root.artTimingEnabled) return
for (var surface in root._artTraceWindows) {
var w = root._artTraceWindows[surface]
if (w.keys.indexOf(key) >= 0)
QbzLocal.artworkTiming(stage, key, w.id)
}
}
// Covers arrive ONE AT A TIME (src/local_artwork.rs phase 2 streams each
// thumbnail through the id-keyed signal the moment it resolves). Rebinding
// `artMap` per arrival is quadratic in the window: each arrival copied the
// whole map and re-evaluated the art binding of EVERY mounted cell, so a
// 50-cover window did ~2500 binding evaluations and 50 map copies during
// exactly the seconds the grid is trying to stay smooth. Arrivals are
// coalesced into ONE rebind per frame instead — the covers still appear
// progressively (16ms granularity is invisible), at O(n) total cost.
property var _artInbox: ({})
// ORDERED REVEAL. The cold-thumbnail pool (local_artwork.rs:163) starts
// work in window order but FINISHES out of order, because decode cost
// varies per cover — so covers used to pop in scattered across the grid.
// Ordering the emission in Rust instead would be a mistake: one slow
// decode at index 3 would hold 4..N behind it, which ADDS latency rather
// than hiding it.
//
// So arrivals are applied behind a reveal FRONT that walks down the
// window at a fixed rate. A cover that lands ahead of the front waits for
// it; a cover that lands behind it (the slow ones) is applied at once,
// having already been passed. The front advances every tick unconditionally,
// which is what makes a stall impossible: a key is delayed at most
// (ordinal / step) ticks no matter what the pool does, and a key with no
// ordinal at all is released immediately.
//
// `_artOrder` is rebuilt per window request (see flushWindows), so the
// wipe restarts from the top of whatever is on screen now.
property var _artOrder: ({})
property var _artHeld: ({})
property int _artFront: 0
/// Keys the front passes per 16ms tick — about one grid row, which reads
/// as a wipe rather than a sweep. A ~50-cover window resolves in ~130ms.
property int artRevealStep: 6
Timer {
id: artFlush
interval: 16
repeat: false
onTriggered: {
if (root.immediateArtwork) {
// One rebind for this batch, with no decorative reveal front.
root.artMap = Object.assign({}, root.artMap, root._artHeld, root._artInbox)
root._artHeld = ({})
root._artInbox = ({})
return
}
var k
// Everything that arrived this tick joins whatever is still held.
for (k in root._artInbox) root._artHeld[k] = root._artInbox[k]
root._artInbox = ({})
root._artFront += root.artRevealStep
var m = Object.assign({}, root.artMap)
var released = false
var stillHeld = false
for (k in root._artHeld) {
var ord = root._artOrder[k]
// No ordinal means this key is not part of the current window
// (a late arrival from a window we have scrolled past). Holding
// it would be holding it forever.
if (ord === undefined || ord < root._artFront) {
m[k] = root._artHeld[k]
delete root._artHeld[k]
released = true
} else {
stillHeld = true
}
}
// A rebind needs a NEW object reference (same-ref assignment is
// not a change in QML).
if (released) root.artMap = m
// Keep ticking while anything waits, or the front stops moving and
// the covers behind it never land.
if (stillHeld) artFlush.start()
}
}
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
root.traceArt("path", key)
root.nativeTracksModel.setArtwork(key, path)
root.nativeAlbumsModel.setArtwork(key, path)
root.nativeArtistsModel.setArtwork(key, path)
root.nativeArtistAlbumsModel.setArtwork(key, path)
if (root.immediateArtwork && !root._artKeep[key]) return
root._artInbox[key] = path
if (!artFlush.running) artFlush.start()
// An arrival is live evidence that the pass is still running, so
// it keeps every placeholder's settle countdown suspended. Without
// this the bound is a fixed timeout from the REPORT, and a cold
// local library resolves slower than that (see artPassMs).
root.artPulse = true
artPulseOff.restart()
}
}
Connections {
target: root.nativeTracksModel
function onPageMiss(page, generation) {
QbzLocal.tracksNativePageMiss(page, generation)
}
}
Connections {
target: root.nativeAlbumsModel
function onPageMiss(page, generation) {
QbzLocal.albumsNativePageMiss(page, generation)
}
}
Connections {
target: root.nativeArtistsModel
function onPageMiss(page, generation) {
QbzLocal.artistsNativePageMiss(page, generation)
}
}
Connections {
target: root.nativeArtistAlbumsModel
function onPageMiss(page, generation) {
QbzLocal.artistAlbumsNativePageMiss(page, generation)
}
}
// A name route from LocalAlbumView ("go to artist" on a local album:
// local/Plex artists have no catalog id) lands the view on the Artists
// tab with that artist selected.
function consumePendingArtist() {
var pending = QbzLocal.localPendingArtist
if (pending === "") return
activateTab("artists")
selectedArtist = pending
QbzLocal.clearPendingArtist()
}
// A TAB route from the cortinilla's local "View more" links. Same shape
// as consumePendingArtist above: the property change is the trigger, so
// it must be released after it is applied or the same link cannot fire
// twice in a row.
function consumePendingRoute() {
var raw = QbzLocal.localPendingRoute
if (raw === "") return
var route
try { route = JSON.parse(raw) } catch (e) { QbzLocal.clearPendingRoute(); return }
if (route.tab) activateTab(route.tab)
// The query pre-filters the TRACKS tab only; the albums and artists
// tabs have no search box of their own.
if (route.tab === "tracks" && route.query) {
// Set the view's own search state too, not just the query, or the
// box would render empty while the list is filtered.
root.tracksSearch = route.query
QbzLocal.tracksSearch(route.query)
}
QbzLocal.clearPendingRoute()
}
Connections {
target: QbzLocal
function onLocalPendingArtistChanged() { root.consumePendingArtist() }
function onLocalPendingRouteChanged() { root.consumePendingRoute() }
}
// Mount: load the default tab. Tab switches load on demand (each tab is
// one query; the Albums/Folders/Artists sets are bounded).
Component.onCompleted: {
restoreNavigationState()
consumePendingArtist()
consumePendingRoute()
loadActiveTab()
navigationSeed.restart()
}
function loadActiveTab() {
if (root.activeTab === "tracks")
QbzLocal.tracksSetFilterJson(JSON.stringify(root.tracksFilter))
else
root.loadTabForView(root.activeTab)
if (root.activeTab === "tracks" && root.tracksSearch !== "")
QbzLocal.tracksSearch(root.tracksSearch)
}
Component.onDestruction: {
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
}
onActiveTabChanged: {
// Tracks owns a server-side descriptor; install the shared funnel
// before its first page is queried. The setter performs that load.
if (!root._restoringNavigationState) {
if (activeTab === "tracks")
QbzLocal.tracksSetFilterJson(JSON.stringify(root.tracksFilter))
else
root.loadTabForView(activeTab)
}
// The tab that just appeared has covers to ask for and the one that
// left has covers to let go of. Each surface answers for itself.
artworkRefresh()
}
/// "Re-report your window." Broadcast for the state changes no single
/// surface can observe on its own (the tab switch). A surface that is
/// hidden when it hears this releases its slot instead.
signal artworkRefresh()
// The folder-detail cover set is SMALL and fully mounted, so the whole
// set is one window report (the grids/lists window themselves).
//
// BOTH of its cover sections contribute. The report used to carry the
// subfolder cards only, while LocalFolderDetail ALSO binds `artMap` on
// the folder's direct track rows — those rows asked for a key nobody had
// requested, so their 36px cell stayed blank forever. The track half is
// gated on `trackArtwork` (default OFF), which is why this is a derived
// property: flipping the setting re-derives it and re-reports by itself.
readonly property var folderDetailArtRows: {
if (!folderDetail) return []
var out = (folderDetail.subfolders || []).slice()
if (trackArtwork && folderDetail.tracks) {
for (var i = 0; i < folderDetail.tracks.length; i++)
out.push(folderDetail.tracks[i])
}
return out
}
function reportFolderDetailWindow() {
var rows = folderDetailArtRows
if (rows.length === 0) { releaseWindow("folder-detail"); return }
queueWindowReport(rows, 0, rows.length - 1, "folder-detail")
}
onFolderDetailArtRowsChanged: reportFolderDetailWindow()
function reportEphemeralWindow() {
var rows = (ephemeral && ephemeral.albums) ? ephemeral.albums : []
if (rows.length === 0) { releaseWindow("ephemeral"); return }
queueWindowReport(rows, 0, rows.length - 1, "ephemeral")
}
onEphemeralChanged: reportEphemeralWindow()
// ---------------------- derived (JS, per-tab) -------------------------
function sortRows(rows, key) {
var field = key.indexOf("year") === 0 ? "year"
: key.indexOf("title") === 0 ? "title" : "artist"
var desc = key.slice(-4) === "desc"
var out = rows.slice()
out.sort(function (a, b) {
var av = (a[field] || "").toString().toLowerCase()
var bv = (b[field] || "").toString().toLowerCase()
if (av === bv) {
var at = (a.title || "").toLowerCase()
var bt = (b.title || "").toLowerCase()
return at < bt ? -1 : at > bt ? 1 : 0
}
return av < bv ? -1 : 1
})
if (desc) out.reverse()
return out
}
function filterRows(rows, needle) {
var q = needle.trim().toLowerCase()
if (q === "") return rows
var out = []
for (var i = 0; i < rows.length; i++) {
var r = rows[i]
if ((r.title || "").toLowerCase().indexOf(q) >= 0
|| (r.artist || "").toLowerCase().indexOf(q) >= 0) out.push(r)
}
return out
}
// Quality / format / source chips. Each SECTION is an OR within itself
// and an AND across sections — an empty section means "any".
function sourceBucket(word) {
var source = (word || "local").toLowerCase()
// library.db stores ordinary scanner rows as `user`; every Local
// Library surface exposes that storage word through the Local chip.
if (source === "" || source === "user") return "local"
if (source === "qobuz_purchase" || source === "qobuz_download")
return "offline"
if (source === "navidrome" || source === "gonic"
|| source === "airsonic" || source === "astiga")
return "subsonic"
return source
}
function applyFilter(rows, selectedFilter) {
var selected = selectedFilter || ({})
if (Object.keys(selected).length === 0) return rows
var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|| selected.mp3 || selected.aac || selected.other
var sAny = selected.local || selected.offline || selected.plex
|| selected.jellyfin || selected.subsonic
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
var out = []
for (var i = 0; i < rows.length; i++) {
var r = rows[i]
if (selected.favorite === true && !root.albumFavorite(r)) continue
if (qAny || fAny || sAny) {
// Test all active media sections against ONE physical copy.
// Independent aggregate arrays are not enough: a logical
// album can carry DSD on its purchased copy and FLAC on a
// different local copy. DSD+FLAC must not manufacture a
// parent whose filtered detail has zero tracks.
var variants = r.mediaVariants || []
if (variants.length === 0) {
var sources = r.sources && r.sources.length > 0
? r.sources : [r.source || "local"]
variants = []
for (var si = 0; si < sources.length; si++) {
variants.push({ "qualityTier": r.qualityTier || "",
"format": r.format || "",
"source": sources[si] })
}
}
var mediaMatches = false
for (var vi = 0; vi < variants.length; vi++) {
var variant = variants[vi]
var tier = (variant.qualityTier || "").toLowerCase()
var fmt = (variant.format || "").toLowerCase()
var qok = !qAny
|| (selected.dsd && tier === "dsd")
|| (selected.hires && (tier === "hires" || tier === "max"))
|| (selected.cd && tier === "cd")
|| (selected.lossy && (tier === "mp3" || tier === "lossy"))
var fok = !fAny || selected[fmt] === true
|| (selected.other === true && known[fmt] !== 1)
// `sourceBucket` folds qobuz_purchase into Offline while
// its raw word remains available to the gold badge.
var sok = !sAny
|| selected[sourceBucket(variant.source)] === true
if (qok && fok && sok) {
mediaMatches = true
break
}
}
if (!mediaMatches) continue
}
out.push(r)
}
return out
}
function albumFavorite(album) {
if (!album || !album.id) return false
var override = root.favoriteOverrides[album.id]
return override === undefined ? album.isFavorite === true : override === true
}
function toggleAlbumFavorite(album, artworkUrl) {
if (!album || !album.id || album.favoriteable !== true) return
var sources = album.sources && album.sources.length > 0
? album.sources : [album.sourceRaw || album.source || ""]
QbzLocal.albumToggleFavorite(
album.id,
album.title || "",
album.artist || "",
artworkUrl || "",
JSON.stringify(sources))
}
// A-Z / by-artist grouping — [{ letter, items }] plus the alpha jumps the
// strips consume (the strip derives its own indices from the groups).
function groupRows(rows, mode) {
if (mode === "off") return []
var buckets = {}
var order = []
for (var i = 0; i < rows.length; i++) {
var r = rows[i]
var key
if (mode === "artist") {
key = (r.artist || "").trim() || "—"
} else {
var c = ((r.title || "").trim().slice(0, 1) || "#").toUpperCase()
key = (c >= "A" && c <= "Z") ? c : "#"
}
if (!buckets[key]) { buckets[key] = []; order.push(key) }
buckets[key].push(r)
}
order.sort()
var out = []
for (i = 0; i < order.length; i++) {
out.push({ "letter": order[i], "items": buckets[order[i]] })
}
return out
}
// Once F1 owns the Albums tab, never run the legacy O(n) JS pipeline in
// parallel. The old document may still be resident because Artists uses
// it until F2; it is evaluated only while that legacy surface is active.
readonly property var albumsVisible: root.albumsNativeViewActive ? []
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort), albumsFilter)
readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup)
readonly property var foldersVisible:
sortRows(filterRows(folders, foldersSearch), foldersSort)
readonly property var foldersGrouped: groupRows(foldersVisible, foldersGroup)
// Legacy and native readers both publish rows in the final global group
// order. Keeping this as a direct alias makes every 500-row legacy page an
// immutable append; re-sorting the accumulated JSON here moved the current
// track hundreds of indices whenever a later page belonged ahead of it.
readonly property var tracksVisible: tracks
function artistMatchesFilter(artist, selected) {
if (!selected || Object.keys(selected).length === 0) return true
var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|| selected.mp3 || selected.aac || selected.other
var sAny = selected.local || selected.offline || selected.plex
|| selected.jellyfin || selected.subsonic
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1,
"mp3": 1, "aac": 1 }
var i, value
if (qAny) {
var qualityOk = false
for (i = 0; i < (artist.qualityTiers || []).length; i++) {
value = (artist.qualityTiers[i] || "").toLowerCase()
if ((selected.dsd && value === "dsd")
|| (selected.hires && (value === "hires" || value === "max"))
|| (selected.cd && value === "cd")
|| (selected.lossy && (value === "mp3" || value === "lossy"))) {
qualityOk = true; break
}
}
if (!qualityOk) return false
}
if (fAny) {
var formatOk = false
for (i = 0; i < (artist.formats || []).length; i++) {
value = (artist.formats[i] || "").toLowerCase()
if (selected[value] === true
|| (selected.other === true && known[value] !== 1)) {
formatOk = true; break
}
}
if (!formatOk) return false
}
if (sAny) {
var sourceOk = false
for (i = 0; i < (artist.sources || []).length; i++) {
if (selected[sourceBucket(artist.sources[i])] === true) {
sourceOk = true; break
}
}
if (!sourceOk) return false
}
return true
}
readonly property var artistsVisible: {
if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return []
var q = artistsSearch.trim().toLowerCase()
var out = []
for (var i = 0; i < artists.length; i++) {
var artist = artists[i]
if (q !== "" && (artist.name || "").toLowerCase().indexOf(q) < 0)
continue
if (!artistMatchesFilter(artist, artistsFilter)) continue
out.push(artist)
}
out.sort(function(a, b) {
var key = root.artistsSort
var year = key.indexOf("year-") === 0
var av = year ? Number(a.year || 0) : (a.name || "").toLowerCase()
var bv = year ? Number(b.year || 0) : (b.name || "").toLowerCase()
if (year && av === 0) return 1
if (year && bv === 0) return -1
var cmp = av < bv ? -1 : av > bv ? 1 : 0
return key.slice(-4) === "desc" ? -cmp : cmp
})
return out
}
readonly property int artistsVisibleCount: artistsVisible.length
readonly property var artistsGrouped: {
if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return []
var rows = artistsVisible
var buckets = {}
var order = []
for (var i = 0; i < rows.length; i++) {
var c = ((rows[i].name || "").trim().slice(0, 1) || "#").toUpperCase()
var key = (c >= "A" && c <= "Z") ? c : "#"
if (!buckets[key]) { buckets[key] = []; order.push(key) }
buckets[key].push(rows[i])
}
order.sort()
var out = []
for (i = 0; i < order.length; i++) {
out.push({ "letter": order[i], "items": buckets[order[i]] })
}
return out
}
// --------------------- Genres column browser -------------------------
// Empty selection maps are the explicit All state. Multiple selections
// are OR-ed within one column and AND-ed across the chained columns.
readonly property var genreBaseAlbums: applyFilter(albums, genresFilter)
function albumArtistNames(album) {
if (album.artists && album.artists.length > 0)
return album.artists
var values = []
var seen = {}
var raw = (album.allArtists || "").trim()
var parts = raw === "" ? [album.artist || ""] : raw.split(",")
if ((album.artist || "").trim() !== "") parts.unshift(album.artist)
for (var i = 0; i < parts.length; i++) {
var display = (parts[i] || "").trim()
var key = display.toLowerCase()
if (display !== "" && !seen[key]) { seen[key] = true; values.push(display) }
}
return values
}
function albumHasSelectedGenre(album) {
if (explorerColumns === "year") return true
if (selectedGenreCount === 0) return true
var genres = album.genres || []
for (var i = 0; i < genres.length; i++)
if (selectedGenres[(genres[i] || "").toLowerCase()] === true) return true
return false
}
function albumHasSelectedYear(album) {
if (explorerColumns === "genre") return true
if (selectedGenreYearCount === 0) return true
var years = album.years && album.years.length > 0
? album.years : [album.year || ""]
for (var i = 0; i < years.length; i++)
if (selectedGenreYears[String(years[i])] === true) return true
return false
}
function albumHasSelectedArtist(album) {
if (selectedGenreArtistCount === 0) return true
var names = albumArtistNames(album)
for (var i = 0; i < names.length; i++)
if (selectedGenreArtists[names[i].toLowerCase()] === true) return true
return false
}
readonly property var genreNames: {
var values = {}, display = {}
for (var i = 0; i < genreBaseAlbums.length; i++) {
var genres = genreBaseAlbums[i].genres || []
for (var j = 0; j < genres.length; j++) {
var name = (genres[j] || "").trim(), key = name.toLowerCase()
if (name !== "" && !values[key]) { values[key] = true; display[key] = name }
}
}
var out = []
var q = genresSearch.trim().toLowerCase()
for (var key in values)
if (q === "" || display[key].toLowerCase().indexOf(q) >= 0)
out.push({ "key": key, "label": display[key] })
out.sort(function(a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var genreYearOptions: {
var values = ({})
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album)) continue
var years = album.years && album.years.length > 0
? album.years : [album.year || ""]
for (var j = 0; j < years.length; j++) {
var key = String(years[j] || "").trim()
if (key !== "") values[key] = true
}
}
var out = [], q = genreYearsSearch.trim().toLowerCase()
for (var key in values)
if (q === "" || key.toLowerCase().indexOf(q) >= 0)
out.push({ "key": key, "label": key })
out.sort(function(a, b) { return Number(b.key) - Number(a.key) })
return out
}
readonly property var genreArtistOptions: {
var values = {}, display = {}
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album) || !albumHasSelectedYear(album)) continue
var names = albumArtistNames(album)
for (var j = 0; j < names.length; j++) {
var key = names[j].toLowerCase()
if (!values[key]) { values[key] = true; display[key] = names[j] }
}
}
var out = [], q = genreArtistsSearch.trim().toLowerCase()
for (var key in values)
if (q === "" || display[key].toLowerCase().indexOf(q) >= 0)
out.push({ "key": key, "label": display[key] })
out.sort(function(a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var genreAlbumOptions: {
var out = [], q = genreAlbumsSearch.trim().toLowerCase()
for (var i = 0; i < genreBaseAlbums.length; i++) {
var album = genreBaseAlbums[i]
if (!albumHasSelectedGenre(album) || !albumHasSelectedYear(album)
|| !albumHasSelectedArtist(album)) continue
if (q !== "" && (album.title || "").toLowerCase().indexOf(q) < 0) continue
out.push({ "key": album.id, "label": album.title || "", "album": album })
}
out.sort(function(a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var genreAlbumsVisible: {
var rows = []
for (var i = 0; i < genreAlbumOptions.length; i++) {
var option = genreAlbumOptions[i]
if (selectedGenreAlbumCount === 0 || selectedGenreAlbums[option.key] === true)
rows.push(option.album)
}
return sortRows(rows, genresSort)
}
// The Explorer facets follow the spreadsheet rule — a plain click picks
// one value, Ctrl adds or removes one, Shift picks the run from the last
// click, Ctrl+Shift adds that run (SelectionModel.nextExclusive) — each
// column over its values AS LISTED (sorted, and filtered by its own
// search box). One anchor per column; a column whose values are rebuilt
// by a change upstream starts without one.
SelectionModel { id: genreFacetSel; idKey: "key" }
SelectionModel { id: yearFacetSel; idKey: "key" }
SelectionModel { id: artistFacetSel; idKey: "key" }
SelectionModel { id: albumFacetSel; idKey: "key" }
function toggleGenre(key, modifiers) {
selectedGenres = nextFacetSelection(genreFacetSel, selectedGenres, key, modifiers, genreNames)
selectedGenreYears = ({})
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
yearFacetSel.anchorId = ""
artistFacetSel.anchorId = ""
albumFacetSel.anchorId = ""
}
function toggleGenreYear(key, modifiers) {
selectedGenreYears = nextFacetSelection(yearFacetSel, selectedGenreYears, key, modifiers,
genreYearOptions)
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
artistFacetSel.anchorId = ""
albumFacetSel.anchorId = ""
}
function toggleGenreArtist(key, modifiers) {
selectedGenreArtists = nextFacetSelection(artistFacetSel, selectedGenreArtists, key,
modifiers, genreArtistOptions)
selectedGenreAlbums = ({})
albumFacetSel.anchorId = ""
}
function toggleGenreAlbum(key, modifiers) {
selectedGenreAlbums = nextFacetSelection(albumFacetSel, selectedGenreAlbums, key, modifiers,
genreAlbumOptions)
}
function setExplorerColumns(mode) {
if (["genre", "year", "both"].indexOf(mode) < 0 || mode === explorerColumns)
return
explorerColumns = mode
QbzLocal.setExplorerColumns(mode)
// A hidden facet must never keep filtering the visible chain.
if (mode === "genre") selectedGenreYears = ({})
else if (mode === "year") selectedGenres = ({})
selectedGenreArtists = ({})
selectedGenreAlbums = ({})
}
readonly property int explorerFacetCount: explorerColumns === "both" ? 4 : 3
readonly property int explorerLeadingCount: explorerColumns === "year"
? genreYearOptions.length : genreNames.length
/// `key` "" is the column's "All" row: it clears the column.
function nextFacetSelection(rule, current, key, modifiers, options) {
if (key === "") {
rule.anchorId = ""
return ({})
}
return rule.nextExclusive(current, key, options || [],
modifiers === undefined ? Qt.NoModifier : modifiers)
}
// The selected artist's albums.
//
// The MATCH lives in Rust — `QbzLocal.artistAlbumIds` ->
// local_artist_match::album_matches_artist, the port of
// local_library.rs:3368-3390 — because it is the same normalized-name rule
// the Artists merge uses and there must be exactly one of it. It compares
// NORMALIZED PARTS for exact equality against the primary artist, every
// `allArtists` comma part and every split-credit part (`,&/;`, feat/ft/
// featuring/with), plus the "various artists" special case.
//
// What it replaced: a lowercase equality on `artist` OR a SUBSTRING
// `indexOf` on `allArtists`. That listed "Airbourne" and "Blair" under
// "Air", and an album credited "A & B" never appeared under "B"
// (PARITY-DEBT #8).
//
// The ids come back rather than the rows, so the pane renders the very
// objects this view already parsed, in the published order.
readonly property var artistAlbums: {
if (selectedArtist === "") return []
if (QbzLocal.localArtistsNativeActive) return []
// The rail filters artist aggregates; the detail must also filter
// physical album copies, including when the selected artist stays.
var rows = applyFilter(albums, artistsFilter)
var ids = {}
try {
var arr = JSON.parse(QbzLocal.artistAlbumIds(selectedArtist))
for (var j = 0; j < arr.length; j++) ids[arr[j]] = true
} catch (e) {
console.warn("[qbz-qt] local: bad artist album ids — " + e)
return []
}
var out = []
for (var i = 0; i < rows.length; i++) {
if (ids[rows[i].id]) out.push(rows[i])
}
return out
}
readonly property var detailSubfolders: {
if (!folderDetail) return []
var q = folderDetailSearch.trim().toLowerCase()
if (q === "") return folderDetail.subfolders
var out = []
for (var i = 0; i < folderDetail.subfolders.length; i++) {
if ((folderDetail.subfolders[i].name || "").toLowerCase().indexOf(q) >= 0)
out.push(folderDetail.subfolders[i])
}
return out
}
// --------------------- windowed artwork ------------------------------
// Report the mounted window as artKeys and prune covers a full window
// away. Same policy as LibraryView (the Slint eviction, QML-side), with
// one correction the port needed: eviction is derived from EVERY live
// surface at once, not from the one array the current call carries.
//
// WHY A REGISTRY AND NOT A SINGLE WINDOW. This view mounts TWO cover
// surfaces side by side more often than not: the Artists tab is the
// avatar rail plus that artist's album grid, and Folders tree mode is the
// subfolder cards plus the folder's track rows. The old per-call eviction
// built `keep` from its own rows, so each surface deleted the other's
// covers on every report and both settled on grey squares. `_windows`
// holds the last window of every live surface keyed by a stable surface
// id, and the keep-set is their union.
property var _windows: ({})
/// Record (or drop) one surface's window. An empty or degenerate window
/// RELEASES the slot rather than pinning covers the surface no longer
/// shows.
function applyWindow(key, rows, first, last) {
if (!rows || rows.length === 0) { delete root._windows[key]; return }
last = Math.min(last, rows.length - 1)
first = Math.max(0, first)
if (first > last) { delete root._windows[key]; return }
root._windows[key] = { "rows": rows, "first": first, "last": last }
}
/// A surface that unmounts, or scrolls/tabs off screen, stops holding its
/// covers. Cheap no-op when the surface was never registered.
function releaseWindow(key) {
var k = key || "default"
if (root.artTimingEnabled && root._artTraceWindows[k]) {
var traced = root._artTraceWindows[k]
for (var i = 0; i < traced.keys.length; i++)
QbzLocal.artworkTiming("window-end", traced.keys[i], traced.id)
delete root._artTraceWindows[k]
}
if (root._windows[k] === undefined && root._pending[k] === undefined) return
delete root._windows[k]
delete root._pending[k]
flushWindows()
}
/// Evict against the union of every live window, then request what is
/// still missing. Keys already resolved are NOT re-sent: `artMap` IS the
/// resolved set and a re-request costs Rust a `stat` per key
/// (local_artwork.rs phase 1), which a scroll would pay per pass.
function flushWindows() {
var keep = {}
var k, w, rows, i, ak, span, lo, hi
for (k in root._windows) {
w = root._windows[k]
rows = w.rows
span = w.last - w.first + 1
lo = Math.max(0, w.first - span)
hi = Math.min(rows.length - 1, w.last + span)
for (i = lo; i <= hi; i++) {
ak = rows[i] ? rows[i].artKey : ""
if (ak) keep[ak] = true
}
}
var m = root.artMap
root._artKeep = keep
var changed = false
for (k in m) if (!keep[k]) { delete m[k]; changed = true }
// The not-yet-flushed arrivals are evicted too, or a cover that landed
// for a row we have just scrolled away from would be re-added by the
// next flush and defeat the eviction.
for (k in root._artInbox) if (!keep[k]) delete root._artInbox[k]
for (k in root._artHeld) if (!keep[k]) delete root._artHeld[k]
if (changed) root.artMap = Object.assign({}, m)
// Ordinals for the ordered reveal, assigned over the window in DISPLAY
// order so the front walks top to bottom. Built for every key in the
// window, not just the missing ones, so a cover that resolves while
// the wipe is mid-flight still knows where it sits.
var order = {}
var ord = 0
var keys = []
var seen = {}
for (k in root._windows) {
w = root._windows[k]
rows = w.rows
for (i = w.first; i <= w.last; i++) {
ak = rows[i] ? rows[i].artKey : ""
if (!ak || seen[ak]) continue
seen[ak] = true
if (!root.immediateArtwork && order[ak] === undefined) order[ak] = ord++
if (m[ak] !== undefined || root._artInbox[ak] !== undefined) continue
keys.push(ak)
}
}
// Restart the wipe at the top of what is on screen NOW.
root._artOrder = order
root._artFront = 0
if (keys.length > 0) {
if (root.artTimingEnabled)
for (var n = 0; n < keys.length; n++) root.traceArt("request", keys[n])
QbzLocal.artworkWindow(JSON.stringify(keys))
}
}
/// Immediate single-surface report — the leading edge and the rate-limit
/// flush both land here.
function reportWindow(rows, first, last, key) {
applyWindow(key || "default", rows, first, last)
flushWindows()
}
// ------------------------- skeleton pulse ----------------------------
// ONE 900ms Timer drives EVERY placeholder in this view AND in every
// tab body under views/local/ (they read `view.skelPhase`) — N
// placeholders, 1 timer, which is QbzSkeleton's preferred drive mode.
// 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
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
readonly property bool anyTabLoading: QbzLocal.localAlbumsLoading
|| QbzLocal.localArtistsLoading || QbzLocal.localFoldersLoading
|| QbzLocal.localTreeLoading || QbzLocal.localTracksLoading
|| QbzLocal.localTracksLoadingMore || QbzLocal.localDetailLoading
|| QbzLocal.localEphemeralLoading
// THE CLEANUP RULE FOR LOCAL ARTWORK. src/local_artwork.rs
// (`resolve_window_blocking`) DROPS keys with no cover — "the QML map
// only ever grows with real hits" — so a local album with no embedded
// art NEVER produces an artMap entry. A per-item placeholder gated only
// on "artMap has no path yet" would therefore shimmer forever on every
// artless album. `artSettleMs` is the bound: each placeholder fades out
// by itself, revealing the card's own empty tile. Per-item handover
// (QbzSkeleton.handedOver) still wins whenever the cover DOES arrive.
//
// The countdown does NOT start at mount — it starts when the artwork pass
// goes quiet (`artPulse` below). A FIRST visit to a cold library decodes
// every cover from scratch and takes seconds; a countdown from mount used
// to expire a beat BEFORE the covers landed, which is the other half of
// "the skeleton disappears before the art is rendered".
readonly property int artSettleMs: 1200
/// How long after the last sign of life a resolution pass is still
/// presumed in flight. Re-armed by every window report AND by every cover
/// that arrives, so a slow first visit extends it by itself instead of
/// needing a pessimistic constant.
readonly property int artPassMs: 2500
// The pulse runs while an artwork window can still resolve, and is also
// what holds off every placeholder's settle countdown.
property bool artPulse: false
Timer {
id: artPulseOff
interval: root.artPassMs
onTriggered: root.artPulse = false
}
Timer {
interval: 900
repeat: true
running: (root.anyTabLoading || root.artPulse)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
/// Does THIS row want a cover at all? An empty artKey means the row has
/// no artwork slot and there is nothing to wait for.
///
/// NOTE — this REPLACED `artPending(key)`, which asked "is the path still
/// missing". That predicate is not the placeholder gate and never was:
/// it goes false when the PATH lands, while the art needs a further load
/// + canvas raster before it is on screen. The placeholder now hands over
/// on QbzSkeleton's `coverSource`/`coverReady` (see QbzSkeleton.qml), and
/// this function only answers "is a cover expected here".
function artWanted(key) { return (key || "") !== "" }
/// The decoded cover for a row, "" until it lands.
function artPathOf(key) { return root.artMap[key] || "" }
// Reporting is rate-limited to one pass per 180ms, but LEADING-EDGE: the
// limiter exists to stop a scroll from firing a resolution pass per pixel,
// and a view that has just mounted is not scrolling. Trailing-only cost
// every first paint a flat 180ms before Rust was even asked for a cover —
// pure latency on the one report the user is actually waiting for.
//
// TWO THINGS THIS USED TO SWALLOW, both of which read as "grey squares
// until I scroll":
// - ONE pending slot for the whole view. Two surfaces reporting inside
// the same 180ms (the artist rail and the artist's album grid do
// exactly that on every selection) meant the second overwrote the
// first and one pane was never requested at all. Pending reports are
// keyed by surface now and ALL of them flush.
// - `restart()` on every call. That is a reset-on-change debounce: a
// continuous flick (or a burst of mount-time reports — rows arrive,
// the column count settles, the tab becomes visible) pushed the
// trailing edge out by another 180ms each time, so the pass fired
// only once everything stopped moving. It is a rate limiter now:
// `start()`, never `restart()`.
Timer {
id: windowDebounce
interval: 180
onTriggered: {
root._lastReportMs = Date.now()
var pending = root._pending
root._pending = ({})
var any = false
for (var k in pending) {
var w = pending[k]
root.applyWindow(k, w.rows, w.first, w.last)
any = true
}
if (any) root.flushWindows()
}
}
/// Surface id -> the window waiting for the next flush.
property var _pending: ({})
// `real` is a double in QML — Date.now() (~1.7e12) needs the range.
property real _lastReportMs: 0
function queueWindowReport(rows, first, last, key) {
var k = key || "default"
if (rows && root.artTimingEnabled) root.traceArtReport(rows, first, last, k)
if (!windowDebounce.running
&& Date.now() - root._lastReportMs >= windowDebounce.interval) {
root._lastReportMs = Date.now()
root.reportWindow(rows, first, last, k)
} else {
root._pending[k] = { "rows": rows, "first": first, "last": last }
if (!windowDebounce.running) windowDebounce.start()
}
// Covers can now land: keep the shared pulse (and every placeholder's
// settle hold) alive until artPassMs after the LAST sign of life —
// this report, or the next cover that arrives.
root.artPulse = true
artPulseOff.restart()
}
// ============================ actions ================================
function openAlbum(id) {
QbzLocal.openAlbumFiltered(id, JSON.stringify(root.filter || {}))
QbzShell.navigateTo("localalbum")
}
function selectFolder(path) {
selectedFolder = path
folderDetailSearch = ""
QbzLocal.selectFolder(path)
}
function selectArtist(name) { selectedArtist = name }
function toggleAlbumsMultiSelect() {
albumsMultiSelect = !albumsMultiSelect
if (!albumsMultiSelect) {
albumsSelected = ({}); albumSel.anchorId = ""
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
}
}
/// Excel-style selection — controls/SelectionModel.qml holds the anchor
/// and the Shift-range rule; this view keeps owning its maps. Ranges run
/// over the VISIBLE rows, because the toolbar's filter is a view filter
/// and `select-all` right below already means the filtered set.
SelectionModel { id: albumSel }
function toggleAlbumSelected(id, mods) {
albumsSelected = albumSel.next(albumsSelected, id, albumsVisible,
mods === undefined ? Qt.NoModifier : mods)
}
function toggleNativeAlbumSelected(index, mods) {
QbzLocal.albumsNativeToggleSelect(
index, (mods & Qt.ShiftModifier) !== 0)
}
function albumsBulkAction(action) {
if (QbzLocal.localAlbumsNativeActive) {
if (action === "clear") QbzLocal.albumsNativeClearSelection()
else if (action === "select-all") QbzLocal.albumsNativeSelectAll()
else QbzLocal.albumsNativeBulkAction(action)
return
}
if (action === "clear") { albumsSelected = ({}); albumSel.anchorId = ""; return }
if (action === "select-all") {
var s = {}
for (var i = 0; i < albumsVisible.length; i++) s[albumsVisible[i].id] = true
albumsSelected = s
return
}
QbzLocal.bulkAction("album", JSON.stringify(Object.keys(albumsSelected)), action)
}
function toggleTracksMultiSelect() {
tracksMultiSelect = !tracksMultiSelect
if (!tracksMultiSelect) {
tracksSelected = ({}); trackSel.anchorId = ""
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
}
}
SelectionModel { id: trackSel }
function toggleTrackSelected(id, mods) {
tracksSelected = trackSel.next(tracksSelected, id, tracksVisible,
mods === undefined ? Qt.NoModifier : mods)
}
function tracksBulkAction(action) {
if (QbzLocal.localTracksNativeActive) {
if (action === "clear") QbzLocal.tracksNativeClearSelection()
else if (action === "select-all") QbzLocal.tracksNativeSelectAll()
else QbzLocal.tracksNativeBulkAction(action)
return
}
if (action === "clear") { tracksSelected = ({}); trackSel.anchorId = ""; return }
if (action === "select-all") {
var s = {}
for (var i = 0; i < tracksVisible.length; i++) s[tracksVisible[i].id] = true
tracksSelected = s
return
}
QbzLocal.bulkAction("track", JSON.stringify(Object.keys(tracksSelected)), action)
}
// --- Ctrl+A / Escape hotkeys interface (2026-08-03 hotkeys-port §4.6) --
// The duck-typed seam the AppShell router calls. Only the albums and
// tracks tabs have multi-select (the folder tree rail's select mode is
// Rust-side with no select-all arm — not covered); on the artists /
// folders tabs selectAll() is a deliberate no-op.
readonly property bool multiSelectOn: root.albumsMultiSelect || root.tracksMultiSelect
function selectAll() {
if (root.activeTab === "albums") {
if (!root.albumsMultiSelect) root.albumsMultiSelect = true
root.albumsBulkAction("select-all")
} else if (root.activeTab === "tracks") {
if (!root.tracksMultiSelect) root.tracksMultiSelect = true
root.tracksBulkAction("select-all")
}
}
function exitMultiSelectMode() {
// Same "leaving drops the selection" contract the toggle buttons
// carry (toggleAlbumsMultiSelect / toggleTracksMultiSelect).
if (root.albumsMultiSelect) {
root.albumsMultiSelect = false
root.albumsSelected = ({})
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
}
if (root.tracksMultiSelect) {
root.tracksMultiSelect = false
root.tracksSelected = ({})
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
}
}
function toggleTreeSelectMode() {
treeSelectMode = !treeSelectMode
QbzLocal.treeSetSelectMode(treeSelectMode)
}
// ============================ view ===================================
Column {
anchors.fill: parent
spacing: 0
// ---- Fixed chrome: title row, tab row, divider (91px) -----------
LocalChrome {
width: parent.width
view: root
}
// ===================== CONTENT =================================
Item {
id: contentArea
width: parent.width
height: parent.height - 91
clip: true
// The `Open` pane is showing. It is the ONE body that renders with
// no indexed library (see the Loader below), so it is also the one
// the empty state has to stand down for.
readonly property bool ephemeralShowing:
root.activeTab === "ephemeral" && root.ephemeralActive
// "Nothing indexed yet" (no db / no registered folder).
//
// ALSO gated on the Open pane, and that second half is not
// hypothetical: on a machine with no library at all — a fresh
// install, or the owner's Mac mini — opening a CD drew the disc
// AND this empty state through it, both fully painted, because
// each was individually correct about its own condition. Reported
// as a transparency bug; it was two views agreeing to be visible.
QbzEmptyState {
visible: !QbzLocal.localAvailable && !contentArea.ephemeralShowing
anchors.centerIn: parent
iconName: "folder-plus"
title: QbzSession.tr("No local library yet", QbzSession.trRev)
body: QbzSession.tr("Add a music folder to scan your local files.", QbzSession.trRev)
actionLabel: QbzSession.tr("Open Local Library settings", QbzSession.trRev)
onActionClicked: QbzShell.navigateTo("settings")
}
// ONE TAB EXISTS AT A TIME. These four were sibling mounts gated
// on `visible:`, which hides an item but does not stop QML from
// building it — so every entry into Local Library instantiated all
// four tab bodies and their views' first screenful of delegates,
// to show one. The same divergence measured in HomeView.qml (see
// the Home tab there for the numbers and for why `asynchronous` is
// NOT the answer); the Tracks tab is the one that makes it matter
// here, being the 16K-row view.
//
// `active` also carries the tab's own precondition: with no local
// library there is nothing to build, and the empty state above is
// what shows instead.
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "albums"
sourceComponent: LocalAlbumsTab { view: root }
}
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "artists"
sourceComponent: LocalArtistsTab { view: root }
}
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "genres"
sourceComponent: LocalGenresTab { view: root }
}
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "folders"
sourceComponent: LocalFoldersTab { view: root }
}
Loader {
anchors.fill: parent
active: QbzLocal.localAvailable && root.activeTab === "tracks"
sourceComponent: LocalTracksTab { view: root }
}
// The `Open` tab, previously the ephemeral ARM of the Folders tab.
//
// NOT gated on `localAvailable`, unlike its four siblings: an
// ephemeral session is content from OUTSIDE the indexed library,
// so a user with an empty (or unscanned) library must still be
// able to open a folder or a disc and play it. Gating it would
// hide the pane behind the "no local library" empty state for
// exactly the people the feature exists for.
Loader {
anchors.fill: parent
active: contentArea.ephemeralShowing
sourceComponent: LocalEphemeralPane { view: root }
}
}
}
// Albums filter popup — sibling of the content column so it FLOATS over
// it instead of taking a layout slot (the Slint mount, :2470).
LocalFilterPopup {
anchors.fill: parent
visible: root.filterOpen
&& ["albums", "artists", "genres", "tracks"].indexOf(root.activeTab) >= 0
view: root
}
}