From 31da9c57bb56e9f46c2e110cd1e1b7a999fa1882 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:33:28 -0600 Subject: [PATCH] =?UTF-8?q?fix(qt):=20kiosk=20feedback=20round=20=E2=80=94?= =?UTF-8?q?=20chrome,=20search,=20album,=20artists,=20lyrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../translations/de/LC_MESSAGES/qbz-ui.po | 3 + .../translations/es/LC_MESSAGES/qbz-ui.po | 3 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 3 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 3 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 3 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 3 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 3 + crates/qbz-qt/qml/immersive/ImmersiveView.qml | 38 +- crates/qbz-qt/qml/kiosk/KioskAlbum.qml | 54 +- .../qbz-qt/qml/kiosk/KioskLocalAlbumsTab.qml | 12 +- .../qbz-qt/qml/kiosk/KioskLocalArtistsTab.qml | 217 ++++---- crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml | 504 +++++++++++++++++- .../qbz-qt/qml/kiosk/KioskLocalTracksTab.qml | 14 +- crates/qbz-qt/qml/kiosk/KioskNowPlaying.qml | 37 +- .../qbz-qt/qml/kiosk/KioskNowPlayingBar.qml | 144 ++--- crates/qbz-qt/qml/kiosk/KioskSearch.qml | 61 ++- crates/qbz-qt/qml/settings/SettingsView.qml | 20 +- crates/qbz-qt/qml/shell/KioskShell.qml | 223 +++++--- crates/qbz-qt/qml/shell/LyricsLinesView.qml | 5 + crates/qbz-qt/qml/shell/QconnectFlyout.qml | 7 + .../qml/views/local/LocalAlbumHeader.qml | 19 + crates/qbz-qt/src/local_artists_model_qt.rs | 25 +- scripts/qt-runtime-gate.sh | 1 + scripts/test_qt_kiosk_feedback.mjs | 199 +++++++ 24 files changed, 1273 insertions(+), 328 deletions(-) create mode 100644 scripts/test_qt_kiosk_feedback.mjs diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 143a0b992..c1e048edb 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -6840,3 +6840,6 @@ msgstr "Liste" msgid "Tree" msgstr "Baum" + +msgid "Forward" +msgstr "Vorwärts" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 4fee3ad8b..6c23f5acb 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -6846,3 +6846,6 @@ msgstr "Lista" msgid "Tree" msgstr "Árbol" + +msgid "Forward" +msgstr "Adelante" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index fd17a5749..27a377fe0 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -6838,3 +6838,6 @@ msgstr "Liste" msgid "Tree" msgstr "Arborescence" + +msgid "Forward" +msgstr "Suivant" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 3a7070986..870a178d2 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -6821,3 +6821,6 @@ msgstr "リスト" msgid "Tree" msgstr "ツリー" + +msgid "Forward" +msgstr "進む" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index a2779649e..72114d52e 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -6828,3 +6828,6 @@ msgstr "Lijst" msgid "Tree" msgstr "Boomstructuur" + +msgid "Forward" +msgstr "Vooruit" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 6392312aa..4155900c4 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -6838,3 +6838,6 @@ msgstr "Lista" msgid "Tree" msgstr "Árvore" + +msgid "Forward" +msgstr "Avançar" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 28dbcad43..5a14112cf 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -6859,3 +6859,6 @@ msgstr "Список" msgid "Tree" msgstr "Дерево" + +msgid "Forward" +msgstr "Вперёд" diff --git a/crates/qbz-qt/qml/immersive/ImmersiveView.qml b/crates/qbz-qt/qml/immersive/ImmersiveView.qml index 0f9777e01..afcf55296 100644 --- a/crates/qbz-qt/qml/immersive/ImmersiveView.qml +++ b/crates/qbz-qt/qml/immersive/ImmersiveView.qml @@ -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 diff --git a/crates/qbz-qt/qml/kiosk/KioskAlbum.qml b/crates/qbz-qt/qml/kiosk/KioskAlbum.qml index 60d0ddbd7..30b583e17 100644 --- a/crates/qbz-qt/qml/kiosk/KioskAlbum.qml +++ b/crates/qbz-qt/qml/kiosk/KioskAlbum.qml @@ -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 } diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalAlbumsTab.qml b/crates/qbz-qt/qml/kiosk/KioskLocalAlbumsTab.qml index 43c4c8180..a976e59ab 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalAlbumsTab.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalAlbumsTab.qml @@ -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 diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalArtistsTab.qml b/crates/qbz-qt/qml/kiosk/KioskLocalArtistsTab.qml index 7d597f785..7677d072e 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalArtistsTab.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalArtistsTab.qml @@ -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) } } } } diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml b/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml index 1919483e2..f059dd714 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml @@ -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 // ===================================================================== diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalTracksTab.qml b/crates/qbz-qt/qml/kiosk/KioskLocalTracksTab.qml index 90141234a..b59508fd6 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalTracksTab.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalTracksTab.qml @@ -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. diff --git a/crates/qbz-qt/qml/kiosk/KioskNowPlaying.qml b/crates/qbz-qt/qml/kiosk/KioskNowPlaying.qml index 66c990429..43aa8cabf 100644 --- a/crates/qbz-qt/qml/kiosk/KioskNowPlaying.qml +++ b/crates/qbz-qt/qml/kiosk/KioskNowPlaying.qml @@ -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 diff --git a/crates/qbz-qt/qml/kiosk/KioskNowPlayingBar.qml b/crates/qbz-qt/qml/kiosk/KioskNowPlayingBar.qml index eb9d3621c..ecd85fa08 100644 --- a/crates/qbz-qt/qml/kiosk/KioskNowPlayingBar.qml +++ b/crates/qbz-qt/qml/kiosk/KioskNowPlayingBar.qml @@ -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 diff --git a/crates/qbz-qt/qml/kiosk/KioskSearch.qml b/crates/qbz-qt/qml/kiosk/KioskSearch.qml index 846be3533..495a44a74 100644 --- a/crates/qbz-qt/qml/kiosk/KioskSearch.qml +++ b/crates/qbz-qt/qml/kiosk/KioskSearch.qml @@ -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 || "") } } } diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index 4412204f1..9ca204e6d 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -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 --------------------------------------- diff --git a/crates/qbz-qt/qml/shell/KioskShell.qml b/crates/qbz-qt/qml/shell/KioskShell.qml index fdc6e8ae3..f4612ba84 100644 --- a/crates/qbz-qt/qml/shell/KioskShell.qml +++ b/crates/qbz-qt/qml/shell/KioskShell.qml @@ -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) // ===================================================================== diff --git a/crates/qbz-qt/qml/shell/LyricsLinesView.qml b/crates/qbz-qt/qml/shell/LyricsLinesView.qml index bb3f9cff9..58bce3b37 100644 --- a/crates/qbz-qt/qml/shell/LyricsLinesView.qml +++ b/crates/qbz-qt/qml/shell/LyricsLinesView.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/QconnectFlyout.qml b/crates/qbz-qt/qml/shell/QconnectFlyout.qml index bb09335ee..6c099bf7f 100644 --- a/crates/qbz-qt/qml/shell/QconnectFlyout.qml +++ b/crates/qbz-qt/qml/shell/QconnectFlyout.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumHeader.qml b/crates/qbz-qt/qml/views/local/LocalAlbumHeader.qml index 36ebc3b0d..e185bf81d 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumHeader.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumHeader.qml @@ -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 ---- diff --git a/crates/qbz-qt/src/local_artists_model_qt.rs b/crates/qbz-qt/src/local_artists_model_qt.rs index 7cddd6b9d..9ce60bade 100644 --- a/crates/qbz-qt/src/local_artists_model_qt.rs +++ b/crates/qbz-qt/src/local_artists_model_qt.rs @@ -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()); diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index bc6a00a25..cca1be52a 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -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")" diff --git a/scripts/test_qt_kiosk_feedback.mjs b/scripts/test_qt_kiosk_feedback.mjs new file mode 100644 index 000000000..b75f9dcc0 --- /dev/null +++ b/scripts/test_qt_kiosk_feedback.mjs @@ -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');