fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics
Address the owner's kiosk feedback on top of the release hardening: - Chrome: the back bar is a drag surface and shrank to 44px with one shared button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a permanent cluster in the bar; the dead Share-logs button left the kiosk Settings header. - Transport bar: the hidden overflow menu is gone; Volume is always visible with a vertical sheet, and Desktop mode has its own button. - Now Playing: one centred transport row (New-bar order); lyrics are centred (shared LyricsLinesView gains an opt-in centered, desktop default false). - Local Library: the media funnel and an Open (ephemeral) entry point reach the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the desktop LocalArtistsTab is untouched); the Artists drill-down query no longer hangs off a stale derived guard. - Search: the All-tab dashboard shelves render again — they were mounted in a Loader Component that read the delegate id under reuse and bound an empty model; they are direct children now. - Album detail: icon actions the height of the quality badge plus play next / later / queue, an inline floating quality badge, and zebra rows; both album headers show a quality badge. - Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the window controls. Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6, Node gate 5/5, release build 0 warnings. cc
This commit is contained in:
24 files changed
+1273
-328
No files matched your search
@@ -6840,3 +6840,6 @@ msgstr "Liste"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "Baum"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "Vorwärts"
|
||||
@@ -6846,3 +6846,6 @@ msgstr "Lista"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "Árbol"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "Adelante"
|
||||
@@ -6838,3 +6838,6 @@ msgstr "Liste"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "Arborescence"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "Suivant"
|
||||
@@ -6821,3 +6821,6 @@ msgstr "リスト"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "ツリー"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "進む"
|
||||
@@ -6828,3 +6828,6 @@ msgstr "Lijst"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "Boomstructuur"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "Vooruit"
|
||||
@@ -6838,3 +6838,6 @@ msgstr "Lista"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "Árvore"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "Avançar"
|
||||
@@ -6859,3 +6859,6 @@ msgstr "Список"
|
||||
|
||||
msgid "Tree"
|
||||
msgstr "Дерево"
|
||||
|
||||
msgid "Forward"
|
||||
msgstr "Вперёд"
|
||||
@@ -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
|
||||
|
||||
@@ -79,25 +79,34 @@ Rectangle {
|
||||
Text { anchors.fill: parent; verticalAlignment: Text.AlignVCenter; text: root.header.artist || ""; color: theme.textSecondary; font.pixelSize: 17; elide: Text.ElideRight }
|
||||
MouseArea { anchors.fill: parent; onClicked: QbzArtist.openArtist(root.header.artistId || "") }
|
||||
}
|
||||
Text { width: parent.width; text: root.header.qualityDetail || ""; color: theme.textMuted; font.pixelSize: 13; elide: Text.ElideRight }
|
||||
Row {
|
||||
spacing: 10
|
||||
Repeater {
|
||||
model: ["Play", "Shuffle"]
|
||||
delegate: Rectangle {
|
||||
required property string modelData
|
||||
required property int index
|
||||
width: Math.max(64, Math.min(132, (headerInfo.width - 10) / 2)); height: 64
|
||||
radius: theme.radiusSm; color: index === 0 ? theme.accent : theme.surfaceElevated
|
||||
Text { anchors.centerIn: parent; text: QbzSession.tr(modelData, QbzSession.trRev); color: index === 0 ? theme.accentText : theme.textPrimary; font.pixelSize: 16 }
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
if (index === 0) QbzPlayer.playAlbum(root.header.id || "")
|
||||
else QbzPlayer.playAlbumShuffled(root.header.id || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Action row (owner feedback 2026-09-07): icon buttons, not
|
||||
// text; Play/Shuffle plus the three queue insertions
|
||||
// (play next / play later / add to queue), all the SAME height;
|
||||
// and the quality badge floats inline at the right of the same
|
||||
// row instead of owning a line above it.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 48
|
||||
Row {
|
||||
id: albumActions
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
QbzIconButton { name: "play-fill"; btnSize: 48; iconSize: 22; activeBackground: true; active: true; onClicked: QbzPlayer.playAlbum(root.header.id || "") }
|
||||
QbzIconButton { name: "shuffle"; btnSize: 48; iconSize: 20; active: QbzPlayer.npShuffle; onClicked: QbzPlayer.playAlbumShuffled(root.header.id || "") }
|
||||
QbzIconButton { name: "list-start"; btnSize: 48; iconSize: 20; onClicked: QbzPlayer.enqueueAlbum(root.header.id || "", "next") }
|
||||
QbzIconButton { name: "list-plus"; btnSize: 48; iconSize: 20; onClicked: QbzPlayer.enqueueAlbum(root.header.id || "", "later") }
|
||||
QbzIconButton { name: "list-end"; btnSize: 48; iconSize: 20; onClicked: QbzPlayer.enqueueAlbum(root.header.id || "", "queue") }
|
||||
}
|
||||
QualityBadgeFull {
|
||||
readonly property string rawTier: root.header.qualityTier || ""
|
||||
visible: tier !== "" || detail !== ""
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tier: rawTier === "max" ? "hires" : rawTier === "lossy" ? "mp3" : rawTier
|
||||
detail: root.header.qualityDetail || ""
|
||||
showIcon: true
|
||||
scaleFactor: 1.15
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +118,12 @@ Rectangle {
|
||||
width: list.width - 32; height: 64; radius: theme.radiusSm
|
||||
readonly property bool current: QbzPlayer.npTrackId === (modelData.id || "")
|
||||
readonly property bool navFocused: root.contentFocus && QbzKioskNav.index === index
|
||||
color: navFocused ? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18) : current ? theme.surfaceElevated : "transparent"
|
||||
// Zebra striping (owner feedback 2026-09-07): odd rows carry a faint
|
||||
// fill so a long tracklist reads as banded rows. Current/nav/focus
|
||||
// states win over the stripe.
|
||||
color: navFocused ? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
|
||||
: current ? theme.surfaceElevated
|
||||
: (index % 2 === 1 ? theme.surfaceHover : "transparent")
|
||||
border.width: navFocused ? 2 : 0; border.color: theme.accent
|
||||
Text { id: number; x: 8; width: 34; height: parent.height; text: modelData.number || (index + 1); color: theme.textMuted; font.pixelSize: 14; verticalAlignment: Text.AlignVCenter; horizontalAlignment: Text.AlignRight }
|
||||
Text { id: duration; anchors.right: parent.right; anchors.rightMargin: 12; height: parent.height; text: modelData.duration || ""; color: theme.textMuted; font.pixelSize: 13; verticalAlignment: Text.AlignVCenter }
|
||||
|
||||
@@ -27,7 +27,10 @@ Item {
|
||||
|
||||
property var view: null
|
||||
|
||||
// The native reader knows no favorites (LocalLibraryView.qml:341-344):
|
||||
// under "Favorites only" the host loads the legacy document instead.
|
||||
readonly property bool nativeActive: QbzLocal.localAlbumsNativeActive
|
||||
&& !(root.view && root.view.favoriteOnly)
|
||||
readonly property var nativeModel: QbzLocalAlbums
|
||||
readonly property int albumTotal: root.nativeActive
|
||||
? (QbzLocal.localAlbumsNativeTotal || 0)
|
||||
@@ -49,7 +52,8 @@ Item {
|
||||
function resetNativeQuery() {
|
||||
if (grid.columns <= 0)
|
||||
return
|
||||
QbzLocal.albumsNativeReset("", "artist-asc", "off", "{}", grid.columns)
|
||||
QbzLocal.albumsNativeReset("", "artist-asc", "off",
|
||||
root.view ? root.view.filterJson : "{}", grid.columns)
|
||||
}
|
||||
Component.onCompleted: {
|
||||
queryCoalescer.restart()
|
||||
@@ -77,6 +81,12 @@ Item {
|
||||
// descriptor exactly as it does on the desktop tab.
|
||||
function onLocalAlbumModeChanged() { queryCoalescer.restart() }
|
||||
}
|
||||
// The funnel IS part of the descriptor: a chip toggled in the host's
|
||||
// sheet re-issues the native query.
|
||||
Connections {
|
||||
target: root.view
|
||||
function onFilterJsonChanged() { queryCoalescer.restart() }
|
||||
}
|
||||
|
||||
// The model only asks for a page when this signal has a receiver
|
||||
// (LocalAlbumsModel::requestPage bails on `!isSignalConnected`), so the
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,16 @@
|
||||
// 3. the artwork window REGISTRY — the one policy that cannot be split per
|
||||
// surface, because eviction is only correct against every live surface at
|
||||
// once (the desktop `_windows` finding, reproduced);
|
||||
// 4. the nav geometry it publishes into QbzKioskNav.
|
||||
// 4. the nav geometry it publishes into QbzKioskNav;
|
||||
// 5. the media FUNNEL (quality / format / source / favorites) — the SAME
|
||||
// persisted state as the desktop view, seeded from `QbzLocal.albumsFilter`
|
||||
// and written through the desktop's own setters (owner report 2026-09-07:
|
||||
// a funnel set on the desktop kept filtering the kiosk with no control to
|
||||
// see or clear it);
|
||||
// 6. the "Open" entry point (folder / audio CD / SACD image) that starts an
|
||||
// ephemeral session — the desktop reaches it through LocalChrome's
|
||||
// toolbar and the nav flyout, neither of which the kiosk mounts, so the
|
||||
// Ephemeral tab was unreachable from the kiosk (owner report 2026-09-07).
|
||||
// Every tab body is its own file, mounted behind `Loader.active`, and each one
|
||||
// consumes the authoritative reader for its surface.
|
||||
//
|
||||
@@ -39,7 +48,9 @@
|
||||
// the OUTGOING state) and restores it on the way back.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
@@ -110,6 +121,206 @@ Rectangle {
|
||||
property string selectedArtist: ""
|
||||
property string selectedGenre: ""
|
||||
|
||||
// =====================================================================
|
||||
// The media funnel
|
||||
// =====================================================================
|
||||
// One quality/format/source funnel follows the user across Albums,
|
||||
// Artists, Genres and Tracks; Favorites is an Albums/Genres-only predicate
|
||||
// that survives visits to the other tabs. Mirrors LocalLibraryView.qml
|
||||
// :323-383 — same seed, same writers, same split into the shared "common"
|
||||
// part — so the two shells cannot disagree about what is filtered.
|
||||
property var albumsFilter: root.parseFilter(QbzLocal.albumsFilter)
|
||||
readonly property var commonFilter: {
|
||||
var out = Object.assign({}, root.albumsFilter || ({}))
|
||||
delete out.favorite
|
||||
return out
|
||||
}
|
||||
readonly property bool favoriteOnly: root.albumsFilter.favorite === true
|
||||
/// The funnel the ACTIVE tab sees.
|
||||
readonly property var filter: (root.activeTab === "albums" || root.activeTab === "genres")
|
||||
? root.albumsFilter : root.commonFilter
|
||||
/// The descriptor handed to the native readers (which know no favorites).
|
||||
readonly property string filterJson: JSON.stringify(root.commonFilter)
|
||||
readonly property int filterCount: {
|
||||
var n = 0
|
||||
for (var k in root.filter)
|
||||
if (root.filter[k])
|
||||
n++
|
||||
return n
|
||||
}
|
||||
function parseFilter(json) {
|
||||
if (!json || json === "")
|
||||
return ({})
|
||||
try {
|
||||
return JSON.parse(json) || ({})
|
||||
} catch (e) {
|
||||
return ({})
|
||||
}
|
||||
}
|
||||
/// The ONE writer (LocalLibraryView.qml:361-383). Persists through the
|
||||
/// desktop's setters and re-queries whatever the active tab reads.
|
||||
function setFilter(f) {
|
||||
var value = f || ({})
|
||||
var favorite = (root.activeTab === "albums" || root.activeTab === "genres")
|
||||
? value.favorite === true : root.favoriteOnly
|
||||
var common = Object.assign({}, value)
|
||||
delete common.favorite
|
||||
var albumValue = Object.assign({}, common)
|
||||
if (favorite)
|
||||
albumValue.favorite = true
|
||||
root.albumsFilter = albumValue
|
||||
QbzLocal.setAlbumsFilterJson(Object.keys(albumValue).length === 0
|
||||
? "" : JSON.stringify(albumValue))
|
||||
// Tracks owns a server-side descriptor; its setter resets the paged
|
||||
// query, so it is only called while Tracks is the visible consumer.
|
||||
// A later tab switch installs the shared state (loadActiveTab).
|
||||
if (root.activeTab === "tracks")
|
||||
QbzLocal.tracksSetFilterJson(JSON.stringify(common))
|
||||
else
|
||||
root.loadActiveTab()
|
||||
}
|
||||
function toggleFilter(key) {
|
||||
var f = Object.assign({}, root.filter)
|
||||
f[key] = !f[key]
|
||||
if (!f[key])
|
||||
delete f[key]
|
||||
root.setFilter(f)
|
||||
}
|
||||
function clearFilter() { root.setFilter({}) }
|
||||
// A later republish still wins (a media server connected or removed
|
||||
// prunes the saved funnel). Guarded on a real difference so writing the
|
||||
// property back does not bounce.
|
||||
Connections {
|
||||
target: QbzLocal
|
||||
function onAlbumsFilterChanged() {
|
||||
var next = root.parseFilter(QbzLocal.albumsFilter)
|
||||
if (JSON.stringify(next) !== JSON.stringify(root.albumsFilter))
|
||||
root.albumsFilter = next
|
||||
}
|
||||
}
|
||||
|
||||
// The client-side predicates for the LEGACY documents, ported from
|
||||
// LocalLibraryView.qml (sourceBucket :795, applyFilter :810,
|
||||
// artistMatchesFilter :862). The native readers filter in Rust from the
|
||||
// same descriptor.
|
||||
function sourceBucket(word) {
|
||||
var source = (word || "local").toLowerCase()
|
||||
if (source === "" || source === "user")
|
||||
return "local"
|
||||
if (source === "qobuz_purchase" || source === "qobuz_download")
|
||||
return "offline"
|
||||
if (source === "navidrome" || source === "gonic"
|
||||
|| source === "airsonic" || source === "astiga")
|
||||
return "subsonic"
|
||||
return source
|
||||
}
|
||||
function applyFilter(rows, selectedFilter) {
|
||||
var selected = selectedFilter || ({})
|
||||
if (Object.keys(selected).length === 0)
|
||||
return rows
|
||||
var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy
|
||||
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|
||||
|| selected.mp3 || selected.aac || selected.other
|
||||
var sAny = selected.local || selected.offline || selected.plex
|
||||
|| selected.jellyfin || selected.subsonic
|
||||
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
|
||||
var out = []
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var r = rows[i]
|
||||
if (selected.favorite === true && r.isFavorite !== true)
|
||||
continue
|
||||
if (qAny || fAny || sAny) {
|
||||
// Test every active section against ONE physical copy: a
|
||||
// logical album can carry DSD on one copy and FLAC on
|
||||
// another, and DSD+FLAC must not manufacture a parent whose
|
||||
// filtered detail has zero tracks.
|
||||
var variants = r.mediaVariants || []
|
||||
if (variants.length === 0) {
|
||||
var sources = r.sources && r.sources.length > 0
|
||||
? r.sources : [r.source || "local"]
|
||||
variants = []
|
||||
for (var si = 0; si < sources.length; si++)
|
||||
variants.push({ "qualityTier": r.qualityTier || "",
|
||||
"format": r.format || "",
|
||||
"source": sources[si] })
|
||||
}
|
||||
var mediaMatches = false
|
||||
for (var vi = 0; vi < variants.length; vi++) {
|
||||
var variant = variants[vi]
|
||||
var tier = (variant.qualityTier || "").toLowerCase()
|
||||
var fmt = (variant.format || "").toLowerCase()
|
||||
var qok = !qAny
|
||||
|| (selected.dsd && tier === "dsd")
|
||||
|| (selected.hires && (tier === "hires" || tier === "max"))
|
||||
|| (selected.cd && tier === "cd")
|
||||
|| (selected.lossy && (tier === "mp3" || tier === "lossy"))
|
||||
var fok = !fAny || selected[fmt] === true
|
||||
|| (selected.other === true && known[fmt] !== 1)
|
||||
var sok = !sAny || selected[root.sourceBucket(variant.source)] === true
|
||||
if (qok && fok && sok) {
|
||||
mediaMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!mediaMatches)
|
||||
continue
|
||||
}
|
||||
out.push(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
function artistMatchesFilter(artist, selected) {
|
||||
if (!selected || Object.keys(selected).length === 0)
|
||||
return true
|
||||
var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy
|
||||
var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|
||||
|| selected.mp3 || selected.aac || selected.other
|
||||
var sAny = selected.local || selected.offline || selected.plex
|
||||
|| selected.jellyfin || selected.subsonic
|
||||
var known = { "flac": 1, "alac": 1, "ape": 1, "wav": 1, "mp3": 1, "aac": 1 }
|
||||
var i, value
|
||||
if (qAny) {
|
||||
var qualityOk = false
|
||||
for (i = 0; i < (artist.qualityTiers || []).length; i++) {
|
||||
value = (artist.qualityTiers[i] || "").toLowerCase()
|
||||
if ((selected.dsd && value === "dsd")
|
||||
|| (selected.hires && (value === "hires" || value === "max"))
|
||||
|| (selected.cd && value === "cd")
|
||||
|| (selected.lossy && (value === "mp3" || value === "lossy"))) {
|
||||
qualityOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!qualityOk)
|
||||
return false
|
||||
}
|
||||
if (fAny) {
|
||||
var formatOk = false
|
||||
for (i = 0; i < (artist.formats || []).length; i++) {
|
||||
value = (artist.formats[i] || "").toLowerCase()
|
||||
if (selected[value] === true
|
||||
|| (selected.other === true && known[value] !== 1)) {
|
||||
formatOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!formatOk)
|
||||
return false
|
||||
}
|
||||
if (sAny) {
|
||||
var sourceOk = false
|
||||
for (i = 0; i < (artist.sources || []).length; i++) {
|
||||
if (selected[root.sourceBucket(artist.sources[i])] === true) {
|
||||
sourceOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!sourceOk)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function tabAvailable(tab) {
|
||||
if (tab === "ephemeral")
|
||||
return root.ephemeralActive
|
||||
@@ -158,7 +369,18 @@ Rectangle {
|
||||
QbzShell.navigateTo("localalbum")
|
||||
}
|
||||
|
||||
function loadActiveTab() { QbzLocal.loadTab(root.activeTab) }
|
||||
/// LocalLibraryView.qml:700-707. Tracks owns a server-side descriptor:
|
||||
/// installing the shared funnel IS its load. Albums under "Favorites
|
||||
/// only" reads the legacy document (the native reader knows no
|
||||
/// favorites — LocalLibraryView.qml:341-344, :515).
|
||||
function loadActiveTab() {
|
||||
if (root.activeTab === "tracks")
|
||||
QbzLocal.tracksSetFilterJson(JSON.stringify(root.commonFilter))
|
||||
else if (root.activeTab === "albums" && root.favoriteOnly)
|
||||
QbzLocal.loadTab("albums-legacy")
|
||||
else
|
||||
QbzLocal.loadTab(root.activeTab)
|
||||
}
|
||||
|
||||
onActiveTabChanged: {
|
||||
root.loadActiveTab()
|
||||
@@ -264,8 +486,23 @@ Rectangle {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
readonly property var albums: ((root.activeTab === "albums" && !QbzLocal.localAlbumsNativeActive) || root.activeTab === "genres" || (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive)) ? root.parseDoc(QbzLocal.localAlbumsJson, []) : []
|
||||
readonly property var artists: (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive) ? root.parseDoc(QbzLocal.localArtistsJson, []) : []
|
||||
// The legacy album/artist documents are read THROUGH the funnel: Albums
|
||||
// and Genres see the favorites predicate, the Artists drill-down the
|
||||
// shared common part (LocalLibraryView.qml:916, :1021, :1213).
|
||||
readonly property var albums: ((root.activeTab === "albums" && (!QbzLocal.localAlbumsNativeActive || root.favoriteOnly)) || root.activeTab === "genres" || (root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive))
|
||||
? root.applyFilter(root.parseDoc(QbzLocal.localAlbumsJson, []),
|
||||
root.activeTab === "artists" ? root.commonFilter : root.albumsFilter)
|
||||
: []
|
||||
readonly property var artists: {
|
||||
if (!(root.activeTab === "artists" && !QbzLocal.localArtistsNativeActive))
|
||||
return []
|
||||
var rows = root.parseDoc(QbzLocal.localArtistsJson, [])
|
||||
var out = []
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
if (root.artistMatchesFilter(rows[i], root.commonFilter))
|
||||
out.push(rows[i])
|
||||
return out
|
||||
}
|
||||
readonly property var folders: (root.activeTab === "folders") ? root.parseDoc(QbzLocal.localFoldersJson, []) : []
|
||||
readonly property var tracks: (root.activeTab === "tracks" && !QbzLocal.localTracksNativeActive) ? root.parseDoc(QbzLocal.localTracksJson, []) : []
|
||||
readonly property var ephemeral: (root.activeTab === "ephemeral") ? root.parseDoc(QbzLocal.localEphemeralJson, null) : null
|
||||
@@ -451,7 +688,7 @@ Rectangle {
|
||||
id: tabScroll
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.pad
|
||||
anchors.rightMargin: root.pad
|
||||
anchors.rightMargin: root.pad + stripActions.width + 8
|
||||
contentWidth: tabsRow.width
|
||||
contentHeight: height
|
||||
clip: true
|
||||
@@ -521,6 +758,82 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// The strip's right cluster: Open (starts an ephemeral session) and
|
||||
// the funnel. Two 64px touch targets, always mounted — the funnel
|
||||
// button is the only place the kiosk shows that a filter is on.
|
||||
Row {
|
||||
id: stripActions
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.pad
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 64
|
||||
spacing: 4
|
||||
|
||||
Rectangle {
|
||||
id: openBtn
|
||||
width: 64
|
||||
height: 64
|
||||
radius: theme.radiusSm
|
||||
color: openArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.t("Open")
|
||||
QbzIcon {
|
||||
name: "folder-open"
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.centerIn: parent
|
||||
tintName: "textPrimary"
|
||||
}
|
||||
MouseArea {
|
||||
id: openArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: openMenu.openBelowRight(openBtn)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: filterBtn
|
||||
width: 64
|
||||
height: 64
|
||||
radius: theme.radiusSm
|
||||
color: filterArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
border.width: root.filterCount > 0 ? 1 : 0
|
||||
border.color: theme.accent
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.t("Quality, format and source filters")
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.centerIn: parent
|
||||
tintName: root.filterCount > 0 ? "accent" : "textPrimary"
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.filterCount > 0
|
||||
x: parent.width - width - 8
|
||||
y: 8
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 10
|
||||
color: theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.filterCount
|
||||
color: theme.accentGlyphColor
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: filterArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: root.openFilterSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: tabStrip.left
|
||||
anchors.right: tabStrip.right
|
||||
@@ -530,6 +843,187 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// The Open menu: LocalChrome.qml:262-282's entries and actions, verbatim
|
||||
// (the audio-CD row is dropped on Windows for the same reason it is
|
||||
// there). The session opens globally and the router lands on the
|
||||
// Ephemeral tab (ContentRouter.qml:606-611).
|
||||
CardMenu {
|
||||
id: openMenu
|
||||
kioskHost: true
|
||||
menuWidth: 260
|
||||
entries: [
|
||||
{ "label": root.t("Open folder…"), "icon": "folder-open", "action": "folder" }
|
||||
].concat(QbzShell.isWindows ? [] : [
|
||||
{ "label": root.t("Open audio CD"), "icon": "disc", "action": "cd" }
|
||||
]).concat([
|
||||
{ "label": root.t("Open SACD image…"), "icon": "disc-3", "action": "sacd" }
|
||||
])
|
||||
onPicked: function (action) {
|
||||
if (action === "folder")
|
||||
QbzLocal.ephemeralOpen()
|
||||
else if (action === "cd")
|
||||
QbzLocal.ephemeralOpenCd()
|
||||
else if (action === "sacd")
|
||||
QbzLocal.ephemeralOpenSacd()
|
||||
}
|
||||
}
|
||||
|
||||
/// The funnel sheet: LocalFilterPopup.qml's sections and chips at touch
|
||||
/// density, centred in the window and clamped inside it.
|
||||
function openFilterSheet() {
|
||||
var win = filterSheet.parent
|
||||
if (win) {
|
||||
filterSheet.x = Math.max(8, (win.width - filterSheet.width) / 2)
|
||||
filterSheet.y = Math.max(8, Math.min(88, win.height - filterSheet.height - 8))
|
||||
}
|
||||
filterSheet.open()
|
||||
}
|
||||
|
||||
component FunnelChip: Rectangle {
|
||||
id: chip
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
signal toggled()
|
||||
height: 44
|
||||
width: chipLabel.implicitWidth + 32
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: chip.active ? theme.accent : theme.borderSubtle
|
||||
color: chip.active ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: chip.label
|
||||
color: chip.active ? theme.accentGlyphColor : theme.textSecondary
|
||||
font.pixelSize: 15
|
||||
font.weight: chip.active ? theme.weightSemibold : theme.weightRegular
|
||||
}
|
||||
MouseArea {
|
||||
id: chipArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: chip.toggled()
|
||||
}
|
||||
}
|
||||
|
||||
component FunnelHeading: Text {
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: filterSheet
|
||||
parent: Overlay.overlay
|
||||
width: Math.min(560, (parent ? parent.width : 800) - 16)
|
||||
height: Math.min(sheetColumn.height + 32, (parent ? parent.height : 480) - 16)
|
||||
padding: 16
|
||||
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
||||
background: Rectangle {
|
||||
color: theme.surfaceCard
|
||||
radius: 10
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
}
|
||||
contentItem: Flickable {
|
||||
contentWidth: width
|
||||
contentHeight: sheetColumn.height
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: sheetColumn
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 44
|
||||
Text {
|
||||
width: parent.width - clearBtn.width
|
||||
height: parent.height
|
||||
text: root.t("Filter")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 18
|
||||
font.weight: theme.weightSemibold
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Rectangle {
|
||||
id: clearBtn
|
||||
visible: root.filterCount > 0
|
||||
width: visible ? clearText.implicitWidth + 32 : 0
|
||||
height: 44
|
||||
radius: theme.radiusSm
|
||||
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Text {
|
||||
id: clearText
|
||||
anchors.centerIn: parent
|
||||
text: root.t("Clear")
|
||||
color: theme.accent
|
||||
font.pixelSize: 15
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: root.clearFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FunnelHeading {
|
||||
visible: root.activeTab === "albums" || root.activeTab === "genres"
|
||||
text: root.t("Favorites")
|
||||
}
|
||||
Flow {
|
||||
visible: root.activeTab === "albums" || root.activeTab === "genres"
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip {
|
||||
label: root.t("Favorites only")
|
||||
active: root.filter.favorite === true
|
||||
onToggled: root.toggleFilter("favorite")
|
||||
}
|
||||
}
|
||||
|
||||
FunnelHeading { text: root.t("Quality") }
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip { label: "DSD"; active: root.filter.dsd === true; onToggled: root.toggleFilter("dsd") }
|
||||
FunnelChip { label: root.t("Hi-Res"); active: root.filter.hires === true; onToggled: root.toggleFilter("hires") }
|
||||
FunnelChip { label: root.t("CD"); active: root.filter.cd === true; onToggled: root.toggleFilter("cd") }
|
||||
FunnelChip { label: root.t("Lossy"); active: root.filter.lossy === true; onToggled: root.toggleFilter("lossy") }
|
||||
}
|
||||
|
||||
FunnelHeading { text: root.t("Format") }
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip { label: "FLAC"; active: root.filter.flac === true; onToggled: root.toggleFilter("flac") }
|
||||
FunnelChip { label: "ALAC"; active: root.filter.alac === true; onToggled: root.toggleFilter("alac") }
|
||||
FunnelChip { label: "APE"; active: root.filter.ape === true; onToggled: root.toggleFilter("ape") }
|
||||
FunnelChip { label: "WAV"; active: root.filter.wav === true; onToggled: root.toggleFilter("wav") }
|
||||
FunnelChip { label: "MP3"; active: root.filter.mp3 === true; onToggled: root.toggleFilter("mp3") }
|
||||
FunnelChip { label: "AAC"; active: root.filter.aac === true; onToggled: root.toggleFilter("aac") }
|
||||
FunnelChip { label: root.t("Other"); active: root.filter.other === true; onToggled: root.toggleFilter("other") }
|
||||
}
|
||||
|
||||
FunnelHeading { text: root.t("Source") }
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
FunnelChip { label: root.t("Local"); active: root.filter.local === true; onToggled: root.toggleFilter("local") }
|
||||
FunnelChip { label: root.t("Offline cache"); active: root.filter.offline === true; onToggled: root.toggleFilter("offline") }
|
||||
FunnelChip { visible: QbzLocal.plexAvailable; label: "Plex"; active: root.filter.plex === true; onToggled: root.toggleFilter("plex") }
|
||||
FunnelChip { visible: QbzLocal.mediaHasJellyfin; label: "Jellyfin"; active: root.filter.jellyfin === true; onToggled: root.toggleFilter("jellyfin") }
|
||||
FunnelChip { visible: QbzLocal.mediaHasSubsonic; label: "Subsonic"; active: root.filter.subsonic === true; onToggled: root.toggleFilter("subsonic") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Content — ONE tab exists at a time
|
||||
// =====================================================================
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
// `QbzLocalTracks` surface is active (the production default since the native
|
||||
// model shipped).
|
||||
//
|
||||
// It issues NO query of its own. `QbzLocal.loadTab("tracks")` — which the host
|
||||
// already calls on mount and on every tab switch — is the whole activation,
|
||||
// and it reuses the user's PERSISTED funnel, sort and grouping.
|
||||
//
|
||||
// That is deliberate and load-bearing: `tracksSetFilterJson` is the desktop
|
||||
// tab's entry point, and it WRITES the funnel through to `ui_prefs`. A kiosk
|
||||
// that called it with a neutral descriptor would silently erase the Full UI's
|
||||
// saved Tracks filter — a Full UI regression caused from a kiosk-only file.
|
||||
// It issues NO query of its own. The host's `loadActiveTab` — called on mount
|
||||
// and on every tab switch — installs the SHARED funnel through
|
||||
// `tracksSetFilterJson`, exactly as the desktop view does
|
||||
// (LocalLibraryView.qml:700-707), and that setter is the load. The kiosk never
|
||||
// hands it a neutral descriptor: the value is the same persisted funnel the
|
||||
// Full UI reads, so nothing saved from the desktop is erased here.
|
||||
//
|
||||
// Everything else is KioskLocalTrackList.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---------------------------------------
|
||||
|
||||
@@ -52,6 +52,42 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
/// The back bar's one button form: 44x36, radius-sm hover fill, a 20px
|
||||
/// glyph. `available` dims the glyph and disarms the area (Back/Forward
|
||||
/// with no history); `active` lights the glyph accent (Cast connected).
|
||||
/// Connect keeps its own golden arm below because its active state is a
|
||||
/// gold tint + border the shared form cannot express.
|
||||
component ChromeButton: Rectangle {
|
||||
id: cb
|
||||
property string name: ""
|
||||
property bool available: true
|
||||
property bool active: false
|
||||
property string label: ""
|
||||
signal clicked()
|
||||
width: 44
|
||||
height: 36
|
||||
radius: theme.radiusSm
|
||||
color: (cbArea.containsMouse && cb.available) ? root.chromeHoverBg : "transparent"
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: cb.label
|
||||
QbzIcon {
|
||||
opacity: cb.available ? 1.0 : 0.32
|
||||
name: cb.name
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.centerIn: cb
|
||||
tintName: cb.active ? "accent" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: cbArea
|
||||
anchors.fill: cb
|
||||
enabled: cb.available
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: cb.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
// KioskShell.slint:204 — the NavRail is a fat touch target.
|
||||
readonly property int navRailHeight: 80
|
||||
readonly property int transportHeight: 64
|
||||
@@ -133,9 +169,40 @@ Rectangle {
|
||||
Rectangle {
|
||||
id: backBar
|
||||
width: shellColumn.width
|
||||
height: 72
|
||||
// 44px — owner feedback 2026-09-07 asked for ~60% of the previous
|
||||
// 72px bar. The four chrome buttons (Back, Forward, Connect,
|
||||
// Cast) share ONE 44x36 / 20px-glyph form (ChromeButton below).
|
||||
height: 44
|
||||
color: theme.surfaceCard
|
||||
|
||||
// Custom-chrome drag surface, the HeaderBar.qml:132-152 pattern
|
||||
// verbatim: declared FIRST so every control above wins
|
||||
// hit-testing; the system move starts only after a real
|
||||
// movement so taps still land; double-click toggles maximize.
|
||||
// Inert under the system title bar (native chrome owns it). A
|
||||
// kiosk on a NUC with a desktop monitor is a windowed app like
|
||||
// any other, and a bar that could not be dragged was reported
|
||||
// as a defect on 2026-09-07.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !QbzShell.systemTitleBar
|
||||
property bool dragStarted: false
|
||||
onPressed: dragStarted = false
|
||||
onPositionChanged: {
|
||||
if (pressed && !dragStarted && root.hostWindow) {
|
||||
dragStarted = true
|
||||
root.hostWindow.startSystemMove()
|
||||
}
|
||||
}
|
||||
onDoubleClicked: {
|
||||
if (root.hostWindow) {
|
||||
root.hostWindow.visibility =
|
||||
root.hostWindow.visibility === Window.Maximized
|
||||
? Window.Windowed : Window.Maximized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The kiosk carries no HeaderBar, so it owns the window chrome
|
||||
// too (KioskShell.slint:240-245): macOS overlay leaves the native
|
||||
// traffic-light inset, Linux frameless draws the controls.
|
||||
@@ -153,7 +220,7 @@ Rectangle {
|
||||
Qt.platform.os !== "osx" && !QbzShell.systemTitleBar
|
||||
|
||||
// padding-left 6 + macInset, padding-right 118 (controls) or 8,
|
||||
// padding-top/bottom 4, spacing 6 -> a 34px content row.
|
||||
// padding-top/bottom 4, spacing 6 -> a 36px content row.
|
||||
// The 118 is not a round number: WindowControls is 34*3 + 2*2 =
|
||||
// 106 wide, pinned 8px from the right edge, leaving 4px of
|
||||
// clearance to the search field.
|
||||
@@ -163,71 +230,28 @@ Rectangle {
|
||||
y: 4
|
||||
width: backBar.width - (6 + backBar.macInset)
|
||||
- (backBar.linChrome ? 118 : 8)
|
||||
- sessionCluster.width - 6
|
||||
height: backBar.height - 8
|
||||
spacing: 6
|
||||
|
||||
// KioskShell.slint:252-269. The disabled state is expressed
|
||||
// THREE times — dimmed, un-hoverable and un-clickable; an
|
||||
// opacity-only port leaves a clickable ghost.
|
||||
Rectangle {
|
||||
// KioskShell.slint:252-269 / 270-287. Disabled = dimmed,
|
||||
// un-hoverable and un-clickable, all three (an opacity-only
|
||||
// port leaves a clickable ghost).
|
||||
ChromeButton {
|
||||
id: backBtn
|
||||
width: 64
|
||||
height: backBarRow.height
|
||||
radius: theme.radiusSm
|
||||
color: (backArea.containsMouse && QbzShell.canBack)
|
||||
? root.chromeHoverBg : "transparent"
|
||||
QbzIcon {
|
||||
// Opacity on the LEAF, not on the button: the
|
||||
// Rectangle resolves to "transparent" in every
|
||||
// disabled frame (hover requires canBack), so the
|
||||
// group node only ever dimmed this glyph — and an
|
||||
// always-on opacity group is its own batch.
|
||||
opacity: QbzShell.canBack ? 1.0 : 0.32
|
||||
name: "chevron-left"
|
||||
width: 22
|
||||
height: 22
|
||||
anchors.centerIn: backBtn
|
||||
tintName: "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: backArea
|
||||
anchors.fill: backBtn
|
||||
enabled: QbzShell.canBack
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.navigateBack()
|
||||
}
|
||||
name: "chevron-left"
|
||||
label: QbzSession.tr("Back", QbzSession.trRev)
|
||||
available: QbzShell.canBack
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzShell.navigateBack()
|
||||
}
|
||||
|
||||
// KioskShell.slint:270-287.
|
||||
Rectangle {
|
||||
ChromeButton {
|
||||
id: fwdBtn
|
||||
width: 64
|
||||
height: backBarRow.height
|
||||
radius: theme.radiusSm
|
||||
color: (fwdArea.containsMouse && QbzShell.canForward)
|
||||
? root.chromeHoverBg : "transparent"
|
||||
QbzIcon {
|
||||
// Opacity on the LEAF, not on the button: the
|
||||
// Rectangle resolves to "transparent" in every
|
||||
// disabled frame (hover requires canForward), so the
|
||||
// group node only ever dimmed this glyph — and an
|
||||
// always-on opacity group is its own batch.
|
||||
opacity: QbzShell.canForward ? 1.0 : 0.32
|
||||
name: "chevron-right"
|
||||
width: 22
|
||||
height: 22
|
||||
anchors.centerIn: fwdBtn
|
||||
tintName: "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: fwdArea
|
||||
anchors.fill: fwdBtn
|
||||
enabled: QbzShell.canForward
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.navigateForward()
|
||||
}
|
||||
name: "chevron-right"
|
||||
label: QbzSession.tr("Forward", QbzSession.trRev)
|
||||
available: QbzShell.canForward
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzShell.navigateForward()
|
||||
}
|
||||
|
||||
// KioskShell.slint:288-312. Submit-only: there is NO live /
|
||||
@@ -256,6 +280,77 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Session cluster — Qobuz Connect + Cast, always visible, right
|
||||
// of the search field and before the window controls. Owner
|
||||
// feedback 2026-09-07: both are fundamental on a kiosk (Connect
|
||||
// especially — the panel is a renderer other devices drive), so
|
||||
// they left the hidden "more" menu of the transport bar for a
|
||||
// permanent home in the chrome. A 1px pipe separates the
|
||||
// cluster from its neighbours on either side. Connect is the
|
||||
// PlayerBar.qml:537-575 golden button at touch size; Cast is the
|
||||
// shared icon button lit while a renderer is connected.
|
||||
Row {
|
||||
id: sessionCluster
|
||||
x: backBar.width - (backBar.linChrome ? 118 : 8) - width
|
||||
y: 4
|
||||
height: backBar.height - 8
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
width: 1
|
||||
height: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: kioskQconnectBtn
|
||||
readonly property bool qcActive: QbzQConnect.qconnectConnected
|
||||
readonly property color gold: "#e0b341"
|
||||
width: 44
|
||||
height: 36
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: theme.radiusSm
|
||||
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
|
||||
: (kioskQcArea.containsMouse ? root.chromeHoverBg : "transparent")
|
||||
border.width: qcActive ? 1 : 0
|
||||
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: QbzSession.tr("Qobuz Connect", QbzSession.trRev)
|
||||
QbzIcon {
|
||||
name: "monitor-speaker"
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.centerIn: parent
|
||||
tintName: kioskQconnectBtn.qcActive ? "amber"
|
||||
: kioskQcArea.containsMouse ? "textPrimary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: kioskQcArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: connectFlyout.openBelowRight(kioskQconnectBtn)
|
||||
}
|
||||
}
|
||||
|
||||
ChromeButton {
|
||||
name: "cast"
|
||||
label: QbzSession.tr("Cast", QbzSession.trRev)
|
||||
active: QbzPlayer.npCastActive
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzCast.openPicker()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: backBar.linChrome
|
||||
width: 1
|
||||
height: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// Linux custom window controls at the right edge
|
||||
// (KioskShell.slint:315-318). Inlined rather than extracted from
|
||||
// HeaderBar.qml:923-999: a shared component would need a new file
|
||||
@@ -564,6 +659,12 @@ Rectangle {
|
||||
// compact player bar used by kiosk mode.
|
||||
QconnectPlaybackConflictModal { }
|
||||
|
||||
// The Connect flyout and the Cast picker are shell-level: their triggers
|
||||
// live in the back bar above, which is mounted on every route (the
|
||||
// transport bar that used to host them is unmounted on Now Playing).
|
||||
QconnectFlyout { id: connectFlyout }
|
||||
CastPicker { }
|
||||
|
||||
// =====================================================================
|
||||
// 4. The kiosk's OWN Immersive mount (KioskShell.slint:737-747)
|
||||
// =====================================================================
|
||||
|
||||
@@ -48,6 +48,10 @@ Item {
|
||||
property int dimmingMode: 2
|
||||
property bool autoFollow: true
|
||||
property bool uppercase: false
|
||||
/// Opt-in centered lines (kiosk Now Playing). Desktop leaves it false, so
|
||||
/// its left-aligned lyrics are unchanged. LyricsLineRow already implements
|
||||
/// the centered layout AND the centered word-sync highlight offset.
|
||||
property bool centered: false
|
||||
// 0 System · 1 LINE Seed JP · 2 Montserrat · 3 Noto Sans · 4 Source Sans 3
|
||||
property int fontIndex: 0
|
||||
property color activeColor: theme.accent
|
||||
@@ -199,6 +203,7 @@ Item {
|
||||
inactiveColor: view.inactiveColor
|
||||
liteFill: view.liteFill
|
||||
showTranslation: view.showTranslation
|
||||
centered: view.centered
|
||||
}
|
||||
|
||||
// Click-to-seek on stamped lines. NOT in the Slint
|
||||
|
||||
@@ -94,6 +94,13 @@ Popup {
|
||||
_place(g.x, g.y, sourceItem.Window.window)
|
||||
}
|
||||
|
||||
// Top-bar placement (the kiosk back bar): below the trigger, right edges
|
||||
// aligned, then the same 8px window clamp.
|
||||
function openBelowRight(sourceItem) {
|
||||
var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4)
|
||||
_place(g.x, g.y, sourceItem.Window.window)
|
||||
}
|
||||
|
||||
// The Slint chrome: surface-main, Radius.sm, 1px border-muted.
|
||||
background: Rectangle {
|
||||
color: theme.surfaceMain
|
||||
|
||||
@@ -251,6 +251,25 @@ Row {
|
||||
font.pixelSize: theme.fontBody
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// Kiosk-only album quality badge (owner feedback 2026-09-07: on the
|
||||
// panel the quality was unknown until a track played). The album row
|
||||
// carries the same tier/detail the cards badge; Rust's "max"/"lossy"
|
||||
// spellings fold into the four names the badge draws, as
|
||||
// controls/QualityBadge.qml does. Desktop keeps its layout untouched.
|
||||
Item {
|
||||
visible: root.kioskHost && kioskQuality.tier !== ""
|
||||
width: parent.width
|
||||
height: visible ? kioskQuality.implicitHeight + 8 : 0
|
||||
QualityBadgeFull {
|
||||
id: kioskQuality
|
||||
readonly property string rawTier: root.album ? (root.album.qualityTier || "") : ""
|
||||
tier: rawTier === "max" ? "hires" : rawTier === "lossy" ? "mp3" : rawTier
|
||||
detail: root.album ? (root.album.qualityDetail || "") : ""
|
||||
showIcon: true
|
||||
scaleFactor: 1.15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: root.compact ? 10 : 20 }
|
||||
|
||||
// ---- Local action row ----
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user