Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc
1129 lines
47 KiB
QML
1129 lines
47 KiB
QML
// KioskLocalLibrary — the kiosk Local Library host.
|
|
//
|
|
// ── WHAT CHANGED AND WHY ──────────────────────────────────────────────────
|
|
// The K0 audit found this view reading LEGACY JSON documents while Full UI had
|
|
// already switched the same surfaces to the paged native models. That is not a
|
|
// styling difference, it is why the panel showed empty Artists and Tracks tabs
|
|
// on a machine whose library is fine: `local_albums_json` / `local_artists_json`
|
|
// / `local_tracks_json` are published by the legacy readers only, and once
|
|
// `albums/artists/tracks_native_active` is true (the production default) Rust
|
|
// stops republishing them. It also mounted `Repeater`s over the whole document
|
|
// with a Loader per row — 20 416 items and 10 053 Loaders on the Albums tab at
|
|
// the 10 000-album fixture, 1.19 s to idle; 20 487 / 10 075 on Artists at
|
|
// 1.87 s (evidence/runtime-baseline/partial-initial.jsonl).
|
|
//
|
|
// This file is now only a HOST. It owns:
|
|
// 1. the tab set, its default and its history;
|
|
// 2. the four legacy documents (still the authority for Folders, Genres and
|
|
// as the automatic fallback when a catalog session fails);
|
|
// 3. the artwork window REGISTRY — the one policy that cannot be split per
|
|
// surface, because eviction is only correct against every live surface at
|
|
// once (the desktop `_windows` finding, reproduced);
|
|
// 4. the nav geometry it publishes into QbzKioskNav;
|
|
// 5. the media FUNNEL (quality / format / source / favorites) — the SAME
|
|
// persisted state as the desktop view, seeded from `QbzLocal.albumsFilter`
|
|
// and written through the desktop's own setters (owner report 2026-09-07:
|
|
// a funnel set on the desktop kept filtering the kiosk with no control to
|
|
// see or clear it);
|
|
// 6. the "Open" entry point (folder / audio CD / SACD image) that starts an
|
|
// ephemeral session — the desktop reaches it through LocalChrome's
|
|
// toolbar and the nav flyout, neither of which the kiosk mounts, so the
|
|
// Ephemeral tab was unreachable from the kiosk (owner report 2026-09-07).
|
|
// Every tab body is its own file, mounted behind `Loader.active`, and each one
|
|
// consumes the authoritative reader for its surface.
|
|
//
|
|
// ── DEFAULT TAB ───────────────────────────────────────────────────────────
|
|
// Albums, always — contract §2.2. A fresh mount, a NavRail entry and a
|
|
// programmatic navigation with no tab all land on Albums, and the persisted
|
|
// tab ORDER is deliberately not consulted for the default: it may put Tracks
|
|
// first, and three different defaults in shell, settings and view is the
|
|
// defect that rule exists to close. Back/Forward is the exception and the
|
|
// point: a restored entry keeps the tab that entry actually recorded, Tracks
|
|
// included — the past is not rewritten.
|
|
//
|
|
// ── HISTORY ───────────────────────────────────────────────────────────────
|
|
// There is no second stack. `KioskNavigation` writes this view's opaque state
|
|
// onto the ONE `QbzShell` history, records a tab destination through
|
|
// `recordKioskTab` BEFORE the tab changes (so the entry that is pushed carries
|
|
// the OUTGOING state) and restores it on the way back.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
color: "transparent"
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
|
|
|
/// Content inset. 16 keeps a card clear of the NavRail at 800px.
|
|
property real pad: 16
|
|
|
|
|
|
// =====================================================================
|
|
// Tabs
|
|
// =====================================================================
|
|
readonly property var knownTabs: ["genres", "albums", "artists", "folders", "tracks"]
|
|
|
|
/// The user's own tab order drives the STRIP. It deliberately does not
|
|
/// drive the DEFAULT (see the header).
|
|
readonly property var orderedTabs: {
|
|
var stored
|
|
try {
|
|
stored = JSON.parse(QbzBridge.settingsJson).localTabOrder
|
|
} catch (e) {
|
|
stored = null
|
|
}
|
|
var out = []
|
|
var i
|
|
if (Array.isArray(stored)) {
|
|
for (i = 0; i < stored.length; i++)
|
|
if (root.knownTabs.indexOf(stored[i]) >= 0 && out.indexOf(stored[i]) < 0)
|
|
out.push(stored[i])
|
|
}
|
|
// Anything the stored order omits still has to be reachable.
|
|
for (i = 0; i < root.knownTabs.length; i++)
|
|
if (out.indexOf(root.knownTabs[i]) < 0)
|
|
out.push(root.knownTabs[i])
|
|
return out
|
|
}
|
|
|
|
readonly property bool ephemeralActive: QbzLocal.localEphemeralActive
|
|
|
|
/// The strip's contents. The open session appends its own tab, exactly as
|
|
/// it does on the desktop, and it is the only tab that can VANISH while
|
|
/// the user is standing on it.
|
|
readonly property var visibleTabs: root.ephemeralActive
|
|
? root.orderedTabs.concat(["ephemeral"]) : root.orderedTabs
|
|
|
|
function tabLabel(id) {
|
|
if (id === "genres") return root.t("Genres")
|
|
if (id === "albums") return root.t("Albums")
|
|
if (id === "artists") return root.t("Artists")
|
|
if (id === "folders") return root.t("Folders")
|
|
if (id === "tracks") return root.t("Tracks")
|
|
// The open session names itself; Rust computes the label so this view
|
|
// and the nav flyout cannot call the same thing two different things.
|
|
return QbzLocal.localEphemeralLabel !== ""
|
|
? QbzLocal.localEphemeralLabel : root.t("Open")
|
|
}
|
|
|
|
/// ALBUMS. Not `orderedTabs[0]`.
|
|
property string activeTab: "albums"
|
|
|
|
/// Opaque per-tab state carried on the shared history entry.
|
|
property string selectedArtist: ""
|
|
property string selectedGenre: ""
|
|
|
|
// =====================================================================
|
|
// The media funnel
|
|
// =====================================================================
|
|
// One quality/format/source funnel follows the user across Albums,
|
|
// Artists, Genres and Tracks; Favorites is an Albums/Genres-only predicate
|
|
// that survives visits to the other tabs. Mirrors LocalLibraryView.qml
|
|
// :323-383 — same seed, same writers, same split into the shared "common"
|
|
// part — so the two shells cannot disagree about what is filtered.
|
|
property var albumsFilter: root.parseFilter(QbzLocal.albumsFilter)
|
|
readonly property var commonFilter: {
|
|
var out = Object.assign({}, root.albumsFilter || ({}))
|
|
delete out.favorite
|
|
return out
|
|
}
|
|
readonly property bool favoriteOnly: root.albumsFilter.favorite === true
|
|
/// The funnel the ACTIVE tab sees.
|
|
readonly property var filter: (root.activeTab === "albums" || root.activeTab === "genres")
|
|
? root.albumsFilter : root.commonFilter
|
|
/// The descriptor handed to the native readers (which know no favorites).
|
|
readonly property string filterJson: JSON.stringify(root.commonFilter)
|
|
readonly property int filterCount: {
|
|
var n = 0
|
|
for (var k in root.filter)
|
|
if (root.filter[k])
|
|
n++
|
|
return n
|
|
}
|
|
function parseFilter(json) {
|
|
if (!json || json === "")
|
|
return ({})
|
|
try {
|
|
return JSON.parse(json) || ({})
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
/// The ONE writer (LocalLibraryView.qml:361-383). Persists through the
|
|
/// desktop's setters and re-queries whatever the active tab reads.
|
|
function setFilter(f) {
|
|
var value = f || ({})
|
|
var favorite = (root.activeTab === "albums" || root.activeTab === "genres")
|
|
? value.favorite === true : root.favoriteOnly
|
|
var common = Object.assign({}, value)
|
|
delete common.favorite
|
|
var albumValue = Object.assign({}, common)
|
|
if (favorite)
|
|
albumValue.favorite = true
|
|
root.albumsFilter = albumValue
|
|
QbzLocal.setAlbumsFilterJson(Object.keys(albumValue).length === 0
|
|
? "" : JSON.stringify(albumValue))
|
|
// Tracks owns a server-side descriptor; its setter resets the paged
|
|
// query, so it is only called while Tracks is the visible consumer.
|
|
// A later tab switch installs the shared state (loadActiveTab).
|
|
if (root.activeTab === "tracks")
|
|
QbzLocal.tracksSetFilterJson(JSON.stringify(common))
|
|
else
|
|
root.loadActiveTab()
|
|
}
|
|
function toggleFilter(key) {
|
|
var f = Object.assign({}, root.filter)
|
|
f[key] = !f[key]
|
|
if (!f[key])
|
|
delete f[key]
|
|
root.setFilter(f)
|
|
}
|
|
function clearFilter() { root.setFilter({}) }
|
|
// A later republish still wins (a media server connected or removed
|
|
// prunes the saved funnel). 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.albumsFilter = next
|
|
}
|
|
}
|
|
|
|
// The client-side predicates for the LEGACY documents, ported from
|
|
// LocalLibraryView.qml (sourceBucket :795, applyFilter :810,
|
|
// artistMatchesFilter :862). The native readers filter in Rust from the
|
|
// same descriptor.
|
|
function sourceBucket(word) {
|
|
var source = (word || "local").toLowerCase()
|
|
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 && r.isFavorite !== true)
|
|
continue
|
|
if (qAny || fAny || sAny) {
|
|
// Test every active section against ONE physical copy: a
|
|
// logical album can carry DSD on one copy and FLAC on
|
|
// another, and 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)
|
|
var sok = !sAny || selected[root.sourceBucket(variant.source)] === true
|
|
if (qok && fok && sok) {
|
|
mediaMatches = true
|
|
break
|
|
}
|
|
}
|
|
if (!mediaMatches)
|
|
continue
|
|
}
|
|
out.push(r)
|
|
}
|
|
return out
|
|
}
|
|
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[root.sourceBucket(artist.sources[i])] === true) {
|
|
sourceOk = true
|
|
break
|
|
}
|
|
}
|
|
if (!sourceOk)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function tabAvailable(tab) {
|
|
if (tab === "ephemeral")
|
|
return root.ephemeralActive
|
|
return root.knownTabs.indexOf(tab) >= 0
|
|
}
|
|
|
|
/// THE recording setter. Everything that represents a user destination —
|
|
/// the strip, the router handshake, `open ephemeral` — goes through it.
|
|
function activateTab(tab) {
|
|
if (!tab || tab === root.activeTab || !root.tabAvailable(tab))
|
|
return
|
|
// BEFORE the assignment: the entry that gets pushed has to carry the
|
|
// state of the tab being LEFT, which is what Back restores.
|
|
nav.recordTab(tab)
|
|
root.activeTab = tab
|
|
}
|
|
|
|
/// A tab change that is NOT a destination: the ephemeral fallback, and the
|
|
/// restoration of a history entry. Neither may push an entry.
|
|
function setTabSilently(tab) {
|
|
if (!tab || tab === root.activeTab)
|
|
return
|
|
root.activeTab = tab
|
|
}
|
|
|
|
// The router's ONE external tab seam (NavFlyout, the kiosk NavRail through
|
|
// `navigateToTab`, and `open ephemeral`). ContentRouter writes this
|
|
// property; `sequence` makes re-selecting the same tab re-apply.
|
|
property var tabNavigationRequest: ({})
|
|
onTabNavigationRequestChanged: {
|
|
if (root.tabNavigationRequest && root.tabNavigationRequest.tab)
|
|
root.activateTab(root.tabNavigationRequest.tab)
|
|
}
|
|
|
|
function selectArtist(name) { root.selectedArtist = name || "" }
|
|
function selectGenre(key) { root.selectedGenre = key || "" }
|
|
|
|
/// A local album card opens the routed local album page. Both halves are
|
|
/// required: `openAlbum` only LOADS the document — the old kiosk called it
|
|
/// alone, so a card tap loaded an album nobody ever navigated to and read
|
|
/// as a dead control.
|
|
function openAlbum(id) {
|
|
if (!id)
|
|
return
|
|
QbzLocal.openAlbum(id)
|
|
QbzShell.navigateTo("localalbum")
|
|
}
|
|
|
|
/// LocalLibraryView.qml:700-707. Tracks owns a server-side descriptor:
|
|
/// installing the shared funnel IS its load. Albums under "Favorites
|
|
/// only" reads the legacy document (the native reader knows no
|
|
/// favorites — LocalLibraryView.qml:341-344, :515).
|
|
function loadActiveTab() {
|
|
if (root.activeTab === "tracks")
|
|
QbzLocal.tracksSetFilterJson(JSON.stringify(root.commonFilter))
|
|
else if (root.activeTab === "albums" && root.favoriteOnly)
|
|
QbzLocal.loadTab("albums-legacy")
|
|
else
|
|
QbzLocal.loadTab(root.activeTab)
|
|
}
|
|
|
|
onActiveTabChanged: {
|
|
root.loadActiveTab()
|
|
root.publishNav(root._navColumns, 0)
|
|
root.artworkRefresh()
|
|
}
|
|
|
|
/// The open session is the one tab that can disappear under the user (the
|
|
/// disc is ejected, the folder is closed). Falling back to Albums is the
|
|
/// desktop lifecycle, and it is SILENT: closing a session is not a
|
|
/// navigation, and Back must not be able to resurrect it.
|
|
onEphemeralActiveChanged: {
|
|
if (!root.ephemeralActive && root.activeTab === "ephemeral")
|
|
root.setTabSilently("albums")
|
|
root.publishNav(root._navColumns, root._navItems)
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
// KioskNavigation has already completed (children complete first), so
|
|
// a restored entry has set `activeTab` by now and this is its load.
|
|
root.loadActiveTab()
|
|
root.publishNav(1, 0)
|
|
}
|
|
|
|
// =====================================================================
|
|
// History — one stack, this view's opaque state on it
|
|
// =====================================================================
|
|
KioskNavigation {
|
|
id: nav
|
|
route: "local"
|
|
snapshot: ({
|
|
"activeTab": root.activeTab,
|
|
"selectedArtist": root.selectedArtist,
|
|
"selectedGenre": root.selectedGenre
|
|
})
|
|
onRestore: function (saved) {
|
|
if (!saved)
|
|
return
|
|
var tab = typeof saved.activeTab === "string" ? saved.activeTab : root.activeTab
|
|
// NO RESURRECTION: an entry recorded while a session was open must
|
|
// not reopen a session that has since been closed.
|
|
if (!root.tabAvailable(tab))
|
|
tab = "albums"
|
|
root.activeTab = tab
|
|
root.selectedArtist = typeof saved.selectedArtist === "string"
|
|
? saved.selectedArtist : ""
|
|
root.selectedGenre = typeof saved.selectedGenre === "string"
|
|
? saved.selectedGenre : ""
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Nav geometry (QbzKioskNav)
|
|
// =====================================================================
|
|
// The mounted tab body is the only thing that knows its own column count
|
|
// and item count, so it publishes them through here. The leading `tabs`
|
|
// entries are the strip.
|
|
property int _navColumns: 1
|
|
property int _navItems: 0
|
|
function publishNav(columns, items) {
|
|
root._navColumns = Math.max(1, columns || 1)
|
|
root._navItems = Math.max(0, items || 0)
|
|
// `visibleTabs` is a derived binding, and an early publish (the
|
|
// ephemeral flag arriving during construction) can reach here before
|
|
// it has been evaluated. A geometry publish is not worth a TypeError.
|
|
var tabs = root.visibleTabs ? root.visibleTabs.length : 0
|
|
if (tabs === 0)
|
|
return
|
|
QbzKioskNav.publishNav(tabs, root._navColumns, tabs + root._navItems, false)
|
|
}
|
|
|
|
// Enter on a tab entry drives the same switch a tap does. The
|
|
// `index < tabs` test is what keeps this handler and the mounted body's
|
|
// disjoint: both see the same pulse.
|
|
Connections {
|
|
target: QbzKioskNav
|
|
function onActivateSeqChanged() {
|
|
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content")
|
|
return
|
|
if (QbzKioskNav.index < 0 || QbzKioskNav.index >= QbzKioskNav.tabs)
|
|
return
|
|
var id = root.visibleTabs[QbzKioskNav.index]
|
|
if (id !== undefined)
|
|
root.activateTab(id)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Legacy documents
|
|
// =====================================================================
|
|
// Still authoritative for Folders and for the Genres browser (whose
|
|
// `load_tab("genres")` arm deliberately routes to `load_albums_legacy`),
|
|
// and still the automatic fallback for Albums/Artists/Tracks when a
|
|
// catalog session fails. A tab body reads these ONLY when its native
|
|
// reader is inactive.
|
|
function parseDoc(json, fallback) {
|
|
if (json === "")
|
|
return fallback
|
|
try {
|
|
return JSON.parse(json)
|
|
} catch (e) {
|
|
console.warn("[qbz-qt] kiosk local: bad document — " + e)
|
|
return fallback
|
|
}
|
|
}
|
|
// The legacy album/artist documents are read THROUGH the funnel: Albums
|
|
// and Genres see the favorites predicate, the Artists drill-down the
|
|
// shared common part (LocalLibraryView.qml:916, :1021, :1213).
|
|
readonly property var albums: ((root.activeTab === "albums" && (!QbzLocal.localAlbumsNativeActive || root.favoriteOnly)) || root.activeTab === "genres" || (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive))
|
|
? root.applyFilter(root.parseDoc(QbzLocal.localAlbumsJson, []),
|
|
root.activeTab === "artists" ? root.commonFilter : root.albumsFilter)
|
|
: []
|
|
readonly property var artists: {
|
|
if (!(root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive))
|
|
return []
|
|
var rows = root.parseDoc(QbzLocal.localArtistsJson, [])
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++)
|
|
if (root.artistMatchesFilter(rows[i], root.commonFilter))
|
|
out.push(rows[i])
|
|
return out
|
|
}
|
|
readonly property var folders: (root.activeTab === "folders") ? root.parseDoc(QbzLocal.localFoldersJson, []) : []
|
|
readonly property var tracks: (root.activeTab === "tracks" && !QbzLocal.localTracksNativeActive) ? root.parseDoc(QbzLocal.localTracksJson, []) : []
|
|
readonly property var ephemeral: (root.activeTab === "ephemeral") ? root.parseDoc(QbzLocal.localEphemeralJson, null) : null
|
|
|
|
readonly property bool trackArtwork: QbzLocal.localTrackArtwork
|
|
|
|
// =====================================================================
|
|
// Artwork window registry
|
|
// =====================================================================
|
|
// Covers are id-keyed: a surface reports the artKeys of the rows it has
|
|
// MOUNTED, Rust answers one `localArtworkReady` per resolved key, and the
|
|
// union of every live window plus one window of margin is what survives
|
|
// eviction. It lives here rather than per tab because two cover surfaces
|
|
// can be alive at once (the Artists list and its drill-down grid), and a
|
|
// per-surface keep-set makes each one delete the other's covers.
|
|
property var artMap: ({})
|
|
property var _artInbox: ({})
|
|
property var _windows: ({})
|
|
property var _pending: ({})
|
|
// `real`, not `int`: Date.now() is ~1.7e12.
|
|
property real _lastReportMs: 0
|
|
|
|
signal artworkRefresh()
|
|
|
|
function artPathOf(key) { return root.artMap[key] || "" }
|
|
function artWanted(key) { return (key || "") !== "" }
|
|
|
|
// Arrivals stream in one at a time. Rebinding `artMap` per arrival is
|
|
// quadratic in the window, so they are coalesced into one rebind per
|
|
// frame — the covers still appear progressively at 16ms granularity.
|
|
Timer {
|
|
id: artFlush
|
|
interval: 16
|
|
repeat: false
|
|
onTriggered: {
|
|
var next = Object.assign({}, root.artMap, root._artInbox)
|
|
root._artInbox = ({})
|
|
// A rebind needs a NEW object reference: a same-ref assignment is
|
|
// not a change in QML.
|
|
root.artMap = next
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: QbzLocal
|
|
function onLocalArtworkReady(key, path) {
|
|
root._artInbox[key] = path
|
|
if (!artFlush.running)
|
|
artFlush.start()
|
|
}
|
|
}
|
|
|
|
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, scrolls off or has nothing to show stops
|
|
/// holding its covers.
|
|
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]
|
|
root.flushWindows()
|
|
}
|
|
|
|
/// Evict against the UNION of every live window, then request what is
|
|
/// still missing. A key already resolved is never re-sent: `artMap` IS the
|
|
/// resolved set and a re-request costs Rust a stat per key.
|
|
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 map = root.artMap
|
|
var changed = false
|
|
for (k in map) {
|
|
if (!keep[k]) {
|
|
delete map[k]
|
|
changed = true
|
|
}
|
|
}
|
|
// Evict the not-yet-flushed arrivals too, or a cover that landed for a
|
|
// row we have just scrolled past would be re-added by the next flush.
|
|
for (k in root._artInbox)
|
|
if (!keep[k])
|
|
delete root._artInbox[k]
|
|
if (changed)
|
|
root.artMap = Object.assign({}, map)
|
|
|
|
var missing = []
|
|
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 (map[ak] !== undefined || root._artInbox[ak] !== undefined)
|
|
continue
|
|
missing.push(ak)
|
|
}
|
|
}
|
|
if (missing.length > 0)
|
|
QbzLocal.artworkWindow(JSON.stringify(missing))
|
|
}
|
|
|
|
// Rate-limited to one resolution pass per 180ms, but LEADING EDGE: the
|
|
// limiter exists so a flick cannot fire a pass per pixel, and a view that
|
|
// has just mounted is not flicking. Pending reports are keyed by SURFACE,
|
|
// so two surfaces reporting inside the same window both survive.
|
|
Timer {
|
|
id: windowDebounce
|
|
interval: 180
|
|
repeat: false
|
|
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()
|
|
}
|
|
}
|
|
function queueWindowReport(rows, first, last, key) {
|
|
var k = key || "default"
|
|
if (!windowDebounce.running
|
|
&& Date.now() - root._lastReportMs >= windowDebounce.interval) {
|
|
root._lastReportMs = Date.now()
|
|
root.applyWindow(k, rows, first, last)
|
|
root.flushWindows()
|
|
} else {
|
|
root._pending[k] = { "rows": rows, "first": first, "last": last }
|
|
if (!windowDebounce.running)
|
|
windowDebounce.start()
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Tab strip
|
|
// =====================================================================
|
|
// 64px — the contract's primary touch target — and the whole cell is the
|
|
// hit area, not the text's bounding box. The strip scrolls horizontally so
|
|
// a six-tab set with a long session name stays reachable at 800px.
|
|
Rectangle {
|
|
id: tabStrip
|
|
|
|
anchors.left: root.left
|
|
anchors.right: root.right
|
|
anchors.top: root.top
|
|
height: 64
|
|
color: theme.surfaceMain
|
|
|
|
Flickable {
|
|
id: tabScroll
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.pad
|
|
anchors.rightMargin: root.pad + stripActions.width + 8
|
|
contentWidth: tabsRow.width
|
|
contentHeight: height
|
|
clip: true
|
|
flickableDirection: Flickable.HorizontalFlick
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Row {
|
|
id: tabsRow
|
|
height: tabScroll.height
|
|
spacing: 6
|
|
|
|
// A FIXED tab list, never a data collection: at most the five
|
|
// ordered tabs plus the open session.
|
|
Repeater {
|
|
model: root.visibleTabs
|
|
|
|
delegate: Rectangle {
|
|
id: tab
|
|
required property string modelData
|
|
required property int index
|
|
|
|
readonly property bool active: root.activeTab === tab.modelData
|
|
readonly property bool navFocused: QbzKioskNav.navActive
|
|
&& QbzKioskNav.zone === "content"
|
|
&& QbzKioskNav.index === tab.index
|
|
|
|
width: Math.max(88, tabText.implicitWidth + 28)
|
|
height: tabsRow.height
|
|
color: tab.active
|
|
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.10)
|
|
: "transparent"
|
|
radius: theme.radiusSm
|
|
border.width: tab.navFocused ? 2 : 0
|
|
border.color: theme.accent
|
|
|
|
Text {
|
|
id: tabText
|
|
anchors.centerIn: parent
|
|
width: Math.min(implicitWidth, 220)
|
|
text: root.tabLabel(tab.modelData)
|
|
color: tab.active ? theme.textPrimary : theme.textMuted
|
|
font.pixelSize: 16
|
|
font.weight: theme.weightSemibold
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 1
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 6
|
|
height: 3
|
|
radius: 2
|
|
color: tab.active ? theme.accent : "transparent"
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.activateTab(tab.modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The strip's right cluster: Open (starts an ephemeral session) and
|
|
// the funnel. Two 64px touch targets, always mounted — the funnel
|
|
// button is the only place the kiosk shows that a filter is on.
|
|
Row {
|
|
id: stripActions
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: root.pad
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: 64
|
|
spacing: 4
|
|
|
|
Rectangle {
|
|
id: openBtn
|
|
width: 64
|
|
height: 64
|
|
radius: theme.radiusSm
|
|
color: openArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Accessible.role: Accessible.Button
|
|
Accessible.name: root.t("Open")
|
|
QbzIcon {
|
|
name: "folder-open"
|
|
width: 26
|
|
height: 26
|
|
anchors.centerIn: parent
|
|
tintName: "textPrimary"
|
|
}
|
|
MouseArea {
|
|
id: openArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: openMenu.openBelowRight(openBtn)
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: filterBtn
|
|
width: 64
|
|
height: 64
|
|
radius: theme.radiusSm
|
|
color: filterArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
border.width: root.filterCount > 0 ? 1 : 0
|
|
border.color: theme.accent
|
|
Accessible.role: Accessible.Button
|
|
Accessible.name: root.t("Quality, format and source filters")
|
|
QbzIcon {
|
|
name: "list-filter"
|
|
width: 26
|
|
height: 26
|
|
anchors.centerIn: parent
|
|
tintName: root.filterCount > 0 ? "accent" : "textPrimary"
|
|
}
|
|
Rectangle {
|
|
visible: root.filterCount > 0
|
|
x: parent.width - width - 8
|
|
y: 8
|
|
width: 20
|
|
height: 20
|
|
radius: 10
|
|
color: theme.accent
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.filterCount
|
|
color: theme.accentGlyphColor
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightBold
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: filterArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: root.openFilterSheet()
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.left: tabStrip.left
|
|
anchors.right: tabStrip.right
|
|
y: tabStrip.height - 1
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
}
|
|
|
|
// The Open menu: LocalChrome.qml:262-282's entries and actions, verbatim
|
|
// (the audio-CD row is dropped on Windows for the same reason it is
|
|
// there). The session opens globally and the router lands on the
|
|
// Ephemeral tab (ContentRouter.qml:606-611).
|
|
CardMenu {
|
|
id: openMenu
|
|
kioskHost: true
|
|
menuWidth: 260
|
|
entries: [
|
|
{ "label": root.t("Open folder…"), "icon": "folder-open", "action": "folder" }
|
|
].concat(QbzShell.isWindows ? [] : [
|
|
{ "label": root.t("Open audio CD"), "icon": "disc", "action": "cd" }
|
|
]).concat([
|
|
{ "label": root.t("Open SACD image…"), "icon": "disc-3", "action": "sacd" }
|
|
])
|
|
onPicked: function (action) {
|
|
if (action === "folder")
|
|
QbzLocal.ephemeralOpen()
|
|
else if (action === "cd")
|
|
QbzLocal.ephemeralOpenCd()
|
|
else if (action === "sacd")
|
|
QbzLocal.ephemeralOpenSacd()
|
|
}
|
|
}
|
|
|
|
/// The funnel sheet: LocalFilterPopup.qml's sections and chips at touch
|
|
/// density, centred in the window and clamped inside it.
|
|
function openFilterSheet() {
|
|
var win = filterSheet.parent
|
|
if (win) {
|
|
filterSheet.x = Math.max(8, (win.width - filterSheet.width) / 2)
|
|
filterSheet.y = Math.max(8, Math.min(88, win.height - filterSheet.height - 8))
|
|
}
|
|
filterSheet.open()
|
|
}
|
|
|
|
component FunnelChip: Rectangle {
|
|
id: chip
|
|
property string label: ""
|
|
property bool active: false
|
|
signal toggled()
|
|
height: 44
|
|
width: chipLabel.implicitWidth + 32
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: chip.active ? theme.accent : theme.borderSubtle
|
|
color: chip.active ? theme.accent
|
|
: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
|
Text {
|
|
id: chipLabel
|
|
anchors.centerIn: parent
|
|
text: chip.label
|
|
color: chip.active ? theme.accentGlyphColor : theme.textSecondary
|
|
font.pixelSize: 15
|
|
font.weight: chip.active ? theme.weightSemibold : theme.weightRegular
|
|
}
|
|
MouseArea {
|
|
id: chipArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: chip.toggled()
|
|
}
|
|
}
|
|
|
|
component FunnelHeading: Text {
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
|
|
Popup {
|
|
id: filterSheet
|
|
parent: Overlay.overlay
|
|
width: Math.min(560, (parent ? parent.width : 800) - 16)
|
|
height: Math.min(sheetColumn.height + 32, (parent ? parent.height : 480) - 16)
|
|
padding: 16
|
|
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
|
background: Rectangle {
|
|
color: theme.surfaceCard
|
|
radius: 10
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
}
|
|
contentItem: Flickable {
|
|
contentWidth: width
|
|
contentHeight: sheetColumn.height
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
Column {
|
|
id: sheetColumn
|
|
width: parent.width
|
|
spacing: 10
|
|
|
|
Row {
|
|
width: parent.width
|
|
height: 44
|
|
Text {
|
|
width: parent.width - clearBtn.width
|
|
height: parent.height
|
|
text: root.t("Filter")
|
|
color: theme.textPrimary
|
|
font.pixelSize: 18
|
|
font.weight: theme.weightSemibold
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
Rectangle {
|
|
id: clearBtn
|
|
visible: root.filterCount > 0
|
|
width: visible ? clearText.implicitWidth + 32 : 0
|
|
height: 44
|
|
radius: theme.radiusSm
|
|
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Text {
|
|
id: clearText
|
|
anchors.centerIn: parent
|
|
text: root.t("Clear")
|
|
color: theme.accent
|
|
font.pixelSize: 15
|
|
font.weight: theme.weightMedium
|
|
}
|
|
MouseArea {
|
|
id: clearArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: root.clearFilter()
|
|
}
|
|
}
|
|
}
|
|
|
|
FunnelHeading {
|
|
visible: root.activeTab === "albums" || root.activeTab === "genres"
|
|
text: root.t("Favorites")
|
|
}
|
|
Flow {
|
|
visible: root.activeTab === "albums" || root.activeTab === "genres"
|
|
width: parent.width
|
|
spacing: 8
|
|
FunnelChip {
|
|
label: root.t("Favorites only")
|
|
active: root.filter.favorite === true
|
|
onToggled: root.toggleFilter("favorite")
|
|
}
|
|
}
|
|
|
|
FunnelHeading { text: root.t("Quality") }
|
|
Flow {
|
|
width: parent.width
|
|
spacing: 8
|
|
FunnelChip { label: "DSD"; active: root.filter.dsd === true; onToggled: root.toggleFilter("dsd") }
|
|
FunnelChip { label: root.t("Hi-Res"); active: root.filter.hires === true; onToggled: root.toggleFilter("hires") }
|
|
FunnelChip { label: root.t("CD"); active: root.filter.cd === true; onToggled: root.toggleFilter("cd") }
|
|
FunnelChip { label: root.t("Lossy"); active: root.filter.lossy === true; onToggled: root.toggleFilter("lossy") }
|
|
}
|
|
|
|
FunnelHeading { text: root.t("Format") }
|
|
Flow {
|
|
width: parent.width
|
|
spacing: 8
|
|
FunnelChip { label: "FLAC"; active: root.filter.flac === true; onToggled: root.toggleFilter("flac") }
|
|
FunnelChip { label: "ALAC"; active: root.filter.alac === true; onToggled: root.toggleFilter("alac") }
|
|
FunnelChip { label: "APE"; active: root.filter.ape === true; onToggled: root.toggleFilter("ape") }
|
|
FunnelChip { label: "WAV"; active: root.filter.wav === true; onToggled: root.toggleFilter("wav") }
|
|
FunnelChip { label: "MP3"; active: root.filter.mp3 === true; onToggled: root.toggleFilter("mp3") }
|
|
FunnelChip { label: "AAC"; active: root.filter.aac === true; onToggled: root.toggleFilter("aac") }
|
|
FunnelChip { label: root.t("Other"); active: root.filter.other === true; onToggled: root.toggleFilter("other") }
|
|
}
|
|
|
|
FunnelHeading { text: root.t("Source") }
|
|
Flow {
|
|
width: parent.width
|
|
spacing: 8
|
|
FunnelChip { label: root.t("Local"); active: root.filter.local === true; onToggled: root.toggleFilter("local") }
|
|
FunnelChip { label: root.t("Offline cache"); active: root.filter.offline === true; onToggled: root.toggleFilter("offline") }
|
|
FunnelChip { visible: QbzLocal.plexAvailable; label: "Plex"; active: root.filter.plex === true; onToggled: root.toggleFilter("plex") }
|
|
FunnelChip { visible: QbzLocal.mediaHasJellyfin; label: "Jellyfin"; active: root.filter.jellyfin === true; onToggled: root.toggleFilter("jellyfin") }
|
|
FunnelChip { visible: QbzLocal.mediaHasSubsonic; label: "Subsonic"; active: root.filter.subsonic === true; onToggled: root.toggleFilter("subsonic") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Content — ONE tab exists at a time
|
|
// =====================================================================
|
|
// `Loader.active`, never `visible: false`: a hidden item still costs what
|
|
// it mounted, and the Tracks body is the one that makes that matter. This
|
|
// is also what keeps the Tracks model out of an Albums visit — nothing
|
|
// subscribes to its page misses and nothing queries it until its own tab
|
|
// is the mounted one.
|
|
Item {
|
|
id: content
|
|
|
|
anchors.left: root.left
|
|
anchors.right: root.right
|
|
anchors.top: tabStrip.bottom
|
|
anchors.bottom: root.bottom
|
|
clip: true
|
|
|
|
/// The open session renders with no indexed library at all — it is
|
|
/// content from OUTSIDE the index, so gating it on `localAvailable`
|
|
/// would hide the pane from exactly the people the feature is for.
|
|
readonly property bool ephemeralShowing:
|
|
root.activeTab === "ephemeral" && root.ephemeralActive
|
|
|
|
KioskEmptyState {
|
|
anchors.fill: parent
|
|
visible: !QbzLocal.localAvailable && !content.ephemeralShowing
|
|
// An EXISTING msgid, present in all eight catalogues — this lane
|
|
// may not add translations, so no new msgid is introduced.
|
|
text: root.t("No folders yet. Add a folder to build your local library.")
|
|
}
|
|
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: QbzLocal.localAvailable && root.activeTab === "genres"
|
|
sourceComponent: KioskLocalGenresTab { view: root }
|
|
}
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: QbzLocal.localAvailable && root.activeTab === "albums"
|
|
sourceComponent: KioskLocalAlbumsTab { view: root }
|
|
}
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: QbzLocal.localAvailable && root.activeTab === "artists"
|
|
sourceComponent: KioskLocalArtistsTab { view: root }
|
|
}
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: QbzLocal.localAvailable && root.activeTab === "folders"
|
|
sourceComponent: KioskLocalFoldersTab { view: root }
|
|
}
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: QbzLocal.localAvailable && root.activeTab === "tracks"
|
|
sourceComponent: KioskLocalTracksTab { view: root }
|
|
}
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: content.ephemeralShowing
|
|
sourceComponent: KioskLocalEphemeralTab { view: root }
|
|
}
|
|
|
|
// Retry. The kiosk primitive draws an error message and no action, and
|
|
// a route that can only say "it failed" is a route the user has to
|
|
// leave. One affordance for every retryable tab, mounted over the
|
|
// message, 64px tall.
|
|
readonly property string activeError: root.activeTab === "albums"
|
|
? QbzLocal.localAlbumsError
|
|
: root.activeTab === "artists" && QbzLocal.localArtistsNativeActive ? QbzLocal.localArtistsNativeError
|
|
: root.activeTab === "tracks" && QbzLocal.localTracksNativeActive ? QbzLocal.localTracksNativeError
|
|
: root.activeTab === "genres" ? QbzLocal.localAlbumsError
|
|
: ""
|
|
|
|
Rectangle {
|
|
id: retry
|
|
visible: content.activeError !== ""
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.verticalCenterOffset: 56
|
|
width: Math.max(140, retryText.implicitWidth + 44)
|
|
height: 64
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
|
|
Text {
|
|
id: retryText
|
|
anchors.centerIn: parent
|
|
text: root.t("Retry")
|
|
color: theme.textPrimary
|
|
font.pixelSize: 16
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.loadActiveTab()
|
|
}
|
|
}
|
|
}
|
|
}
|