Files
qbz/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
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
2026-09-14 00:49:54 -06:00

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()
}
}
}
}