fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics

Address the owner's kiosk feedback on top of the release hardening:

- Chrome: the back bar is a drag surface and shrank to 44px with one shared
  button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a
  permanent cluster in the bar; the dead Share-logs button left the kiosk
  Settings header.
- Transport bar: the hidden overflow menu is gone; Volume is always visible
  with a vertical sheet, and Desktop mode has its own button.
- Now Playing: one centred transport row (New-bar order); lyrics are centred
  (shared LyricsLinesView gains an opt-in centered, desktop default false).
- Local Library: the media funnel and an Open (ephemeral) entry point reach
  the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the
  desktop LocalArtistsTab is untouched); the Artists drill-down query no
  longer hangs off a stale derived guard.
- Search: the All-tab dashboard shelves render again — they were mounted in a
  Loader Component that read the delegate id under reuse and bound an empty
  model; they are direct children now.
- Album detail: icon actions the height of the quality badge plus play next /
  later / queue, an inline floating quality badge, and zebra rows; both album
  headers show a quality badge.
- Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the
  window controls.

Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the
runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6,
Node gate 5/5, release build 0 warnings.

cc
This commit is contained in:
vicrodh committed 2026-09-07 12:33:28 -06:00
1 parent b1dba198c9
commit 31da9c57bb
24 files changed
+1273 -328

No files matched your search

+499 -5
View File
@@ -19,7 +19,16 @@
// 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.
// 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.
//
@@ -39,7 +48,9 @@
// the OUTGOING state) and restores it on the way back.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
@@ -110,6 +121,206 @@ Rectangle {
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
@@ -158,7 +369,18 @@ Rectangle {
QbzShell.navigateTo("localalbum")
}
function loadActiveTab() { QbzLocal.loadTab(root.activeTab) }
/// 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()
@@ -264,8 +486,23 @@ Rectangle {
return fallback
}
}
readonly property var albums: ((root.activeTab === "albums" && !QbzLocal.localAlbumsNativeActive) || root.activeTab === "genres" || (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive)) ? root.parseDoc(QbzLocal.localAlbumsJson, []) : []
readonly property var artists: (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive) ? root.parseDoc(QbzLocal.localArtistsJson, []) : []
// 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
@@ -451,7 +688,7 @@ Rectangle {
id: tabScroll
anchors.fill: parent
anchors.leftMargin: root.pad
anchors.rightMargin: root.pad
anchors.rightMargin: root.pad + stripActions.width + 8
contentWidth: tabsRow.width
contentHeight: height
clip: true
@@ -521,6 +758,82 @@ Rectangle {
}
}
// 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
@@ -530,6 +843,187 @@ Rectangle {
}
}
// 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.surfaceHover : 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
// =====================================================================