fix(qbz-qt): covers were never actually resized, plus the adversarial parity round

**The scaled-cover fix from the last round produced nothing.** The seam ran —
462 requests in one offscreen smoke — and wrote zero files, so the owner was
right that nothing improved. The artwork cache stores files as `.img`, and
`image::open` picks its decoder from the EXTENSION, so every call failed
silently. Sniffing the content instead yields 101 derivatives on the same run.
The header atmosphere had the identical bug and had therefore never produced an
image either, which is why the flat fallback was all anyone ever saw.

Reported as fixed last round without checking that it ran. Verified by effect
this time, not by reading the diff.

The rest came through an analyse / refute / implement / verify pipeline, and the
adversarial passes earned their keep:

- The sidebar mini rail: icons sat left of centre because their slot kept its
  fixed 24px width while the rail collapsed, so the hit target was rectangular
  and off-axis. The rail narrows, rows are square, and the mini state gains the
  folder flyout and tooltips it never had. The reviewer caught a comment
  claiming the width change was a one-token revert when it is two — corrected
  rather than left to mislead the next reader.
- Playback context now travels from every producer, not the handful that
  happened to stamp it.
- Collapsible per-tab search in Library, and label artwork.
- Every View-all destination Home offers, plus the label page and its releases.
- Collapsing the sidebar drops a Large bar back to New, live, without
  overwriting the user's persisted preference.

cc
This commit is contained in:
vicrodh committed 2026-07-29 00:06:34 -06:00
1 parent 2a5b5ac16e
commit c56fab3c4e
34 files changed
+6234 -201

No files matched your search

