The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.
Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:
1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
table. Slint can derive it because its entries carry the tab
(Favorites { tab }); here an entry is a bare route id, so a derived scope
would read "library" for all five Library tabs and pour the Albums grid's
offset into whatever tab mounted. Letting the reporter name its own scope
buys tab granularity for free, and the mismatch case degrades the right way:
an unmatched scope restores NOTHING instead of restoring the wrong thing.
2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
contentY to 0 whenever its model is replaced, and several pages assign their
model from a binding whose inputs arrive over the network after the mount —
the album page rebuilds listCells from three deferred rails, so it hands its
ListView a new array up to four times in its first seconds and snaps back to
the top over a restore that already ran correctly. The offset is held and
re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
drag or a flick raises `moving`, and from then the position is theirs).
Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.
cc
788 lines
37 KiB
QML
788 lines
37 KiB
QML
// Label landing page — QML port of label/LabelPageView.slint.
|
|
//
|
|
// A circular-portrait header with description + Follow, a JUMP TO strip, a
|
|
// Popular Tracks list with a 5 -> 20 -> 50 reveal, and the Releases /
|
|
// Critics' Picks / Playlists / Artists / More Labels carousels.
|
|
//
|
|
// SCROLLING-PAGE CONVENTION (AlbumView / ArtistView / PlaylistView): the
|
|
// whole page scrolls in one Flickable whose Column pads 32 / 32 / top 11 /
|
|
// bottom 100. The .slint's NavButtons row is dropped (back/forward live in
|
|
// the shell header, HeaderBar.qml:235) but its 22px spacer is KEPT, so the
|
|
// header sits where every other detail page's does.
|
|
//
|
|
// POC-NOTEs, each deliberate:
|
|
// - JUMP TO is NOT sticky. ArtistView.qml:1204 documents the same call for
|
|
// the .slint's sticky clamp; matching it keeps the two detail pages
|
|
// consistent. This is the one accepted visual delta.
|
|
// - NO multi-select — and the ORIGINAL reason is now STALE on both halves:
|
|
// rows/TrackRow.qml:116 DOES have the `selectMode` arm, and add-to-playlist
|
|
// DOES have a bridge seam (QbzPlaylistPicker.openForTracks,
|
|
// src/playlist_picker_bridge.rs). What is still missing is only the
|
|
// selection STATE and the bar itself (LibraryView.qml's `tracksSelected`
|
|
// map is the pattern to copy). Until then the select disc renders DIMMED
|
|
// and inert (the ArtistView "Radio" precedent) and the bar is absent.
|
|
// Per-ROW add-to-playlist and add-to-mixtape are both LIVE on this page
|
|
// already, through the shared TrackRow menu.
|
|
// - NO "In library" tab. The Qt library feed carries no label id, so
|
|
// `libraryCount` is always 0 and the SegmentedTabBar never mounts (see
|
|
// src/label_qt.rs). A toggle that switches to an empty tab is worse.
|
|
// - Share copies the Qobuz link with no toast: there is no Rust clipboard
|
|
// bridge and no toast seam (TrackRow.qml:538-558 documents both).
|
|
// - No scroll restore (the port has no NavState.restore-scope counterpart).
|
|
//
|
|
// LOGO FIT — a CONSCIOUS deviation: both .slint label views draw the logo
|
|
// `image-fit: cover` (LabelPageView.slint:116). This port draws it CONTAIN,
|
|
// the way qml/cards/LabelCard.qml already does, because label logos are
|
|
// wordmarks on transparent backgrounds and cropping one to a circle cuts the
|
|
// name off. The Slint behaviour is cover; this is not it, on purpose.
|
|
|
|
import QtQuick
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../cards"
|
|
import "../controls"
|
|
import "../rows"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
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.labelJson)
|
|
} catch (e) {
|
|
return {}
|
|
}
|
|
}
|
|
readonly property var topTracks: doc.topTracks || []
|
|
readonly property var releases: doc.releases || []
|
|
readonly property var critics: doc.critics || []
|
|
readonly property var playlists: doc.playlists || []
|
|
readonly property var artists: doc.artists || []
|
|
readonly property var moreLabels: doc.moreLabels || []
|
|
readonly property var jumpTabs: doc.jumpTabs || []
|
|
|
|
property string activeJumpTab: "about"
|
|
/// Popular Tracks progressive reveal: 5 -> 20 -> 50 (the Tauri steps).
|
|
property int previewCount: 5
|
|
property string loadedLabelId: ""
|
|
|
|
// Reset the per-label view state when the id actually changes (the
|
|
// document is republished on the artwork pass too — ArtistView's
|
|
// syncArtistState rule).
|
|
onDocChanged: {
|
|
var id = doc.id || ""
|
|
if (id === root.loadedLabelId)
|
|
return
|
|
root.loadedLabelId = id
|
|
root.previewCount = 5
|
|
root.activeJumpTab = "about"
|
|
root.setMultiSelect(false)
|
|
flick.contentY = 0
|
|
}
|
|
|
|
// --- Multi-select (Popular Tracks) -------------------------------------
|
|
// Same shape as AlbumView/PlaylistView: selection in QML, select-all /
|
|
// clear local, the rest via QbzPlayer.bulkTracksAction.
|
|
property bool multiSelect: false
|
|
property var selected: ({})
|
|
readonly property int selectedCount: Object.keys(root.selected).length
|
|
readonly property bool multiSelectOn: root.multiSelect
|
|
function setMultiSelect(on) {
|
|
root.multiSelect = on
|
|
if (!on) root.selected = ({})
|
|
}
|
|
function toggleSelected(id) {
|
|
var m = root.selected
|
|
if (m[id] === true) delete m[id]
|
|
else m[id] = true
|
|
// A rebind needs a NEW object reference — mutating in place notifies
|
|
// nothing.
|
|
root.selected = Object.assign({}, m)
|
|
}
|
|
function selectedIdsInOrder() {
|
|
var rows = root.topTracks
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++)
|
|
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function bulkAction(action) {
|
|
if (action === "select-all") {
|
|
var m = {}
|
|
var rows = root.topTracks
|
|
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
|
|
root.selected = m
|
|
return
|
|
}
|
|
if (action === "clear") { root.selected = ({}); return }
|
|
var ids = root.selectedIdsInOrder()
|
|
if (ids.length === 0) return
|
|
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "label", String(doc.id || ""))
|
|
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
|
|
root.selected = ({})
|
|
}
|
|
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
|
|
function selectAll() {
|
|
if (!root.multiSelect) root.setMultiSelect(true)
|
|
root.bulkAction("select-all")
|
|
}
|
|
function exitMultiSelectMode() {
|
|
if (root.multiSelect) root.setMultiSelect(false)
|
|
}
|
|
|
|
function scrollToSection(id) {
|
|
root.activeJumpTab = id
|
|
if (id === "about") {
|
|
flick.contentY = 0
|
|
return
|
|
}
|
|
for (var i = 0; i < catalog.children.length; i++) {
|
|
var c = catalog.children[i]
|
|
if (c.anchorId !== undefined && c.anchorId === id) {
|
|
var p = c.mapToItem(flick.contentItem, 0, 0)
|
|
flick.contentY = Math.max(0, p.y - 48)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Share ------------------------------------------------------------
|
|
// The QML `Loader`/`TextEdit`/`copy()` clipboard hack that used to live
|
|
// here is GONE. Its header said "there is no toast seam, so the copy is
|
|
// silent" — both halves of that are now false (`toast_qt.rs`, and
|
|
// `share_qt.rs` since the artist Share landed), and the hack was not
|
|
// merely silent: it formatted `https://open.qobuz.com/label/{id}`, a host
|
|
// with no `/label/` route, so the link it copied did not resolve at all.
|
|
// The header ⋯ entry calls `QbzHome.labelShare(id)` and Rust owns the URL,
|
|
// the clipboard and the "Link copied" toast — 1:1 with main.rs:13164-13178.
|
|
|
|
// ============================ shared rail =============================
|
|
// ONE horizontal rail for all five carousels; `kind` picks the card, the
|
|
// way the .slint mounts Carousel / PlaylistCarousel / ArtistCarousel.
|
|
component CardRail: Column {
|
|
id: cr
|
|
property string title: ""
|
|
property var items: []
|
|
/// "album" | "playlist" | "artist" | "label"
|
|
property string kind: "album"
|
|
property bool showViewAll: false
|
|
signal viewAllClicked()
|
|
|
|
width: parent ? parent.width : 0
|
|
spacing: 12
|
|
|
|
readonly property int perPage: Math.max(1, Math.floor((crList.width + 32) / 232))
|
|
readonly property int step: perPage * 232
|
|
readonly property real maxScroll: Math.max(0, crList.contentWidth - crList.width)
|
|
|
|
QbzSectionHeader {
|
|
title: cr.title
|
|
leftEnabled: crList.contentX > 1
|
|
rightEnabled: crList.contentX < cr.maxScroll - 1
|
|
showViewAll: cr.showViewAll
|
|
onViewAllClicked: cr.viewAllClicked()
|
|
onPageLeft: crList.contentX = Math.max(0, crList.contentX - cr.step)
|
|
onPageRight: crList.contentX = Math.min(cr.maxScroll, crList.contentX + cr.step)
|
|
}
|
|
Item {
|
|
width: parent.width
|
|
height: 246
|
|
ListView {
|
|
id: crList
|
|
anchors.fill: parent
|
|
orientation: ListView.Horizontal
|
|
spacing: 32
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: cr.items
|
|
delegate: Item {
|
|
id: crCell
|
|
required property var modelData
|
|
width: 200
|
|
height: 246
|
|
|
|
Component {
|
|
id: crAlbum
|
|
AlbumCard {
|
|
albumId: crCell.modelData.id
|
|
title: crCell.modelData.title
|
|
artist: crCell.modelData.artist
|
|
artistId: crCell.modelData.artistId
|
|
genre: crCell.modelData.genre
|
|
year: crCell.modelData.year
|
|
qualityTier: crCell.modelData.qualityTier
|
|
ribbon: crCell.modelData.ribbon || ""
|
|
ribbonKind: crCell.modelData.ribbonKind || ""
|
|
artSource: crCell.modelData.artPath || ""
|
|
isPinned: crCell.modelData.isPinned === true
|
|
// The pin payload's display snapshot — the REMOTE
|
|
// url (artPath is the local cache path). Without
|
|
// it a label release pinned from here landed in
|
|
// the Home Pinned rail as a grey placeholder.
|
|
artworkUrl: crCell.modelData.artUrl || ""
|
|
// Stamped on the row by label_qt; false made the
|
|
// glyph lie and inverted the first click.
|
|
isFavorite: crCell.modelData.isFavorite === true
|
|
}
|
|
}
|
|
Component {
|
|
id: crPlaylist
|
|
PlaylistCard {
|
|
// artworkUrl defaults to `item.artUrl` (the card's
|
|
// own arm), so only the pin state is handed over.
|
|
item: crCell.modelData
|
|
artSource: crCell.modelData.artPath || ""
|
|
isPinned: crCell.modelData.isPinned === true
|
|
}
|
|
}
|
|
Component {
|
|
id: crArtist
|
|
ArtistCard {
|
|
item: crCell.modelData
|
|
artSource: crCell.modelData.artPath || ""
|
|
isPinned: crCell.modelData.isPinned === true
|
|
artworkUrl: crCell.modelData.artUrl || ""
|
|
// LabelPageView.slint:524 passes card-follow-mode
|
|
// "none" here — and now that ArtistCard's follow
|
|
// arm is LIVE (its `hasFollowSeam: false` was a
|
|
// factual error: library_toggle_favorite routes
|
|
// "artist"), this line is what actually keeps the
|
|
// chip off this carousel instead of a constant.
|
|
followMode: "none"
|
|
}
|
|
}
|
|
Component {
|
|
id: crLabel
|
|
LabelCard {
|
|
item: crCell.modelData
|
|
artSource: crCell.modelData.artPath || ""
|
|
}
|
|
}
|
|
Loader {
|
|
anchors.fill: parent
|
|
sourceComponent: cr.kind === "playlist" ? crPlaylist
|
|
: cr.kind === "artist" ? crArtist
|
|
: cr.kind === "label" ? crLabel : crAlbum
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================ offline gate ============================
|
|
QbzOfflinePlaceholder {
|
|
visible: QbzSession.offline
|
|
anchors.centerIn: parent
|
|
showSettingsAction: true
|
|
onSettingsClicked: QbzShell.navigateTo("settings")
|
|
}
|
|
|
|
// ============================ the page ================================
|
|
Flickable {
|
|
id: flick
|
|
anchors.fill: parent
|
|
visible: !QbzSession.offline
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: page.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: page
|
|
width: parent.width
|
|
leftPadding: 32
|
|
rightPadding: 32
|
|
topPadding: 11
|
|
bottomPadding: 100
|
|
spacing: 0
|
|
|
|
// The .slint's NavButtons row is not drawn (shell header owns
|
|
// back/forward) but its 22px spacer is kept.
|
|
Item { width: 1; height: 22 }
|
|
|
|
// --- Header ---------------------------------------------------
|
|
Row {
|
|
width: parent.width - 64
|
|
spacing: 32
|
|
|
|
Rectangle {
|
|
width: 180
|
|
height: 180
|
|
radius: 90
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
|
|
QbzIcon {
|
|
visible: (root.doc.imageUrl || "") === ""
|
|
name: "disc-3"
|
|
width: 60
|
|
height: 60
|
|
anchors.centerIn: parent
|
|
tintName: "muted"
|
|
}
|
|
// Contain-fit inside the square inscribed in the circle
|
|
// (180 / sqrt(2) ~= 127 -> margin 26) — see the header
|
|
// note on the logo-fit deviation.
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
anchors.margins: 26
|
|
source: root.doc.artPath || ""
|
|
radius: 0
|
|
fit: "contain"
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 180 - 32
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 8
|
|
spacing: 0
|
|
|
|
Text {
|
|
text: QbzSession.tr("LABEL", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.5
|
|
}
|
|
Item { width: 1; height: 6 }
|
|
Text {
|
|
width: parent.width
|
|
text: root.doc.name || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Item { visible: (root.doc.description || "") !== ""; width: 1; height: 12 }
|
|
Text {
|
|
visible: (root.doc.description || "") !== ""
|
|
// .slint: `width: root.width - 296px`.
|
|
width: Math.max(0, root.width - 296)
|
|
text: root.doc.descriptionShort || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Item { visible: root.doc.descriptionTruncated === true; width: 1; height: 4 }
|
|
Text {
|
|
visible: root.doc.descriptionTruncated === true
|
|
text: QbzSession.tr("Read more", QbzSession.trRev)
|
|
color: readMoreArea.containsMouse ? theme.accentHover : theme.accent
|
|
font.pixelSize: theme.fontLegal
|
|
MouseArea {
|
|
id: readMoreArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
var shell = root.parent
|
|
while (shell && shell.openTextModal === undefined)
|
|
shell = shell.parent
|
|
if (!shell) return
|
|
// The .slint modal heading is @tr("About"),
|
|
// not the label name.
|
|
shell.openTextModal(
|
|
QbzSession.tr("About", QbzSession.trRev),
|
|
root.doc.description || "")
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 18 }
|
|
|
|
// Action row — NO Play button (owner call, .slint:171-179:
|
|
// Popular Tracks already carries one and it does the same).
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
QbzCircleAction {
|
|
id: followBtn
|
|
readonly property bool following: root.doc.isFollowing === true
|
|
name: following ? "check" : "user-plus"
|
|
active: following
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzHome.labelFollow()
|
|
}
|
|
QbzCircleAction {
|
|
name: "shuffle"
|
|
btnEnabled: root.topTracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzHome.labelShuffle()
|
|
}
|
|
QbzCircleAction {
|
|
id: headerMoreBtn
|
|
name: "ellipsis"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: function (mouse) {
|
|
headerMenu.openAtCursor(headerMoreBtn, mouse.x, mouse.y)
|
|
}
|
|
}
|
|
// Clamped stretch (ArtistView.qml:1161 — an unclamped
|
|
// negative width silently reflows the whole row).
|
|
Item {
|
|
width: Math.max(0, parent.width - 3 * 32 - 3 * 12)
|
|
height: 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CardMenu {
|
|
id: headerMenu
|
|
menuWidth: 224
|
|
entries: [
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next",
|
|
"enabled": root.topTracks.length > 0 },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later",
|
|
"enabled": root.topTracks.length > 0 },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue",
|
|
"enabled": root.topTracks.length > 0 },
|
|
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "share" },
|
|
]
|
|
onPicked: function (a) {
|
|
// Share goes through Rust (share_qt::share_label): it owns
|
|
// the long-lived clipboard AND the "Link copied" toast the
|
|
// reference raises (main.rs:13164-13178). The URL is built
|
|
// there too — this call site used to format it here and
|
|
// got the HOST wrong (see the header note above).
|
|
if (a === "share")
|
|
QbzHome.labelShare(root.doc.id || "")
|
|
else
|
|
QbzHome.labelTopAction(a)
|
|
}
|
|
}
|
|
|
|
// --- JUMP TO ---------------------------------------------------
|
|
// The .slint reserves 52px here for its sticky overlay; this port
|
|
// puts the bar itself in the flow (see the header POC-NOTE).
|
|
Item { visible: root.jumpTabs.length > 1; width: 1; height: visible ? 8 : 0 }
|
|
QbzJumpNavBar {
|
|
visible: root.jumpTabs.length > 1
|
|
width: parent.width - 64
|
|
padH: 0
|
|
showSearch: false
|
|
// surface-main @ bar-alpha (0.3), LabelPageView.slint:593 —
|
|
// see the note on the identical mount in ArtistView.
|
|
barBg: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
|
|
tabs: root.jumpTabs
|
|
activeTabId: root.activeJumpTab
|
|
onTabClicked: function (id) { root.scrollToSection(id) }
|
|
}
|
|
|
|
// --- Catalog ---------------------------------------------------
|
|
Column {
|
|
id: catalog
|
|
width: parent.width - 64
|
|
spacing: 0
|
|
|
|
// First-paint block.
|
|
Item {
|
|
visible: QbzHome.labelLoading && root.topTracks.length === 0
|
|
width: parent.width
|
|
height: visible ? 280 : 0
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: 18
|
|
QbzSpinner {
|
|
size: 36
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
}
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: QbzSession.tr("Loading…", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multi-select bulk bar — INLINE above the Popular Tracks
|
|
// header (bar in flow, content below).
|
|
QbzMultiSelectBar {
|
|
visible: root.multiSelect
|
|
width: parent.width
|
|
selectedCount: root.selectedCount
|
|
actions: [
|
|
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
|
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
|
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
|
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
|
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
|
]
|
|
onAction: function (id) { root.bulkAction(id) }
|
|
}
|
|
|
|
// --- Popular Tracks -------------------------------------
|
|
// The WHOLE block is gated on a non-empty list (.slint:313).
|
|
Column {
|
|
property string anchorId: "popular"
|
|
visible: root.topTracks.length > 0
|
|
width: parent.width
|
|
spacing: 0
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
Text {
|
|
width: Math.max(0, parent.width - 3 * 12 - 44 - 32 - 32)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Popular Tracks", QbzSession.trRev)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
QbzCircleAction {
|
|
primary: true
|
|
name: "play-fill"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzHome.labelPlayTop()
|
|
}
|
|
// Multi-select toggle — live since the bulk-bar port.
|
|
QbzCircleAction {
|
|
name: "square-check-big"
|
|
active: root.multiSelect
|
|
btnEnabled: root.topTracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: root.setMultiSelect(!root.multiSelect)
|
|
}
|
|
QbzCircleAction {
|
|
id: topMoreBtn
|
|
name: "ellipsis"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: function (mouse) {
|
|
topMenu.openAtCursor(topMoreBtn, mouse.x, mouse.y)
|
|
}
|
|
}
|
|
}
|
|
CardMenu {
|
|
id: topMenu
|
|
menuWidth: 224
|
|
entries: [
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
]
|
|
onPicked: function (a) { QbzHome.labelTopAction(a) }
|
|
}
|
|
|
|
Item { width: 1; height: 10 }
|
|
|
|
Repeater {
|
|
model: root.topTracks
|
|
delegate: TrackRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
visible: index < root.previewCount
|
|
height: visible ? 50 : 0
|
|
// SMOOTH REVEAL (owner, 2026-08-02: "que la
|
|
// aparicion de lo que se cargue, sea smooth").
|
|
// This site is CLIENT-SIDE: every row of
|
|
// topTracks is already instantiated by the
|
|
// Repeater and only gated on `previewCount`
|
|
// (LabelPageView.slint:426, which snaps), so the
|
|
// fade goes on the rows themselves — there is no
|
|
// appended tail and therefore no `fadeFrom`
|
|
// threshold to keep.
|
|
//
|
|
// It is safe to fade UNCONDITIONALLY here, which
|
|
// is NOT true of the network-backed Load-more
|
|
// sites: a QML Behavior does not run while its
|
|
// object is being created, so when the label
|
|
// document is republished (the artwork pass —
|
|
// see onDocChanged above) and the Repeater
|
|
// rebuilds every delegate, each one takes its
|
|
// opacity as a plain initial value and NOTHING
|
|
// re-fades. The animation only ever runs when
|
|
// `previewCount` moves on delegates that already
|
|
// exist, i.e. exactly on the reveal.
|
|
//
|
|
// 220ms / OutCubic = the content duration this
|
|
// round standardises on (controls/QbzLoadMore
|
|
// .qml uses 180ms for its skeleton).
|
|
// The COLLAPSE back to 5 stays instant on
|
|
// purpose: `visible` is left snapping, so the
|
|
// rows leave the layout in the same frame. Held
|
|
// open for a 220ms fade-out instead, the page
|
|
// would show ~2200px of blank list and then jump
|
|
// — worse than the snap, not better.
|
|
opacity: index < root.previewCount ? 1.0 : 0.0
|
|
Behavior on opacity {
|
|
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
|
|
}
|
|
item: modelData
|
|
number: index + 1
|
|
showArtwork: true
|
|
showAlbum: true
|
|
selectMode: root.multiSelect
|
|
checked: root.selected[item.id] === true
|
|
onToggleSelect: root.toggleSelected(item.id)
|
|
onPlayRequested: QbzHome.labelPlayTrack(modelData.id)
|
|
onEnqueueRequested: function (mode) {
|
|
QbzHome.labelEnqueueTrack(modelData.id, mode)
|
|
}
|
|
// MyQBZ "Add to mixtape" — the HOST builds the
|
|
// AddItem array (TrackRow does not know
|
|
// itemType/source).
|
|
//
|
|
// SOURCE: a label page is a Qobuz catalog surface.
|
|
// These rows are the `/label/page` response's
|
|
// `top_tracks` mapped to track rows
|
|
// (label_qt.rs:280-287) — there is no local label
|
|
// library and no non-Qobuz row can reach this
|
|
// Repeater, so `modelData.id` is a Qobuz catalog
|
|
// id by construction of the document.
|
|
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": "qobuz",
|
|
"sourceItemId": modelData.id,
|
|
"title": modelData.title || "",
|
|
"subtitle": modelData.artist || "",
|
|
"artworkUrl": "", "year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
}
|
|
|
|
// Reveal control: 5 -> 20 -> 50, then back to 5
|
|
// (LabelPageView.slint:439-466). This was the fifth
|
|
// hand-rolled copy of the plain Load-more shape; it is
|
|
// now controls/QbzLoadMore.qml, arm (e) in that file's
|
|
// inventory — same 28-tall box, same 13px centred label,
|
|
// same hover textPrimary / textSecondary, same
|
|
// `implicitWidth + 24` hit box. Idle pixels unchanged.
|
|
//
|
|
// CLIENT-SIDE, so no fetch to wait on: `busy` stays
|
|
// false and `skeleton` stays "none", which is what keeps
|
|
// a placeholder from appearing under a button whose
|
|
// rows are revealed in the same frame. The smooth half
|
|
// of the owner's request lives on the TrackRow delegate
|
|
// above, not here (see the SMOOTH REVEAL note there).
|
|
Item { visible: root.topTracks.length > 5; width: 1; height: visible ? 4 : 0 }
|
|
QbzLoadMore {
|
|
visible: root.topTracks.length > 5
|
|
width: parent.width
|
|
buttonHeight: 28
|
|
skeleton: "none"
|
|
busy: false
|
|
// The label ternary is verbatim from the copy this
|
|
// replaces: "Load more" while the reveal can still
|
|
// grow, "View less" once it cannot (both are
|
|
// existing msgids — no new strings).
|
|
label: (root.previewCount < 50
|
|
&& root.topTracks.length > root.previewCount)
|
|
? QbzSession.tr("Load more", QbzSession.trRev)
|
|
: QbzSession.tr("View less", QbzSession.trRev)
|
|
onClicked: {
|
|
if (root.previewCount < 50
|
|
&& root.topTracks.length > root.previewCount)
|
|
root.previewCount = root.previewCount === 5 ? 20 : 50
|
|
else
|
|
root.previewCount = 5
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Releases ---------------------------------------------
|
|
Column {
|
|
property string anchorId: "releases"
|
|
visible: root.releases.length > 0
|
|
width: parent.width
|
|
spacing: 0
|
|
Item { width: 1; height: 32 }
|
|
CardRail {
|
|
title: QbzSession.tr("Releases", QbzSession.trRev)
|
|
items: root.releases
|
|
kind: "album"
|
|
showViewAll: true
|
|
onViewAllClicked: QbzHome.labelOpenReleases()
|
|
}
|
|
}
|
|
|
|
// --- Critics' Picks ---------------------------------------
|
|
Column {
|
|
property string anchorId: "critics"
|
|
visible: root.critics.length > 0
|
|
width: parent.width
|
|
spacing: 0
|
|
Item { width: 1; height: 32 }
|
|
CardRail {
|
|
title: QbzSession.tr("Critics' Picks", QbzSession.trRev)
|
|
items: root.critics
|
|
kind: "album"
|
|
}
|
|
}
|
|
|
|
// --- Playlists ---------------------------------------------
|
|
Column {
|
|
property string anchorId: "playlists"
|
|
visible: root.playlists.length > 0
|
|
width: parent.width
|
|
spacing: 0
|
|
Item { width: 1; height: 32 }
|
|
CardRail {
|
|
title: QbzSession.tr("Playlists", QbzSession.trRev)
|
|
items: root.playlists
|
|
kind: "playlist"
|
|
}
|
|
}
|
|
|
|
// --- Artists ------------------------------------------------
|
|
Column {
|
|
property string anchorId: "artists"
|
|
visible: root.artists.length > 0
|
|
width: parent.width
|
|
spacing: 0
|
|
Item { width: 1; height: 32 }
|
|
CardRail {
|
|
title: QbzSession.tr("Artists", QbzSession.trRev)
|
|
items: root.artists
|
|
kind: "artist"
|
|
}
|
|
}
|
|
|
|
// --- More Labels --------------------------------------------
|
|
// NOTE the msgid: the carousel is "More labels" (lowercase l)
|
|
// and the JUMP TO tab is "More Labels" — two distinct msgids
|
|
// in the .slint, kept distinct here.
|
|
Column {
|
|
property string anchorId: "labels"
|
|
visible: root.moreLabels.length > 0
|
|
width: parent.width
|
|
spacing: 0
|
|
Item { width: 1; height: 32 }
|
|
CardRail {
|
|
title: QbzSession.tr("More labels", QbzSession.trRev)
|
|
items: root.moreLabels
|
|
kind: "label"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: "label" }
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: flick
|
|
}
|
|
}
|