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
1753 lines
80 KiB
QML
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
|
|
}
|
|
|
|
}
|