+12
View File
@@ -105,6 +105,18 @@ fn main() {
"qml/views/PlaylistView.qml",
"qml/views/SearchView.qml",
"qml/views/SectionRail.qml",
"qml/controls/BrowseGenreButton.qml",
"qml/controls/QbzToggleButton.qml",
"qml/controls/ViewModeToggle.qml",
"qml/views/AlbumCollection.qml",
"qml/views/AlbumListHeader.qml",
"qml/views/AlbumListRow.qml",
"qml/views/DiscoverBrowseView.qml",
"qml/views/LabelReleasesView.qml",
"qml/views/LabelView.qml",
"qml/views/PlayHistoryView.qml",
"qml/views/PlaylistBrowseView.qml",
"qml/views/PlaylistListRow.qml",
"qml/controls/DiscoverConfigModal.qml",
"qml/controls/GenreFilterPopup.qml",
"qml/shell/NavFlyout.qml",
+21 -2
View File
@@ -45,6 +45,12 @@ Rectangle {
property bool isPinned: false
// Source badge (Library show-local): "local" | "plex" | "" (hidden).
property string source: ""
// Local play count — rendered as the muted "{} plays" line under the
// artist, and ONLY there (discover/AlbumCard.slint:508-513). Every
// surface but Most Played Albums publishes 0, which is why that page's
// card is 286px tall and every other one is 266px: this line IS the
// 20px difference.
property int plays: 0
// --- Local Library mode (additive; default = the Qobuz behaviour) ----
// The Local Library mounts THIS card, but its `albumId` is a group key
@@ -69,7 +75,9 @@ Rectangle {
QbzTheme { id: theme }
width: 200
height: 246
// 246 normally; +20 for the "{} plays" line (see `plays`) — the same
// 266 -> 286 step MostPlayedAlbumsView.slint:23 takes for its grid.
height: 246 + (root.plays > 0 ? 20 : 0)
color: "transparent"
readonly property bool overlayOn: artArea.containsMouse || pinArea.containsMouse
@@ -340,7 +348,7 @@ Rectangle {
// --- Title / artist + quality badge ------------------------------
Row {
width: 200
height: 40
height: 40 + (root.plays > 0 ? 20 : 0)
spacing: theme.spacingSm
Column {
width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0)
@@ -390,6 +398,17 @@ Rectangle {
onClicked: if (root.artistId !== "") QbzArtist.openArtist(root.artistId)
}
}
// Play count — Most Played Albums only (see `plays`).
Text {
visible: root.plays > 0
width: parent.width
height: visible ? 18 : 0
text: QbzSession.tr("{} plays", QbzSession.trRev).replace("{}", root.plays)
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// Icon-only quality badge (QualityBadge.slint).
QualityMini {
+16 -10
View File
@@ -17,12 +17,16 @@
// and NO ⋯ menu on a label card, so the overlay row carries exactly one
// disc (open) instead of the artist's follow/play/more triplet.
//
// The label landing page is out of scope — there is no openLabel on the Qt
// bridge. Rather than keep an open affordance that does nothing, the card
// DISABLES it while `hasLabelViewSeam` is false: no overlay disc, no
// pointing-hand cursor, no hover scrim, no accent on the title. The tile
// still renders 1:1; it just does not pretend to be clickable. Flip the
// constant and fill `openLabel()` when the label view lands.
// The label landing page LANDED (qml/views/LabelView.qml +
// src/label_qt.rs), so `hasLabelViewSeam` is now true and every open path
// on this card — body, overlay disc, title — funnels through
// `QbzHome.openLabel(id)`. That also un-deadens the Library > Labels grid,
// which mounts this same card (LibraryView.qml:1191).
//
// The constant STAYS, deliberately: it is the one switch that turns the
// card's whole open affordance (overlay disc, pointing-hand cursor, hover
// scrim, title accent) on and off together, so the card can never drift
// back into looking clickable while doing nothing.
//
// item contract: { id, title, subtitle, imageUrl } plus the
// host-resolved artSource string prop. The artwork is driven by
@@ -50,9 +54,8 @@ Rectangle {
QbzTheme { id: theme }
// No label view on the Qt bridge — see the header. Everything that would
// navigate is gated on this.
readonly property bool hasLabelViewSeam: false
// Everything that navigates is gated on this — see the header.
readonly property bool hasLabelViewSeam: true
// No logo URL -> the indigo/violet glyph arm (the Slint condition).
readonly property bool noLogo: !root.item.imageUrl
@@ -63,7 +66,10 @@ Rectangle {
function openLabel() {
if (!root.hasLabelViewSeam)
return
// GLUE NEEDED: QbzBridge/QbzArtist has no openLabel(labelId).
var id = (root.item && root.item.id) ? String(root.item.id) : ""
if (id === "")
return
QbzHome.openLabel(id)
}
implicitWidth: 200
@@ -0,0 +1,80 @@
// BrowseGenreButton — the "Filter by genre" trigger
// (discover/BrowseHeaderTools.slint:102-145): accent fill + "N genres" while
// the SHARED selection is non-empty, else a surface-elevated pill reading
// "Filter by genre".
//
// Extracted from HomeView.qml's inline copy so the Home toolbar and the two
// browse pages draw the identical control. The one difference the Slint keeps
// is the height: HomeView.slint:85 is 32px, BrowseHeaderTools.slint:108 is
// 34px (the ui-control-alignment-standard toolbar size) — hence `btnHeight`.
//
// THE BADGE READS THE BRIDGE, NOT THE POPUP. `GenreFilterPopup` is declared
// LAST in every host (declaration order is z-order) and a creation-time
// binding that dereferences a not-yet-created id registers NO dependency, so
// it would never re-evaluate. The count is parsed straight off
// `QbzBridge.genreFilterJson` here, per context.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
/// "discover" | "library-all" — whose selection this button reports.
property string context: "discover"
property int btnHeight: 34
signal clicked()
QbzTheme { id: theme }
readonly property var genreDoc: {
try {
return JSON.parse(QbzBridge.genreFilterJson)
} catch (e) {
return {}
}
}
readonly property int count: (genreDoc.counts || {})[root.context] || 0
readonly property bool active: root.count > 0
width: genreRow.width
height: btnHeight
radius: 6
color: root.active ? theme.accent
: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row {
id: genreRow
height: parent.height
leftPadding: 12
rightPadding: 14
spacing: 7
QbzIcon {
name: "list-filter"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: root.active ? "primary" : "secondary"
}
Text {
text: root.count === 0
? QbzSession.tr("Filter by genre", QbzSession.trRev)
: root.count === 1
? QbzSession.tr("1 genre", QbzSession.trRev)
: QbzSession.tr("{} genres", QbzSession.trRev).replace("{}", root.count)
color: root.active ? theme.accentText : theme.textSecondary
font.pixelSize: 13
font.weight: root.active ? theme.weightMedium : theme.weightRegular
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: genreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
@@ -0,0 +1,62 @@
// QbzToggleButton — 1:1 port of primitives/ToggleButton.slint: one half of a
// segmented toggle (grid/list) or a standalone toolbar toggle (group-by /
// Hi-Res). Shared by every album-toolbar surface in the Slint (Discover
// Browse, Label Releases, Favorites), so it is shared here too.
//
// Numbers, read off ToggleButton.slint:
// :22 34px square (30px with `sm`) :35 glyph 17px (15px with `sm`)
// :25 active -> surface-hover fill, text-primary glyph
// :30 hover -> surface-elevated fill
// idle -> transparent fill, text-muted glyph
//
// NOT QbzToggle.qml — that one is the on/off SWITCH (checked/toggled(bool)).
//
// LIGHT-THEME CORRECTNESS: icon tints are PRE-BAKED SVG colours
// (assets/icons/<tint>/), and the "primary" bake is literally #ffffff. On a
// light theme the active glyph would paint white on a near-white fill, so the
// bake is chosen from the LIVE token's lightness — the same rule
// QbzIconButton.qml and QbzCircleAction.qml already carry.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string name: ""
property bool active: false
/// Bootstrap-style small variant (ToggleButton `sm`): 30px, not 34px.
property bool sm: false
property bool btnEnabled: true
signal clicked()
QbzTheme { id: theme }
readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
width: sm ? 30 : 34
height: sm ? 30 : 34
radius: 6
opacity: btnEnabled ? 1.0 : 0.35
color: root.active
? theme.surfaceHover
: (tbArea.containsMouse && root.btnEnabled ? theme.surfaceElevated : "transparent")
QbzIcon {
name: root.name
width: root.sm ? 15 : 17
height: root.sm ? 15 : 17
anchors.centerIn: parent
tintName: root.active ? root.tintStrong : "muted"
}
MouseArea {
id: tbArea
anchors.fill: parent
enabled: root.btnEnabled
hoverEnabled: true
cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.clicked()
}
}
@@ -0,0 +1,45 @@
// ViewModeToggle — discover/BrowseHeaderTools.slint:149-173, verbatim: the
// 68x34 grid/list pair, radius 6, surface-elevated, CLIPPED so the segments'
// own corners never poke out.
//
// NOT views/local/LocalSegToggle.qml — that one is 60x30 with 26x24 segments
// (LocalLibraryView.slint's own ViewToggle) and reusing it here would ship a
// browse toolbar 8px short of every other control on the row
// (ui-control-alignment-standard: 34px).
//
// `mode` is the caller's state ("grid" | "list"); the toggle only reports the
// change — the host owns persistence, exactly like the Slint two-way binding.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string mode: "grid"
signal setMode(string value)
QbzTheme { id: theme }
width: 68
height: 34
radius: 6
color: theme.surfaceElevated
clip: true
Row {
anchors.centerIn: parent
spacing: 0
QbzToggleButton {
name: "layout-grid"
active: root.mode !== "list"
onClicked: root.setMode("grid")
}
QbzToggleButton {
name: "list"
active: root.mode === "list"
onClicked: root.setMode("list")
}
}
}
+7 -1
View File
@@ -161,7 +161,13 @@ Rectangle {
: QbzShell.currentView === "artist" ? "../views/ArtistView.qml"
: QbzShell.currentView === "settings" ? "../settings/SettingsView.qml"
: QbzShell.currentView === "search" ? "../views/SearchView.qml"
: QbzShell.currentView === "playlist" ? "../views/PlaylistView.qml" : ""
: QbzShell.currentView === "playlist" ? "../views/PlaylistView.qml"
: QbzShell.currentView === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
: QbzShell.currentView === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
: QbzShell.currentView === "recentalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "mostplayedalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "label" ? "../views/LabelView.qml"
: QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml" : ""
}
}
+346 -15
View File
@@ -1,8 +1,24 @@
// Left navigation sidebar — QML port of crates/qbz-ui/ui/shell/Sidebar.slint.
//
// Three states (ShellState.sidebar-state): 0 = open 240px (icon + label),
// 1 = mini 64px (icons only), 2 = closed 0px. Width animates 160ms
// ease-in-out; the header's panel-left button cycles (QbzShell.cycleSidebar).
// 1 = mini (icons only), 2 = closed 0px. Width animates 160ms ease-in-out;
// the header's panel-left button cycles (QbzShell.cycleSidebar).
//
// MINI RAIL: the rail is 50px here, not Slint's 64 — a deliberate,
// one-token owner deviation (theme.sidebarMiniWidth) so the square 34px row
// exactly fills the 8px-padded track. Everything else in the mini state is
// 1:1 with Slint: rows centre their lone glyph, nested playlist rows
// collapse to zero, hovering a row names it in a bubble to the right
// (SidebarTooltip.slint) and clicking a FOLDER opens its playlists in a
// flyout to the right (SidebarFolderPopup.slint) — in mini a folder cannot
// expand in place, so a toggle there would be a control that does nothing.
//
// FILE LENGTH (>500): the two mini affordances above are shared single
// instances declared at this root (never one Popup per row), which is what
// keeps them cheap; they are extraction candidates into
// qml/shell/SidebarFolderFlyout.qml, but a new .qml also needs a build.rs
// entry and an unregistered type fails the WHOLE file at load, so they stay
// here until that glue lands.
//
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
@@ -93,6 +109,260 @@ Rectangle {
// this root's `clip` nor its 240px width can cut it off.
NavFlyout { id: navFlyout }
// ---- Collapsed (mini) affordances -----------------------------------
// In the mini rail a playlist row is an icon and nothing else, so the two
// things the open sidebar gives for free have to be restored:
// - the NAME, as a hover bubble to the row's right (SidebarTooltip.slint);
// - a folder's CONTENTS, as a flyout to the row's right, because the
// nested rows are hidden in mini (SidebarFolderPopup.slint).
// Both are ONE shared instance driven by the hovered/clicked row (exactly
// like Slint's SidebarTooltipState / SidebarFolderPopupState), never one
// Popup per delegate: the tree can hold hundreds of rows.
// Both are QtQuick.Controls popups, so their content is reparented to the
// WINDOW overlay and this root's `clip: true` cannot cut them — the same
// reason NavFlyout and HeaderBar's playlists flyout are Popups.
// Race-safe show/hide (Slint SidebarRow.show-tooltip / hide-tooltip): the
// leaving row only clears the bubble if it still owns it, so sliding the
// pointer straight from one row to the next never blanks it.
function showMiniTip(row, entry) {
if (!root.mini) return
var p = row.mapToItem(root, row.width + 6, 0)
miniTip.anchorX = p.x
miniTip.anchorY = p.y
miniTip.rowH = row.height
miniTip.tipText = entry.kind === "folder"
? entry.name + " (" + entry.count + ")"
: entry.name
miniTip.ownerId = entry.id
}
function hideMiniTip(id) {
if (miniTip.ownerId === id) miniTip.ownerId = ""
}
// Mini folder click: the flyout, not a toggle (the nested rows are hidden,
// so toggling is a control that renders and does nothing — Slint opens the
// popup instead, Sidebar.slint:355-377).
function openFolderFlyout(row, entry) {
folderFlyout.folderId = entry.id
folderFlyout.folderName = entry.name
folderFlyout.folderCount = entry.count
// The children are only present in the published entries while the
// folder is EXPANDED. Expand it if it is not — never toggle blindly:
// a folder left open before the sidebar collapsed would be CLOSED by a
// toggle and the flyout would list nothing (and while a search query is
// active every folder is force-expanded, sidebar_qt.rs:299).
if (!entry.expanded) QbzShell.sidebarToggleFolder(entry.id)
var p = row.mapToItem(root, row.width + 6, 0)
folderFlyout.x = p.x
folderFlyout.y = p.y
folderFlyout.open()
}
// Cycling the sidebar while either is open would leave it floating over a
// rail that no longer exists (the bubble self-hides through `root.mini`;
// the flyout needs telling).
Connections {
target: QbzShell
function onSidebarStateChanged() {
folderFlyout.close()
miniTip.ownerId = ""
}
}
// Hover bubble — SidebarTooltip.slint: surface-elevated, radius sm, 1px
// border-muted, 10/10/5/5 padding, 12px w500 text-primary, VISUAL ONLY
// (a ToolTip never takes the pointer, so it cannot block the row it
// describes).
ToolTip {
id: miniTip
property string ownerId: ""
property string tipText: ""
property real anchorX: 0
property real anchorY: 0
property real rowH: 32
// Auto-hides the moment the sidebar leaves mini (SidebarTooltip
// .slint:14) or the folder flyout takes over the same +6px slot
// (Slint drops it explicitly on click, Sidebar.slint:353).
visible: root.mini && ownerId !== "" && !folderFlyout.visible
delay: 0
timeout: -1
padding: 0
x: anchorX
y: anchorY + Math.round((rowH - implicitHeight) / 2)
contentItem: Text {
text: miniTip.tipText
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
leftPadding: 10
rightPadding: 10
topPadding: 5
bottomPadding: 5
}
background: Rectangle {
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
}
// Folder flyout — SidebarFolderPopup.slint: 230px, 30px header row +
// hairline + up to four 32px rows, 4px inner padding, surface-main /
// radius sm / 1px border-muted.
Popup {
id: folderFlyout
width: 230
padding: 0
topPadding: 4
bottomPadding: 4
// Slint's literal panel height (34 + 1 + list + 8) — 4px taller than
// the content, kept 1:1 rather than tightened.
height: 34 + 1 + folderFlyout.visibleRows * 32 + 8
// Replaces Slint's full-window scrim TouchArea (SidebarFolderPopup
// .slint:56-60); the Popup's own background swallows clicks inside,
// so no hand-built scrim and no click-through.
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
property string folderId: ""
property string folderName: ""
property int folderCount: 0
// The rows are a BINDING over the published entries, never a snapshot
// read inside the click handler: sidebarToggleFolder republishes
// sidebarJson through the cxx-qt UI queue (shell_bridge.rs::ui ->
// CxxQtThread::queue), so the children land a LATER event-loop turn.
// Reading `root.entries` at click time would open an empty panel.
readonly property var rows: root.entries.filter(function (e) {
return e.kind !== "folder" && e.folderId === folderFlyout.folderId
})
// Height off the folder's own count (known at click time) so the panel
// does not resize when the children land a tick later.
readonly property int visibleRows:
Math.min(Math.max(folderFlyout.folderCount, folderFlyout.rows.length), 4)
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
spacing: 0
// Header — folder name (30px, 12px side padding, 14px accent
// folder-open glyph, 13px/600 text-primary).
Item {
width: parent.width
height: 30
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: "folder-open"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Text {
width: parent.width - 22
height: parent.height
text: folderFlyout.folderName
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// Playlist list — four rows visible, the rest scroll (the shared
// QbzScrollBar mounts past four, like Slint's ListScrollbar).
Item {
width: parent.width
height: folderFlyout.visibleRows * 32
Flickable {
id: fpFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: fpColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: fpColumn
width: parent.width
Repeater {
model: folderFlyout.rows
delegate: Rectangle {
id: fpRow
required property var modelData
width: fpColumn.width
height: 32
radius: 5
color: fpArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: "list-music"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Text {
width: parent.width - 22
height: parent.height
text: fpRow.modelData.name
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: fpArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.activePlaylistId = fpRow.modelData.id
QbzBridge.openPlaylist(fpRow.modelData.id)
folderFlyout.close()
}
}
}
}
}
}
QbzScrollBar {
visible: folderFlyout.folderCount > 4 && fpFlick.contentHeight > fpFlick.height
target: fpFlick
width: 10
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
width: QbzShell.sidebarState === 2 ? 0
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
: theme.sidebarOpenWidth
@@ -122,7 +392,9 @@ Rectangle {
readonly property bool lit: isActive || (navArea.containsMouse && isEnabled)
width: parent ? parent.width : 0
height: 34
// Mini: a SQUARE row that fills the 34px padded track (theme token,
// rail 50 - 2*8). Open keeps Slint's 34px (Sidebar.slint:575).
height: root.mini ? theme.sidebarMiniRow : 34
radius: 6
opacity: isEnabled ? 1.0 : 0.45
color: lit ? theme.surfaceHover : "transparent"
@@ -521,6 +793,7 @@ Rectangle {
Repeater {
model: root.entries
delegate: Rectangle {
id: plRow
required property var modelData
property bool isFolder: modelData.kind === "folder"
// Slint SidebarRow.use-collage: opt-OUT setting AND
@@ -552,7 +825,15 @@ Rectangle {
function onDragActiveChanged() { recomputeDrop() }
}
width: plColumn.width
height: root.mini && modelData.indent ? 0 : 32
// Mini: nested rows collapse to nothing (their
// folder opens the flyout instead, Sidebar.slint:
// 213-215), top-level rows become the SAME square
// the nav rows are — a 34px row in a 34px track.
// Slint keeps 32px here; the 2px come from the
// narrower rail (theme.sidebarMiniRow).
height: root.mini
? (modelData.indent ? 0 : theme.sidebarMiniRow)
: 32
visible: !(root.mini && modelData.indent)
radius: 6
// success-bg + success-border while hot (Theme
@@ -568,18 +849,32 @@ Rectangle {
anchors.rightMargin: root.mini ? 0 : 6
spacing: 8
// Leading icon slot (24px fixed).
// Leading icon slot: 24px fixed while open (so
// every row's name starts at the same x), the
// WHOLE row in mini so the lone glyph lands on
// the rail's centre line — Slint gets this from
// `alignment: mini ? center : stretch`
// (Sidebar.slint:230), which a QML Row has no
// equivalent for. Without it the slot is the
// only visible child, sits at x=0, and the
// playlist/folder glyphs render 12px LEFT of
// the nav glyphs above them (owner screenshot).
// Same idiom as NavRow's slot above.
Item {
width: 24
width: root.mini ? parent.width : 24
height: parent.height
// Folder icon (accent).
// Folder icon (accent). Every glyph in this
// slot is placed with rounded arithmetic
// (Sidebar.slint:245-246): `centerIn` of an
// odd 15px glyph in an even track lands on a
// half pixel and blurs it.
QbzIcon {
visible: isFolder
name: modelData.expanded && !root.mini ? "folder-open" : "folder"
width: 15
height: 15
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -4.5
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
tintName: "accent"
}
// 2x2 micro-collage (or list-music glyph).
@@ -587,8 +882,8 @@ Rectangle {
visible: useCollage
width: 20
height: 20
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -2
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
radius: 3
color: theme.surfaceElevated
clip: true
@@ -613,8 +908,8 @@ Rectangle {
name: "list-music"
width: 15
height: 15
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.mini ? 0 : -4.5
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
}
}
@@ -685,9 +980,24 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Mini: the row is an icon, so hovering names
// it (Sidebar.slint:342-349). No-op while open.
onContainsMouseChanged: {
if (containsMouse) root.showMiniTip(plRow, plRow.modelData)
else root.hideMiniTip(plRow.modelData.id)
}
onClicked: {
// Slint drops the bubble on click so it
// does not linger behind the flyout.
root.hideMiniTip(plRow.modelData.id)
if (isFolder) {
QbzShell.sidebarToggleFolder(modelData.id)
// Mini: the nested rows are hidden, so
// a toggle would change NOTHING on
// screen — the contents open in a
// flyout to the right instead
// (Sidebar.slint:355-377).
if (root.mini) root.openFolderFlyout(plRow, plRow.modelData)
else QbzShell.sidebarToggleFolder(modelData.id)
} else {
root.activePlaylistId = modelData.id
QbzBridge.openPlaylist(modelData.id)
@@ -697,7 +1007,10 @@ Rectangle {
}
}
// Empty state.
// Empty state. NOTE: Slint does NOT gate this on mini
// (Sidebar.slint:1077-1082) and word-wraps the sentence
// inside the 48px track; keeping the gate here is a
// deliberate, owner-visible divergence.
Text {
visible: !root.mini && root.entries.length === 0 && !QbzSession.offline
width: plColumn.width
@@ -708,6 +1021,24 @@ Rectangle {
}
}
}
// List scrollbar (Sidebar.slint:1090-1137). The shared
// QbzScrollBar — the port's ListScrollbar replica — rather than a
// third scrollbar implementation. Two accepted deviations from
// the sidebar's bespoke Slint one: it reveals on SCROLL (900ms
// auto-hide) instead of on row-hover, and its thumb is the
// standard 8/10px instead of the sidebar's thinner 3-6px.
// Placed in the sidebar's right PADDING (negative margin), like
// Slint's `x: parent.width - width + (mini ? 8 : Spacing.md)`, so
// the gutter never overlays the rows and cannot eat their clicks.
QbzScrollBar {
target: plFlick
width: root.mini ? 8 : 10
anchors.right: parent.right
anchors.rightMargin: -(root.mini ? 8 : theme.spacingMd)
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
+13 -1
View File
@@ -140,7 +140,19 @@ QtObject {
readonly property int headerHeight: 42
// Three-state sidebar rendered widths (ShellState.sidebar-rendered-width).
readonly property int sidebarOpenWidth: 240
readonly property int sidebarMiniWidth: 64
// Mini rail — DELIBERATE deviation from state.slint:4075 (64px), owner
// request: the 64px rail read as a wide empty gutter around a 32px row.
// rail = spacingSm + row + spacingSm, so the square mini row exactly
// fills the 8px-padded track (Sidebar.qml root padding = Sidebar.slint:
// 716-718). REVERT is TWO tokens, not one: putting 64 back here also
// makes sidebarMiniRow 48, so the rows would grow from 34 to 48 and stop
// matching Slint — set sidebarMiniRow to a literal 34 at the same time.
// (Adversarial review caught this comment claiming a one-token revert.)
readonly property int sidebarMiniWidth: 50
// The square row inside that rail (34px). Rows are `width: parent.width`,
// so this is only their HEIGHT; it exists as a token so the rail and the
// row can never drift apart.
readonly property int sidebarMiniRow: sidebarMiniWidth - 2 * spacingSm
// Right queue/lyrics column width (AppShell).
readonly property int queuePanelWidth: 300
// Small now-playing bar total height (ShellState.npb-small-height,
+251
View File
@@ -0,0 +1,251 @@
// AlbumCollection — QML port of discover/AlbumCollectionView.slint: the
// rendered album collection (flat grid, flat list, or group-by sections),
// WITHOUT the surrounding toolbar / loading / empty / load-more states. The
// host owns those, exactly as in the .slint, so the column math and the
// list header+rows live in one place and are shared by Discover Browse,
// Label Releases and the two play-history pages.
//
// (views/local/LocalAlbumCollection.qml is the LOCAL twin: same idea, but
// its rows are LocalAlbumRow and it is coupled to LocalLibraryView for the
// skeleton pulse and the per-item artwork gate. Catalog pages have neither,
// so this is the catalog-side collection rather than a fifth arm bolted onto
// that one.)
//
// VIEW MODE: the flat grid mounts on `viewMode !== "list"`. The .slint tests
// `== "grid"` (:294) and therefore renders NOTHING for an empty view-mode —
// a trap the Rust seeding avoids anyway, but there is no reason to reproduce
// a blank page here.
//
// WINDOWING (flat grid only, opt-in via `flick`): only cards within ~one
// viewport of the visible band mount a real AlbumCard; the rest keep their
// footprint as bare Items, so the grid height — and therefore the scroll
// geometry — never changes. The band is SAMPLED by a timer rather than bound
// to `flick.contentY`: a direct binding re-evaluates every delegate on every
// scroll frame, which is the O(n)-per-frame cost the .slint's own sampler
// exists to avoid (see its "POST-LAYOUT SNAPSHOT SAMPLING" note). Grouped
// sections are never windowed — same call the .slint makes (:265-277).
import QtQuick
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../theme"
Column {
id: root
/// Flat list of home_qt::HomeCard rows.
property var albums: []
/// [{ title, albums: [...] }] — only read while `isGrouped`.
property var grouped: []
property bool isGrouped: false
/// "grid" | "list".
property string viewMode: "grid"
property int cardWidth: 200
property int cardHeight: 266
property int cardGap: 24
property int listRowGap: 4
/// Render the "{} plays" line (Most Played Albums only — AlbumCard.slint
/// :508 shows it when the card carries a non-zero count, and every other
/// surface publishes 0).
property bool showPlays: false
/// The scrolling host. Set it to enable grid windowing; leave null and
/// every card mounts (the right choice for a bounded set like the 24-item
/// play history).
property Flickable flick: null
/// Approximate y of this collection inside the host's content — the
/// .slint's `content-offset`.
property real contentOffset: 0
QbzTheme { id: theme }
width: parent ? parent.width : 0
spacing: 0
// --- Windowing band (row indices), sampled ---------------------------
property int bandFirst: 0
property int bandLast: 9999
readonly property bool windowed: flick !== null
function sampleBand() {
if (!root.windowed || flatGrid.columns <= 0)
return
var pitch = root.cardHeight + root.cardGap
var top = root.flick.contentY - root.contentOffset
var h = root.flick.height
// One viewport of prefetch on each side.
root.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
root.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
}
Timer {
interval: 80
repeat: true
running: root.windowed && root.visible
onTriggered: root.sampleBand()
}
Component.onCompleted: root.sampleBand()
// One group-by section's card grid (FULL mounts — see the header note).
// Declared before its use for readability; QML registers inline
// components document-wide either way.
component SectionGrid: Item {
id: sg
property var items: []
width: parent ? parent.width : 0
readonly property int columns: Math.max(
1, Math.floor((width + root.cardGap) / (root.cardWidth + root.cardGap)))
readonly property int rows: Math.ceil(sg.items.length / sg.columns)
height: sg.rows > 0
? sg.rows * root.cardHeight + (sg.rows - 1) * root.cardGap
: 0
Repeater {
model: sg.items
delegate: Item {
id: gcell
required property var modelData
required property int index
x: (gcell.index % sg.columns) * (root.cardWidth + root.cardGap)
y: Math.floor(gcell.index / sg.columns) * (root.cardHeight + root.cardGap)
width: root.cardWidth
height: root.cardHeight
AlbumCard {
albumId: gcell.modelData.id
title: gcell.modelData.title
artist: gcell.modelData.artist
artistId: gcell.modelData.artistId
genre: gcell.modelData.genre
year: gcell.modelData.year
qualityTier: gcell.modelData.qualityTier
ribbon: gcell.modelData.ribbon || ""
ribbonKind: gcell.modelData.ribbonKind || ""
artSource: gcell.modelData.artPath || ""
isPinned: gcell.modelData.isPinned === true
isFavorite: false
}
}
}
}
// --- Grouped sections -------------------------------------------------
Column {
visible: root.isGrouped && root.grouped.length > 0
width: parent.width
spacing: 0
// In list mode the column header sits ONCE above all sections.
AlbumListHeader { visible: root.viewMode === "list" }
Repeater {
model: root.isGrouped ? root.grouped : []
delegate: Column {
id: sectionCol
required property var modelData
required property int index
width: root.width
spacing: 12
Item { visible: sectionCol.index > 0; width: 1; height: 24 }
Text {
text: sectionCol.modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
// List arm.
Column {
visible: root.viewMode === "list"
width: parent.width
spacing: root.listRowGap
Repeater {
model: root.viewMode === "list" ? (sectionCol.modelData.albums || []) : []
delegate: AlbumListRow {
required property var modelData
required property int index
item: modelData
rowIndex: index
}
}
}
// Grid arm.
SectionGrid {
visible: root.viewMode !== "list"
items: sectionCol.modelData.albums || []
}
}
}
}
// --- Flat list --------------------------------------------------------
Column {
visible: !root.isGrouped && root.viewMode === "list" && root.albums.length > 0
width: parent.width
spacing: root.listRowGap
AlbumListHeader { }
Repeater {
model: (!root.isGrouped && root.viewMode === "list") ? root.albums : []
delegate: AlbumListRow {
required property var modelData
required property int index
item: modelData
rowIndex: index
}
}
}
// --- Flat grid (windowed) --------------------------------------------
Item {
id: flatGrid
visible: !root.isGrouped && root.viewMode !== "list" && root.albums.length > 0
width: parent.width
readonly property int columns: Math.max(
1, Math.floor((width + root.cardGap) / (root.cardWidth + root.cardGap)))
readonly property int rows: Math.ceil(root.albums.length / flatGrid.columns)
height: flatGrid.rows > 0
? flatGrid.rows * root.cardHeight + (flatGrid.rows - 1) * root.cardGap
: 0
Repeater {
model: (!root.isGrouped && root.viewMode !== "list") ? root.albums : []
delegate: Item {
id: cell
required property var modelData
required property int index
readonly property int rowIndex: Math.floor(cell.index / flatGrid.columns)
x: (cell.index % flatGrid.columns) * (root.cardWidth + root.cardGap)
y: cell.rowIndex * (root.cardHeight + root.cardGap)
width: root.cardWidth
height: root.cardHeight
// Component declared in the DELEGATE scope so `modelData`
// resolves (the PinnedRail dispatch pattern).
Component {
id: cardComp
AlbumCard {
albumId: cell.modelData.id
title: cell.modelData.title
artist: cell.modelData.artist
artistId: cell.modelData.artistId
genre: cell.modelData.genre
year: cell.modelData.year
qualityTier: cell.modelData.qualityTier
ribbon: cell.modelData.ribbon || ""
ribbonKind: cell.modelData.ribbonKind || ""
artSource: cell.modelData.artPath || ""
isPinned: cell.modelData.isPinned === true
plays: root.showPlays ? (cell.modelData.plays || 0) : 0
isFavorite: false
}
}
Loader {
anchors.fill: parent
active: !root.windowed
|| (cell.rowIndex >= root.bandFirst && cell.rowIndex <= root.bandLast)
sourceComponent: cardComp
}
}
}
}
}
@@ -0,0 +1,74 @@
// AlbumListHeader — primitives/AlbumListRow.slint:43-127, the column header
// above the LIST arm of an album collection (Discover Browse, Label
// Releases). 32px, 11px semibold muted labels with 0.5 letter-spacing and a
// bottom hairline.
//
// COLUMN SET, and why it is shorter than the Slint's: `AlbumListCols` there
// is rank · art · ITEM · TYPE · SOURCE · QUALITY · TRACKS · YEAR · overflow.
// The Qt transport for these pages is `home_qt::HomeCard`, which carries no
// release type and no track count, and SOURCE is hidden on both catalog
// pages anyway (`show-source: false`, DiscoverBrowseView.slint:168). Naming
// columns with nothing under them is worse than not drawing them, so the
// header lists exactly the three the rows fill. Widths are the Slint's.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
QbzTheme { id: theme }
// AlbumListCols: art 52, quality 150, year 64, overflow 36, gap 12.
readonly property int colArt: 52
readonly property int colQuality: 150
readonly property int colYear: 64
readonly property int colOverflow: 36
readonly property int colGap: 12
width: parent ? parent.width : 0
height: 32
color: "transparent"
component ColLabel: Text {
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
height: 32
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: root.colGap
Item { width: root.colArt; height: 1 }
ColLabel {
width: parent.width - root.colArt - root.colQuality - root.colYear
- root.colOverflow - 4 * root.colGap
text: QbzSession.tr("ITEM", QbzSession.trRev)
elide: Text.ElideRight
}
ColLabel {
width: root.colQuality
text: QbzSession.tr("QUALITY", QbzSession.trRev)
}
ColLabel {
width: root.colYear
text: QbzSession.tr("YEAR", QbzSession.trRev)
horizontalAlignment: Text.AlignHCenter
}
Item { width: root.colOverflow; height: 1 }
}
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
+191
View File
@@ -0,0 +1,191 @@
// AlbumListRow — primitives/AlbumListRow.slint:129+, the LIST arm of a
// CATALOG album collection (Discover Browse, Label Releases). 64px
// (`list-row-h`, DiscoverBrowseView.slint:29), radius 8, odd-row zebra,
// hover surface-hover — coherent with TrackRow.
//
// The local twin is views/local/LocalAlbumRow.qml (56px, local actions, a
// host-owned skeleton). This one is Qobuz-wired: the body opens the catalog
// album, and the ⋯ menu carries the five entries the Slint always shows
// (Open album / Play / Play next / Play later / Add to queue). Right-clicking
// the row opens the same menu at the pointer, like every other ⋯ site.
//
// "Block this album" is absent for the same reason AlbumCard.qml gates it
// off: the Qt bridge exposes the blacklist COUNTERS but no block-album
// invokable, and a row that renders and does nothing is a defect.
//
// item contract: home_qt::HomeCard — { id, title, artist, artistId, year,
// qualityTier, qualityDetail, artPath }.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
property var item: ({})
/// Row index — drives the even/odd zebra (coherent with TrackRow).
/// NOT named `index`: a Repeater injects one into the delegate context
/// and a same-named property here would shadow it.
property int rowIndex: 0
QbzTheme { id: theme }
// Same columns (and widths) as AlbumListHeader.qml.
readonly property int colArt: 52
readonly property int colQuality: 150
readonly property int colYear: 64
readonly property int colOverflow: 36
readonly property int colGap: 12
readonly property bool rowHovered: rowArea.containsMouse || moreArea.containsMouse
|| artistArea.containsMouse
width: parent ? parent.width : 0
height: 64
radius: theme.radiusSm
color: rowHovered ? theme.surfaceHover
: (rowIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
else
QbzAlbum.openAlbum(root.item.id || "")
}
}
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "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) {
var id = root.item.id || ""
if (id === "") return
if (a === "open") QbzAlbum.openAlbum(id)
else if (a === "play") QbzPlayer.playAlbum(id)
else QbzPlayer.enqueueAlbum(id, a)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: root.colGap
// Art cell (52px column, 44px thumb centred).
Item {
width: root.colArt
height: parent.height
Rectangle {
width: 44
height: 44
anchors.centerIn: parent
radius: 4
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.item.artPath || ""
radius: 4
}
}
}
// ITEM — title over artist (the artist line links to the artist).
Column {
width: parent.width - root.colArt - root.colQuality - root.colYear
- root.colOverflow - 4 * root.colGap
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.item.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLink
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
id: artistText
width: parent.width
text: root.item.artist || ""
color: artistArea.containsMouse ? theme.textPrimary : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
// Only a real artist id is clickable — otherwise the
// pointer promises a page that cannot open.
enabled: (root.item.artistId || "") !== ""
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.openArtist(root.item.artistId)
}
}
}
// QUALITY — the SAME badge the album page shows (tier label over the
// exact bit-depth / sample-rate line).
Item {
width: root.colQuality
height: parent.height
QualityBadgeFull {
anchors.verticalCenter: parent.verticalCenter
tier: root.item.qualityTier || ""
detail: root.item.qualityDetail || ""
}
}
Text {
width: root.colYear
height: parent.height
text: root.item.year || ""
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Item {
width: root.colOverflow
height: parent.height
Rectangle {
width: 32
height: 32
radius: 6
anchors.centerIn: parent
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: "ellipsis"
width: 18
height: 18
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
}
}
}
@@ -0,0 +1,222 @@
// Discover "View all" full-list page — QML port of
// discover/DiscoverBrowseView.slint. One /discover/<module> endpoint (New
// Releases, Press Accolades, Ideal Discography, Qobuzissimes, Albums of the
// Week, Most Streamed) as a paginated full grid or list.
//
// Layout, read off the .slint:
// :44 fixed 56px header — title LEFT, search / genre / grid-list RIGHT
// :139 body padding 32 / 32, top 8, bottom 100
// :26 200x266 cards, gap 24; :29 64px list rows, gap 4
// :126 infinite scroll: within 600px of the bottom, has-more, not already
// loading, and NO active search (the filter is client-side, so paging
// under one would bring back unfiltered rows)
// :205 scrollbar below the header; :233 genre popup anchored y=56
//
// NAV BUTTONS: the .slint draws a NavButtons pair at x=32 and starts the
// title 16px after it. In this port back/forward live in the shell header
// (HeaderBar.qml:235), so the title starts at x=48 — the 32 + 0 + 16 the
// HomeView toolbar already uses (HomeView.qml:744).
//
// POC-NOTEs: no scroll restore (the port has no counterpart to
// NavState.report-scroll / restore-scope); `windowed` grid mounting is the
// AlbumCollection sampler rather than the .slint's own.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.discoverBrowseJson)
} catch (e) {
return {}
}
}
readonly property var items: doc.items || []
readonly property string query: doc.query || ""
// Seeded to "grid" by the Rust opener — an empty mode renders nothing.
readonly property string viewMode: doc.viewMode || "grid"
// --- Shared genre selection ------------------------------------------
// `genre_filter_qt::after_selection_change` only knows how to reload
// Home, so this page watches the published selection itself and asks for
// a re-fetch from offset 0. The signature is the selected NAMES, not the
// count: swapping one genre for another leaves the count unchanged.
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)
QbzHome.discoverBrowseGenreChanged()
root.lastGenreSig = root.genreSig
}
Component.onCompleted: root.lastGenreSig = root.genreSig
// ============================ offline gate ============================
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 {
id: header
width: parent.width
height: 56
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Text {
x: 48
y: 25 - height / 2
width: Math.max(0, tools.x - 48 - 16)
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Row {
id: tools
x: parent.width - width - 32
y: 25 - height / 2
spacing: 8
QbzLineEdit {
searchMode: true
width: 200
placeholder: QbzSession.tr("Search…", QbzSession.trRev)
text: root.query
onEdited: function (v) { QbzHome.discoverBrowseSearch(v) }
}
BrowseGenreButton {
context: "discover"
onClicked: genrePopup.toggle()
}
ViewModeToggle {
mode: root.viewMode
onSetMode: function (m) { QbzHome.discoverBrowseSetViewMode(m) }
}
}
}
// --- Scrolling list ----------------------------------------------
Item {
width: parent.width
height: parent.height - 56
Flickable {
id: flick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
// Infinite scroll, guarded exactly as the .slint is.
onContentYChanged: {
if (contentY + height >= contentHeight - 600
&& root.doc.hasMore === true
&& !QbzHome.discoverBrowseLoadingMore
&& !QbzHome.discoverBrowseLoading
&& root.query === "")
QbzHome.discoverBrowseLoadMore()
}
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 8
bottomPadding: 100
spacing: 0
QbzSpinner {
visible: QbzHome.discoverBrowseLoading
size: 36
anchors.horizontalCenter: parent.horizontalCenter
}
// Search filtered everything out.
Text {
visible: !QbzHome.discoverBrowseLoading
&& root.items.length === 0 && root.query !== ""
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No results found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
AlbumCollection {
visible: !QbzHome.discoverBrowseLoading
width: parent.width - 64
albums: root.items
viewMode: root.viewMode
cardWidth: 200
cardHeight: 266
cardGap: 24
listRowGap: 4
flick: flick
contentOffset: 8
}
Item { visible: QbzHome.discoverBrowseLoadingMore; width: 1; height: 24 }
QbzSpinner {
visible: QbzHome.discoverBrowseLoadingMore
size: 28
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// Tracks only the scrolling list (the header is a sibling above).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
}
// Declared LAST — declaration order IS z-order, so the popup keeps its
// own presses instead of the toolbar swallowing them.
GenreFilterPopup {
id: genrePopup
anchors.fill: parent
context: "discover"
// The .slint anchors this page's popup at y=56 (not HomeView's 52).
anchorTop: 56
anchorRight: 32
}
}
+149 -83
View File
@@ -23,9 +23,18 @@
// (src/recommendations_qt.rs), LAZY-loaded the first time the tab becomes
// visible, and the tab itself is gated on the persisted showRecommendations
// pref exactly as in Slint.
// - Card clicks / hover actions (play / favorite / more / pin) and
// "View all" / context menus are inert — album/artist pages, playback
// and per-user stores are later phases.
// - "View all" is LIVE. WHICH rails carry the link is decided HERE, per
// TAB, by `viewAllKind()` — not in Rust: `home_qt::assemble()` clones ONE
// candidate list into all three tab documents, and Slint's For You arm for
// `recentlyPlayedAlbums` has NO link (ForYouView.slint:117-126) while
// Home's does (HomeView.slint:411). Stamping the decision on the shared
// candidate would leak the link onto For You. The four destinations:
// endpoint != "" -> DiscoverBrowse for that /discover/<module>
// qobuzPlaylists -> PlaylistBrowse (Home + Editor's Picks only)
// recentlyPlayedAlbums -> Recently Played (Home only, non-placeholder)
// mostPlayedAlbums -> Most Played (Home + For You)
// - Card clicks / hover actions (play / favorite / more / pin) are live;
// context menus follow each card's own seam constants.
// - The offline mount mirrors AppShell's ADR-010 seam: OfflineState.offline
// -> the OfflinePlaceholder replica (exact msgids).
@@ -79,19 +88,13 @@ Rectangle {
}
// --- shared genre filter, "discover" context -------------------------
// Read STRAIGHT off the bridge singleton, not off genrePopup: the popup
// is declared LAST (z-order) and a creation-time binding that
// dereferences a not-yet-created id registers NO dependency, so it would
// never re-evaluate. The popup instance is only touched from click
// handlers, which run long after creation.
readonly property var genreDoc: {
try {
return JSON.parse(QbzBridge.genreFilterJson)
} catch (e) {
return {}
}
}
readonly property int genreCount: (genreDoc.counts || {})["discover"] || 0
// The badge lives in controls/BrowseGenreButton.qml now (the browse
// pages draw the same control). It reads QbzBridge.genreFilterJson
// STRAIGHT off the bridge singleton, never off genrePopup: the popup is
// declared LAST (z-order) and a creation-time binding that dereferences
// a not-yet-created id registers NO dependency, so it would never
// re-evaluate. The popup instance is only touched from click handlers,
// which run long after creation.
// --- skeleton pulse ---------------------------------------------------
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
@@ -142,13 +145,68 @@ Rectangle {
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ "View all" ==============================
/// Which full-list page a rail's "View all" opens on THIS tab, or "" for
/// no link. Read the header note for why the decision lives here.
/// "discover" -> DiscoverBrowse for `section.endpoint`
/// "playlists" -> PlaylistBrowse
/// "recent" -> Recently Played Albums
/// "mostplayed"-> Most Played Albums
function viewAllKind(s, tab) {
if (!s)
return ""
// Generic album carousels + both mostStreamed variants: the section
// carries the /discover endpoint the page pages through.
if ((s.endpoint || "") !== "")
return "discover"
// HomeView.slint:355 mounts the playlist arm on the Home AND Editor's
// Picks repeater; discover_prefs has no qobuzPlaylists entry for For
// You, so this only ever fires on the two tabs that render it.
if (s.id === "qobuzPlaylists" && (tab === "home" || tab === "editorPicks"))
return "playlists"
// Home only (ForYouView.slint's arm has no show-view-all), and never
// on the empty-history placeholder (kind "recentPlaceholder").
if (s.id === "recentlyPlayedAlbums" && tab === "home" && s.kind === "album")
return "recent"
// HomeView.slint:506 AND ForYouView.slint:152 — both tabs.
if (s.id === "mostPlayedAlbums")
return "mostplayed"
return ""
}
/// The page title, when it differs from the rail title. Only the slim
/// "Most Streamed" rail does: it is titled "Popular albums" on Home but
/// HomeView.slint:466 hard-codes @tr("Most Streamed") for the page.
function viewAllTitle(s) {
if (s.id === "mostStreamed")
return QbzSession.tr("Most Streamed", QbzSession.trRev)
return s.title || ""
}
function openViewAll(s, tab) {
var kind = root.viewAllKind(s, tab)
if (kind === "discover")
QbzHome.openDiscoverBrowse(s.endpoint || "", root.viewAllTitle(s))
else if (kind === "playlists")
QbzHome.openPlaylistBrowse()
else if (kind === "recent")
QbzHome.openRecentAlbums()
else if (kind === "mostplayed")
QbzHome.openMostPlayedAlbums()
}
// ============================ shared components =======================
// Circular page-control button (Carousel's NavButton).
// Horizontal album rail (Carousel.slint): header + clipped ListView,
// page chevrons (per-page step like the Slint paging).
component AlbumRail: Column {
id: albumRail
property var sectionData: ({})
/// Which Discover tab is rendering this rail — the "View all"
/// decision is per tab (see viewAllKind).
property string tabId: "home"
width: parent ? parent.width : 0
spacing: 12
@@ -159,10 +217,14 @@ Rectangle {
QbzSectionHeader {
title: sectionData.title
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < maxScroll - 1
showViewAll: (sectionData.endpoint || "") !== ""
onPageLeft: rail.contentX = Math.min(0, rail.contentX - step)
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
rightEnabled: rail.contentX < albumRail.maxScroll - 1
showViewAll: root.viewAllKind(albumRail.sectionData, albumRail.tabId) !== ""
onViewAllClicked: root.openViewAll(albumRail.sectionData, albumRail.tabId)
// Math.MIN here paged LEFT to a permanent 0 (the chevron looked
// enabled and did nothing once contentX passed the first step);
// PinnedRail:408 and SlimGrid:341 both use max.
onPageLeft: rail.contentX = Math.max(0, rail.contentX - albumRail.step)
onPageRight: rail.contentX = Math.min(albumRail.maxScroll, rail.contentX + albumRail.step)
}
Item {
width: parent.width
@@ -323,6 +385,7 @@ Rectangle {
component SlimGrid: Column {
id: sgrid
property var sectionData: ({})
property string tabId: "home"
// true = the rows are TRACKS (click plays), false = ALBUMS (click opens).
property bool tracks: false
width: parent ? parent.width : 0
@@ -337,7 +400,8 @@ Rectangle {
title: sectionData.title
leftEnabled: grid.contentX > 1
rightEnabled: grid.contentX < sgrid.maxScroll - 1
showViewAll: (sectionData.endpoint || "") !== ""
showViewAll: root.viewAllKind(sgrid.sectionData, sgrid.tabId) !== ""
onViewAllClicked: root.openViewAll(sgrid.sectionData, sgrid.tabId)
onPageLeft: grid.contentX = Math.max(0, grid.contentX - grid.width)
onPageRight: grid.contentX = Math.min(sgrid.maxScroll, grid.contentX + grid.width)
}
@@ -580,7 +644,11 @@ Rectangle {
// The section-rails renderer (one per Discover tab — the tab bodies
// differ only in WHICH sections doc they mount).
component SectionRails: Column {
id: rails
property var sectionsModel: []
/// "home" | "editorPicks" | "forYou" | "recommendations" — forwarded
/// to every rail so its "View all" resolves per tab.
property string tabId: "home"
width: parent ? parent.width : 0
spacing: 40
@@ -588,6 +656,7 @@ Rectangle {
model: sectionsModel
delegate: Loader {
required property var modelData
property string railTab: rails.tabId
width: parent ? parent.width : 0
sourceComponent: modelData.kind === "album" ? albumRailComp
: modelData.kind === "playlist" ? playlistRailComp
@@ -605,28 +674,48 @@ Rectangle {
}
Component {
id: albumRailComp
AlbumRail { sectionData: parent.sectionData }
AlbumRail { sectionData: parent.sectionData; tabId: parent.railTab }
}
Component {
id: playlistRailComp
Column {
id: plRail
property var sectionData: parent.sectionData
property string tabId: parent.railTab
width: parent ? parent.width : 0
spacing: 12
Text {
text: sectionData.title
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
readonly property int perPage: Math.max(1, Math.floor((plList.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, plList.contentWidth - plList.width)
// The rail used to draw a bare Text: no chevrons (so
// a 40-playlist rail's tail was unreachable) and no
// "View all". PlaylistCarousel.slint:109-115 has both.
// POC-NOTE: the Slint arm ALSO puts a PlaylistTagFilter
// on this title line (HomeView.slint:360-374) and shows
// @tr("No playlists match the selected categories.")
// when the tags filter everything out. Neither is
// ported here — the tag filter DOES exist on the
// PlaylistBrowse page this link opens.
QbzSectionHeader {
title: plRail.sectionData.title
leftEnabled: plList.contentX > 1
rightEnabled: plList.contentX < plRail.maxScroll - 1
showViewAll: root.viewAllKind(plRail.sectionData, plRail.tabId) !== ""
onViewAllClicked: root.openViewAll(plRail.sectionData, plRail.tabId)
onPageLeft: plList.contentX = Math.max(0, plList.contentX - plRail.step)
onPageRight: plList.contentX = Math.min(plRail.maxScroll, plList.contentX + plRail.step)
}
ListView {
id: plList
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
model: plRail.sectionData.items
delegate: PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
@@ -636,32 +725,44 @@ Rectangle {
}
Component {
id: slimGridComp
SlimGrid { sectionData: parent.sectionData }
SlimGrid { sectionData: parent.sectionData; tabId: parent.railTab }
}
Component {
id: trackGridComp
SlimGrid { sectionData: parent.sectionData; tracks: true }
SlimGrid { sectionData: parent.sectionData; tabId: parent.railTab; tracks: true }
}
Component {
id: artistRailComp
Column {
id: arRail
property var sectionData: parent.sectionData
width: parent ? parent.width : 0
spacing: 12
Text {
text: sectionData.title
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
readonly property int perPage: Math.max(1, Math.floor((arList.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, arList.contentWidth - arList.width)
// ArtistCarousel.slint:141-147 pages with chevrons; a
// bare Text left the tail of an 18-artist rail
// unreachable. No "View all" — the Slint artist arms
// (topArtists / artistsToFollow) carry none.
QbzSectionHeader {
title: arRail.sectionData.title
leftEnabled: arList.contentX > 1
rightEnabled: arList.contentX < arRail.maxScroll - 1
onPageLeft: arList.contentX = Math.max(0, arList.contentX - arRail.step)
onPageRight: arList.contentX = Math.min(arRail.maxScroll, arList.contentX + arRail.step)
}
ListView {
id: arList
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
model: arRail.sectionData.items
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
@@ -777,49 +878,14 @@ Rectangle {
height: 32
spacing: 6
// GenreButton — accent fill + "N genres" while the shared
// "discover" selection is non-empty (1:1 Slint).
Rectangle {
id: genreBtn
readonly property bool active: root.genreCount > 0
width: genreRow.width
height: 32
radius: 6
color: genreBtn.active ? theme.accent
: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row {
id: genreRow
height: parent.height
leftPadding: 12
rightPadding: 14
spacing: 7
QbzIcon {
name: "list-filter"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: genreBtn.active ? "primary" : "secondary"
}
Text {
text: root.genreCount === 0
? QbzSession.tr("Filter by genre", QbzSession.trRev)
: root.genreCount === 1
? QbzSession.tr("1 genre", QbzSession.trRev)
: QbzSession.tr("{} genres", QbzSession.trRev)
.replace("{}", root.genreCount)
color: genreBtn.active ? theme.accentText : theme.textSecondary
font.pixelSize: 13
font.weight: genreBtn.active ? theme.weightMedium : theme.weightRegular
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: genreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: genrePopup.toggle()
}
// GenreButton — now the SHARED controls/BrowseGenreButton
// (the browse pages draw the same control; this was a
// verbatim copy of it). HomeView.slint:85 is the 32px
// variant, BrowseHeaderTools.slint:108 the 34px one.
BrowseGenreButton {
context: "discover"
btnHeight: 32
onClicked: genrePopup.toggle()
}
// GearButton — per-tab show/hide + reorder of the Discover
@@ -937,7 +1003,7 @@ Rectangle {
}
// Section rails.
SectionRails { sectionsModel: root.sections }
SectionRails { sectionsModel: root.sections; tabId: "home" }
}
// ===== Editor's Picks (phase 13) ========================
@@ -949,7 +1015,7 @@ Rectangle {
visible: QbzHome.homeLoading && root.editorSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.editorSections }
SectionRails { sectionsModel: root.editorSections; tabId: "editorPicks" }
}
// ===== For You (phase 13) =================================
@@ -961,7 +1027,7 @@ Rectangle {
visible: QbzHome.homeLoading && root.forYouSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.forYouSections }
SectionRails { sectionsModel: root.forYouSections; tabId: "forYou" }
}
// ===== Recommendations (external reco engine) =============
@@ -998,7 +1064,7 @@ Rectangle {
// Rails resolve progressively — a row that is still
// building, or whose service is not connected, is simply
// ABSENT from the document (never an empty frame).
SectionRails { sectionsModel: root.recoSections }
SectionRails { sectionsModel: root.recoSections; tabId: "recommendations" }
// Nothing built and nothing in flight: the Slint empty
// state, verbatim msgids.
@@ -0,0 +1,308 @@
// "See all releases" — QML port of label/LabelReleasesView.slint: the
// sub-view reached from the label landing's Releases carousel. A circular
// label header over the full album catalog, with a toolbar (search /
// group-by-artist / Hi-Res filter / sort / grid-list) and load-more paging.
//
// NO FIXED HEADER: everything scrolls in one Flickable (unlike the browse
// pages). The scrolling-page convention applies — Column padding 32 / 32 /
// top 11 / bottom 100, the .slint's NavButtons row dropped but its 22px
// spacer kept (see LabelView.qml).
//
// The rendered set (`visible` / `grouped`) is derived in RUST
// (src/label_qt.rs::derive_releases, the port of label.rs:177-249), including
// the fast path that reuses the live catalog when nothing is filtered.
// `hiresCount` is computed over the FULL loaded catalog BEFORE filtering
// (label.rs:190-193) — the count line reports the catalog's Hi-Res total,
// not the filtered one.
//
// Load more is driven by `hasMore`, NEVER by `total`: /label/getAlbums caps
// each page well below the catalog, so `total` is an unreliable gate
// (.slint:296-298).
//
// View mode is UI-only in the .slint (`LabelState.view-mode` is written
// straight from the toggle), so it stays purely in QML here too.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.labelReleasesJson)
} catch (e) {
return {}
}
}
readonly property var visibleAlbums: doc.visible || []
readonly property var grouped: doc.grouped || []
readonly property bool groupBy: doc.groupBy === true
readonly property int shown: doc.shown || 0
readonly property int total: doc.total || 0
readonly property int hiresCount: doc.hiresCount || 0
/// UI-only (see the header note).
property string viewMode: "grid"
readonly property var sortLabels: [
QbzSession.tr("Newest", QbzSession.trRev),
QbzSession.tr("Oldest", QbzSession.trRev),
QbzSession.tr("Title A-Z", QbzSession.trRev),
QbzSession.tr("Title Z-A", QbzSession.trRev),
QbzSession.tr("Artist A-Z", QbzSession.trRev),
]
readonly property var sortKeys: ["newest", "oldest", "title-asc", "title-desc", "artist-asc"]
readonly property int sortIndex: Math.max(0, root.sortKeys.indexOf(root.doc.sortBy || "newest"))
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
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
Item { width: 1; height: 22 }
// --- Label header (row spacing 24, right column spacing 6) ----
Row {
width: parent.width - 64
spacing: 24
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, same conscious deviation as LabelView.qml
// (both .slint label views use cover).
RoundedImage {
anchors.fill: parent
anchors.margins: 26
source: root.doc.artPath || ""
radius: 0
fit: "contain"
}
}
Column {
width: parent.width - 180 - 24
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
text: QbzSession.tr("LABEL", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
visible: root.total > 0
text: root.total + " " + QbzSession.tr("albums", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
}
Item { width: 1; height: 24 }
// --- Toolbar ---------------------------------------------------
Item {
width: parent.width - 64
height: 44
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - tools.width - 12)
spacing: 2
Text {
width: parent.width
text: QbzSession.tr("Releases", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.shown + " " + QbzSession.tr("albums", QbzSession.trRev)
+ (root.hiresCount > 0 ? " · " + root.hiresCount + " Hi-Res" : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
id: tools
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzLineEdit {
searchMode: true
expandable: true
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search this label…", QbzSession.trRev)
onEdited: function (v) { QbzHome.labelReleasesSearch(v) }
}
QbzToggleButton {
name: "user"
active: root.groupBy
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.labelReleasesSetGroup(!root.groupBy)
}
QbzToggleButton {
name: "list-filter"
active: root.doc.filterHires === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.labelReleasesSetHires(root.doc.filterHires !== true)
}
QbzSelect {
anchors.verticalCenter: parent.verticalCenter
menuWidth: 168
options: root.sortLabels
currentIndex: root.sortIndex
onSelected: function (i) { QbzHome.labelReleasesSetSort(root.sortKeys[i]) }
}
ViewModeToggle {
anchors.verticalCenter: parent.verticalCenter
mode: root.viewMode
onSetMode: function (m) { root.viewMode = m }
}
}
}
Item { width: 1; height: 24 }
// --- States ----------------------------------------------------
QbzSpinner {
visible: QbzHome.labelReleasesLoading && root.shown === 0
&& root.grouped.length === 0
size: 36
anchors.horizontalCenter: parent.horizontalCenter
}
// Search / filter matched nothing (the catalog itself is loaded).
Column {
visible: !QbzHome.labelReleasesLoading && root.total > 0 && root.shown === 0
anchors.horizontalCenter: parent.horizontalCenter
spacing: 10
QbzIcon {
name: "search"
width: 32
height: 32
anchors.horizontalCenter: parent.horizontalCenter
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No results", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
// --- Album collection -----------------------------------------
AlbumCollection {
width: parent.width - 64
albums: root.visibleAlbums
grouped: root.grouped
isGrouped: root.groupBy
viewMode: root.viewMode
cardWidth: 200
cardHeight: 266
cardGap: 24
listRowGap: 4
flick: flick
// .slint:280 — 11 padding-top + 22 spacer + 180 header + 24
// + ~42 toolbar + 24 ~= 303.
contentOffset: 303
}
// --- Load more (gated on hasMore, NEVER on total) -------------
Item {
visible: root.shown > 0 && root.doc.hasMore === true
width: parent.width - 64
height: visible ? 32 : 0
Rectangle {
anchors.centerIn: parent
width: loadMoreText.implicitWidth + 24
height: 32
color: "transparent"
Text {
id: loadMoreText
anchors.centerIn: parent
text: root.doc.loadMoreLoading === true
? QbzSession.tr("Loading…", QbzSession.trRev)
: QbzSession.tr("Load more", QbzSession.trRev)
color: loadMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: loadMoreArea
anchors.fill: parent
enabled: root.doc.loadMoreLoading !== true
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.labelReleasesLoadMore()
}
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
+634
View File
@@ -0,0 +1,634 @@
// 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. qml/rows/TrackRow.qml has no multi-select arm, and two
// of the .slint MultiSelectBar's seven actions (add-to-playlist,
// add-to-mixtape) have no bridge seam at all (TrackRow.qml:108-112), so a
// full port would ship two dead entries. The select disc renders DIMMED
// and inert (the ArtistView "Radio" precedent) and the bar is absent.
// - 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: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
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"
flick.contentY = 0
}
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 (TrackRow.qml's Loader/TextEdit pattern — QtQuick exposes no
// Clipboard type, and there is no toast seam, so the copy is silent).
Loader {
id: clipLoader
active: false
sourceComponent: TextEdit { visible: false }
}
function copyToClipboard(text) {
if (!text || text === "")
return
clipLoader.active = true
clipLoader.item.text = text
clipLoader.item.selectAll()
clipLoader.item.copy()
clipLoader.active = false
}
// ============================ 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
isFavorite: false
}
}
Component {
id: crPlaylist
PlaylistCard {
item: crCell.modelData
artSource: crCell.modelData.artPath || ""
}
}
Component {
id: crArtist
ArtistCard {
item: crCell.modelData
artSource: crCell.modelData.artPath || ""
// The .slint passes card-follow-mode "none" here;
// ArtistCard's hasFollowSeam already gates the chip
// off, so this is belt and braces.
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) {
if (a === "share")
root.copyToClipboard("https://open.qobuz.com/label/" + (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
barBg: root.ambientOn ? "transparent" : 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
}
}
}
// --- 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: DIMMED and inert (see the header note).
QbzCircleAction {
name: "square-check-big"
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter
}
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
item: modelData
number: index + 1
showArtwork: true
showAlbum: true
onPlayRequested: QbzHome.labelPlayTrack(modelData.id)
onEnqueueRequested: function (mode) {
QbzHome.labelEnqueueTrack(modelData.id, mode)
}
}
}
// Reveal control: 5 -> 20 -> 50, then back to 5.
Item { visible: root.topTracks.length > 5; width: 1; height: visible ? 4 : 0 }
Item {
visible: root.topTracks.length > 5
width: parent.width
height: visible ? 28 : 0
Rectangle {
anchors.centerIn: parent
width: revealText.implicitWidth + 24
height: 28
color: "transparent"
Text {
id: revealText
anchors.centerIn: parent
text: (root.previewCount < 50
&& root.topTracks.length > root.previewCount)
? QbzSession.tr("Load more", QbzSession.trRev)
: QbzSession.tr("View less", QbzSession.trRev)
color: revealArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: revealArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
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"
}
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
+80 -56
View File
@@ -132,6 +132,45 @@ Rectangle {
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc
property string playlistsSubTab: "favorites"
// ONE shared per-tab query, reset on entry — that IS the reference.
// In Slint every entry into one of the five tabs runs
// navigate_favorites -> load_favorites -> apply_favorites, which assigns
// "" to that tab's search (favorites.rs:570/595/614/627/649) AND, because
// the ExpandableSearch lives inside an `if` branch, destroys and
// re-creates it COLLAPSED. So a tab is never re-entered with a stale
// filter and five independent queries would be wrong. The port has one
// feed and no per-tab reload, so the reset is explicit here.
// The All tab is deliberately NOT reset: LibraryAllState.search is never
// cleared anywhere in the reference, so its feed stays filtered behind a
// collapsed magnifier — quirk included.
onActiveTabChanged: {
tabSearch = ""
if (tabSearchBox) tabSearchBox.closeSearch()
}
// Per-tab emptiness gate. Slint gates each per-tab toolbar (search AND
// genre/sort/sub-tab/view toggles) on the tab's FULL loaded set
// (FavoritesView.slint:593/636/707/722/747) — searching down to zero
// results must NOT hide it, so this counts the feed and never
// visibleItems(). Same keep-rules as visibleItems() minus the playlists
// sub-tab split, which makes the playlists count the favorites-OR-
// following the reference gate wants (counts.playlists is favorites
// only, library_qt.rs:479).
readonly property var tabTotals: {
var c = { "tracks": 0, "albums": 0, "artists": 0, "playlists": 0, "labels": 0 }
for (var i = 0; i < feed.length; i++) {
var it = feed[i]
if (it.kind === "track") { if (it.group === "favorites") c.tracks++ }
else if (it.kind === "album") { if (it.group === "favorites") c.albums++ }
else if (it.kind === "artist") c.artists++
else if (it.kind === "label") c.labels++
else if (it.kind === "playlist") c.playlists++
}
return c
}
readonly property bool tabHasItems:
activeTab === "all" || (tabTotals[activeTab] || 0) > 0
// ------------------------- derive (JS) ------------------------------
function visibleItems() {
var items = []
@@ -286,6 +325,15 @@ Rectangle {
windowDebounce.restart()
}
// All-tab search settle -> re-report the first window so the filtered
// head gets covers. Lives at ROOT scope (its callee already does) so it
// survives the search control it used to be parented to.
Timer {
id: allSearchDebounce
interval: 250
onTriggered: root.queueWindowReport(0, 59)
}
// ============================ components =============================
// Segmented tab (SegmentedTabBar's Segment) with count badge.
@@ -360,49 +408,9 @@ Rectangle {
}
}
// Toolbar search box (ExpandableSearch sm replica, fixed width).
component SearchBox: Rectangle {
property string placeholder: ""
property alias text: edit.text
signal searchEdited()
width: 220
height: 30
radius: 6
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
spacing: 6
QbzIcon {
name: "search"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
id: edit
width: parent.width - 19
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: searchEdited()
Text {
visible: edit.text === ""
anchors.fill: parent
text: placeholder
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
// (The inline toolbar SearchBox is gone — both toolbars now mount the
// shared controls/QbzLineEdit in its expandable arm, which IS
// primitives/ExpandableSearch.slint 1:1.)
// Card heart (live favorite toggle).
@@ -768,17 +776,20 @@ Rectangle {
visible: root.activeTab === "all"
spacing: 8
height: parent.height
SearchBox {
// FavoritesView.slint:801-813 — collapsed magnifier that
// opens LEFT over the tab bar; the All tab is the only
// one that debounces after the keystroke.
QbzLineEdit {
searchMode: true
expandable: true
sm: true
elevated: false // ExpandableSearch fill = surface-card
openWidth: 196 // = max-open-width
placeholder: QbzSession.tr("Search your library", QbzSession.trRev)
onSearchEdited: {
root.search = text
onEdited: function (v) {
root.search = v
allSearchDebounce.restart()
}
Timer {
id: allSearchDebounce
interval: 250
onTriggered: root.queueWindowReport(0, 59)
}
}
// Source switches (tooltips are the Slint copies).
ToolToggle { name: "shopping-bag"; active: root.showPurchases; onClicked: root.showPurchases = !root.showPurchases }
@@ -892,14 +903,27 @@ Rectangle {
}
// ===== Other-tab toolbars =====
SearchBox {
visible: root.activeTab !== "all"
// ONE instance for the five tabs: the reference clears the
// query on every tab entry (see onActiveTabChanged), so
// per-tab instances would only implement a persistence that
// does not exist. A Row skips `visible: false` children, so
// the 30px slot appears/disappears cleanly and, because the
// ROOT width never leaves collapsedSize while the field
// opens, nothing to its right ever moves.
QbzLineEdit {
id: tabSearchBox
visible: root.activeTab !== "all" && root.tabHasItems
searchMode: true
expandable: true
sm: true
elevated: false
openWidth: 196
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onSearchEdited: root.tabSearch = text
onEdited: function (v) { root.tabSearch = v }
}
// Albums sort popup (real, JS-side).
Rectangle {
visible: root.activeTab === "albums"
visible: root.activeTab === "albums" && root.tabHasItems
width: albumsSortRow.width
height: 30
radius: 6
@@ -958,7 +982,7 @@ Rectangle {
}
// Playlists sub-tab (Library / Following).
Rectangle {
visible: root.activeTab === "playlists"
visible: root.activeTab === "playlists" && root.tabHasItems
width: plSubRow.width
height: 30
radius: 6
+217
View File
@@ -0,0 +1,217 @@
// Recently Played Albums + Most Played Albums — ONE view for the two
// local-history "View all" pages, because discover/RecentAlbumsView.slint
// and discover/MostPlayedAlbumsView.slint are the same page with five
// differences (the .slint keeps them apart only because Slint has no
// runtime-armed component). `mode` on the published document arms them:
//
// | recent | mostPlayed
// title | "Recently Played Albums" | "Most Played Albums"
// card height | 266 | 286 (the "{} plays" line)
// search box | none | 240x34, right-anchored
// root background | ambient-aware (:31) | surface-main (:26)
// header backgrnd | surface-main (:43) | surface-main (:34)
//
// Everything else is byte-identical, INCLUDING the loading block (spinner 36
// + @tr("Loading…")) and the empty msgid.
//
// NO OFFLINE GATE, deliberately: AppShell.slint mounts both routes while
// offline (:456-470) — neither is in `qobuz-view-blocked` (:112-136) — because
// both read a LOCAL store. Rendering QbzOfflinePlaceholder here would hide
// data that is perfectly available.
//
// NAV BUTTONS: back/forward live in the shell header, so the title starts at
// x: 48 (see DiscoverBrowseView.qml's note).
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.playHistoryJson)
} catch (e) {
return {}
}
}
// The document is the source of truth; `currentView` only covers the
// frame before the first publish lands.
readonly property string mode: doc.mode
|| (QbzShell.currentView === "mostplayedalbums" ? "mostPlayed" : "recent")
readonly property bool mostPlayed: mode === "mostPlayed"
readonly property var items: doc.items || []
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// RecentAlbumsView.slint:31 is ambient-aware; MostPlayedAlbumsView
// .slint:26 is plain surface-main.
color: (!mostPlayed && ambientOn) ? "transparent" : theme.surfaceMain
radius: 12
Column {
anchors.fill: parent
spacing: 0
// --- Fixed 56px header --------------------------------------------
Item {
width: parent.width
height: 56
// Unconditional surface-main on BOTH pages (:43 / :34) — these
// two never take the ambient bar alpha the browse pages do.
Rectangle {
anchors.fill: parent
color: theme.surfaceMain
}
Text {
x: 48
y: 25 - height / 2
width: Math.max(0, parent.width - 48 - 32 - (root.mostPlayed ? 240 + 16 : 0))
text: root.mostPlayed
? QbzSession.tr("Most Played Albums", QbzSession.trRev)
: QbzSession.tr("Recently Played Albums", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
// Most Played only: a 240x34 title/artist filter. No clear-X in
// the .slint (:59-104), so the shared search arm is not used here.
Rectangle {
visible: root.mostPlayed
x: parent.width - width - 32
y: 25 - height / 2
width: 240
height: 34
radius: 6
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
width: parent.width - 14 - 8
height: parent.height
clip: true
TextInput {
id: mpSearch
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
selectByMouse: true
onTextEdited: QbzHome.mostPlayedFilter(text)
// The opener resets the box in the same hop that
// raises `loading`; re-seed while not editing.
Binding {
target: mpSearch
property: "text"
value: root.doc.search || ""
when: !mpSearch.activeFocus
}
}
Text {
visible: mpSearch.text === ""
anchors.fill: parent
text: QbzSession.tr("Search…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
// --- Scrolling grid ------------------------------------------------
Item {
width: parent.width
height: parent.height - 56
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
// Spinner + "Loading…" — identical on both pages.
Column {
visible: QbzHome.playHistoryLoading
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
QbzSpinner {
size: 36
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
}
}
Text {
visible: !QbzHome.playHistoryLoading && root.items.length === 0
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Albums you play will appear here.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
AlbumCollection {
visible: !QbzHome.playHistoryLoading
width: parent.width - 64
albums: root.items
viewMode: "grid"
cardWidth: 200
// The 20px difference IS the "{} plays" line.
cardHeight: root.mostPlayed ? 286 : 266
cardGap: 24
showPlays: root.mostPlayed
flick: flick
contentOffset: 8
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
}
}
@@ -0,0 +1,349 @@
// 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,
// infinite scroll 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"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
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: doc.viewMode || "grid"
readonly property int headerH: tags.length > 0 ? 92 : 56
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)
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: 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: 56
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Text {
x: 48
y: 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: 25 - height / 2
spacing: 8
QbzLineEdit {
searchMode: true
width: 200
placeholder: QbzSession.tr("Search…", QbzSession.trRev)
text: root.query
onEdited: function (v) { QbzHome.playlistBrowseSearch(v) }
}
BrowseGenreButton {
context: "discover"
onClicked: genrePopup.toggle()
}
ViewModeToggle {
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 ? 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: 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
onContentYChanged: {
if (contentY + height >= contentHeight - 600
&& root.doc.hasMore === true
&& !QbzHome.playlistBrowseLoadingMore
&& !QbzHome.playlistBrowseLoading
&& root.query === "")
QbzHome.playlistBrowseLoadMore()
}
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
}
// --- Grid ----------------------------------------------
Item {
id: plGrid
visible: !QbzHome.playlistBrowseLoading && root.viewMode !== "list"
width: parent.width - 64
readonly property int cardW: 200
readonly property int cardH: 246
readonly property int gap: 24
readonly property int columns: Math.max(
1, Math.floor((width + gap) / (cardW + gap)))
readonly property int rows: Math.ceil(root.items.length / columns)
height: rows > 0 ? rows * cardH + (rows - 1) * gap : 0
Repeater {
model: (root.viewMode !== "list") ? root.items : []
delegate: Item {
id: plCell
required property var modelData
required property int index
x: (plCell.index % plGrid.columns) * (plGrid.cardW + plGrid.gap)
y: Math.floor(plCell.index / plGrid.columns) * (plGrid.cardH + plGrid.gap)
width: plGrid.cardW
height: plGrid.cardH
PlaylistCard {
// `body-opens: true` in the .slint — the
// card's body click opens the playlist and
// the overlay button plays it.
item: plCell.modelData
artSource: plCell.modelData.artPath || ""
}
}
}
}
// --- List ----------------------------------------------
Column {
visible: !QbzHome.playlistBrowseLoading && root.viewMode === "list"
width: parent.width - 64
spacing: 2
Repeater {
model: (root.viewMode === "list") ? root.items : []
delegate: PlaylistListRow {
required property var modelData
required property int index
item: modelData
rowIndex: index
}
}
}
Item { visible: QbzHome.playlistBrowseLoadingMore; width: 1; height: 24 }
QbzSpinner {
visible: QbzHome.playlistBrowseLoadingMore
size: 28
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
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
}
}
+158
View File
@@ -0,0 +1,158 @@
// PlaylistListRow — 1:1 port of primitives/PlaylistListRow.slint: the LIST
// arm of the Qobuz Playlists browse page. 60px, radius 8, odd-row zebra,
// 44px thumb, name over "owner • N tracks", a play affordance and the ⋯
// menu. The whole row body opens the playlist.
//
// item contract: home_qt::HomeCard as the playlist pages publish it —
// { id, title, subtitle, artPath }.
//
// Menu inventory vs the .slint's five rows: Play / Play next / Play later /
// Add to queue are wired through QbzPlayer; "Follow on Qobuz" is NOT — the
// follow invokable takes the playlist that is currently OPEN
// (`playlistToggleFollow`), so a row cannot call it, exactly as
// PlaylistCard.qml documents for "Copy to your library". The entry stays out
// of the menu rather than rendering and doing nothing.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
property var item: ({})
/// Row index — drives the even/odd zebra (coherent with TrackRow).
/// NOT named `index`: a Repeater injects one into the delegate context
/// and a same-named property here would shadow it.
property int rowIndex: 0
QbzTheme { id: theme }
readonly property bool rowHovered: rowArea.containsMouse || playArea.containsMouse
|| moreArea.containsMouse
width: parent ? parent.width : 0
height: 60
radius: theme.radiusSm
color: rowHovered ? theme.surfaceHover
: (rowIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
else
QbzBridge.openPlaylist(root.item.id || "")
}
}
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "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) {
var id = root.item.id || ""
if (id === "") return
if (a === "play") QbzPlayer.playPlaylistById(id)
else QbzPlayer.enqueuePlaylistById(id, a)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 12
Rectangle {
width: 44
height: 44
anchors.verticalCenter: parent.verticalCenter
radius: 6
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.item.artPath || ""
radius: 6
}
QbzIcon {
visible: (root.item.artPath || "") === ""
name: "list-music"
width: 20
height: 20
anchors.centerIn: parent
tintName: "muted"
}
}
Column {
width: parent.width - 44 - 2 * 30 - 4 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.item.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.item.subtitle || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Item {
width: 30
height: parent.height
QbzIcon {
name: "play-fill"
width: 16
height: 16
anchors.centerIn: parent
tintName: root.rowHovered ? "primary" : "muted"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playPlaylistById(root.item.id || "")
}
}
Item {
width: 30
height: parent.height
QbzIcon {
name: "ellipsis"
width: 16
height: 16
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
}
}
+17 -1
View File
@@ -596,8 +596,24 @@ fn track_row_to_queue(row: &TrackRow) -> QueueTrack {
}
/// Stash the queue at publish time (called from load_artist_view).
///
/// The queue is born STAMPED with the PAGE's artist — playback.rs stamps
/// ("artist", artist_id) at every artist play path (:2848 Popular, :3108
/// shuffled, :3170 from-row) and never derives it. Deriving is wrong here in
/// two ways: `derive_context` returns the shared ALBUM before it ever tests the
/// artist (so an artist whose Popular Tracks come from one album lands on that
/// album), and `TrackRow.artist_id` is the track's OWN credited performer
/// (`map_track`), so a single featured collaboration makes the queue share no
/// artist and lose its origin entirely. `data.id` is the page id — the thing
/// the user actually launched.
fn stash_top_queue(data: &ArtistViewData) {
let queue: Vec<QueueTrack> = data.top_tracks.iter().map(track_row_to_queue).collect();
let mut queue: Vec<QueueTrack> = data.top_tracks.iter().map(track_row_to_queue).collect();
if !data.id.is_empty() {
for track in queue.iter_mut() {
track.context_kind = Some("artist".to_string());
track.context_id = Some(data.id.clone());
}
}
*TOP_QUEUE.lock().unwrap() = queue;
}
+9 -1
View File
@@ -476,7 +476,15 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option<std::path::PathBuf> {
return Some(out);
}
let img = image::open(src).ok()?;
// The artwork cache stores files as `.img` — `image::open` guesses the
// format from the EXTENSION and fails on it, which is why not one
// derivative was ever produced. Sniff the content instead.
let img = image::ImageReader::open(src)
.ok()?
.with_guessed_format()
.ok()?
.decode()
.ok()?;
// Upscaling would only cost bytes — hand back the source instead.
if img.width() <= w && img.height() <= h {
return Some(std::path::PathBuf::from(src));
+9 -1
View File
@@ -138,7 +138,15 @@ pub fn for_cover_blocking(cover_path: &str) -> Option<String> {
return Some(crate::artwork_qt::file_url(&out.to_string_lossy()));
}
let img = image::open(src_path).ok()?.to_rgba8();
// Same trap as artwork_qt::scaled_path: the cache uses `.img`, and
// `image::open` guesses by extension.
let img = image::ImageReader::open(src_path)
.ok()?
.with_guessed_format()
.ok()?
.decode()
.ok()?
.to_rgba8();
let atmo = atmosphere_from(&img);
atmo.save(&out).ok()?;
Some(crate::artwork_qt::file_url(&out.to_string_lossy()))
+937
View File
@@ -0,0 +1,937 @@
//! The Discover "View all" full-list pages + the two local play-history
//! pages — the Slint-free port of `crates/qbz/src/discover_browse.rs`,
//! `playlist_browse.rs` and the `RecentAlbumsState` /
//! `MostPlayedAlbumsState` halves of `main.rs`.
//!
//! Four routes live here, all publishing ONE JSON document each onto
//! `QbzHome` (the phase-23 bridge pattern):
//!
//! `discoverbrowse` DiscoverBrowseView.slint — one /discover/<module>
//! endpoint, infinite scroll (PAGE_SIZE 50), shared
//! genre filter, client-side search.
//! `playlistbrowse` PlaylistBrowseView.slint — /discover/playlists,
//! server-side category tag + genre, client-side
//! search.
//! `recentalbums` RecentAlbumsView.slint — the LOCAL play-history
//! album store (`recently_qt`, capped 24). No network.
//! `mostplayedalbums` MostPlayedAlbumsView.slint — the LOCAL play-count
//! store (`album_play_history::all_albums`, NOT the
//! rail's `top_albums(20)`), with a title/artist
//! filter.
//!
//! Split out of `home_qt.rs` deliberately: that module is already ~1.2 k
//! lines and these are four independent controllers, not more Home mapping.
//!
//! POC-NOTEs (deltas vs the Slint controllers, each deliberate):
//! - Blacklist filtering: `crate::artist_blacklist` does not exist in this
//! port (see the phase-1 skip documented in `home_qt.rs`), so the
//! `album_blacklisted` drop the Slint applies on the discover pages is
//! inherited as a no-op. Playlists are unfiltered in Slint too.
//! - Scroll restore: the Slint pages each call `NavState.report-scroll` and
//! re-apply `scroll-restore` on back/forward. The Qt port has NO
//! scroll-restore mechanism at all (`nav_qt` stores view ids only), so
//! these pages do not restore scroll. Dropped knowingly, not missed.
//! - `nav_qt` stores view IDs, so back-to-`discoverbrowse` re-mounts
//! whatever document is currently published (same limitation the album /
//! artist routes already have).
use std::sync::Mutex;
use cxx_qt_lib::QString;
use crate::home_qt::{attach_card_art, HomeCard};
/// Page size for both catalog pages (discover_browse.rs:33 /
/// playlist_browse.rs:34).
pub const PAGE_SIZE: u32 = 50;
// ===========================================================================
// Shared helpers
// ===========================================================================
/// Lowercase-contains over title + artist — the Slint `visible` derivation.
fn matches(card: &HomeCard, needle: &str) -> bool {
needle.is_empty()
|| card.title.to_lowercase().contains(needle)
|| card.artist.to_lowercase().contains(needle)
}
/// JSON array of the cards a page should render (search applied).
fn visible_json(cards: &[HomeCard], query: &str) -> String {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return serde_json::to_string(cards).unwrap_or_else(|_| "[]".to_string());
}
let filtered: Vec<&HomeCard> = cards.iter().filter(|c| matches(c, &needle)).collect();
serde_json::to_string(&filtered).unwrap_or_else(|_| "[]".to_string())
}
// ===========================================================================
// DiscoverBrowse
// ===========================================================================
struct DiscoverBrowse {
endpoint: String,
title: String,
/// Next page offset — advanced by the FETCHED count, never by the
/// surviving count (discover_browse.rs:36-38: dropping rows client-side
/// must not shift the server window).
next_offset: u32,
has_more: bool,
cards: Vec<HomeCard>,
query: String,
/// "grid" | "list" — PERSISTS across navigations, like the Slint state
/// global. Seeded to "grid" on the first open (an empty view-mode
/// renders NOTHING: AlbumCollectionView.slint:294 tests `== "grid"`).
view_mode: String,
loading: bool,
loading_more: bool,
/// Bumped on every fresh open so a late page for the previous endpoint
/// is dropped instead of appended (the artist_qt generation guard).
generation: u64,
}
impl DiscoverBrowse {
const fn new() -> Self {
Self {
endpoint: String::new(),
title: String::new(),
next_offset: 0,
has_more: false,
cards: Vec::new(),
query: String::new(),
view_mode: String::new(),
loading: false,
loading_more: false,
generation: 0,
}
}
}
static DISCOVER: Mutex<DiscoverBrowse> = Mutex::new(DiscoverBrowse::new());
fn publish_discover() {
let (json, loading, loading_more) = {
let Ok(s) = DISCOVER.lock() else {
return;
};
let doc = format!(
r#"{{"title":{},"endpoint":{},"viewMode":{},"query":{},"hasMore":{},"total":{},"items":{}}}"#,
json_str(&s.title),
json_str(&s.endpoint),
json_str(&s.view_mode),
json_str(&s.query),
s.has_more,
s.cards.len(),
visible_json(&s.cards, &s.query),
);
(doc, s.loading, s.loading_more)
};
crate::home_bridge::ui(move |mut b| {
b.as_mut()
.set_discover_browse_json(QString::from(json.as_str()));
b.as_mut().set_discover_browse_loading(loading);
b.as_mut().set_discover_browse_loading_more(loading_more);
});
}
/// Minimal JSON string escaping for the hand-assembled documents above
/// (serde for the card arrays, this for the scalar fields).
fn json_str(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
/// Rail "View all" on a generic album carousel.
///
/// Order matters and mirrors `main.rs::open_album`: offline gate ->
/// `nav_qt::record` (which ALREADY publishes `currentView`, so there is no
/// second `set_current_view` call) -> clear the document + raise `loading`
/// in ONE bridge hop -> fetch. Without the clear the page renders the
/// PREVIOUS module until the fetch lands.
pub fn open_discover_browse(endpoint: String, title: String) {
if crate::offline_fwd::engine().status().is_offline() {
return;
}
if endpoint.is_empty() {
log::warn!("[qbz-qt] open_discover_browse with an empty endpoint; ignoring");
return;
}
crate::nav_qt::record("discoverbrowse");
let generation = {
let Ok(mut s) = DISCOVER.lock() else {
return;
};
let mode = if s.view_mode.is_empty() {
"grid".to_string()
} else {
s.view_mode.clone()
};
s.generation = s.generation.wrapping_add(1);
s.endpoint = endpoint;
s.title = title;
s.next_offset = 0;
s.has_more = false;
s.cards.clear();
s.query.clear();
s.view_mode = mode;
s.loading = true;
s.loading_more = false;
s.generation
};
publish_discover();
fetch_discover(generation);
}
/// Infinite scroll. Guarded exactly like DiscoverBrowseView.slint:126-135 —
/// the search filter is client-side, so paging is disabled while one is
/// active (appending would bring back unfiltered rows).
pub fn discover_browse_load_more() {
let generation = {
let Ok(mut s) = DISCOVER.lock() else {
return;
};
if s.loading || s.loading_more || !s.has_more || !s.query.trim().is_empty() {
return;
}
s.loading_more = true;
s.generation
};
publish_discover();
fetch_discover(generation);
}
pub fn discover_browse_search(query: String) {
{
let Ok(mut s) = DISCOVER.lock() else {
return;
};
s.query = query;
}
publish_discover();
}
pub fn discover_browse_set_view_mode(mode: String) {
{
let Ok(mut s) = DISCOVER.lock() else {
return;
};
s.view_mode = if mode == "list" {
"list".to_string()
} else {
"grid".to_string()
};
}
publish_discover();
}
/// The shared "discover" genre selection changed while this page is on
/// screen: re-fetch from offset 0 (the Slint reaches the same place through
/// `GenreFilterActions` -> the browse controller's refetch;
/// `genre_filter_qt::after_selection_change` only knows how to reload Home).
pub fn discover_browse_genre_changed() {
let generation = {
let Ok(mut s) = DISCOVER.lock() else {
return;
};
if s.endpoint.is_empty() {
return;
}
s.generation = s.generation.wrapping_add(1);
s.next_offset = 0;
s.has_more = false;
s.cards.clear();
s.loading = true;
s.loading_more = false;
s.generation
};
publish_discover();
fetch_discover(generation);
}
fn fetch_discover(generation: u64) {
let runtime = crate::app();
let (endpoint, offset) = {
let Ok(s) = DISCOVER.lock() else {
return;
};
(s.endpoint.clone(), s.next_offset)
};
crate::spawn(async move {
let genre_ids = crate::genre_filter_qt::discover_genre_ids();
match runtime
.core()
.get_discover_albums(&endpoint, genre_ids, offset, PAGE_SIZE)
.await
{
Ok(data) => {
let has_more = data.has_more;
let fetched = data.items.len() as u32;
let mut cards: Vec<HomeCard> =
data.items.into_iter().map(crate::home_qt::map_album).collect();
let missing = attach_card_art(&mut cards);
{
let Ok(mut s) = DISCOVER.lock() else {
return;
};
if s.generation != generation {
return;
}
s.cards.extend(cards);
s.next_offset = offset.saturating_add(fetched);
s.has_more = has_more && fetched > 0;
s.loading = false;
s.loading_more = false;
}
publish_discover();
if !missing.is_empty() {
crate::artwork_qt::download_missing(missing).await;
refresh_discover_art(generation);
}
}
Err(e) => {
log::warn!("[qbz-qt] discover browse fetch failed ({endpoint}): {e}");
{
let Ok(mut s) = DISCOVER.lock() else {
return;
};
if s.generation != generation {
return;
}
s.loading = false;
s.loading_more = false;
s.has_more = false;
}
publish_discover();
}
}
});
}
fn refresh_discover_art(generation: u64) {
let mut cards = {
let Ok(mut s) = DISCOVER.lock() else {
return;
};
if s.generation != generation {
return;
}
std::mem::take(&mut s.cards)
};
let _ = attach_card_art(&mut cards);
{
let Ok(mut s) = DISCOVER.lock() else {
return;
};
if s.generation != generation {
// The page moved on while we were re-attaching; the newer state
// owns `cards` now, so drop ours instead of clobbering it.
return;
}
s.cards = cards;
}
publish_discover();
}
// ===========================================================================
// PlaylistBrowse
// ===========================================================================
#[derive(Clone, serde::Serialize)]
struct TagRow {
slug: String,
name: String,
selected: bool,
}
struct PlaylistBrowse {
title: String,
tags: Vec<TagRow>,
/// Server-side category. Cleared ONLY on a fresh open from the rail
/// (playlist_browse.rs:118-121 `reset_tag`).
selected_tag: String,
next_offset: u32,
has_more: bool,
cards: Vec<HomeCard>,
query: String,
view_mode: String,
loading: bool,
loading_more: bool,
generation: u64,
}
impl PlaylistBrowse {
const fn new() -> Self {
Self {
title: String::new(),
tags: Vec::new(),
selected_tag: String::new(),
next_offset: 0,
has_more: false,
cards: Vec::new(),
query: String::new(),
view_mode: String::new(),
loading: false,
loading_more: false,
generation: 0,
}
}
}
static PLAYLISTS: Mutex<PlaylistBrowse> = Mutex::new(PlaylistBrowse::new());
fn publish_playlists() {
let (json, loading, loading_more) = {
let Ok(s) = PLAYLISTS.lock() else {
return;
};
let doc = format!(
r#"{{"title":{},"tags":{},"selectedTag":{},"viewMode":{},"query":{},"hasMore":{},"total":{},"items":{}}}"#,
json_str(&s.title),
serde_json::to_string(&s.tags).unwrap_or_else(|_| "[]".to_string()),
json_str(&s.selected_tag),
json_str(&s.view_mode),
json_str(&s.query),
s.has_more,
s.cards.len(),
visible_json(&s.cards, &s.query),
);
(doc, s.loading, s.loading_more)
};
crate::home_bridge::ui(move |mut b| {
b.as_mut()
.set_playlist_browse_json(QString::from(json.as_str()));
b.as_mut().set_playlist_browse_loading(loading);
b.as_mut().set_playlist_browse_loading_more(loading_more);
});
}
/// "View all" on the Qobuz Playlists rail. The search query clears on a
/// fresh navigation; the view mode AND the selected tag persist (the Slint
/// clears the tag only through `reset_tag`, which this entry point IS —
/// `main.rs:17519 on_open_playlist_browse` passes `reset_tag = true`).
pub fn open_playlist_browse() {
if crate::offline_fwd::engine().status().is_offline() {
return;
}
crate::nav_qt::record("playlistbrowse");
let generation = {
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
let mode = if s.view_mode.is_empty() {
"grid".to_string()
} else {
s.view_mode.clone()
};
s.generation = s.generation.wrapping_add(1);
s.title = qbz_i18n::t("Qobuz Playlists");
s.selected_tag.clear();
for tag in s.tags.iter_mut() {
tag.selected = false;
}
s.next_offset = 0;
s.has_more = false;
s.cards.clear();
s.query.clear();
s.view_mode = mode;
s.loading = true;
s.loading_more = false;
s.generation
};
publish_playlists();
fetch_tags_once();
fetch_playlists(generation);
}
pub fn playlist_browse_load_more() {
let generation = {
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
if s.loading || s.loading_more || !s.has_more || !s.query.trim().is_empty() {
return;
}
s.loading_more = true;
s.generation
};
publish_playlists();
fetch_playlists(generation);
}
pub fn playlist_browse_search(query: String) {
{
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
s.query = query;
}
publish_playlists();
}
pub fn playlist_browse_set_view_mode(mode: String) {
{
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
s.view_mode = if mode == "list" {
"list".to_string()
} else {
"grid".to_string()
};
}
publish_playlists();
}
/// Category radio: SERVER-side, so the catalog is re-fetched from offset 0.
pub fn playlist_browse_select_tag(slug: String) {
let generation = {
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
s.selected_tag = slug.clone();
for tag in s.tags.iter_mut() {
tag.selected = tag.slug == slug;
}
s.generation = s.generation.wrapping_add(1);
s.next_offset = 0;
s.has_more = false;
s.cards.clear();
s.loading = true;
s.loading_more = false;
s.generation
};
publish_playlists();
fetch_playlists(generation);
}
/// Same shape as the discover page: the shared genre selection is
/// server-side here too.
pub fn playlist_browse_genre_changed() {
let generation = {
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
s.generation = s.generation.wrapping_add(1);
s.next_offset = 0;
s.has_more = false;
s.cards.clear();
s.loading = true;
s.loading_more = false;
s.generation
};
publish_playlists();
fetch_playlists(generation);
}
/// `/playlist/getTags` once per session — the tag bar is static per locale
/// and a re-fetch on every visit would flash the bar in and out.
fn fetch_tags_once() {
{
let Ok(s) = PLAYLISTS.lock() else {
return;
};
if !s.tags.is_empty() {
return;
}
}
let runtime = crate::app();
crate::spawn(async move {
match runtime.core().get_playlist_tags().await {
Ok(tags) => {
{
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
let selected = s.selected_tag.clone();
s.tags = tags
.into_iter()
.map(|t| TagRow {
selected: t.slug == selected,
slug: t.slug,
name: t.name,
})
.collect();
}
publish_playlists();
}
Err(e) => log::warn!("[qbz-qt] playlist tags fetch failed: {e}"),
}
});
}
/// `owner • N tracks` (playlist_browse.rs:59-73 — THREE spaces each side
/// of the bullet; owner alone when the count is 0).
fn playlist_subtitle(owner: &str, tracks: u32) -> String {
if tracks == 0 {
return owner.to_string();
}
let count = qbz_i18n::tf(
"{} track",
"{} tracks",
tracks as i64,
&[tracks.to_string().as_str()],
);
format!("{owner} • {count}")
}
fn fetch_playlists(generation: u64) {
let runtime = crate::app();
let (tag, offset) = {
let Ok(s) = PLAYLISTS.lock() else {
return;
};
(
if s.selected_tag.is_empty() {
None
} else {
Some(s.selected_tag.clone())
},
s.next_offset,
)
};
crate::spawn(async move {
let genre_ids = crate::genre_filter_qt::discover_genre_ids();
match runtime
.core()
.get_discover_playlists(tag, genre_ids, Some(PAGE_SIZE), Some(offset))
.await
{
Ok(page) => {
let has_more = page.has_more;
let fetched = page.items.len() as u32;
let mut cards: Vec<HomeCard> = page
.items
.into_iter()
.map(|p| {
let owner = if p.owner.name.is_empty() {
"Qobuz".to_string()
} else {
p.owner.name.clone()
};
let tracks = p.tracks_count;
let mut card = crate::home_qt::map_playlist(p);
card.subtitle = playlist_subtitle(&owner, tracks);
card
})
.collect();
let missing = attach_card_art(&mut cards);
{
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
if s.generation != generation {
return;
}
s.cards.extend(cards);
s.next_offset = offset.saturating_add(fetched);
s.has_more = has_more && fetched > 0;
s.loading = false;
s.loading_more = false;
}
publish_playlists();
if !missing.is_empty() {
crate::artwork_qt::download_missing(missing).await;
refresh_playlist_art(generation);
}
}
Err(e) => {
log::warn!("[qbz-qt] playlist browse fetch failed: {e}");
{
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
if s.generation != generation {
return;
}
s.loading = false;
s.loading_more = false;
s.has_more = false;
}
publish_playlists();
}
}
});
}
fn refresh_playlist_art(generation: u64) {
let mut cards = {
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
if s.generation != generation {
return;
}
std::mem::take(&mut s.cards)
};
let _ = attach_card_art(&mut cards);
{
let Ok(mut s) = PLAYLISTS.lock() else {
return;
};
if s.generation != generation {
return;
}
s.cards = cards;
}
publish_playlists();
}
// ===========================================================================
// Play history — Recently Played Albums / Most Played Albums
// ===========================================================================
struct PlayHistory {
/// "recent" | "mostPlayed" — the view arms off this, so it travels on
/// the document instead of being re-derived from `currentView` (a back
/// navigation would race that).
mode: String,
cards: Vec<HomeCard>,
search: String,
loading: bool,
generation: u64,
}
impl PlayHistory {
const fn new() -> Self {
Self {
mode: String::new(),
cards: Vec::new(),
search: String::new(),
loading: false,
generation: 0,
}
}
}
static HISTORY: Mutex<PlayHistory> = Mutex::new(PlayHistory::new());
fn publish_history() {
let (json, loading) = {
let Ok(s) = HISTORY.lock() else {
return;
};
let doc = format!(
r#"{{"mode":{},"search":{},"total":{},"items":{}}}"#,
json_str(&s.mode),
json_str(&s.search),
s.cards.len(),
serde_json::to_string(&s.cards).unwrap_or_else(|_| "[]".to_string()),
);
(doc, s.loading)
};
crate::home_bridge::ui(move |mut b| {
b.as_mut().set_play_history_json(QString::from(json.as_str()));
b.as_mut().set_play_history_loading(loading);
});
}
/// NO offline gate on either history route: the Slint mounts both while
/// offline (AppShell.slint:456-470 — neither appears in `qobuz-view-blocked`
/// at :112-136) because both read a LOCAL store.
pub fn open_recent_albums() {
crate::nav_qt::record("recentalbums");
let generation = {
let Ok(mut s) = HISTORY.lock() else {
return;
};
s.generation = s.generation.wrapping_add(1);
s.mode = "recent".to_string();
s.cards.clear();
s.search.clear();
s.loading = true;
s.generation
};
publish_history();
load_history(generation);
}
pub fn open_most_played_albums() {
crate::nav_qt::record("mostplayedalbums");
let generation = {
let Ok(mut s) = HISTORY.lock() else {
return;
};
s.generation = s.generation.wrapping_add(1);
s.mode = "mostPlayed".to_string();
s.cards.clear();
// The opener resets the box in the same hop that raises `loading`
// (Slint main.rs:3860-3869).
s.search.clear();
s.loading = true;
s.generation
};
publish_history();
load_history(generation);
}
/// Most Played only: lowercase-contains over title/artist across the WHOLE
/// store (Slint main.rs:3877-3897 — the filter re-derives from
/// `MOST_PLAYED_ROWS`, it does not narrow the already-filtered model).
pub fn most_played_filter(query: String) {
let generation = {
let Ok(mut s) = HISTORY.lock() else {
return;
};
if s.mode != "mostPlayed" {
return;
}
s.search = query;
s.generation
};
load_history(generation);
}
fn load_history(generation: u64) {
crate::spawn(async move {
let (mode, needle) = {
let Ok(s) = HISTORY.lock() else {
return;
};
(s.mode.clone(), s.search.trim().to_lowercase())
};
// Both stores are local SQLite / JSON reads; keep them off the Qt
// thread anyway (the play-history table can hold thousands of rows).
let filter = needle.clone();
let mut cards: Vec<HomeCard> = tokio::task::spawn_blocking(move || {
let needle = filter;
if mode == "mostPlayed" {
qbz_app::settings::album_play_history::all_albums()
.into_iter()
.filter(|r| {
needle.is_empty()
|| r.title.to_lowercase().contains(&needle)
|| r.artist.to_lowercase().contains(&needle)
})
.map(|r| HomeCard {
is_pinned: crate::sidebar_qt::is_pinned("album", &r.album_id),
id: r.album_id,
title: r.title,
artist: r.artist,
artist_id: r.artist_id,
year: r.year,
quality_tier: r.quality_tier,
quality_label: r.quality_label,
art_url: r.artwork_url,
// The 20px the 286px card is taller than every other
// album card (AlbumCard.slint:508).
plays: r.plays,
..HomeCard::default()
})
.collect::<Vec<_>>()
} else {
crate::recently_qt::load_albums()
.into_iter()
.map(crate::home_qt::map_recent_album)
.collect::<Vec<_>>()
}
})
.await
.unwrap_or_default();
let missing = attach_card_art(&mut cards);
{
let Ok(mut s) = HISTORY.lock() else {
return;
};
// Generation guards the mode switch; the needle guards a
// keystroke race — typing "abc" fires three overlapping tasks and
// a slow "ab" landing last would repaint stale rows under a box
// that reads "abc".
if s.generation != generation || s.search.trim().to_lowercase() != needle {
return;
}
s.cards = cards;
s.loading = false;
}
publish_history();
if !missing.is_empty() {
crate::artwork_qt::download_missing(missing).await;
let mut cards = {
let Ok(mut s) = HISTORY.lock() else {
return;
};
if s.generation != generation {
return;
}
std::mem::take(&mut s.cards)
};
let _ = attach_card_art(&mut cards);
{
let Ok(mut s) = HISTORY.lock() else {
return;
};
if s.generation != generation {
return;
}
s.cards = cards;
}
publish_history();
}
});
}
// ===========================================================================
// Live language switch
// ===========================================================================
/// Re-publish whichever browse page is on screen after a language change.
/// The Rust-built strings on these documents are the DiscoverBrowse title
/// (it arrives already translated from the rail), the playlist page title
/// and the playlist row subtitles (`tf()` plurals) — everything else is
/// `@tr` on the QML side and re-evaluates on `trRev`.
///
/// GLUE NEEDED: nothing calls this yet — `main.rs::apply_language` only
/// republishes "album" / "artist" off `LAST_DETAIL`. See the report.
#[allow(dead_code)]
pub fn republish_for_language() {
{
if let Ok(mut s) = PLAYLISTS.lock() {
if !s.title.is_empty() {
s.title = qbz_i18n::t("Qobuz Playlists");
}
}
}
publish_discover();
publish_playlists();
publish_history();
}
#[cfg(test)]
mod tests {
use super::*;
fn card(title: &str, artist: &str) -> HomeCard {
HomeCard {
title: title.to_string(),
artist: artist.to_string(),
..HomeCard::default()
}
}
#[test]
fn search_matches_title_or_artist_case_insensitively() {
let c = card("Kind of Blue", "Miles Davis");
assert!(matches(&c, ""));
assert!(matches(&c, "blue"));
assert!(matches(&c, "miles"));
assert!(!matches(&c, "coltrane"));
}
#[test]
fn playlist_subtitle_shape() {
// Owner alone when the playlist reports no tracks.
assert_eq!(playlist_subtitle("Qobuz", 0), "Qobuz");
// Three spaces each side of the bullet (playlist_browse.rs:59-73).
let s = playlist_subtitle("Qobuz", 12);
assert!(s.starts_with("Qobuz • "), "unexpected subtitle: {s}");
assert!(s.contains("12"));
}
#[test]
fn visible_json_filters() {
let cards = vec![card("Blue Train", "Coltrane"), card("Giant Steps", "Coltrane")];
assert!(visible_json(&cards, "blue").contains("Blue Train"));
assert!(!visible_json(&cards, "blue").contains("Giant Steps"));
assert!(visible_json(&cards, "").contains("Giant Steps"));
}
}
+238
View File
@@ -2,6 +2,16 @@
//! God-object; the pattern is documented in main.rs). Props: the three tab
//! section documents + loading/error. Invokable: reloadHome (the retry
//! button / manual refresh).
//!
//! It also carries the six pages reachable FROM Home (the rails' "View all"
//! links and the label funnel), because they are the same domain and a new
//! `#[cxx_qt::bridge]` singleton would need its own `build.rs` `rust_files`
//! entry and its own `Main.qml` boot line — both forbidden files:
//! `discoverBrowse*` -> src/browse_qt.rs (route "discoverbrowse")
//! `playlistBrowse*` -> src/browse_qt.rs ("playlistbrowse")
//! `playHistory*` -> src/browse_qt.rs ("recentalbums" / "mostplayedalbums")
//! `label*` -> src/label_qt.rs ("label")
//! `labelReleases*` -> src/label_qt.rs ("labelreleases")
use std::pin::Pin;
use std::sync::OnceLock;
@@ -41,6 +51,22 @@ pub mod qbz_home {
// connected is simply absent from the document.
#[qproperty(QString, reco_sections_json)]
#[qproperty(bool, reco_loading)]
// --- "View all" full-list pages (src/browse_qt.rs) ----------------
#[qproperty(QString, discover_browse_json)]
#[qproperty(bool, discover_browse_loading)]
#[qproperty(bool, discover_browse_loading_more)]
#[qproperty(QString, playlist_browse_json)]
#[qproperty(bool, playlist_browse_loading)]
#[qproperty(bool, playlist_browse_loading_more)]
// Recently Played Albums + Most Played Albums share ONE document and
// ONE view; `mode` on the document arms the differences.
#[qproperty(QString, play_history_json)]
#[qproperty(bool, play_history_loading)]
// --- Label landing + releases sub-view (src/label_qt.rs) ----------
#[qproperty(QString, label_json)]
#[qproperty(bool, label_loading)]
#[qproperty(QString, label_releases_json)]
#[qproperty(bool, label_releases_loading)]
type QbzHome = super::QbzHomeRust;
@@ -58,6 +84,75 @@ pub mod qbz_home {
/// session (idempotent; a re-entry only repaints from memory).
#[qinvokable]
fn load_recommendations(self: Pin<&mut QbzHome>);
// --- DiscoverBrowse ("View all" on a generic album carousel) ------
#[qinvokable]
fn open_discover_browse(self: Pin<&mut QbzHome>, endpoint: QString, title: QString);
#[qinvokable]
fn discover_browse_load_more(self: Pin<&mut QbzHome>);
#[qinvokable]
fn discover_browse_search(self: Pin<&mut QbzHome>, query: QString);
#[qinvokable]
fn discover_browse_set_view_mode(self: Pin<&mut QbzHome>, mode: QString);
/// The shared "discover" genre selection changed while the page is
/// mounted — re-fetch from offset 0.
#[qinvokable]
fn discover_browse_genre_changed(self: Pin<&mut QbzHome>);
// --- PlaylistBrowse ("View all" on the Qobuz Playlists rail) ------
#[qinvokable]
fn open_playlist_browse(self: Pin<&mut QbzHome>);
#[qinvokable]
fn playlist_browse_load_more(self: Pin<&mut QbzHome>);
#[qinvokable]
fn playlist_browse_search(self: Pin<&mut QbzHome>, query: QString);
#[qinvokable]
fn playlist_browse_select_tag(self: Pin<&mut QbzHome>, slug: QString);
#[qinvokable]
fn playlist_browse_set_view_mode(self: Pin<&mut QbzHome>, mode: QString);
#[qinvokable]
fn playlist_browse_genre_changed(self: Pin<&mut QbzHome>);
// --- Play history -------------------------------------------------
#[qinvokable]
fn open_recent_albums(self: Pin<&mut QbzHome>);
#[qinvokable]
fn open_most_played_albums(self: Pin<&mut QbzHome>);
#[qinvokable]
fn most_played_filter(self: Pin<&mut QbzHome>, query: QString);
// --- Label landing -------------------------------------------------
#[qinvokable]
fn open_label(self: Pin<&mut QbzHome>, label_id: QString);
#[qinvokable]
fn label_follow(self: Pin<&mut QbzHome>);
#[qinvokable]
fn label_play_top(self: Pin<&mut QbzHome>);
#[qinvokable]
fn label_shuffle(self: Pin<&mut QbzHome>);
/// Popular Tracks row click — the whole list becomes the queue.
#[qinvokable]
fn label_play_track(self: Pin<&mut QbzHome>, track_id: QString);
/// Popular Tracks row menu: "next" | "later" | "queue".
#[qinvokable]
fn label_enqueue_track(self: Pin<&mut QbzHome>, track_id: QString, mode: QString);
/// The two ⋯ menus' all-tracks queue entries.
#[qinvokable]
fn label_top_action(self: Pin<&mut QbzHome>, mode: QString);
#[qinvokable]
fn label_open_releases(self: Pin<&mut QbzHome>);
// --- Label releases sub-view --------------------------------------
#[qinvokable]
fn label_releases_load_more(self: Pin<&mut QbzHome>);
#[qinvokable]
fn label_releases_set_sort(self: Pin<&mut QbzHome>, sort: QString);
#[qinvokable]
fn label_releases_set_hires(self: Pin<&mut QbzHome>, on: bool);
#[qinvokable]
fn label_releases_set_group(self: Pin<&mut QbzHome>, on: bool);
#[qinvokable]
fn label_releases_search(self: Pin<&mut QbzHome>, query: QString);
}
impl cxx_qt::Threading for QbzHome {}
@@ -74,6 +169,18 @@ pub struct QbzHomeRust {
for_you_sections_json: QString,
reco_sections_json: QString,
reco_loading: bool,
discover_browse_json: QString,
discover_browse_loading: bool,
discover_browse_loading_more: bool,
playlist_browse_json: QString,
playlist_browse_loading: bool,
playlist_browse_loading_more: bool,
play_history_json: QString,
play_history_loading: bool,
label_json: QString,
label_loading: bool,
label_releases_json: QString,
label_releases_loading: bool,
}
impl Default for QbzHomeRust {
@@ -86,6 +193,19 @@ impl Default for QbzHomeRust {
for_you_sections_json: QString::from("[]"),
reco_sections_json: QString::from("[]"),
reco_loading: false,
// "{}" so the views' JSON.parse never throws on the first frame.
discover_browse_json: QString::from("{}"),
discover_browse_loading: false,
discover_browse_loading_more: false,
playlist_browse_json: QString::from("{}"),
playlist_browse_loading: false,
playlist_browse_loading_more: false,
play_history_json: QString::from("{}"),
play_history_loading: false,
label_json: QString::from("{}"),
label_loading: false,
label_releases_json: QString::from("{}"),
label_releases_loading: false,
}
}
}
@@ -118,4 +238,122 @@ impl qbz_home::QbzHome {
pub fn load_recommendations(self: Pin<&mut Self>) {
crate::recommendations_qt::ensure_loaded();
}
// --- DiscoverBrowse ---------------------------------------------------
pub fn open_discover_browse(self: Pin<&mut Self>, endpoint: QString, title: QString) {
crate::browse_qt::open_discover_browse(endpoint.to_string(), title.to_string());
}
pub fn discover_browse_load_more(self: Pin<&mut Self>) {
crate::browse_qt::discover_browse_load_more();
}
pub fn discover_browse_search(self: Pin<&mut Self>, query: QString) {
crate::browse_qt::discover_browse_search(query.to_string());
}
pub fn discover_browse_set_view_mode(self: Pin<&mut Self>, mode: QString) {
crate::browse_qt::discover_browse_set_view_mode(mode.to_string());
}
pub fn discover_browse_genre_changed(self: Pin<&mut Self>) {
crate::browse_qt::discover_browse_genre_changed();
}
// --- PlaylistBrowse ---------------------------------------------------
pub fn open_playlist_browse(self: Pin<&mut Self>) {
crate::browse_qt::open_playlist_browse();
}
pub fn playlist_browse_load_more(self: Pin<&mut Self>) {
crate::browse_qt::playlist_browse_load_more();
}
pub fn playlist_browse_search(self: Pin<&mut Self>, query: QString) {
crate::browse_qt::playlist_browse_search(query.to_string());
}
pub fn playlist_browse_select_tag(self: Pin<&mut Self>, slug: QString) {
crate::browse_qt::playlist_browse_select_tag(slug.to_string());
}
pub fn playlist_browse_set_view_mode(self: Pin<&mut Self>, mode: QString) {
crate::browse_qt::playlist_browse_set_view_mode(mode.to_string());
}
pub fn playlist_browse_genre_changed(self: Pin<&mut Self>) {
crate::browse_qt::playlist_browse_genre_changed();
}
// --- Play history ------------------------------------------------------
pub fn open_recent_albums(self: Pin<&mut Self>) {
crate::browse_qt::open_recent_albums();
}
pub fn open_most_played_albums(self: Pin<&mut Self>) {
crate::browse_qt::open_most_played_albums();
}
pub fn most_played_filter(self: Pin<&mut Self>, query: QString) {
crate::browse_qt::most_played_filter(query.to_string());
}
// --- Label -------------------------------------------------------------
pub fn open_label(self: Pin<&mut Self>, label_id: QString) {
crate::label_qt::open_label(label_id.to_string());
}
pub fn label_follow(self: Pin<&mut Self>) {
crate::label_qt::toggle_follow();
}
pub fn label_play_top(self: Pin<&mut Self>) {
crate::label_qt::play_top(false);
}
pub fn label_shuffle(self: Pin<&mut Self>) {
crate::label_qt::play_top(true);
}
pub fn label_play_track(self: Pin<&mut Self>, track_id: QString) {
crate::label_qt::play_track(track_id.to_string());
}
pub fn label_enqueue_track(self: Pin<&mut Self>, track_id: QString, mode: QString) {
crate::label_qt::enqueue_track(track_id.to_string(), mode.to_string());
}
pub fn label_top_action(self: Pin<&mut Self>, mode: QString) {
crate::label_qt::enqueue_top(mode.to_string());
}
pub fn label_open_releases(self: Pin<&mut Self>) {
crate::label_qt::open_releases();
}
// --- Label releases ----------------------------------------------------
pub fn label_releases_load_more(self: Pin<&mut Self>) {
crate::label_qt::releases_load_more();
}
pub fn label_releases_set_sort(self: Pin<&mut Self>, sort: QString) {
crate::label_qt::releases_set_sort(sort.to_string());
}
pub fn label_releases_set_hires(self: Pin<&mut Self>, on: bool) {
crate::label_qt::releases_set_hires(on);
}
pub fn label_releases_set_group(self: Pin<&mut Self>, on: bool) {
crate::label_qt::releases_set_group(on);
}
pub fn label_releases_search(self: Pin<&mut Self>, query: QString) {
crate::label_qt::releases_search(query.to_string());
}
}
+47 -6
View File
@@ -18,9 +18,15 @@
//! rail. Artist Spotlight likewise has no ported panel.
//! - Qobuz Mixes renders as the four static navigation tiles (the mix DETAIL
//! views are out of scope — tiles inert).
//! - The "View all" full-list pages: the rails show the link when the
//! section carries an endpoint (1:1 header) but the click is INERT — the
//! DiscoverBrowse page is out of scope.
//! - The "View all" full-list pages are LIVE (see `browse_qt`): the generic
//! album carousels open DiscoverBrowse for their endpoint, and the three
//! local rails (Qobuz Playlists / Recently Played Albums / Most Played
//! Albums) open their own pages. WHICH rails carry the link is decided in
//! `HomeView.qml`'s `viewAllKind()` — per TAB, because the same candidate
//! section is cloned into all three tab lists by `assemble()` and Slint's
//! For You arm for `recentlyPlayedAlbums` has NO link while Home's does
//! (ForYouView.slint:117 vs HomeView.slint:411). Stamping it on the
//! candidate here would leak the link into For You.
//! - Editor's Picks / For You: RENDERING parity (same rails as the Slint
//! descriptor arms, prefs-driven order); the Slint per-branch LAZY For You
//! load is flattened into this one pass, so the rails that need a wave-1
@@ -70,6 +76,12 @@ pub struct HomeCard {
pub is_pinned: bool,
/// Slim rows ("Popular albums"): the 1-based rank ("" = none).
pub rank: String,
/// Local play count. NON-ZERO ONLY on the Most Played Albums page —
/// `discover/AlbumCard.slint:508` renders `@tr("{} plays")` when it is,
/// which is the whole reason that page's card is 286px and every other
/// surface's is 266px. Omitted from the wire when 0.
#[serde(default, skip_serializing_if = "is_zero")]
pub plays: u32,
/// Pinned rail only: the card's own kind ("album" | "artist" |
/// "playlist") — the mixed PinnedCarousel slot dispatch.
#[serde(rename = "itemKind", default)]
@@ -83,6 +95,10 @@ pub struct HomeCard {
pub art_path: String,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
#[derive(Clone, Serialize)]
pub struct HomeSection {
pub id: String,
@@ -573,6 +589,25 @@ pub(crate) fn renderable_ids(tab: qbz_app::settings::discover_prefs::DiscoveryTa
out
}
/// Fill `art_path` from the disk cache for a BARE card list and return the
/// urls still missing. `artwork_qt::attach_cached` only speaks
/// `[HomeSection]`, and every full-list page (`browse_qt`, `label_qt`)
/// carries plain `Vec<HomeCard>` — wrapping here keeps ONE artwork path for
/// the rails and the pages instead of a second copy of the cache lookup.
pub(crate) fn attach_card_art(cards: &mut Vec<HomeCard>) -> Vec<String> {
let mut wrap = vec![HomeSection {
id: String::new(),
title: String::new(),
kind: "album".to_string(),
hint: String::new(),
endpoint: String::new(),
items: std::mem::take(cards),
}];
let missing = crate::artwork_qt::attach_cached(&mut wrap);
*cards = wrap.pop().map(|s| s.items).unwrap_or_default();
missing
}
/// Serialize + push the three tab documents onto the home bridge.
pub(crate) fn publish(sections: &DiscoverSections) {
let home_json = serde_json::to_string(&sections.home).unwrap_or_else(|_| "[]".to_string());
@@ -765,7 +800,7 @@ pub(crate) fn map_album(album: DiscoverAlbum) -> HomeCard {
/// Map a Discover playlist into a single-cover card (1:1 home.rs
/// `map_playlist`): landscape `rectangle` preferred, first square cover
/// fallback; first tag -> UPPERCASE category subtag.
fn map_playlist(p: DiscoverPlaylist) -> HomeCard {
pub(crate) fn map_playlist(p: DiscoverPlaylist) -> HomeCard {
let art_url = p
.image
.rectangle
@@ -908,7 +943,7 @@ pub(crate) fn quality_detail_from_parts(bit_depth: Option<u32>, sample_rate: Opt
// ---------------------------------------------------------------------------
/// Flat-Album card mapping (foryou.rs `map_album`).
fn map_flat_album(album: Album) -> HomeCard {
pub(crate) fn map_flat_album(album: Album) -> HomeCard {
let year = album
.release_date_original
.as_deref()
@@ -932,6 +967,12 @@ fn map_flat_album(album: Album) -> HomeCard {
year,
quality_tier,
quality_label,
// The list arm's QualityBadgeFull renders the bare exact-quality
// line; without it a list row shows the tier label over a blank.
quality_detail: quality_detail_from_parts(
album.maximum_bit_depth,
album.maximum_sampling_rate,
),
art_url: album.image.best().cloned().unwrap_or_default(),
..HomeCard::default()
}
@@ -939,7 +980,7 @@ fn map_flat_album(album: Album) -> HomeCard {
/// Map one recently-played album (local history) onto a card. The stored ISO
/// release date is localized here exactly as the discover cards do.
fn map_recent_album(a: crate::recently_qt::RecentAlbum) -> HomeCard {
pub(crate) fn map_recent_album(a: crate::recently_qt::RecentAlbum) -> HomeCard {
HomeCard {
is_pinned: crate::sidebar_qt::is_pinned("album", &a.id),
id: a.id,
File diff suppressed because it is too large. Load diff
+22 -2
View File
@@ -688,12 +688,21 @@ async fn fetch_purchases(
(albums, tracks)
}
/// Label image — label.rs `extract_label_image`: the nested "square" url.
/// Label image — 1:1 with the reference `label.rs::extract_label_image`.
/// The value is flexible: the favorites wire ships a BARE STRING (Android
/// DTO), `/label/page` ships an object keyed mega/extralarge/large/... .
/// The string arm must come first — `Value::get(key)` on a string is always
/// `None`, so without it every favourite label resolved to "" and the card
/// fell back to the gradient placeholder. The `is_empty` guard is a port
/// addition (the reference lacks it) and cannot regress the key order.
fn extract_label_image(image: Option<&serde_json::Value>) -> String {
let Some(image) = image else {
return String::new();
};
for key in ["square", "large", "medium", "small", "thumbnail"] {
if let Some(url) = image.as_str() {
return url.to_string();
}
for key in ["mega", "extralarge", "large", "thumbnail", "small"] {
if let Some(url) = image.get(key).and_then(|v| v.as_str()) {
if !url.is_empty() {
return url.to_string();
@@ -796,6 +805,17 @@ pub(crate) fn is_local_feed_id(kind: &str, id: &str) -> bool {
}
}
/// main.rs `is_local_album_key` (qbz/src/main.rs:2630-2632) — the PLAY / NAV
/// guard, a SUPERSET of `is_local_feed_id("album", …)`: it also catches the
/// unknown-album bucket. Deliberately a separate helper: `is_local_feed_id` is
/// a byte-for-byte port of the Slint's `library_all.rs:874-880` twin and also
/// decides local-favorites routing, so widening it there would diverge from
/// its reference AND change which API an album literally titled
/// `__unknown_album__` hits.
pub(crate) fn is_local_album_key(id: &str) -> bool {
is_local_feed_id("album", id) || id == "__unknown_album__"
}
/// Flip the favorite flag on the stored feed row (model-of-truth for the
/// `libraryFavoriteChanged` signal).
fn set_feed_favorite(kind: &str, id: &str, value: bool) {
+23 -4
View File
@@ -91,8 +91,19 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
} else {
None
},
context_kind: Some("local".to_string()),
context_id: Some(t.album_group_key.clone()),
// NO container origin. `"local"` is a kind the Slint never emits and
// nothing consumes: `playback.rs`'s own `local_queue_track`
// (:1478-1481) leaves both fields None and lets the per-track ALBUM
// fallback in `refresh_now_playing_meta` (:1959-1965) land the glyph on
// the LocalAlbum view. Leaving it stamped also defeated the shared
// seam — `stamp_context`'s already-stamped test is satisfied by
// ("local", key), so routing these queues through `set_queue_stamped`
// would have preserved the invented kind instead of deriving a real
// one. Post-change: a single-album local queue derives ("album",
// group_key); a folder / Tracks-tab queue spanning albums derives
// nothing and falls back per track to the same key.
context_kind: None,
context_id: None,
}
}
@@ -250,7 +261,10 @@ async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, start: usize, shu
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
}
runtime.core().set_queue(queue, Some(start)).await;
// Through the SHARED seam (not `core().set_queue`) so the origin is derived
// from the queue: one album -> ("album", group_key), a mixed folder queue ->
// nothing, and the per-track album fallback lands it on the same view.
crate::playback_qt::set_queue_stamped(runtime, queue, Some(start), None).await;
crate::playback_qt::publish_queue(runtime).await;
play_audible(runtime, &first).await;
crate::playback_qt::refresh_now_playing(runtime).await;
@@ -349,7 +363,12 @@ pub async fn enqueue(runtime: &Runtime, kind: String, id: String, mode: String)
if tracks.is_empty() {
return;
}
let queue: Vec<QueueTrack> = tracks.iter().map(local_queue_track).collect();
// Same stamping seam the Qobuz enqueue paths use, so an appended local
// block carries its own origin instead of inheriting whatever is playing.
let queue = crate::playback_qt::stamped(
tracks.iter().map(local_queue_track).collect(),
None,
);
// Same core helpers the Qobuz rows use: "next" inserts at the cursor
// (reversed so a multi-track insert keeps its order), "later" appends to
// the block tail, anything else appends.
+2
View File
@@ -54,6 +54,8 @@ mod local_bulk;
mod local_ephemeral;
mod local_album_actions;
mod nav_qt;
mod browse_qt;
mod label_qt;
mod now_playing;
mod output_labels;
mod quality_state;
+217 -8
View File
@@ -95,7 +95,6 @@ impl PlayContext {
Self::new("artist", id)
}
#[allow(dead_code)] // used once the playlist play path routes through here
pub fn playlist(id: &str) -> Option<Self> {
Self::new("playlist", id)
}
@@ -138,6 +137,13 @@ fn derive_context(tracks: &[QueueTrack]) -> Option<PlayContext> {
}
}
// ORDER IS DELIBERATE — album before artist. Do not flip it: a one-album
// queue (the common case: an album play whose producer forgot to stamp)
// legitimately wants the ALBUM origin, and an artist page's Popular Tracks
// only reach the artist arm because they span albums. Producers that know
// better (playlist / artist page / label) pass an EXPLICIT context and
// never depend on this order — this is the safety net for the next
// producer someone adds, not the primary mechanism.
if one_album {
if let Some(id) = album {
return PlayContext::album(id);
@@ -185,17 +191,45 @@ pub(crate) async fn set_queue_stamped(
context: Option<PlayContext>,
) {
stamp_context(&mut tracks, context);
warn_dead_context(&tracks, "set_queue");
runtime.core().set_queue(tracks, start).await;
}
/// Same guarantee for the ADD paths (play-next / add-to-queue): appended tracks
/// carry their own origin, so the glyph stays right after the queue advances
/// into them.
fn stamped(mut tracks: Vec<QueueTrack>, context: Option<PlayContext>) -> Vec<QueueTrack> {
///
/// `pub(crate)` on purpose: the local/ephemeral/bulk enqueue paths live in
/// other modules and must be able to reach the SAME stamping seam — a private
/// helper is what forced them to hand-roll their context in the first place.
pub(crate) fn stamped(mut tracks: Vec<QueueTrack>, context: Option<PlayContext>) -> Vec<QueueTrack> {
stamp_context(&mut tracks, context);
warn_dead_context(&tracks, "enqueue");
tracks
}
/// Tripwire: a track with NEITHER a container origin NOR an album id publishes
/// `ctx-id == ""` (refresh_now_playing's fallback has nothing to fall back to),
/// which renders the song-card layers glyph as a dead control. Nothing in the
/// current producer set can hit this — the point is that the NEXT producer
/// someone adds says so in the log instead of shipping an inert button.
fn warn_dead_context(tracks: &[QueueTrack], site: &str) {
let dead = tracks
.iter()
.filter(|t| {
t.context_id.as_deref().unwrap_or("").is_empty()
&& t.album_id.as_deref().unwrap_or("").is_empty()
})
.count();
if dead > 0 {
log::warn!(
"[qbz-qt] playback context: {dead}/{} track(s) reached {site} with no context AND no \
album id — their 'playing from' glyph will be inert",
tracks.len(),
);
}
}
// ---------------------------------------------------------------------------
// Play an album (album-card click on Home)
// ---------------------------------------------------------------------------
@@ -391,6 +425,16 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
Ok(())
}
/// LOCAL/Plex album keys must NEVER reach `fetch_album_queue` — `get_album`
/// 404s on a group key, the play returns Err and the card does nothing.
/// Mirrors the Slint guard inside `("album","play")` (qbz/src/main.rs:11871,
/// helper `is_local_album_key` at :2630-2632), which exists precisely because
/// Home "Recently played" / Pinned / Most-Played rails mix local rows into a
/// Qobuz card. Every album entry point below tests this FIRST.
fn is_local_album(album_id: &str) -> bool {
crate::library_qt::is_local_album_key(album_id)
}
/// "Play next" / "Add to queue" for an album (AlbumCard ⋯ menu): resolve
/// the album's tracks and insert them after the current track (mode
/// "next") or append them (mode "later").
@@ -399,6 +443,17 @@ pub async fn enqueue_album(
album_id: &str,
mode: &str,
) -> Result<(), String> {
if is_local_album(album_id) {
log::info!("[qbz-qt] enqueue_album {album_id}: local/Plex group key -> local path");
crate::local_playback::enqueue(
runtime,
"album".to_string(),
album_id.to_string(),
mode.to_string(),
)
.await;
return Ok(());
}
let tracks = stamped(
fetch_album_queue(runtime, album_id).await?,
PlayContext::album(album_id),
@@ -432,6 +487,15 @@ pub async fn play_album_from(
album_id: &str,
start_index: usize,
) -> Result<(), String> {
if is_local_album(album_id) {
// The local path owns its own queue building + audible step. The start
// INDEX is not expressible there (it takes a row id) — every caller
// that reaches here does so with 0 (`play_album`), and the row-anchored
// variant is `play_album_from_track` below, which does pass the id.
log::info!("[qbz-qt] play_album {album_id}: local/Plex group key -> local path");
crate::local_playback::play_album(runtime, album_id.to_string(), None, false).await;
return Ok(());
}
log::info!("[qbz-qt] play_album: resolving {album_id} (start {start_index})");
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = start_index.min(tracks.len() - 1);
@@ -457,6 +521,17 @@ pub async fn play_album_from_track(
album_id: &str,
track_id: u64,
) -> Result<(), String> {
if is_local_album(album_id) {
log::info!("[qbz-qt] play_album_from_track {album_id}: local/Plex group key -> local path");
crate::local_playback::play_album(
runtime,
album_id.to_string(),
Some(track_id as i64),
false,
)
.await;
return Ok(());
}
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
let first_id = tracks[start].id;
@@ -521,15 +596,55 @@ pub async fn play_track_list_in(
pub async fn enqueue_track_list(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
) -> Result<(), String> {
enqueue_track_list_mode(runtime, tracks, "queue").await
}
/// `enqueue_track_list` with the three insertion modes the row/card menus use,
/// mirroring `enqueue_album`: "next" inserts at the cursor (fed REVERSED so a
/// multi-track insert keeps its order), "later" appends to the manual block's
/// tail, anything else appends.
///
/// Exists because ArtistView's ⋯ menu has BOTH "Play all next" and "Add all to
/// queue" and the port wired them to the same append (the Slint arm for
/// `next-all` is `enqueue_artist_top_selected(.., next: true)`, main.rs:15301).
pub async fn enqueue_track_list_mode(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
mode: &str,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
runtime.core().add_tracks(stamped(tracks, None)).await;
let tracks = stamped(tracks, None);
match mode {
"next" => {
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
}
"later" => {
for track in tracks {
runtime.core().add_track_later(track).await;
}
}
_ => runtime.core().add_tracks(tracks).await,
}
publish_queue(runtime).await;
Ok(())
}
/// ArtistView ⋯ "Play all next" / "Add all to queue" — the whole body, so the
/// invokable in `player_bridge.rs` can call it without a `main.rs` shim (see
/// GLUE in the report).
pub async fn enqueue_artist_top_mode(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
mode: &str,
) -> Result<(), String> {
let (queue, _) = crate::artist_qt::top_queue(None);
enqueue_track_list_mode(runtime, queue, mode).await
}
/// One track from an album context into the queue ("Play next" / "Add to
/// queue" on an AlbumView row).
pub async fn enqueue_album_track(
@@ -538,6 +653,20 @@ pub async fn enqueue_album_track(
track_id: u64,
mode: &str,
) -> Result<(), String> {
if is_local_album(album_id) {
// One LOCAL row: the local enqueue resolves it by row id (the cached
// Tracks page / open detail / library.db), so the group key is only
// used here to decide the route.
log::info!("[qbz-qt] enqueue_album_track {album_id}/{track_id}: local/Plex -> local path");
crate::local_playback::enqueue(
runtime,
"track".to_string(),
track_id.to_string(),
mode.to_string(),
)
.await;
return Ok(());
}
let tracks = fetch_album_queue(runtime, album_id).await?;
let Some(track) = tracks.into_iter().find(|t| t.id == track_id) else {
return Err(format!("track {track_id} not in album {album_id}"));
@@ -611,14 +740,94 @@ fn feed_queue_track(track_id: u64) -> Result<QueueTrack, String> {
})
}
/// Play a single track as a one-element queue (Library track rows). The
/// queue meta is rebuilt from the Library feed row; the audio resolves by
/// id through the same `play_track_resolved` path.
/// Catalog fallback for a track the Library feed does NOT hold — Search rows
/// and the search hero, Home's "Recently played tracks" rail, anything reached
/// by id from a view that keeps no full `Track`. This is the Qt port of the
/// Slint's last resort, `playback.rs::play_track_now` (:3242-3283): fetch the
/// track and build its one-row queue from the response. Context is left
/// unstamped exactly like the Slint — a bare track play falls back to its own
/// album in `refresh_now_playing`.
async fn catalog_queue_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<QueueTrack, String> {
let track = runtime
.core()
.get_track(track_id)
.await
.map_err(|e| format!("get_track {track_id} failed: {e}"))?;
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
Some(album) => (
album.id.clone(),
album.title.clone(),
album.image.best().cloned().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
let album_key = if album_id.is_empty() {
None
} else {
Some(album_id.clone())
};
Ok(QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: album_title,
album_version: None,
duration_secs: track.duration as u64,
artwork_url: if album_artwork.is_empty() {
None
} else {
Some(album_artwork)
},
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
streamable: track.streamable,
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
context_kind: None,
context_id: None,
})
}
/// One queue row for a bare track id: the Library feed first (free, already
/// resolved), the catalog second. Before the fallback existed, every entry
/// point outside the Library feed — search rows, the search hero, Home's
/// Recently-Played-Tracks rail — returned Err here and rendered a control that
/// did nothing.
async fn queue_track_for(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<QueueTrack, String> {
match feed_queue_track(track_id) {
Ok(qt) => Ok(qt),
Err(feed_miss) => {
log::debug!("[qbz-qt] {feed_miss}; resolving from the catalog");
catalog_queue_track(runtime, track_id).await
}
}
}
/// Play a single track as a one-element queue (Library track rows, Search
/// rows/hero, Home's Recently-Played-Tracks rail). The queue meta comes from
/// the Library feed row when it has one and from `get_track` otherwise; the
/// audio resolves by id through the same `play_track_resolved` path.
pub async fn play_single_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<(), String> {
let qt = feed_queue_track(track_id)?;
let qt = queue_track_for(runtime, track_id).await?;
// Bare single-track play: no container origin, so the derive falls to the
// track's own album — same landing spot as the Slint fallback.
set_queue_stamped(runtime, vec![qt], Some(0), None).await;
@@ -641,7 +850,7 @@ pub async fn enqueue_single_track(
track_id: u64,
mode: &str,
) -> Result<(), String> {
let qt = stamped(vec![feed_queue_track(track_id)?], None)
let qt = stamped(vec![queue_track_for(runtime, track_id).await?], None)
.pop()
.expect("one track in, one track out");
match mode {
+44 -7
View File
@@ -253,11 +253,24 @@ fn row_to_queue(row: &PlaylistTrackRow) -> QueueTrack {
source: Some("qobuz".to_string()),
parental_warning: row.explicit,
source_item_id_hint: None,
context_kind: Some("playlist".to_string()),
// A ROW does not know which playlist it belongs to. Stamping a kind
// here with no id was a HALF-stamp: `refresh_now_playing`'s
// both-or-album match (playback_qt.rs, the port of playback.rs:1959)
// discards a kind without an id, so every playlist play — card, rail,
// header, row — published the ALBUM glyph. The origin is stamped where
// the playlist id is in scope instead (`open_context` / `queue_for`).
context_kind: None,
context_id: None,
}
}
/// The OPEN playlist as a playback origin (header Play / Shuffle / row play).
/// None when no playlist is open or its id is empty — an empty context must
/// never be stamped.
fn open_context() -> Option<crate::playback_qt::PlayContext> {
with_doc(|d| crate::playback_qt::PlayContext::playlist(&d.id)).flatten()
}
/// The playlist's tracks as a playable queue (play-all / shuffle / enqueue).
pub fn current_queue() -> Vec<QueueTrack> {
PAGE.lock()
@@ -840,7 +853,15 @@ pub async fn play_playlist_by_id(
playlist_id: u64,
) -> Result<(), String> {
let tracks = queue_for(runtime, playlist_id).await?;
play_queue_at(runtime, tracks, 0).await
// The card path has NO open doc — the origin comes from the id it was
// asked to play, not from `open_context()`.
play_queue_at(
runtime,
tracks,
0,
crate::playback_qt::PlayContext::playlist(&playlist_id.to_string()),
)
.await
}
/// Card menu queueing: the whole playlist after the current track ("next"
@@ -851,7 +872,14 @@ pub async fn enqueue_playlist_by_id(
playlist_id: u64,
mode: &str,
) -> Result<(), String> {
let tracks = queue_for(runtime, playlist_id).await?;
// Appended rows carry their OWN origin, so the glyph stays right once the
// queue advances into them (the Slint leaves its enqueue paths unstamped —
// playback.rs:4323/:4400 — which is why an enqueued playlist there shows
// the album; stamping is the strict improvement, same landing page shape).
let tracks = crate::playback_qt::stamped(
queue_for(runtime, playlist_id).await?,
crate::playback_qt::PlayContext::playlist(&playlist_id.to_string()),
);
match mode {
"next" => {
for track in tracks.into_iter().rev() {
@@ -920,16 +948,23 @@ pub async fn add_tracks(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id:
// Playback (play-all / shuffle / row play / row enqueue)
// ---------------------------------------------------------------------------
/// Every play in this module funnels here, and it goes through the SHARED
/// stamping seam (`playback_qt::set_queue_stamped`) rather than
/// `core().set_queue` — bypassing it was the second half of the lost-context
/// bug: even a correctly stamped queue would have been fine, but an unstamped
/// one got no derive either.
async fn play_queue_at(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: usize,
context: Option<crate::playback_qt::PlayContext>,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("playlist has no playable tracks".to_string());
}
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(start), context).await;
crate::queue_qt::publish(runtime).await;
runtime
.core()
@@ -943,7 +978,7 @@ async fn play_queue_at(
/// Header Play: the playlist's tracks (current sort) from the top.
pub async fn play_all(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<(), String> {
let tracks = current_queue();
play_queue_at(runtime, tracks, 0).await
play_queue_at(runtime, tracks, 0, open_context()).await
}
/// Header Shuffle: shuffle on, then play (the core queue owns the order —
@@ -958,7 +993,7 @@ pub async fn play_shuffled(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<
pub async fn play_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: &str) -> Result<(), String> {
let start = with_doc(|d| d.tracks.iter().position(|t| t.id == track_id)).flatten();
let tracks = current_queue();
play_queue_at(runtime, tracks, start.unwrap_or(0)).await
play_queue_at(runtime, tracks, start.unwrap_or(0), open_context()).await
}
/// Row ⋯ queueing: one playlist track into the EXISTING queue
@@ -971,7 +1006,9 @@ pub async fn enqueue_track(
let row = with_doc(|d| d.tracks.iter().find(|t| t.id == track_id).cloned())
.flatten()
.ok_or_else(|| format!("track {track_id} not in the open playlist"))?;
let qt = row_to_queue(&row);
let qt = crate::playback_qt::stamped(vec![row_to_queue(&row)], open_context())
.pop()
.expect("one row in, one row out");
match mode {
"next" => runtime.core().add_track_next(qt).await,
"later" => runtime.core().add_track_later(qt).await,
+7 -3
View File
@@ -214,9 +214,13 @@ pub(crate) fn record(track: RecentTrack) {
/// recorded here shows title / artist / artwork / quality and no genre-date
/// overlay line — never a wrong one.
///
/// GLUE: call from the DE-DUPED playback track-change edge, next to
/// `integrations_qt::on_track_change_edge` — see the handoff.
#[allow(dead_code)]
/// WIRED: called from the DE-DUPED playback track-change edge in
/// `playback_qt::start_poll_loop`, next to
/// `integrations_qt::on_track_change_edge`. Both this rail and the "Most
/// Played Albums" rail therefore contain LOCAL and PLEX rows (the album play
/// history stores `source` verbatim and never filters on it), which is why
/// every album play/enqueue entry point has to route a group key to the local
/// path instead of `get_album` — see `playback_qt::is_local_album`.
pub(crate) fn record_queue_track(track: &qbz_models::QueueTrack) {
let tier = crate::home_qt::quality_tier_from_depth(track.bit_depth);
let detail = crate::home_qt::quality_detail_from_parts(track.bit_depth, track.sample_rate);
+7
View File
@@ -380,6 +380,13 @@ impl qbz_shell::QbzShell {
// 1:1 Slint (`ShellState.cycle-sidebar` -> persist-sidebar-state): the
// state survives the relaunch instead of snapping back to open.
crate::settings_qt::set_sidebar_state(next);
// state.slint:4094-4096 — the Large dock lives in the open sidebar, so
// collapsing falls back to New. LIVE only: this is the generated
// property setter, never npb_set_mode, which would persist the fallback
// and lose the user's Large preference.
if next != 0 && *self.npb_mode() == 3 {
self.as_mut().set_npb_mode(0);
}
}
pub fn toggle_queue(mut self: Pin<&mut Self>) {