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
This commit is contained in:
vicrodh committed 2026-08-19 07:00:35 -06:00
1 parent 047db8ea9f
commit 26ceeab828
41 files changed
+2896 -96

No files matched your search

+69 -15
View File
@@ -1020,24 +1020,58 @@ ApplicationWindow {
// Splash (SplashScreen.slint): the same 720px dark card as the login
// screen while the silent session restore resolves.
//
// PARITY PASS (2026-08-19). Three things this block had lost against the
// reference, all of them user-visible:
// * the LEGAL DISCLAIMER, which SplashScreen.slint:75-86 pins to the
// bottom of the SCREEN (not inside the card — Tauri put it there and
// the Slint port kept that on purpose). It was simply absent here, so
// the one screen a first-run user stares at said nothing about the
// app not being certified by Qobuz.
// * the strings, which were bare QML literals. Every other screen goes
// through QbzSession.tr() with the EXACT msgid of the Slint @tr()
// call so the existing .po catalogues apply; these three did not, so
// the splash was English in all eight locales.
// * the colours and metrics, which were hardcoded hex and magic numbers
// (#0f0f0f / #1a1a1a / #ffffff / #888888, 52, 104, 8, 32). They happen
// to match the DEFAULT dark palette and nothing else: on any of the
// other 34 themes the splash was a black card the rest of the app had
// stopped being. Tokens now, like LoginScreen.qml.
Rectangle {
anchors.fill: parent
color: "#0f0f0f"
color: splashTheme.surfaceMain
visible: QbzSession.screen === "splash"
QbzTheme { id: splashTheme }
// Faked drop shadow (blur 32, offset-y 8) — the same hand-copy
// LoginScreen.qml:41-49 carries, for the same reason and with the same
// caveat written there. The reference gives BOTH cards the shadow
// (SplashScreen.slint:23-25); only the login one had it here.
Rectangle {
anchors.horizontalCenter: splashCard.horizontalCenter
y: splashCard.y + 8
width: splashCard.width
height: splashCard.height
radius: splashTheme.radiusLg
color: splashTheme.cardShadow
opacity: 0.5
}
Rectangle {
id: splashCard
anchors.centerIn: parent
width: 720
height: splashColumn.height + 104
radius: 16
color: "#1a1a1a"
height: splashColumn.height + 2 * splashTheme.cardPadding
radius: splashTheme.radiusLg
color: splashTheme.surfaceCard
Column {
id: splashColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 52
anchors.margins: splashTheme.cardPadding
spacing: 0
Image {
anchors.horizontalCenter: parent.horizontalCenter
@@ -1046,29 +1080,49 @@ ApplicationWindow {
height: 140
fillMode: Image.PreserveAspectFit
}
Item { width: 1; height: 8 }
Item { width: 1; height: splashTheme.spacingSm }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "QBZ"
color: "#ffffff"
font.pixelSize: 29
font.weight: Font.DemiBold
text: QbzSession.tr("QBZ", QbzSession.trRev)
color: splashTheme.textPrimary
font.pixelSize: splashTheme.fontWordmark
font.weight: splashTheme.weightSemibold
font.letterSpacing: 8
}
Item { width: 1; height: 2 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "QOBUZ™ PLAYER"
color: "#888888"
font.pixelSize: 15
text: QbzSession.tr("QOBUZ™ PLAYER", QbzSession.trRev)
color: splashTheme.textMuted
font.pixelSize: splashTheme.fontSubtitle
font.letterSpacing: 4
}
Item { width: 1; height: 32 }
Item { width: 1; height: splashTheme.spacingXl }
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
size: 32
size: 30
}
}
}
// Legal disclaimer — pinned to the BOTTOM OF THE SCREEN, not inside
// the card. That placement is the reference's, and its comment says it
// is Tauri's too (SplashScreen.slint:73-74). Two msgids concatenated
// with a space, exactly as the reference concatenates them, so both
// reuse the catalogue entries the login screen already uses.
Text {
width: Math.min(parent.width - 80, 720)
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 24
text: QbzSession.tr("This application uses the Qobuz API but is not certified by Qobuz.", QbzSession.trRev)
+ " "
+ QbzSession.tr("Qobuz™ is a trademark of Qobuz. QBZ is an open-source application licensed under the MIT License and is not affiliated with, endorsed by, or certified by Qobuz.", QbzSession.trRev)
color: splashTheme.textMuted
// 10px LITERAL, not fontLegal: the reference hardcodes 10px here
// (SplashScreen.slint:80) where its login twin uses Typography.legal.
font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}