Files
qbz/crates/qbz-qt/qml/views/PlaylistBrowseView.qml
T
vicrodh b1dba198c9 feat(qt): kiosk profile release hardening
Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus
22 shared opt-in hosts, shared navigation history with Back/Forward, Albums
as the default library tab, native paged Local Library (artists/tracks),
lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and
artwork sized to the physical frame, static skeletons, 64px transport bar,
Settings touch reflow and a persistent Immersive exit.

Desktop keeps its prior behavior: every shared component defaults to the
desktop path and only opts into Kiosk behavior through explicit properties
(origin-relative ScrollMemory, viewport-bounded artwork requests).

Regression coverage: kiosk navigation and artwork Node tests added to the
runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n
strings for all eight locales.

Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and
1280x800 and a Desktop A/B with seven pixel-identical captures.

cc
2026-09-07 08:23:11 -06:00

611 lines
27 KiB
QML

// Qobuz Playlists "View all" page — QML port of
// discover/PlaylistBrowseView.slint: the playlist twin of DiscoverBrowseView
// (same fixed 56px header with the shared browse tools, genre overlay,
// explicit pagination and scrollbar), plus a single-select category tag bar
// under the header.
//
// ADR-008: the categories are radio DOTS, never pills (FilterRadio,
// PlaylistBrowseView.slint:24-68 — 22px tall, 14px ring / 1.5px border, 8px
// accent dot, 12px label that goes text-primary on select OR hover).
//
// Filtering, 1:1 with the .slint: the tag and the shared genre selection are
// SERVER-side (re-fetch from offset 0); the search box filters the loaded set
// client-side and disables load-more while active.
//
// Layout notes:
// :80 chrome above the list = 56px, or 92px when the tag set is non-empty
// :155 the 36px tag bar mounts ONLY when there are tags
// :262 grid 200x246, gap 24; :281 list rows, spacing 2
// :336 the genre popup anchors at y=56 EVEN with the 92px header — the
// .slint overlaps the tag bar and this reproduces it rather than
// inventing a different anchor
//
// The .slint declares only `media-action` (no open-album), and AppShell
// forwards only that; the same surface is kept here — playlist rows and
// cards route through QbzBridge.openPlaylist / QbzPlayer.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../theme"
import "../assets/kiosk-art.js" as ArtPolicy
Rectangle {
id: root
property bool kioskHost: false
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.playlistBrowseJson)
} catch (e) {
return {}
}
}
readonly property var items: doc.items || []
readonly property var tags: doc.tags || []
readonly property string selectedTag: doc.selectedTag || ""
readonly property string query: doc.query || ""
readonly property string viewMode: root.kioskHost ? "list" : (doc.viewMode || "grid")
readonly property int headerH: root.kioskHost ? (tags.length > 0 ? 108 : 64) : (tags.length > 0 ? 92 : 56)
// The playlist collection is custom (mixed own-art / collage cards), so
// it owns the same append-only fade AlbumCollection provides to album
// pages. The ids present when Load more is clicked remain opaque; only
// rows from the page that lands ride `_tailReveal`.
property var _tailSeen: null
property real _tailReveal: 1.0
function clearTailFade() {
tailFade.stop()
root._tailSeen = null
root._tailReveal = 1.0
}
function armTailFade() {
var seen = {}
for (var i = 0; i < root.items.length; i++) {
var id = root.items[i] ? (root.items[i].id || "") : ""
if (id !== "") seen[id] = true
}
root._tailSeen = seen
root._tailReveal = 0.0
}
function tailOpacity(item) {
if (!root._tailSeen || !item)
return 1.0
var id = item.id || ""
return id !== "" && root._tailSeen[id] !== true
? root._tailReveal : 1.0
}
onItemsChanged: {
if (!root._tailSeen)
return
var hasNew = false
for (var i = 0; i < root.items.length; i++) {
var id = root.items[i] ? (root.items[i].id || "") : ""
if (id !== "" && root._tailSeen[id] !== true) {
hasNew = true
break
}
}
if (hasNew) tailFade.restart()
else root.clearTailFade()
}
onSelectedTagChanged: root.clearTailFade()
onQueryChanged: root.clearTailFade()
NumberAnimation {
id: tailFade
target: root
property: "_tailReveal"
from: 0.0
to: 1.0
duration: 220
easing.type: Easing.OutCubic
onFinished: root.clearTailFade()
}
readonly property string genreSig: {
try {
var d = JSON.parse(QbzBridge.genreFilterJson)
return JSON.stringify((d.names || {})["discover"] || [])
} catch (e) {
return ""
}
}
property string lastGenreSig: ""
onGenreSigChanged: {
if (root.lastGenreSig !== "" && root.lastGenreSig !== root.genreSig) {
root.clearTailFade()
QbzHome.playlistBrowseGenreChanged()
}
root.lastGenreSig = root.genreSig
}
Component.onCompleted: root.lastGenreSig = root.genreSig
// Single-select category option (FilterRadio).
component FilterRadio: Item {
id: fr
property string label: ""
property bool selected: false
signal clicked()
width: frRow.width
height: root.kioskHost ? 44 : 22
Row {
id: frRow
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Rectangle {
width: 14
height: 14
radius: 7
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
border.width: 1.5
border.color: fr.selected ? theme.accent : theme.textMuted
Rectangle {
width: 8
height: 8
radius: 4
anchors.centerIn: parent
color: fr.selected ? theme.accent : "transparent"
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: fr.label
color: (fr.selected || frArea.containsMouse)
? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
}
}
MouseArea {
id: frArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: fr.clicked()
}
}
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
Column {
anchors.fill: parent
spacing: 0
visible: !QbzSession.offline
// --- Fixed 56px header -------------------------------------------
Item {
width: parent.width
height: root.kioskHost ? 64 : 56
Rectangle {
anchors.fill: parent
// Rounded at the TOP because this bar is full-bleed at y=0 of
// the content pane, and under the dynamic background the pane's
// own rounding cannot reach it: Qt's `clip` is a rectangular
// scissor that ignores `radius`, and AppShell hides its bezel
// nubs while the field is meant to show through the corners.
// So a full-bleed pane child rounds ITSELF — AppShell.qml says
// exactly this ("there is no mask that can do it for it here")
// and this bar was the counterexample the owner spotted in
// Discover. Invisible with the background off: the view root
// paints the same colour underneath.
topLeftRadius: theme.radiusMd
topRightRadius: theme.radiusMd
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Text {
x: 48
y: (root.kioskHost ? 32 : 25) - height / 2
width: Math.max(0, plTools.x - 48 - 16)
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Row {
id: plTools
x: parent.width - width - 32
y: (root.kioskHost ? 32 : 25) - height / 2
spacing: 8
QbzLineEdit {
kioskHost: root.kioskHost
searchMode: true
width: 200
placeholder: QbzSession.tr("Search…", QbzSession.trRev)
text: root.query
onEdited: function (v) { QbzHome.playlistBrowseSearch(v) }
}
BrowseGenreButton {
btnHeight: root.kioskHost ? 44 : 34
context: "discover"
onClicked: genrePopup.toggle()
}
ViewModeToggle {
visible: !root.kioskHost
mode: root.viewMode
onSetMode: function (m) { QbzHome.playlistBrowseSetViewMode(m) }
}
}
}
// --- Category tag bar (only with tags) ---------------------------
Item {
visible: root.tags.length > 0
width: parent.width
height: visible ? (root.kioskHost ? 44 : 36) : 0
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Flickable {
x: 32
width: parent.width - 64
height: parent.height
contentWidth: tagRow.width
contentHeight: height
clip: true
boundsBehavior: Flickable.StopAtBounds
Row {
id: tagRow
y: root.kioskHost ? 0 : 7
spacing: 16
FilterRadio {
label: QbzSession.tr("All", QbzSession.trRev)
selected: root.selectedTag === ""
onClicked: QbzHome.playlistBrowseSelectTag("")
}
Repeater {
model: root.tags
delegate: FilterRadio {
required property var modelData
// Tag names arrive pre-localized from the API.
label: modelData.name
selected: modelData.selected === true
onClicked: QbzHome.playlistBrowseSelectTag(modelData.slug)
}
}
}
}
}
// --- Scrolling list ----------------------------------------------
Item {
width: parent.width
height: parent.height - root.headerH
Flickable {
id: flick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 8
bottomPadding: 100
spacing: 0
QbzSpinner {
visible: QbzHome.playlistBrowseLoading
size: 36
anchors.horizontalCenter: parent.horizontalCenter
}
// Two empty states, split on whether a search is active.
Text {
visible: !QbzHome.playlistBrowseLoading
&& root.items.length === 0 && root.query !== ""
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No results found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
Text {
visible: !QbzHome.playlistBrowseLoading
&& root.items.length === 0 && root.query === ""
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No playlists match the selected categories.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
// --- Virtualized collection ----------------------------
Item {
id: plGrid
// --- Windowing + windowed artwork -----------------
//
// This grid used to mount EVERY card, and a View-all of
// playlists runs to hundreds — each one a PlaylistCard
// carrying a four-tile collage. AlbumCollection had
// solved this for the album pages and this one never
// got it.
//
// Scroll events run a constant-time coverage guard.
// The slice itself changes only after roughly one
// viewport of travel, but it changes synchronously:
// a fast wheel/scrollbar jump can never outrun the old
// slice and paint a completely blank viewport while a
// delayed timer catches up.
//
// The FULL footprint stays on this one Item, while the
// Repeater model is only the sampled slice. This avoids
// both the old card cost and one Loader shell per
// off-screen result. The list arm uses the same band.
property var band: ({first: 0, last: 0})
readonly property int bandFirst: band.first
readonly property int bandLast: band.last
function sampleBand() {
if (plGrid.columns <= 0 || !plGrid.visible)
return
var listMode = root.viewMode === "list"
var pitch = listMode ? plGrid.listH + plGrid.listGap
: plGrid.cardH + plGrid.gap
var top = flick.contentY - plGrid.y
var h = flick.height
plGrid.band = {first: Math.max(0, Math.floor((top - (root.kioskHost ? pitch : 2 * h)) / pitch)),
last: Math.max(0, Math.ceil((top + (root.kioskHost ? h : 3 * h)) / pitch))}
}
function refreshBand() {
plGrid.sampleBand()
plGrid.reportArtWindow()
}
function ensureBandCoverage() {
if (root.kioskHost) { plGrid.refreshBand(); return }
if (!plGrid.visible || plGrid.columns <= 0)
return
var listMode = root.viewMode === "list"
var pitch = listMode ? plGrid.listH + plGrid.listGap
: plGrid.cardH + plGrid.gap
var top = flick.contentY - plGrid.y
var h = Math.max(1, flick.height)
var totalRows = listMode ? root.items.length
: Math.ceil(root.items.length / plGrid.columns)
if (totalRows <= 0 || top + h <= 0
|| top >= totalRows * pitch)
return
var visibleFirst = Math.max(0, Math.floor(top / pitch))
var visibleLast = Math.max(0, Math.ceil((top + h) / pitch))
var innerRunway = root.kioskHost ? 1 : Math.max(1, Math.ceil(h / pitch))
if (visibleFirst < plGrid.bandFirst
|| visibleLast > plGrid.bandLast
|| (visibleFirst > innerRunway
&& visibleFirst - plGrid.bandFirst < innerRunway)
|| (visibleLast < totalRows - innerRunway
&& plGrid.bandLast - visibleLast < innerRunway))
plGrid.refreshBand()
}
// Covers, one key at a time and only near the viewport
// — the Library > All shape. Rust used to download
// every missing cover of the page in one batch and then
// republish the whole document to attach the paths, so
// nothing had artwork until everything did and the
// republish rebuilt every delegate on arrival.
property var artMap: ({})
readonly property var _artAsked: ({ seen: ({}) })
function artKey(m) {
var u = m.artUrl || ""
return root.kioskHost ? ArtPolicy.sizedUrl(u, Math.ceil(44 * Screen.devicePixelRatio)) : u
}
function artOf(m) {
if (!m)
return ""
var u = plGrid.artKey(m)
if (u !== "" && plGrid.artMap[u])
return plGrid.artMap[u]
return m.artPath || ""
}
function reportArtWindow() {
if (plGrid.columns <= 0 || !plGrid.visible)
return
var cols = root.viewMode === "list" ? 1 : plGrid.columns
var lo = Math.max(0, plGrid.bandFirst * cols)
var hi = Math.min(root.items.length - 1,
(plGrid.bandLast + 1) * cols - 1)
var pending = []
var asked = plGrid._artAsked.seen
for (var i = lo; i <= hi; i++) {
var it = root.items[i]
if (!it)
continue
var u = plGrid.artKey(it)
if (u === "" || (it.artPath || "") !== ""
|| plGrid.artMap[u] || asked[u] === true)
continue
asked[u] = true
pending.push(u)
}
if (pending.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
}
Connections {
target: flick
function onContentYChanged() { plGrid.ensureBandCoverage() }
function onHeightChanged() { plGrid.refreshBand() }
}
Connections {
target: root
function onItemsChanged() { plGrid.refreshBand() }
function onViewModeChanged() { plGrid.refreshBand() }
}
Component.onCompleted: {
plGrid.refreshBand()
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
if (plGrid.artMap[key] === path
|| plGrid._artAsked.seen[key] !== true)
return
var m = plGrid.artMap
m[key] = path
plGrid.artMap = Object.assign({}, m)
}
}
visible: !QbzHome.playlistBrowseLoading
width: parent.width - 64
readonly property int cardW: 200
readonly property int cardH: 246
readonly property int gap: 24
readonly property int listH: root.kioskHost ? 64 : 60
readonly property int listGap: 2
readonly property int columns: Math.max(
1, Math.floor((width + gap) / (cardW + gap)))
readonly property int rows: Math.ceil(root.items.length / columns)
readonly property int gridFrom: Math.min(root.items.length,
Math.max(0, bandFirst * columns))
readonly property int gridTo: Math.min(root.items.length,
Math.max(gridFrom, (bandLast + 1) * columns))
readonly property int listFrom: Math.min(root.items.length,
Math.max(0, bandFirst))
readonly property int listTo: Math.min(root.items.length,
Math.max(listFrom, bandLast + 1))
height: root.viewMode === "list"
? (root.items.length > 0
? root.items.length * listH
+ (root.items.length - 1) * listGap : 0)
: (rows > 0 ? rows * cardH + (rows - 1) * gap : 0)
Repeater {
model: root.viewMode !== "list"
? Math.max(0, plGrid.gridTo - plGrid.gridFrom) : 0
delegate: Item {
id: plCell
required property int index
readonly property int globalIndex: plGrid.gridFrom + index
readonly property var cardData: root.items[globalIndex] || ({})
x: (globalIndex % plGrid.columns) * (plGrid.cardW + plGrid.gap)
y: Math.floor(globalIndex / plGrid.columns) * (plGrid.cardH + plGrid.gap)
width: plGrid.cardW
height: plGrid.cardH
opacity: root.tailOpacity(plCell.cardData)
onCardDataChanged: {
if (!browseCard)
return
browseCard.isPinned = Qt.binding(function () {
return plCell.cardData.isPinned === true
})
}
PlaylistCard {
id: browseCard
// `body-opens: true` in the .slint — the
// card's body click opens the playlist and
// the overlay button plays it.
item: plCell.cardData
artSource: plGrid.artOf(plCell.cardData)
// The row carries the pin state
// (home_qt `map_playlist`, which this page
// reuses); the card defaults to false, so
// without the hand-over the glyph read
// "unpinned" on an already-pinned playlist
// and the first click un-pinned it.
// `artworkUrl` needs no hand-over: the
// card defaults it to `item.artUrl`.
isPinned: plCell.cardData.isPinned === true
}
}
}
Repeater {
model: root.viewMode === "list"
? Math.max(0, Math.min(root.items.length, plGrid.band.last + 1) - Math.min(root.items.length, plGrid.band.first)) : 0
delegate: PlaylistListRow {
kioskHost: root.kioskHost
required property int index
readonly property int globalIndex: plGrid.listFrom + index
readonly property var rowData: root.items[globalIndex] || ({})
x: 0
y: globalIndex * (plGrid.listH + plGrid.listGap)
width: plGrid.width
item: rowData
artSource: plGrid.artOf(rowData)
rowIndex: globalIndex
opacity: root.tailOpacity(rowData)
}
}
}
Item {
visible: !QbzHome.playlistBrowseLoading
&& root.items.length > 0
&& root.doc.hasMore === true
&& root.query === ""
width: parent.width - 64
height: visible ? loadMore.height : 0
QbzLoadMore {
id: loadMore
width: parent.width
buttonHeight: root.kioskHost ? 64 : 32
busy: QbzHome.playlistBrowseLoadingMore
skeleton: root.viewMode === "list" ? "rows" : "cards"
// Playlist grid pitch: 200x246 + 24px gutter.
cellW: 224
cellH: 270
rowH: 60
rowGap: 2
rowCount: 2
rowArtSize: 44
onClicked: {
root.armTailFade()
QbzHome.playlistBrowseLoadMore()
}
}
}
}
}
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: flick; scope: "playlistbrowse" }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
}
GenreFilterPopup {
id: genrePopup
anchors.fill: parent
context: "discover"
anchorTop: 56
anchorRight: 32
}
}