feat(qt): cortinilla local row click routing

Local rows route through the local seams, never the Qobuz ones. Their ids
live in a different space entirely: an album id is a group key, a track id is
a library row id, and an artist row has NO id at all. Handing any of them to
the catalog routes would 404 or, worse, open a stranger's album that happens
to share the number.

  - album  -> the local album view, by group key.
  - artist -> the LocalLibrary Artists tab BY NAME, because there is no id to
              route by.
  - track  -> played from the per-query snapshot.

Playing from the snapshot rather than re-resolving the id is deliberate: the
snapshot is the exact vector the payload was built from, so the queue matches
what the user is looking at - including the Plex rows that were prepended,
whose order a fresh lookup would not reproduce. The queue is the WHOLE
fetched vector starting at the clicked row, which is the reference's
behaviour: the dropdown shows three tracks, but activating one gives a real
queue instead of an orphan.

The position lookup is STRICT. If the clicked row is not in the snapshot it
logs and does nothing; falling back to index 0 would silently play a
different track than the one under the cursor, which is worse than not
responding.

Local rows stay out of the learned ranking, which the existing guard already
handled - it was dead code until now because no local rows existed. The
reason is the same id-space problem: a bucket keyed on a local id could never
be matched again, and a local artist has no id to key on.

Playlist rows are no longer inert. The note calling them impossible said
there was no playlist view in the port; open_playlist has existed and
PlaylistView.qml ships, so the row now opens the playlist like every other
surface does. PARITY-DEBT row 28 loses one of its sites.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C8
(sections 3.1-3.3, 3.6, R7-2).

Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.

cc
This commit is contained in:
vicrodh committed 2026-08-03 17:23:31 -06:00
1 parent 15610bc7e9
commit 7e58f2f43c
2 files changed
+59 -4

No files matched your search

+6 -1
View File
@@ -375,7 +375,12 @@ pub async fn play_current_if_local(runtime: &Runtime, track_id: u64) -> bool {
/// Set the queue to `tracks` and start `start` (playback.rs
/// `play_local_tracks_now`: `set_queue` then the AUDIBLE step).
async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, start: usize, shuffle: bool) {
pub(crate) async fn play_rows(
runtime: &Runtime,
tracks: Vec<LocalTrack>,
start: usize,
shuffle: bool,
) {
if tracks.is_empty() {
return;
}
+53 -3
View File
@@ -952,8 +952,13 @@ fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f64 {
}
/// Click / Enter on a row: record the interaction (Capa B) and dispatch by
/// kind (album -> album view, artist -> artist view, track -> play,
/// playlist -> inert POC-NOTE). The QML clears the input + closes itself.
/// kind. Qobuz rows go to the catalog views; LOCAL rows go to the local seams
/// (album by group key, artist BY NAME, track from the snapshot). The QML
/// clears the input and closes itself.
///
/// Local rows are deliberately NOT recorded into the learned ranking: their
/// ids are in a different space from the catalog's, and a local artist row
/// has no id at all, so a bucket keyed on them could never be matched again.
pub fn row_clicked(flat_index: i32) {
let snap = LAST_CORT.lock().unwrap_or_else(|e| e.into_inner()).clone();
let Some(data) = snap else { return };
@@ -973,6 +978,22 @@ pub fn row_clicked(flat_index: i32) {
};
record(&data.query, &row.kind, &row.id, action);
}
// LOCAL rows route through the local seams, never the Qobuz ones: their
// ids live in a different space entirely (an album id is a group key, an
// artist row has NO id, a track id is a library row id). Handing any of
// them to the catalog routes would 404 or, worse, open someone else's
// album that happens to share the number.
if row.source == "local" {
match row.kind.as_str() {
// The group key opens the local album view directly.
"album" => crate::local_bridge::open_album_by_id(row.id),
// BY NAME — local artists have no id (search_local.rs).
"artist" => crate::local_album_actions::open_artist_by_name(row.title),
"track" => play_local_row(&row.id),
_ => {}
}
return;
}
match row.kind.as_str() {
"album" => crate::open_album(row.id),
"artist" => crate::open_artist(row.id),
@@ -981,11 +1002,40 @@ pub fn row_clicked(flat_index: i32) {
crate::play_track(id);
}
}
// playlist: no playlist view in the POC (POC-NOTE — inert).
"playlist" => crate::open_playlist(row.id),
_ => {}
}
}
/// Play a local cortinilla track row from the per-query snapshot.
///
/// The row id is NOT re-resolved against the database: the snapshot is the
/// exact vector the payload was built from, so playing from it guarantees the
/// queue matches what the user is looking at — including the Plex rows that
/// were prepended, which a fresh lookup by id would not reproduce in order.
///
/// The queue is the WHOLE fetched vector (76 rows normally, 136 expanded)
/// starting at the clicked row, which is the reference's behaviour: the
/// dropdown shows three of them, but activating one gives you a real queue
/// rather than a single orphan track.
fn play_local_row(row_id: &str) {
let rows = LAST_CORT_LOCAL
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
// STRICT position: if the clicked row is not in the snapshot, do nothing.
// Falling back to 0 would silently play a DIFFERENT track than the one
// under the cursor, which is worse than not responding.
let Some(start) = rows.iter().position(|t| t.id.to_string() == row_id) else {
log::warn!("[qbz-qt] cortinilla: local row {row_id} not in the snapshot — ignored");
return;
};
let runtime = crate::app();
crate::spawn(async move {
crate::local_playback::play_rows(&runtime, rows, start, false).await;
});
}
/// "View more" on a section: full search page on the matching tab
/// (album -> 1, track -> 2, artist -> 3, playlist -> 4).
pub async fn view_more(runtime: &Arc<AppRuntime<LoggingAdapter>>, kind: &str) {