feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus

**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
This commit is contained in:
vicrodh committed 2026-07-28 18:12:31 -06:00
1 parent 2b31d3866c
commit f3a33245b2
52 files changed
+4962 -719

No files matched your search

+144 -11
View File
@@ -12,6 +12,7 @@
//! - Row favorite seeds from the phase-5 library feed (Slint uses
//! fav_cache — same truth).
use std::collections::HashSet;
use std::sync::Mutex;
use qbz_app::shell::AppRuntime;
@@ -42,6 +43,11 @@ pub struct QueueRow {
pub art_url: String,
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// LOCAL or Plex row (`QueueTrack::is_local`). The heart is NOT
/// clickable on these — see `toggle_favorite`: their `id` is a
/// library.db row id, not a Qobuz catalog id.
#[serde(rename = "isLocal")]
pub is_local: bool,
/// #442 section header drawn ABOVE this row: "" | "next-in-queue" |
/// "next-up" (only on the unfiltered list).
pub section: String,
@@ -94,13 +100,45 @@ fn fmt_duration(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn row_from(track: &QueueTrack) -> QueueRow {
let is_favorite = crate::library_qt::with_library(|d| {
/// Canonical decimal parse — the exact equivalent of the old
/// `feed_id == track.id.to_string()` string compare (rejects the two forms
/// `u64::from_str` accepts but `to_string` never emits: a leading `+` and
/// leading zeros), so the heart state does not change with the lookup shape.
fn canonical_u64(s: &str) -> Option<u64> {
if s.is_empty() || s.starts_with('+') || (s.len() > 1 && s.starts_with('0')) {
return None;
}
s.parse::<u64>().ok()
}
/// Favorite TRACK ids from the phase-5 library feed — built ONCE per publish.
///
/// PERF (owner report: "the queue lags while a local album is loaded"). This
/// used to be a per-ROW `feed.iter().any(...)`: a full linear scan of the
/// ~10k-row library feed for EVERY row a publish builds (1 current + <=40 page
/// + <=50 history — the core caps history at 50, `qbz-player/src/queue.rs:748`),
/// with a fresh `track.id.to_string()` heap allocation on every compared
/// element and a separate LIBRARY lock per row. That is ~900k allocations and
/// ~91 lock round-trips per publish, and a LOCAL row can never match a Qobuz
/// feed id, so `any()` never short-circuits — every local row paid the FULL
/// scan, which is exactly why the lag shows up with a local album loaded.
///
/// The Slint reference resolves the same truth in O(1) from `fav_cache`'s
/// `HashSet<u64>` (`crates/qbz/src/queue.rs:230`); this is the port of that
/// shape onto the feed we have: one lock, one pass, allocation-free lookups.
fn favorite_track_ids() -> HashSet<u64> {
crate::library_qt::with_library(|d| {
d.feed
.iter()
.any(|i| i.kind == "track" && i.id == track.id.to_string() && i.is_favorite)
.filter(|i| i.is_favorite && i.kind == "track")
.filter_map(|i| canonical_u64(&i.id))
.collect()
})
.unwrap_or(false);
.unwrap_or_default()
}
fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
let is_favorite = favorites.contains(&track.id);
let tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
@@ -123,6 +161,7 @@ fn row_from(track: &QueueTrack) -> QueueRow {
explicit: track.parental_warning,
art_url: track.artwork_url.clone().unwrap_or_default(),
is_favorite,
is_local: track.is_local,
section: String::new(),
}
}
@@ -136,8 +175,43 @@ fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize)
(page, page_count, start, end)
}
/// Search predicate — same truth as `display_title(t)` + artist, minus the
/// intermediate `display_title` clone in the (dominant) version-less case.
/// Runs over the WHOLE upcoming list on every keystroke, so the saved
/// allocation is one per queue track per keypress.
fn matches_query(track: &QueueTrack, query: &str) -> bool {
let title_hit = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title)
.to_lowercase()
.contains(query),
None => track.title.to_lowercase().contains(query),
};
title_hit || track.artist.to_lowercase().contains(query)
}
/// Last document handed to Qt — the publish is skipped when the freshly built
/// one is byte-identical (`set_queue_json` emits unconditionally, and a
/// no-op emit is NOT free downstream: THREE QML files re-`JSON.parse` the
/// document (QueuePanel, PlayerBar, NowPlayingBarSmall) and QueuePanel's
/// `onDocChanged` re-dispatches the artwork window for every row — one
/// `stat` + one `libraryArtworkReady` emit + one full `coverMap` object
/// rebuild per row, invalidating every cover binding in the panel AND the
/// sidebar). Several user actions publish twice for one change (e.g.
/// `local_playback::play_rows` -> publish, then the audible step's
/// `play_queue_track` -> publish again), and the poll loop's track edge
/// publishes on top of the transport's own. Same dedup convention as
/// `now_playing::set_effective_stream`.
static LAST_JSON: Mutex<String> = Mutex::new(String::new());
/// Ids of the LOCAL/Plex rows in the document currently on screen (<=91),
/// refreshed by every publish — the guard `toggle_favorite` consults so a
/// local row's library.db id never reaches the Qobuz favorites API.
static LOCAL_ROW_IDS: std::sync::LazyLock<Mutex<HashSet<u64>>> =
std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
/// Build + publish the whole panel document.
pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let t_build = std::time::Instant::now();
let state = runtime.core().get_queue_state_full().await;
let (search, requested_page) = {
let view = VIEW.lock().unwrap();
@@ -152,21 +226,21 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
state
.upcoming
.iter()
.filter(|t| {
display_title(t).to_lowercase().contains(&query)
|| t.artist.to_lowercase().contains(&query)
})
.filter(|t| matches_query(t, &query))
.collect()
};
let upcoming_total = filtered.len();
let (page, page_count, start, end) = paginate(upcoming_total, requested_page);
VIEW.lock().unwrap().page = page;
// ONE feed pass for the whole document (see `favorite_track_ids`).
let favorites = favorite_track_ids();
let page_rows: Vec<QueueRow> = filtered[start..end]
.iter()
.enumerate()
.map(|(i, t)| {
let mut r = row_from(t);
let mut r = row_from(t, &favorites);
if query.is_empty() {
let g = start + i;
if state.manual_next_count > 0 && g == 0 {
@@ -184,8 +258,15 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
.map(|idx| state.total_tracks.saturating_sub(idx + 1))
.unwrap_or(state.total_tracks);
let history: Vec<QueueRow> = state.history.iter().map(row_from).collect();
let current = state.current_track.as_ref().map(row_from);
// Bounded by construction: the core keeps at most 50 history entries
// (`qbz-player/src/queue.rs:748`), so the document cannot grow without
// bound — <=91 rows total (1 current + <=40 page + <=50 history).
let history: Vec<QueueRow> = state
.history
.iter()
.map(|t| row_from(t, &favorites))
.collect();
let current = state.current_track.as_ref().map(|t| row_from(t, &favorites));
let doc = QueueDoc {
has_current: current.is_some(),
@@ -205,7 +286,37 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
qbz_models::RepeatMode::One => 2,
},
};
let rows = doc.upcoming.len() + doc.history.len() + usize::from(doc.has_current);
*LOCAL_ROW_IDS.lock().unwrap() = doc
.upcoming
.iter()
.chain(doc.history.iter())
.chain(doc.current.iter())
.filter(|r| r.is_local)
.filter_map(|r| canonical_u64(&r.id))
.collect();
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
// Identical document -> no Qt hop, no QML re-parse, no artwork re-dispatch.
// The compare/store and the Qt post stay under ONE lock so two concurrent
// publishes cannot post out of order and strand a stale document behind an
// up-to-date `LAST_JSON` (there is no `.await` in here, so the guard never
// crosses a yield point).
let mut last = LAST_JSON.lock().unwrap();
if *last == json {
log::debug!(
"[qbz-qt][perf] queue publish: unchanged, skipped ({rows} rows, {:?})",
t_build.elapsed()
);
return;
}
last.clear();
last.push_str(&json);
log::debug!(
"[qbz-qt][perf] queue publish: {rows} rows, {} favs, {} bytes, build {:?}",
favorites.len(),
json.len(),
t_build.elapsed()
);
crate::queue_bridge::ui(move |mut b| {
b.as_mut().set_queue_json(QString::from(json.as_str()));
});
@@ -277,11 +388,33 @@ pub async fn clear_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
}
/// Toggle a heart for a queue row (Qobuz favorite), then republish.
///
/// GUARD: a LOCAL/Plex row's `id` is a library.db row id which is NUMERIC,
/// so `library_qt::is_local_feed_id` (which recognizes local tracks only by
/// their non-numeric FILE-PATH feed ids) would classify it as a Qobuz id and
/// send `add_favorite("tracks", "<db row id>")` — favoriting an unrelated
/// catalog track. Local hearts have no seam in this port (`local_bulk.rs`
/// documents the same gap), so the mutation is refused here AND the row
/// publishes `isLocal` so the panel can drop the control instead of showing
/// one that cannot work.
pub async fn toggle_favorite(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
kind: &str,
id: &str,
) {
if kind == "track" {
let local = canonical_u64(id)
.map(|tid| LOCAL_ROW_IDS.lock().unwrap().contains(&tid))
.unwrap_or(false);
if local {
log::warn!(
"[qbz-qt] queue favorite: {id} is a LOCAL/Plex row — no local \
favorites seam in this port, refusing to route it to the \
Qobuz favorites API"
);
return;
}
}
let _ = crate::library_qt::toggle_favorite(runtime, kind, id).await;
publish(runtime).await;
}