Materialize album browse fields and FTS in the derived catalog, then expose exact counts and stable keyset queries for the current filters, sort, grouping, and enabled sources. Bind the existing Albums layout to a bounded native Qt model with asynchronous page misses, anchors, selection ranges, generation cancellation, artwork updates, and per-surface fallback. Resolve Plex native edition keys directly and retain the metadata-mode legacy reader. cx
1125 lines
51 KiB
QML
1125 lines
51 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
|
|
|
|
// ============================ state ==================================
|
|
// Slint defaults, verbatim (state.slint LocalLibraryState).
|
|
property string activeTab: "albums"
|
|
|
|
// 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: QbzLocal.localAlbumsNativeActive
|
|
? 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 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
|
|
|
|
// Albums quality/format/source filter (LibAlbumFilterState).
|
|
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 filter: root.parseFilter(QbzLocal.albumsFilter)
|
|
function parseFilter(json) {
|
|
if (!json || json === "") return ({})
|
|
try { return JSON.parse(json) || ({}) } catch (e) { return ({}) }
|
|
}
|
|
// 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.filter))
|
|
root.filter = 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 neither can change
|
|
/// the funnel and forget to persist it.
|
|
function setFilter(f) {
|
|
filter = f
|
|
QbzLocal.setAlbumsFilterJson(
|
|
Object.keys(f).length === 0 ? "" : JSON.stringify(f))
|
|
}
|
|
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: three groups
|
|
/// 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("Quality", tr),
|
|
values: pick(["hires", "cd", "lossy"],
|
|
[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. Albums
|
|
/// is the fallback because it is this view's own default (:63).
|
|
onEphemeralActiveChanged: {
|
|
if (!ephemeralActive && activeTab === "ephemeral") activeTab = "albums"
|
|
}
|
|
|
|
/// 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.activeTab = "ephemeral" }
|
|
}
|
|
|
|
// ---------------------------- 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: ({})
|
|
|
|
// 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: {
|
|
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.nativeTracksModel.setArtwork(key, path)
|
|
root.nativeAlbumsModel.setArtwork(key, path)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
activeTab = "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) activeTab = 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: {
|
|
QbzLocal.loadTab(root.activeTab)
|
|
consumePendingArtist()
|
|
consumePendingRoute()
|
|
}
|
|
Component.onDestruction: {
|
|
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
|
|
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
|
|
}
|
|
onActiveTabChanged: {
|
|
QbzLocal.loadTab(activeTab)
|
|
// The Artists detail derives from the album set (the DB aggregates
|
|
// the contributor list per album), so make sure it is loaded.
|
|
if (activeTab === "artists" && albums.length === 0)
|
|
QbzLocal.loadTab("albums-legacy")
|
|
// 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 applyFilter(rows) {
|
|
if (filterCount === 0) return rows
|
|
var qAny = filter.hires || filter.cd || filter.lossy
|
|
var fAny = filter.flac || filter.alac || filter.ape || filter.wav
|
|
|| filter.mp3 || filter.aac || filter.other
|
|
var sAny = filter.local || filter.offline || filter.plex
|
|
|| filter.jellyfin || filter.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]
|
|
var tier = (r.qualityTier || "").toLowerCase()
|
|
var fmt = (r.format || "").toLowerCase()
|
|
if (qAny) {
|
|
var qok = (filter.hires && (tier === "hires" || tier === "max"))
|
|
|| (filter.cd && tier === "cd")
|
|
|| (filter.lossy && (tier === "mp3" || tier === "lossy"))
|
|
if (!qok) continue
|
|
}
|
|
if (fAny) {
|
|
var fok = filter[fmt] === true
|
|
|| (filter.other === true && known[fmt] !== 1)
|
|
if (!fok) continue
|
|
}
|
|
if (sAny) {
|
|
// The chips are keyed off the FOLDED word, which is why
|
|
// local_rows.rs keeps folding `qobuz_purchase` into "offline"
|
|
// — a purchased album IS a Qobuz download as far as this row
|
|
// of chips is concerned, and the badge reads `sourceRaw`
|
|
// instead of splitting the bucket.
|
|
//
|
|
// The normalisation below is the reference's §10-H bug kept
|
|
// closed: Tauri's three arms never mention `qobuz_purchase`,
|
|
// so a raw word reaching here matches no chip and ticking ANY
|
|
// source filter hides every purchased album, silently.
|
|
var src = (r.source || "local").toLowerCase()
|
|
if (src === "qobuz_purchase" || src === "qobuz_download") src = "offline"
|
|
// Every brand spelling of a Subsonic server is ONE chip, the
|
|
// same fold `SourceId::from_word` applies. A row stamped
|
|
// "navidrome" that matched no chip would VANISH the moment any
|
|
// source filter was ticked — the §10-H shape above, in a new
|
|
// source.
|
|
if (src === "navidrome" || src === "gonic" || src === "airsonic"
|
|
|| src === "astiga") src = "subsonic"
|
|
if (!filter[src]) continue
|
|
}
|
|
out.push(r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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: QbzLocal.localAlbumsNativeActive
|
|
&& activeTab === "albums" ? []
|
|
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort))
|
|
readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup)
|
|
|
|
readonly property var foldersVisible:
|
|
sortRows(filterRows(folders, foldersSearch), foldersSort)
|
|
readonly property var foldersGrouped: groupRows(foldersVisible, foldersGroup)
|
|
|
|
// Legacy Tracks: search + sort are server-side and grouping remains its
|
|
// client-side compatibility reorder. Phase E does not evaluate this array:
|
|
// its immutable SQL descriptor owns search/sort/group globally and the
|
|
// QAbstractListModel pages only the visible window.
|
|
//
|
|
// The GROUP modes are the exception: they are a CLIENT-side visual
|
|
// reorder layered on top of that order (`derive_tracks`,
|
|
// local_library.rs:1196-1247, sorts the rows by the group key before the
|
|
// headers go in; commit e379aa65 calls it out as "group modes keep their
|
|
// client-side visual reorder on top"). Doing it here rather than inside
|
|
// LocalTracksTab keeps ONE definition of "the visible order" — the tab's
|
|
// entry model, the artwork window, select-all and the play seam all read
|
|
// this same array, which is what makes the queue match the screen
|
|
// (PARITY-DEBT #14).
|
|
function cmpStr(a, b) { return a < b ? -1 : a > b ? 1 : 0 }
|
|
readonly property var tracksVisible: {
|
|
var rows = tracks
|
|
if (tracksGroup === "off" || rows.length === 0) return rows
|
|
var lc = function (v) { return (v || "").toString().toLowerCase() }
|
|
var out = rows.slice()
|
|
if (tracksGroup === "album") {
|
|
out.sort(function (a, b) {
|
|
return cmpStr(lc(a.album), lc(b.album)) || cmpStr(lc(a.title), lc(b.title))
|
|
})
|
|
} else if (tracksGroup === "artist") {
|
|
out.sort(function (a, b) {
|
|
return cmpStr(lc(a.artist), lc(b.artist))
|
|
|| cmpStr(lc(a.album), lc(b.album))
|
|
|| cmpStr(lc(a.title), lc(b.title))
|
|
})
|
|
} else if (tracksGroup === "name") {
|
|
out.sort(function (a, b) { return cmpStr(lc(a.title), lc(b.title)) })
|
|
}
|
|
return out
|
|
}
|
|
|
|
readonly property var artistsVisible: {
|
|
var q = artistsSearch.trim().toLowerCase()
|
|
if (q === "") return artists
|
|
var out = []
|
|
for (var i = 0; i < artists.length; i++) {
|
|
if ((artists[i].name || "").toLowerCase().indexOf(q) >= 0) out.push(artists[i])
|
|
}
|
|
return out
|
|
}
|
|
readonly property int artistsVisibleCount: artistsVisible.length
|
|
readonly property var artistsGrouped: {
|
|
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
|
|
}
|
|
|
|
// 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 []
|
|
var rows = albums // binding dependency: re-derive on every republish
|
|
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._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
|
|
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 (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) 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 (!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.openAlbum(id)
|
|
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 === "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 && root.activeTab === "albums"
|
|
view: root
|
|
}
|
|
}
|