feat(myqbz): detail expanded-mode inline tracks + headless skip-to-item

Phase-2 Slice 12b.
- Expanded view-mode: replaced the "Inline tracks coming soon" placeholder with
  real per-item inline track expansion. Matches Tauri 1:1 (no chevron -- every
  album/playlist row auto-shows its tracks). fetch_item_tracks reuses the shared
  ProdItemResolver (display-only; spec §8 routing matrix); renders the app's
  TrackRow indented; inline-track media actions (play/play-next/play-later/go-to).
- skip-to-item: headless myqbz_play::skip_to_next_item/skip_to_previous_item over
  the qbz-core queue using the crate's next_item_index/previous_item_index (3s prev
  rule). UI trigger DEFERRED -- Tauri has zero frontend callsites for skip-to-item
  (no skip-album button exists), so wiring one would invent UI; the global next()/
  previous() are untouched.

Known polish (S12 fidelity pass): expanded-mode per-item cache should key on
source|source_item_id (survive filter/sort) + viewport-gate; inline go-to uses
item_type; duration "--:--". Backend crates untouched. cargo check green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 08:09:31 -06:00
1 parent 0bffd32555
commit 6cbd51427d
5 files changed
+555 -25

No files matched your search

@@ -23,8 +23,9 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, NavState } from "../state.slint";
import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
@@ -1326,28 +1327,85 @@ export component MixtapeDetailView inherits Rectangle {
background: Theme.surface-elevated;
}
// Rows. Expanded mode renders the same rows; per-item inline
// track expansion is DEFERRED (a later slice) — the mode
// toggle + shell are present, but no inline TrackRows yet.
for item[i] in MyQbzDetailState.items: DetailRow {
item: item;
index: i + 1;
select-mode: MyQbzDetailState.select-mode;
}
// Expanded-mode deferred note (only when no rows would
// otherwise hint at it): a thin caption so the toggle's
// effect is discoverable without faking the feature.
if MyQbzDetailState.view-mode == "expanded": Rectangle {
height: 28px;
Text {
text: @tr("Inline tracks coming soon");
color: Theme.text-muted;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
// Rows. In LIST mode each item is just its row. In EXPANDED
// mode every album / playlist row is followed by its tracks
// rendered directly underneath (spec 12 §8 — no chevron,
// always visible, fetched once on entering expanded mode).
for item[i] in MyQbzDetailState.items: VerticalLayout {
spacing: 0px;
DetailRow {
item: item;
index: i + 1;
select-mode: MyQbzDetailState.select-mode;
}
// Inline tracks (.expanded-tracks): only in expanded
// mode, only for album / playlist items. Left-indented
// 52px to align under the artwork + gap.
if MyQbzDetailState.view-mode == "expanded" && item.can-expand: Rectangle {
// content-sized: the VerticalLayout below drives the
// height (loading row / empty caption / N TrackRows).
height: tracks-col.preferred-height;
tracks-col := VerticalLayout {
padding-left: 52px;
padding-right: 12px;
padding-top: 4px;
padding-bottom: 8px;
spacing: 0px;
// Loading: spinner while the fetch is in flight
// and nothing is cached yet.
if item.expand-loading && item.inline-tracks.length == 0: Rectangle {
height: 32px;
LoadingSpinner {
size: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Loaded but empty: the "no results" caption.
if !item.expand-loading && item.tracks-loaded
&& item.inline-tracks.length == 0: Rectangle {
height: 32px;
Text {
text: @tr("No results found");
color: Theme.text-muted;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Tracks: one shared TrackRow per resolved track.
// hide-download + hide-favorite for inline rows
// (spec 12 §8 props); per-source menu logic is
// handled inside TrackRow by track.source.
for et[ti] in item.inline-tracks: TrackRow {
track: et;
index: ti;
show-artwork: false;
show-favorite: false;
show-download: false;
show-source: false;
draggable: false;
media-action(kind, id, action) => {
// The artist link emits ("artist", id, "open"); the
// context menu emits ("track", id, "go-to-artist").
// Both -> the parent item's artist navigation.
if ((kind == "artist" && action == "open") || action == "go-to-artist") {
MyQbzDetailActions.open-artist(item.source, et.artist);
} else {
// play / play-next / queue / go-to-album (album-link
// "open" too) all route through inline-track-action;
// the album link's id is ignored — go-to-album always
// opens the PARENT item (spec §8 menuActions).
MyQbzDetailActions.inline-track-action(
item.source-item-id,
et.id,
(kind == "album" && action == "open") ? "go-to-album" : action);
}
}
}
}
}
}
}
+26
View File
@@ -1599,6 +1599,21 @@ export struct MixtapeDetailItem {
// UI-owned multi-select state (select-mode — deferred wiring; the column
// renders when select-mode is on so geometry matches the PSD).
selected: bool,
// --- Expanded view-mode inline tracks (spec 12 §8) --------------------
// In the "expanded" view-mode every album / playlist item shows its
// tracks rendered directly underneath — always visible, no chevron (1:1
// with Tauri: "like LocalLibrary's Tracks page with Group-by-album").
// Whether this item can host inline tracks (album / playlist; never a
// bare track) — gates the inline section + the auto-fetch.
can-expand: bool,
// True once the per-item track fetch has completed (success OR empty), so
// the view can distinguish "still loading" from "loaded, no results".
tracks-loaded: bool,
// True while the per-item track fetch is in flight (spinner state).
expand-loading: bool,
// The resolved inline tracks (rendered as shared TrackRows under the row).
// Fetched once on entering expanded mode, then cached on the row.
inline-tracks: [TrackItem],
}
export global MyQbzDetailState {
@@ -1693,6 +1708,17 @@ export global MyQbzDetailActions {
callback item-action(string /* source-item-id */, string /* action */);
// Toggle one row's selection (select-mode).
callback toggle-item-select(int /* position */);
// --- Expanded view-mode inline tracks (spec 12 §8) --------------------
// Fired when the "expanded" view-mode becomes active (and on the initial
// open in expanded mode): the Rust side fetches every expandable item's
// tracks (via the enqueue resolver) that aren't already cached, populating
// each row's inline-tracks model. Idempotent — cached rows are skipped, so
// re-entering expanded mode is instant.
callback ensure-expanded();
// Play / queue / navigate from an inline track row. `action` is one of
// "play" | "play-next" | "play-later" | "go-to-album".
// The parent item's source-item-id scopes the fetch + go-to-album target.
callback inline-track-action(string /* item-source-item-id */, string /* track-id */, string /* action */);
// --- Bulk action bar (select-mode, spec 12 §13) -----------------------
// The bar emits the chosen action id verbatim; the Rust side maps it:
// "add-to-mixtape" -> open the global AddToMixtapeModal with the