feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON document per screen plus a revision counter, so QML re-parses even when the text is byte-identical. - The list: two tabs with per-type counters, a server-side search on a 300 ms debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort, direction, two filters and grid/list; Tracks gets group and the filters only. Building the Albums toolbar on both would have invented two controls. - The detail: a format dropdown whose selection re-scopes every downloaded mark, the progress section and Add-to-Library together; a determinate bar counted in TRACKS, never bytes; goodies as an album-level action counted separately. - The album download is the frontend loop, not the Rust command the reference registered and never called. Sequential, concurrency 1, cancellable, and a failed track does not abort the album. - Both prefs are read at runtime, not merely written. `show_purchases` is the master gate and `nav_tb_purchases` only relocates the entry to the title bar; both default false, so Purchases still ships hidden. - Scanned local rows keep their raw `qobuz_purchase` source so the gold badge survives in list mode as well as grid, while still classifying under the Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping them would badge every purchase the user merely OWNS as locally downloaded. Defects fixed after the screens were written, none of which any test could have caught: - `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every publish is a silent no-op, so both screens would have stayed permanently empty with nothing logged. The cause was an ownership gap rather than the code: `Main.qml` belonged to no lane because the plan listed the files each lane CREATES rather than the files it TOUCHES, and "belongs to nobody" is indistinguishable from "done" until something runs. - A second download of the same album nested inside the first. The store rewrites its destination to the album subfolder after the first successful track so that Add-to-Library adds the album rather than the whole music root, and the download planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`, one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are now separate fields with names that cannot be confused, with a regression test. - A tab counter could stick at zero forever with its list plainly full: the two ids calls each swallow their error, and the reference's backfill from the list response was missing. - Cancelling made the whole progress block vanish instead of showing the cancelled state, whose msgid was already translated in all eight catalogs. - Add-to-Library re-walked the entire library when the folder id it needed was already in hand. - A live language switch and a logout left the previous state behind. Deliberate divergences from the contract, each recorded with its reason: the page header is a plain `fontSection` title with no icon tile, because nothing else in this application announces itself over a large coloured square; purchase download status travels inside the album document rather than on the shared cache channel, which is keyed by track id alone and would let the two features overwrite each other on the same row. Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p qbz-qt` 398 passing, offscreen smoke green with zero QML complaints. Not verified, and cannot be from here: every populated screen. This account owns nothing, so only the empty states have ever rendered. cc
This commit is contained in:
32 files changed
+6477
-17
No files matched your search
+21
-1
@@ -359,7 +359,7 @@ fn main() {
|
||||
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
||||
// modules and must NOT be listed — only files that declare a
|
||||
// #[cxx_qt::bridge] mod belong in this array.
|
||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/shader_scene_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs", "src/about_bridge.rs"],
|
||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/shader_scene_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs", "src/about_bridge.rs", "src/purchases_bridge.rs"],
|
||||
qml_files: &[
|
||||
"qml/LoginScreen.qml",
|
||||
"qml/Main.qml",
|
||||
@@ -647,6 +647,26 @@ fn main() {
|
||||
"qml/views/myqbz/MyQbzDetailRow.qml",
|
||||
"qml/views/myqbz/MyQbzDetailView.qml",
|
||||
"qml/views/myqbz/MyQbzGridView.qml",
|
||||
// Purchases (routes "purchases" and "purchase-album") — the
|
||||
// opt-in Qobuz store surface, plus its own module directory
|
||||
// like views/local/ and views/myqbz/.
|
||||
//
|
||||
// TWO different silent failures meet here, and the feature is
|
||||
// the one nobody can smoke-test (it is not sold in the owner's
|
||||
// region, so their account returns an empty list forever):
|
||||
// a missing ROUTE view leaves the qrc without the file and the
|
||||
// router mounts nothing, and a missing MODULE file fails its
|
||||
// PARENT at load with "… is not a type" — taking the whole
|
||||
// screen, not one row. Both are invisible to cargo check and to
|
||||
// both audit scripts, and `show_purchases` defaults OFF, so
|
||||
// neither would be noticed by anyone who never turns it on.
|
||||
"qml/views/PurchasesView.qml",
|
||||
"qml/views/PurchaseAlbumView.qml",
|
||||
"qml/views/purchases/PurchaseAlbumsCollection.qml",
|
||||
"qml/views/purchases/PurchaseGridCard.qml",
|
||||
"qml/views/purchases/PurchaseListRow.qml",
|
||||
"qml/views/purchases/PurchaseTrackRow.qml",
|
||||
"qml/views/purchases/PurchasesToolbar.qml",
|
||||
// Playlist Manager (route "playlistmanager"): the router target
|
||||
// plus the TWELVE files of its own module directory. A .qml
|
||||
// missing from this array is absent from the qrc and fails its
|
||||
|
||||
@@ -366,6 +366,12 @@ ApplicationWindow {
|
||||
// startup, an auto-show, a "new version" badge) would be a silent
|
||||
// no-op. Booting it here keeps it in the same contract as the other 34.
|
||||
QbzAbout.boot()
|
||||
// Purchases. Unlike QbzAbout above, this one has NO self-registering
|
||||
// invokable to fall back on: `purchases_bridge::ui()` is a silent no-op
|
||||
// until the Qt-thread hop exists, so without this line every publish is
|
||||
// dropped and both purchase screens stay permanently empty with nothing
|
||||
// logged. Position is free (only QbzSession and QbzQConnect are ordered).
|
||||
QbzPurchases.boot()
|
||||
|
||||
// Seed the maximized latch ONCE, imperatively — see its declaration for
|
||||
// why it must not be a binding on the bridge property.
|
||||
|
||||
@@ -147,7 +147,11 @@ Rectangle {
|
||||
radius: 4
|
||||
color: "#b3000000"
|
||||
SourceIcon {
|
||||
kind: root.item.source || ""
|
||||
// `sourceRaw` FIRST (contract §D.1): local rows carry
|
||||
// `qobuz_purchase` there, and `source` folds it into
|
||||
// "offline" for the source chips. Feed rows publish no
|
||||
// `sourceRaw` at all, so they fall through unchanged.
|
||||
kind: root.item.sourceRaw || root.item.source || ""
|
||||
// .slint:191-193 — plex 16, qobuz 18, local 14. "offline"
|
||||
// is the Qt word for a Qobuz download (local_rows.rs
|
||||
// `badge_source`); the .slint's local-library track rows
|
||||
|
||||
@@ -96,7 +96,7 @@ Item {
|
||||
: QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml"
|
||||
// Artist page > a release section > "See discography", and the
|
||||
// album page's "From the same artist" View all. Same two-file
|
||||
// contract as the rest of this chain (nav_qt.rs:9-16):
|
||||
// contract as the rest of this chain (nav_qt.rs:9-19):
|
||||
// artist_releases_qt::open records the id, this arm mounts it.
|
||||
// Forgetting the arm is NOT a crash — the ternary falls through
|
||||
// to "" and the pane goes blank with nothing logged, i.e. the
|
||||
@@ -138,11 +138,24 @@ Item {
|
||||
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml"
|
||||
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
|
||||
// section — playlist_manager_qt::navigate() records the route.
|
||||
// The route is a TWO-FILE contract (nav_qt.rs:9-16): the caller
|
||||
// The route is a TWO-FILE contract (nav_qt.rs:9-19): the caller
|
||||
// records the id, this arm mounts it, and the failure mode for a
|
||||
// missing arm is a BLANK content pane, logged nowhere.
|
||||
: QbzShell.currentView === "playlistmanager" ? "../views/PlaylistManagerView.qml"
|
||||
// KIOSK-ONLY route (nav_qt.rs:24-29): the NavRail's fifth tile,
|
||||
// Purchases — the opt-in Qobuz store surface (Settings >
|
||||
// Appearance > Show Purchases, default OFF). The sidebar's direct
|
||||
// row records "purchases"; a purchased album's card records
|
||||
// "purchase-album" through QbzPurchases.openAlbum(id), which holds
|
||||
// the id — the route carries none, exactly like "localalbum".
|
||||
//
|
||||
// BOTH arms land in the same edit on purpose. This chain's failure
|
||||
// mode is a blank pane logged nowhere, and Purchases is the one
|
||||
// feature nobody on this team can smoke-test (the owner's region
|
||||
// does not sell it, so the account returns an empty list forever):
|
||||
// a missing arm here would first be seen by a stranger.
|
||||
: QbzShell.currentView === "purchases" ? "../views/PurchasesView.qml"
|
||||
: QbzShell.currentView === "purchase-album" ? "../views/PurchaseAlbumView.qml"
|
||||
// KIOSK-ONLY route (nav_qt.rs:46-51): the NavRail's fifth tile,
|
||||
// the full-screen player. The desktop shell has no equivalent —
|
||||
// its transport is the persistent bar — so outside kiosk this id
|
||||
// falls through to "" and the pane is blank, which is the desktop
|
||||
|
||||
@@ -158,6 +158,31 @@ Rectangle {
|
||||
// section dropdown (on hover and on click), and the ENTRIES navigate.
|
||||
readonly property var navSections: navFlyout.sections
|
||||
|
||||
/// The settings snapshot, try-parsed. `settingsJson` is `"{}"` before the
|
||||
/// first publish, and a bare `JSON.parse` throwing on that frame would take
|
||||
/// the whole header down rather than just one entry.
|
||||
readonly property var settingsDoc: {
|
||||
try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) }
|
||||
}
|
||||
|
||||
/// Purchases in the TITLE BAR — the third row of the §7.1 truth table.
|
||||
///
|
||||
/// This is the EXACT COMPLEMENT of `Sidebar.qml`'s `purchasesVisible`, and
|
||||
/// it has to be, because that property WITHDRAWS the sidebar row for this
|
||||
/// configuration. Until this existed the entry was withdrawn from one host
|
||||
/// and offered by none: with `show_purchases` and `nav_tb_purchases` both on
|
||||
/// and a custom title bar, Purchases simply had no way in.
|
||||
///
|
||||
/// `nav_tb_purchases` only RELOCATES; `show_purchases` is the master gate.
|
||||
/// Under system chrome or with no title bar there is nowhere to relocate TO,
|
||||
/// so the sidebar keeps it and this stays false.
|
||||
readonly property bool purchasesInHeader:
|
||||
root.settingsDoc.showPurchases === true
|
||||
&& !QbzSession.offline
|
||||
&& root.settingsDoc.navTbPurchases === true
|
||||
&& !QbzShell.systemTitleBar
|
||||
&& !QbzShell.hideTitleBar
|
||||
|
||||
// Highlighted section — derived from the live view (see NavFlyout), OR'd
|
||||
// in the triggers with "my menu is open" (Slint highlights off
|
||||
// HeaderMenuState.open-index).
|
||||
@@ -173,6 +198,59 @@ Rectangle {
|
||||
// Full text tab (HeaderBar.slint NavTab): 30px tall, radius sm, icon 14
|
||||
// (dropped under 1140px), label 11px — semibold + elevated fill when the
|
||||
// section is the current view.
|
||||
/// The title-bar Purchases entry. Deliberately NOT a `NavTab`: that
|
||||
/// component is built around a section object and its click opens the
|
||||
/// flyout, while Purchases navigates straight to its route.
|
||||
component PurchaseTab: Rectangle {
|
||||
id: purchaseTab
|
||||
property bool showIcon: true
|
||||
/// Icon-only, to sit beside the compact section buttons.
|
||||
property bool compact: false
|
||||
readonly property bool isActive:
|
||||
QbzShell.currentView === "purchases" || QbzShell.currentView === "purchase-album"
|
||||
|
||||
height: 30
|
||||
width: purchaseRow.implicitWidth
|
||||
radius: theme.radiusSm
|
||||
color: purchaseTab.isActive ? theme.surfaceElevated
|
||||
: purchaseArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
id: purchaseRow
|
||||
height: parent.height
|
||||
leftPadding: purchaseTab.compact ? 8 : (purchaseTab.showIcon ? 9 : 11)
|
||||
rightPadding: purchaseTab.compact ? 8 : 11
|
||||
spacing: 6
|
||||
QbzIcon {
|
||||
visible: purchaseTab.showIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "shopping-bag"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: purchaseTab.isActive ? "primary" : "muted"
|
||||
}
|
||||
Text {
|
||||
visible: !purchaseTab.compact
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Purchases", QbzSession.trRev)
|
||||
color: purchaseTab.isActive ? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: purchaseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// Never touches `navFlyout.triggerHovered`. Setting it while another
|
||||
// section's panel is open marks the flyout as hovered and halts its
|
||||
// 1 s idle-close, leaving that panel hanging open under the pointer.
|
||||
onClicked: QbzShell.navigateTo("purchases")
|
||||
}
|
||||
}
|
||||
|
||||
component NavTab: Rectangle {
|
||||
id: navTab
|
||||
property var section: null
|
||||
@@ -336,6 +414,14 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
// Purchases is a DIRECT destination, not a section: it has no
|
||||
// flyout and no entries, so it cannot ride the Repeater above —
|
||||
// NavTab's click opens a panel. Hence its own tab.
|
||||
PurchaseTab {
|
||||
visible: root.purchasesInHeader
|
||||
showIcon: root.width >= 1140
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Compact section nav — while the sidebar is fully closed (so the
|
||||
@@ -356,6 +442,13 @@ Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
// The same direct entry, icon-only, so the compact form is not the
|
||||
// one configuration where Purchases disappears again.
|
||||
PurchaseTab {
|
||||
visible: root.purchasesInHeader
|
||||
compact: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
// Thin separator + the playlists flyout button — ONLY while the
|
||||
// sidebar is really closed. In the opt-in always-compact mode the
|
||||
// sidebar list is still on screen, so the button stays away
|
||||
|
||||
@@ -70,9 +70,18 @@ Rectangle {
|
||||
// views) keep mounting offline and are deliberately absent below.
|
||||
//
|
||||
// Two arms of the reference's predicate have no counterpart here:
|
||||
// - musician / award / award-albums / purchases / purchase-album /
|
||||
// location are ABSENT-BY-RULING (contract R1) — there is no Qt view
|
||||
// and no Qt route id for them, so they can never be `currentView`.
|
||||
// - award / award-albums / location are ABSENT-BY-RULING (contract R1) —
|
||||
// there is no Qt view and no Qt route id for them, so they can never be
|
||||
// `currentView`.
|
||||
// (This list USED to include musician and the two purchases routes. It
|
||||
// was wrong about both by the time anyone read it: ContentRouter mounts
|
||||
// `musician`, `purchases` and `purchase-album`, and nav_qt.rs lists all
|
||||
// three ids. Corrected 2026-08-16 — a stale absence claim is the kind of
|
||||
// comment that gets cited as authority later.)
|
||||
// Purchases needs no arm here regardless: it is gated OFF while offline
|
||||
// at both of its entry points (Sidebar's `purchasesVisible` and
|
||||
// HeaderBar's `purchasesInHeader` both require `!QbzSession.offline`),
|
||||
// so a kiosk session cannot reach it in the first place.
|
||||
// - PlaylistState.offline-subset (the D11.a "mixed Qobuz playlist
|
||||
// rendered from its local sidecar rows" flag, state.slint:2183) has
|
||||
// NO Qt member: playlist_qt.rs's PlaylistDoc carries isLocalPlaylist
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
// nothing uses it today (a row that clicks into nothing is a defect, not a
|
||||
// stub). Its leading glyph goes through shell/NavSectionGlyph.qml, because My
|
||||
// QBZ can carry a user-supplied icon (SidebarNavRow `raw-icon`).
|
||||
// PURCHASES is the one row in that block that is NOT a section: it is a
|
||||
// DirectRow (SidebarDirectRow) — no dropdown, no catalog entry, it just
|
||||
// navigates — and it is triple-gated (opt-in `show_purchases`, hidden while
|
||||
// offline, and relocated to the custom title bar by `nav_tb_purchases`).
|
||||
// Both prefs default OFF, so on a stock install this block is still the same
|
||||
// four section rows it has always been.
|
||||
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load, sort,
|
||||
// search, expand/collapse, drag-drop target), and so is everything the "..."
|
||||
// menu offers: New folder opens QbzFolderEdit's create panel (the small
|
||||
@@ -111,6 +117,35 @@ Rectangle {
|
||||
// keep the highlight honest. Slint additionally highlights the row whose
|
||||
// menu is open — the rows OR `navFlyout.openId` in for that.
|
||||
readonly property string activeNav: navFlyout.activeSection
|
||||
|
||||
// ---- Purchases row gating (contract §7.1, Sidebar.slint:794-805) -----
|
||||
// NO new bridge property is needed. `settings_bool`'s tail is
|
||||
// unconditional on success — `publish_snapshot()` re-serialises the WHOLE
|
||||
// settings document after every successful write (settings_qt.rs:2278-2285)
|
||||
// — and both prefs are already in it (`showPurchases` / `navTbPurchases`,
|
||||
// :1446-1449). So the two Appearance toggles reach this row through
|
||||
// QbzBridge.settingsJson and nothing else is wired.
|
||||
//
|
||||
// GUARDED parse, the NavFlyout.qml:65-70 precedent: a bare JSON.parse in a
|
||||
// binding throws on the pre-publish frame and would take the whole sidebar
|
||||
// down with it, not just this row.
|
||||
readonly property var settingsDoc: {
|
||||
try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) }
|
||||
}
|
||||
// `show_purchases` is the MASTER gate; `nav_tb_purchases` only RELOCATES
|
||||
// the entry to the custom title bar — and it can only relocate it when
|
||||
// there IS one. Under the system chrome, or with no title bar at all, the
|
||||
// entry stays here (the truth table's fourth row): the Appearance toggle
|
||||
// is disabled in those states, but a `true` set earlier survives the
|
||||
// switch, and honouring it then would make Purchases unreachable.
|
||||
// Both prefs default false — Purchases ships hidden.
|
||||
readonly property bool purchasesVisible:
|
||||
root.settingsDoc.showPurchases === true
|
||||
&& !QbzSession.offline
|
||||
&& !(root.settingsDoc.navTbPurchases === true
|
||||
&& !QbzShell.systemTitleBar
|
||||
&& !QbzShell.hideTitleBar)
|
||||
|
||||
// Playlist tree state (phase 7).
|
||||
property bool searchOpen: false
|
||||
property bool playlistsCollapsed: false
|
||||
@@ -392,6 +427,78 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// A DIRECT destination row — the port of SidebarDirectRow
|
||||
// (Sidebar.slint:646-685). Same 34px metrics, same radius, same lit
|
||||
// treatment as NavRow above; what it does NOT have is the flyout. It
|
||||
// navigates on click, so there is no catalog `section` behind it — which
|
||||
// is also why the glyph goes to QbzIcon directly rather than through
|
||||
// shell/NavSectionGlyph.qml (that component's whole input IS a section).
|
||||
// `shopping-bag` is a registered tint master (icon_tint_qt.rs:261), so it
|
||||
// is available in every tint under all 36 themes.
|
||||
//
|
||||
// IT MUST NEVER TOUCH `navFlyout.triggerHovered`, and the reason is the
|
||||
// opposite of the obvious one. The idle-close timer is
|
||||
// `running: panel.visible && !panel.menuHovered` (NavFlyout.qml:285), so
|
||||
// with nothing open the flag pins nothing. The hazard is setting it while
|
||||
// ANOTHER section's panel IS open: it feeds `menuHovered` (:368-369),
|
||||
// which halts the 1s idle-close and leaves that flyout hanging open for as
|
||||
// long as the pointer rests on this row. Hovering a direct row is not a
|
||||
// flyout event at all.
|
||||
component DirectRow: Rectangle {
|
||||
id: directRow
|
||||
// A QbzIcon glyph name (not a section) and its label.
|
||||
property string iconName: ""
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
signal activated()
|
||||
|
||||
readonly property bool lit: directRow.active || directArea.containsMouse
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
// Mini: the SAME square track the nav rows use, so a direct row and a
|
||||
// section row cannot land on different centre lines.
|
||||
height: root.mini ? theme.sidebarMiniRow : 34
|
||||
radius: 6
|
||||
color: directRow.lit ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.mini ? 0 : 8
|
||||
anchors.rightMargin: root.mini ? 0 : 8
|
||||
spacing: 10
|
||||
|
||||
Item {
|
||||
width: root.mini ? parent.width : 16
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
name: directRow.iconName
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: directRow.lit ? "textPrimary" : "secondary"
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: !root.mini
|
||||
height: parent.height
|
||||
width: parent.width - (root.mini ? 0 : 26)
|
||||
text: directRow.label
|
||||
color: directRow.lit ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: directArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: directRow.activated()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
// Mini left/right are ASYMMETRIC on purpose — see `miniPadLeft` at the
|
||||
@@ -430,6 +537,26 @@ Rectangle {
|
||||
visible: !(modelData.qobuz && QbzSession.offline)
|
||||
}
|
||||
}
|
||||
|
||||
// Purchases — LAST in the block and the only non-section row here
|
||||
// (Sidebar.slint:794-805). Opt-in, hidden while offline (the
|
||||
// surface needs the Qobuz session), and ACTIVE on both purchase
|
||||
// routes so the highlight survives the click into a purchased
|
||||
// album's detail.
|
||||
//
|
||||
// The click only navigates. Entering the list is the VIEW's job:
|
||||
// `openList()` pairs with `leave()` on the view's own
|
||||
// mount/unmount (§G.1), and Back reaches this route without ever
|
||||
// passing through this row — a row that pre-loaded would leave the
|
||||
// history path unloaded and double-fetch the direct path.
|
||||
DirectRow {
|
||||
visible: root.purchasesVisible
|
||||
iconName: "shopping-bag"
|
||||
label: QbzSession.tr("Purchases", QbzSession.trRev)
|
||||
active: QbzShell.currentView === "purchases"
|
||||
|| QbzShell.currentView === "purchase-album"
|
||||
onActivated: QbzShell.navigateTo("purchases")
|
||||
}
|
||||
}
|
||||
|
||||
// Hairline under the section nav — mounted with the nav block, not
|
||||
|
||||
@@ -330,7 +330,18 @@ Rectangle {
|
||||
if (!fok) continue
|
||||
}
|
||||
if (sAny) {
|
||||
// The chips are keyed off the FOLDED word, which is why
|
||||
// local_rows.rs keeps folding `qobuz_purchase` into "offline"
|
||||
// — a purchased album IS a Qobuz download as far as this row
|
||||
// of chips is concerned, and the badge reads `sourceRaw`
|
||||
// instead of splitting the bucket.
|
||||
//
|
||||
// The normalisation below is the reference's §10-H bug kept
|
||||
// closed: Tauri's three arms never mention `qobuz_purchase`,
|
||||
// so a raw word reaching here matches no chip and ticking ANY
|
||||
// source filter hides every purchased album, silently.
|
||||
var src = (r.source || "local").toLowerCase()
|
||||
if (src === "qobuz_purchase" || src === "qobuz_download") src = "offline"
|
||||
if (!filter[src]) continue
|
||||
}
|
||||
out.push(r)
|
||||
|
||||
@@ -0,0 +1,997 @@
|
||||
// Purchased-album detail page — screen 2 of Purchases
|
||||
// (contract §6 / §15.2, recon 02-tauri-frontend §B.2).
|
||||
//
|
||||
// Built from views/AlbumView.qml: same page shell (Flickable + 32px-padded
|
||||
// Column + QbzScrollBar), same 224px cover frame, the same rows/TrackRow +
|
||||
// rows/TrackListHeader table on rows/TrackCols geometry, the same disc
|
||||
// separators. What it adds over that template is the five things §15.2 lists:
|
||||
//
|
||||
// 1. the FORMAT dropdown (QbzSelect) — and the re-scoping it triggers is
|
||||
// the most surprising behaviour on this screen: every green check, the
|
||||
// progress section and Add-to-Library are all recomputed for the newly
|
||||
// selected format. NONE of that is computed here. The document says
|
||||
// `downloaded` per track, `progress` and `addToLibraryVisible`; this
|
||||
// file renders exactly what it is handed, so the re-scope is one
|
||||
// republish and cannot drift from the controller's own answer.
|
||||
// 2. a DETERMINATE progress bar, `completed / total` — counted in TRACKS,
|
||||
// never bytes (§4.2). This is the ONLY place a percentage appears.
|
||||
// 3. ADD TO LIBRARY, which is a SEQUENCE and not a banner (§6): the click
|
||||
// is one `addToLibrary()` call and the controller owns adding the
|
||||
// folder, clearing the download state, the toast and the scan.
|
||||
// 4. DOWNLOAD GOODIES — album-level only, never per-track, and ABSENT
|
||||
// (not disabled, not an empty state) when the list is empty (§14.3).
|
||||
// 5. track titles through `formatTrackTitle` (§10-C).
|
||||
//
|
||||
// --- The data seam ------------------------------------------------------
|
||||
// `QbzPurchases.albumJson`, re-parsed on `QbzPurchases.purchasesRev` (§G.3).
|
||||
// The rev is read purely as a binding dependency so a byte-identical
|
||||
// republish still re-parses. Every field is read defensively: the document
|
||||
// defaults to "{}" and QML parses it on the first frame.
|
||||
//
|
||||
// --- Per-track download state -------------------------------------------
|
||||
// `status` is the existing four-part vocabulary — 0 none · 1 queued ·
|
||||
// 2 downloading · 3 ready · 4 failed — drawn with the SAME glyph set
|
||||
// TrackRow's offline column uses (TrackRow.qml:782-835), so a purchase
|
||||
// download looks identical to a cache download. It does NOT ride the
|
||||
// `trackCacheStatusChanged` channel (§G.4): that channel is keyed by
|
||||
// track id alone and drives the OFFLINE-CACHE indicator, and a track can be
|
||||
// both cached offline and purchase-downloaded. Purchase status travels
|
||||
// inside `album_json`, which is per-album and cannot collide.
|
||||
//
|
||||
// --- Why the status cell lives OUTSIDE the shared TrackRow --------------
|
||||
// The views/local/LocalTrackRow.qml pattern: the shared row is narrowed by
|
||||
// exactly the 28px cell + 14px gap it does not draw, and the cell is drawn
|
||||
// in the gutter that frees. TrackListHeader reserves the same slice through
|
||||
// `trailingReserve: 42`, so the header and the rows stay the same width by
|
||||
// construction. The alternative — teaching TrackRow a second, purchase-only
|
||||
// download column — would fork a file five other surfaces share.
|
||||
//
|
||||
// --- Animation ----------------------------------------------------------
|
||||
// Every spinner on this page is ONE phase advanced on the shared shell pulse
|
||||
// (QbzShell.pulseMs, ~30 Hz) and frozen when nothing is in flight. Qt Quick
|
||||
// has no partial redraws, so a component-local RotationAnimation here would
|
||||
// bill a whole-window present per display frame for the life of a download.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../rows"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Transparent while the ambient background is active — AlbumView's rule.
|
||||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
// Rounds to the AppShell content-frame bezel (QML clips are rectangular,
|
||||
// so the frame's rounding never reaches the view — the fill must round).
|
||||
radius: 12
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
// ---- The document (§G.3) --------------------------------------------
|
||||
// `rev` is a binding dependency ONLY: it is bumped on every publish so a
|
||||
// republish of byte-identical JSON still re-parses here.
|
||||
readonly property var doc: root._parse(QbzPurchases.albumJson, QbzPurchases.purchasesRev)
|
||||
function _parse(raw, rev) {
|
||||
try { return JSON.parse(raw || "{}") } catch (e) { return ({}) }
|
||||
}
|
||||
|
||||
readonly property bool loading: doc.loading === true
|
||||
readonly property string errorText: doc.error || ""
|
||||
readonly property bool ready: !root.loading && root.errorText === ""
|
||||
|
||||
readonly property var formats: doc.formats || []
|
||||
readonly property var goodies: doc.goodies || []
|
||||
readonly property var discs: doc.discs || []
|
||||
readonly property var progress: doc.progress || ({})
|
||||
readonly property bool downloadable: doc.downloadable !== false
|
||||
|
||||
// Disc headers render only on a multi-disc album (recon §B.2.2-7:
|
||||
// `isMultiDisc = discGroups.size > 1`).
|
||||
readonly property bool multiDisc: root.discs.length > 1
|
||||
|
||||
// The dropdown's index. `formats` order IS the dropdown order and index 0
|
||||
// is the default (§G.3), so an id the document has not caught up with yet
|
||||
// resolves to 0 rather than to a blank control.
|
||||
readonly property int formatIndex: {
|
||||
var want = doc.selectedFormatId
|
||||
for (var i = 0; i < root.formats.length; i++)
|
||||
if (root.formats[i].id === want) return i
|
||||
return 0
|
||||
}
|
||||
readonly property var formatLabels: {
|
||||
var out = []
|
||||
for (var i = 0; i < root.formats.length; i++)
|
||||
out.push(root.formats[i].label || "")
|
||||
return out
|
||||
}
|
||||
|
||||
// Click-to-play is gated on `streamable`, which on THIS screen defaults
|
||||
// FALSE (§G.3 / §2.6): these tracks come from the catalog album, not from
|
||||
// the purchases list. The album Play button therefore only appears when
|
||||
// at least one track can actually be played — a hero button that plays
|
||||
// nothing is worse than no hero button.
|
||||
readonly property bool anyStreamable: {
|
||||
for (var d = 0; d < root.discs.length; d++) {
|
||||
var ts = root.discs[d].tracks || []
|
||||
for (var i = 0; i < ts.length; i++)
|
||||
if (ts[i].streamable === true) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- The one animation clock ----------------------------------------
|
||||
// Advanced on the shared shell pulse and ONLY while something is actually
|
||||
// in flight; a frozen page writes nothing, so it dirties nothing.
|
||||
readonly property bool anyTrackBusy: {
|
||||
for (var d = 0; d < root.discs.length; d++) {
|
||||
var ts = root.discs[d].tracks || []
|
||||
for (var i = 0; i < ts.length; i++) {
|
||||
var st = ts[i].status
|
||||
if (st === 1 || st === 2) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
readonly property bool spinning: root.visible
|
||||
&& (root.loading || root.progress.active === true || root.anyTrackBusy)
|
||||
property real spinDeg: 0
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onPulseMsChanged() {
|
||||
// 12° per ~30Hz tick = one revolution per second, the rate
|
||||
// theme/QbzSpinner.qml animates at.
|
||||
if (root.spinning)
|
||||
root.spinDeg = (root.spinDeg + 12) % 360
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Track titles (§10-C) -------------------------------------------
|
||||
/// `formatTrackTitle` — the Tauri helper (src/lib/utils/trackTitle.ts:26-31):
|
||||
/// the version, when there is one, is appended in parentheses. Hard-coding
|
||||
/// `title.trim()` here is what made the latent #360 regression unreachable
|
||||
/// in the reference, so it is a real function even though `version` is
|
||||
/// empty on most releases.
|
||||
function formatTrackTitle(title, version) {
|
||||
var base = (title || "").trim()
|
||||
var v = (version || "").trim()
|
||||
return v === "" ? base : base + " (" + v + ")"
|
||||
}
|
||||
|
||||
/// M:SS. The shared TrackRow renders `item.duration` as a STRING and the
|
||||
/// document carries SECONDS, so a raw hand-off prints "245" in the
|
||||
/// duration column.
|
||||
function fmtTrackDuration(secs) {
|
||||
var s = Math.max(0, Math.floor(secs || 0))
|
||||
var m = Math.floor(s / 60)
|
||||
var r = s % 60
|
||||
return m + ":" + (r < 10 ? "0" : "") + r
|
||||
}
|
||||
|
||||
/// Album total — `1h 12m` / `12m`, the reference's own units
|
||||
/// (PurchaseAlbumDetailView.svelte:140-145). They are not localised there
|
||||
/// either; inventing two new msgids for them is not this port's call.
|
||||
function fmtAlbumDuration(secs) {
|
||||
var s = Math.max(0, Math.floor(secs || 0))
|
||||
var h = Math.floor(s / 3600)
|
||||
var m = Math.floor((s % 3600) / 60)
|
||||
return h > 0 ? (h + "h " + m + "m") : (m + "m")
|
||||
}
|
||||
|
||||
/// The purchase date, long month — the detail screen's format
|
||||
/// (`month: 'long'`, recon §B.2.2-3). 0 = never rendered.
|
||||
function fmtPurchased(ts) {
|
||||
if (!ts || ts <= 0) return ""
|
||||
return Qt.formatDate(new Date(ts * 1000), Locale.LongFormat)
|
||||
}
|
||||
|
||||
/// The meta line: purchase date · label · year · genre, joined only over
|
||||
/// the parts that exist. (The reference emits a leading "·" when the
|
||||
/// genre is present and the date is not — that is a bug, not a behaviour.)
|
||||
readonly property string metaLine: {
|
||||
var parts = []
|
||||
var p = root.fmtPurchased(doc.purchasedAt)
|
||||
if (p !== "") parts.push(root.t("Purchased on") + " " + p)
|
||||
if ((doc.label || "") !== "") parts.push(doc.label)
|
||||
if ((doc.year || "") !== "") parts.push(doc.year)
|
||||
if ((doc.genre || "") !== "") parts.push(doc.genre)
|
||||
return parts.join(" • ")
|
||||
}
|
||||
|
||||
// ---- Artwork ---------------------------------------------------------
|
||||
// The url-keyed pipeline every other view uses: the url goes out on
|
||||
// QbzShell.sidebarArtworkWindow and the decoded path comes back on
|
||||
// QbzLibrary.libraryArtworkReady, keyed by that same url.
|
||||
property var coverMap: ({})
|
||||
property string dispatchedCover: ""
|
||||
function dispatchCover() {
|
||||
var url = doc.artworkUrl || ""
|
||||
if (url === "" || url === root.dispatchedCover) return
|
||||
root.dispatchedCover = url
|
||||
QbzShell.sidebarArtworkWindow(JSON.stringify([url]))
|
||||
}
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
var m = root.coverMap
|
||||
m[key] = path
|
||||
root.coverMap = Object.assign({}, m)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: root.dispatchCover()
|
||||
onDocChanged: root.dispatchCover()
|
||||
|
||||
// ======================== the page ====================================
|
||||
Flickable {
|
||||
id: pageFlick
|
||||
anchors.fill: parent
|
||||
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 }
|
||||
|
||||
// --- Loading ---------------------------------------------------
|
||||
// The reference's loading state is a centred 32px spinner, not a
|
||||
// skeleton (recon §B.2.2-2) — and a spinner is honest here,
|
||||
// because the detail is ONE request graph that lands in one piece
|
||||
// rather than the staged passes AlbumView shows placeholders for.
|
||||
Item {
|
||||
visible: root.loading
|
||||
width: parent.width - 64
|
||||
height: visible ? 220 : 0
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "loader-circle"
|
||||
width: 32
|
||||
height: 32
|
||||
tintName: "accent"
|
||||
rotation: root.spinDeg
|
||||
}
|
||||
}
|
||||
|
||||
// --- Error + retry (LibraryView.qml:920-959) -------------------
|
||||
Column {
|
||||
visible: !root.loading && root.errorText !== ""
|
||||
width: parent.width - 64
|
||||
spacing: 10
|
||||
Item { width: 1; height: 60 }
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.t("Couldn't load purchases. Check your connection.")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
// No detail sub-line. The two-line shape borrowed from
|
||||
// LibraryView only earns its second line when the document
|
||||
// carries something MORE SPECIFIC than the headline; this
|
||||
// controller only ever emits the same generic sentence, so
|
||||
// printing it twice just says it twice.
|
||||
Rectangle {
|
||||
// §G.1 has no dedicated reload; `openAlbum(id)` re-records
|
||||
// the detail and reloads it, which is the retry. With no
|
||||
// id there is nothing to retry, so the button is absent
|
||||
// rather than dead.
|
||||
visible: (root.doc.id || "") !== ""
|
||||
width: retryText.implicitWidth + 28
|
||||
height: 32
|
||||
radius: 6
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
id: retryText
|
||||
anchors.centerIn: parent
|
||||
text: root.t("Retry")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
MouseArea {
|
||||
id: retryArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPurchases.openAlbum(root.doc.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album header ---------------------------------------------
|
||||
Row {
|
||||
visible: root.ready
|
||||
width: parent.width - 64
|
||||
spacing: 32
|
||||
|
||||
Rectangle {
|
||||
width: 224
|
||||
height: 224
|
||||
radius: 12
|
||||
color: theme.surfaceElevated
|
||||
// No clip: RoundedImage confines itself on both arms, and
|
||||
// a clip is an unconditional batch root.
|
||||
RoundedImage {
|
||||
visible: root.downloadable
|
||||
anchors.fill: parent
|
||||
source: root.coverMap[root.doc.artworkUrl || ""] || ""
|
||||
radius: 12
|
||||
}
|
||||
// An album the account cannot download shows the warning
|
||||
// in place of the cover (recon §B.2.2-3) — the one place
|
||||
// this screen says "unavailable", and it says it about the
|
||||
// PURCHASE, never about the network.
|
||||
Column {
|
||||
visible: !root.downloadable
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
name: "triangle-alert"
|
||||
width: 18
|
||||
height: 18
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.t("Unavailable")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - 224 - 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 0
|
||||
|
||||
// `fontSection`, matching AlbumView.qml:626 exactly — this
|
||||
// header is a copy of that one and the size had drifted to
|
||||
// `fontTitle` (25), which would have made a PURCHASED album
|
||||
// shout louder than an ordinary one on the very next screen.
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.doc.title || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { width: 1; height: 4 }
|
||||
Text {
|
||||
// `width: fit-content` in the reference — the hover
|
||||
// target must be the NAME, not the whole line, or the
|
||||
// pointer turns into a link half a screen away from it.
|
||||
width: Math.min(implicitWidth, parent.width)
|
||||
text: root.doc.artist || ""
|
||||
color: (root.doc.artistId || "") !== ""
|
||||
? (artistArea.containsMouse ? theme.accentHover : theme.accent)
|
||||
: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
MouseArea {
|
||||
id: artistArea
|
||||
anchors.fill: parent
|
||||
enabled: (root.doc.artistId || "") !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: QbzArtist.openArtist(root.doc.artistId)
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 8 }
|
||||
Text {
|
||||
visible: root.metaLine !== ""
|
||||
width: parent.width
|
||||
text: root.metaLine
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item { visible: root.metaLine !== ""; width: 1; height: 6 }
|
||||
// The album's stored quality, through the shared chip
|
||||
// rather than a hand-rolled "24-bit / 96 kHz" string —
|
||||
// QualityBadgeFull hides ITSELF on an empty tier, so a
|
||||
// document that does not carry one costs no empty band.
|
||||
QualityBadgeFull {
|
||||
tier: root.doc.qualityTier || ""
|
||||
detail: root.doc.qualityDetail || ""
|
||||
}
|
||||
// Gated on the same tier the badge is: the badge hides
|
||||
// ITSELF and a Column skips an invisible child, so an
|
||||
// ungated spacer would leave 6px of nothing behind it.
|
||||
Item {
|
||||
visible: (root.doc.qualityTier || "") !== ""
|
||||
width: 1
|
||||
height: 6
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
var n = root.doc.tracksCount || 0
|
||||
var s = root.t("{} tracks").replace("{}", n)
|
||||
var d = root.fmtAlbumDuration(root.doc.duration)
|
||||
return (root.doc.duration || 0) > 0 ? (s + " · " + d) : s
|
||||
}
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Item { width: 1; height: 20 }
|
||||
|
||||
// --- Action row --------------------------------------
|
||||
// Rendered ONLY for a downloadable album (recon §B.2.2-6):
|
||||
// on an album the account cannot download, none of these
|
||||
// has anything to act on.
|
||||
Row {
|
||||
visible: root.downloadable
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
QbzCircleAction {
|
||||
visible: root.anyStreamable
|
||||
primary: true
|
||||
name: "play-fill"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.playAlbum(root.doc.id || "")
|
||||
}
|
||||
// Download all. Disabled — never hidden — while a
|
||||
// download is running or when the synthesis produced
|
||||
// no format, so the control stays where the user left
|
||||
// it (§4.3: the four formats come from album metadata,
|
||||
// and an album can legitimately offer none).
|
||||
QbzCircleAction {
|
||||
primary: !root.anyStreamable
|
||||
name: "cloud-download"
|
||||
btnEnabled: !(root.progress.active === true)
|
||||
&& root.formats.length > 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: {
|
||||
if (root.progress.active !== true && root.formats.length > 0)
|
||||
QbzPurchases.downloadAlbum()
|
||||
}
|
||||
}
|
||||
// THE FORMAT DROPDOWN. Options in document order —
|
||||
// that order IS the dropdown order and index 0 is the
|
||||
// default. Choosing one re-scopes every downloaded
|
||||
// mark, the progress section and Add-to-Library; none
|
||||
// of that is computed here, it arrives in the next
|
||||
// republish.
|
||||
QbzSelect {
|
||||
visible: root.formats.length > 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
menuWidth: 200
|
||||
options: root.formatLabels
|
||||
currentIndex: root.formatIndex
|
||||
enabled: !(root.progress.active === true)
|
||||
onSelected: function (i) {
|
||||
if (i >= 0 && i < root.formats.length)
|
||||
QbzPurchases.setFormat(root.formats[i].id)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: root.formats.length === 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("No downloadable formats available")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
IconTextButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.t("Choose folder")
|
||||
iconName: "folder-open"
|
||||
btnEnabled: !(root.progress.active === true)
|
||||
onClicked: QbzPurchases.chooseDestination()
|
||||
}
|
||||
// GOODIES — album-level only, never per-track, and
|
||||
// completely absent when the list is empty (§14.3).
|
||||
// Not disabled, not an empty state: the owner's
|
||||
// account will never populate this, so a wrong empty
|
||||
// state would be a permanent wrong nobody can see.
|
||||
IconTextButton {
|
||||
visible: root.goodies.length > 0 && !goodieCount.visible
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.t("Download goodies")
|
||||
iconName: "cloud-download"
|
||||
btnEnabled: !(root.progress.active === true)
|
||||
onClicked: QbzPurchases.downloadGoodies()
|
||||
}
|
||||
// Goodies are counted SEPARATELY from tracks (§14.3), so
|
||||
// they get their own readout rather than a slot in the
|
||||
// track bar. Without it a multi-item booklet download is
|
||||
// indistinguishable from a click that did nothing until
|
||||
// the final toast lands, and the natural reaction is to
|
||||
// click again.
|
||||
Row {
|
||||
id: goodieCount
|
||||
readonly property var gp: root.doc.goodiesProgress || ({})
|
||||
visible: goodieCount.gp.active === true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
QbzIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "loader-circle"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "accent"
|
||||
rotation: root.spinDeg
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("Downloading {}/{}...")
|
||||
.replace("{}", goodieCount.gp.completed || 0)
|
||||
.replace("{}", goodieCount.gp.total || 0)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The chosen destination, shown so "Choose folder" is not a
|
||||
// button whose effect is invisible.
|
||||
Item {
|
||||
visible: destPath.visible
|
||||
width: 1
|
||||
height: 8
|
||||
}
|
||||
Text {
|
||||
id: destPath
|
||||
visible: root.downloadable && (root.doc.destination || "") !== ""
|
||||
width: parent.width
|
||||
text: root.doc.destination || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download progress (§15.2-2) -------------------------------
|
||||
// Determinate, `completed / total`, counted in TRACKS and never in
|
||||
// bytes (§4.2). The ONLY percentage on the whole feature.
|
||||
Item { visible: progressBox.visible; width: 1; height: 24 }
|
||||
Rectangle {
|
||||
id: progressBox
|
||||
// The reference renders this section while downloading, when the
|
||||
// album finished, AND after a cancel — `downloadingAll ||
|
||||
// allComplete || wasCancelled`. Without the third arm a cancel
|
||||
// makes the whole box VANISH, which reads as "nothing ever
|
||||
// happened" rather than "you stopped it".
|
||||
visible: root.ready
|
||||
&& (root.progress.active === true
|
||||
|| root.progress.allComplete === true
|
||||
|| root.progress.cancelled === true)
|
||||
width: parent.width - 64
|
||||
height: visible ? 66 : 0
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
readonly property int completed: root.progress.completed || 0
|
||||
readonly property int total: root.progress.total || 0
|
||||
readonly property bool done: root.progress.allComplete === true
|
||||
readonly property bool cancelled: root.progress.cancelled === true && !progressBox.done
|
||||
readonly property real fraction:
|
||||
progressBox.total > 0
|
||||
? Math.max(0, Math.min(1, progressBox.completed / progressBox.total))
|
||||
: 0
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 14
|
||||
anchors.topMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
spacing: 6
|
||||
|
||||
// 32px, not the label's own line height: the Cancel
|
||||
// control is an IconTextButton and those are 32 tall, so a
|
||||
// 20px band would let it hang out of the box.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
QbzIcon {
|
||||
visible: progressBox.done
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "check"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "accent"
|
||||
}
|
||||
QbzIcon {
|
||||
visible: progressBox.cancelled
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "x"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "muted"
|
||||
}
|
||||
// The spinner turns only while work is actually in
|
||||
// flight — a cancelled album must not keep spinning.
|
||||
QbzIcon {
|
||||
visible: !progressBox.done && !progressBox.cancelled
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "loader-circle"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "accent"
|
||||
rotation: root.spinDeg
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: progressBox.done
|
||||
? root.t("Download complete")
|
||||
: progressBox.cancelled
|
||||
? root.t("Download cancelled ({}/{})")
|
||||
.replace("{}", progressBox.completed)
|
||||
.replace("{}", progressBox.total)
|
||||
: root.t("Downloading {}/{}...")
|
||||
.replace("{}", progressBox.completed)
|
||||
.replace("{}", progressBox.total)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
// Cancel is offered only while the album loop is
|
||||
// running. A failed track does NOT stop it (§4.2b) —
|
||||
// it keeps its own retry control and the album carries
|
||||
// on — so this button means "stop the rest", not
|
||||
// "something went wrong".
|
||||
IconTextButton {
|
||||
visible: root.progress.active === true
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.t("Cancel")
|
||||
iconName: "x"
|
||||
danger: true
|
||||
onClicked: QbzPurchases.cancelAlbumDownload()
|
||||
}
|
||||
}
|
||||
|
||||
// The bar. NO `Behavior on width`: it is fed by the
|
||||
// document, and an animator on a data-fed property turns
|
||||
// one republish into a display-rate run of whole-window
|
||||
// presents.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 4
|
||||
radius: 2
|
||||
color: theme.surfaceElevated
|
||||
Rectangle {
|
||||
width: Math.round(parent.width * progressBox.fraction)
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: progressBox.done ? theme.success : theme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add to Library (§15.2-3) ----------------------------------
|
||||
// A SEQUENCE, not a banner: the click is one call, and the
|
||||
// controller adds the resolved album subfolder as a library
|
||||
// folder, clears the download state, raises the toast and starts
|
||||
// the background scan. Its own visibility is re-scoped by the
|
||||
// format dropdown, which is why the flag is read off the document
|
||||
// rather than derived from `allComplete` here.
|
||||
Item { visible: addRow.visible; width: 1; height: 16 }
|
||||
Rectangle {
|
||||
id: addRow
|
||||
visible: root.ready && root.doc.addToLibraryVisible === true
|
||||
width: parent.width - 64
|
||||
height: visible ? 56 : 0
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.success
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 14
|
||||
spacing: 12
|
||||
|
||||
QbzPrimaryButton {
|
||||
id: addBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.t("Add to Library")
|
||||
btnHeight: 32
|
||||
labelSize: theme.fontLegal
|
||||
onClicked: QbzPurchases.addToLibrary()
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Clamped: the button auto-sizes to its label, and a
|
||||
// long translation must not drive this negative.
|
||||
width: Math.max(0, parent.width - 12 - addBtn.width)
|
||||
text: root.t("Add these downloaded tracks to your local library.")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Divider ---------------------------------------------------
|
||||
Item { visible: root.ready; width: 1; height: 24 }
|
||||
Rectangle {
|
||||
visible: root.ready
|
||||
width: parent.width - 64
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
Item { visible: root.ready; width: 1; height: 8 }
|
||||
|
||||
// --- Track table -----------------------------------------------
|
||||
Column {
|
||||
visible: root.ready
|
||||
width: parent.width - 64
|
||||
spacing: 0
|
||||
|
||||
// Column header. The arms MIRROR the delegate's arms below —
|
||||
// no heart, no shared offline column, no ⋯ — and
|
||||
// `trailingReserve` is the 28px status cell plus its 14px gap,
|
||||
// i.e. exactly what the delegate spends outside the shared
|
||||
// row. The cloud glyph that heads that column is drawn here
|
||||
// because the reserve slot is, by design, an unlabelled Item.
|
||||
Item {
|
||||
// A labelled header over nothing is worse than no header.
|
||||
visible: root.discs.length > 0
|
||||
width: parent.width
|
||||
height: visible ? 40 : 0
|
||||
TrackListHeader {
|
||||
anchors.fill: parent
|
||||
bandHeight: 40
|
||||
labelSpacing: 0.5
|
||||
showFavorite: false
|
||||
showDownload: false
|
||||
showMenu: false
|
||||
trailingReserve: 42
|
||||
}
|
||||
QbzIcon {
|
||||
x: parent.width - 40 + 7
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "cloud-download"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
|
||||
// Discs. The document is ALREADY grouped (§G.3), in the order
|
||||
// the reference's first-seen bucketing produces, so nothing is
|
||||
// regrouped or re-sorted here.
|
||||
Repeater {
|
||||
model: root.discs
|
||||
delegate: Column {
|
||||
id: discBlock
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 0
|
||||
|
||||
// Disc separator — multi-disc albums only, exactly as
|
||||
// AlbumView and the reference both gate it.
|
||||
Item {
|
||||
visible: root.multiDisc
|
||||
width: parent.width
|
||||
height: visible ? 40 : 0
|
||||
Text {
|
||||
x: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("Disc") + " "
|
||||
+ (discBlock.modelData.number || (discBlock.index + 1))
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: discBlock.modelData.tracks || []
|
||||
delegate: Item {
|
||||
id: trackRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
height: 50
|
||||
|
||||
readonly property bool streamable: modelData.streamable === true
|
||||
readonly property string trackId: modelData.id || ""
|
||||
/// The row's live download state. A persisted
|
||||
/// `downloaded` — which the controller has
|
||||
/// already re-scoped to the SELECTED format —
|
||||
/// reads as `ready`, so the green check
|
||||
/// disappears the moment the dropdown moves to
|
||||
/// a format this track was not downloaded in.
|
||||
/// A transient status always wins, because a
|
||||
/// download in flight is newer than the
|
||||
/// registry.
|
||||
readonly property int status: {
|
||||
var s = modelData.status
|
||||
if (s === undefined || s === 0)
|
||||
return modelData.downloaded === true ? 3 : 0
|
||||
return s
|
||||
}
|
||||
|
||||
// The item the shared row draws. Built rather
|
||||
// than passed through for three reasons: the
|
||||
// title needs `formatTrackTitle`, the duration
|
||||
// is SECONDS here and TrackRow renders it as a
|
||||
// string, and the artist line is shown only
|
||||
// when the performer differs from the album
|
||||
// artist (recon §B.2.2-7).
|
||||
readonly property var rowItem: ({
|
||||
"id": trackRow.trackId,
|
||||
"title": root.formatTrackTitle(trackRow.modelData.title,
|
||||
trackRow.modelData.version),
|
||||
"artist": (trackRow.modelData.artist || "") !== ""
|
||||
&& trackRow.modelData.artist !== (root.doc.artist || "")
|
||||
? trackRow.modelData.artist : "",
|
||||
"duration": root.fmtTrackDuration(trackRow.modelData.duration),
|
||||
"qualityTier": trackRow.modelData.qualityTier || "",
|
||||
"qualityDetail": trackRow.modelData.qualityDetail || ""
|
||||
})
|
||||
|
||||
TrackRow {
|
||||
// Narrowed by exactly the 28px status cell
|
||||
// + the 14px gap this row draws outside
|
||||
// the shared one (LocalTrackRow's pattern).
|
||||
width: trackRow.width - 42
|
||||
item: trackRow.rowItem
|
||||
number: trackRow.modelData.trackNumber > 0
|
||||
? trackRow.modelData.trackNumber : (trackRow.index + 1)
|
||||
zebra: true
|
||||
showFavorite: false
|
||||
showDownload: false
|
||||
showMenu: false
|
||||
// Not a drag source, and `draggable` is
|
||||
// also the "this id is a Qobuz catalog id"
|
||||
// predicate that arms the shared row's own
|
||||
// OFFLINE-cache column — which must stay
|
||||
// dark here, because purchase state and
|
||||
// cache state are different features that
|
||||
// must not overwrite each other (§G.4).
|
||||
draggable: false
|
||||
// Click-to-play only where the catalog
|
||||
// says the track is streamable. It defaults
|
||||
// FALSE on this screen; do not invert it.
|
||||
clickPlays: trackRow.streamable
|
||||
onPlayRequested: {
|
||||
if (trackRow.streamable)
|
||||
QbzPlayer.playAlbumFrom(root.doc.id || "",
|
||||
trackRow.trackId)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The purchase download cell ------------
|
||||
// Same glyph set, sizes and tints as the
|
||||
// shared row's offline column
|
||||
// (TrackRow.qml:782-835) so the two features
|
||||
// read identically — on a DIFFERENT channel.
|
||||
Item {
|
||||
x: trackRow.width - 40
|
||||
width: 28
|
||||
height: 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
// Gated on the SAME predicate the click is:
|
||||
// on an album the account cannot download,
|
||||
// or with no synthesized format, the
|
||||
// reference renders nothing here at all
|
||||
// (recon §B.2.3, last row) rather than a
|
||||
// cloud that does nothing.
|
||||
QbzIcon {
|
||||
visible: trackRow.status === 0 && dlArea.enabled
|
||||
anchors.centerIn: parent
|
||||
name: "cloud-download"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: dlArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
QbzIcon {
|
||||
visible: trackRow.status === 1 || trackRow.status === 2
|
||||
anchors.centerIn: parent
|
||||
name: "loader-circle"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: "accent"
|
||||
rotation: root.spinDeg
|
||||
}
|
||||
QbzIcon {
|
||||
visible: trackRow.status === 3
|
||||
anchors.centerIn: parent
|
||||
name: "circle-check-big"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: "accent"
|
||||
}
|
||||
// Failed keeps its RETRY control and the
|
||||
// album keeps going (§4.2b): the glyph is
|
||||
// the control, exactly as the reference
|
||||
// makes the warning triangle clickable.
|
||||
QbzIcon {
|
||||
visible: trackRow.status === 4
|
||||
anchors.centerIn: parent
|
||||
name: "triangle-alert"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: "favorite"
|
||||
}
|
||||
MouseArea {
|
||||
id: dlArea
|
||||
anchors.fill: parent
|
||||
// Nothing to download on an album the
|
||||
// account cannot download, and nothing
|
||||
// to download into with no format.
|
||||
enabled: root.downloadable
|
||||
&& root.formats.length > 0
|
||||
&& trackRow.status !== 1
|
||||
&& trackRow.status !== 2
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor
|
||||
: Qt.ArrowCursor
|
||||
ToolTip.visible: containsMouse && trackRow.status === 4
|
||||
ToolTip.text: root.t("Retry")
|
||||
ToolTip.delay: 350
|
||||
onClicked: {
|
||||
if (trackRow.status === 4)
|
||||
QbzPurchases.retryTrack(trackRow.trackId)
|
||||
else
|
||||
QbzPurchases.downloadTrack(trackRow.trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to list. Reached when the album resolved but carries
|
||||
// no tracks — rare, and the alternative is a labelled header
|
||||
// over blank space.
|
||||
// (No `anchors.horizontalCenter` — this is a Column child, and
|
||||
// the Column owns x. QbzEmptyState centres its own contents.)
|
||||
QbzEmptyState {
|
||||
visible: root.ready && root.discs.length === 0
|
||||
width: parent.width
|
||||
iconName: "shopping-bag"
|
||||
title: root.t("No purchased tracks yet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thin auto-hiding scrollbar (ListScrollbar).
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: pageFlick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// PurchasesView — the My-Purchases library surface, route id "purchases".
|
||||
// Port of purchases/PurchasesView.slint (:1300-1470 is the page itself), which
|
||||
// is in turn the 1:1 port of `PurchasesView.svelte`.
|
||||
//
|
||||
// Region by region (contract §15.1):
|
||||
// 1. header 94x94 accent-gradient tile + the `shopping-bag` glyph at 32
|
||||
// 2. region notice dismissible, per user, and its wording is COMMERCIAL
|
||||
// 3. sticky nav QbzTabBar (Albums / Tracks, with counts) + expandable search
|
||||
// 4. toolbar NOT the same on both tabs — see views/purchases/PurchasesToolbar
|
||||
// 5. body albums grid/list, or the track rows
|
||||
// 6. states loading · error + retry · empty
|
||||
//
|
||||
// ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────
|
||||
// No pagination, no infinite scroll, no track context menu, no double-click,
|
||||
// no multi-select, no drag, no Escape handling: none of it exists in the
|
||||
// reference (§1). Two CSS classes in the Tauri original (`.playing` on list
|
||||
// rows, `.track-row.failed`) have NO rule in the only stylesheet — they are
|
||||
// dead, and porting them as visible states would add behaviour that never
|
||||
// existed. There is also NO offline placeholder: §15.1-6 is explicit that
|
||||
// Tauri has none and that none was ruled in.
|
||||
//
|
||||
// ── STATE LIFETIME (§10-F) ───────────────────────────────────────────────
|
||||
// Tab, sort, direction, grouping, grid/list and the search text all die when
|
||||
// the screen is left. `QbzPurchases.leave()` fires from this component's
|
||||
// destruction, which is the moment the route arm unloads it. The four
|
||||
// PERSISTED preferences (hide-unavailable, hide-downloaded, quality filter,
|
||||
// region-notice-dismissed) are the only survivors, and they live in Rust.
|
||||
//
|
||||
// ── THE SEARCH DEBOUNCE IS OURS ──────────────────────────────────────────
|
||||
// §G.1: QML owns the 300 ms debounce and `search()` is the fire. It is a
|
||||
// ONE-SHOT Timer, which the repaint-pulse rule allows — a repeating one would
|
||||
// not be. The call REFETCHES BOTH types from the server (§5); it is not a
|
||||
// local filter over the active tab.
|
||||
//
|
||||
// ── WHY THE BODY IS NOT AlbumCollection ──────────────────────────────────
|
||||
// See the header of views/purchases/PurchaseGridCard.qml. In one line: the
|
||||
// shared card/row pair hardwires its click to `QbzAlbum.openAlbum`, which would
|
||||
// make the purchase-album route unreachable, and neither has an arm for the
|
||||
// UNAVAILABLE state this screen is built around. The .slint reached the same
|
||||
// conclusion for the same reasons and drew its own cells too.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
import "purchases"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Round to the AppShell content-frame bezel (the literal in all painting
|
||||
// view roots — QML clips are rectangular, so the frame's rounding never
|
||||
// reaches the view and the view's own fill must round).
|
||||
radius: 12
|
||||
color: root.ambientOn ? "transparent" : theme.surfaceMain
|
||||
readonly property bool ambientOn: theme.ambientOn
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
// ── the document ──────────────────────────────────────────────────────
|
||||
// GUARDED: a raw JSON.parse in a binding throws on the pre-publish frame.
|
||||
// `purchasesRev` is a pure binding dependency — it is bumped on EVERY
|
||||
// publish so this re-parses even when the JSON text is byte-identical.
|
||||
function _parse(json, rev) {
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch (e) {
|
||||
return ({})
|
||||
}
|
||||
}
|
||||
readonly property var doc: root._parse(QbzPurchases.listJson,
|
||||
QbzPurchases.purchasesRev)
|
||||
|
||||
readonly property string tab: root.doc.tab || "albums"
|
||||
readonly property var toolbarDoc: root.doc.toolbar || ({})
|
||||
readonly property string group: root.toolbarDoc.group || "off"
|
||||
readonly property string viewMode: root.toolbarDoc.viewMode || "grid"
|
||||
readonly property var albums: root.doc.albums || []
|
||||
readonly property var tracks: root.doc.tracks || []
|
||||
readonly property var counts: root.doc.counts || ({})
|
||||
readonly property string search: root.doc.search || ""
|
||||
readonly property bool loading: root.doc.loading === true
|
||||
readonly property string loadError: root.doc.error || ""
|
||||
|
||||
readonly property bool onAlbums: root.tab !== "tracks"
|
||||
readonly property var rows: root.onAlbums ? root.albums : root.tracks
|
||||
readonly property bool grouped: root.group !== "off" && root.group !== ""
|
||||
|
||||
// ── entry / exit ──────────────────────────────────────────────────────
|
||||
// `openList()` carries the `metadataLoaded` guard on the Rust side (§5), so
|
||||
// a re-entry — or a second caller — costs nothing.
|
||||
Component.onCompleted: QbzPurchases.openList()
|
||||
Component.onDestruction: QbzPurchases.leave()
|
||||
|
||||
// ── search: 300 ms, one shot ──────────────────────────────────────────
|
||||
property string pendingQuery: ""
|
||||
Timer {
|
||||
id: searchDebounce
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: QbzPurchases.search(root.pendingQuery)
|
||||
}
|
||||
|
||||
// ── grouping ──────────────────────────────────────────────────────────
|
||||
// `list_json` publishes FLAT arrays only — §G.2 has no grouped section, and
|
||||
// `purchases_qt.rs::build_list_doc` says so in as many words ("GROUPING IS
|
||||
// NOT DONE HERE … the QML host owns the bucketing"). The rules below are
|
||||
// the ones that comment spells out, so the two halves cannot drift:
|
||||
//
|
||||
// album key artist name, or alphaGroupKey(title)
|
||||
// track key alphaGroupKey(title) / performer / album title, else "Unknown"
|
||||
// order by key, ascending
|
||||
//
|
||||
// It is presentation-only: search, sort and filtering all stay in Rust, and
|
||||
// the rows inside a bucket keep the order Rust handed them, so the toolbar's
|
||||
// sort still decides what comes first WITHIN a section.
|
||||
function _alphaKey(s) {
|
||||
var v = (s || "").trim()
|
||||
if (v === "")
|
||||
return "#"
|
||||
var c = v.charAt(0).toUpperCase()
|
||||
// The reference's exact test: anything that is not A-Z — a digit, a
|
||||
// bracket, Cyrillic, kana — shares the "#" bucket. Widening it here
|
||||
// would silently disagree with the Rust-side rule this mirrors.
|
||||
return (c >= "A" && c <= "Z") ? c : "#"
|
||||
}
|
||||
function _bucket(list, keyOf) {
|
||||
var byKey = {}
|
||||
var keys = []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var k = keyOf(list[i])
|
||||
if (byKey[k] === undefined) {
|
||||
byKey[k] = []
|
||||
keys.push(k)
|
||||
}
|
||||
byKey[k].push(list[i])
|
||||
}
|
||||
keys.sort(function (a, b) { return a.localeCompare(b) })
|
||||
var out = []
|
||||
for (var j = 0; j < keys.length; j++)
|
||||
out.push({ "title": keys[j], "items": byKey[keys[j]] })
|
||||
return out
|
||||
}
|
||||
/// Albums: "alpha" (by album title) | "artist".
|
||||
function albumGroups() {
|
||||
if (!root.grouped)
|
||||
return []
|
||||
if (root.group === "artist")
|
||||
return root._bucket(root.albums, function (a) {
|
||||
return (a.artist || "").trim() || root.t("Unknown")
|
||||
})
|
||||
return root._bucket(root.albums, function (a) { return root._alphaKey(a.title) })
|
||||
}
|
||||
/// Tracks: "album" | "artist" | "name" (by track title initial).
|
||||
function trackGroups() {
|
||||
if (!root.grouped)
|
||||
return []
|
||||
if (root.group === "album")
|
||||
return root._bucket(root.tracks, function (tr) {
|
||||
return (tr.album || "").trim() || root.t("Unknown")
|
||||
})
|
||||
if (root.group === "artist")
|
||||
return root._bucket(root.tracks, function (tr) {
|
||||
return (tr.artist || "").trim() || root.t("Unknown")
|
||||
})
|
||||
return root._bucket(root.tracks, function (tr) { return root._alphaKey(tr.title) })
|
||||
}
|
||||
|
||||
function openAlbum(id) {
|
||||
if (!id || id === "")
|
||||
return
|
||||
// §G.1, in THIS order: the controller records the detail first, then
|
||||
// the route changes.
|
||||
QbzPurchases.openAlbum(id)
|
||||
QbzShell.navigateTo("purchase-album")
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
leftPadding: 24
|
||||
rightPadding: 24
|
||||
topPadding: 24
|
||||
// The port's scroll runway: the last row must clear the player bar.
|
||||
bottomPadding: 100
|
||||
spacing: 16
|
||||
|
||||
readonly property int contentW: Math.max(0, page.width - 48)
|
||||
|
||||
// ── 1. Header ─────────────────────────────────────────────────
|
||||
//
|
||||
// OWNER RULING, 2026-08-16: plain title, no icon tile.
|
||||
//
|
||||
// §15.1-1 specified the reference's 94x94 accent-gradient tile with
|
||||
// a 32px shopping-bag in it, and a title at `fontTitle` (25). That
|
||||
// was a faithful port of Tauri's own header — and faithful is what
|
||||
// made it wrong here, because NOTHING else in this application looks
|
||||
// like that. Library opens straight into its toolbar with no page
|
||||
// title at all; My QBZ and the label header title themselves at
|
||||
// `fontSection` (18) / bold. Purchases stood out as the one page
|
||||
// shouting its own name over a large coloured square.
|
||||
//
|
||||
// So this diverges from the contract deliberately: app-internal
|
||||
// consistency beats fidelity to a header the reference happened to
|
||||
// draw that way. The `shopping-bag` glyph still identifies the
|
||||
// destination in the sidebar and the title bar, which is where a
|
||||
// section icon belongs in this shell.
|
||||
Text {
|
||||
width: page.contentW
|
||||
text: root.t("My Purchases")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// ── 2. Region notice ──────────────────────────────────────────
|
||||
// The copy must NOT be reworded to blame the network: §2.5b proved
|
||||
// the endpoint answers 200 in a blocked region, so the limitation
|
||||
// this strip describes is COMMERCIAL.
|
||||
Rectangle {
|
||||
id: regionNotice
|
||||
visible: root.doc.regionNoticeVisible === true
|
||||
width: page.contentW
|
||||
height: visible ? noticeRow.implicitHeight + 24 : 0
|
||||
radius: 8
|
||||
// The WarningBanner "info" recipe: the accent at 10 % over a
|
||||
// 30 % border, derived from the live token rather than frozen
|
||||
// into a literal.
|
||||
color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.10)
|
||||
border.width: 1
|
||||
border.color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.30)
|
||||
|
||||
Row {
|
||||
id: noticeRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 10
|
||||
|
||||
QbzIcon {
|
||||
name: "info"
|
||||
width: 18
|
||||
height: 18
|
||||
tintName: "accent"
|
||||
}
|
||||
Text {
|
||||
width: Math.max(0, parent.width - 18 - 24 - 20)
|
||||
text: root.t("Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region.")
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Rectangle {
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 4
|
||||
color: dismissArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
QbzIcon {
|
||||
name: "x"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: dismissArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPurchases.dismissRegionNotice()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Sticky nav bar ─────────────────────────────────────────
|
||||
// STICKY, and done with a `transform` rather than a `y` write: the
|
||||
// Column owns this item's `y`, so pinning it by assignment would
|
||||
// fight the positioner. A Translate leaves the layout alone and
|
||||
// costs one binding on `contentY` — ONE item, not one per delegate,
|
||||
// and the window is already being repainted while a scroll is in
|
||||
// flight, so it adds no present of its own.
|
||||
Item {
|
||||
id: navRow
|
||||
width: page.contentW
|
||||
height: 34
|
||||
// Above the toolbar and the body, which scroll UNDER it.
|
||||
z: 5
|
||||
|
||||
readonly property bool stuck: flick.contentY > navRow.y
|
||||
|
||||
transform: Translate {
|
||||
y: Math.max(0, flick.contentY - navRow.y)
|
||||
}
|
||||
|
||||
// The backdrop, and only once pinned: at rest it would paint an
|
||||
// opaque band over the page for nothing. It bleeds into the
|
||||
// page's 24px gutters and over the 16px Column gap above so
|
||||
// nothing peeks around it.
|
||||
Rectangle {
|
||||
x: -24
|
||||
y: -16
|
||||
width: parent.width + 48
|
||||
height: parent.height + 24
|
||||
color: navRow.stuck
|
||||
? (theme.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain)
|
||||
: "transparent"
|
||||
}
|
||||
|
||||
QbzTabBar {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
counts: true
|
||||
underline: true
|
||||
activeId: root.tab
|
||||
tabs: [
|
||||
{ "id": "albums", "label": root.t("Albums"),
|
||||
"count": root.counts.albums || 0 },
|
||||
{ "id": "tracks", "label": root.t("Tracks"),
|
||||
"count": root.counts.tracks || 0 },
|
||||
]
|
||||
onSelected: function (id) { QbzPurchases.setTab(id) }
|
||||
}
|
||||
|
||||
QbzLineEdit {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
searchMode: true
|
||||
expandable: true
|
||||
openWidth: 220
|
||||
// Bound, never assigned: the control's own clear latch
|
||||
// depends on the host re-seeding through this binding.
|
||||
text: root.search
|
||||
placeholder: root.t("Search purchases…")
|
||||
onEdited: function (v) {
|
||||
root.pendingQuery = v
|
||||
searchDebounce.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Toolbar ────────────────────────────────────────────────
|
||||
PurchasesToolbar {
|
||||
width: page.contentW
|
||||
tab: root.tab
|
||||
toolbar: root.toolbarDoc
|
||||
}
|
||||
|
||||
// ── 5/6. Body and its states ──────────────────────────────────
|
||||
|
||||
// Loading — the .slint's centred spinner under an 80px lead-in.
|
||||
Item {
|
||||
// `searching` too, not just `loading`. A search REFETCHES both
|
||||
// complete purchase sets from the server (§5); without this the
|
||||
// screen sits on stale rows for the whole round trip with no
|
||||
// sign anything is happening, and the user retypes.
|
||||
visible: root.loading || root.doc.searching === true
|
||||
width: page.contentW
|
||||
height: visible ? 112 : 0
|
||||
QbzSpinner {
|
||||
size: 32
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
}
|
||||
|
||||
// Error + retry (the LibraryView.qml:920-959 block).
|
||||
Column {
|
||||
visible: !root.loading && root.loadError !== ""
|
||||
width: page.contentW
|
||||
spacing: 10
|
||||
Item { width: 1; height: 48 }
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.t("Couldn't load purchases. Check your connection.")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Rectangle {
|
||||
width: retryText.implicitWidth + 28
|
||||
height: 32
|
||||
radius: 6
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
id: retryText
|
||||
anchors.centerIn: parent
|
||||
text: root.t("Retry")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
MouseArea {
|
||||
id: retryArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// §G.1 has no dedicated reload; `openList()` is the one
|
||||
// entry point that re-runs the active tab's load.
|
||||
onClicked: QbzPurchases.openList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty. Two different sentences on purpose: with a query in the
|
||||
// box "No purchases yet" would tell the user they own nothing,
|
||||
// which is a lie about their account rather than about the filter.
|
||||
// Neither line accuses the network (§15.1-6).
|
||||
Column {
|
||||
visible: !root.loading && root.loadError === "" && root.rows.length === 0
|
||||
width: page.contentW
|
||||
spacing: 0
|
||||
Item { width: 1; height: 60 }
|
||||
QbzEmptyState {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
iconName: root.search !== ""
|
||||
? "search"
|
||||
: (root.onAlbums ? "shopping-bag" : "music")
|
||||
iconSize: 48
|
||||
titleMuted: true
|
||||
titleWeight: theme.weightRegular
|
||||
title: root.search !== ""
|
||||
? root.t("No results")
|
||||
: (root.onAlbums ? root.t("No purchases yet")
|
||||
: root.t("No purchased tracks yet"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- ALBUMS, flat -------------------------------------------------
|
||||
PurchaseAlbumsCollection {
|
||||
visible: !root.loading && root.loadError === "" && root.onAlbums
|
||||
&& !root.grouped && root.albums.length > 0
|
||||
width: page.contentW
|
||||
albums: (root.onAlbums && !root.grouped) ? root.albums : []
|
||||
viewMode: root.viewMode
|
||||
onOpenAlbum: function (id) { root.openAlbum(id) }
|
||||
}
|
||||
|
||||
// --- ALBUMS, grouped ----------------------------------------------
|
||||
Column {
|
||||
visible: !root.loading && root.loadError === "" && root.onAlbums
|
||||
&& root.grouped && root.albums.length > 0
|
||||
width: page.contentW
|
||||
spacing: 16
|
||||
Repeater {
|
||||
model: (root.onAlbums && root.grouped) ? root.albumGroups() : []
|
||||
delegate: Column {
|
||||
required property var modelData
|
||||
width: page.contentW
|
||||
spacing: 12
|
||||
Text {
|
||||
width: parent.width
|
||||
text: modelData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
PurchaseAlbumsCollection {
|
||||
width: parent.width
|
||||
albums: modelData.items
|
||||
viewMode: root.viewMode
|
||||
onOpenAlbum: function (id) { root.openAlbum(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TRACKS, flat -------------------------------------------------
|
||||
Column {
|
||||
visible: !root.loading && root.loadError === "" && !root.onAlbums
|
||||
&& !root.grouped && root.tracks.length > 0
|
||||
width: page.contentW
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: (!root.onAlbums && !root.grouped) ? root.tracks : []
|
||||
delegate: PurchaseTrackRow {
|
||||
required property var modelData
|
||||
width: page.contentW
|
||||
track: modelData
|
||||
onPlayRequested: QbzPlayer.playTrack(modelData.id || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TRACKS, grouped ----------------------------------------------
|
||||
Column {
|
||||
visible: !root.loading && root.loadError === "" && !root.onAlbums
|
||||
&& root.grouped && root.tracks.length > 0
|
||||
width: page.contentW
|
||||
spacing: 16
|
||||
Repeater {
|
||||
model: (!root.onAlbums && root.grouped) ? root.trackGroups() : []
|
||||
delegate: Column {
|
||||
required property var modelData
|
||||
width: page.contentW
|
||||
spacing: 8
|
||||
Text {
|
||||
width: parent.width
|
||||
text: modelData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: modelData.items
|
||||
delegate: PurchaseTrackRow {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
track: modelData
|
||||
onPlayRequested: QbzPlayer.playTrack(modelData.id || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: flick
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,17 @@ Item {
|
||||
// BADGE is what the flag switches (blanking
|
||||
// `source` would also silence the card's
|
||||
// source-aware menu gates).
|
||||
source: cardCell.modelData.source
|
||||
//
|
||||
// `sourceRaw` FIRST (contract §D.1). It carries
|
||||
// `qobuz_purchase` and nothing else; `source`
|
||||
// folds that into "offline" so the source chips
|
||||
// keep filtering it, which means the card's gold
|
||||
// purchase chip (AlbumCard.qml:588) only ever
|
||||
// fires off the raw word. All FOUR badge sites
|
||||
// do this — grid and list must not disagree
|
||||
// about the same album.
|
||||
source: cardCell.modelData.sourceRaw
|
||||
|| cardCell.modelData.source
|
||||
showSourceBadge: root.showSource
|
||||
// SELECT MODE IS THE CARD'S (AlbumCard's
|
||||
// `selectMode`/`selected`/`selectToggled`, the
|
||||
|
||||
@@ -174,7 +174,11 @@ Rectangle {
|
||||
// `cloud-download` for an offline copy.
|
||||
SourceIcon {
|
||||
visible: (root.item.source || "") !== ""
|
||||
kind: root.item.source || ""
|
||||
// `sourceRaw` FIRST (contract §D.1): it carries
|
||||
// `qobuz_purchase`, which `source` folds into "offline" for the
|
||||
// benefit of the source chips. Without this the LIST mode of
|
||||
// the very same album loses the gold mark the GRID shows.
|
||||
kind: root.item.sourceRaw || root.item.source || ""
|
||||
// .slint:319 — local 15, the marks 16; muted local tint.
|
||||
glyphSize: 15
|
||||
plexSize: 16
|
||||
|
||||
@@ -156,7 +156,10 @@ Item {
|
||||
// brand marks.
|
||||
SourceIcon {
|
||||
visible: (root.item.source || "") !== ""
|
||||
kind: root.item.source || ""
|
||||
// `sourceRaw` FIRST (contract §D.1): it carries `qobuz_purchase`, which
|
||||
// `source` folds into "offline" so the source chips still filter it.
|
||||
// A purchased track's row is gold; a plain download's is not.
|
||||
kind: root.item.sourceRaw || root.item.source || ""
|
||||
// .slint:722 — local 15, the marks 16. `localTint` muted is the rule
|
||||
// SourceGlyph.slint:6-8 calls load-bearing (never accent).
|
||||
glyphSize: 15
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// PurchaseAlbumsCollection — one collection of purchased albums, drawn as a
|
||||
// grid or as a list. 1:1 port of purchases/PurchasesView.slint's
|
||||
// `AlbumsCollection` (:1155-1210).
|
||||
//
|
||||
// "One collection" = either the whole flat set, or ONE group's albums when the
|
||||
// toolbar's grouping is on; the host mounts as many of these as there are
|
||||
// groups, which is what keeps the group headers and their bodies in one place.
|
||||
//
|
||||
// GEOMETRY, off the .slint: grid cards 162x232 on a 16px gap (the Tauri
|
||||
// `auto-fill minmax(162px, 1fr)` column math, reproduced exactly), list rows
|
||||
// 60px on a 4px gap. The root is content-sized in both arms so the page's one
|
||||
// Flickable owns the scroll — there is no nested scroller here.
|
||||
//
|
||||
// NO WINDOWING. views/AlbumCollection.qml samples a visible band every 80ms and
|
||||
// mounts only the cards inside it; that machinery is coupled to ITS delegate
|
||||
// (cards/AlbumCard.qml) and its host contract, and §15.1 forbids building
|
||||
// virtualisation of our own. Every purchased album therefore mounts, exactly as
|
||||
// it does in Tauri and in the .slint. Recorded as a known cost, not an
|
||||
// oversight: a very large purchase library will pay for it on entry.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// Rows of `list_json.albums` (§G.2) — already searched / sorted / filtered
|
||||
/// by the controller.
|
||||
property var albums: []
|
||||
/// "grid" | "list".
|
||||
property string viewMode: "grid"
|
||||
signal openAlbum(string albumId)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property int cardWidth: 162
|
||||
readonly property int cardHeight: 232
|
||||
readonly property int cardGap: 16
|
||||
readonly property int listRowHeight: 60
|
||||
readonly property int listGap: 4
|
||||
|
||||
readonly property int columns: Math.max(
|
||||
1, Math.floor((width + root.cardGap) / (root.cardWidth + root.cardGap)))
|
||||
readonly property int gridRows:
|
||||
Math.ceil(root.albums.length / Math.max(1, root.columns))
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
// Content-sized: the page Flickable reads this through its Column.
|
||||
height: root.viewMode === "list"
|
||||
? (root.albums.length > 0
|
||||
? root.albums.length * root.listRowHeight
|
||||
+ (root.albums.length - 1) * root.listGap
|
||||
: 0)
|
||||
: (root.gridRows > 0
|
||||
? root.gridRows * root.cardHeight + (root.gridRows - 1) * root.cardGap
|
||||
: 0)
|
||||
|
||||
// --- Grid -------------------------------------------------------------
|
||||
Item {
|
||||
visible: root.viewMode !== "list"
|
||||
anchors.fill: parent
|
||||
Repeater {
|
||||
// The model is gated on the arm so the hidden arm builds no
|
||||
// delegates at all (the AlbumCollection convention) — a hidden
|
||||
// Repeater still instantiates everything otherwise.
|
||||
model: root.viewMode !== "list" ? root.albums : []
|
||||
delegate: PurchaseGridCard {
|
||||
required property var modelData
|
||||
required property int index
|
||||
x: (index % root.columns) * (root.cardWidth + root.cardGap)
|
||||
y: Math.floor(index / root.columns) * (root.cardHeight + root.cardGap)
|
||||
width: root.cardWidth
|
||||
height: root.cardHeight
|
||||
album: modelData
|
||||
onClicked: root.openAlbum(modelData.id || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- List -------------------------------------------------------------
|
||||
Column {
|
||||
visible: root.viewMode === "list"
|
||||
anchors.fill: parent
|
||||
spacing: root.listGap
|
||||
Repeater {
|
||||
model: root.viewMode === "list" ? root.albums : []
|
||||
delegate: PurchaseListRow {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
album: modelData
|
||||
onClicked: root.openAlbum(modelData.id || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// PurchaseGridCard — one album cell of the Purchases grid. 1:1 port of
|
||||
// purchases/PurchasesView.slint's `PurchaseGridCard` (:230-300).
|
||||
//
|
||||
// SELF-CONTAINED ON PURPOSE, and the .slint records the same call in its own
|
||||
// header: the Purchases card is "card-lite" — cover / title / artist / quality,
|
||||
// NO play overlay, no heart, no pin badge, no ⋯ menu — and it carries an
|
||||
// UNAVAILABLE state that `cards/AlbumCard.qml` has no arm for at all.
|
||||
// Threading two Purchases-only flags through the shared card would also have to
|
||||
// go through `views/AlbumCollection.qml`, which mounts AlbumCard with ~15
|
||||
// hardcoded properties, exposes no signals, and routes every click to
|
||||
// `QbzAlbum.openAlbum` — i.e. straight to the CATALOG album page, which would
|
||||
// leave the purchase-album route (§G.1: `QbzPurchases.openAlbum` then
|
||||
// `QbzShell.navigateTo("purchase-album")`) unreachable. So the cell is its own
|
||||
// file, exactly as the .slint decided for exactly these reasons.
|
||||
//
|
||||
// 162x232: a 1:1 cover (Radius.sm well, surface-elevated, disc-3 placeholder)
|
||||
// over the title / artist / quality lines.
|
||||
//
|
||||
// UNAVAILABLE (`downloadable == false`): the cell dims to .45 (.55 on hover)
|
||||
// and an alpha-60 scrim with the "Unavailable" label covers the artwork. The
|
||||
// click is gated on it too — an unavailable purchase must not open a detail
|
||||
// page that has nothing to offer. No `Behavior` on the opacity: it is fed by
|
||||
// data and by hover, which is precisely the shape the repaint-pulse rule
|
||||
// forbids animating (the .slint animates neither).
|
||||
//
|
||||
// There is deliberately NO "downloaded" mark here. Neither Tauri nor the
|
||||
// .slint draws one on an album cell; `downloaded` feeds the "Hide downloaded"
|
||||
// filter and the detail screen, and inventing a badge would be a behaviour
|
||||
// this screen never had.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// One row of `list_json.albums` (§G.2).
|
||||
property var album: ({})
|
||||
signal clicked()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property bool avail: root.album.downloadable !== false
|
||||
/// `artPath`, NEVER `artworkUrl`. The url is the REMOTE cover and this port
|
||||
/// does not fetch covers from a delegate — Rust resolves them onto disk and
|
||||
/// publishes the `file://` path, `""` until it lands. A cell handed only the
|
||||
/// url draws nothing at all (purchases_qt.rs::AlbumRow::art_path, and the
|
||||
/// same trap documented in artist_releases_qt.rs and musician_qt.rs).
|
||||
readonly property string artUrl: root.album.artPath || ""
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
// .slint:238 — .45 / .55 on hover / 1.0.
|
||||
opacity: root.avail ? 1.0 : (cardArea.containsMouse ? 0.55 : 0.45)
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
// --- Cover, 1:1 ---------------------------------------------------
|
||||
Rectangle {
|
||||
id: cover
|
||||
width: parent.width
|
||||
height: parent.width
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
// No clip: RoundedImage confines its own crop and the scrim below
|
||||
// is geometrically contained (the AlbumCard rule — one batch root
|
||||
// per cell, no scissor that rounds nothing).
|
||||
|
||||
RoundedImage {
|
||||
id: art
|
||||
anchors.fill: parent
|
||||
source: root.artUrl
|
||||
radius: theme.radiusSm
|
||||
}
|
||||
// .slint:252 — the empty-well glyph. Gated on `ready`, not on "the
|
||||
// path is non-empty": a url that is still decoding (or that failed)
|
||||
// must not leave a bare square (RoundedImage's readiness contract).
|
||||
QbzIcon {
|
||||
visible: !art.ready
|
||||
name: "disc-3"
|
||||
width: 36
|
||||
height: 36
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
|
||||
// .slint:263-278 — the unavailable scrim over the artwork.
|
||||
Rectangle {
|
||||
visible: !root.avail
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusSm
|
||||
color: theme.alphaTier(60)
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 12
|
||||
text: root.t("Unavailable")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Title / artist / quality ------------------------------------
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
height: 20
|
||||
text: root.album.title || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
height: 17
|
||||
text: root.album.artist || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// .slint:288 — the quality line is dropped entirely when the row
|
||||
// carries no tier, rather than reserving an empty band.
|
||||
Text {
|
||||
visible: (root.album.qualityTier || "") !== ""
|
||||
width: parent.width
|
||||
height: visible ? 17 : 0
|
||||
text: root.album.qualityTier || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: cardArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.avail ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (root.avail) root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// PurchaseListRow — the LIST arm of the Purchases albums tab. 1:1 port of
|
||||
// purchases/PurchasesView.slint's `PurchaseListRow` (:305-395).
|
||||
//
|
||||
// NOT views/AlbumListRow.qml, for the same three reasons the .slint gives for
|
||||
// not reusing its own shared row: the columns are different (this row carries a
|
||||
// PURCHASE-DATE column and no ⋯ menu), the unavailable state has no arm there,
|
||||
// and AlbumListRow's body click is hardwired to `QbzAlbum.openAlbum` — which
|
||||
// would open the catalog page instead of the purchase detail.
|
||||
//
|
||||
// 60px, Radius.sm, 48x48 art, title over artist, a 110px quality column and a
|
||||
// 110px purchase-date column, both right-aligned. Unavailable rows dim to .5,
|
||||
// lose their hover fill, gain a trailing "Unavailable" label and do not open.
|
||||
//
|
||||
// NO ZEBRA: the .slint gives this row a transparent resting fill (:314), unlike
|
||||
// the catalog AlbumListRow's odd-row alpha-4. Adding one here would be a stripe
|
||||
// pattern this screen never had.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// One row of `list_json.albums` (§G.2).
|
||||
property var album: ({})
|
||||
signal clicked()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property bool avail: root.album.downloadable !== false
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
/// Epoch -> the viewer's short date. "" for a row that carries no stamp,
|
||||
/// so the column collapses to blank rather than to 1970.
|
||||
function purchaseDate() {
|
||||
var ts = root.album.purchasedAt || 0
|
||||
if (ts <= 0)
|
||||
return ""
|
||||
// Defensive: the seam says seconds, but a producer that ever hands
|
||||
// milliseconds through would otherwise print a date in the year 55000.
|
||||
var ms = ts > 100000000000 ? ts : ts * 1000
|
||||
return new Date(ms).toLocaleDateString(Qt.locale(), Locale.ShortFormat)
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 60
|
||||
radius: theme.radiusSm
|
||||
color: (rowArea.containsMouse && root.avail) ? theme.surfaceHover : "transparent"
|
||||
opacity: root.avail ? 1.0 : 0.5
|
||||
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.avail ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (root.avail) root.clicked()
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
// 48x48 art.
|
||||
Item {
|
||||
width: 48
|
||||
height: parent.height
|
||||
Rectangle {
|
||||
width: 48
|
||||
height: 48
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
RoundedImage {
|
||||
id: art
|
||||
anchors.fill: parent
|
||||
// `artPath`, never `artworkUrl` — see PurchaseGridCard.
|
||||
source: root.album.artPath || ""
|
||||
radius: theme.radiusSm
|
||||
}
|
||||
QbzIcon {
|
||||
visible: !art.ready
|
||||
name: "disc-3"
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title over artist — the stretch column.
|
||||
Column {
|
||||
width: Math.max(0, parent.width - 48 - 110 - 110
|
||||
- (unavailLabel.visible ? unavailLabel.width + 12 : 0)
|
||||
- 3 * 12)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.album.title || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.album.artist || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// Quality column.
|
||||
Text {
|
||||
width: 110
|
||||
height: parent.height
|
||||
text: root.album.qualityTier || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Purchase-date column.
|
||||
Text {
|
||||
width: 110
|
||||
height: parent.height
|
||||
text: root.purchaseDate()
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Unavailable label — an EXTRA column that exists only on those rows
|
||||
// (.slint:384-391), which is why the stretch column above subtracts it
|
||||
// conditionally.
|
||||
Text {
|
||||
id: unavailLabel
|
||||
visible: !root.avail
|
||||
height: parent.height
|
||||
text: root.t("Unavailable")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// PurchaseTrackRow — one row of the Purchases TRACKS tab. 1:1 port of
|
||||
// purchases/PurchasesView.slint's `PurchaseTrackRow` (:400-560).
|
||||
//
|
||||
// NOT rows/TrackRow.qml. The .slint's own header names the reason and it holds
|
||||
// here too: this row's columns are Purchases-only (a BARE quality string, a
|
||||
// purchase-date column, a download slot) and it has neither the number/play
|
||||
// cell, nor the heart, nor the ⋯ menu that the shared row is built around —
|
||||
// §15.1 explicitly forbids a track context menu on this screen. It also has no
|
||||
// column header above it, so `rows/TrackCols.qml`'s whole reason to exist (a
|
||||
// header and a row that cannot disagree) does not apply.
|
||||
//
|
||||
// 56px, Radius.sm: 40x40 art · title over "artist · album" · a 64px quality
|
||||
// column · a 44px duration column · a 100px purchase-date column · a 30px
|
||||
// download slot.
|
||||
//
|
||||
// STATE, and one deliberate reduction from the .slint. The reference's download
|
||||
// slot is a FOUR-state control (idle / downloading / complete / failed) that
|
||||
// opens an anchored format picker. §G carries no picker seam — `list_json`
|
||||
// tracks publish `downloaded` and nothing else about a download, and §G.1 has
|
||||
// no picker invokable — so the slot here is a read-only INDICATOR: a success
|
||||
// check on a downloaded row, an empty reserve otherwise. The column geometry is
|
||||
// the reference's, so the control can be armed later without moving a pixel.
|
||||
// (Flagged to the orchestrator rather than invented.)
|
||||
//
|
||||
// The row body plays, and only when `streamable` — the purchases-list default
|
||||
// is TRUE (§G.2), so an unplayable row is one the API really did mark that way.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// One row of `list_json.tracks` (§G.2).
|
||||
property var track: ({})
|
||||
signal playRequested()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property bool streamable: root.track.streamable !== false
|
||||
readonly property bool downloaded: root.track.downloaded === true
|
||||
/// The now-playing row, read from the shared player state — the .slint's
|
||||
/// `PurchasesState.active-track-id` has no equivalent in `list_json`, and
|
||||
/// `QbzPlayer.npTrackId` is what every other row in this port asks
|
||||
/// (rows/TrackRow.qml:324).
|
||||
readonly property bool active: QbzPlayer.npTrackId === (root.track.id || "")
|
||||
&& (root.track.id || "") !== ""
|
||||
|
||||
/// §10-C / §G.3: `title (version)`. Published empty today on the list
|
||||
/// document, so the call is inert until the producer fills it — which is
|
||||
/// exactly why it must be here and not hard-coded away.
|
||||
function displayTitle() {
|
||||
var ttl = root.track.title || ""
|
||||
var ver = root.track.version || ""
|
||||
return ver !== "" ? ttl + " (" + ver + ")" : ttl
|
||||
}
|
||||
|
||||
function fmtDuration(secs) {
|
||||
var s = Math.max(0, Math.floor(secs || 0))
|
||||
var m = Math.floor(s / 60)
|
||||
var r = s % 60
|
||||
return m + ":" + (r < 10 ? "0" : "") + r
|
||||
}
|
||||
|
||||
function purchaseDate() {
|
||||
var ts = root.track.purchasedAt || 0
|
||||
if (ts <= 0)
|
||||
return ""
|
||||
var ms = ts > 100000000000 ? ts : ts * 1000
|
||||
return new Date(ms).toLocaleDateString(Qt.locale(), Locale.ShortFormat)
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 56
|
||||
radius: theme.radiusSm
|
||||
// .slint:414 — the active row takes alpha-10; hover only applies to a row
|
||||
// the click can actually act on.
|
||||
color: root.active ? theme.alphaTier(10)
|
||||
: ((rowArea.containsMouse && root.streamable) ? theme.surfaceHover : "transparent")
|
||||
// .slint:418 — a downloaded row reads as "already dealt with".
|
||||
opacity: root.downloaded ? 0.75 : 1.0
|
||||
|
||||
// Declared FIRST so the cells that want their own pointer (none today, but
|
||||
// the download slot lands here) are hit-tested after it, exactly like the
|
||||
// .slint's ordering note.
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
enabled: root.streamable
|
||||
hoverEnabled: true
|
||||
cursorShape: root.streamable ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.playRequested()
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
// 40x40 artwork.
|
||||
Item {
|
||||
width: 40
|
||||
height: parent.height
|
||||
Rectangle {
|
||||
width: 40
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
RoundedImage {
|
||||
id: art
|
||||
anchors.fill: parent
|
||||
// `artPath`, never `artworkUrl` — see PurchaseGridCard.
|
||||
source: root.track.artPath || ""
|
||||
radius: theme.radiusSm
|
||||
}
|
||||
QbzIcon {
|
||||
visible: !art.ready
|
||||
name: "music"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title over the "artist · album" meta line — the stretch column.
|
||||
Column {
|
||||
width: Math.max(0, parent.width - 40
|
||||
- (qualityCell.visible ? 64 + 12 : 0)
|
||||
- 44 - 100 - 30 - 4 * 12)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.displayTitle()
|
||||
color: root.active ? theme.accent : theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
// `artist · album`. The .slint makes the artist a link; the list
|
||||
// document carries no `artistId` (§G.2), so there is no page to
|
||||
// route to and a pointer that promises one would lie. Plain text
|
||||
// until the seam grows the field.
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
var a = root.track.artist || ""
|
||||
var al = root.track.album || ""
|
||||
return al !== "" ? (a + " · " + al) : a
|
||||
}
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// Quality column — BARE, and dropped entirely when the row has none
|
||||
// (.slint:498).
|
||||
Text {
|
||||
id: qualityCell
|
||||
visible: (root.track.qualityTier || "") !== ""
|
||||
width: 64
|
||||
height: parent.height
|
||||
text: root.track.qualityTier || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Duration.
|
||||
Text {
|
||||
width: 44
|
||||
height: parent.height
|
||||
text: root.fmtDuration(root.track.duration)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
// Purchase date.
|
||||
Text {
|
||||
width: 100
|
||||
height: parent.height
|
||||
text: root.purchaseDate()
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Download slot — see the header: the reference's 4-state control,
|
||||
// reduced to its "complete" arm because §G publishes no other state.
|
||||
Item {
|
||||
width: 30
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
visible: root.downloaded
|
||||
name: "check"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
// The .slint paints this one `Theme.success`; QbzIcon's tint
|
||||
// vocabulary has no success arm, and "accent" is the token this
|
||||
// port uses for a positive glyph on a theme surface.
|
||||
tintName: "accent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// PurchasesToolbar — the per-tab control row of the Purchases list. 1:1 port of
|
||||
// purchases/PurchasesView.slint's `AlbumsToolbar` (:915-1050) and
|
||||
// `TracksToolbar` (:1055-1120), folded into one file because they are the same
|
||||
// bar with a different set of controls.
|
||||
//
|
||||
// THE TWO BARS ARE NOT THE SAME, and that is the whole point of the file:
|
||||
//
|
||||
// Albums [stretch] group · sort · filter · grid/list
|
||||
// Tracks [stretch] group · filter
|
||||
//
|
||||
// No sort and no grid/list on Tracks (§5, `PurchasesView.svelte:574` vs `:727`).
|
||||
// Building the Albums bar on both tabs invents two controls the reference never
|
||||
// had, so the arms are separate `visible` blocks rather than one parameterised
|
||||
// row that quietly grows a control.
|
||||
//
|
||||
// Everything is right-aligned (the .slint opens each bar with a stretch
|
||||
// spacer). Controls are 30px on a 8px gap — the toolbar scale of
|
||||
// ui-control-alignment-standard, the same one LibraryToolbar's chips use.
|
||||
//
|
||||
// The dropdowns are `QbzContextMenu`, not bespoke anchored Rectangles: it is
|
||||
// this port's shared menu surface, it clamps itself into the window, it closes
|
||||
// on an outside press, and it carries the ADR-009 stacking the .slint has to
|
||||
// spell out by hand (`z: 3000` on its filter panel).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// "albums" | "tracks".
|
||||
property string tab: "albums"
|
||||
/// `list_json.toolbar` (§G.2).
|
||||
property var toolbar: ({})
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
readonly property string group: root.toolbar.group || "off"
|
||||
readonly property string sortKey: root.toolbar.sort || "purchased"
|
||||
readonly property bool ascending: root.toolbar.ascending === true
|
||||
readonly property string viewMode: root.toolbar.viewMode || "grid"
|
||||
readonly property bool hideUnavailable: root.toolbar.hideUnavailable === true
|
||||
readonly property bool hideDownloaded: root.toolbar.hideDownloaded === true
|
||||
readonly property string qualityFilter: root.toolbar.qualityFilter || "all"
|
||||
|
||||
/// The funnel's badge. Counted GLOBALLY — the Tracks bar hides the
|
||||
/// Availability section but the preference itself is still in force, which
|
||||
/// is exactly what the .slint's single `active-filter-count` does.
|
||||
readonly property int activeFilterCount:
|
||||
(root.hideUnavailable ? 1 : 0)
|
||||
+ (root.hideDownloaded ? 1 : 0)
|
||||
+ (root.qualityFilter !== "all" ? 1 : 0)
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 30
|
||||
|
||||
// ── One toolbar dropdown trigger (.slint `ToolbarButton`, :37-70) ──────
|
||||
component ToolbarButton: Rectangle {
|
||||
id: tb
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
signal clicked()
|
||||
width: tbRow.width
|
||||
height: 30
|
||||
radius: 6
|
||||
color: tbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Row {
|
||||
id: tbRow
|
||||
height: parent.height
|
||||
leftPadding: 10
|
||||
rightPadding: 8
|
||||
spacing: 6
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: tb.label
|
||||
color: tb.active ? theme.accent : theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
QbzIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "chevron-down"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: tbArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: tb.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
// ── One row inside an open dropdown (.slint `MenuRow`, :73-104) ────────
|
||||
component MenuRow: Rectangle {
|
||||
id: mr
|
||||
property string label: ""
|
||||
property bool selected: false
|
||||
/// "↑" / "↓" on the active sort row, "" everywhere else. A bare glyph,
|
||||
/// not a translatable string — the .slint uses the same two literals.
|
||||
property string arrow: ""
|
||||
signal picked()
|
||||
width: parent ? parent.width : 0
|
||||
height: 32
|
||||
radius: 4
|
||||
color: mrArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 8
|
||||
Text {
|
||||
width: Math.max(0, parent.width - (mr.arrow !== "" ? arrowText.width + 8 : 0))
|
||||
height: parent.height
|
||||
text: mr.label
|
||||
color: mr.selected ? theme.accent : theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
id: arrowText
|
||||
visible: mr.arrow !== ""
|
||||
height: parent.height
|
||||
text: mr.arrow
|
||||
color: theme.accent
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: mrArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: mr.picked()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section label inside the filter panel ─────────────────────────────
|
||||
component FilterSectionLabel: Text {
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
|
||||
// ── One checkbox + label line inside the filter panel ─────────────────
|
||||
component FilterCheckRow: Item {
|
||||
id: fcr
|
||||
property string label: ""
|
||||
property bool checked: false
|
||||
signal toggled()
|
||||
width: parent ? parent.width : 0
|
||||
height: 24
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
QbzCheckbox {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: fcr.checked
|
||||
onToggled: fcr.toggled()
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: fcr.label
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: fcr.toggled()
|
||||
}
|
||||
}
|
||||
|
||||
// ── One Quality radio (.slint `QualityRadio`, :168-222) ───────────────
|
||||
component QualityRadioRow: Item {
|
||||
id: qr
|
||||
property string label: ""
|
||||
property string hint: ""
|
||||
property bool selected: false
|
||||
signal picked()
|
||||
width: parent ? parent.width : 0
|
||||
height: 26
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 8
|
||||
border.width: qr.selected ? 0 : 1.5
|
||||
border.color: theme.textMuted
|
||||
color: qr.selected ? theme.accent : "transparent"
|
||||
Rectangle {
|
||||
visible: qr.selected
|
||||
anchors.centerIn: parent
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
// The .slint says `Theme.accent-text`; this port routes
|
||||
// every on-accent mark through the measured selector
|
||||
// (theme/QbzTheme.qml, "ON AN ACCENT FILL"), exactly like
|
||||
// controls/QbzRadioOption.qml.
|
||||
color: theme.accentGlyphColor
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qr.label
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
Text {
|
||||
visible: qr.hint !== ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qr.hint
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: qr.picked()
|
||||
}
|
||||
}
|
||||
|
||||
// ── The bar ───────────────────────────────────────────────────────────
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
// --- Group ---------------------------------------------------------
|
||||
ToolbarButton {
|
||||
id: groupBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.tab === "tracks"
|
||||
? (root.group === "album" ? root.t("Group: Album")
|
||||
: root.group === "artist" ? root.t("Group: Artist")
|
||||
: root.group === "name" ? root.t("Group: Name")
|
||||
: root.t("Group: Off"))
|
||||
: (root.group === "artist" ? root.t("Group: Artist")
|
||||
: root.group === "alpha" ? root.t("Group: A–Z")
|
||||
: root.t("Group: Off"))
|
||||
onClicked: groupMenu.openBelowLeft(groupBtn)
|
||||
|
||||
QbzContextMenu {
|
||||
id: groupMenu
|
||||
menuWidth: 180
|
||||
MenuRow {
|
||||
label: root.t("Off")
|
||||
selected: root.group === "off"
|
||||
onPicked: { QbzPurchases.setGroup("off"); groupMenu.close() }
|
||||
}
|
||||
// Albums arm: A–Z / Artist. `visible: false` drops the row AND
|
||||
// its spacing — the panel's Column skips it entirely, so the
|
||||
// two arms never leave a gap behind.
|
||||
MenuRow {
|
||||
visible: root.tab !== "tracks"
|
||||
label: root.t("A–Z")
|
||||
selected: root.group === "alpha"
|
||||
onPicked: { QbzPurchases.setGroup("alpha"); groupMenu.close() }
|
||||
}
|
||||
// Tracks arm: Album / Artist / Name.
|
||||
MenuRow {
|
||||
visible: root.tab === "tracks"
|
||||
label: root.t("Album")
|
||||
selected: root.group === "album"
|
||||
onPicked: { QbzPurchases.setGroup("album"); groupMenu.close() }
|
||||
}
|
||||
MenuRow {
|
||||
label: root.t("Artist")
|
||||
selected: root.group === "artist"
|
||||
onPicked: { QbzPurchases.setGroup("artist"); groupMenu.close() }
|
||||
}
|
||||
MenuRow {
|
||||
visible: root.tab === "tracks"
|
||||
label: root.t("Name")
|
||||
selected: root.group === "name"
|
||||
onPicked: { QbzPurchases.setGroup("name"); groupMenu.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sort (ALBUMS ONLY) --------------------------------------------
|
||||
ToolbarButton {
|
||||
id: sortBtn
|
||||
// A Row skips an invisible child AND its spacing, so the Tracks bar
|
||||
// closes up on its own — no width dance is needed here.
|
||||
visible: root.tab !== "tracks"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.sortKey === "artist" ? root.t("Sort: Artist")
|
||||
: root.sortKey === "album" ? root.t("Sort: Album")
|
||||
: root.sortKey === "quality" ? root.t("Sort: Quality")
|
||||
: root.t("Sort: Date")
|
||||
onClicked: sortMenu.openBelowLeft(sortBtn)
|
||||
|
||||
// Re-picking the ACTIVE key flips the direction, which is the
|
||||
// .slint's `select-album-sort` (:790-800) and Tauri's behaviour:
|
||||
// there is no separate direction control on this screen.
|
||||
function pick(key) {
|
||||
if (root.sortKey === key)
|
||||
QbzPurchases.setSort(key, !root.ascending)
|
||||
else
|
||||
QbzPurchases.setSort(key, false)
|
||||
sortMenu.close()
|
||||
}
|
||||
function arrowFor(key) {
|
||||
return root.sortKey === key ? (root.ascending ? "↑" : "↓") : ""
|
||||
}
|
||||
|
||||
QbzContextMenu {
|
||||
id: sortMenu
|
||||
menuWidth: 180
|
||||
// "purchased" is §G.2's key for the purchase date; the label is
|
||||
// the existing "Sort: Date" / "Date" msgid pair.
|
||||
MenuRow {
|
||||
label: root.t("Date")
|
||||
selected: root.sortKey === "purchased"
|
||||
arrow: sortBtn.arrowFor("purchased")
|
||||
onPicked: sortBtn.pick("purchased")
|
||||
}
|
||||
MenuRow {
|
||||
label: root.t("Artist")
|
||||
selected: root.sortKey === "artist"
|
||||
arrow: sortBtn.arrowFor("artist")
|
||||
onPicked: sortBtn.pick("artist")
|
||||
}
|
||||
MenuRow {
|
||||
label: root.t("Album")
|
||||
selected: root.sortKey === "album"
|
||||
arrow: sortBtn.arrowFor("album")
|
||||
onPicked: sortBtn.pick("album")
|
||||
}
|
||||
MenuRow {
|
||||
label: root.t("Quality")
|
||||
selected: root.sortKey === "quality"
|
||||
arrow: sortBtn.arrowFor("quality")
|
||||
onPicked: sortBtn.pick("quality")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filter funnel + panel ------------------------------------------
|
||||
Rectangle {
|
||||
id: filterBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 6
|
||||
color: filterArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: root.activeFilterCount > 0 ? "accent" : "muted"
|
||||
}
|
||||
// Active-count badge — a 14px accent disc in the corner
|
||||
// (.slint:124-140). Declared after the glyph so it paints over it.
|
||||
Rectangle {
|
||||
visible: root.activeFilterCount > 0
|
||||
x: parent.width - width - 1
|
||||
y: 1
|
||||
width: 14
|
||||
height: 14
|
||||
radius: 7
|
||||
color: theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.activeFilterCount
|
||||
color: theme.accentGlyphColor
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: filterArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: filterMenu.openBelowRight(filterBtn)
|
||||
}
|
||||
|
||||
QbzContextMenu {
|
||||
id: filterMenu
|
||||
menuWidth: 280
|
||||
contentSpacing: 10
|
||||
|
||||
// Header + "Clear all".
|
||||
Item {
|
||||
width: parent ? parent.width : 0
|
||||
height: 22
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("Filters")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
// §G.1 has no `clear_all_filters` invokable, so "Clear all"
|
||||
// is the three writes it stands for. Same end state, no new
|
||||
// seam.
|
||||
Text {
|
||||
id: clearAll
|
||||
visible: root.activeFilterCount > 0
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.t("Clear all")
|
||||
color: theme.accent
|
||||
font.pixelSize: theme.fontLegal
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
QbzPurchases.setHideUnavailable(false)
|
||||
QbzPurchases.setHideDownloaded(false)
|
||||
QbzPurchases.setQualityFilter("all")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Availability — ALBUMS ONLY (.slint:1090 `show-availability`).
|
||||
Column {
|
||||
visible: root.tab !== "tracks"
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 6
|
||||
FilterSectionLabel { text: root.t("Availability") }
|
||||
FilterCheckRow {
|
||||
label: root.t("Hide unavailable")
|
||||
checked: root.hideUnavailable
|
||||
onToggled: QbzPurchases.setHideUnavailable(!root.hideUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
// Quality.
|
||||
Column {
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 4
|
||||
FilterSectionLabel { text: root.t("Quality") }
|
||||
QualityRadioRow {
|
||||
label: root.t("All")
|
||||
selected: root.qualityFilter === "all"
|
||||
onPicked: QbzPurchases.setQualityFilter("all")
|
||||
}
|
||||
QualityRadioRow {
|
||||
label: root.t("Hi-Res")
|
||||
// Bare units, not a sentence — untranslated in the
|
||||
// .slint too (:1082).
|
||||
hint: "24bit+"
|
||||
selected: root.qualityFilter === "hires"
|
||||
onPicked: QbzPurchases.setQualityFilter("hires")
|
||||
}
|
||||
QualityRadioRow {
|
||||
label: root.t("CD Quality")
|
||||
hint: "16/44.1"
|
||||
selected: root.qualityFilter === "cd"
|
||||
onPicked: QbzPurchases.setQualityFilter("cd")
|
||||
}
|
||||
QualityRadioRow {
|
||||
label: root.t("MP3")
|
||||
hint: "MP3"
|
||||
// "lossy", NOT "mp3": that is the value the shared
|
||||
// filter predicate names (`QualityFilter::Lossy`).
|
||||
selected: root.qualityFilter === "lossy"
|
||||
onPicked: QbzPurchases.setQualityFilter("lossy")
|
||||
}
|
||||
}
|
||||
|
||||
// Downloads.
|
||||
Column {
|
||||
width: parent ? parent.width : 0
|
||||
spacing: 6
|
||||
FilterSectionLabel { text: root.t("Downloads") }
|
||||
FilterCheckRow {
|
||||
label: root.t("Hide downloaded")
|
||||
checked: root.hideDownloaded
|
||||
onToggled: QbzPurchases.setHideDownloaded(!root.hideDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grid / list (ALBUMS ONLY) --------------------------------------
|
||||
// The glyph shows the OTHER mode, like Tauri and the .slint (:145-165).
|
||||
Rectangle {
|
||||
id: viewBtn
|
||||
visible: root.tab !== "tracks"
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: viewArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
QbzIcon {
|
||||
name: root.viewMode === "grid" ? "list" : "layout-grid"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: viewArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: viewArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPurchases.setViewMode(
|
||||
root.viewMode === "grid" ? "list" : "grid")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ use serde::Serialize;
|
||||
use crate::local_bridge::ui;
|
||||
use crate::local_playback::{local_queue_track, play_local_file, play_plex_track, plex_rating_key};
|
||||
use crate::local_rows::{
|
||||
album_key, badge_source, map_track, tier_of, to_json, total_duration, AlbumRow, TrackRow,
|
||||
album_key, badge_source, badge_source_raw, map_track, tier_of, to_json, total_duration,
|
||||
AlbumRow, TrackRow,
|
||||
};
|
||||
use crate::local_state::{state, with_art};
|
||||
|
||||
@@ -248,6 +249,7 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
|
||||
format: best.format.to_string(),
|
||||
art_key: album_key(id),
|
||||
source: badge_source(first.source.as_deref()),
|
||||
source_raw: badge_source_raw(first.source.as_deref()),
|
||||
directory_path: card.as_ref().map(|c| c.directory_path.clone()).unwrap_or_default(),
|
||||
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
//! values app-wide: `"local"` (a user file), `"offline"` (a Qobuz download —
|
||||
//! the Slint's raw `qobuz_download`), `"plex"` (a Plex-cache row). QML keys
|
||||
//! its badge glyph/tint off this string.
|
||||
//!
|
||||
//! `sourceRaw` rides ALONGSIDE it and is the exception that proves the fold is
|
||||
//! lossy: a scanned file that matches a `downloaded_purchases` row is stamped
|
||||
//! `qobuz_purchase` in the DB (`database.rs:1105-1120`), `source` folds that
|
||||
//! into `"offline"` so it keeps filtering under the Offline chip, and
|
||||
//! `sourceRaw` carries the word itself so the badge can draw the GOLD Qobuz
|
||||
//! mark. Two fields because the filter and the badge want different answers
|
||||
//! from the same column. It is emitted ONLY when it says something new, so on
|
||||
//! a library with no purchases every published row is byte-identical to what
|
||||
//! it was before.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -42,6 +52,10 @@ pub struct AlbumRow {
|
||||
pub art_key: String,
|
||||
/// "local" | "offline" | "plex" (the source badge).
|
||||
pub source: String,
|
||||
/// `"qobuz_purchase"`, or empty (omitted from the JSON). See the module
|
||||
/// header: the badge prefers this, every filter keeps reading `source`.
|
||||
#[serde(rename = "sourceRaw", skip_serializing_if = "String::is_empty")]
|
||||
pub source_raw: String,
|
||||
#[serde(rename = "directoryPath")]
|
||||
pub directory_path: String,
|
||||
/// Number of distinct contributing folders (metadata mode only; > 1 is
|
||||
@@ -82,6 +96,10 @@ pub struct TrackRow {
|
||||
pub art_path: String,
|
||||
/// "local" | "offline" | "plex" (the source badge).
|
||||
pub source: String,
|
||||
/// `"qobuz_purchase"`, or empty (omitted from the JSON). See the module
|
||||
/// header: the badge prefers this, every filter keeps reading `source`.
|
||||
#[serde(rename = "sourceRaw", skip_serializing_if = "String::is_empty")]
|
||||
pub source_raw: String,
|
||||
pub explicit: bool,
|
||||
#[serde(rename = "isFavorite")]
|
||||
pub is_favorite: bool,
|
||||
@@ -220,6 +238,13 @@ pub fn artist_key(name: &str) -> String {
|
||||
}
|
||||
|
||||
/// The badge value for a raw `qbz_library` source column.
|
||||
///
|
||||
/// `qobuz_purchase` folds into `"offline"` DELIBERATELY and must keep doing
|
||||
/// so: this value is what the Local Library's source chips filter on
|
||||
/// (`LocalLibraryView.qml`'s `applyFilter`), and a purchased album is a Qobuz
|
||||
/// download — giving it a fourth bucket would make it vanish the moment the
|
||||
/// user ticks any source chip. The distinction the fold loses is restored
|
||||
/// beside it by `badge_source_raw`, not by widening this function.
|
||||
pub fn badge_source(raw: Option<&str>) -> String {
|
||||
match raw {
|
||||
Some("plex") => "plex".into(),
|
||||
@@ -228,6 +253,27 @@ pub fn badge_source(raw: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw source word the BADGE needs, or empty when the folded value above
|
||||
/// already says everything.
|
||||
///
|
||||
/// Only `qobuz_purchase` qualifies today: it is the one raw value with a badge
|
||||
/// of its own (`controls/SourceIcon.qml:75` draws the Qobuz mark in gold for
|
||||
/// it) that `badge_source` destroys. `"user"` and `"qobuz_download"` are NOT
|
||||
/// echoed here on purpose — they have no badge the folded value does not
|
||||
/// already produce, and handing four QML consumers a second spelling of a
|
||||
/// value they already handle is how the two drift apart.
|
||||
///
|
||||
/// Contract §9.4: this word is preserved on SCANNED LOCAL rows only. The
|
||||
/// remote purchases feed (`library_qt::fetch_purchases`) keeps publishing
|
||||
/// `"qobuz"` — it never consults the download registry, so stamping it there
|
||||
/// would badge every purchase the user merely OWNS as locally downloaded.
|
||||
pub fn badge_source_raw(raw: Option<&str>) -> String {
|
||||
match raw {
|
||||
Some("qobuz_purchase") => "qobuz_purchase".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mappers (they also register the row's artwork SOURCE in the art index —
|
||||
// a local cover path, or a raw Plex `/library/...` thumb path)
|
||||
@@ -252,6 +298,7 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, String>) -> AlbumRow {
|
||||
track_count: a.track_count,
|
||||
art_key: key,
|
||||
source: badge_source(Some(a.source.as_str())),
|
||||
source_raw: badge_source_raw(Some(a.source.as_str())),
|
||||
directory_path: a.directory_path,
|
||||
all_artists: a.all_artists,
|
||||
folder_count,
|
||||
@@ -283,6 +330,7 @@ pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, String>) -> TrackRow
|
||||
art_key: key,
|
||||
art_path: String::new(),
|
||||
source: badge_source(t.source.as_deref()),
|
||||
source_raw: badge_source_raw(t.source.as_deref()),
|
||||
explicit: false,
|
||||
is_favorite: false,
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ mod playlist_picker_bridge;
|
||||
mod playlist_manager_bridge;
|
||||
mod folder_edit_bridge;
|
||||
mod playlist_edit_bridge;
|
||||
// Purchases (2026-08-15 purchases contract §G.1): the QbzPurchases singleton —
|
||||
// two documents (list + album detail) and a publish counter. Its controller
|
||||
// half is `purchases_qt` below, a PLAIN module. This file DOES belong in
|
||||
// build.rs's rust_files (lane D owns that edit); without it the QML singleton
|
||||
// silently does not exist.
|
||||
mod purchases_bridge;
|
||||
// Playlist Importer (public Spotify / Apple Music / Tidal / Deezer playlists).
|
||||
// Its own singleton: a separate domain from both the playlist detail and the
|
||||
// manager, opened from two shell surfaces that outlive each other, and its
|
||||
@@ -258,6 +264,11 @@ mod playlist_manager_rows;
|
||||
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
||||
mod playlist_edit_qt;
|
||||
mod playlist_qt;
|
||||
// Purchases controller (2026-08-15 purchases contract): the two screens' state,
|
||||
// the FRONTEND album-download loop (concurrency 1, cancellable, per-track
|
||||
// progress) and the format re-scoping. Plain module — it declares no
|
||||
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
||||
mod purchases_qt;
|
||||
mod queue_qt;
|
||||
mod search_qt;
|
||||
// Immersive Suggestions controller (2026-08-02 immersive-port contract §4.5,
|
||||
@@ -786,6 +797,10 @@ pub(crate) fn do_logout() {
|
||||
// The local-library documents are per-user; a later login must not
|
||||
// inherit the previous user's cached tree.
|
||||
local_library_qt::reset();
|
||||
// Purchases likewise: the list, the open album detail and the in-memory
|
||||
// download statuses are all scoped to the account that bought them.
|
||||
// Leaving them behind would show the next user someone else's library.
|
||||
purchases_qt::reset();
|
||||
// A later login must re-fetch Home + Library (new user, fresh data)
|
||||
// and re-warm the favourite-id cache that `auth_qt::logout` just
|
||||
// emptied — otherwise the next account's hearts stay blank until it
|
||||
@@ -2092,6 +2107,12 @@ pub(crate) fn apply_language(code: String) {
|
||||
// card's (always empty) subtitle — so it re-translates by itself and a
|
||||
// republish would be dead code.
|
||||
musician_qt::republish();
|
||||
// Purchases, for the same reason: both of its documents carry exactly one
|
||||
// Rust-translated string (the load-error line), and a string travelling as
|
||||
// data cannot be reached by `trRev`. A no-op unless a purchase screen is
|
||||
// standing in its error state — which is also the only state the owner's
|
||||
// own account can produce, so it is the one worth getting right.
|
||||
purchases_qt::republish();
|
||||
let (view, id) = LAST_DETAIL.lock().unwrap().clone();
|
||||
if !id.is_empty() {
|
||||
match view.as_str() {
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
//!
|
||||
//! There is NO route table here, and that is deliberate: a view id is an
|
||||
//! opaque `String`, and the only place the set of legal ids is enumerated is
|
||||
//! `qml/shell/AppShell.qml`'s content `Loader` chain. Adding a route is
|
||||
//! therefore a two-file change — the caller `record`s its id, AppShell grows
|
||||
//! an arm for it — and nothing needs registering in this file. The failure
|
||||
//! mode when the AppShell arm is forgotten is a BLANK content pane (the
|
||||
//! `qml/shell/ContentRouter.qml`'s content `Loader` chain. (It used to live in
|
||||
//! `AppShell.qml`; the kiosk port extracted it so BOTH shells share one chain,
|
||||
//! and this paragraph said "AppShell" for far too long afterwards — it has
|
||||
//! since sent more than one reader to the wrong file.) Adding a route is
|
||||
//! therefore a two-file change — the caller `record`s its id, ContentRouter
|
||||
//! grows an arm for it — and nothing needs registering in this file. The
|
||||
//! failure mode when the router arm is forgotten is a BLANK content pane (the
|
||||
//! ternary falls through to `""`), recoverable with Back; it is not a crash,
|
||||
//! which is exactly why it is easy to miss.
|
||||
//!
|
||||
@@ -20,7 +23,7 @@
|
||||
//! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
|
||||
//! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
|
||||
//! `discobuilder`, `blacklist`, `playlistmanager`, `nowplaying`, `scene`,
|
||||
//! `musician`.
|
||||
//! `musician`, `purchases`, `purchase-album`.
|
||||
//!
|
||||
//! `scene` (the "artists from the same place" discovery view) and `musician`
|
||||
//! both carry a required context that only their opener has, so neither is in
|
||||
@@ -29,6 +32,17 @@
|
||||
//! only the `contextual` branch navigates; `weak`/`none` publish a document
|
||||
//! that a global overlay renders (`musician_qt.rs`).
|
||||
//!
|
||||
//! `purchase-album` is in that same context-carrying class (its album id lives
|
||||
//! in the purchases controller, not in the id), and `purchases` is left out of
|
||||
//! the restore set for a DIFFERENT reason: it is opt-in and ships hidden
|
||||
//! (`show_purchases`, default false), so restoring onto it could open the app
|
||||
//! on a surface whose own entry point is switched off. Neither is in
|
||||
//! `VIEW_TO_PREF` below, so `startup_view()` can never resolve to either — and
|
||||
//! neither is offered by the Startup page dropdown, whose own list
|
||||
//! (`settings_qt::STARTUP_PAGE_VALUES`) would have to grow a matching entry
|
||||
//! first. The Slint build DOES restore `purchases` (`qbz/src/main.rs:637`);
|
||||
//! bringing that across is an additive change that needs both halves.
|
||||
//!
|
||||
//! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the
|
||||
//! NavRail's fifth tile and the route the kiosk's full-screen player lives on.
|
||||
//! The desktop shell has no equivalent — its transport is the persistent bar —
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! QbzPurchases — the Purchases domain bridge (contract §G.1).
|
||||
//!
|
||||
//! Two documents and a publish counter, twenty invokables. The controller is
|
||||
//! `purchases_qt.rs`; this file is glue and nothing else, exactly like
|
||||
//! `musician_bridge.rs`.
|
||||
//!
|
||||
//! The module is `qbz_purchases_bridge`, not `qbz_purchases`: bridge modules
|
||||
//! are never named after a workspace crate (the `qbz_library` collision is the
|
||||
//! documented precedent). The QML type name comes from the QObject
|
||||
//! (`QbzPurchases`), not from the module.
|
||||
//!
|
||||
//! # A new bridge is SEVEN artefacts, not one file
|
||||
//!
|
||||
//! This file · its `build.rs` `rust_files` entry · the `main.rs` `mod` line ·
|
||||
//! the QML singleton registration (which IS the build.rs entry — `#[qml_element]
|
||||
//! #[qml_singleton]` only takes effect for files listed there) · `Main.qml`'s
|
||||
//! `boot()` call · `ContentRouter.qml`'s `purchases` AND `purchase-album` arms ·
|
||||
//! the sidebar row. Five of those belong to lane D; this lane ships the two
|
||||
//! files it owns plus the `mod` lines. A bridge missing from `build.rs` does
|
||||
//! not fail the build: its QML singleton simply does not exist, and every
|
||||
//! `QbzPurchases.foo()` becomes a runtime ReferenceError that `cargo check`
|
||||
//! cannot see. `qml_singleton_xref.py` is the gate that catches it.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cxx_qt::CxxQtThread;
|
||||
use cxx_qt::Threading as _;
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod qbz_purchases_bridge {
|
||||
extern "C++" {
|
||||
include!("cxx-qt-lib/qstring.h");
|
||||
type QString = cxx_qt_lib::QString;
|
||||
}
|
||||
|
||||
#[auto_cxx_name]
|
||||
extern "RustQt" {
|
||||
#[qobject]
|
||||
#[qml_element]
|
||||
#[qml_singleton]
|
||||
/// The Purchases LIST document (route `purchases`) — state, tab, the
|
||||
/// two per-type counters, the toolbar and the two already
|
||||
/// filtered+sorted row arrays. Shape frozen in contract §G.2.
|
||||
#[qproperty(QString, list_json)]
|
||||
/// The purchased-album DETAIL document (route `purchase-album`) —
|
||||
/// header, format dropdown, disc-grouped tracklist, per-track download
|
||||
/// status, the track-counted progress block and the goodies list.
|
||||
/// Shape frozen in contract §G.3.
|
||||
///
|
||||
/// `"{}"` and never `""`: both documents are `JSON.parse`d on the first
|
||||
/// frame, and an empty string throws. (`""` is a legitimate default
|
||||
/// only where empty IS the closed state of a modal — neither of these
|
||||
/// is one.)
|
||||
#[qproperty(QString, album_json)]
|
||||
/// Publish counter, bumped by every write to EITHER document above.
|
||||
/// QML re-parses on the QString change already; this exists so a view
|
||||
/// can force a re-read when the text happens to be byte-identical
|
||||
/// (re-opening the same album, or a filter toggle that changes nothing
|
||||
/// visible), the same trick `QbzSession.trRev` plays.
|
||||
#[qproperty(i32, purchases_rev)]
|
||||
|
||||
type QbzPurchases = super::QbzPurchasesRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||
/// singleton; only QbzSession.boot also fires crate::on_boot).
|
||||
#[qinvokable]
|
||||
fn boot(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// Entering the list route. Runs the ONCE-per-session guarded metadata
|
||||
/// load (registry + the two per-type totals, concurrently) and the
|
||||
/// active tab's load. Re-entry while the metadata is already loaded
|
||||
/// only reloads the tab — the §5 `metadataLoaded` guard, which is
|
||||
/// Tauri's and is owed.
|
||||
#[qinvokable]
|
||||
fn open_list(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// Leaving the list route — `Component.onDestruction`. Drops ALL
|
||||
/// transient state (tab, sort, direction, grouping, view mode, search
|
||||
/// text and both row caches) because Tauri's `{#if}` chain destroys the
|
||||
/// component and Qt must not preserve it by accident (§10-F). The four
|
||||
/// persisted preferences and the region-notice flag survive; so does
|
||||
/// the download store, which is process-wide by design.
|
||||
#[qinvokable]
|
||||
fn leave(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// `"albums"` | `"tracks"`. Loads that tab's list if it has not been
|
||||
/// loaded this visit.
|
||||
#[qinvokable]
|
||||
fn set_tab(self: Pin<&mut QbzPurchases>, tab: QString);
|
||||
|
||||
/// The search FIRE — **QML owns the 300 ms debounce** with a one-shot
|
||||
/// `Timer`; this is what the timer calls. It REFETCHES FROM THE SERVER
|
||||
/// and re-requests BOTH types, then filters in Rust (§5). An empty
|
||||
/// query refetches the active tab.
|
||||
#[qinvokable]
|
||||
fn search(self: Pin<&mut QbzPurchases>, query: QString);
|
||||
|
||||
/// Albums tab only. `key` is `"purchased"` | `"artist"` | `"album"` |
|
||||
/// `"quality"`; `ascending` is the direction.
|
||||
#[qinvokable]
|
||||
fn set_sort(self: Pin<&mut QbzPurchases>, key: QString, ascending: bool);
|
||||
|
||||
/// `"off"` | `"alpha"` | `"artist"` (Albums) · `"off"` | `"name"` |
|
||||
/// `"artist"` | `"album"` (Tracks).
|
||||
#[qinvokable]
|
||||
fn set_group(self: Pin<&mut QbzPurchases>, key: QString);
|
||||
|
||||
/// `"grid"` | `"list"`. Albums tab only.
|
||||
#[qinvokable]
|
||||
fn set_view_mode(self: Pin<&mut QbzPurchases>, mode: QString);
|
||||
|
||||
/// Persisted (`purchases_hide_unavailable`).
|
||||
#[qinvokable]
|
||||
fn set_hide_unavailable(self: Pin<&mut QbzPurchases>, on: bool);
|
||||
|
||||
/// Persisted (`purchases_hide_downloaded`).
|
||||
#[qinvokable]
|
||||
fn set_hide_downloaded(self: Pin<&mut QbzPurchases>, on: bool);
|
||||
|
||||
/// `"all"` | `"hires"` | `"cd"` | `"lossy"`. Persisted
|
||||
/// (`purchases_quality_filter`) — the FOURTH preference, the one the
|
||||
/// first draft of the contract missed.
|
||||
#[qinvokable]
|
||||
fn set_quality_filter(self: Pin<&mut QbzPurchases>, value: QString);
|
||||
|
||||
/// Dismiss the one-time region notice. Persisted
|
||||
/// (`purchases_region_notice_seen`).
|
||||
#[qinvokable]
|
||||
fn dismiss_region_notice(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// Open the purchased-album detail. RECORDS the album and starts the
|
||||
/// fetch; the QML caller then calls `QbzShell.navigateTo("purchase-album")`
|
||||
/// — in THAT order (§C.2).
|
||||
#[qinvokable]
|
||||
fn open_album(self: Pin<&mut QbzPurchases>, album_id: QString);
|
||||
|
||||
/// Change the selected download format. RE-SCOPES every downloaded
|
||||
/// mark on the detail screen, the progress block AND the
|
||||
/// Add-to-Library affordance (§6) — the single most surprising
|
||||
/// behaviour on that screen.
|
||||
#[qinvokable]
|
||||
fn set_format(self: Pin<&mut QbzPurchases>, format_id: i32);
|
||||
|
||||
/// Native folder picker for the download destination.
|
||||
#[qinvokable]
|
||||
fn choose_destination(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// "Download all" — the FRONTEND loop: strictly sequential,
|
||||
/// concurrency 1, cancellable between tracks (§4.1).
|
||||
#[qinvokable]
|
||||
fn download_album(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// One track. `track_id` is a **QString**, never an int: QML `int` is
|
||||
/// 32-bit and Qobuz track ids overflow it (§G.0).
|
||||
#[qinvokable]
|
||||
fn download_track(self: Pin<&mut QbzPurchases>, track_id: QString);
|
||||
|
||||
/// The retry control on a failed row. Same primitive as
|
||||
/// `download_track`; a separate name because the row's affordance is a
|
||||
/// different one and the contract lists both.
|
||||
#[qinvokable]
|
||||
fn retry_track(self: Pin<&mut QbzPurchases>, track_id: QString);
|
||||
|
||||
/// Cooperative cancel. The in-flight track finishes; every track that
|
||||
/// has not started becomes `cancelled` (§4.1).
|
||||
#[qinvokable]
|
||||
fn cancel_album_download(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// Album-level goodies (booklets, videos). Counted SEPARATELY from the
|
||||
/// track progress — a goodie is not a track (§14.3).
|
||||
#[qinvokable]
|
||||
fn download_goodies(self: Pin<&mut QbzPurchases>);
|
||||
|
||||
/// "Add to Library" — the full sequence, not a banner (§6): add the
|
||||
/// resolved album subfolder as a library folder, clear the download
|
||||
/// state, toast, start a background scan.
|
||||
#[qinvokable]
|
||||
fn add_to_library(self: Pin<&mut QbzPurchases>);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzPurchases {}
|
||||
}
|
||||
|
||||
use qbz_purchases_bridge::QbzPurchases;
|
||||
|
||||
/// Rust side of the purchases bridge (plain storage, phase-1 pattern).
|
||||
pub struct QbzPurchasesRust {
|
||||
list_json: QString,
|
||||
album_json: QString,
|
||||
purchases_rev: i32,
|
||||
}
|
||||
|
||||
impl Default for QbzPurchasesRust {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// "{}" and not "": both views JSON.parse these straight, and an
|
||||
// empty string throws into the catch arm on the very first frame.
|
||||
list_json: QString::from("{}"),
|
||||
album_json: QString::from("{}"),
|
||||
purchases_rev: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The UI hop (bridges/mod.rs pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static QT_THREAD: OnceLock<CxxQtThread<QbzPurchases>> = OnceLock::new();
|
||||
|
||||
/// Queue a purchases-bridge mutation onto the Qt event loop (no-op before boot
|
||||
/// registers the thread).
|
||||
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzPurchases>) + Send + 'static) {
|
||||
if let Some(thread) = QT_THREAD.get() {
|
||||
let _ = thread.queue(f);
|
||||
}
|
||||
}
|
||||
|
||||
impl qbz_purchases_bridge::QbzPurchases {
|
||||
pub fn boot(self: Pin<&mut Self>) {
|
||||
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||
log::warn!("[qbz-qt] purchases Qt thread already registered");
|
||||
}
|
||||
}
|
||||
|
||||
// One-line delegates into `purchases_qt`. No `crate::` forwarder in
|
||||
// main.rs: this domain owns all of its state in its own module, and the one
|
||||
// navigation it performs is recorded by the QML caller's
|
||||
// `QbzShell.navigateTo` (§C.2), so there is nothing for main.rs to
|
||||
// coordinate.
|
||||
|
||||
pub fn open_list(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::open_list();
|
||||
}
|
||||
|
||||
pub fn leave(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::leave();
|
||||
}
|
||||
|
||||
pub fn set_tab(self: Pin<&mut Self>, tab: QString) {
|
||||
crate::purchases_qt::set_tab(tab.to_string());
|
||||
}
|
||||
|
||||
pub fn search(self: Pin<&mut Self>, query: QString) {
|
||||
crate::purchases_qt::search(query.to_string());
|
||||
}
|
||||
|
||||
pub fn set_sort(self: Pin<&mut Self>, key: QString, ascending: bool) {
|
||||
crate::purchases_qt::set_sort(key.to_string(), ascending);
|
||||
}
|
||||
|
||||
pub fn set_group(self: Pin<&mut Self>, key: QString) {
|
||||
crate::purchases_qt::set_group(key.to_string());
|
||||
}
|
||||
|
||||
pub fn set_view_mode(self: Pin<&mut Self>, mode: QString) {
|
||||
crate::purchases_qt::set_view_mode(mode.to_string());
|
||||
}
|
||||
|
||||
pub fn set_hide_unavailable(self: Pin<&mut Self>, on: bool) {
|
||||
crate::purchases_qt::set_hide_unavailable(on);
|
||||
}
|
||||
|
||||
pub fn set_hide_downloaded(self: Pin<&mut Self>, on: bool) {
|
||||
crate::purchases_qt::set_hide_downloaded(on);
|
||||
}
|
||||
|
||||
pub fn set_quality_filter(self: Pin<&mut Self>, value: QString) {
|
||||
crate::purchases_qt::set_quality_filter(value.to_string());
|
||||
}
|
||||
|
||||
pub fn dismiss_region_notice(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::dismiss_region_notice();
|
||||
}
|
||||
|
||||
pub fn open_album(self: Pin<&mut Self>, album_id: QString) {
|
||||
crate::purchases_qt::open_album(album_id.to_string());
|
||||
}
|
||||
|
||||
pub fn set_format(self: Pin<&mut Self>, format_id: i32) {
|
||||
crate::purchases_qt::set_format(format_id);
|
||||
}
|
||||
|
||||
pub fn choose_destination(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::choose_destination();
|
||||
}
|
||||
|
||||
pub fn download_album(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::download_album();
|
||||
}
|
||||
|
||||
pub fn download_track(self: Pin<&mut Self>, track_id: QString) {
|
||||
crate::purchases_qt::download_track(track_id.to_string());
|
||||
}
|
||||
|
||||
pub fn retry_track(self: Pin<&mut Self>, track_id: QString) {
|
||||
crate::purchases_qt::download_track(track_id.to_string());
|
||||
}
|
||||
|
||||
pub fn cancel_album_download(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::cancel_album_download();
|
||||
}
|
||||
|
||||
pub fn download_goodies(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::download_goodies();
|
||||
}
|
||||
|
||||
pub fn add_to_library(self: Pin<&mut Self>) {
|
||||
crate::purchases_qt::add_to_library();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3872,6 +3872,9 @@ msgstr "Download konnte nicht gestartet werden. Bitte versuche es erneut."
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "Keine herunterladbaren Formate verfügbar"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "Extras herunterladen"
|
||||
|
||||
# Playlist "Suggested Songs" section (T8)
|
||||
msgid "Suggested songs"
|
||||
msgstr "Vorgeschlagene Titel"
|
||||
|
||||
@@ -804,3 +804,7 @@ msgstr "This musician appears in album credits."
|
||||
|
||||
msgid "Tunnel Flow"
|
||||
msgstr "Tunnel Flow"
|
||||
|
||||
# --- Purchases (Slice 13, Qt port) ---
|
||||
msgid "Download goodies"
|
||||
msgstr "Download goodies"
|
||||
@@ -3872,6 +3872,9 @@ msgstr "No se pudo iniciar la descarga. Inténtalo de nuevo."
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "No hay formatos disponibles para descargar"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "Descargar extras"
|
||||
|
||||
# Playlist "Suggested Songs" section (T8)
|
||||
msgid "Suggested songs"
|
||||
msgstr "Canciones sugeridas"
|
||||
|
||||
@@ -3872,6 +3872,9 @@ msgstr "Échec du démarrage du téléchargement. Veuillez réessayer."
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "Aucun format téléchargeable disponible"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "Télécharger les goodies"
|
||||
|
||||
# Playlist "Suggested Songs" section (T8)
|
||||
msgid "Suggested songs"
|
||||
msgstr "Titres suggérés"
|
||||
|
||||
@@ -3713,6 +3713,9 @@ msgstr "ダウンロードを開始できませんでした。もう一度お試
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "ダウンロード可能なフォーマットがありません"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "特典をダウンロード"
|
||||
|
||||
# Playlist "Suggested Songs" section (T8)
|
||||
msgid "Suggested songs"
|
||||
msgstr "おすすめの曲"
|
||||
|
||||
@@ -3863,6 +3863,9 @@ msgstr "Starten van download mislukt. Probeer het opnieuw."
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "Geen downloadbare formaten beschikbaar"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "Extra's downloaden"
|
||||
|
||||
msgid "Suggested songs"
|
||||
msgstr "Voorgestelde nummers"
|
||||
|
||||
|
||||
@@ -3872,6 +3872,9 @@ msgstr "Falha ao iniciar o download. Tente novamente."
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "Nenhum formato disponível para download"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "Baixar extras"
|
||||
|
||||
# Playlist "Suggested Songs" section (T8)
|
||||
msgid "Suggested songs"
|
||||
msgstr "Músicas sugeridas"
|
||||
|
||||
@@ -3737,6 +3737,9 @@ msgstr "Не удалось начать загрузку. Повторите п
|
||||
msgid "No downloadable formats available"
|
||||
msgstr "Нет форматов, доступных для скачивания"
|
||||
|
||||
msgid "Download goodies"
|
||||
msgstr "Скачать бонусы"
|
||||
|
||||
# Playlist "Suggested Songs" section (T8)
|
||||
msgid "Suggested songs"
|
||||
msgstr "Рекомендованные треки"
|
||||
|
||||
Reference in new issue
Block a user