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:
41 files changed
+2896
-96
No files matched your search
@@ -52,7 +52,23 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
)?;
|
||||
// PLAYBACK CONTEXT of the event — "album" | "playlist" | "" (unknown).
|
||||
//
|
||||
// Additive, and it has to be: this .db is SHARED with the Slint build,
|
||||
// whose tree is frozen and whose INSERT names its columns explicitly
|
||||
// (`INSERT INTO album_play_events (album_id, occurred_at)`), so an extra
|
||||
// column with a DEFAULT is invisible to it and it simply writes `''`.
|
||||
// Every legacy row is `''` too, and the ranking query treats `''` as
|
||||
// "album" — so no history is lost or reinterpreted by this migration.
|
||||
//
|
||||
// SQLite has no `ADD COLUMN IF NOT EXISTS`; running it and swallowing the
|
||||
// duplicate-column error is the idiom, and it is cheaper than a
|
||||
// `PRAGMA table_info` round trip on every open.
|
||||
let _ = conn.execute_batch(
|
||||
"ALTER TABLE album_play_events ADD COLUMN context_kind TEXT NOT NULL DEFAULT '';",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_db() -> Option<Connection> {
|
||||
@@ -103,6 +119,11 @@ pub struct AlbumPlayMeta<'a> {
|
||||
pub quality_label: &'a str,
|
||||
pub year: &'a str,
|
||||
pub source: &'a str,
|
||||
/// What the user was PLAYING FROM when this event happened: `"album"`,
|
||||
/// `"playlist"`, or `""` when nothing was stamped. NOT the same axis as
|
||||
/// [`Self::source`], which is where the audio comes from (qobuz / local /
|
||||
/// plex) — an album can be local AND played from a playlist.
|
||||
pub context_kind: &'a str,
|
||||
}
|
||||
|
||||
/// One ranked album for the "Most Played Albums" rail / View-all page.
|
||||
@@ -124,8 +145,9 @@ pub struct AlbumPlayRow {
|
||||
/// Internal so tests can drive it against an in-memory connection.
|
||||
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)",
|
||||
params![m.album_id, now],
|
||||
"INSERT INTO album_play_events (album_id, occurred_at, context_kind) \
|
||||
VALUES (?, ?, ?)",
|
||||
params![m.album_id, now, m.context_kind],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history insert event failed: {e}");
|
||||
}
|
||||
@@ -174,6 +196,13 @@ fn query_on(conn: &Connection, limit: Option<u32>) -> Vec<AlbumPlayRow> {
|
||||
JOIN (
|
||||
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||
FROM album_play_events
|
||||
-- Only plays that were ABOUT the album. A playlist of 40 tracks
|
||||
-- from 40 albums used to add one play to each of them, which is
|
||||
-- how "Most Played Albums" came to be a list of whatever playlist
|
||||
-- the user had on. `''` is a legacy row or one written by the
|
||||
-- other frontend, and is counted — dropping it would erase
|
||||
-- history that is almost entirely genuine album listening.
|
||||
WHERE context_kind IN ('', 'album')
|
||||
GROUP BY album_id
|
||||
) p ON p.album_id = m.album_id
|
||||
ORDER BY p.plays DESC, p.last_at DESC
|
||||
@@ -249,6 +278,7 @@ mod tests {
|
||||
quality_label: "Hi-Res",
|
||||
year: "2024",
|
||||
source: "qobuz",
|
||||
context_kind: "album",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,16 @@ pub enum DiscoverySectionId {
|
||||
/// "Most Played Albums" — top albums by local play count
|
||||
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
|
||||
MostPlayedAlbums,
|
||||
/// "Recently Played Playlists" — the playlists the user actually chose to
|
||||
/// play, by last play (`qbz_app::settings::playlist_play_history`).
|
||||
///
|
||||
/// Its sibling `RecentlyPlayedAlbums` used to absorb these: every track
|
||||
/// start wrote its ALBUM into the recent list whatever the user had put on,
|
||||
/// so playing one 40-track playlist filled a 24-entry album rail with that
|
||||
/// playlist's contents. Plays are separated by context now, and this is
|
||||
/// where the playlist half surfaces. Home + For You, default ON — it is the
|
||||
/// answer to a question the album rail was silently answering wrong.
|
||||
RecentlyPlayedPlaylists,
|
||||
}
|
||||
|
||||
impl DiscoverySectionId {
|
||||
@@ -115,6 +125,7 @@ impl DiscoverySectionId {
|
||||
ArtistSpotlight => "artistSpotlight",
|
||||
Pinned => "pinned",
|
||||
MostPlayedAlbums => "mostPlayedAlbums",
|
||||
RecentlyPlayedPlaylists => "recentlyPlayedPlaylists",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +153,7 @@ impl DiscoverySectionId {
|
||||
"artistSpotlight" => ArtistSpotlight,
|
||||
"pinned" => Pinned,
|
||||
"mostPlayedAlbums" => MostPlayedAlbums,
|
||||
"recentlyPlayedPlaylists" => RecentlyPlayedPlaylists,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -192,6 +204,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(PressAwards, true),
|
||||
pref(QobuzPlaylists, true),
|
||||
pref(RecentlyPlayedAlbums, true),
|
||||
pref(RecentlyPlayedPlaylists, true),
|
||||
pref(ContinueListening, true),
|
||||
pref(IdealDiscography, true),
|
||||
pref(MostStreamed, true),
|
||||
@@ -223,6 +236,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(RadioStations, true),
|
||||
pref(ContinueListening, true),
|
||||
pref(RecentlyPlayedAlbums, true),
|
||||
pref(RecentlyPlayedPlaylists, true),
|
||||
pref(TopArtists, true),
|
||||
pref(FavoriteAlbums, true),
|
||||
pref(SimilarAlbums, true),
|
||||
@@ -418,6 +432,20 @@ pub fn reconcile_list(persisted: Option<&Vec<Value>>, fallback: &[SectionPref])
|
||||
// SQLite store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The rail sizes the UI offers, IN THE ORDER THE UI OFFERS THEM — a selector
|
||||
/// sends back an index into this array, so reordering it silently changes what
|
||||
/// every existing user has selected. Append, never reorder.
|
||||
///
|
||||
/// `0` means "no cap" and is the DEFAULT on purpose: before this setting
|
||||
/// existed the editorial rails were not truncated at all
|
||||
/// (`home_qt::order_by_prefs` pushed every item through), so any other default
|
||||
/// would shrink the Discover page of every user who upgrades without ever
|
||||
/// asking them.
|
||||
pub const RAIL_SIZE_PRESETS: [i64; 5] = [10, 15, 20, 25, 0];
|
||||
|
||||
/// The default rail size — `0`, i.e. today's uncapped behaviour.
|
||||
pub const DEFAULT_RAIL_SIZE: i64 = 0;
|
||||
|
||||
pub struct DiscoverPrefsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
@@ -448,6 +476,36 @@ impl DiscoverPrefsStore {
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize discover prefs: {}", e))?;
|
||||
|
||||
// Rail size lives in its OWN TABLE, not as a key inside `prefs_json`,
|
||||
// and that is a data-safety decision rather than a stylistic one.
|
||||
//
|
||||
// `DiscoverPrefs::to_json` rebuilds the blob FROM SCRATCH out of the
|
||||
// keys the running binary knows, and `save` overwrites the whole
|
||||
// column with it. This .db is SHARED with the Slint build, whose tree
|
||||
// is frozen and will not be recompiled — so the installed Slint binary
|
||||
// has the old `to_json` permanently. A new key inside the blob would
|
||||
// therefore survive exactly until the user touched any toggle over
|
||||
// there, and then vanish, with nothing logged anywhere.
|
||||
//
|
||||
// A separate table cannot be hit that way: the old binary never names
|
||||
// it in any statement, and `CREATE TABLE IF NOT EXISTS` is idempotent
|
||||
// in whichever build opens the file first. WAL + busy_timeout are
|
||||
// already set above, so two binaries on one file is the existing
|
||||
// design, not something this adds.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS discover_rail_size (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
rail_size INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create discover rail size table: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO discover_rail_size (id, rail_size) VALUES (1, ?1)",
|
||||
params![DEFAULT_RAIL_SIZE],
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize discover rail size: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
@@ -480,6 +538,39 @@ impl DiscoverPrefsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many items each Discover rail may show. `0` = no cap.
|
||||
///
|
||||
/// Never an error to the caller: a missing row, an old file without the
|
||||
/// table, or a value outside the presets all fall back to the default.
|
||||
pub fn load_rail_size(&self) -> i64 {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT rail_size FROM discover_rail_size WHERE id = 1",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.ok()
|
||||
.filter(|n| RAIL_SIZE_PRESETS.contains(n))
|
||||
.unwrap_or(DEFAULT_RAIL_SIZE)
|
||||
}
|
||||
|
||||
/// Persist the rail size (upsert row 1). Values outside the presets are
|
||||
/// rejected rather than stored, so `load_rail_size` and the UI's index can
|
||||
/// never disagree about what is selected.
|
||||
pub fn save_rail_size(&self, n: i64) -> Result<(), String> {
|
||||
if !RAIL_SIZE_PRESETS.contains(&n) {
|
||||
return Err(format!("rail size {n} is not one of {RAIL_SIZE_PRESETS:?}"));
|
||||
}
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO discover_rail_size (id, rail_size) VALUES (1, ?1)
|
||||
ON CONFLICT(id) DO UPDATE SET rail_size = excluded.rail_size",
|
||||
params![n],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save discover rail size: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the whole prefs blob (upsert row 1).
|
||||
pub fn save(&self, prefs: &DiscoverPrefs) -> Result<(), String> {
|
||||
self.conn
|
||||
@@ -525,18 +616,20 @@ mod tests {
|
||||
#[test]
|
||||
fn defaults_match_spec_exactly() {
|
||||
let d = default_prefs();
|
||||
// home: 15 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
||||
// + the local mostPlayedAlbums, default off).
|
||||
// home: 16 entries, first 9 ON (Tauri sectionPrefs.ts + Slint `pinned`
|
||||
// + the local mostPlayedAlbums, default off, + recentlyPlayedPlaylists
|
||||
// sitting right after its album sibling, default on).
|
||||
assert_eq!(
|
||||
ids(&d.home),
|
||||
vec![
|
||||
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
|
||||
RecentlyPlayedPlaylists,
|
||||
ContinueListening, IdealDiscography, MostStreamed, Pinned,
|
||||
QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists,
|
||||
FavoriteAlbums, MostPlayedAlbums,
|
||||
]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::Home), 8);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::Home), 9);
|
||||
assert!(d.is_enabled(DiscoveryTab::Home, MostStreamed));
|
||||
assert!(!d.is_enabled(DiscoveryTab::Home, Qobuzissimes));
|
||||
// editorPicks: 7 entries, all ON.
|
||||
@@ -545,13 +638,15 @@ mod tests {
|
||||
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
|
||||
// forYou: 14 entries, qobuzMixes first, pinned second; the 13
|
||||
// Tauri+Slint ones ON, mostPlayedAlbums (local addition) OFF.
|
||||
assert_eq!(d.for_you.len(), 14);
|
||||
// forYou: 15 entries, qobuzMixes first, pinned second; the 13
|
||||
// Tauri+Slint ones plus recentlyPlayedPlaylists ON, mostPlayedAlbums
|
||||
// (local addition) OFF.
|
||||
assert_eq!(d.for_you.len(), 15);
|
||||
assert_eq!(d.for_you[0].id, QobuzMixes);
|
||||
assert_eq!(d.for_you[1].id, Pinned);
|
||||
assert_eq!(d.for_you[13].id, MostPlayedAlbums);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 13);
|
||||
assert_eq!(d.for_you[6].id, RecentlyPlayedPlaylists);
|
||||
assert_eq!(d.for_you[14].id, MostPlayedAlbums);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 14);
|
||||
}
|
||||
|
||||
// --- Group 2: reconcile_list ---
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod local_favorites;
|
||||
pub mod payload_cache;
|
||||
pub mod pinned_items;
|
||||
pub mod playback;
|
||||
pub mod playlist_play_history;
|
||||
pub mod plex;
|
||||
pub mod album_play_history;
|
||||
pub mod reco_store;
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Local PLAYLIST play history — source of truth for the "Recently Played
|
||||
//! Playlists" rail.
|
||||
//!
|
||||
//! The structural twin of [`crate::settings::album_play_history`], and it
|
||||
//! exists because that module answered the wrong question. Playing a playlist
|
||||
//! of 40 tracks drawn from 40 albums used to write 40 ALBUM plays, so
|
||||
//! "Recently Played" became a list of whatever playlist was on and the
|
||||
//! playlist itself was recorded nowhere. Contexts are now separated at the
|
||||
//! write edge (`recently_qt::record_queue_track` reads `QueueTrack
|
||||
//! ::context_kind`), and this is where the playlist half lands.
|
||||
//!
|
||||
//! # Why a NEW file and not a column somewhere
|
||||
//!
|
||||
//! `recently_played.json` is a DESTRUCTIVE round trip between the two
|
||||
//! frontends: the Slint build deserializes it into its own structs, serde
|
||||
//! drops the fields it does not know, and its next write puts the truncated
|
||||
//! object back. Anything Qt added there would disappear the first time the
|
||||
//! other build played a track, intermittently and with nothing logged. A .db
|
||||
//! the frozen tree has never heard of cannot be destroyed that way — it never
|
||||
//! opens it.
|
||||
//!
|
||||
//! One event per TRACK-START, exactly like the album store (see its header):
|
||||
//! the de-dup for "recently played" happens in the `GROUP BY`, so listening to
|
||||
//! forty tracks of one playlist yields ONE card with `plays = 40`.
|
||||
//!
|
||||
//! SQLite is opened lazily and every read/write swallows its errors into a
|
||||
//! `log::warn!`; a fresh user with no .db yet gets an empty list, so the rail
|
||||
//! self-hides.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
static DB: OnceLock<Mutex<Option<Connection>>> = OnceLock::new();
|
||||
|
||||
fn db_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("playlist_play_history.db"))
|
||||
}
|
||||
|
||||
/// Create the tables + index on a fresh connection (shared by the lazy opener
|
||||
/// and the in-memory test connections).
|
||||
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS playlist_play_events (
|
||||
playlist_id TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS playlist_play_events_pl
|
||||
ON playlist_play_events(playlist_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS playlist_meta (
|
||||
playlist_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
artwork_url TEXT NOT NULL DEFAULT '',
|
||||
track_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn open_db() -> Option<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = match Connection::open(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz] playlist_play_history open failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// ADR-002: WAL, so a read from the render path never blocks on a write.
|
||||
let _ = conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;",
|
||||
);
|
||||
if let Err(e) = init_schema(&conn) {
|
||||
log::warn!("[qbz] playlist_play_history schema failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(conn)
|
||||
}
|
||||
|
||||
fn with_db<T, F>(f: F) -> Option<T>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Option<T>,
|
||||
{
|
||||
let cell = DB.get_or_init(|| Mutex::new(open_db()));
|
||||
let guard = cell.lock().ok()?;
|
||||
let conn = guard.as_ref()?;
|
||||
f(conn)
|
||||
}
|
||||
|
||||
/// Playlist metadata captured at play time (refreshed on every play, so
|
||||
/// renames and cover changes converge).
|
||||
pub struct PlaylistPlayMeta<'a> {
|
||||
/// The Qobuz numeric id as text, or `"local:<uuid>"` for a first-class
|
||||
/// local playlist. It is whatever `PlayContext::playlist(..)` stamped, so
|
||||
/// the event and the card can never key differently.
|
||||
pub playlist_id: &'a str,
|
||||
pub title: &'a str,
|
||||
pub owner: &'a str,
|
||||
pub owner_id: &'a str,
|
||||
pub artwork_url: &'a str,
|
||||
pub track_count: u32,
|
||||
/// `"qobuz"` | `"local"` — where the playlist itself lives, NOT where its
|
||||
/// audio comes from (a Qobuz playlist can hold local rows).
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
/// One playlist row for the rail / a View-all page.
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
pub struct PlaylistPlayRow {
|
||||
pub playlist_id: String,
|
||||
pub title: String,
|
||||
pub owner: String,
|
||||
pub owner_id: String,
|
||||
pub artwork_url: String,
|
||||
pub track_count: u32,
|
||||
pub source: String,
|
||||
pub plays: u32,
|
||||
}
|
||||
|
||||
/// Upsert the meta WITHOUT recording a play.
|
||||
///
|
||||
/// The two halves are separate on purpose. The meta is known by the view that
|
||||
/// starts playback (it has the header in hand); the event is written at the
|
||||
/// track-start edge, which only ever sees a `QueueTrack` and its
|
||||
/// `context_id`. Splitting them keeps the edge from having to resolve a
|
||||
/// playlist id back into a title on every single track change.
|
||||
fn upsert_meta_on(conn: &Connection, m: &PlaylistPlayMeta, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
r#"
|
||||
INSERT INTO playlist_meta
|
||||
(playlist_id, title, owner, owner_id, artwork_url,
|
||||
track_count, source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(playlist_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
owner = excluded.owner,
|
||||
owner_id = excluded.owner_id,
|
||||
artwork_url = excluded.artwork_url,
|
||||
track_count = excluded.track_count,
|
||||
source = excluded.source,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
m.playlist_id,
|
||||
m.title,
|
||||
m.owner,
|
||||
m.owner_id,
|
||||
m.artwork_url,
|
||||
m.track_count,
|
||||
m.source,
|
||||
now
|
||||
],
|
||||
) {
|
||||
log::warn!("[qbz] playlist_play_history upsert meta failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn record_event_on(conn: &Connection, playlist_id: &str, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO playlist_play_events (playlist_id, occurred_at) VALUES (?, ?)",
|
||||
params![playlist_id, now],
|
||||
) {
|
||||
log::warn!("[qbz] playlist_play_history insert event failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Remember what a playlist IS, so a later play event can render a card.
|
||||
/// Called by the views that start playback from a playlist. No-op on an empty
|
||||
/// id.
|
||||
pub fn record_playlist_meta(m: PlaylistPlayMeta) {
|
||||
if m.playlist_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = now_secs();
|
||||
with_db(|conn| {
|
||||
upsert_meta_on(conn, &m, now);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Record one play event. Called at the track-start edge when the track's
|
||||
/// playback context is a playlist. No-op on an empty id.
|
||||
pub fn record_playlist_play(playlist_id: &str) {
|
||||
if playlist_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = now_secs();
|
||||
with_db(|conn| {
|
||||
record_event_on(conn, playlist_id, now);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// `order_desc` is the ORDER BY body — the only difference between "recently
|
||||
/// played" and "most played", which is why both come out of one query.
|
||||
fn query_on(conn: &Connection, order: &str, limit: Option<u32>) -> Vec<PlaylistPlayRow> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT m.playlist_id, m.title, m.owner, m.owner_id, m.artwork_url,
|
||||
m.track_count, m.source, p.plays
|
||||
FROM playlist_meta m
|
||||
JOIN (
|
||||
SELECT playlist_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||
FROM playlist_play_events
|
||||
GROUP BY playlist_id
|
||||
) p ON p.playlist_id = m.playlist_id
|
||||
ORDER BY {order}
|
||||
{}
|
||||
"#,
|
||||
limit.map(|n| format!("LIMIT {n}")).unwrap_or_default()
|
||||
);
|
||||
let out = (|| -> Option<Vec<PlaylistPlayRow>> {
|
||||
let mut stmt = conn.prepare(&sql).ok()?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PlaylistPlayRow {
|
||||
playlist_id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
owner: row.get(2)?,
|
||||
owner_id: row.get(3)?,
|
||||
artwork_url: row.get(4)?,
|
||||
track_count: row.get::<_, i64>(5)? as u32,
|
||||
source: row.get(6)?,
|
||||
plays: row.get::<_, i64>(7)? as u32,
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
Some(rows.flatten().collect())
|
||||
})();
|
||||
out.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The `limit` most recently played playlists (the rail).
|
||||
pub fn recent_playlists(limit: u32) -> Vec<PlaylistPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, "p.last_at DESC", Some(limit)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every played playlist, most recent first (a "View all" page).
|
||||
pub fn all_recent_playlists() -> Vec<PlaylistPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, "p.last_at DESC", None))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ranked by play count. Not wired to a rail — the owner asked for one rail,
|
||||
/// and this falls out of the same table for free when a second one is wanted.
|
||||
#[allow(dead_code)]
|
||||
pub fn top_playlists(limit: u32) -> Vec<PlaylistPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, "p.plays DESC, p.last_at DESC", Some(limit))))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drop every trace of the given playlists — the parity of
|
||||
/// `recently_qt::prune_albums`, for a playlist the user deleted or unfollowed.
|
||||
/// Returns how many meta rows went.
|
||||
pub fn prune_playlists(ids: &[String]) -> usize {
|
||||
if ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
with_db(|conn| {
|
||||
let mut gone = 0usize;
|
||||
for id in ids {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM playlist_play_events WHERE playlist_id = ?",
|
||||
params![id],
|
||||
);
|
||||
gone += conn
|
||||
.execute("DELETE FROM playlist_meta WHERE playlist_id = ?", params![id])
|
||||
.unwrap_or(0);
|
||||
}
|
||||
Some(gone)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta<'a>(id: &'a str, title: &'a str) -> PlaylistPlayMeta<'a> {
|
||||
PlaylistPlayMeta {
|
||||
playlist_id: id,
|
||||
title,
|
||||
owner: "Someone",
|
||||
owner_id: "42",
|
||||
artwork_url: "http://art",
|
||||
track_count: 12,
|
||||
source: "qobuz",
|
||||
}
|
||||
}
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_orders_by_last_play_not_by_count() {
|
||||
let c = mem();
|
||||
// A is played far more, B far more RECENTLY. "Recently played" must
|
||||
// put B first — that is the whole difference from "most played".
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
upsert_meta_on(&c, &meta("B", "Playlist B"), 100);
|
||||
for i in 0..30 {
|
||||
record_event_on(&c, "A", 100 + i);
|
||||
}
|
||||
record_event_on(&c, "B", 9_000);
|
||||
|
||||
let recent = query_on(&c, "p.last_at DESC", Some(10));
|
||||
assert_eq!(recent.len(), 2);
|
||||
assert_eq!(recent[0].playlist_id, "B");
|
||||
assert_eq!(recent[1].playlist_id, "A");
|
||||
assert_eq!(recent[1].plays, 30);
|
||||
|
||||
let top = query_on(&c, "p.plays DESC, p.last_at DESC", Some(10));
|
||||
assert_eq!(top[0].playlist_id, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_card_per_playlist_however_many_tracks() {
|
||||
let c = mem();
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
for i in 0..40 {
|
||||
record_event_on(&c, "A", 100 + i);
|
||||
}
|
||||
let recent = query_on(&c, "p.last_at DESC", None);
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].plays, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_without_a_play_does_not_show() {
|
||||
// The JOIN is what enforces it: opening a playlist upserts nothing on
|
||||
// its own, and even if it did, a card with no play is not "recently
|
||||
// played".
|
||||
let c = mem();
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
assert!(query_on(&c, "p.last_at DESC", None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_removes_events_and_meta() {
|
||||
let c = mem();
|
||||
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
|
||||
upsert_meta_on(&c, &meta("B", "Playlist B"), 100);
|
||||
record_event_on(&c, "A", 100);
|
||||
record_event_on(&c, "B", 100);
|
||||
let _ = c.execute("DELETE FROM playlist_play_events WHERE playlist_id = 'A'", []);
|
||||
let _ = c.execute("DELETE FROM playlist_meta WHERE playlist_id = 'A'", []);
|
||||
let left = query_on(&c, "p.last_at DESC", None);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(left[0].playlist_id, "B");
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,12 @@ fn main() {
|
||||
"qml/controls/QbzToggle.qml",
|
||||
"qml/controls/QbzToolButton.qml",
|
||||
"qml/controls/QbzTooltip.qml",
|
||||
// Applied-filters tooltip: the trigger a filter control mounts
|
||||
// beside itself (it writes the shell channel QbzTooltip reads).
|
||||
"qml/controls/QbzFilterTip.qml",
|
||||
// Home > Qobuz Playlists: the category filter, trigger + popup.
|
||||
"qml/controls/PlaylistTagFilterButton.qml",
|
||||
"qml/controls/PlaylistTagFilterPopup.qml",
|
||||
"qml/controls/QualityBadge.qml",
|
||||
"qml/controls/QualityMini.qml",
|
||||
"qml/controls/SettingRow.qml",
|
||||
|
||||
+69
-15
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,37 @@ Rectangle {
|
||||
readonly property int count: (genreDoc.counts || {})[root.context] || 0
|
||||
readonly property bool active: root.count > 0
|
||||
|
||||
// APPLIED-FILTERS TOOLTIP. The selected genre NAMES are already on the wire
|
||||
// per context (`genre_filter_qt.rs` publishes `names`, expanded to include
|
||||
// tree descendants), so this surface costs nothing but the wiring — and
|
||||
// wiring it HERE gives it to all four hosts at once: Home/Discover, the two
|
||||
// browse pages and Library.
|
||||
//
|
||||
// `extraGroups` lets a host append the filters this button knows nothing
|
||||
// about (a search term, a sort, the source toggles) so one bubble answers
|
||||
// the whole toolbar instead of one control.
|
||||
property var extraGroups: []
|
||||
readonly property var genreNames: (genreDoc.names || {})[root.context] || []
|
||||
readonly property var summaryGroups: {
|
||||
var out = []
|
||||
if (root.genreNames.length > 0)
|
||||
out.push({
|
||||
group: QbzSession.tr("Genre", QbzSession.trRev),
|
||||
values: root.genreNames
|
||||
})
|
||||
var ex = root.extraGroups || []
|
||||
for (var i = 0; i < ex.length; i++)
|
||||
out.push(ex[i])
|
||||
return out
|
||||
}
|
||||
|
||||
QbzFilterTip {
|
||||
id: genreTip
|
||||
ownerKey: "genre-" + root.context
|
||||
anchor: root
|
||||
groups: root.summaryGroups
|
||||
}
|
||||
|
||||
width: genreRow.width
|
||||
height: btnHeight
|
||||
radius: 6
|
||||
@@ -94,5 +125,9 @@ Rectangle {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
onEntered: genreTip.enter()
|
||||
onExited: genreTip.exit()
|
||||
// A click opens the genre popup right over the bubble; drop it first.
|
||||
onPressed: genreTip.exit()
|
||||
}
|
||||
}
|
||||
@@ -176,13 +176,68 @@ Item {
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
|
||||
// --- Section list ---------------------------------------------
|
||||
// Explicit, content-derived height capped to what is left of the
|
||||
// panel; the Flickable scrolls past that.
|
||||
// --- Items per carousel ---------------------------------------
|
||||
// Recovered from Tauri (`HomeSettingsModal.svelte`), which let the
|
||||
// user cap how many items each carousel showed. It never reached
|
||||
// Slint — the discovery-v2 modal Slint was ported from only ever
|
||||
// modelled {id, enabled} — so Qt inherited the absence.
|
||||
//
|
||||
// GLOBAL, so it reads its own bridge property rather than the
|
||||
// per-tab document: `root.mine` gates the rows below on the tab
|
||||
// payload having landed, and this control must be usable before
|
||||
// that.
|
||||
Item {
|
||||
visible: root.tab !== "recommendations"
|
||||
width: parent.width
|
||||
height: Math.max(0, Math.min(listCol.height, root.height * 0.78 - 260))
|
||||
height: 34
|
||||
|
||||
Text {
|
||||
id: sizeLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Items per carousel", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
QbzSelect {
|
||||
anchors.left: sizeLabel.right
|
||||
anchors.leftMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
sm: true
|
||||
menuWidth: 140
|
||||
// Option ORDER is the contract with RAIL_SIZE_PRESETS in
|
||||
// qbz-app/src/settings/discover_prefs.rs — the bridge
|
||||
// publishes an index, not a count, so reordering these
|
||||
// silently changes what every stored value means. "All" is
|
||||
// the stored 0 and the default: it is what this page did
|
||||
// before the setting existed.
|
||||
options: [
|
||||
"10",
|
||||
"15",
|
||||
"20",
|
||||
"25",
|
||||
QbzSession.tr("All", QbzSession.trRev),
|
||||
]
|
||||
currentIndex: QbzHome.discoverRailSizeIndex
|
||||
onSelected: function (i) { QbzHome.discoverSetRailSize(i) }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section list ---------------------------------------------
|
||||
// Explicit, content-derived height capped to what is left of the
|
||||
// panel; the Flickable scrolls past that.
|
||||
//
|
||||
// The subtrahend is the FIXED-CHROME BUDGET (title + banner + count
|
||||
// + the size row + footer + margins), and it is load-bearing: the
|
||||
// panel clips (`clip: true` above), so a row added over this list
|
||||
// without raising the number does not overflow visibly — the last
|
||||
// sections and the Reset footer simply stop existing. It went
|
||||
// 260 -> 308 with the size row: 34 of row plus the column's 14 of
|
||||
// spacing.
|
||||
Item {
|
||||
visible: root.tab !== "recommendations"
|
||||
width: parent.width
|
||||
height: Math.max(0, Math.min(listCol.height, root.height * 0.78 - 308))
|
||||
clip: true
|
||||
Flickable {
|
||||
id: listFlick
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// "Filter by category" trigger for the Home rail of Qobuz Playlists — port of
|
||||
// `crates/qbz-ui/ui/discover/PlaylistTagFilter.slint:66-118`.
|
||||
//
|
||||
// 220x30, radius 6, NO outline: the reference states the size contract
|
||||
// explicitly ("sm variant — matches QbzSelect { sm: true } used by the toolbar
|
||||
// selects: 30px tall, 6px radius, no outline"), so this is the small toolbar
|
||||
// control, not the 34px browse-header one that BrowseGenreButton draws.
|
||||
//
|
||||
// Extracted rather than inlined into HomeView for the reason BrowseGenreButton
|
||||
// states about itself: one control, drawn identically wherever it is mounted.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property int count: 0
|
||||
signal clicked()
|
||||
|
||||
// The selected tag NAMES, for the applied-filters tooltip. The host has
|
||||
// them (it holds the tag catalogue); this control only knows the count.
|
||||
property var selectedNames: []
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 220
|
||||
height: 30
|
||||
radius: 6
|
||||
// Translucent at rest under the dynamic background, like every other chip
|
||||
// in this toolbar band (PlaylistTagFilter.slint:77).
|
||||
color: tagArea.containsMouse
|
||||
? theme.surfaceHover
|
||||
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
|
||||
|
||||
QbzFilterTip {
|
||||
id: tagTip
|
||||
ownerKey: "home-playlist-tags"
|
||||
anchor: root
|
||||
groups: root.selectedNames.length > 0
|
||||
? [{ group: QbzSession.tr("Category", QbzSession.trRev),
|
||||
values: root.selectedNames }]
|
||||
: []
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 13
|
||||
height: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 13 - 14 - 2 * parent.spacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.count > 0
|
||||
? QbzSession.tr("{} selected", QbzSession.trRev)
|
||||
.replace("{}", root.count)
|
||||
: QbzSession.tr("Filter by category", QbzSession.trRev)
|
||||
color: root.count > 0 ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzIcon {
|
||||
name: "chevron-down"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tagArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
onEntered: tagTip.enter()
|
||||
onExited: tagTip.exit()
|
||||
onPressed: tagTip.exit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Qobuz-Playlists category filter — the dropdown of the Home rail's
|
||||
// "Filter by category" trigger. Port of
|
||||
// `crates/qbz-ui/ui/discover/PlaylistTagFilter.slint:120-183`.
|
||||
//
|
||||
// The filter is CLIENT-side over the 40 cards already in hand: the tags ship in
|
||||
// the same /discover/index response as the playlists, so opening this costs no
|
||||
// request. The selection is a UNION (a card passes if it carries ANY selected
|
||||
// slug) and an empty selection shows everything.
|
||||
//
|
||||
// The selection lives in RUST (`home_qt::toggle_playlist_tag`), not in a
|
||||
// property here or on HomeView: this view is destroyed on every navigation, so
|
||||
// a QML-side selection would be forgotten the moment the user opened an album
|
||||
// and came back. Slint keeps it in `TAB_SECTIONS` for the same reason and says
|
||||
// so: "Client-side; survives a tab switch."
|
||||
//
|
||||
// ANCHORING. The trigger sits INSIDE the Home flickable, at a y that depends on
|
||||
// the scroll position and on the rail order the Discover configurator dictates,
|
||||
// so the fixed `anchorTop` of GenreFilterPopup is not available here. The
|
||||
// position is captured by `mapToItem` AT OPEN and stored as NUMBERS — never a
|
||||
// reference to the trigger — so a rebuilt rail cannot dangle under an open
|
||||
// popup. Same rule, same reason, as controls/QbzTooltip.qml:127-133.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// [{ slug, name }] — the offered set, from QbzHome.playlistTagsJson.
|
||||
property var tags: []
|
||||
// Selected slugs, from the same document.
|
||||
property var selected: []
|
||||
|
||||
readonly property bool opened: root._open
|
||||
property bool _open: false
|
||||
property real _x: 0
|
||||
property real _y: 0
|
||||
|
||||
signal toggled(string slug)
|
||||
signal cleared()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// 220 = the trigger's width (PlaylistTagFilter.slint:69), so the card lines
|
||||
// up under it exactly.
|
||||
readonly property int cardW: 220
|
||||
readonly property int rowH: 32
|
||||
readonly property int footerH: 32
|
||||
readonly property int listMax: 320
|
||||
readonly property real listH: Math.min(root.tags.length * root.rowH, root.listMax)
|
||||
readonly property real cardH: root.listH + root.footerH + 10
|
||||
|
||||
anchors.fill: parent
|
||||
visible: root._open
|
||||
z: 3000
|
||||
|
||||
function openAt(item) {
|
||||
if (!item)
|
||||
return
|
||||
var p = item.mapToItem(root, 0, 0)
|
||||
// 4px of air under the trigger (PlaylistTagFilter.slint:122), clamped
|
||||
// so the card never leaves the window.
|
||||
root._x = Math.max(8, Math.min(root.width - root.cardW - 8, p.x))
|
||||
root._y = Math.max(8, Math.min(root.height - root.cardH - 8,
|
||||
p.y + item.height + 4))
|
||||
root._open = true
|
||||
}
|
||||
function close() {
|
||||
root._open = false
|
||||
}
|
||||
function toggle(item) {
|
||||
if (root._open)
|
||||
root.close()
|
||||
else
|
||||
root.openAt(item)
|
||||
}
|
||||
|
||||
// Click-out backdrop. Declared FIRST so the card below covers it.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: Math.round(root._x)
|
||||
y: Math.round(root._y)
|
||||
width: root.cardW
|
||||
height: root.cardH
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
clip: true
|
||||
|
||||
// Swallow clicks so the backdrop does not close the card.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Flickable {
|
||||
id: listFlick
|
||||
x: 0
|
||||
y: 5
|
||||
width: parent.width
|
||||
height: root.listH
|
||||
contentWidth: width
|
||||
contentHeight: listCol.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: listCol
|
||||
width: listFlick.width
|
||||
|
||||
Repeater {
|
||||
model: root.tags
|
||||
delegate: Rectangle {
|
||||
id: tagRow
|
||||
width: listCol.width
|
||||
height: root.rowH
|
||||
color: tagArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
readonly property string slug: modelData.slug || ""
|
||||
readonly property bool picked:
|
||||
(root.selected || []).indexOf(tagRow.slug) >= 0
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 4
|
||||
color: tagRow.picked ? theme.accent : "transparent"
|
||||
border.width: tagRow.picked ? 0 : 1.5
|
||||
border.color: theme.textMuted
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: tagRow.picked
|
||||
name: "check"
|
||||
width: 11
|
||||
height: 11
|
||||
// The reference hardcodes #ffffff
|
||||
// (PlaylistTagFilter.slint:43). This port
|
||||
// does not: white on a pale accent measures
|
||||
// under 2.6:1 on 16 of the 35 palettes, so
|
||||
// every check in this build takes the
|
||||
// measured selector instead.
|
||||
tintName: theme.accentGlyphTint
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 24 - parent.spacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.name || ""
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
font.weight: tagRow.picked ? theme.weightMedium
|
||||
: theme.weightRegular
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tagArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled(tagRow.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "All categories" — clears the selection. Dimmed and inert when there
|
||||
// is nothing to clear (PlaylistTagFilter.slint:159-180).
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.footerH
|
||||
y: root.listH + 5
|
||||
opacity: (root.selected || []).length > 0 ? 1.0 : 0.5
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("All categories", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: (root.selected || []).length > 0
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.cleared()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Applied-filters tooltip trigger — the two-line wiring a filter control mounts
|
||||
// beside itself so hovering it says WHAT is currently filtered, not just that
|
||||
// something is.
|
||||
//
|
||||
// Non-visual. It writes the shell's `filterTipJson` channel
|
||||
// (`shell_bridge.rs`), which `AppShell.qml` feeds to the one `QbzTooltip`
|
||||
// overlay. That indirection is not ceremony: a funnel lives five levels inside
|
||||
// a view's toolbar and the overlay is a sibling of the whole shell, so a QML
|
||||
// `id` cannot reach it, and the art preview solved the identical problem the
|
||||
// identical way.
|
||||
//
|
||||
// THE HOST BUILDS `groups`, and there is no way around it: of the app's filter
|
||||
// surfaces roughly a third keep their state in QML properties and the rest in
|
||||
// Rust documents, so nothing here could read one source and be right. What this
|
||||
// does own is the CONTRACT — `[{ group: "<label>", values: ["…"] }]`, groups
|
||||
// with no values dropped, and an empty result meaning "show nothing", which
|
||||
// lets a control keep its ordinary one-line tooltip for the unfiltered case.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// QbzFilterTip {
|
||||
// id: filterTip
|
||||
// ownerKey: "local-albums-filter"
|
||||
// anchor: funnelButton
|
||||
// groups: root.filterSummaryGroups
|
||||
// }
|
||||
// MouseArea {
|
||||
// hoverEnabled: true
|
||||
// onEntered: filterTip.enter()
|
||||
// onExited: filterTip.exit()
|
||||
// }
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Unique per surface. It is the race-safe owner id the overlay closes on,
|
||||
// so two funnels on one screen never blank each other's bubble.
|
||||
property string ownerKey: ""
|
||||
// The control the bubble points at.
|
||||
property Item anchor: null
|
||||
// [{ group: "Genre", values: ["Rock", "Jazz"] }, …]
|
||||
property var groups: []
|
||||
|
||||
width: 0
|
||||
height: 0
|
||||
visible: false
|
||||
|
||||
// True when there is actually something to say. Hosts read it to decide
|
||||
// whether to show their plain tooltip instead.
|
||||
readonly property bool hasSummary: {
|
||||
var gs = root.groups || []
|
||||
for (var i = 0; i < gs.length; i++) {
|
||||
var v = (gs[i] || {}).values || []
|
||||
if (v.length > 0)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function enter() {
|
||||
if (!root.anchor || !root.hasSummary)
|
||||
return
|
||||
// Scene coordinates: the overlay fills the window, so its coordinate
|
||||
// space and the scene's are the same. Captured as NUMBERS — the anchor
|
||||
// may be destroyed (a rebuilt view, a recycled delegate) while the
|
||||
// bubble is still up.
|
||||
var p = root.anchor.mapToItem(null, 0, 0)
|
||||
QbzShell.filterTipJson = JSON.stringify({
|
||||
key: root.ownerKey,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
w: root.anchor.width,
|
||||
h: root.anchor.height,
|
||||
groups: root.groups
|
||||
})
|
||||
}
|
||||
|
||||
function exit() {
|
||||
// Only clear if this control still owns the channel, so sliding from
|
||||
// one funnel straight onto another does not blank the new bubble.
|
||||
var d = {}
|
||||
try {
|
||||
d = JSON.parse(QbzShell.filterTipJson)
|
||||
} catch (e) {
|
||||
d = {}
|
||||
}
|
||||
if (d && d.key === root.ownerKey)
|
||||
QbzShell.filterTipJson = "{}"
|
||||
}
|
||||
|
||||
// A view being torn down under an open bubble must not leave it floating.
|
||||
Component.onDestruction: root.exit()
|
||||
}
|
||||
@@ -102,10 +102,28 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional control mounted at the START of the right-hand row, i.e. LEFT
|
||||
/// of "View all" and the page chevrons. Added for the Qobuz-Playlists
|
||||
/// rail's category filter, which the reference puts on this very line
|
||||
/// (HomeView.slint:358-374).
|
||||
///
|
||||
/// A Component, not an Item: it is declared by the HOST, so it keeps the
|
||||
/// host's scope and can see the host's ids — and it is only instantiated
|
||||
/// where it is set. The `visible`/`active` gate keeps the thirteen existing
|
||||
/// instantiations pixel-identical, because `Row` skips invisible children
|
||||
/// and adds no spacing for them.
|
||||
property Component leading: null
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
Loader {
|
||||
visible: root.leading !== null
|
||||
active: root.leading !== null
|
||||
sourceComponent: root.leading
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
// "View all" link/chip (accent = Search's, secondary = Home's).
|
||||
Rectangle {
|
||||
visible: root.showViewAll
|
||||
|
||||
@@ -43,6 +43,19 @@
|
||||
// 9/9/5/5 padding, 11px/medium text-primary, downward caret,
|
||||
// y = anchor.top - height - 9, centre clamped 8px from the
|
||||
// window edges with the caret still pointing at the control.
|
||||
// showSummary() APPLIED-FILTERS bubble — this port's own, no Slint twin.
|
||||
// Same machine, structured content: a list of {group, values}
|
||||
// instead of one string, laid out UNDER the control (filter
|
||||
// funnels live in toolbars, so the space is below them) with an
|
||||
// upward caret, flipping above when it would leave the window.
|
||||
// 10/10/8/8 padding, 6px between groups — one step up from the
|
||||
// one-line bubble and one step below a flyout's 14/12, which is
|
||||
// a DELIBERATE departure from the control-alignment standard
|
||||
// (that document sizes flyouts, and this is not one).
|
||||
// Group headers are 11px semibold text-muted rather than the
|
||||
// standard GroupHeader (ALL-CAPS 11px, letter-spacing 1.5):
|
||||
// inside an 11px bubble the tracked caps are unreadable.
|
||||
// Values are comma-separated TEXT, never chips — ADR-008.
|
||||
//
|
||||
// DELIBERATE DEVIATIONS FROM SLINT, both stated because they are visible:
|
||||
// - MAX WIDTH + ELISION. Neither Slint bubble caps its width; a 200-character
|
||||
@@ -61,6 +74,7 @@
|
||||
// against Slint's `drop-shadow-blur`, so it is deliberately NOT done here.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
@@ -79,7 +93,9 @@ Item {
|
||||
// never blanks it (Sidebar.slint:199-203).
|
||||
property string ownerKey: ""
|
||||
property string label: ""
|
||||
// 0 = right of the anchor (sidebar rail), 1 = above it (caret bubble).
|
||||
// 0 = right of the anchor (sidebar rail), 1 = above it (caret bubble),
|
||||
// 2 = below it (applied-filters summary, upward caret; flips to 1 when it
|
||||
// would fall off the bottom).
|
||||
property int placement: 0
|
||||
// Anchor rectangle, in THIS overlay's coordinates. Captured as plain
|
||||
// numbers on show — never a reference to the anchoring Item, so a recycled
|
||||
@@ -91,7 +107,52 @@ Item {
|
||||
// 0 disables the cap (Slint's own behaviour).
|
||||
property int maxWidth: 320
|
||||
|
||||
readonly property bool shown: ownerKey !== "" && label !== ""
|
||||
// ---- Summary mode -----------------------------------------------------
|
||||
// [{ group: "Genre", values: ["Rock", "Jazz"] }, …]. Empty = the classic
|
||||
// one-line bubble, so nothing about the sidebar changes.
|
||||
property var groups: []
|
||||
property int summaryMinWidth: 160
|
||||
|
||||
readonly property bool isSummary: root.placement === 2 && root._rows.length > 0
|
||||
readonly property bool shown: ownerKey !== ""
|
||||
&& (label !== "" || root.isSummary)
|
||||
|
||||
// OVERFLOW, and why there is no scrolling.
|
||||
//
|
||||
// A scrollable tooltip cannot be scrolled: this overlay is `enabled: false`
|
||||
// by construction (it must never take the pointer, or it steals the hover
|
||||
// that is keeping it open — see the header). So the rule for "too much
|
||||
// content" has to be SUMMARY, not scroll, and it is staged:
|
||||
// 1. at most 3 values per group, then "+N more";
|
||||
// 2. at most 5 groups, then one final "+N more filters" row;
|
||||
// 3. every row elides inside the fixed width;
|
||||
// 4. a hard width cap of min(maxWidth, 40% of the window), floor 160.
|
||||
// With those, the height cap is never the thing that truncates.
|
||||
readonly property int _maxValues: 3
|
||||
readonly property int _maxGroups: 5
|
||||
readonly property var _rows: {
|
||||
var gs = root.groups || []
|
||||
var out = []
|
||||
var n = Math.min(gs.length, root._maxGroups)
|
||||
for (var i = 0; i < n; i++) {
|
||||
var g = gs[i] || {}
|
||||
var vals = g.values || []
|
||||
if (vals.length === 0)
|
||||
continue
|
||||
var line = vals.slice(0, root._maxValues).join(", ")
|
||||
if (vals.length > root._maxValues)
|
||||
line += " " + QbzSession.tr("+{} more", QbzSession.trRev)
|
||||
.replace("{}", vals.length - root._maxValues)
|
||||
out.push({ head: g.group || "", body: line })
|
||||
}
|
||||
if (gs.length > root._maxGroups)
|
||||
out.push({
|
||||
head: "",
|
||||
body: QbzSession.tr("+{} more filters", QbzSession.trRev)
|
||||
.replace("{}", gs.length - root._maxGroups)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- API --------------------------------------------------------------
|
||||
// `item` is any Item; its bounds are mapped into this overlay. `key` is the
|
||||
@@ -112,16 +173,48 @@ Item {
|
||||
root.label = text
|
||||
root.ownerKey = key
|
||||
}
|
||||
// The applied-filters bubble. `groups` is built by the HOST, not read from
|
||||
// a global: of the app's filter surfaces roughly a third keep their state
|
||||
// in QML and the rest in Rust documents, so there is no single source this
|
||||
// could read. Passing an empty (or all-empty) array is a no-op — a surface
|
||||
// with nothing applied shows its ordinary one-line tooltip instead.
|
||||
function showSummary(item, key, groups) {
|
||||
if (!item)
|
||||
return
|
||||
const p = item.mapToItem(root, 0, 0)
|
||||
root.showSummaryAt(key, p.x, p.y, item.width, item.height, groups)
|
||||
}
|
||||
// The same, from plain NUMBERS — the form the bridge channel uses, and the
|
||||
// honest one: this overlay has never wanted an Item, only a rectangle.
|
||||
function showSummaryAt(key, x, y, w, h, groups) {
|
||||
if (!groups || groups.length === 0)
|
||||
return
|
||||
root.groups = groups
|
||||
if (root._rows.length === 0) {
|
||||
root.groups = []
|
||||
return
|
||||
}
|
||||
root.anchorX = x
|
||||
root.anchorY = y
|
||||
root.anchorW = w
|
||||
root.anchorH = h
|
||||
root.label = ""
|
||||
root.placement = 2
|
||||
root.ownerKey = key
|
||||
}
|
||||
// Race-safe close: only the owner may clear the bubble.
|
||||
function hide(key) {
|
||||
if (root.ownerKey === key)
|
||||
if (root.ownerKey === key) {
|
||||
root.ownerKey = ""
|
||||
root.groups = []
|
||||
}
|
||||
}
|
||||
// Unconditional close — for the events that invalidate the anchor itself
|
||||
// (the sidebar leaving the mini state, a flyout taking the same slot, a
|
||||
// view change).
|
||||
function hideAll() {
|
||||
root.ownerKey = ""
|
||||
root.groups = []
|
||||
}
|
||||
|
||||
function _capture(item) {
|
||||
@@ -137,12 +230,22 @@ Item {
|
||||
// reads the Text's natural implicitWidth (NoWrap + ElideRight leaves
|
||||
// implicitWidth at the unelided width, so assigning the Text a narrower
|
||||
// width below is not a binding loop) and the cap only ever shrinks it.
|
||||
readonly property real padH: placement === 0 ? 10 : 9
|
||||
readonly property real padV: 5
|
||||
readonly property real bubbleW: Math.min(
|
||||
tipText.implicitWidth + 2 * padH,
|
||||
maxWidth > 0 ? maxWidth : Number.MAX_VALUE)
|
||||
readonly property real bubbleH: tipText.implicitHeight + 2 * padV
|
||||
readonly property real padH: root.isSummary ? 10 : (placement === 0 ? 10 : 9)
|
||||
readonly property real padV: root.isSummary ? 8 : 5
|
||||
// The width cap for the summary is tighter than the plain bubble's: 40% of
|
||||
// the window, so a wide screen does not get a 320px slab floating over a
|
||||
// toolbar, with a 160px floor so a one-word filter still reads as a bubble.
|
||||
readonly property real summaryCap: Math.max(
|
||||
root.summaryMinWidth,
|
||||
Math.min(maxWidth > 0 ? maxWidth : 320, root.width * 0.4))
|
||||
readonly property real bubbleW: root.isSummary
|
||||
? Math.max(root.summaryMinWidth,
|
||||
Math.min(sumMeasure.implicitWidth + 2 * padH, root.summaryCap))
|
||||
: Math.min(tipText.implicitWidth + 2 * padH,
|
||||
maxWidth > 0 ? maxWidth : Number.MAX_VALUE)
|
||||
readonly property real bubbleH: root.isSummary
|
||||
? sumCol.implicitHeight + 2 * padV
|
||||
: tipText.implicitHeight + 2 * padV
|
||||
|
||||
// Right placement (SidebarTooltip.slint:17-19): +6px off the row's right
|
||||
// edge, vertically centred on the row. Flips to the row's LEFT when the
|
||||
@@ -163,19 +266,32 @@ Item {
|
||||
Math.max(8, root.width - bubbleW - 8), aboveCx - bubbleW / 2))
|
||||
readonly property real aboveY: anchorY - bubbleH - 9
|
||||
|
||||
// Below placement (summary): 9px of air under the control, same horizontal
|
||||
// clamp as `above`. Flips ABOVE when the bubble would leave the bottom of
|
||||
// the window — a filter funnel near the foot of a short window is exactly
|
||||
// where a toolbar ends up on a laptop.
|
||||
readonly property real belowY: anchorY + anchorH + 9
|
||||
readonly property bool flipUp: root.isSummary
|
||||
&& (root.belowY + root.bubbleH > root.height - 8)
|
||||
&& (root.aboveY >= 8)
|
||||
// True when the caret points UP at a control above the bubble.
|
||||
readonly property bool caretUp: root.isSummary && !root.flipUp
|
||||
|
||||
// ---- The bubble -------------------------------------------------------
|
||||
Item {
|
||||
id: bubble
|
||||
visible: root.shown
|
||||
x: Math.round(root.placement === 0 ? root.rightX : root.aboveX)
|
||||
y: Math.round(root.placement === 0 ? root.rightY : root.aboveY)
|
||||
y: Math.round(root.placement === 0 ? root.rightY
|
||||
: root.isSummary ? (root.flipUp ? root.aboveY : root.belowY)
|
||||
: root.aboveY)
|
||||
width: Math.ceil(root.bubbleW)
|
||||
height: Math.ceil(root.bubbleH)
|
||||
|
||||
// Shadow approximation (see the header): one offset rect, same radius.
|
||||
Rectangle {
|
||||
x: 0
|
||||
y: root.placement === 0 ? 4 : 2
|
||||
y: root.placement === 0 ? 4 : (root.isSummary ? 3 : 2)
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
radius: root.placement === 0 ? theme.radiusSm : 4
|
||||
@@ -193,6 +309,7 @@ Item {
|
||||
|
||||
Text {
|
||||
id: tipText
|
||||
visible: !root.isSummary
|
||||
x: root.padH
|
||||
y: root.padV
|
||||
width: Math.max(0, parent.width - 2 * root.padH)
|
||||
@@ -204,6 +321,76 @@ Item {
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Summary body. Every row is NoWrap + elide, deliberately: with
|
||||
// `Text.Wrap` a Text's implicitWidth depends on the width assigned
|
||||
// to it, and `bubbleW` is computed FROM those implicit widths — a
|
||||
// binding loop. NoWrap keeps implicitWidth at the unelided width,
|
||||
// which is the same reason the one-line bubble above uses it. The
|
||||
// values are already capped to three plus a "+N more", so a row is
|
||||
// short by construction and the cap rarely bites.
|
||||
Column {
|
||||
id: sumCol
|
||||
visible: root.isSummary
|
||||
x: root.padH
|
||||
y: root.padV
|
||||
width: Math.max(0, parent.width - 2 * root.padH)
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.isSummary ? root._rows : []
|
||||
delegate: Column {
|
||||
width: sumCol.width
|
||||
spacing: 1
|
||||
Text {
|
||||
visible: (modelData.head || "") !== ""
|
||||
width: parent.width
|
||||
text: modelData.head || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: modelData.body || ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Off-screen measuring pass. A Column's implicitWidth is the widest of
|
||||
// its children, and an UNASSIGNED Text's width is its implicitWidth —
|
||||
// so this reports the natural width of the widest row without any of
|
||||
// the visible rows influencing it. It only ever holds delegates while
|
||||
// the summary is up (`_rows` is empty otherwise), so at rest it costs
|
||||
// nothing.
|
||||
Column {
|
||||
id: sumMeasure
|
||||
visible: false
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: root.isSummary ? root._rows : []
|
||||
delegate: Column {
|
||||
spacing: 1
|
||||
Text {
|
||||
visible: (modelData.head || "") !== ""
|
||||
text: modelData.head || ""
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Text {
|
||||
text: modelData.body || ""
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Downward caret, above-placement only. Slint draws a Path
|
||||
@@ -214,18 +401,22 @@ Item {
|
||||
// recycling; see theme/RoundedImage.qml).
|
||||
Canvas {
|
||||
id: caret
|
||||
visible: root.placement === 1
|
||||
visible: root.placement === 1 || root.isSummary
|
||||
width: 10
|
||||
height: 6
|
||||
x: Math.round(Math.max(4, Math.min(parent.width - width - 4,
|
||||
root.aboveCx - bubble.x - width / 2)))
|
||||
y: parent.height - 1
|
||||
y: root.caretUp ? -(height - 1) : parent.height - 1
|
||||
renderTarget: Canvas.Image
|
||||
renderStrategy: Canvas.Immediate
|
||||
// The fill has to follow the theme, and a Canvas does not repaint
|
||||
// on a colour binding by itself.
|
||||
property color fill: theme.surfaceElevated
|
||||
// The triangle points DOWN at a control below the bubble, and UP at
|
||||
// one above it. A Canvas repaints on neither of these by itself.
|
||||
property bool up: root.caretUp
|
||||
onFillChanged: caret.requestPaint()
|
||||
onUpChanged: caret.requestPaint()
|
||||
onVisibleChanged: if (visible) caret.requestPaint()
|
||||
onPaint: {
|
||||
var ctx = caret.getContext("2d")
|
||||
@@ -235,9 +426,15 @@ Item {
|
||||
ctx.clearRect(0, 0, caret.width, caret.height)
|
||||
ctx.fillStyle = caret.fill
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(caret.width / 2, caret.height)
|
||||
ctx.lineTo(caret.width, 0)
|
||||
if (caret.up) {
|
||||
ctx.moveTo(0, caret.height)
|
||||
ctx.lineTo(caret.width / 2, 0)
|
||||
ctx.lineTo(caret.width, caret.height)
|
||||
} else {
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(caret.width / 2, caret.height)
|
||||
ctx.lineTo(caret.width, 0)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
@@ -820,6 +820,29 @@ Rectangle {
|
||||
z: 3530
|
||||
}
|
||||
|
||||
// The applied-filters summary rides the bridge (shell_bridge.rs
|
||||
// `filter_tip_json`) because the funnels that raise it live deep inside
|
||||
// view toolbars and cannot name this overlay — the same reason, and the
|
||||
// same solution, as the art preview above. Numbers in, numbers out: a
|
||||
// toolbar that is destroyed while its bubble is up leaves nothing dangling.
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onFilterTipJsonChanged() {
|
||||
var d = {}
|
||||
try {
|
||||
d = JSON.parse(QbzShell.filterTipJson)
|
||||
} catch (e) {
|
||||
d = {}
|
||||
}
|
||||
if (!d || !d.key) {
|
||||
tooltipOverlay.hideAll()
|
||||
return
|
||||
}
|
||||
tooltipOverlay.showSummaryAt(d.key, d.x || 0, d.y || 0,
|
||||
d.w || 0, d.h || 0, d.groups || [])
|
||||
}
|
||||
}
|
||||
|
||||
// Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) —
|
||||
// mounted LAST, mirroring QconnectDevModal.slint's topmost mount in
|
||||
// AppShell.slint. Ordering against the tooltip above is a non-issue: this
|
||||
|
||||
@@ -115,9 +115,114 @@ Item {
|
||||
// last thing before the Loader instantiates.
|
||||
function _stampRoute(path) {
|
||||
root._routeT0 = Date.now()
|
||||
// Drop the page to transparent HERE and not in an onCurrentViewChanged
|
||||
// handler, for the same reason the stopwatch is stamped here: binding
|
||||
// re-evaluation and signal handlers have no guaranteed order, and a
|
||||
// drop applied after the Loader had already rebuilt would flash the new
|
||||
// page at full opacity, blank it, and fade it in again. Inside the
|
||||
// binding it is by construction the last thing that happens before the
|
||||
// instantiation.
|
||||
//
|
||||
// The guard matters: two ids can resolve to the SAME file
|
||||
// ("recentalbums" / "mostplayedalbums" both mount PlayHistoryView),
|
||||
// and then the Loader does not reload, `onLoaded` never fires and
|
||||
// nothing would ever bring the page back — a permanently invisible
|
||||
// pane. Same for the "" fall-through of an unmapped route.
|
||||
if (path !== root._fadePath) {
|
||||
root._fadePath = path
|
||||
if (path !== "" && !QbzShell.reduceMotion) {
|
||||
// Stop first: a second navigation inside the 300 ms would
|
||||
// otherwise have a running render-thread Animator writing the
|
||||
// property back from underneath this assignment.
|
||||
fadeIn.stop()
|
||||
viewLoader.opacity = 0
|
||||
fadeGuard.restart()
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --- Page fade ---------------------------------------------------------
|
||||
// COSMETIC, FIXED DURATION, AND IT NEVER WAITS FOR CONTENT. The route
|
||||
// change below is synchronous by design (read the Loader's header), so the
|
||||
// UI thread is frozen while the new view is built and there is no frame in
|
||||
// which a fade-OUT could be shown: the page therefore snaps to transparent
|
||||
// in the same turn as the rebuild — invisible, because nothing renders
|
||||
// during it — and only the REVEAL is animated. Net added latency: zero.
|
||||
// The content is built exactly as fast as it was before; what changed is
|
||||
// that its first frame arrives transparent and on its way in.
|
||||
//
|
||||
// (A real fade-OUT is possible and costs ~32 ms: latch the route, run an
|
||||
// Animator 1 -> 0, and let a Timer defer the swap by one frame so the
|
||||
// out-fade runs on the render thread DURING the freeze. It is not here
|
||||
// because the brief was "sobre todo no añadir latencia extra", and 32 ms
|
||||
// is not zero. The seam is one latched property away if that trade is ever
|
||||
// taken.)
|
||||
//
|
||||
// OpacityAnimator, not NumberAnimation, and this is the whole trick: an
|
||||
// Animator runs on the RENDER thread, so it keeps advancing while the GUI
|
||||
// thread is still paying off everything the mount deferred (layout, the
|
||||
// first delegate refills). Measured on this shell with the thread blocked:
|
||||
// OpacityAnimator advanced 0.53 where NumberAnimation advanced 0.11. The
|
||||
// shared QbzShell.pulseMs cannot serve this: the pulse arrives through
|
||||
// `ui(...)` onto the Qt event loop (shell_bridge.rs:966), i.e. the very
|
||||
// thread the mount is blocking, so a pulse-driven fade would freeze during
|
||||
// exactly the milliseconds it exists to cover.
|
||||
//
|
||||
// GPU doctrine (qt-frontend/2026-08-11-scenegraph-batches §9) forbids
|
||||
// CONTINUOUS animation off the shared pulse, because each dirty frame
|
||||
// presents the whole window at ~1.2% GPU. This one is bounded and
|
||||
// user-initiated: one navigation buys ~18 presents at 60 Hz, and at rest
|
||||
// it writes NOTHING (the Animator is stopped and opacity is a flat 1).
|
||||
property string _fadePath: ""
|
||||
// 300 ms. The contract's first proposal was 120-150 ms; at 140 the owner
|
||||
// could not see it at all. Part of that was the duration and part was a
|
||||
// real defect (see the note on WHAT fades, below) — 300 fixes the half
|
||||
// that is duration, and is still one bounded animation per navigation.
|
||||
property int fadeMs: 300
|
||||
|
||||
// WHAT FADES, and why this is the page and not a veil over it.
|
||||
//
|
||||
// The first cut of this faded a Rectangle laid over the content, coloured
|
||||
// from the pane it sits in. That is wrong here, and invisibly so: AppShell
|
||||
// paints the pane `surface-main` normally but `surface-main @ 0.22` while
|
||||
// the ambient background is on (AppShell.qml:324, QbzTheme.qml:38), and the
|
||||
// ambient background is on whenever a track is loaded (QbzTheme.qml:132).
|
||||
// So in the configuration the app actually runs in, a veil at FULL opacity
|
||||
// covered 22% of the page and the fade read as a flicker. Fading the page
|
||||
// itself has no such failure mode, needs no colour at all, and keeps the
|
||||
// ambient art visible underneath — which a veil opaque enough to work would
|
||||
// have blacked out for the length of the fade.
|
||||
//
|
||||
// No `layer.enabled`: group opacity would cost an FBO the size of the
|
||||
// content pane, allocated at the exact moment the mount is already the most
|
||||
// expensive thing on screen. Per-node alpha lets overlapping children show
|
||||
// through each other, which is the honest trade — and at 300 ms on a page
|
||||
// that is arriving rather than sitting still, it is not visible.
|
||||
//
|
||||
// reduceMotion (shell_bridge.rs:217) skips the whole thing: the page simply
|
||||
// appears, which is the behaviour before this block existed.
|
||||
|
||||
// Watchdog. `onLoaded` is the only thing that brings the page back, and an
|
||||
// invisible content pane is the worst failure this file can produce, so a
|
||||
// missed signal (a view that fails to instantiate, a source that resolves
|
||||
// to nothing) must not be able to leave it at zero.
|
||||
Timer {
|
||||
id: fadeGuard
|
||||
interval: 1500
|
||||
repeat: false
|
||||
onTriggered: viewLoader.opacity = 1
|
||||
}
|
||||
|
||||
OpacityAnimator {
|
||||
id: fadeIn
|
||||
target: viewLoader
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: root.fadeMs
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: viewLoader
|
||||
anchors.fill: parent
|
||||
@@ -222,6 +327,17 @@ Item {
|
||||
: root.kiosk && QbzShell.currentView === "nowplaying" ? "../kiosk/KioskNowPlaying.qml" : "")
|
||||
|
||||
onLoaded: {
|
||||
// The reveal starts in the same turn the view finished building,
|
||||
// i.e. before a single frame of it has been presented. The render
|
||||
// thread picks the Animator up at that first frame, so the fade is
|
||||
// exactly as long as it says it is no matter how busy the GUI
|
||||
// thread still is.
|
||||
fadeGuard.stop()
|
||||
if (QbzShell.reduceMotion) {
|
||||
viewLoader.opacity = 1
|
||||
} else {
|
||||
fadeIn.restart()
|
||||
}
|
||||
var built = Date.now() - root._routeT0
|
||||
console.info(navTiming, "[navtiming] " + QbzShell.currentView
|
||||
+ " built=" + built + "ms")
|
||||
|
||||
@@ -781,9 +781,24 @@ Rectangle {
|
||||
root.groupMenuOpen = false
|
||||
root.genrePopupOpen = !root.genrePopupOpen
|
||||
}
|
||||
onContainsMouseChanged: containsMouse
|
||||
? tips.showAbove(genreBtn, "scene-genre", root.tr("Filter by genre"))
|
||||
: tips.hide("scene-genre")
|
||||
// With genres ON, say WHICH — the badge only ever
|
||||
// showed a number. This view already owns a QbzTooltip
|
||||
// instance (`tips`), so it calls the summary mode
|
||||
// directly instead of going through the shell channel
|
||||
// that toolbars without one have to use.
|
||||
onContainsMouseChanged: {
|
||||
if (!containsMouse) {
|
||||
tips.hide("scene-genre")
|
||||
} else if (root.activeGenres.length > 0) {
|
||||
tips.showSummary(genreBtn, "scene-genre", [{
|
||||
group: root.tr("Genre"),
|
||||
values: root.activeGenres
|
||||
}])
|
||||
} else {
|
||||
tips.showAbove(genreBtn, "scene-genre",
|
||||
root.tr("Filter by genre"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,57 @@ Rectangle {
|
||||
|| (item ? (item.artPath || "") : "")
|
||||
}
|
||||
|
||||
// --- Qobuz Playlists: category filter ---------------------------------
|
||||
// Client-side over the 40 cached cards; the tags ride in the SAME
|
||||
// /discover/index response, so nothing here costs a request. The selection
|
||||
// itself lives in Rust (`home_qt`) because this view is destroyed on every
|
||||
// navigation — a property here would forget it the moment the user opened
|
||||
// an album and came back, where the reference survives (its selection lives
|
||||
// in TAB_SECTIONS and its comment says "survives a tab switch").
|
||||
readonly property var playlistTagDoc: {
|
||||
try {
|
||||
return JSON.parse(QbzHome.playlistTagsJson)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
readonly property var playlistTags: root.playlistTagDoc.tags || []
|
||||
readonly property var playlistTagSel: root.playlistTagDoc.selected || []
|
||||
/// The selected tags' display NAMES, for the applied-filters tooltip.
|
||||
readonly property var playlistTagNames: {
|
||||
var out = []
|
||||
var sel = root.playlistTagSel
|
||||
var all = root.playlistTags
|
||||
for (var i = 0; i < all.length; i++)
|
||||
if (sel.indexOf(all[i].slug) >= 0)
|
||||
out.push(all[i].name)
|
||||
return out
|
||||
}
|
||||
/// UNION, and an empty selection passes everything (home.rs:79-92).
|
||||
function playlistPasses(card) {
|
||||
if (root.playlistTagSel.length === 0)
|
||||
return true
|
||||
var t = (card && card.tags) || []
|
||||
for (var i = 0; i < t.length; i++)
|
||||
if (root.playlistTagSel.indexOf(t[i]) >= 0)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
function filterPlaylists(items) {
|
||||
var out = []
|
||||
var src = items || []
|
||||
for (var i = 0; i < src.length; i++)
|
||||
if (root.playlistPasses(src[i]))
|
||||
out.push(src[i])
|
||||
return out
|
||||
}
|
||||
// The popup is declared last (z-order); a QML id is document-scoped, so a
|
||||
// rail delegate cannot name it. Same bridge-function pattern the genre
|
||||
// popup already uses from the toolbar.
|
||||
function togglePlaylistTagPopup(btn) {
|
||||
plTagPopup.toggle(btn)
|
||||
}
|
||||
|
||||
/// The patch map first, then whatever the document happened to carry (a
|
||||
/// full Home reload publishes the paths it already has).
|
||||
function forYouArtOf(item) {
|
||||
@@ -1206,12 +1257,27 @@ Rectangle {
|
||||
// The rail used to draw a bare Text: no chevrons (so
|
||||
// a 40-playlist rail's tail was unreachable) and no
|
||||
// "View all". PlaylistCarousel.slint:109-115 has both.
|
||||
// POC-NOTE: the Slint arm ALSO puts a PlaylistTagFilter
|
||||
// on this title line (HomeView.slint:360-374) and shows
|
||||
// @tr("No playlists match the selected categories.")
|
||||
// when the tags filter everything out. Neither is
|
||||
// ported here — the tag filter DOES exist on the
|
||||
// PlaylistBrowse page this link opens.
|
||||
//
|
||||
// The CATEGORY FILTER on this title line
|
||||
// (HomeView.slint:360-374) is now ported too, and with
|
||||
// it the empty-result line. The POC-NOTE that used to
|
||||
// sit here said neither was, and pointed at the
|
||||
// PlaylistBrowse page as the substitute — that page's
|
||||
// tag filter is a DIFFERENT one (single-select and
|
||||
// server-side), so the note was directing the reader to
|
||||
// a control that does not do this job.
|
||||
//
|
||||
// Only the Qobuz-Playlists rail offers it: it is the
|
||||
// only playlist section the index ships tags for, and
|
||||
// the button hides itself when the set is empty.
|
||||
readonly property bool tagsOffered:
|
||||
plRail.sectionData.id === "qobuzPlaylists"
|
||||
&& root.playlistTags.length > 0
|
||||
readonly property var visibleItems:
|
||||
root.playlistTagSel.length > 0 && plRail.tagsOffered
|
||||
? root.filterPlaylists(plRail.sectionData.items)
|
||||
: plRail.sectionData.items
|
||||
|
||||
QbzSectionHeader {
|
||||
title: plRail.sectionData.title
|
||||
leftEnabled: plList.contentX > 1
|
||||
@@ -1220,16 +1286,39 @@ Rectangle {
|
||||
onViewAllClicked: root.openViewAll(plRail.sectionData, plRail.tabId)
|
||||
onPageLeft: plList.contentX = Math.max(0, plList.contentX - plRail.step)
|
||||
onPageRight: plList.contentX = Math.min(plRail.maxScroll, plList.contentX + plRail.step)
|
||||
leading: plRail.tagsOffered ? plTagFilterComp : null
|
||||
}
|
||||
Component {
|
||||
id: plTagFilterComp
|
||||
PlaylistTagFilterButton {
|
||||
count: root.playlistTagSel.length
|
||||
selectedNames: root.playlistTagNames
|
||||
onClicked: root.togglePlaylistTagPopup(this)
|
||||
}
|
||||
}
|
||||
// Zero results with a filter on: say so, instead of
|
||||
// showing an empty band the user cannot explain
|
||||
// (HomeView.slint:393-397). The rail stays mounted so
|
||||
// the filter is still reachable to clear.
|
||||
Text {
|
||||
visible: plRail.visibleItems.length === 0
|
||||
&& root.playlistTagSel.length > 0
|
||||
width: parent.width
|
||||
text: QbzSession.tr("No playlists match the selected categories.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
ListView {
|
||||
id: plList
|
||||
visible: plRail.visibleItems.length > 0
|
||||
width: parent.width
|
||||
height: 246
|
||||
height: visible ? 246 : 0
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: plRail.sectionData.items
|
||||
model: plRail.visibleItems
|
||||
delegate: PlaylistCard {
|
||||
item: modelData
|
||||
artSource: root.sectionArtOf(modelData)
|
||||
@@ -1726,4 +1815,20 @@ Rectangle {
|
||||
id: configModal
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
// Qobuz-Playlists category filter. Its trigger lives inside the flickable
|
||||
// at a y that depends on the scroll and on the rail order, so the popup
|
||||
// captures the anchor by mapToItem AT OPEN rather than pinning to a fixed
|
||||
// offset the way the genre popup can.
|
||||
PlaylistTagFilterPopup {
|
||||
id: plTagPopup
|
||||
anchors.fill: parent
|
||||
tags: root.playlistTags
|
||||
selected: root.playlistTagSel
|
||||
onToggled: function (slug) { QbzHome.togglePlaylistTag(slug) }
|
||||
onCleared: {
|
||||
QbzHome.clearPlaylistTags()
|
||||
plTagPopup.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,28 @@ Rectangle {
|
||||
/// declared LAST in this file and cannot be named from a child component.
|
||||
function toggleGenrePopup() { libGenrePopup.toggle() }
|
||||
|
||||
/// The All tab's filters as the applied-filters tooltip wants them: the
|
||||
/// selected genres, plus the four SOURCE switches — but only when one is
|
||||
/// OFF, because all four on is the default and "everything is shown" is not
|
||||
/// a filter worth naming. Genres come back in display casing here (the
|
||||
/// lowercased copy above exists for matching, not for reading).
|
||||
readonly property var filterSummaryGroups: {
|
||||
var tr = QbzSession.trRev
|
||||
var hidden = []
|
||||
if (!root.showFavorites)
|
||||
hidden.push(QbzSession.tr("Favorites", tr))
|
||||
if (!root.showPurchases)
|
||||
hidden.push(QbzSession.tr("Purchases", tr))
|
||||
if (!root.showFollowing)
|
||||
hidden.push(QbzSession.tr("Following", tr))
|
||||
if (!root.showLocal)
|
||||
hidden.push(QbzSession.tr("Local", tr))
|
||||
var out = []
|
||||
if (hidden.length > 0)
|
||||
out.push({ group: QbzSession.tr("Hidden", tr), values: hidden })
|
||||
return out
|
||||
}
|
||||
|
||||
// Other-tab state.
|
||||
property string tabSearch: ""
|
||||
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc
|
||||
|
||||
@@ -116,6 +116,39 @@ Rectangle {
|
||||
}
|
||||
function clearFilter() { filter = ({}) }
|
||||
|
||||
/// The funnel's state as the applied-filters tooltip wants it: three groups
|
||||
/// in the popup's own order, each holding the labels of the keys that are
|
||||
/// on. Built HERE because this is where the state lives — the toolbar only
|
||||
/// draws the trigger, and `LocalFilterPopup` only draws chips.
|
||||
///
|
||||
/// The labels are the popup's, verbatim, so the bubble reads exactly like
|
||||
/// the control the user set. Two are not translated there either: FLAC /
|
||||
/// ALAC / APE / WAV / MP3 / AAC and "Plex" are proper names.
|
||||
readonly property var filterSummaryGroups: {
|
||||
function pick(keys, labels) {
|
||||
var out = []
|
||||
for (var i = 0; i < keys.length; i++)
|
||||
if (root.filter[keys[i]] === true)
|
||||
out.push(labels[i])
|
||||
return out
|
||||
}
|
||||
var tr = QbzSession.trRev
|
||||
return [
|
||||
{ group: QbzSession.tr("Quality", tr),
|
||||
values: pick(["hires", "cd", "lossy"],
|
||||
[QbzSession.tr("Hi-Res", tr), QbzSession.tr("CD", tr),
|
||||
QbzSession.tr("Lossy", tr)]) },
|
||||
{ group: QbzSession.tr("Format", tr),
|
||||
values: pick(["flac", "alac", "ape", "wav", "mp3", "aac", "other"],
|
||||
["FLAC", "ALAC", "APE", "WAV", "MP3", "AAC",
|
||||
QbzSession.tr("Other", tr)]) },
|
||||
{ group: QbzSession.tr("Source", tr),
|
||||
values: pick(["local", "offline", "plex"],
|
||||
[QbzSession.tr("Local", tr),
|
||||
QbzSession.tr("Offline cache", tr), "Plex"]) }
|
||||
]
|
||||
}
|
||||
|
||||
// Per-row artwork on the track lists — the Slint gates this on
|
||||
// AppearanceState.local-library-track-artwork for the freeze reason and
|
||||
// its default is OFF.
|
||||
|
||||
@@ -136,6 +136,11 @@ Rectangle {
|
||||
/// only affordances and, for the reorder below, it changes WHICH sort is
|
||||
/// the reorderable one.
|
||||
readonly property bool isLocal: doc.isLocalPlaylist === true
|
||||
// A MIXED playlist: a QOBUZ playlist carrying local-file and/or Plex rows
|
||||
// from the `library.db` sidecar tables. It is NOT `isLocal` and must not be
|
||||
// conflated with it — it keeps every Qobuz affordance (owner, follow, copy,
|
||||
// share, the heart). The one thing it loses is reorder; see `canReorder`.
|
||||
readonly property bool isMixed: doc.isMixed === true
|
||||
/// A Qobuz WRITE can be attempted. `QbzSession.offlineMode` is the shared
|
||||
/// tri-state (0 = online, 1 = no connection, 2 = induced offline — the same
|
||||
/// property `shell/HeaderBar.qml:101` and `controls/QbzOfflinePlaceholder`
|
||||
@@ -157,8 +162,17 @@ Rectangle {
|
||||
// filter is active would hand a filtered index to a document index — the
|
||||
// Playlist Manager takes the same precaution (`playlist_manager_qt.rs:236`
|
||||
// requires `query.is_empty()` before it lights its arrows).
|
||||
//
|
||||
// MIXED playlists are excluded outright, and this is a data-integrity gate
|
||||
// rather than a UX one: a Qobuz playlist's custom order is stored keyed by
|
||||
// `u64` catalog id, while a local row's display id is a `library.db` rowid
|
||||
// in the same numeric space, so a reorder here would write an order that
|
||||
// collides with a real track and moves the WRONG row on the next load.
|
||||
// Rust refuses it too (main.rs `playlist_reorder` / `playlist_move_row`) —
|
||||
// this only keeps the app from offering something it will not do.
|
||||
readonly property bool canReorder: root.isOwner
|
||||
&& root.searchQuery.trim() === ""
|
||||
&& !root.isMixed
|
||||
&& (root.isLocal ? root.sortField === "default" : root.sortField === "custom")
|
||||
|
||||
// --- Settled state from Rust (`libraryFavoriteChanged` / `pinChanged`) -
|
||||
|
||||
@@ -103,7 +103,32 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.view.toggleGenrePopup()
|
||||
onClicked: {
|
||||
libFilterTip.exit()
|
||||
root.view.toggleGenrePopup()
|
||||
}
|
||||
onEntered: libFilterTip.enter()
|
||||
onExited: libFilterTip.exit()
|
||||
}
|
||||
|
||||
// The genre chip is the toolbar's filter affordance, so it carries the
|
||||
// whole bar's summary: the genres it owns, plus the source switches
|
||||
// that live next to it and are just as invisible once set.
|
||||
QbzFilterTip {
|
||||
id: libFilterTip
|
||||
ownerKey: "library-all-filter"
|
||||
anchor: gtb
|
||||
groups: {
|
||||
var out = []
|
||||
var names = (root.view.genreDoc.names || {})["library-all"] || []
|
||||
if (names.length > 0)
|
||||
out.push({ group: QbzSession.tr("Genre", QbzSession.trRev),
|
||||
values: names })
|
||||
var ex = root.view.filterSummaryGroups || []
|
||||
for (var i = 0; i < ex.length; i++)
|
||||
out.push(ex[i])
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,15 +170,30 @@ Row {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.view.filterOpen = !root.view.filterOpen
|
||||
onClicked: {
|
||||
localFilterTip.exit()
|
||||
root.view.filterOpen = !root.view.filterOpen
|
||||
}
|
||||
onEntered: localFilterTip.enter()
|
||||
onExited: localFilterTip.exit()
|
||||
}
|
||||
}
|
||||
// WHAT is filtered, not just that something is. The funnel is a
|
||||
// 10-key one and its badge only ever said a number, so a user
|
||||
// coming back to a session had to open the popup to find out what
|
||||
// they had set.
|
||||
QbzFilterTip {
|
||||
id: localFilterTip
|
||||
ownerKey: "local-albums-filter"
|
||||
anchor: filtArea
|
||||
groups: root.view.filterSummaryGroups
|
||||
}
|
||||
// The plain one-line tip stays for the UNFILTERED case: with
|
||||
// nothing applied there is no summary to show, and "Quality, format
|
||||
// and source filters" is what the control needs to say then.
|
||||
LocalTip {
|
||||
visible: filtArea.containsMouse
|
||||
text: root.view.filterCount > 0
|
||||
? QbzSession.tr("Filters", QbzSession.trRev)
|
||||
+ " (" + root.view.filterCount + ")"
|
||||
: QbzSession.tr("Quality, format and source filters", QbzSession.trRev)
|
||||
visible: filtArea.containsMouse && !localFilterTip.hasSummary
|
||||
text: QbzSession.tr("Quality, format and source filters", QbzSession.trRev)
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.view.filterCount > 0
|
||||
|
||||
@@ -55,6 +55,28 @@ Item {
|
||||
+ (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
|
||||
|
||||
@@ -389,7 +411,19 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: filterMenu.openBelowRight(filterBtn)
|
||||
onClicked: {
|
||||
purchasesFilterTip.exit()
|
||||
filterMenu.openBelowRight(filterBtn)
|
||||
}
|
||||
onEntered: purchasesFilterTip.enter()
|
||||
onExited: purchasesFilterTip.exit()
|
||||
}
|
||||
|
||||
QbzFilterTip {
|
||||
id: purchasesFilterTip
|
||||
ownerKey: "purchases-filter"
|
||||
anchor: filterBtn
|
||||
groups: root.filterSummaryGroups
|
||||
}
|
||||
|
||||
QbzContextMenu {
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
//! opening an empty modal (HomeView.qml).
|
||||
|
||||
use qbz_app::settings::discover_prefs::{
|
||||
DiscoverPrefs, DiscoverPrefsStore, DiscoverySectionId, DiscoveryTab,
|
||||
DiscoverPrefs, DiscoverPrefsStore, DiscoverySectionId, DiscoveryTab, DEFAULT_RAIL_SIZE,
|
||||
RAIL_SIZE_PRESETS,
|
||||
};
|
||||
use cxx_qt_lib::QString;
|
||||
use serde::Serialize;
|
||||
@@ -61,6 +62,7 @@ fn label_for(id: DiscoverySectionId) -> &'static str {
|
||||
ArtistSpotlight => qbz_i18n::mark("Artist Spotlight"),
|
||||
Pinned => qbz_i18n::mark("Pinned"),
|
||||
MostPlayedAlbums => qbz_i18n::mark("Most Played Albums"),
|
||||
RecentlyPlayedPlaylists => qbz_i18n::mark("Recently Played Playlists"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +89,80 @@ pub(crate) fn store() -> Option<DiscoverPrefsStore> {
|
||||
.and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Items per rail
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Tauri had this and Slint never got it: `HomeSettingsModal.svelte` let the
|
||||
// user say how many items each carousel showed, over `homeSettingsStore.ts`.
|
||||
// The modal that replaced it in the discovery-v2 rewrite
|
||||
// (`DiscoverySettingsModal.svelte`) only ever modelled `{id, enabled}`, the
|
||||
// Slint port took THAT modal 1:1, and so it copied the absence — which Qt then
|
||||
// inherited from Slint. This is the recovery.
|
||||
//
|
||||
// It is GLOBAL, not per rail. Tauri had six keys but five of its rails already
|
||||
// shared one, and a per-rail knob would put twenty spinners in a modal whose
|
||||
// job is to be glanced at.
|
||||
|
||||
/// The stored cap, `0` = uncapped. Falls back to the default with no store
|
||||
/// (nobody logged in yet), which is also the uncapped value — so the page
|
||||
/// behaves exactly as it did before this setting existed until someone
|
||||
/// deliberately changes it.
|
||||
pub(crate) fn rail_size() -> i64 {
|
||||
store().map(|s| s.load_rail_size()).unwrap_or(DEFAULT_RAIL_SIZE)
|
||||
}
|
||||
|
||||
/// Stored size -> select index. The index IS the contract with the option
|
||||
/// order in `DiscoverConfigModal.qml`; see `RAIL_SIZE_PRESETS`.
|
||||
pub(crate) fn rail_size_index() -> i32 {
|
||||
let n = rail_size();
|
||||
RAIL_SIZE_PRESETS
|
||||
.iter()
|
||||
.position(|&p| p == n)
|
||||
.unwrap_or_else(|| {
|
||||
RAIL_SIZE_PRESETS
|
||||
.iter()
|
||||
.position(|&p| p == DEFAULT_RAIL_SIZE)
|
||||
.unwrap_or(0)
|
||||
}) as i32
|
||||
}
|
||||
|
||||
/// Configurator select -> persist -> re-render the tabs -> republish the
|
||||
/// RESOLVED index.
|
||||
///
|
||||
/// Resolved, not the index that came in — the same honesty the reco cache
|
||||
/// window buys with the same line: if the write fails (no per-user store), the
|
||||
/// select snaps back to what is actually stored instead of displaying a size
|
||||
/// nobody saved.
|
||||
///
|
||||
/// `republish_cached()` and not a reload: the cut happens at assembly time over
|
||||
/// the candidates already in memory, so a new size lands on the next frame with
|
||||
/// no network round trip — exactly like a section toggle.
|
||||
pub(crate) fn set_rail_size_index(index: i32) {
|
||||
let idx = index.clamp(0, RAIL_SIZE_PRESETS.len() as i32 - 1) as usize;
|
||||
let size = RAIL_SIZE_PRESETS[idx];
|
||||
let Some(store) = store() else {
|
||||
log::warn!("[qbz-qt] discover rail size: no per-user prefs store (not logged in?)");
|
||||
publish_rail_size_index();
|
||||
return;
|
||||
};
|
||||
if let Err(e) = store.save_rail_size(size) {
|
||||
log::warn!("[qbz-qt] discover rail size: save failed: {e}");
|
||||
publish_rail_size_index();
|
||||
return;
|
||||
}
|
||||
log::info!("[qbz-qt] discover rail size set to {size} (0 = all)");
|
||||
crate::home_qt::republish_cached();
|
||||
publish_rail_size_index();
|
||||
}
|
||||
|
||||
/// Push the stored size onto the bridge as its select index. Called at boot
|
||||
/// (so the select opens on the right entry the FIRST time) and after a write.
|
||||
pub(crate) fn publish_rail_size_index() {
|
||||
let index = rail_size_index();
|
||||
crate::home_bridge::ui(move |mut b| b.as_mut().set_discover_rail_size_index(index));
|
||||
}
|
||||
|
||||
/// The tab's ordered pref ids, narrowed to what this port renders.
|
||||
fn visible_ids(prefs: &DiscoverPrefs, tab: DiscoveryTab) -> Vec<DiscoverySectionId> {
|
||||
let renderable = crate::home_qt::renderable_ids(tab);
|
||||
|
||||
@@ -86,6 +86,22 @@ pub mod qbz_home {
|
||||
/// lets it open on the stored entry without QML owning the mapping.
|
||||
/// Seeded at boot from the shared per-user discover prefs.
|
||||
#[qproperty(i32, reco_cache_ttl_index)]
|
||||
/// Configurator > "Items per carousel", as the select's INDEX into
|
||||
/// `discover_prefs::RAIL_SIZE_PRESETS` rather than the count, for the
|
||||
/// same reason as the window above: the control is a fixed option list
|
||||
/// and QML must not own the mapping. GLOBAL (not per tab), which is why
|
||||
/// it rides its own property instead of the per-tab config document —
|
||||
/// the modal has to be able to read it before the tab payload lands.
|
||||
#[qproperty(i32, discover_rail_size_index)]
|
||||
/// The Qobuz-Playlists rail's category filter, as ONE small document:
|
||||
/// `{ tags: [{slug, name}], selected: ["slug", ...] }`.
|
||||
///
|
||||
/// Its own property rather than a field on `homeSectionsJson`, and this
|
||||
/// is the load-bearing part: republishing the sections document
|
||||
/// destroys and rebuilds every rail's QQmlDelegateModel and resets
|
||||
/// their horizontal scroll (see the note at the top of this file). A
|
||||
/// toggle has to be able to move ~40 bytes without doing that.
|
||||
#[qproperty(QString, playlist_tags_json)]
|
||||
// --- "View all" full-list pages (src/browse_qt.rs) ----------------
|
||||
#[qproperty(QString, discover_browse_json)]
|
||||
#[qproperty(bool, discover_browse_loading)]
|
||||
@@ -134,6 +150,19 @@ pub mod qbz_home {
|
||||
/// a window nobody stored.
|
||||
#[qinvokable]
|
||||
fn reco_set_cache_ttl_index(self: Pin<&mut QbzHome>, index: i32);
|
||||
/// Configurator > "Items per carousel": an index into
|
||||
/// `RAIL_SIZE_PRESETS` (last entry = no cap). Persists to its own table
|
||||
/// in the shared per-user discover prefs file and republishes the
|
||||
/// RESOLVED index.
|
||||
#[qinvokable]
|
||||
fn discover_set_rail_size(self: Pin<&mut QbzHome>, index: i32);
|
||||
/// Toggle one Qobuz-Playlists category tag by slug. Client-side over
|
||||
/// the cached cards; an empty selection shows everything.
|
||||
#[qinvokable]
|
||||
fn toggle_playlist_tag(self: Pin<&mut QbzHome>, slug: QString);
|
||||
/// "All categories" — drop the whole selection.
|
||||
#[qinvokable]
|
||||
fn clear_playlist_tags(self: Pin<&mut QbzHome>);
|
||||
/// Configurator > "Refresh now": rebuild every row, bypassing the
|
||||
/// results blob. The engine still honours its own per-week
|
||||
/// ListenBrainz cache, so this is not a way to hammer that service.
|
||||
@@ -342,6 +371,8 @@ pub struct QbzHomeRust {
|
||||
reco_weekly_json: QString,
|
||||
reco_loading: bool,
|
||||
reco_cache_ttl_index: i32,
|
||||
discover_rail_size_index: i32,
|
||||
playlist_tags_json: QString,
|
||||
discover_browse_json: QString,
|
||||
discover_browse_loading: bool,
|
||||
discover_browse_loading_more: bool,
|
||||
@@ -380,6 +411,12 @@ impl Default for QbzHomeRust {
|
||||
// by `recommendations_qt::publish_cache_ttl_index()` at boot, once
|
||||
// the per-user directory is known.
|
||||
reco_cache_ttl_index: 2,
|
||||
// The uncapped preset's index; the real value is pushed by
|
||||
// `discover_config_qt::publish_rail_size_index()` at boot, once the
|
||||
// per-user directory is known.
|
||||
discover_rail_size_index: 4,
|
||||
// "{}" so the view's JSON.parse never throws on the first frame.
|
||||
playlist_tags_json: QString::from("{}"),
|
||||
reco_loading: false,
|
||||
// "{}" so the views' JSON.parse never throws on the first frame.
|
||||
discover_browse_json: QString::from("{}"),
|
||||
@@ -453,6 +490,18 @@ impl qbz_home::QbzHome {
|
||||
crate::recommendations_qt::set_cache_ttl_index(index);
|
||||
}
|
||||
|
||||
pub fn discover_set_rail_size(self: Pin<&mut Self>, index: i32) {
|
||||
crate::discover_config_qt::set_rail_size_index(index);
|
||||
}
|
||||
|
||||
pub fn toggle_playlist_tag(self: Pin<&mut Self>, slug: QString) {
|
||||
crate::home_qt::toggle_playlist_tag(&slug.to_string());
|
||||
}
|
||||
|
||||
pub fn clear_playlist_tags(self: Pin<&mut Self>) {
|
||||
crate::home_qt::clear_playlist_tags();
|
||||
}
|
||||
|
||||
pub fn reco_refresh_now(self: Pin<&mut Self>) {
|
||||
crate::recommendations_qt::refresh();
|
||||
}
|
||||
|
||||
@@ -120,6 +120,17 @@ pub struct HomeCard {
|
||||
pub item_kind: String,
|
||||
/// Playlist rows: the UPPERCASE first-tag category subtag.
|
||||
pub category: String,
|
||||
/// Playlist rows: ALL of the playlist's tag SLUGS — the material the
|
||||
/// client-side category filter matches against (home.rs
|
||||
/// `PlaylistCardData.tags`). Distinct from `category`, which is one tag's
|
||||
/// display name in caps for the card's eyebrow.
|
||||
///
|
||||
/// Off the wire when empty, like `source` / `subtitle` / `plays` above:
|
||||
/// every rail shares this one struct, and without the guard some two
|
||||
/// thousand album cards would each carry a useless `"tags":[]` across the
|
||||
/// three tab documents.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(rename = "artUrl")]
|
||||
pub art_url: String,
|
||||
/// `file://<cached path>` when already on disk ("" = needs download).
|
||||
@@ -149,6 +160,14 @@ pub struct HomeSection {
|
||||
pub items: Vec<HomeCard>,
|
||||
}
|
||||
|
||||
/// One offered playlist category tag. `name` arrives already localized from
|
||||
/// the discover index.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct PlaylistTagRow {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Push one album-carousel section from its discover container (drops
|
||||
/// empty/missing containers, like home.rs `push_section`).
|
||||
fn push_albums(
|
||||
@@ -353,6 +372,10 @@ fn build_candidates(
|
||||
});
|
||||
|
||||
// Qobuz Playlists row (single-cover cards, first-tag category subtag).
|
||||
//
|
||||
// 40 cards, not the API's 18: the category filter below is CLIENT-SIDE and
|
||||
// needs material to work with — the reference raised the same number for
|
||||
// the same reason (home.rs:295-301).
|
||||
let playlists: Vec<HomeCard> = containers
|
||||
.playlists
|
||||
.map(|c| c.data.items)
|
||||
@@ -361,6 +384,19 @@ fn build_candidates(
|
||||
.take(40)
|
||||
.map(map_playlist)
|
||||
.collect();
|
||||
// The offered category tags ride in the SAME /discover/index response as
|
||||
// the cards (container `playlists_tags`), so the whole filter costs zero
|
||||
// extra round trips — which is why it can be applied live over the cache.
|
||||
let playlist_tags: Vec<PlaylistTagRow> = containers
|
||||
.playlists_tags
|
||||
.map(|c| c.data.items)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|t| PlaylistTagRow {
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
})
|
||||
.collect();
|
||||
if !playlists.is_empty() {
|
||||
out.push(HomeSection {
|
||||
id: "qobuzPlaylists".to_string(),
|
||||
@@ -373,6 +409,14 @@ fn build_candidates(
|
||||
items: playlists,
|
||||
});
|
||||
}
|
||||
// The tag set does NOT ride on the section, and that is deliberate: it
|
||||
// would force `tags: Vec::new()` into twenty-three unrelated HomeSection
|
||||
// literals, and the SELECTION needs a bridge property of its own anyway
|
||||
// (it is mutated per click and must not republish `homeSectionsJson` —
|
||||
// doing that destroys and rebuilds every rail's QQmlDelegateModel and
|
||||
// resets their horizontal scroll, see home_bridge.rs:49-58). One small
|
||||
// document carries both halves.
|
||||
set_playlist_tag_catalog(playlist_tags);
|
||||
|
||||
// Recently-played rails — the SHARED local history file
|
||||
// (`recently_qt`, the same `recently_played.json` the Slint build
|
||||
@@ -404,6 +448,39 @@ fn build_candidates(
|
||||
items: recent_albums,
|
||||
});
|
||||
}
|
||||
// Recently Played PLAYLISTS — the rail that exists because its album
|
||||
// sibling above used to answer for it. Every track start wrote its ALBUM
|
||||
// into the recent history whatever the user had actually put on, so one
|
||||
// 40-track playlist filled a 24-entry album rail with that playlist's
|
||||
// contents and the playlist itself was recorded nowhere. Plays carry their
|
||||
// context now (`recently_qt::record_queue_track` reads
|
||||
// `QueueTrack::context_kind`), the album rail takes only album plays, and
|
||||
// the playlist plays land here.
|
||||
let recent_playlists: Vec<HomeCard> =
|
||||
qbz_app::settings::playlist_play_history::recent_playlists(24)
|
||||
.into_iter()
|
||||
.map(map_recent_playlist)
|
||||
.collect();
|
||||
if recent_playlists.is_empty() {
|
||||
out.push(HomeSection {
|
||||
id: "recentlyPlayedPlaylists".to_string(),
|
||||
title: qbz_i18n::t("Recently Played Playlists"),
|
||||
kind: "recentPlaceholder".to_string(),
|
||||
hint: qbz_i18n::t("Playlists you play will appear here."),
|
||||
endpoint: String::new(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
} else {
|
||||
out.push(HomeSection {
|
||||
id: "recentlyPlayedPlaylists".to_string(),
|
||||
title: qbz_i18n::t("Recently Played Playlists"),
|
||||
kind: "playlist".to_string(),
|
||||
hint: String::new(),
|
||||
endpoint: String::new(),
|
||||
items: recent_playlists,
|
||||
});
|
||||
}
|
||||
|
||||
let recent_tracks: Vec<HomeCard> = crate::recently_qt::load_tracks()
|
||||
.into_iter()
|
||||
.take(24)
|
||||
@@ -634,6 +711,92 @@ fn order_by_prefs(
|
||||
gated
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Qobuz Playlists — client-side category filter
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The rail's tags were dropped in the port: `map_playlist` kept only the first
|
||||
// tag's NAME in caps for the card eyebrow and threw the slugs away, and the
|
||||
// index's `playlists_tags` container was never read. So the filter the Slint
|
||||
// build has over this rail (`discover/PlaylistTagFilter.slint`) had nothing to
|
||||
// stand on.
|
||||
//
|
||||
// It is a CLIENT-side filter over the cached 40 cards — the tags ship in the
|
||||
// same /discover/index response as the cards themselves, so no round trip — and
|
||||
// the selection is a UNION: a card passes if it carries ANY selected slug, and
|
||||
// an empty selection passes everything (home.rs:79-92).
|
||||
//
|
||||
// WHY THE SELECTION LIVES IN RUST. `HomeView.qml` is destroyed on every
|
||||
// navigation (the router's Loader rebuilds it), so a `property var` on its root
|
||||
// would forget the selection the moment the user visited an album and came
|
||||
// back. Slint keeps it in `TAB_SECTIONS` for exactly this reason and calls it
|
||||
// out: "Client-side; survives a tab switch." Parity, not decoration.
|
||||
|
||||
/// The tags the index offered, and the slugs currently selected. ONE document
|
||||
/// so QML parses once; ~40 bytes of selection, so a toggle notifies only the
|
||||
/// filter and never the rails.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
struct PlaylistTagDoc {
|
||||
tags: Vec<PlaylistTagRow>,
|
||||
selected: Vec<String>,
|
||||
}
|
||||
|
||||
static PLAYLIST_TAGS: Mutex<Vec<PlaylistTagRow>> = Mutex::new(Vec::new());
|
||||
static PLAYLIST_TAG_SEL: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
/// A FRESH index load replaces the offered set and RESETS the selection — the
|
||||
/// available tags may have changed, and keeping a slug the new set no longer
|
||||
/// offers would filter the rail down to nothing with no way to see why
|
||||
/// (home.rs:1097-1104 resets it for the same reason).
|
||||
fn set_playlist_tag_catalog(tags: Vec<PlaylistTagRow>) {
|
||||
if let Ok(mut t) = PLAYLIST_TAGS.lock() {
|
||||
*t = tags;
|
||||
}
|
||||
if let Ok(mut sel) = PLAYLIST_TAG_SEL.lock() {
|
||||
sel.clear();
|
||||
}
|
||||
publish_playlist_tags();
|
||||
}
|
||||
|
||||
fn publish_playlist_tags() {
|
||||
let doc = PlaylistTagDoc {
|
||||
tags: PLAYLIST_TAGS.lock().map(|t| t.clone()).unwrap_or_default(),
|
||||
selected: PLAYLIST_TAG_SEL.lock().map(|s| s.clone()).unwrap_or_default(),
|
||||
};
|
||||
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
|
||||
crate::home_bridge::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_playlist_tags_json(cxx_qt_lib::QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle one category slug. 1:1 `home.rs::toggle_playlist_tag`.
|
||||
pub(crate) fn toggle_playlist_tag(slug: &str) {
|
||||
if let Ok(mut sel) = PLAYLIST_TAG_SEL.lock() {
|
||||
match sel.iter().position(|s| s == slug) {
|
||||
Some(i) => {
|
||||
sel.remove(i);
|
||||
}
|
||||
None => sel.push(slug.to_string()),
|
||||
}
|
||||
}
|
||||
publish_playlist_tags();
|
||||
}
|
||||
|
||||
/// "All categories" — an empty selection shows every playlist.
|
||||
pub(crate) fn clear_playlist_tags() {
|
||||
if let Ok(mut sel) = PLAYLIST_TAG_SEL.lock() {
|
||||
sel.clear();
|
||||
}
|
||||
publish_playlist_tags();
|
||||
}
|
||||
|
||||
/// Re-push the document without changing it — for the boot seed and for a
|
||||
/// cached re-render, so the filter opens on the live state the first time.
|
||||
pub(crate) fn republish_playlist_tags() {
|
||||
publish_playlist_tags();
|
||||
}
|
||||
|
||||
/// The persisted per-tab section prefs (order + visibility) — the store the
|
||||
/// Discover configurator mutates (`discover_config_qt`).
|
||||
pub(crate) fn load_prefs() -> qbz_app::settings::discover_prefs::DiscoverPrefs {
|
||||
@@ -665,11 +828,31 @@ fn assemble(
|
||||
.into_iter()
|
||||
.filter(|s| s.kind != "recentPlaceholder")
|
||||
.collect();
|
||||
DiscoverSections {
|
||||
// "Items per carousel" (the Discover configurator). ONE read per assemble,
|
||||
// applied here rather than where the candidates are built — the point of
|
||||
// putting it at assembly time is that changing the number re-renders from
|
||||
// the cache on the next frame, exactly like a section toggle, instead of
|
||||
// forcing a round trip to /discover/index every time somebody nudges it.
|
||||
//
|
||||
// `0` = uncapped, which is the default and is what this page did before the
|
||||
// setting existed. It reaches the rails that go through `assemble`, i.e.
|
||||
// every rail the configurator can list; Pinned, Radio Stations and Qobuz
|
||||
// Mixes ride their own bridge documents and are untouched.
|
||||
let mut out = DiscoverSections {
|
||||
home,
|
||||
editor,
|
||||
for_you,
|
||||
};
|
||||
let cap = crate::discover_config_qt::rail_size();
|
||||
if cap > 0 {
|
||||
let cap = cap as usize;
|
||||
for list in [&mut out.home, &mut out.editor, &mut out.for_you] {
|
||||
for section in list.iter_mut() {
|
||||
section.items.truncate(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The SET of section ids the POC can actually RENDER for a tab — the
|
||||
@@ -767,6 +950,12 @@ pub(crate) fn republish_cached() {
|
||||
missing.extend(crate::artwork_qt::attach_cached(&mut sections.for_you));
|
||||
missing.dedup();
|
||||
publish(§ions);
|
||||
// The rail's category filter reads its own tiny property, which nothing in
|
||||
// this path touches. Re-pushing it keeps a re-rendered Home showing the
|
||||
// selection that is actually in force — and it is the only writer the
|
||||
// filter has, so if the property were ever reset (a rebuilt bridge) this is
|
||||
// where it comes back.
|
||||
republish_playlist_tags();
|
||||
if !missing.is_empty() {
|
||||
crate::spawn(async move {
|
||||
crate::artwork_qt::download_missing(missing).await;
|
||||
@@ -1052,6 +1241,15 @@ pub(crate) fn map_playlist(p: DiscoverPlaylist) -> HomeCard {
|
||||
.and_then(|t| t.first())
|
||||
.map(|t| t.name.to_uppercase())
|
||||
.unwrap_or_default();
|
||||
// Every slug, for the client-side category filter. The eyebrow above wants
|
||||
// one localized NAME in caps; the filter wants all the SLUGS — same source,
|
||||
// two different derivations, and conflating them is why the port shipped
|
||||
// without a filter to begin with.
|
||||
let tags: Vec<String> = p
|
||||
.tags
|
||||
.as_ref()
|
||||
.map(|t| t.iter().map(|tag| tag.slug.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
HomeCard {
|
||||
// Pin badge state from the per-user store (home.rs
|
||||
// `playlist_to_item`). Without the seed the rail's glyph reads
|
||||
@@ -1073,6 +1271,7 @@ pub(crate) fn map_playlist(p: DiscoverPlaylist) -> HomeCard {
|
||||
id: p.id.to_string(),
|
||||
title: p.name,
|
||||
category,
|
||||
tags,
|
||||
art_url,
|
||||
..HomeCard::default()
|
||||
}
|
||||
@@ -1336,6 +1535,48 @@ pub(crate) fn map_recent_album(a: crate::recently_qt::RecentAlbum) -> HomeCard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one recently-played PLAYLIST onto a playlist card.
|
||||
///
|
||||
/// The rail is fed by `playlist_play_history`, not by the Qobuz API, so the
|
||||
/// card is built from what was captured at play time. Two consequences worth
|
||||
/// knowing: a LOCAL playlist (`local:<uuid>`) has a local file for artwork
|
||||
/// rather than a URL, which is why the path arm exists; and the pin / heart
|
||||
/// glyphs are seeded from the same stores every other playlist card reads, so
|
||||
/// a pinned playlist does not draw as unpinned here (the defect class the
|
||||
/// comment on `map_playlist` describes).
|
||||
pub(crate) fn map_recent_playlist(
|
||||
p: qbz_app::settings::playlist_play_history::PlaylistPlayRow,
|
||||
) -> HomeCard {
|
||||
let local = p.source == "local";
|
||||
let id_for_stores = p.playlist_id.clone();
|
||||
HomeCard {
|
||||
is_pinned: crate::sidebar_qt::is_pinned("playlist", &id_for_stores),
|
||||
is_favorite: p
|
||||
.playlist_id
|
||||
.parse::<u64>()
|
||||
.map(crate::fav_cache_qt::is_playlist_favorite)
|
||||
.unwrap_or(false),
|
||||
playlist_owned: local
|
||||
|| p.owner_id
|
||||
.parse::<u64>()
|
||||
.map(crate::playlist_qt::owns)
|
||||
.unwrap_or(false),
|
||||
playlist_following: p
|
||||
.playlist_id
|
||||
.parse::<u64>()
|
||||
.map(crate::playlist_qt::is_following)
|
||||
.unwrap_or(false),
|
||||
id: p.playlist_id,
|
||||
title: p.title,
|
||||
artist: p.owner,
|
||||
// A local playlist's cover is already a file on disk; a Qobuz one is a
|
||||
// URL the artwork cache resolves like every other rail.
|
||||
art_path: if local { p.artwork_url.clone() } else { String::new() },
|
||||
art_url: if local { String::new() } else { p.artwork_url },
|
||||
..HomeCard::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one recently-played track onto a slim row (`slimTracks` — click plays).
|
||||
fn map_recent_track(t: crate::recently_qt::RecentTrack) -> HomeCard {
|
||||
HomeCard {
|
||||
|
||||
@@ -392,10 +392,158 @@ pub fn clear_open_snapshot() {
|
||||
}
|
||||
|
||||
/// The id of the open local detail, if one is open.
|
||||
///
|
||||
/// Since Seam B (mixed Qobuz details adopt this snapshot too — see
|
||||
/// [`set_open_mixed_snapshot`]) this is `Some` for TWO different kinds of open
|
||||
/// page, and the difference matters at four call sites. Read the note on
|
||||
/// [`local_detail_open`] before routing on it.
|
||||
pub fn open_id() -> Option<String> {
|
||||
CURRENT_META.lock().ok()?.as_ref().map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// True only while a FIRST-CLASS LOCAL detail (`local:<uuid>`) is the open
|
||||
/// page — not a mixed Qobuz one.
|
||||
///
|
||||
/// [`open_id`] used to answer exactly that question, because the snapshot could
|
||||
/// only ever hold a local playlist. Seam B broke the equivalence: a mixed Qobuz
|
||||
/// detail now adopts the same snapshot (it has to — its rows are the same
|
||||
/// blend of catalog, file and Plex tracks, and they are resolved by the same
|
||||
/// helpers), so `open_id()` is `Some("123456")` on a page that is still, for
|
||||
/// every write, a Qobuz playlist.
|
||||
///
|
||||
/// The split is deliberate and it follows the reference, which never routed on
|
||||
/// this snapshot at all: Slint asks three separate predicates
|
||||
/// (`is_local() || offline_subset() || playlist::is_mixed()`, main.rs:13531)
|
||||
/// and picks a different one per action. Here that becomes: PLAYBACK routes on
|
||||
/// `open_id()` — both kinds play from the merged queue, which is the whole
|
||||
/// point — while REMOVE and REORDER route on this, because a mixed playlist's
|
||||
/// writes go to Qobuz and to the sidecar tables, never to `local_playlists`.
|
||||
/// Sending them to [`remove_row`] would look right and silently write nothing:
|
||||
/// the repo has no row for a Qobuz playlist id.
|
||||
pub fn local_detail_open() -> bool {
|
||||
open_id().map(|id| is_local_id(&id)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read + resolve a QOBUZ playlist's SIDECAR rows (`playlist_local_tracks` +
|
||||
/// `playlist_plex_tracks`) with their stored absolute positions — the shared
|
||||
/// reader behind the ONLINE mixed detail (`playlist_qt::load`). Port of
|
||||
/// `crates/qbz/src/local_playlist.rs:520-587`.
|
||||
///
|
||||
/// Runs the one-shot position healing first (Seam C): collided slots — the
|
||||
/// legacy 0-based picker/drag writes, and create-and-add's parallel 0-based
|
||||
/// local+plex rows — renumber stably into the append region. Drift alone is
|
||||
/// never touched (E7). Healing is BEST-EFFORT: the interleave tolerates
|
||||
/// collisions (same-slot rows all emit), so a failure logs and reading
|
||||
/// proceeds.
|
||||
///
|
||||
/// Plex refs resolve from the Plex cache in ONE bulk lookup; a miss renders the
|
||||
/// honest `Unresolved` row (E8) instead of vanishing — a row the user put there
|
||||
/// that silently disappears is indistinguishable from data loss.
|
||||
///
|
||||
/// Returned rows are local-table-first then plex, each position ASC — the
|
||||
/// stable claim order the interleave's same-slot emit relies on (E1/E2).
|
||||
///
|
||||
/// BLOCKING — call it on a worker thread.
|
||||
pub fn read_sidecar_rows_blocking(
|
||||
playlist_id: u64,
|
||||
qobuz_track_count: u32,
|
||||
include_plex: bool,
|
||||
) -> Vec<LoadedRow> {
|
||||
let (mut rows, plex_refs) = with_db(false, |db| {
|
||||
match db.heal_playlist_sidecar_positions(playlist_id, qobuz_track_count) {
|
||||
Ok(healed) => {
|
||||
for entry in &healed {
|
||||
log::warn!(
|
||||
"[qbz-qt] playlist {playlist_id}: healed sidecar position collision — {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] playlist {playlist_id}: sidecar healing failed: {e}");
|
||||
}
|
||||
}
|
||||
let rows: Vec<LoadedRow> = db
|
||||
.get_playlist_local_tracks_with_position(playlist_id)?
|
||||
.into_iter()
|
||||
.map(|r| LoadedRow {
|
||||
position: r.playlist_position,
|
||||
item: RowItem::Local(Box::new(r.track)),
|
||||
})
|
||||
.collect();
|
||||
let plex_refs: Vec<(String, i32)> = if include_plex {
|
||||
db.get_playlist_plex_tracks_with_position(playlist_id)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((rows, plex_refs))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if !plex_refs.is_empty() {
|
||||
let keys: Vec<String> = plex_refs.iter().map(|(key, _)| key.clone()).collect();
|
||||
let resolved: HashMap<String, qbz_library::LocalTrack> =
|
||||
match qbz_plex::plex_cache_get_cached_tracks_by_keys(&keys) {
|
||||
Ok(list) => list
|
||||
.into_iter()
|
||||
.map(crate::local_plex::map_cached_to_local_track)
|
||||
// Keyed by `file_path`, which is where the Plex merge
|
||||
// stores the rating key (local_plex.rs:276) — the same
|
||||
// convention `local_picker_ref_for_track` reads back.
|
||||
.map(|t| (t.file_path.clone(), t))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] playlist {playlist_id}: plex cache resolve failed: {e}");
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
rows.extend(plex_refs.into_iter().map(|(key, position)| LoadedRow {
|
||||
position,
|
||||
item: match resolved.get(&key) {
|
||||
Some(track) => RowItem::Plex(Box::new(track.clone())),
|
||||
None => {
|
||||
log::warn!(
|
||||
"[qbz-qt] playlist {playlist_id}: plex key {key:?} not in the Plex cache \
|
||||
— rendered as unavailable"
|
||||
);
|
||||
RowItem::Unresolved {
|
||||
kind: "plex",
|
||||
reference: key,
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Adopt the ONLINE mixed Qobuz detail's merged queue snapshot into the
|
||||
/// open-detail statics this module owns, so `play` / `play_shuffled` /
|
||||
/// `local_picker_ref_for_row` work over the merged rows exactly like a local
|
||||
/// detail does (row identity E11). Port of `local_playlist.rs:599-612`.
|
||||
///
|
||||
/// `offline_only` is always false here — a real Qobuz playlist never stamps the
|
||||
/// D8 guard; excluding the local/Plex rows from a QConnect push happens
|
||||
/// per-track at admission, off `QueueTrack.source`.
|
||||
///
|
||||
/// It DOES write `CURRENT_META`, like the reference, and that is what makes the
|
||||
/// three playback routes serve a mixed detail with no change of their own. The
|
||||
/// two routes that must NOT follow it read [`local_detail_open`] instead.
|
||||
pub fn set_open_mixed_snapshot(
|
||||
playlist_id: &str,
|
||||
queue: Vec<QueueTrack>,
|
||||
positions: HashMap<String, i32>,
|
||||
) {
|
||||
if let Ok(mut cur) = CURRENT_QUEUE.lock() {
|
||||
*cur = queue;
|
||||
}
|
||||
if let Ok(mut meta) = CURRENT_META.lock() {
|
||||
*meta = Some((playlist_id.to_string(), false));
|
||||
}
|
||||
if let Ok(mut pos) = ROW_POSITIONS.lock() {
|
||||
*pos = positions;
|
||||
}
|
||||
}
|
||||
|
||||
// `queue_track_for_row` and `plex_key_for_row` lived here and are GONE: they
|
||||
// read the open-detail queue snapshot to build a playable QueueTrack, which is
|
||||
// what `playlist_qt::row_to_queue` already does for BOTH kinds of playlist —
|
||||
@@ -452,7 +600,7 @@ pub fn local_picker_ref_for_track(track: &qbz_library::LocalTrack) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn total_duration_label(rows: &[LoadedRow]) -> String {
|
||||
pub(crate) fn total_duration_label(rows: &[LoadedRow]) -> String {
|
||||
let secs: u64 = rows
|
||||
.iter()
|
||||
.map(|r| match &r.item {
|
||||
@@ -473,7 +621,7 @@ fn total_duration_label(rows: &[LoadedRow]) -> String {
|
||||
}
|
||||
|
||||
/// Build the display row + its queue track (when playable) for one resolved row.
|
||||
fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueTrack>) {
|
||||
pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueTrack>) {
|
||||
match item {
|
||||
RowItem::Qobuz(track) => {
|
||||
let row = crate::playlist_qt::map_track(track);
|
||||
@@ -810,6 +958,22 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> bool {
|
||||
if let Ok(mut pos) = ROW_POSITIONS.lock() {
|
||||
*pos = positions;
|
||||
}
|
||||
// The LOCAL half of the "Recently Played Playlists" meta. Same contract as
|
||||
// the Qobuz loader: metadata only, the play event comes from the
|
||||
// track-start edge, and the rail's JOIN keeps a merely-browsed playlist
|
||||
// off it. `source: "local"` records where the PLAYLIST lives — its rows
|
||||
// can still be Qobuz tracks.
|
||||
qbz_app::settings::playlist_play_history::record_playlist_meta(
|
||||
qbz_app::settings::playlist_play_history::PlaylistPlayMeta {
|
||||
playlist_id: &doc.id,
|
||||
title: &doc.name,
|
||||
owner: "",
|
||||
owner_id: "",
|
||||
artwork_url: doc.covers.first().map(String::as_str).unwrap_or(""),
|
||||
track_count: doc.track_count.max(0) as u32,
|
||||
source: "local",
|
||||
},
|
||||
);
|
||||
crate::playlist_qt::adopt_doc(doc);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -660,6 +660,7 @@ fn on_session_entered() {
|
||||
// default is just the store's default (48 h) and would silently misreport
|
||||
// a user who chose something else until they changed it again.
|
||||
recommendations_qt::publish_cache_ttl_index();
|
||||
discover_config_qt::publish_rail_size_index();
|
||||
|
||||
// Restore the persisted player volume so audio starts at the SAVED level
|
||||
// (1:1 with qbz/src/main.rs:220-227). Without it every launch started at
|
||||
@@ -1539,6 +1540,11 @@ pub(crate) fn open_playlist(playlist_id: String) {
|
||||
// cortinilla. Self-gated on the Search view being current, so every other
|
||||
// caller of this router is unaffected.
|
||||
search_qt::record_page_interaction("playlist", &playlist_id, search_qt::InteractionAction::Open);
|
||||
// Drop the previous detail's "mixed" latch before either arm runs. `load`
|
||||
// sets it authoritatively at the end, but between here and there sits a
|
||||
// network fetch, and a remove clicked inside that window would otherwise
|
||||
// route by the shape of the page the user just left.
|
||||
playlist_qt::clear_mixed();
|
||||
// LOCAL playlists (`local:<uuid>`) route to their own loader and open
|
||||
// REGARDLESS of connectivity. They are the whole feature for people using
|
||||
// QBZ as a player without Qobuz — refusing them while offline would gate
|
||||
@@ -1706,10 +1712,23 @@ pub(crate) fn playlist_enqueue_track(track_id: String, mode: String) {
|
||||
pub(crate) fn playlist_remove_track(row_id: String) {
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
if local_playlist_qt::open_id().is_some() {
|
||||
// `local_detail_open`, NOT `open_id().is_some()`: since the mixed
|
||||
// merge landed, a Qobuz detail with sidecar rows adopts that same
|
||||
// snapshot, and routing on its mere presence would send this remove
|
||||
// into the LOCAL playlist repo — which holds no row for a Qobuz
|
||||
// playlist id, so the click would render as done and write nothing.
|
||||
if local_playlist_qt::local_detail_open() {
|
||||
local_playlist_qt::remove_row(&runtime, &row_id).await;
|
||||
return;
|
||||
}
|
||||
// A sidecar row of a MIXED Qobuz playlist: it comes out of
|
||||
// `library.db`, not out of the Qobuz membership. Returns false when the
|
||||
// row turns out to be an ordinary Qobuz one, so this falls through.
|
||||
if playlist_qt::is_mixed()
|
||||
&& playlist_qt::remove_sidecar_row(&runtime, &row_id).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(playlist_track_id) = row_id.parse::<u64>() else {
|
||||
log::warn!("[qbz-qt] playlist remove: non-numeric row id {row_id}");
|
||||
return;
|
||||
@@ -1728,13 +1747,24 @@ pub(crate) fn playlist_reorder(from: i32, slot: i32) {
|
||||
if from < 0 || slot < 0 || slot == from || slot == from + 1 {
|
||||
return;
|
||||
}
|
||||
if local_playlist_qt::open_id().is_some() {
|
||||
if local_playlist_qt::local_detail_open() {
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
local_playlist_qt::reorder_row(&runtime, from as usize, slot as usize).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A MIXED Qobuz playlist has no reorder yet, and the honest answer is to
|
||||
// do nothing rather than to corrupt the page: the custom-order sidecar
|
||||
// keys by `u64` catalog id, and a local row's display id is a library
|
||||
// rowid in the same numeric space — writing one would collide with a real
|
||||
// track and reorder the WRONG row on the next load. The view hides the
|
||||
// affordance (PlaylistView.qml gates `canReorder` on `!doc.isMixed`); this
|
||||
// is the guard for every other way in.
|
||||
if playlist_qt::is_mixed() {
|
||||
log::info!("[qbz-qt] playlist reorder ignored: mixed playlist");
|
||||
return;
|
||||
}
|
||||
playlist_qt::reorder_track(from as usize, slot as usize);
|
||||
}
|
||||
|
||||
@@ -1743,13 +1773,18 @@ pub(crate) fn playlist_move_row(row_id: String, delta: i32) {
|
||||
if delta == 0 {
|
||||
return;
|
||||
}
|
||||
if local_playlist_qt::open_id().is_some() {
|
||||
if local_playlist_qt::local_detail_open() {
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
local_playlist_qt::move_row(&runtime, &row_id, delta).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Same guard, same reason as the drag above.
|
||||
if playlist_qt::is_mixed() {
|
||||
log::info!("[qbz-qt] playlist move-row ignored: mixed playlist");
|
||||
return;
|
||||
}
|
||||
playlist_qt::move_row(&row_id, delta);
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,184 @@ pub struct PlaylistDoc {
|
||||
/// the cloud entirely.
|
||||
#[serde(default, rename = "offlineOnly", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub offline_only: bool,
|
||||
/// This QOBUZ detail carries SIDECAR rows — local files and/or Plex tracks
|
||||
/// added to it through `playlist_local_tracks` / `playlist_plex_tracks`.
|
||||
/// The "carretes paralelos" playlist.
|
||||
///
|
||||
/// NOT the same flag as `is_local_playlist`, and it must not be folded into
|
||||
/// it: a mixed playlist is still a Qobuz playlist with an owner, a follow
|
||||
/// state, a copy action, a share link and a heart, and it keeps every one of
|
||||
/// those affordances. What it loses is drag/chevron reorder — see
|
||||
/// `apply_custom_order`, which keys its stored order by a `u64` catalog id
|
||||
/// and cannot tell one from a library row id.
|
||||
#[serde(default, rename = "isMixed", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub is_mixed: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed ("carretes paralelos") playlists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// True while the open ONLINE Qobuz detail carries sidecar (local/Plex) rows.
|
||||
/// Set at the end of every [`load`], cleared by [`reset`] — the same lifetime
|
||||
/// the reference gives it (`qbz/src/playlist.rs:32`).
|
||||
///
|
||||
/// It exists because the DOC is not readable from the two write paths that need
|
||||
/// the answer synchronously (`main.rs` routes before any await), and because
|
||||
/// those paths must NOT route on `local_playlist_qt::open_id()`: a mixed detail
|
||||
/// adopts that snapshot too, so `open_id()` alone would send a Qobuz playlist's
|
||||
/// remove and reorder into the local-playlist repo, where they would find no
|
||||
/// rows and write nothing. That is the renders-and-no-ops failure this module
|
||||
/// has already been bitten by once.
|
||||
static MIXED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Whether the open ONLINE Qobuz detail is a mixed playlist.
|
||||
pub fn is_mixed() -> bool {
|
||||
MIXED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Drop the mixed latch on navigation, BEFORE the next detail loads.
|
||||
///
|
||||
/// Without this the flag would survive between pages for the length of a fetch,
|
||||
/// and a remove clicked in that window would route by the PREVIOUS playlist's
|
||||
/// shape. `load` sets it authoritatively at the end; this only closes the gap.
|
||||
pub fn clear_mixed() {
|
||||
MIXED.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Remove ONE sidecar row (a local file or a Plex track) from the open MIXED
|
||||
/// Qobuz playlist, then reload so the page settles on the merged truth rather
|
||||
/// than on an optimistic patch.
|
||||
///
|
||||
/// This arm has to exist, and its absence was not a cosmetic gap: the Qobuz arm
|
||||
/// parses the display row id as a `u64` and posts it to Qobuz as a
|
||||
/// `playlist_track_id`. A local row's display id IS its `library.db` row id, in
|
||||
/// the same numeric space — so without this the click would send a local
|
||||
/// library rowid to Qobuz as if it were a catalog track, and the row would
|
||||
/// still be there afterwards.
|
||||
///
|
||||
/// A Plex row never rides its numeric id (the synthetic ids do not resolve):
|
||||
/// its rating key comes back from the open detail's queue snapshot through
|
||||
/// `local_picker_ref_for_row`, which returns it as `"plex:<key>"`.
|
||||
pub async fn remove_sidecar_row(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
row_id: &str,
|
||||
) -> bool {
|
||||
let Some(playlist_id) = with_doc(|d| d.id.clone()).and_then(|id| id.parse::<u64>().ok()) else {
|
||||
return false;
|
||||
};
|
||||
let source = with_doc(|d| {
|
||||
d.tracks
|
||||
.iter()
|
||||
.find(|t| t.id == row_id)
|
||||
.map(|t| t.source.clone())
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let removal = match source.as_str() {
|
||||
"local" => match row_id.parse::<i64>() {
|
||||
Ok(id) => SidecarRemoval::Local(id),
|
||||
Err(_) => {
|
||||
log::warn!("[qbz-qt] playlist remove: non-numeric local row id {row_id}");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"plex" => match crate::local_playlist_qt::local_picker_ref_for_row(row_id)
|
||||
.and_then(|r| r.strip_prefix("plex:").map(str::to_string))
|
||||
{
|
||||
Some(key) => SidecarRemoval::Plex(key),
|
||||
None => {
|
||||
log::warn!("[qbz-qt] playlist remove: no plex key for row {row_id}");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// Not a sidecar row — the caller must fall through to the Qobuz arm.
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
crate::library_db_qt::with_db(false, |db| {
|
||||
match &removal {
|
||||
SidecarRemoval::Local(id) => db.remove_local_track_from_playlist(playlist_id, *id)?,
|
||||
SidecarRemoval::Plex(key) => db.remove_plex_track_from_playlist(playlist_id, key)?,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = load(runtime, playlist_id).await {
|
||||
log::warn!("[qbz-qt] playlist reload after sidecar remove failed: {e}");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
enum SidecarRemoval {
|
||||
Local(i64),
|
||||
Plex(String),
|
||||
}
|
||||
|
||||
/// Tauri's absolute-slot interleave — the `displayTracks` contract. Port of
|
||||
/// `qbz/src/playlist.rs:455-496`, kept literal on purpose.
|
||||
///
|
||||
/// Sidecar rows claim their STORED positions as slots in the merged list; Qobuz
|
||||
/// tracks fill the remaining slots in server order;
|
||||
/// `total = max(sum of rows, max stored position + 1)` so stale high slots
|
||||
/// still render (E3); unclaimed slots with no Qobuz track left are skipped
|
||||
/// (never a blank row); leftover Qobuz tracks append. Same-slot collisions emit
|
||||
/// ALL claimants — local first, then plex, in stable claim order — rather than
|
||||
/// Tauri's Map collapse, which dropped one of them (E1/E2 fix-forward; the
|
||||
/// stored data is repaired separately by the healing pass). Display numbering
|
||||
/// is the emit order, contiguous.
|
||||
pub(crate) fn interleave_rows(
|
||||
qobuz: Vec<Track>,
|
||||
sidecar: Vec<crate::local_playlist_qt::LoadedRow>,
|
||||
) -> Vec<crate::local_playlist_qt::LoadedRow> {
|
||||
use crate::local_playlist_qt::{LoadedRow, RowItem};
|
||||
let qobuz_to_row = |(i, t): (usize, Track)| LoadedRow {
|
||||
position: i as i32,
|
||||
item: RowItem::Qobuz(Box::new(t)),
|
||||
};
|
||||
if sidecar.is_empty() {
|
||||
return qobuz.into_iter().enumerate().map(qobuz_to_row).collect();
|
||||
}
|
||||
let sidecar_len = sidecar.len();
|
||||
let mut max_pos: i32 = -1;
|
||||
let mut buckets: std::collections::HashMap<i32, Vec<LoadedRow>> =
|
||||
std::collections::HashMap::new();
|
||||
for row in sidecar {
|
||||
// Corrupt negative positions claim slot 0 rather than vanishing.
|
||||
let pos = row.position.max(0);
|
||||
max_pos = max_pos.max(pos);
|
||||
buckets.entry(pos).or_default().push(row);
|
||||
}
|
||||
let total = (qobuz.len() + sidecar_len).max((max_pos + 1) as usize);
|
||||
let mut out: Vec<LoadedRow> = Vec::with_capacity(qobuz.len() + sidecar_len);
|
||||
let mut qobuz_iter = qobuz.into_iter();
|
||||
for pos in 0..total as i32 {
|
||||
if let Some(rows) = buckets.remove(&pos) {
|
||||
out.extend(rows);
|
||||
} else if let Some(track) = qobuz_iter.next() {
|
||||
out.push(LoadedRow {
|
||||
position: pos,
|
||||
item: RowItem::Qobuz(Box::new(track)),
|
||||
});
|
||||
}
|
||||
// else: an unclaimed slot past the Qobuz tracks — a gap, skipped.
|
||||
}
|
||||
for track in qobuz_iter {
|
||||
out.push(LoadedRow {
|
||||
position: 0,
|
||||
item: RowItem::Qobuz(Box::new(track)),
|
||||
});
|
||||
}
|
||||
// Positions in the merged output are the contiguous display slots; the
|
||||
// stored sidecar positions did their job claiming the order.
|
||||
for (i, row) in out.iter_mut().enumerate() {
|
||||
row.position = i as i32;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -824,7 +1002,64 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
.map(|d| qbz_text_utils::strip_html::strip_html(&d))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut rows: Vec<PlaylistTrackRow> = tracks.iter().map(map_track).collect();
|
||||
// --- Seam A: merge-on-load ---------------------------------------------
|
||||
//
|
||||
// THE BUG THIS CLOSES: a "mixed" playlist is a QOBUZ playlist (numeric id)
|
||||
// with local-file and/or Plex rows attached through the `library.db`
|
||||
// sidecar tables `playlist_local_tracks` / `playlist_plex_tracks`. Qt could
|
||||
// WRITE those rows (`playlist_picker_qt::write_sidecar_refs`) and COUNT
|
||||
// them (`folders_qt` feeds the Playlist Manager's "(N local)" badge) — but
|
||||
// this loader built its rows exclusively from the Qobuz API response, so
|
||||
// the rows the user had just added never appeared on the page they were
|
||||
// added to. Two comments elsewhere in the port claimed this merge already
|
||||
// happened; they were describing the reference, not this file.
|
||||
//
|
||||
// The reader does the healing and the Plex-cache resolve; the interleave
|
||||
// places sidecar rows at their STORED absolute slots and fills the rest
|
||||
// with the server order. Plex rows are always included online — their
|
||||
// availability is connectivity-based (E13), not stored.
|
||||
let qobuz_count = tracks.len() as u32;
|
||||
let sidecar = tokio::task::spawn_blocking(move || {
|
||||
crate::local_playlist_qt::read_sidecar_rows_blocking(playlist_id, qobuz_count, true)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mixed = !sidecar.is_empty();
|
||||
let merged = interleave_rows(tracks, sidecar);
|
||||
|
||||
// Display rows + the playable snapshot + the row positions in ONE pass, so
|
||||
// a row's display id and its queue entry can never disagree — the same
|
||||
// shape `local_playlist_qt::load` uses, and the reason row identity (E11)
|
||||
// survives the connectivity flip.
|
||||
let mut rows: Vec<PlaylistTrackRow> = Vec::with_capacity(merged.len());
|
||||
let mut merged_queue: Vec<QueueTrack> = Vec::new();
|
||||
let mut merged_positions: std::collections::HashMap<String, i32> =
|
||||
std::collections::HashMap::new();
|
||||
for row in &merged {
|
||||
let (item, queue) = crate::local_playlist_qt::row_to_display(&row.item);
|
||||
merged_positions.insert(item.id.clone(), row.position);
|
||||
if let Some(q) = queue {
|
||||
merged_queue.push(q);
|
||||
}
|
||||
rows.push(item);
|
||||
}
|
||||
|
||||
// Seam B: a mixed detail plays through local_playlist's merged queue
|
||||
// snapshot (source-aware QueueTracks — a local file must never be queued as
|
||||
// a catalog id), so `main.rs`'s three playback routes serve it unchanged.
|
||||
// A pure-Qobuz detail clears the snapshot instead, or its rows would
|
||||
// resolve against the previous playlist's queue.
|
||||
MIXED.store(mixed, std::sync::atomic::Ordering::Relaxed);
|
||||
if mixed {
|
||||
crate::local_playlist_qt::set_open_mixed_snapshot(
|
||||
&playlist_id.to_string(),
|
||||
merged_queue,
|
||||
merged_positions,
|
||||
);
|
||||
} else {
|
||||
crate::local_playlist_qt::clear_open_snapshot();
|
||||
}
|
||||
|
||||
// Artwork (the reload_home pattern): disk hits inline, one background
|
||||
// download + republish.
|
||||
let cover_path = crate::artwork_qt::cached_path(&cover_url);
|
||||
@@ -910,6 +1145,7 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
doc.is_following = is_following;
|
||||
doc.is_copied = is_copied;
|
||||
doc.pinned = pinned;
|
||||
doc.is_mixed = mixed;
|
||||
doc.loading = false;
|
||||
let (field, asc) = sort_state;
|
||||
apply_sort(doc, &field, asc);
|
||||
@@ -917,6 +1153,25 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
|
||||
};
|
||||
publish(&doc);
|
||||
|
||||
// Remember WHAT this playlist is, for the "Recently Played Playlists"
|
||||
// rail. Only the metadata — the play EVENT is written at the track-start
|
||||
// edge, which sees a QueueTrack and an id and nothing else. Upserting on
|
||||
// load rather than on play is what keeps a renamed or re-covered playlist
|
||||
// converging, and it cannot put a card on the rail by itself: the rail's
|
||||
// query JOINs meta against events, so a playlist merely browsed shows
|
||||
// nowhere (there is a test for exactly that).
|
||||
qbz_app::settings::playlist_play_history::record_playlist_meta(
|
||||
qbz_app::settings::playlist_play_history::PlaylistPlayMeta {
|
||||
playlist_id: &playlist_id.to_string(),
|
||||
title: &doc.name,
|
||||
owner: &doc.owner,
|
||||
owner_id: &doc.owner_id.to_string(),
|
||||
artwork_url: &doc.cover_url,
|
||||
track_count: doc.track_count.max(0) as u32,
|
||||
source: "qobuz",
|
||||
},
|
||||
);
|
||||
|
||||
if !missing.is_empty() {
|
||||
crate::spawn(async move {
|
||||
crate::artwork_qt::download_missing(missing).await;
|
||||
@@ -951,7 +1206,16 @@ fn apply_sort(doc: &mut PlaylistDoc, field: &str, asc: bool) {
|
||||
"artist" => doc.tracks.sort_by(|a, b| a.artist.to_lowercase().cmp(&b.artist.to_lowercase())),
|
||||
"album" => doc.tracks.sort_by(|a, b| a.album.to_lowercase().cmp(&b.album.to_lowercase())),
|
||||
"duration" => doc.tracks.sort_by_key(|t| t.duration_secs),
|
||||
"custom" => apply_custom_order(doc),
|
||||
// A MIXED playlist has no usable custom order, and forcing one would
|
||||
// scramble the merge that just ran: `apply_custom_order` keys its
|
||||
// stored ranks by `t.id.parse::<u64>()`, and a local row's display id
|
||||
// is a `library.db` rowid living in the SAME numeric space as a catalog
|
||||
// id — the two collide silently. A LocalFile / Unresolved row whose id
|
||||
// is a path or a `plex:<key>` does not parse at all and sinks to the
|
||||
// end on `u64::MAX`. So a mixed detail keeps the absolute-slot order
|
||||
// the interleave computed, which is the order the user actually chose.
|
||||
"custom" if !doc.is_mixed => apply_custom_order(doc),
|
||||
"custom" => {}
|
||||
// "default" / "added": the API insertion order is the natural
|
||||
// order; "added" starts newest-first (asc=false reverses).
|
||||
_ => {}
|
||||
|
||||
@@ -226,6 +226,20 @@ pub(crate) fn prune_albums(album_ids: &[String]) -> usize {
|
||||
/// here as well as there — see [`is_ephemeral_track_id`].
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn record(track: RecentTrack) {
|
||||
record_in_context(track, "")
|
||||
}
|
||||
|
||||
/// The same write, told what the user was PLAYING FROM.
|
||||
///
|
||||
/// The context cannot ride on `RecentTrack` itself, and this is the constraint
|
||||
/// that shapes the whole feature: `recently_played.json` is a DESTRUCTIVE round
|
||||
/// trip between the two frontends. The Slint build deserializes it into its own
|
||||
/// structs, serde silently drops fields it does not know, and its next write
|
||||
/// puts the truncated object back — so a `context_kind` added to `RecentTrack`
|
||||
/// would survive exactly until the other build played one track, intermittently
|
||||
/// and with nothing logged. The gate therefore lives at the WRITE, and the file
|
||||
/// on disk keeps the shape both builds agree on.
|
||||
pub(crate) fn record_in_context(track: RecentTrack, context_kind: &str) {
|
||||
if track.id.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -238,7 +252,17 @@ pub(crate) fn record(track: RecentTrack) {
|
||||
return;
|
||||
}
|
||||
let mut store = read_store();
|
||||
if !track.album_id.is_empty() {
|
||||
// The ALBUM half is written only when the play was ABOUT an album.
|
||||
//
|
||||
// THE BUG THIS CLOSES: every track start wrote its album into
|
||||
// `store.albums`, whatever the user had actually chosen to play. A playlist
|
||||
// of 40 tracks from 40 different albums therefore pushed 40 entries into a
|
||||
// list capped at 24, and "Recently Played" became a list of that playlist's
|
||||
// contents — the playlist itself recorded nowhere. `""` (nothing stamped)
|
||||
// still counts: most of the untagged entry points ARE album listening, and
|
||||
// treating them as playlists would empty the album rail instead.
|
||||
let album_context = context_kind.is_empty() || context_kind == "album";
|
||||
if album_context && !track.album_id.is_empty() {
|
||||
store.albums.retain(|a| a.id != track.album_id);
|
||||
store.albums.insert(
|
||||
0,
|
||||
@@ -318,6 +342,21 @@ pub(crate) fn record_queue_track(track: &qbz_models::QueueTrack) {
|
||||
let source = track.source.clone().unwrap_or_default();
|
||||
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
|
||||
// What the user chose to play, as opposed to where the audio comes from
|
||||
// (`source`). Two different axes with confusingly similar names: a LOCAL
|
||||
// playlist is `context_kind = "playlist"` AND `source = "local"`.
|
||||
let context_kind = track.context_kind.clone().unwrap_or_default();
|
||||
let context_id = track.context_id.clone().unwrap_or_default();
|
||||
|
||||
// A playlist play is recorded AS a playlist play, in its own store, and
|
||||
// does not touch either album history. The meta (title, owner, cover) was
|
||||
// already upserted by the view that started playback — the edge only ever
|
||||
// sees a QueueTrack, and resolving an id back into a header on every track
|
||||
// change would be a fetch per track.
|
||||
if context_kind == "playlist" {
|
||||
qbz_app::settings::playlist_play_history::record_playlist_play(&context_id);
|
||||
}
|
||||
|
||||
qbz_app::settings::album_play_history::record_album_play(
|
||||
qbz_app::settings::album_play_history::AlbumPlayMeta {
|
||||
album_id: &album_id,
|
||||
@@ -329,25 +368,29 @@ pub(crate) fn record_queue_track(track: &qbz_models::QueueTrack) {
|
||||
quality_label: &quality_label,
|
||||
year: "",
|
||||
source: &source,
|
||||
context_kind: &context_kind,
|
||||
},
|
||||
);
|
||||
|
||||
record(RecentTrack {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
subtitle: track.artist.clone(),
|
||||
artwork_url: artwork.clone(),
|
||||
album_id,
|
||||
album_title: track.album.clone(),
|
||||
album_artist: track.artist.clone(),
|
||||
album_artwork_url: artwork,
|
||||
quality_tier: tier.to_string(),
|
||||
quality_label,
|
||||
genre: String::new(),
|
||||
release_date: String::new(),
|
||||
artist_id: track.artist_id,
|
||||
source,
|
||||
});
|
||||
record_in_context(
|
||||
RecentTrack {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
subtitle: track.artist.clone(),
|
||||
artwork_url: artwork.clone(),
|
||||
album_id,
|
||||
album_title: track.album.clone(),
|
||||
album_artist: track.artist.clone(),
|
||||
album_artwork_url: artwork,
|
||||
quality_tier: tier.to_string(),
|
||||
quality_label,
|
||||
genre: String::new(),
|
||||
release_date: String::new(),
|
||||
artist_id: track.artist_id,
|
||||
source,
|
||||
},
|
||||
&context_kind,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -900,9 +900,20 @@ fn reco_cache_ttl_secs() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// PRE-EXISTING FAILURE, fixed here rather than left red. `c02e7713d` split
|
||||
// the two Weekly rows out of the document and turned them into ordering
|
||||
// SLOTS — `slot()` deliberately never self-hides, and its doc comment says
|
||||
// why: a slot that vanished while empty and came back when filled would
|
||||
// change the document, which is the republish that split exists to avoid.
|
||||
// These two tests still asserted the pre-split behaviour and had been red
|
||||
// ever since. The CODE is right; the assertions were stale.
|
||||
#[test]
|
||||
fn empty_rows_publish_no_sections() {
|
||||
assert!(sections(&Rows::default()).is_empty());
|
||||
fn empty_rows_publish_only_the_ordering_slots() {
|
||||
let ids: Vec<String> = sections(&Rows::default())
|
||||
.into_iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
assert_eq!(ids, vec![WEEKLY_EXPLORATION, WEEKLY_JAMS]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -941,8 +952,9 @@ mod tests {
|
||||
rec_albums: vec![HomeCard::default()],
|
||||
..Rows::default()
|
||||
};
|
||||
let secs = sections(&rows);
|
||||
assert_eq!(secs.len(), 1);
|
||||
assert_eq!(secs[0].id, "recAlbums");
|
||||
let ids: Vec<String> = sections(&rows).into_iter().map(|s| s.id).collect();
|
||||
// Every EMPTY row is gone; the two slots stay, at their place in the
|
||||
// lineup, because they are ordering and not content.
|
||||
assert_eq!(ids, vec!["recAlbums", WEEKLY_EXPLORATION, WEEKLY_JAMS]);
|
||||
}
|
||||
}
|
||||
@@ -375,6 +375,22 @@ pub mod qbz_shell {
|
||||
// Carried here for the same reason the drag ghost is: the trigger
|
||||
// lives inside the bar, the overlay must paint above every surface, and
|
||||
// the two are in different .qml files.
|
||||
/// APPLIED-FILTERS TOOLTIP CHANNEL. Written by QML, read by QML — the
|
||||
/// bridge is only the wire.
|
||||
///
|
||||
/// A filter funnel lives deep inside a view's toolbar; the tooltip
|
||||
/// overlay is a sibling of the whole shell. A QML `id` does not cross
|
||||
/// documents, so the two cannot see each other, and threading an
|
||||
/// `Item` down five levels for every one of eighteen filter surfaces is
|
||||
/// the kind of plumbing that gets half-done. The art preview solved the
|
||||
/// identical problem the identical way (`art_preview_*` below): the
|
||||
/// state rides the bridge.
|
||||
///
|
||||
/// `{ key, x, y, w, h, groups: [{group, values[]}] }` in SCENE
|
||||
/// coordinates (the overlay fills the window, so they are its own), or
|
||||
/// `{}` to hide. Numbers, never an Item reference — a recycled toolbar
|
||||
/// must not be able to dangle here.
|
||||
#[qproperty(QString, filter_tip_json)]
|
||||
#[qproperty(bool, art_preview_show)]
|
||||
#[qproperty(f32, art_preview_x)]
|
||||
#[qproperty(f32, art_preview_y)]
|
||||
@@ -758,6 +774,7 @@ pub struct QbzShellRust {
|
||||
drag_y: f32,
|
||||
drag_over_playlist_id: QString,
|
||||
drag_over_queue_index: i32,
|
||||
filter_tip_json: QString,
|
||||
art_preview_show: bool,
|
||||
art_preview_x: f32,
|
||||
art_preview_y: f32,
|
||||
@@ -868,6 +885,8 @@ impl Default for QbzShellRust {
|
||||
// -1 = the pointer is not over the queue. NOT 0, which is a valid
|
||||
// slot (drop at the very top of upcoming).
|
||||
drag_over_queue_index: -1,
|
||||
// "{}" so the overlay's JSON.parse never throws on the first frame.
|
||||
filter_tip_json: QString::from("{}"),
|
||||
art_preview_show: false,
|
||||
art_preview_x: 0.0,
|
||||
art_preview_y: 0.0,
|
||||
|
||||
@@ -5254,3 +5254,32 @@ msgstr "Wird ersetzt …"
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "Der Ersatz nimmt den Platz des nicht verfügbaren Titels in der Playlist ein."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Elemente pro Karussell"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Zuletzt gespielte Playlists"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "Playlists, die du abspielst, erscheinen hier."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} weitere"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} weitere Filter"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Aktive Filter"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Kategorie"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Favoriten"
|
||||
@@ -867,3 +867,35 @@ msgstr "Replacing..."
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "The replacement takes the unavailable track's place in the playlist."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Items per carousel"
|
||||
|
||||
msgid "All"
|
||||
msgstr "All"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Recently Played Playlists"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "Playlists you play will appear here."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} more"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} more filters"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Applied filters"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Category"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Favorites"
|
||||
@@ -5260,3 +5260,32 @@ msgstr "Reemplazando..."
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "El reemplazo ocupa el lugar de la pista no disponible en la lista."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Elementos por carrusel"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Playlists reproducidas recientemente"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "Las playlists que reproduzcas aparecerán aquí."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} más"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} filtros más"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Filtros aplicados"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Categoría"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Favoritos"
|
||||
@@ -5252,3 +5252,32 @@ msgstr "Remplacement…"
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "Le remplacement prend la place du titre indisponible dans la playlist."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Éléments par carrousel"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Playlists écoutées récemment"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "Les playlists que vous écoutez apparaîtront ici."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} de plus"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} filtres de plus"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Filtres appliqués"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Catégorie"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Favoris"
|
||||
@@ -5237,3 +5237,32 @@ msgstr "置き換え中…"
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "置き換えた曲は、再生できない曲の位置に入ります。"
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "カルーセルあたりの項目数"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "最近再生したプレイリスト"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "再生したプレイリストがここに表示されます。"
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "他 {} 件"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "他 {} 件のフィルター"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "適用中のフィルター"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "カテゴリー"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "お気に入り"
|
||||
@@ -5242,3 +5242,32 @@ msgstr "Vervangen…"
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "De vervanging neemt de plaats van het niet-beschikbare nummer in de afspeellijst in."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Items per carrousel"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Onlangs afgespeelde afspeellijsten"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "Afspeellijsten die je afspeelt verschijnen hier."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} meer"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} filters meer"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Toegepaste filters"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Categorie"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Favorieten"
|
||||
@@ -5252,3 +5252,32 @@ msgstr "Substituindo..."
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "A substituição ocupa o lugar da faixa indisponível na playlist."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Itens por carrossel"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Playlists reproduzidas recentemente"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "As playlists que você reproduzir aparecerão aqui."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} mais"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} filtros a mais"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Filtros aplicados"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Categoria"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Favoritos"
|
||||
@@ -5271,3 +5271,32 @@ msgstr "Замена…"
|
||||
|
||||
msgid "The replacement takes the unavailable track's place in the playlist."
|
||||
msgstr "Замена займёт место недоступного трека в плейлисте."
|
||||
|
||||
# Discover configurator: items-per-carousel (Tauri parity recovery, 2026-08-19)
|
||||
msgid "Items per carousel"
|
||||
msgstr "Элементов в карусели"
|
||||
|
||||
# Discover: Recently Played Playlists rail (context-aware play history, 2026-08-19)
|
||||
msgid "Recently Played Playlists"
|
||||
msgstr "Недавно прослушанные плейлисты"
|
||||
|
||||
msgid "Playlists you play will appear here."
|
||||
msgstr "Здесь появятся плейлисты, которые вы слушаете."
|
||||
|
||||
# Applied-filters tooltip (2026-08-19)
|
||||
msgid "+{} more"
|
||||
msgstr "+{} ещё"
|
||||
|
||||
msgid "+{} more filters"
|
||||
msgstr "+{} фильтров ещё"
|
||||
|
||||
msgid "Applied filters"
|
||||
msgstr "Применённые фильтры"
|
||||
|
||||
# Applied-filters tooltip: group labels (2026-08-19)
|
||||
msgid "Category"
|
||||
msgstr "Категория"
|
||||
|
||||
# Applied-filters tooltip: Library source switches (2026-08-19)
|
||||
msgid "Favorites"
|
||||
msgstr "Избранное"
|
||||
@@ -146,6 +146,12 @@ pub fn render_kind(id: DiscoverySectionId) -> &'static str {
|
||||
MostStreamed => "slimGrid",
|
||||
QobuzPlaylists => "playlistCarousel",
|
||||
RecentlyPlayedAlbums => "albumCarousel",
|
||||
// Added for the Qt track (playlist plays no longer register as album
|
||||
// plays; the playlists get their own rail). This arm exists ONLY to
|
||||
// keep this frozen tree compiling against the shared enum — the Slint
|
||||
// build has no data source for it and never renders it. Same class of
|
||||
// two-line follow as `MostPlayedAlbums`.
|
||||
RecentlyPlayedPlaylists => "playlistCarousel",
|
||||
ContinueListening => "slimGrid",
|
||||
QobuzMixes => "qobuzMixes",
|
||||
RadioStations => "radio",
|
||||
@@ -191,6 +197,7 @@ pub fn label_for(id: DiscoverySectionId) -> &'static str {
|
||||
ArtistSpotlight => qbz_i18n::mark("Artist Spotlight"), // discovery.artistSpotlight
|
||||
Pinned => qbz_i18n::mark("Pinned"), // Slint-era section, no Tauri key
|
||||
MostPlayedAlbums => qbz_i18n::mark("Most Played Albums"), // local: most-played rail
|
||||
RecentlyPlayedPlaylists => qbz_i18n::mark("Recently Played Playlists"), // local: Qt-track rail
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user