Files
qbz/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml
T
vicrodh 26ceeab828 feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.

MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.

Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.

PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.

The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.

SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.

CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.

APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.

RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.

ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.

Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.

Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.

Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.

cc
2026-08-19 07:00:35 -06:00

560 lines
22 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
/// The same three settings as the badge, but SAID — the applied-filters
/// tooltip. Counted the same way and in the menu's own order, so the number
/// on the disc and the lines in the bubble can never disagree.
readonly property var filterSummaryGroups: {
var avail = []
if (root.hideUnavailable)
avail.push(root.t("Hide unavailable"))
if (root.hideDownloaded)
avail.push(root.t("Hide downloaded"))
var q = []
if (root.qualityFilter === "hires")
q.push(root.t("Hi-Res"))
else if (root.qualityFilter === "cd")
q.push(root.t("CD"))
else if (root.qualityFilter === "lossy")
q.push(root.t("Lossy"))
return [
{ group: root.t("Availability"), values: avail },
{ group: root.t("Quality"), values: q }
]
}
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: {
purchasesFilterTip.exit()
filterMenu.openBelowRight(filterBtn)
}
onEntered: purchasesFilterTip.enter()
onExited: purchasesFilterTip.exit()
}
QbzFilterTip {
id: purchasesFilterTip
ownerKey: "purchases-filter"
anchor: filterBtn
groups: root.filterSummaryGroups
}
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")
}
}
}
}