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:
34 files changed
+6234
-201
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" : ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(§ions.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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in new issue
Block a user