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

@@ -6840,3 +6840,6 @@ msgstr "Liste"
msgid "Tree"
msgstr "Baum"
msgid "Forward"
msgstr "Vorwärts"
@@ -6846,3 +6846,6 @@ msgstr "Lista"
msgid "Tree"
msgstr "Árbol"
msgid "Forward"
msgstr "Adelante"
@@ -6838,3 +6838,6 @@ msgstr "Liste"
msgid "Tree"
msgstr "Arborescence"
msgid "Forward"
msgstr "Suivant"
@@ -6821,3 +6821,6 @@ msgstr "リスト"
msgid "Tree"
msgstr "ツリー"
msgid "Forward"
msgstr "進む"
@@ -6828,3 +6828,6 @@ msgstr "Lijst"
msgid "Tree"
msgstr "Boomstructuur"
msgid "Forward"
msgstr "Vooruit"
@@ -6838,3 +6838,6 @@ msgstr "Lista"
msgid "Tree"
msgstr "Árvore"
msgid "Forward"
msgstr "Avançar"
@@ -6859,3 +6859,6 @@ msgstr "Список"
msgid "Tree"
msgstr "Дерево"
msgid "Forward"
msgstr "Вперёд"
+26 -12
View File
@@ -168,7 +168,9 @@ Item {
cursorShape: (root.Window.window !== null
&& root.Window.window.visibility === Window.FullScreen
&& !root.chromeVisible) ? Qt.BlankCursor : Qt.ArrowCursor
onPressed: function (mouse) { mouse.accepted = true }
// A press on the backdrop WAKES the chrome: touch panels have no
// hover, so this is the only way a kiosk finger brings the exit back.
onPressed: function (mouse) { mouse.accepted = true; root.wake() }
onReleased: function (mouse) { mouse.accepted = true }
onClicked: function (mouse) { mouse.accepted = true }
onDoubleClicked: function (mouse) { mouse.accepted = true }
@@ -791,25 +793,37 @@ Item {
anchors.fill: parent
accent: QbzShell.ambientAccent
}
// Appliance exit never follows the auto-hiding chrome. The existing
// close path retains fullscreen and restores shell focus via preKiosk.
// Appliance exit. It FOLLOWS the auto-hiding chrome now (owner feedback
// 2026-09-07: an always-on 64px white-bordered box was too loud on the
// art) — same `chromeVisible` state as the header and the player bar,
// hit-enabled only while shown, and it comes back on any pointer move,
// key or tap (the backdrop press below wakes the chrome for touch). The
// close path is unchanged: it retains fullscreen and restores shell
// focus via preKiosk. Quiet chrome: translucent fill, hairline border.
Rectangle {
visible: root.preKiosk
anchors.top: parent.top
opacity: root.chromeVisible ? 1 : 0
enabled: root.chromeVisible
// BOTTOM-right (owner feedback 2026-09-07): at the top it collided
// with the immersive's own window controls. The player bar is
// horizontally centred at y=height-114, so the right corner is clear;
// this sits in it, clear of both the controls and the bar.
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 12
width: 64
height: 64
anchors.bottomMargin: 24
anchors.rightMargin: 16
width: 52
height: 52
radius: 8
color: "#171717"
border.color: "#ffffff"
border.width: 2
color: "#99171717"
border.color: "#59ffffff"
border.width: 1
z: 3001
activeFocusOnTab: visible
activeFocusOnTab: visible && root.chromeVisible
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Close", QbzSession.trRev)
Accessible.onPressAction: QbzImmersive.open = false
QbzIcon { anchors.centerIn: parent; width: 32; height: 32; name: "x"; tintName: "primary" }
QbzIcon { anchors.centerIn: parent; width: 24; height: 24; name: "x"; tintName: "primary" }
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
QbzImmersive.open = false
+34 -20
View File
@@ -79,25 +79,34 @@ Rectangle {
Text { anchors.fill: parent; verticalAlignment: Text.AlignVCenter; text: root.header.artist || ""; color: theme.textSecondary; font.pixelSize: 17; elide: Text.ElideRight }
MouseArea { anchors.fill: parent; onClicked: QbzArtist.openArtist(root.header.artistId || "") }
}
Text { width: parent.width; text: root.header.qualityDetail || ""; color: theme.textMuted; font.pixelSize: 13; elide: Text.ElideRight }
Row {
spacing: 10
Repeater {
model: ["Play", "Shuffle"]
delegate: Rectangle {
required property string modelData
required property int index
width: Math.max(64, Math.min(132, (headerInfo.width - 10) / 2)); height: 64
radius: theme.radiusSm; color: index === 0 ? theme.accent : theme.surfaceElevated
Text { anchors.centerIn: parent; text: QbzSession.tr(modelData, QbzSession.trRev); color: index === 0 ? theme.accentText : theme.textPrimary; font.pixelSize: 16 }
MouseArea {
anchors.fill: parent
onClicked: {
if (index === 0) QbzPlayer.playAlbum(root.header.id || "")
else QbzPlayer.playAlbumShuffled(root.header.id || "")
}
}
}
// Action row (owner feedback 2026-09-07): icon buttons, not
// text; Play/Shuffle plus the three queue insertions
// (play next / play later / add to queue), all the SAME height;
// and the quality badge floats inline at the right of the same
// row instead of owning a line above it.
Item {
width: parent.width
height: 48
Row {
id: albumActions
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzIconButton { name: "play-fill"; btnSize: 48; iconSize: 22; activeBackground: true; active: true; onClicked: QbzPlayer.playAlbum(root.header.id || "") }
QbzIconButton { name: "shuffle"; btnSize: 48; iconSize: 20; active: QbzPlayer.npShuffle; onClicked: QbzPlayer.playAlbumShuffled(root.header.id || "") }
QbzIconButton { name: "list-start"; btnSize: 48; iconSize: 20; onClicked: QbzPlayer.enqueueAlbum(root.header.id || "", "next") }
QbzIconButton { name: "list-plus"; btnSize: 48; iconSize: 20; onClicked: QbzPlayer.enqueueAlbum(root.header.id || "", "later") }
QbzIconButton { name: "list-end"; btnSize: 48; iconSize: 20; onClicked: QbzPlayer.enqueueAlbum(root.header.id || "", "queue") }
}
QualityBadgeFull {
readonly property string rawTier: root.header.qualityTier || ""
visible: tier !== "" || detail !== ""
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
tier: rawTier === "max" ? "hires" : rawTier === "lossy" ? "mp3" : rawTier
detail: root.header.qualityDetail || ""
showIcon: true
scaleFactor: 1.15
}
}
}
@@ -109,7 +118,12 @@ Rectangle {
width: list.width - 32; height: 64; radius: theme.radiusSm
readonly property bool current: QbzPlayer.npTrackId === (modelData.id || "")
readonly property bool navFocused: root.contentFocus && QbzKioskNav.index === index
color: navFocused ? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18) : current ? theme.surfaceElevated : "transparent"
// Zebra striping (owner feedback 2026-09-07): odd rows carry a faint
// fill so a long tracklist reads as banded rows. Current/nav/focus
// states win over the stripe.
color: navFocused ? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
: current ? theme.surfaceElevated
: (index % 2 === 1 ? theme.surfaceHover : "transparent")
border.width: navFocused ? 2 : 0; border.color: theme.accent
Text { id: number; x: 8; width: 34; height: parent.height; text: modelData.number || (index + 1); color: theme.textMuted; font.pixelSize: 14; verticalAlignment: Text.AlignVCenter; horizontalAlignment: Text.AlignRight }
Text { id: duration; anchors.right: parent.right; anchors.rightMargin: 12; height: parent.height; text: modelData.duration || ""; color: theme.textMuted; font.pixelSize: 13; verticalAlignment: Text.AlignVCenter }
@@ -27,7 +27,10 @@ Item {
property var view: null
// The native reader knows no favorites (LocalLibraryView.qml:341-344):
// under "Favorites only" the host loads the legacy document instead.
readonly property bool nativeActive: QbzLocal.localAlbumsNativeActive
&& !(root.view && root.view.favoriteOnly)
readonly property var nativeModel: QbzLocalAlbums
readonly property int albumTotal: root.nativeActive
? (QbzLocal.localAlbumsNativeTotal || 0)
@@ -49,7 +52,8 @@ Item {
function resetNativeQuery() {
if (grid.columns <= 0)
return
QbzLocal.albumsNativeReset("", "artist-asc", "off", "{}", grid.columns)
QbzLocal.albumsNativeReset("", "artist-asc", "off",
root.view ? root.view.filterJson : "{}", grid.columns)
}
Component.onCompleted: {
queryCoalescer.restart()
@@ -77,6 +81,12 @@ Item {
// descriptor exactly as it does on the desktop tab.
function onLocalAlbumModeChanged() { queryCoalescer.restart() }
}
// The funnel IS part of the descriptor: a chip toggled in the host's
// sheet re-issues the native query.
Connections {
target: root.view
function onFilterJsonChanged() { queryCoalescer.restart() }
}
// The model only asks for a page when this signal has a receiver
// (LocalAlbumsModel::requestPage bails on `!isSignalConnected`), so the
+108 -109
View File
@@ -50,6 +50,24 @@ Item {
readonly property real rowH: 72
readonly property real headerH: 26
// Grid sizing for the round-card rail (owner feedback 2026-09-07:
// homologate the single-column rail to Library > Artists' round-card grid).
// Mirrors KioskLibrary's feedGrid roundCells math: ~5 columns at 800px,
// ~9 at 1280px.
readonly property real gridPad: root.view ? root.view.pad : 16
readonly property real gridGap: 14
readonly property real gridMinCell: 132
readonly property real gridLabelH: 46
readonly property real gridAvailRow: Math.max(0, width - 2 * gridPad + gridGap)
readonly property int gridColumns: width > 0
? Math.max(2, Math.floor(gridAvailRow / (gridMinCell + gridGap)))
: 0
readonly property real gridRightPad: Math.max(0, gridPad - gridGap)
readonly property real gridAvail: Math.max(0, width - gridPad - gridRightPad)
readonly property real gridCellW: gridColumns > 0 ? Math.max(1, Math.floor(gridAvail / gridColumns)) : 1
readonly property real gridCardW: Math.max(0, gridCellW - gridGap)
readonly property real gridCellH: Math.max(1, gridCardW + gridLabelH + gridGap)
// ---------------------------------------------------------------------
// Readers
// ---------------------------------------------------------------------
@@ -94,8 +112,14 @@ Item {
// ---------------------------------------------------------------------
// Native queries
// ---------------------------------------------------------------------
// `artists_native_reset` REFUSES a non-default sort or funnel, so the
// kiosk — which exposes neither control — passes the neutral descriptor.
// `artists_native_reset` REFUSES a non-default sort or funnel: a filtered
// descriptor makes Rust drop the native reader (`deactivate_for_legacy`)
// WITHOUT publishing the legacy document, which would leave this rail
// empty. So the rail keeps the neutral descriptor and lists every artist;
// the host's funnel (KioskLocalLibrary.qml) applies to the LEGACY reader
// (rail and drill-down) and to the Albums, Genres and Tracks tabs. The
// native drill-down (`artistsNativeSelect`) carries no funnel either —
// a Rust seam this kiosk-only round does not add.
Timer {
id: railQuery
interval: 0
@@ -107,9 +131,19 @@ Item {
interval: 0
repeat: false
onTriggered: {
if (!root.nativeActive || !root.drilled || albumGrid.columns <= 0)
if (root.selectedArtist === "")
return
QbzLocal.artistsNativeSelect(root.selectedArtist, albumGrid.columns)
// NO `nativeActive` gate and no geometry gate. The native flag
// can be false at the instant this fires (a fresh mount issues
// the rail reset in the same tick; a restored drill-down mounts
// before activation) and the query was silently dropped — the
// "0 albums" drill-down of 2026-09-07. Rust refuses the call
// itself when the native reader is not in play (`select_artist`
// checks `requested()` and the album mode), so an unconditional
// call costs nothing there, and the legacy drill-down keeps
// reading `legacyArtistAlbums` regardless. Rust also clamps
// columns to >= 1; `onColumnsChanged` re-issues once settled.
QbzLocal.artistsNativeSelect(root.selectedArtist, Math.max(1, albumGrid.columns))
}
}
Component.onCompleted: {
@@ -118,11 +152,25 @@ Item {
detailQuery.restart()
root.publishNav()
}
onSelectedArtistChanged: {
if (root.drilled)
detailQuery.restart()
root.publishNav()
Qt.callLater(root.reportSoon)
// THE 2026-09-07 "0 albums" DEFECT. This used to be a root
// `onSelectedArtistChanged` handler guarded by `if (root.drilled)`. Inside
// that handler the DERIVED readonly `drilled` is still the previous value
// (the alias has changed, its dependents have not been re-evaluated yet —
// verified with a bare `qml` scene: handler sees drilled=false while the
// alias already reads "A-ha"), so the detail query was never issued and
// every kiosk drill-down showed "0 albums" with nothing in the log. The
// desktop tab reacts through a Connections on the HOST's signal
// (views/local/LocalArtistsTab.qml:89), where every derived value is
// fresh; this is that pattern, and the guards below read the SOURCE
// rather than a derived property.
Connections {
target: root.view
function onSelectedArtistChanged() {
if (root.view.selectedArtist !== "")
detailQuery.restart()
root.publishNav()
Qt.callLater(root.reportSoon)
}
}
Connections {
target: albumGrid
@@ -141,7 +189,7 @@ Item {
if (root.drilled)
root.view.publishNav(Math.max(1, albumGrid.columns), root.artistAlbumTotal)
else
root.view.publishNav(1, root.entryCount)
root.view.publishNav(Math.max(1, root.gridColumns), root.entryCount)
}
onEntryCountChanged: root.publishNav()
onArtistAlbumTotalChanged: root.publishNav()
@@ -206,7 +254,7 @@ Item {
function onIndexChanged() {
if (root.itemFocused)
Qt.callLater(function () {
rail.positionViewAtIndex(root.focusedItem, ListView.Contain)
rail.positionViewAtIndex(root.focusedItem, GridView.Contain)
})
}
function onActivateSeqChanged() {
@@ -234,14 +282,14 @@ Item {
root.view.releaseWindow("artists")
return
}
var first = rail.indexAt(4, rail.contentY + 1)
var last = rail.indexAt(4, rail.contentY + Math.max(1, rail.height) - 1)
if (first < 0)
first = Math.max(0, Math.floor((rail.contentY - rail.originY) / root.rowH))
if (last < 0)
last = Math.min(root.entryCount - 1, first + Math.ceil(rail.height / root.rowH))
first = Math.max(0, first - 1)
last = Math.min(root.entryCount - 1, last + 1)
// Grid window: one row of overscan on each side, in ENTRY units
// (each cell is one entry — bands included).
var cols = Math.max(1, root.gridColumns)
var ch = Math.max(1, root.gridCellH)
var firstRow = Math.max(0, Math.floor((rail.contentY - rail.originY) / ch) - 1)
var lastRow = Math.floor((rail.contentY - rail.originY + Math.max(1, rail.height)) / ch) + 1
var first = Math.max(0, firstRow * cols)
var last = Math.min(root.entryCount - 1, (lastRow + 1) * cols - 1)
var resident = []
for (var i = first; i <= last; i++) {
@@ -274,16 +322,20 @@ Item {
// =====================================================================
// The artist list
// =====================================================================
ListView {
GridView {
id: rail
anchors.fill: parent
visible: !root.drilled && !QbzLocal.localArtistsLoading
&& root.nativeError === ""
&& root.artistTotal > 0
clip: true
topMargin: 8
bottomMargin: 8
cacheBuffer: Math.min(root.rowH, Math.max(0, rail.height / 2))
leftMargin: root.gridPad
rightMargin: root.gridRightPad
topMargin: root.gridPad
bottomMargin: root.gridPad
cellWidth: root.gridCellW
cellHeight: root.gridCellH
cacheBuffer: Math.max(0, Math.ceil(root.gridCellH))
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
model: rail.visible ? (root.nativeActive ? root.nativeModel : root.legacyEntries) : []
@@ -298,6 +350,9 @@ Item {
root.view.releaseWindow("artists")
}
// Uniform cell. Bands (t:0) become a centered letter tile — Library >
// Artists has no bands, but keeping them as inline letter tiles marks
// the A-Z groups without breaking the grid's uniform geometry.
delegate: Item {
id: entrySlot
required property var modelData
@@ -310,107 +365,51 @@ Item {
readonly property var item: entrySlot.modelData
? entrySlot.modelData.item : null
width: rail.width
height: entrySlot.band ? root.headerH : root.rowH
width: root.gridCellW
height: root.gridCellH
// A-Z band.
// A-Z band letter, centered in the cell's art box.
Text {
anchors.fill: parent
anchors.leftMargin: root.view ? root.view.pad : 16
width: root.gridCardW
height: root.gridCardW
x: (root.gridCellW - width) / 2
visible: entrySlot.band
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: entrySlot.band ? entrySlot.modelData.label : ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.pixelSize: 34
font.weight: theme.weightBold
}
// Page not resident: a static tile, no avatar request.
// Page not resident: a static circular tile, no avatar request.
Rectangle {
anchors.fill: parent
anchors.margins: 6
width: root.gridCardW
height: root.gridCardW
x: (root.gridCellW - width) / 2
visible: entrySlot.entryLoading
radius: theme.radiusSm
radius: width / 2
color: theme.surfaceElevated
opacity: 0.55
}
// The artist row. 72px, the whole row is the tap target.
Rectangle {
id: artistRow
anchors.fill: parent
anchors.leftMargin: root.view ? root.view.pad : 16
anchors.rightMargin: root.view ? root.view.pad : 16
// The artist — a round card (title + album count), the whole tile
// is the tap target; tapping drills into that artist's albums.
KioskCard {
visible: !entrySlot.band && !entrySlot.entryLoading
radius: theme.radiusSm
color: root.itemFocused && root.focusedItem === entrySlot.index
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
: "transparent"
border.width: root.itemFocused && root.focusedItem === entrySlot.index ? 2 : 0
border.color: theme.accent
Row {
anchors.left: parent.left
anchors.leftMargin: 8
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 14
Rectangle {
id: avatar
width: 52
height: 52
radius: 26
color: theme.surfaceElevated
clip: true
KioskArtwork {
anchors.fill: parent
radius: 26
fit: "crop"
source: entrySlot.item
? (entrySlot.item.artPath
|| (root.view ? root.view.artPathOf(entrySlot.item.artKey) : ""))
: ""
}
}
Column {
width: parent.width - 52 - 14
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: entrySlot.item ? (entrySlot.item.name || "") : ""
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
width: parent.width
text: entrySlot.item
? ((entrySlot.item.albumCount || 0) + " "
+ root.t("albums"))
: ""
color: theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
MouseArea {
anchors.fill: parent
onClicked: {
if (entrySlot.item)
root.select(entrySlot.item.name)
}
}
round: true
artSize: root.gridCardW
navFocused: root.itemFocused && root.focusedItem === entrySlot.index
album: ({
"id": entrySlot.item ? (entrySlot.item.name || "") : "",
"title": entrySlot.item ? (entrySlot.item.name || "") : "",
"artist": entrySlot.item
? ((entrySlot.item.albumCount || 0) + " " + root.t("albums")) : "",
"artwork": entrySlot.item
? (entrySlot.item.artPath
|| (root.view ? root.view.artPathOf(entrySlot.item.artKey) : "")) : ""
})
onClicked: function (name) { if (name !== "") root.select(name) }
}
}
}
+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
// =====================================================================
@@ -4,14 +4,12 @@
// `QbzLocalTracks` surface is active (the production default since the native
// model shipped).
//
// It issues NO query of its own. `QbzLocal.loadTab("tracks")` — which the host
// already calls on mount and on every tab switch — is the whole activation,
// and it reuses the user's PERSISTED funnel, sort and grouping.
//
// That is deliberate and load-bearing: `tracksSetFilterJson` is the desktop
// tab's entry point, and it WRITES the funnel through to `ui_prefs`. A kiosk
// that called it with a neutral descriptor would silently erase the Full UI's
// saved Tracks filter — a Full UI regression caused from a kiosk-only file.
// It issues NO query of its own. The host's `loadActiveTab` — called on mount
// and on every tab switch — installs the SHARED funnel through
// `tracksSetFilterJson`, exactly as the desktop view does
// (LocalLibraryView.qml:700-707), and that setter is the load. The kiosk never
// hands it a neutral descriptor: the value is the same persisted funnel the
// Full UI reads, so nothing saved from the desktop is erased here.
//
// Everything else is KioskLocalTrackList.
+25 -12
View File
@@ -221,6 +221,8 @@ Rectangle {
LyricsLinesView {
lines: root.lyricsLines
synced: root.lyricsSynced
// Owner feedback 2026-09-07: the kiosk Now Playing lyrics are centered.
centered: true
// Slint's row reads LyricsState.show-translation itself
// (LyricsLinesView.slint:271); the Qt view takes it from the host.
showTranslation: QbzLyrics.showTranslation
@@ -807,23 +809,34 @@ Rectangle {
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.08)
: "transparent"
Flow {
// ONE row, the desktop New bar's arrangement (shell/
// TransportControls.qml): the three transport buttons in the
// middle, the modifiers flanking them — info and shuffle to
// the left, repeat, add and favorite to the right. Every
// button is CENTRED on the row's axis: the previous Flow
// top-aligned a mix of 44px and 64px buttons, which is the
// misalignment reported on 2026-09-07. The cluster sits on
// the panel's centre and gives way to the quality stamp only
// when the panel is too narrow for both.
Row {
id: transport
x: 4; y: 4
width: Math.max(0, transportRow.width - 8)
height: childrenRect.height
x: Math.max(4, Math.min((transportRow.width - width) / 2,
transportRow.width - width - qualityStamp.width - 12))
y: 4
height: 64
spacing: 2
QbzIconButton { name: "shuffle"; btnSize: 44; iconSize: 20; active: QbzPlayer.npShuffle; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.toggleShuffle() }
QbzIconButton { name: "skip-back"; btnSize: 64; iconSize: 24; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.previous() }
QbzIconButton { name: QbzPlayer.npPlaying ? "pause" : "play-fill"; btnSize: 64; iconSize: 28; activeBackground: true; active: true; btnEnabled: QbzPlayer.npHasTrack || QbzQueue.hasPlayTarget; onClicked: QbzPlayer.togglePlay() }
QbzIconButton { name: "skip-forward"; btnSize: 64; iconSize: 24; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.next() }
QbzIconButton { name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"; btnSize: 44; iconSize: 20; active: QbzPlayer.npRepeatMode > 0; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.cycleRepeat() }
QbzIconButton { id: addButton; name: "plus"; btnSize: 44; iconSize: 20; btnEnabled: QbzPlayer.npHasTrack; onClicked: addMenu.openBelowRight(addButton) }
QbzIconButton { name: root.npFavorite ? "heart-filled" : "heart"; btnSize: 44; iconSize: 20; active: root.npFavorite; btnEnabled: QbzPlayer.npHasTrack && root.npSource === "qobuz"; onClicked: QbzQueue.queueToggleFavorite("track", QbzPlayer.npTrackId) }
QbzIconButton { name: "info"; btnSize: 44; iconSize: 20; btnEnabled: QbzPlayer.npHasTrack; onClicked: root.openTrackInfo() }
QbzIconButton { name: "info"; btnSize: 44; iconSize: 20; anchors.verticalCenter: parent.verticalCenter; btnEnabled: QbzPlayer.npHasTrack; onClicked: root.openTrackInfo() }
QbzIconButton { name: "shuffle"; btnSize: 44; iconSize: 20; anchors.verticalCenter: parent.verticalCenter; active: QbzPlayer.npShuffle; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.toggleShuffle() }
QbzIconButton { name: "skip-back"; btnSize: 64; iconSize: 24; anchors.verticalCenter: parent.verticalCenter; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.previous() }
QbzIconButton { name: QbzPlayer.npPlaying ? "pause" : "play-fill"; btnSize: 64; iconSize: 28; anchors.verticalCenter: parent.verticalCenter; activeBackground: true; active: true; btnEnabled: QbzPlayer.npHasTrack || QbzQueue.hasPlayTarget; onClicked: QbzPlayer.togglePlay() }
QbzIconButton { name: "skip-forward"; btnSize: 64; iconSize: 24; anchors.verticalCenter: parent.verticalCenter; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.next() }
QbzIconButton { name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"; btnSize: 44; iconSize: 20; anchors.verticalCenter: parent.verticalCenter; active: QbzPlayer.npRepeatMode > 0; btnEnabled: QbzPlayer.npHasTrack; onClicked: QbzPlayer.cycleRepeat() }
QbzIconButton { id: addButton; name: "plus"; btnSize: 44; iconSize: 20; anchors.verticalCenter: parent.verticalCenter; btnEnabled: QbzPlayer.npHasTrack; onClicked: addMenu.openBelowRight(addButton) }
QbzIconButton { name: root.npFavorite ? "heart-filled" : "heart"; btnSize: 44; iconSize: 20; anchors.verticalCenter: parent.verticalCenter; active: root.npFavorite; btnEnabled: QbzPlayer.npHasTrack && root.npSource === "qobuz"; onClicked: QbzQueue.queueToggleFavorite("track", QbzPlayer.npTrackId) }
}
AudioStamp {
id: qualityStamp
anchors.right: transportRow.right
anchors.rightMargin: 2
anchors.bottom: transportRow.bottom
+79 -65
View File
@@ -1,11 +1,11 @@
// Touch transport: shared bridge state/actions, a single 64px footprint.
// Detailed queue, seek and secondary actions live in the Now Playing route.
// Detailed queue, seek and secondary actions live in the Now Playing route;
// Qobuz Connect and Cast live in the back bar (KioskShell.qml).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
import "../shell"
Rectangle {
id: root
@@ -97,6 +97,14 @@ Rectangle {
}
}
}
// The transport cluster. Owner feedback 2026-09-07: the hidden "more"
// menu is gone — Connect and Cast moved to the back bar (KioskShell),
// Settings has its NavRail tile, Immersive has the Visualizer tile — so
// every remaining control is a permanent 64px button: shuffle/repeat
// (wide panels only; Now Playing carries them everywhere), the three
// transport buttons, an always-visible Volume button whose slider opens
// VERTICALLY above the bar, the Now Playing shortcut and the way back
// to Desktop mode.
Row {
id: transport
anchors.right: parent.right
@@ -131,88 +139,94 @@ Rectangle {
available: QbzPlayer.npHasTrack; active: QbzPlayer.npRepeatMode > 0
onClicked: QbzPlayer.cycleRepeat()
}
TouchButton {
id: volumeButton
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
label: QbzSession.tr("Volume", QbzSession.trRev)
active: volumePopup.opened
onClicked: root.openVolume()
}
TouchButton {
name: "list-music"; label: QbzSession.tr("Now Playing", QbzSession.trRev)
onClicked: QbzShell.navigateTo("nowplaying")
}
TouchButton {
name: "maximize-2"; label: QbzSession.tr("Immersive", QbzSession.trRev)
onClicked: QbzImmersive.open = true
}
TouchButton {
id: moreButton
name: "ellipsis"; label: QbzSession.tr("More options", QbzSession.trRev)
onClicked: moreMenu.open()
name: "monitor"; label: QbzSession.tr("Desktop mode", QbzSession.trRev)
onClicked: QbzSession.toggleProfile()
}
}
// Volume lock, both halves (PlayerBar.qml:86-96): the ALSA-Direct
// hardware derivation while local, and the peer's "no remote volume".
readonly property bool volumeLocked:
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote) || QbzPlayer.npRemoteVolumeLocked
/// Opens the vertical volume sheet ABOVE its button, horizontally centred
/// on it and clamped 8px inside the window on every side. The sheet's
/// length is derived from the window height, so it can never be taller
/// than the space above the bar — on a 480px panel the slider shortens
/// rather than the window eating it.
function openVolume() {
if (volumePopup.opened) {
volumePopup.close()
return
}
var win = volumePopup.parent
var g = volumeButton.mapToItem(null, volumeButton.width / 2, 0)
volumePopup.x = Math.max(8, Math.min(g.x - volumePopup.width / 2,
win.width - volumePopup.width - 8))
volumePopup.y = Math.max(8, g.y - volumePopup.height - 8)
volumePopup.open()
}
Popup {
id: moreMenu
id: volumePopup
parent: Overlay.overlay
width: Math.min(380, parent ? parent.width - 24 : 380)
height: menuColumn.height + 24
x: parent ? parent.width - width - 12 : 0
y: parent ? Math.max(12, parent.height - root.barHeight - height - 8) : 0
// Slider travel: whatever fits between the bar and the top of the
// window, between 120 and 260px.
readonly property real sliderLength: Math.max(120, Math.min(260,
(parent ? parent.height : 480) - root.barHeight - 64 - 24 - 32))
width: 64 + 24
height: sliderLength + 8 + 64 + 24
padding: 12
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
background: Rectangle { color: theme.surfaceCard; radius: 8; border.color: theme.borderSubtle; border.width: 1 }
onOpened: volumeControl.forceActiveFocus()
Column {
id: menuColumn
width: parent.width
width: 64
spacing: 8
Row {
spacing: 4
TouchButton {
name: "shuffle"; label: QbzSession.tr("Shuffle", QbzSession.trRev)
available: QbzPlayer.npHasTrack; active: QbzPlayer.npShuffle
onClicked: QbzPlayer.toggleShuffle()
}
TouchButton {
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
label: QbzSession.tr("Repeat", QbzSession.trRev)
available: QbzPlayer.npHasTrack; active: QbzPlayer.npRepeatMode > 0
onClicked: QbzPlayer.cycleRepeat()
}
TouchButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
label: QbzSession.tr("Mute", QbzSession.trRev)
available: volumeControl.enabled
active: QbzPlayer.npMuted
onClicked: QbzPlayer.toggleMute()
}
TouchButton {
name: "settings-2"; label: QbzSession.tr("Settings", QbzSession.trRev)
onClicked: { moreMenu.close(); QbzShell.navigateTo("settings") }
// The shared horizontal slider, turned on its side: a 64px-tall
// slider of `sliderLength` width rotated a quarter turn
// counter-clockwise occupies a 64 x sliderLength box centred on
// the same point, and its +x (louder) axis now points UP. Qt
// maps pointer events through the transform, so the drag math
// in QbzSlider is untouched.
Item {
width: 64
height: volumePopup.sliderLength
QbzSlider {
id: volumeControl
kioskHost: true
width: parent.height
height: 44
anchors.centerIn: parent
rotation: -90
minimum: 0; maximum: 1000
enabled: !root.volumeLocked
value: Math.round(QbzPlayer.npVolume * 1000)
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
onReleased: function (v) { QbzPlayer.persistVolume(v / 1000.0) }
}
}
QbzSlider {
id: volumeControl
kioskHost: true
width: parent.width
minimum: 0; maximum: 1000
enabled: !((QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote) || QbzPlayer.npRemoteVolumeLocked)
value: Math.round(QbzPlayer.npVolume * 1000)
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
onReleased: function (v) { QbzPlayer.persistVolume(v / 1000.0) }
}
SettingsButton {
width: parent.width; kioskHost: true
text: QbzSession.tr("Qobuz Connect", QbzSession.trRev)
onClicked: { moreMenu.close(); connectMenu.openAboveRight(moreButton) }
}
SettingsButton {
width: parent.width; kioskHost: true
text: QbzSession.tr("Cast", QbzSession.trRev)
onClicked: { moreMenu.close(); QbzCast.openPicker() }
}
SettingsButton {
width: parent.width; kioskHost: true
text: QbzSession.tr("Desktop mode", QbzSession.trRev)
onClicked: { moreMenu.close(); QbzSession.toggleProfile() }
TouchButton {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
label: QbzSession.tr("Mute", QbzSession.trRev)
available: !root.volumeLocked
active: QbzPlayer.npMuted
onClicked: QbzPlayer.toggleMute()
}
}
}
QconnectFlyout { id: connectMenu }
CastPicker { }
Rectangle {
width: parent.width; height: 2
color: theme.surfaceElevated
+42 -19
View File
@@ -178,41 +178,64 @@ Rectangle {
delegate: Column {
id: section
required property var modelData
// GUARDED, STABLE section data. The preview Components below are
// instantiated by a Loader, and at that instant the delegate's
// `modelData` is briefly undefined; a Component that read
// `section.modelData.rows` DIRECTLY threw "Cannot read property
// 'rows' of undefined", which killed the binding for good and
// left every dashboard shelf empty (the 2026-09-07 "search is
// broken" regression from the hardening's dashboard rewrite —
// reproduced in a bare `qml` scene; the guarded read below is
// what stops the throw, NOT disabling reuse, which still threw).
readonly property string sTitle: section.modelData && section.modelData.title ? section.modelData.title : ""
readonly property string sKind: section.modelData && section.modelData.kind ? section.modelData.kind : "album"
readonly property var sRows: section.modelData && section.modelData.rows ? section.modelData.rows : []
readonly property int sTab: section.modelData && section.modelData.tab !== undefined ? section.modelData.tab : -1
width: dashboardList.width - 32; spacing: 8
Item {
width: parent.width; height: 44
Text { anchors.left: parent.left; anchors.right: viewAll.left; height: parent.height; text: QbzSession.tr(section.modelData.title, QbzSession.trRev); color: theme.textPrimary; font.pixelSize: 18; font.weight: theme.weightSemibold; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight }
Text { anchors.left: parent.left; anchors.right: viewAll.left; height: parent.height; text: QbzSession.tr(section.sTitle, QbzSession.trRev); color: theme.textPrimary; font.pixelSize: 18; font.weight: theme.weightSemibold; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight }
Rectangle {
id: viewAll
anchors.right: parent.right; width: 100; height: 44
visible: section.modelData.tab >= 0; color: "transparent"
visible: section.sTab >= 0; color: "transparent"
Text { anchors.centerIn: parent; text: QbzSession.tr("View all", QbzSession.trRev); color: theme.textSecondary; font.pixelSize: 14 }
MouseArea { anchors.fill: parent; onClicked: root.selectTab(section.modelData.tab) }
MouseArea { anchors.fill: parent; onClicked: root.selectTab(section.sTab) }
}
}
Loader {
// The preview is a DIRECT child of the delegate, NOT a
// Component behind a Loader. The Loader form (a Component
// defined in the delegate, reading `section.modelData`/`section`)
// is what emptied every shelf on 2026-09-07: the Component's
// bindings to the delegate resolved against an undefined
// `section` at instantiation and never recovered. KioskDiscover
// mounts its shelf as a direct child for exactly this reason;
// this matches it. The two kinds are two direct children gated
// by `visible`; the track preview is a bounded Repeater (<=3),
// never a nested ListView.
KioskShelf {
visible: section.sKind !== "track"
width: parent.width
sourceComponent: section.modelData.kind === "track" ? previewTracks : previewShelf
kind: section.sKind
rows: section.sKind !== "track" ? section.sRows : []
restoreX: root.shelfPositions[(root.doc.query || "") + ":" + section.sTitle] || 0
onScrollSettled: function(x) { root.saveShelf((root.doc.query || "") + ":" + section.sTitle, x) }
onOpen: function(id) { root.open(section.sKind, id) }
}
Component {
id: previewShelf
KioskShelf { width: section.width; kind: section.modelData.kind; rows: section.modelData.rows; restoreX: root.shelfPositions[(root.doc.query || "") + ":" + section.modelData.title] || 0; onScrollSettled: function(x) { root.saveShelf((root.doc.query || "") + ":" + section.modelData.title, x) }; onOpen: function(id) { root.open(section.modelData.kind, id) } }
}
Component {
id: previewTracks
ListView {
id: previewList
width: section.width; height: Math.min(3, count) * 64
clip: true; cacheBuffer: 0; reuseItems: true
model: section.modelData.rows
boundsBehavior: Flickable.StopAtBounds
Column {
visible: section.sKind === "track"
width: parent.width
spacing: 0
Repeater {
model: section.sKind === "track" ? section.sRows.slice(0, 3) : []
delegate: KioskTrackRow {
id: previewRow
required property var modelData
width: previewList.width; height: 64
required property int index
width: parent.width; height: 64
KioskCoverSource { id: cover; remote: previewRow.modelData.artUrl || ""; local: previewRow.modelData.artPath || ""; edge: 46 }
track: ({ id: modelData.id || "", title: modelData.title || "", artist: modelData.artist || "", duration: modelData.duration || "", artwork: cover.source })
onClicked: root.open("track", modelData.id || "")
onClicked: root.open("track", previewRow.modelData.id || "")
}
}
}
+6 -14
View File
@@ -122,11 +122,15 @@ Item {
font.pixelSize: root.kioskHost ? (theme.fontTitle) * 1.2 : (theme.fontTitle)
font.weight: theme.weightBold
}
// The kiosk section selector. The "Share logs" icon button that
// used to sit to its right is gone: it called QbzShell.logOpen(),
// whose viewer modal is mounted by the desktop AppShell only, so
// on the kiosk it was a dead control (owner report 2026-09-07).
QbzSelect {
visible: root.kioskHost
kioskHost: true
anchors.right: logsButton.left
anchors.rightMargin: 8
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
menuWidth: Math.max(180, Math.min(300, parent.width - 270))
searchable: options.length > 8
@@ -137,18 +141,6 @@ Item {
flick.contentY = 0
}
}
SettingsButton {
id: logsButton
visible: root.kioskHost
kioskHost: true
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
minWidth: 44
iconName: "cloud-upload"
onClicked: QbzShell.logOpen()
Accessible.name: QbzSession.tr("Share logs", QbzSession.trRev)
}
}
// --- Sub-nav + active panel ---------------------------------------
+162 -61
View File
@@ -52,6 +52,42 @@ Rectangle {
QbzTheme { id: theme }
/// The back bar's one button form: 44x36, radius-sm hover fill, a 20px
/// glyph. `available` dims the glyph and disarms the area (Back/Forward
/// with no history); `active` lights the glyph accent (Cast connected).
/// Connect keeps its own golden arm below because its active state is a
/// gold tint + border the shared form cannot express.
component ChromeButton: Rectangle {
id: cb
property string name: ""
property bool available: true
property bool active: false
property string label: ""
signal clicked()
width: 44
height: 36
radius: theme.radiusSm
color: (cbArea.containsMouse && cb.available) ? root.chromeHoverBg : "transparent"
Accessible.role: Accessible.Button
Accessible.name: cb.label
QbzIcon {
opacity: cb.available ? 1.0 : 0.32
name: cb.name
width: 20
height: 20
anchors.centerIn: cb
tintName: cb.active ? "accent" : "secondary"
}
MouseArea {
id: cbArea
anchors.fill: cb
enabled: cb.available
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: cb.clicked()
}
}
// KioskShell.slint:204 — the NavRail is a fat touch target.
readonly property int navRailHeight: 80
readonly property int transportHeight: 64
@@ -133,9 +169,40 @@ Rectangle {
Rectangle {
id: backBar
width: shellColumn.width
height: 72
// 44px — owner feedback 2026-09-07 asked for ~60% of the previous
// 72px bar. The four chrome buttons (Back, Forward, Connect,
// Cast) share ONE 44x36 / 20px-glyph form (ChromeButton below).
height: 44
color: theme.surfaceCard
// Custom-chrome drag surface, the HeaderBar.qml:132-152 pattern
// verbatim: declared FIRST so every control above wins
// hit-testing; the system move starts only after a real
// movement so taps still land; double-click toggles maximize.
// Inert under the system title bar (native chrome owns it). A
// kiosk on a NUC with a desktop monitor is a windowed app like
// any other, and a bar that could not be dragged was reported
// as a defect on 2026-09-07.
MouseArea {
anchors.fill: parent
enabled: !QbzShell.systemTitleBar
property bool dragStarted: false
onPressed: dragStarted = false
onPositionChanged: {
if (pressed && !dragStarted && root.hostWindow) {
dragStarted = true
root.hostWindow.startSystemMove()
}
}
onDoubleClicked: {
if (root.hostWindow) {
root.hostWindow.visibility =
root.hostWindow.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
}
// The kiosk carries no HeaderBar, so it owns the window chrome
// too (KioskShell.slint:240-245): macOS overlay leaves the native
// traffic-light inset, Linux frameless draws the controls.
@@ -153,7 +220,7 @@ Rectangle {
Qt.platform.os !== "osx" && !QbzShell.systemTitleBar
// padding-left 6 + macInset, padding-right 118 (controls) or 8,
// padding-top/bottom 4, spacing 6 -> a 34px content row.
// padding-top/bottom 4, spacing 6 -> a 36px content row.
// The 118 is not a round number: WindowControls is 34*3 + 2*2 =
// 106 wide, pinned 8px from the right edge, leaving 4px of
// clearance to the search field.
@@ -163,71 +230,28 @@ Rectangle {
y: 4
width: backBar.width - (6 + backBar.macInset)
- (backBar.linChrome ? 118 : 8)
- sessionCluster.width - 6
height: backBar.height - 8
spacing: 6
// KioskShell.slint:252-269. The disabled state is expressed
// THREE times — dimmed, un-hoverable and un-clickable; an
// opacity-only port leaves a clickable ghost.
Rectangle {
// KioskShell.slint:252-269 / 270-287. Disabled = dimmed,
// un-hoverable and un-clickable, all three (an opacity-only
// port leaves a clickable ghost).
ChromeButton {
id: backBtn
width: 64
height: backBarRow.height
radius: theme.radiusSm
color: (backArea.containsMouse && QbzShell.canBack)
? root.chromeHoverBg : "transparent"
QbzIcon {
// Opacity on the LEAF, not on the button: the
// Rectangle resolves to "transparent" in every
// disabled frame (hover requires canBack), so the
// group node only ever dimmed this glyph — and an
// always-on opacity group is its own batch.
opacity: QbzShell.canBack ? 1.0 : 0.32
name: "chevron-left"
width: 22
height: 22
anchors.centerIn: backBtn
tintName: "secondary"
}
MouseArea {
id: backArea
anchors.fill: backBtn
enabled: QbzShell.canBack
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateBack()
}
name: "chevron-left"
label: QbzSession.tr("Back", QbzSession.trRev)
available: QbzShell.canBack
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.navigateBack()
}
// KioskShell.slint:270-287.
Rectangle {
ChromeButton {
id: fwdBtn
width: 64
height: backBarRow.height
radius: theme.radiusSm
color: (fwdArea.containsMouse && QbzShell.canForward)
? root.chromeHoverBg : "transparent"
QbzIcon {
// Opacity on the LEAF, not on the button: the
// Rectangle resolves to "transparent" in every
// disabled frame (hover requires canForward), so the
// group node only ever dimmed this glyph — and an
// always-on opacity group is its own batch.
opacity: QbzShell.canForward ? 1.0 : 0.32
name: "chevron-right"
width: 22
height: 22
anchors.centerIn: fwdBtn
tintName: "secondary"
}
MouseArea {
id: fwdArea
anchors.fill: fwdBtn
enabled: QbzShell.canForward
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateForward()
}
name: "chevron-right"
label: QbzSession.tr("Forward", QbzSession.trRev)
available: QbzShell.canForward
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.navigateForward()
}
// KioskShell.slint:288-312. Submit-only: there is NO live /
@@ -256,6 +280,77 @@ Rectangle {
}
}
// Session cluster — Qobuz Connect + Cast, always visible, right
// of the search field and before the window controls. Owner
// feedback 2026-09-07: both are fundamental on a kiosk (Connect
// especially — the panel is a renderer other devices drive), so
// they left the hidden "more" menu of the transport bar for a
// permanent home in the chrome. A 1px pipe separates the
// cluster from its neighbours on either side. Connect is the
// PlayerBar.qml:537-575 golden button at touch size; Cast is the
// shared icon button lit while a renderer is connected.
Row {
id: sessionCluster
x: backBar.width - (backBar.linChrome ? 118 : 8) - width
y: 4
height: backBar.height - 8
spacing: 6
Rectangle {
width: 1
height: 20
anchors.verticalCenter: parent.verticalCenter
color: theme.borderSubtle
}
Rectangle {
id: kioskQconnectBtn
readonly property bool qcActive: QbzQConnect.qconnectConnected
readonly property color gold: "#e0b341"
width: 44
height: 36
anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
: (kioskQcArea.containsMouse ? root.chromeHoverBg : "transparent")
border.width: qcActive ? 1 : 0
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Qobuz Connect", QbzSession.trRev)
QbzIcon {
name: "monitor-speaker"
width: 20
height: 20
anchors.centerIn: parent
tintName: kioskQconnectBtn.qcActive ? "amber"
: kioskQcArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: kioskQcArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: connectFlyout.openBelowRight(kioskQconnectBtn)
}
}
ChromeButton {
name: "cast"
label: QbzSession.tr("Cast", QbzSession.trRev)
active: QbzPlayer.npCastActive
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzCast.openPicker()
}
Rectangle {
visible: backBar.linChrome
width: 1
height: 20
anchors.verticalCenter: parent.verticalCenter
color: theme.borderSubtle
}
}
// Linux custom window controls at the right edge
// (KioskShell.slint:315-318). Inlined rather than extracted from
// HeaderBar.qml:923-999: a shared component would need a new file
@@ -564,6 +659,12 @@ Rectangle {
// compact player bar used by kiosk mode.
QconnectPlaybackConflictModal { }
// The Connect flyout and the Cast picker are shell-level: their triggers
// live in the back bar above, which is mounted on every route (the
// transport bar that used to host them is unmounted on Now Playing).
QconnectFlyout { id: connectFlyout }
CastPicker { }
// =====================================================================
// 4. The kiosk's OWN Immersive mount (KioskShell.slint:737-747)
// =====================================================================
@@ -48,6 +48,10 @@ Item {
property int dimmingMode: 2
property bool autoFollow: true
property bool uppercase: false
/// Opt-in centered lines (kiosk Now Playing). Desktop leaves it false, so
/// its left-aligned lyrics are unchanged. LyricsLineRow already implements
/// the centered layout AND the centered word-sync highlight offset.
property bool centered: false
// 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3
property int fontIndex: 0
property color activeColor: theme.accent
@@ -199,6 +203,7 @@ Item {
inactiveColor: view.inactiveColor
liteFill: view.liteFill
showTranslation: view.showTranslation
centered: view.centered
}
// Click-to-seek on stamped lines. NOT in the Slint
@@ -94,6 +94,13 @@ Popup {
_place(g.x, g.y, sourceItem.Window.window)
}
// Top-bar placement (the kiosk back bar): below the trigger, right edges
// aligned, then the same 8px window clamp.
function openBelowRight(sourceItem) {
var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4)
_place(g.x, g.y, sourceItem.Window.window)
}
// The Slint chrome: surface-main, Radius.sm, 1px border-muted.
background: Rectangle {
color: theme.surfaceMain
@@ -251,6 +251,25 @@ Row {
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
// Kiosk-only album quality badge (owner feedback 2026-09-07: on the
// panel the quality was unknown until a track played). The album row
// carries the same tier/detail the cards badge; Rust's "max"/"lossy"
// spellings fold into the four names the badge draws, as
// controls/QualityBadge.qml does. Desktop keeps its layout untouched.
Item {
visible: root.kioskHost && kioskQuality.tier !== ""
width: parent.width
height: visible ? kioskQuality.implicitHeight + 8 : 0
QualityBadgeFull {
id: kioskQuality
readonly property string rawTier: root.album ? (root.album.qualityTier || "") : ""
tier: rawTier === "max" ? "hires" : rawTier === "lossy" ? "mp3" : rawTier
detail: root.album ? (root.album.qualityDetail || "") : ""
showIcon: true
scaleFactor: 1.15
anchors.verticalCenter: parent.verticalCenter
}
}
Item { width: 1; height: root.compact ? 10 : 20 }
// ---- Local action row ----
+23 -2
View File
@@ -394,7 +394,17 @@ pub(crate) fn request_page(page: i32, generation: i32) {
}
pub(crate) fn select_artist(name: String, columns: i32) {
// Every exit of the detail query used to be silent except success and
// fallback; a kiosk drill-down that showed "0 albums" for an artist the
// desktop lists left nothing in the log to reason from (2026-09-07). One
// line per tap, and one per silent exit.
log::info!(
"[local-catalog] phase=artist-select name={name:?} columns={columns} requested={} album_mode={}",
requested(),
crate::local_library_qt::album_mode()
);
if !requested() || crate::local_library_qt::album_mode() != "folder" {
log::info!("[local-catalog] phase=artist-select-skip reason=not-native-or-metadata-mode");
return;
}
let generation = next_generation(&DETAIL_GENERATION);
@@ -411,6 +421,7 @@ pub(crate) fn select_artist(name: String, columns: i32) {
bridge.as_mut().set_local_artist_albums_loading(true);
});
if artist_key.is_empty() {
log::info!("[local-catalog] phase=artist-select-skip generation={generation} reason=empty-artist-key");
ui(move |mut bridge| {
cpp_detail_reset(generation, 0, 0);
bridge.as_mut().set_local_artist_albums_native_total(0);
@@ -423,8 +434,13 @@ pub(crate) fn select_artist(name: String, columns: i32) {
tokio::task::spawn_blocking(move || open_detail(generation, artist_key, columns)).await;
match result {
Ok(Ok(opened)) => activate_detail(opened),
Ok(Err("superseded")) => {}
Ok(Err(reason)) => fallback_detail(generation, reason),
Ok(Err("superseded")) => {
log::info!("[local-catalog] phase=artist-select-skip generation={generation} reason=superseded");
}
Ok(Err(reason)) => {
log::info!("[local-catalog] phase=artist-select-fallback generation={generation} reason={reason}");
fallback_detail(generation, reason)
}
Err(_) => fallback_detail(generation, "detail-worker-join"),
}
});
@@ -909,6 +925,7 @@ fn open_detail(
let (catalog, catalog_generation) = open_active()?;
let (sources, none_enabled) = enabled_sources(&catalog)?;
if none_enabled {
log::info!("[local-catalog] phase=artist-select-skip generation={generation} reason=no-enabled-sources");
return Ok(OpenedDetail {
generation,
catalog_generation,
@@ -958,6 +975,10 @@ fn open_detail(
fn activate_detail(opened: OpenedDetail) {
if DETAIL_GENERATION.load(Ordering::Acquire) != opened.generation {
log::info!(
"[local-catalog] phase=artist-select-skip generation={} reason=stale-before-activate",
opened.generation
);
return;
}
let wire = Arc::new(opened.loaded.wire.clone());
+1
View File
@@ -6,6 +6,7 @@ export QBZ_PREBUILT_SHADERS=1
node scripts/test_qt_local_views.mjs
node scripts/test_qt_kiosk_art.mjs
node scripts/test_qt_kiosk_navigation.mjs
node scripts/test_qt_kiosk_feedback.mjs
python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -p qbz-qt --no-fail-fast
target_dir="${CARGO_TARGET_DIR:-$PWD/crates/target}"
logs="$(mktemp -d "${TMPDIR:-/tmp}/qbz-qt-gate-XXXXXX")"
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
// Regressions for the 2026-09-07 kiosk feedback round:
// - the kiosk's client-side funnel predicates are the DESKTOP's (the two
// ports are run side by side over the same fixtures);
// - the chrome/transport decisions hold statically: the back bar drags,
// Connect + Cast live in the back bar, the transport bar has no hidden
// menu, Volume is always visible, Desktop mode has a button, the Now
// Playing transport is one centred row, and Local Library exposes the
// Open entry point and the funnel.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const qml = (rel) => fs.readFileSync(new URL(`../crates/qbz-qt/qml/${rel}`, import.meta.url), 'utf8');
const desktop = qml('views/LocalLibraryView.qml');
const kioskHost = qml('kiosk/KioskLocalLibrary.qml');
const shell = qml('shell/KioskShell.qml');
const bar = qml('kiosk/KioskNowPlayingBar.qml');
const nowPlaying = qml('kiosk/KioskNowPlaying.qml');
const albumsTab = qml('kiosk/KioskLocalAlbumsTab.qml');
const flyout = qml('shell/QconnectFlyout.qml');
/// The full `function name(...) {...}` text, brace-matched.
function fn(source, name) {
const at = source.indexOf(`function ${name}(`);
assert.ok(at >= 0, `function ${name} in source`);
const start = source.indexOf('{', at);
for (let depth = 1, i = start + 1; i < source.length; i++) {
if (source[i] === '{') depth++;
if (source[i] === '}' && --depth === 0) return source.slice(at, i + 1);
}
throw Error(`unclosed ${name}`);
}
// ---- funnel parity ----------------------------------------------------------
// Cross-context arrays carry foreign prototypes, so results are compared
// through JSON rather than as live objects.
const run = (ctx, code) => JSON.parse(vm.runInContext(`JSON.stringify(${code})`, ctx));
const desktopCtx = vm.createContext({ root: { albumFavorite: (r) => r.isFavorite === true } });
vm.runInContext([fn(desktop, 'sourceBucket'), fn(desktop, 'applyFilter'), fn(desktop, 'artistMatchesFilter')].join('\n'), desktopCtx);
const kioskCtx = vm.createContext({ root: {} });
vm.runInContext([fn(kioskHost, 'sourceBucket'), fn(kioskHost, 'applyFilter'), fn(kioskHost, 'artistMatchesFilter'),
'root.sourceBucket = sourceBucket; root.applyFilter = applyFilter; root.artistMatchesFilter = artistMatchesFilter;'].join('\n'), kioskCtx);
const albums = [
{ id: 'a', isFavorite: true, mediaVariants: [{ qualityTier: 'hires', format: 'flac', source: 'user' }] },
{ id: 'b', isFavorite: false, mediaVariants: [{ qualityTier: 'dsd', format: 'dsf', source: 'qobuz_purchase' }, { qualityTier: 'cd', format: 'flac', source: 'user' }] },
{ id: 'c', isFavorite: false, qualityTier: 'mp3', format: 'mp3', sources: ['navidrome'] },
{ id: 'd', isFavorite: true, qualityTier: 'cd', format: 'alac', source: 'plex' },
{ id: 'e', isFavorite: false, mediaVariants: [{ qualityTier: 'max', format: 'wav', source: '' }] },
];
const artists = [
{ name: 'A', qualityTiers: ['hires', 'cd'], formats: ['flac'], sources: ['user'] },
{ name: 'B', qualityTiers: ['dsd'], formats: ['dsf'], sources: ['qobuz_download'] },
{ name: 'C', qualityTiers: ['mp3'], formats: ['mp3'], sources: ['gonic'] },
{ name: 'D', qualityTiers: [], formats: [], sources: [] },
];
const funnels = [
{}, { hires: true }, { dsd: true }, { flac: true }, { flac: true, dsd: true }, { other: true },
{ plex: true }, { subsonic: true }, { offline: true }, { local: true }, { favorite: true },
{ favorite: true, cd: true }, { lossy: true, mp3: true }, { wav: true, hires: true }, { cd: true, local: true },
];
for (const f of funnels) {
const want = run(desktopCtx, `applyFilter(${JSON.stringify(albums)}, ${JSON.stringify(f)}).map(r => r.id)`);
const got = run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, ${JSON.stringify(f)}).map(r => r.id)`);
assert.deepEqual(got, want, `applyFilter parity for ${JSON.stringify(f)}`);
const wantArtists = run(desktopCtx, `${JSON.stringify(artists)}.filter(a => artistMatchesFilter(a, ${JSON.stringify(f)})).map(a => a.name)`);
const gotArtists = run(kioskCtx, `${JSON.stringify(artists)}.filter(a => root.artistMatchesFilter(a, ${JSON.stringify(f)})).map(a => a.name)`);
assert.deepEqual(gotArtists, wantArtists, `artistMatchesFilter parity for ${JSON.stringify(f)}`);
}
// The fixtures actually discriminate: not every funnel keeps every row.
assert.deepEqual(run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, {"hires":true}).map(r => r.id)`), ['a', 'e']);
assert.deepEqual(run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, {"favorite":true,"cd":true}).map(r => r.id)`), ['d']);
assert.deepEqual(run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, {"subsonic":true}).map(r => r.id)`), ['c']);
// ---- the funnel is wired, not just computed --------------------------------
assert.match(kioskHost, /property var albumsFilter: root\.parseFilter\(QbzLocal\.albumsFilter\)/, 'seeded from the bridge');
assert.match(kioskHost, /QbzLocal\.setAlbumsFilterJson\(/, 'persists through the desktop writer');
assert.match(kioskHost, /QbzLocal\.tracksSetFilterJson\(JSON\.stringify\(root\.commonFilter\)\)/, 'Tracks installs the shared funnel');
assert.match(kioskHost, /function onAlbumsFilterChanged\(\)/, 'a later republish wins');
assert.match(albumsTab, /albumsNativeReset\("", "artist-asc", "off",\s*root\.view \? root\.view\.filterJson : "\{\}"/, 'native Albums descriptor carries the funnel');
assert.match(albumsTab, /function onFilterJsonChanged\(\)/, 'a chip toggle re-queries native Albums');
assert.match(albumsTab, /!\(root\.view && root\.view\.favoriteOnly\)/, 'Favorites only forces the legacy Albums reader');
assert.match(kioskHost, /root\.activeTab === "albums" && root\.favoriteOnly\)\n\s+QbzLocal\.loadTab\("albums-legacy"\)/, 'host loads the legacy document under Favorites only');
for (const key of ['dsd', 'hires', 'cd', 'lossy', 'flac', 'alac', 'ape', 'wav', 'mp3', 'aac', 'other', 'local', 'offline', 'plex', 'jellyfin', 'subsonic', 'favorite'])
assert.match(kioskHost, new RegExp(`toggleFilter\\("${key}"\\)`), `chip for ${key}`);
assert.match(kioskHost, /onClicked: root\.clearFilter\(\)/, 'Clear');
// ---- Open (ephemeral) entry point ------------------------------------------
for (const call of ['QbzLocal.ephemeralOpen()', 'QbzLocal.ephemeralOpenCd()', 'QbzLocal.ephemeralOpenSacd()'])
assert.ok(kioskHost.includes(call), `${call} reachable from the kiosk`);
assert.match(kioskHost, /"Open folder…"[\s\S]*"Open audio CD"[\s\S]*"Open SACD image…"/, 'the desktop Open menu rows, verbatim');
// ---- chrome: drag + session cluster ----------------------------------------
assert.match(shell, /enabled: !QbzShell\.systemTitleBar[\s\S]*root\.hostWindow\.startSystemMove\(\)/, 'the back bar is the drag surface');
assert.match(shell, /connectFlyout\.openBelowRight\(kioskQconnectBtn\)/, 'Connect opens from the back bar');
assert.match(shell, /name: "cast"[\s\S]{0,400}onClicked: QbzCast\.openPicker\(\)/, 'Cast opens from the back bar');
assert.match(shell, /QconnectFlyout \{ id: connectFlyout \}[\s\S]*CastPicker \{ \}/, 'flyout + picker are shell-level');
assert.match(flyout, /function openBelowRight\(sourceItem\)/, 'top-bar placement exists');
assert.ok(shell.indexOf('id: sessionCluster') < shell.indexOf('id: windowControls'), 'cluster is declared before the window controls');
assert.match(shell, /- sessionCluster\.width - 6/, 'the search row yields the cluster width');
// ---- transport bar: no hidden menu, Volume + Desktop mode always visible ---
assert.ok(!bar.includes('moreMenu') && !bar.includes('"ellipsis"'), 'the hidden more-menu is gone');
assert.ok(!bar.includes('QconnectFlyout') && !bar.includes('CastPicker'), 'Connect/Cast no longer live in the bar');
const barOrder = ['"skip-back"', '"pause" : "play-fill"', '"skip-forward"', 'id: volumeButton', '"list-music"', '"monitor"'];
let cursor = bar.indexOf('id: transport');
for (const marker of barOrder) {
const at = bar.indexOf(marker, cursor);
assert.ok(at > cursor, `${marker} in transport order`);
cursor = at;
}
assert.match(bar, /name: "monitor"; label: QbzSession\.tr\("Desktop mode"[\s\S]{0,120}onClicked: QbzSession\.toggleProfile\(\)/, 'Desktop mode button');
assert.match(bar, /rotation: -90/, 'the volume slider opens vertically');
assert.match(bar, /volumePopup\.y = Math\.max\(8, g\.y - volumePopup\.height - 8\)/, 'the sheet is clamped inside the window');
assert.match(bar, /sliderLength: Math\.max\(120, Math\.min\(260,/, 'the sheet shortens on a short window');
const volumeButtonAt = bar.indexOf('id: volumeButton');
assert.ok(!/visible: root\.width >= \d+/.test(bar.slice(volumeButtonAt, volumeButtonAt + 200)), 'Volume has no width gate');
// ---- Now Playing transport: one centred row, every button on the axis -----
const transportAt = nowPlaying.indexOf('id: transport\n');
const transportBlock = nowPlaying.slice(transportAt, nowPlaying.indexOf('AudioStamp', transportAt));
assert.ok(nowPlaying.slice(transportAt - 40, transportAt).includes('Row {'), 'transport is a Row, not a Flow');
const buttons = transportBlock.match(/QbzIconButton \{[^\n]*\}/g);
assert.equal(buttons.length, 8, 'eight transport buttons');
for (const b of buttons) assert.match(b, /anchors\.verticalCenter: parent\.verticalCenter/, `centred: ${b.slice(0, 60)}`);
const order = buttons.map(b => b.match(/name: ([^;]+);/)[1]);
assert.deepEqual(order.slice(2, 5).map(n => n.includes('play-fill') ? 'play' : n), ['"skip-back"', 'play', '"skip-forward"'], 'transport in the middle');
assert.equal(order[0], '"info"'); assert.equal(order[1], '"shuffle"');
assert.match(transportBlock, /x: Math\.max\(4, Math\.min\(\(transportRow\.width - width\) \/ 2,/, 'the cluster is centred and yields to the stamp');
// ---- Artists drill-down: the query must not hang off a stale derived guard --
// A root `onSelectedArtistChanged` handler reads the DERIVED `drilled` before it
// is re-evaluated (false), so `if (root.drilled) detailQuery.restart()` never
// ran and every drill-down showed "0 albums". The trigger is a Connections on
// the host, and its guard reads the source.
const artistsTab = qml('kiosk/KioskLocalArtistsTab.qml');
assert.ok(!/^\s*onSelectedArtistChanged:/m.test(artistsTab), 'no root onSelectedArtistChanged handler');
assert.match(artistsTab, /Connections \{\s*target: root\.view\s*function onSelectedArtistChanged\(\) \{\s*if \(root\.view\.selectedArtist !== ""\)\s*detailQuery\.restart\(\)/, 'detail query re-issued from the host signal, guarded on the source');
assert.ok(!/detailQuery[\s\S]{0,400}if \(!root\.nativeActive/.test(artistsTab), 'the detail query is not gated on nativeActive');
// ---- round 2 (2026-09-07): quality badges, quiet immersive exit, 44px bar, no dead button
const localHeader = qml('views/local/LocalAlbumHeader.qml');
const kioskAlbum = qml('kiosk/KioskAlbum.qml');
const immersive = qml('immersive/ImmersiveView.qml');
const settings = qml('settings/SettingsView.qml');
assert.match(localHeader, /visible: root\.kioskHost && kioskQuality\.tier !== ""[\s\S]{0,600}QualityBadgeFull \{/, 'kiosk-only album quality badge in the local header');
assert.match(kioskAlbum, /QualityBadgeFull \{[\s\S]{0,400}detail: root\.header\.qualityDetail/, 'Qobuz kiosk album header carries a quality badge');
assert.ok(!/text: root\.header\.qualityDetail \|\| ""; color: theme\.textMuted/.test(kioskAlbum), 'the muted quality line is gone');
const exitAt = immersive.indexOf('visible: root.preKiosk');
const exitBlock = immersive.slice(exitAt, exitAt + 900);
assert.match(exitBlock, /opacity: root\.chromeVisible \? 1 : 0/, 'kiosk exit follows the auto-hiding chrome');
assert.match(exitBlock, /enabled: root\.chromeVisible/, 'kiosk exit is hit-enabled only while shown');
assert.ok(!/border\.width: 2/.test(exitBlock) && /width: 52/.test(exitBlock), 'kiosk exit is the quiet 52px form');
assert.match(immersive, /onPressed: function \(mouse\) \{ mouse\.accepted = true; root\.wake\(\) \}/, 'a backdrop press wakes the chrome (touch)');
assert.match(shell, /id: backBar[\s\S]{0,400}height: 44/, 'the back bar is 44px');
for (const id of ['backBtn', 'fwdBtn']) assert.match(shell, new RegExp(`ChromeButton \\{\\s*id: ${id}`), `${id} uses the shared chrome form`);
assert.match(shell, /ChromeButton \{\s*name: "cast"/, 'Cast uses the shared chrome form');
assert.match(shell, /id: kioskQconnectBtn[\s\S]{0,200}width: 44\s*height: 36/, 'Connect matches the 44x36 form');
const kioskHeader = settings.slice(settings.indexOf('// --- Header'), settings.indexOf('// --- Sub-nav'));
assert.ok(!kioskHeader.includes('id: logsButton') && !/onClicked: QbzShell\.logOpen\(\)/.test(kioskHeader), 'the dead Share-logs button is gone from the kiosk settings header');
// ---- round 3 (2026-09-07): search dashboard shelves, album actions, immersive exit ----
const search = qml('kiosk/KioskSearch.qml');
// The dashboard preview Components are instantiated by a Loader; reading the
// outer delegate's `section.modelData.rows` directly threw and emptied every
// shelf. Guarded stable props must be used instead.
assert.match(search, /readonly property var sRows: section\.modelData && section\.modelData\.rows/, 'guarded sRows');
assert.match(search, /readonly property string sKind: section\.modelData && section\.modelData\.kind/, 'guarded sKind');
assert.match(search, /rows: section\.sKind !== "track" \? section\.sRows : \[\]/, 'shelf is a direct child bound to guarded rows');
assert.ok(!/sourceComponent: section\.sKind === "track"/.test(search), 'the dashboard preview no longer hides behind a Loader Component');
assert.ok(!/model: section\.modelData\.rows/.test(search), 'no direct modelData.rows read in a preview list');
assert.ok(!/rows: section\.modelData\.rows/.test(search), 'no direct modelData.rows read in the shelf');
const albumQ = qml('kiosk/KioskAlbum.qml');
for (const ic of ['play-fill', 'shuffle', 'list-start', 'list-plus', 'list-end'])
assert.match(albumQ, new RegExp(`QbzIconButton \\{ name: "${ic}"`), `album action icon ${ic}`);
assert.ok(!/model: \["Play", "Shuffle"\]/.test(albumQ), 'the text Play/Shuffle buttons are gone');
assert.match(albumQ, /QbzPlayer\.enqueueAlbum\(root\.header\.id \|\| "", "next"\)/, 'play next enqueues the album');
assert.match(albumQ, /QbzPlayer\.enqueueAlbum\(root\.header\.id \|\| "", "queue"\)/, 'add to queue enqueues the album');
assert.match(albumQ, /QualityBadgeFull \{[\s\S]{0,200}anchors\.right: parent\.right[\s\S]{0,120}anchors\.verticalCenter/, 'quality badge floats inline at the right');
assert.match(albumQ, /index % 2 === 1 \? theme\.surfaceHover/, 'album track rows are zebra-striped');
const immersive2 = qml('immersive/ImmersiveView.qml');
const exitAt2 = immersive2.indexOf('visible: root.preKiosk');
const exitBlock2 = immersive2.slice(exitAt2, exitAt2 + 700);
assert.match(exitBlock2, /anchors\.bottom: parent\.bottom/, 'immersive kiosk exit is anchored to the bottom');
assert.ok(!/anchors\.top: parent\.top/.test(exitBlock2), 'immersive kiosk exit is no longer at the top');
// ---- round 4 (2026-09-07): lyrics centered, artists rail as round-card grid ----
const llv = qml('shell/LyricsLinesView.qml');
assert.match(llv, /property bool centered: false/, 'LyricsLinesView exposes an opt-in centered');
assert.match(llv, /centered: view\.centered/, 'LyricsLinesView threads centered into the row');
const knp = qml('kiosk/KioskNowPlaying.qml');
assert.match(knp, /LyricsLinesView \{[\s\S]{0,400}centered: true/, 'kiosk Now Playing lyrics are centered');
const artistsTab2 = qml('kiosk/KioskLocalArtistsTab.qml');
assert.match(artistsTab2, /GridView \{\s*id: rail/, 'the artists rail is a GridView (multi-column), not a single-column ListView');
assert.match(artistsTab2, /KioskCard \{[\s\S]{0,120}round: true/, 'artists render as round cards');
assert.match(artistsTab2, /positionViewAtIndex\(root\.focusedItem, GridView\.Contain\)/, 'grid focus scroll uses GridView.Contain');
console.log('Kiosk feedback round: funnel parity with the desktop, Open entry point, back-bar drag/Connect/Cast, transport bar and Now Playing transport pass');