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

+79 -1
View File
@@ -2399,52 +2399,84 @@ fn wire_myqbz_detail(
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
}
// A toolbar re-derive rebuilds the rendered model with fresh rows
// (tracks_loaded reset to false). While in expanded view-mode the new
// visible rows must (re-)fetch their inline tracks (spec §8 auto-fetch).
fn ensure_expanded_if_active(
window: &AppWindow,
runtime: &Arc<AppRuntime<SlintAdapter>>,
handle: &tokio::runtime::Handle,
) {
if window.global::<MyQbzDetailState>().get_view_mode() == "expanded" {
myqbz_detail::ensure_expanded(runtime.clone(), window.as_weak(), handle.clone());
}
}
// --- Toolbar (client-side re-derive) --------------------------------
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_search_changed(move |q| {
if let Some(w) = weak.upgrade() {
myqbz_detail::search(&w, q.as_str());
refresh_row_covers(&w, &image_cache);
ensure_expanded_if_active(&w, &runtime, &handle);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_set_sort(move |field| {
if let Some(w) = weak.upgrade() {
myqbz_detail::set_sort(&w, field.as_str());
refresh_row_covers(&w, &image_cache);
ensure_expanded_if_active(&w, &runtime, &handle);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_set_type_filter(move |value| {
if let Some(w) = weak.upgrade() {
myqbz_detail::set_type_filter(&w, value.as_str());
refresh_row_covers(&w, &image_cache);
ensure_expanded_if_active(&w, &runtime, &handle);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_toggle_source_filter(move |kind| {
if let Some(w) = weak.upgrade() {
myqbz_detail::toggle_source_filter(&w, kind.as_str());
refresh_row_covers(&w, &image_cache);
ensure_expanded_if_active(&w, &runtime, &handle);
}
});
}
{
let weak = window.as_weak();
let runtime = app_runtime.clone();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_set_view_mode(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzDetailState>().set_view_mode(mode);
w.global::<MyQbzDetailState>().set_view_mode(mode.clone());
// Entering expanded mode: fetch every expandable item's tracks
// (spec §8 — tracks render directly under each row).
if mode == "expanded" {
myqbz_detail::ensure_expanded(runtime.clone(), weak.clone(), handle.clone());
}
}
});
}
@@ -2467,10 +2499,13 @@ fn wire_myqbz_detail(
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
let runtime = app_runtime.clone();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_reset_filters(move || {
if let Some(w) = weak.upgrade() {
myqbz_detail::reset_filters(&w);
refresh_row_covers(&w, &image_cache);
ensure_expanded_if_active(&w, &runtime, &handle);
}
});
}
@@ -2866,6 +2901,49 @@ fn wire_myqbz_detail(
);
});
}
// --- Expanded view-mode: inline tracks under every album/playlist (§8) -
// Fired when the expanded view-mode becomes active; fetches each
// expandable item's tracks (skipping already-cached rows).
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_ensure_expanded(move || {
myqbz_detail::ensure_expanded(runtime.clone(), weak.clone(), handle.clone());
});
}
// Inline-track row actions (play / play-next / play-later / go-to-album).
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<Act>()
.on_inline_track_action(move |item_source_item_id, track_id, action| {
let Some(w) = weak.upgrade() else { return };
// go-to-album routes through the existing open-item path (Qobuz
// album view vs local-album by id), keeping nav in one place.
if action == "go-to-album" {
w.global::<Act>()
.invoke_open_item("".into(), "album".into(), item_source_item_id);
return;
}
let id = w.global::<MyQbzDetailState>().get_id().to_string();
if id.is_empty() {
return;
}
myqbz_play::play_inline_track(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
item_source_item_id.to_string(),
track_id.to_string(),
action.to_string(),
);
});
}
}
/// Wire the Discography Builder (spec 13). Back -> browser-back (returns to the
+155 -1
View File
@@ -32,7 +32,7 @@ use qbz_models::mixtape::{
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState};
use crate::{AppWindow, ContentView, MixtapeDetailItem, MyQbzDetailState, NavState, TrackItem};
thread_local! {
/// The full, original-order item list for the open collection — the
@@ -175,6 +175,12 @@ fn to_item(item: &MixtapeCollectionItem) -> MixtapeDetailItem {
artwork_url: artwork_url.into(),
artwork: slint::Image::default(),
selected: false,
// Expanded-mode inline tracks (spec 12 §8): albums and playlists can
// host inline tracks; a bare track item is itself (no expansion).
can_expand: matches!(item.item_type, ItemType::Album | ItemType::Playlist),
tracks_loaded: false,
expand_loading: false,
inline_tracks: ModelRc::new(VecModel::from(Vec::<TrackItem>::new())),
}
}
@@ -424,6 +430,154 @@ pub fn selected_full_items(window: &AppWindow) -> Vec<MixtapeCollectionItem> {
})
}
// ──────────────────────── expanded-mode inline tracks ─────────────────────
/// The full `MixtapeCollectionItem` for one `source_item_id` (the row's stable
/// key). Sourced from `FULL_ITEMS` so the resolver gets the numeric
/// year/track_count + the typed item_type/source. UI thread.
fn full_item_by_source_id(source_item_id: &str) -> Option<MixtapeCollectionItem> {
FULL_ITEMS.with(|cell| {
cell.borrow()
.iter()
.find(|it| it.source_item_id == source_item_id)
.cloned()
})
}
/// "m:ss" track duration (spec 12 §8 `formatSec`, the common positive case;
/// the inline resolver always yields a concrete `duration_secs`).
fn track_duration_str(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
/// Title + parenthesized Qobuz version suffix (spec 12 §8 `formatTrackTitle`).
fn inline_track_title(track: &qbz_models::QueueTrack) -> String {
match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
}
}
/// Map one resolved `QueueTrack` into the shared `TrackItem` the inline
/// `TrackRow`s render. Quality tier/detail are derived the same way as the
/// now-playing + album-row badges (24-bit+ = Hi-Res), so the inline badge
/// matches every other surface. `source` drives the per-source `TrackRow`
/// affordances (Plex/local rows hide the favorite + offline columns).
fn track_to_item(track: &qbz_models::QueueTrack) -> TrackItem {
let quality_tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if track.hires => "hires",
None => "",
};
let quality_detail = if quality_tier.is_empty() {
String::new()
} else {
crate::quality::detail(track.bit_depth, track.sample_rate)
};
let source = track
.source
.clone()
.unwrap_or_else(|| if track.is_local { "local".into() } else { "qobuz".into() });
TrackItem {
id: track.id.to_string().into(),
number: String::new().into(),
title: inline_track_title(track).into(),
artist: track.artist.clone().into(),
album: String::new().into(),
duration: track_duration_str(track.duration_secs).into(),
quality_tier: quality_tier.into(),
quality_detail: quality_detail.into(),
explicit: track.parental_warning,
selected: false,
artwork_url: String::new().into(),
artwork: slint::Image::default(),
is_favorite: false,
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default().into(),
album_id: track.album_id.clone().unwrap_or_default().into(),
source: source.into(),
removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
}
}
/// Find the rendered row for `source_item_id` and mutate it in place. UI thread.
fn with_row_by_source_id<F: FnOnce(&mut MixtapeDetailItem)>(
window: &AppWindow,
source_item_id: &str,
f: F,
) {
let model = window.global::<MyQbzDetailState>().get_items();
for i in 0..model.row_count() {
if let Some(mut it) = model.row_data(i) {
if it.source_item_id == source_item_id {
f(&mut it);
model.set_row_data(i, it);
break;
}
}
}
}
/// Ensure every expandable item's inline tracks are loaded (spec 12 §8). Fired
/// when the "expanded" view-mode becomes active. For each rendered row that
/// `can_expand` and is not already loaded / loading, flips `expand-loading` on
/// and spawns a per-item fetch via the shared enqueue resolver
/// (`myqbz_play::fetch_item_tracks`); on completion it populates that row's
/// inline-tracks model + marks it loaded. Idempotent: already-cached rows are
/// skipped, so re-entering expanded mode is instant (and re-deriving the model
/// after a filter/sort resets `tracks_loaded`, so the new rows re-fetch).
pub fn ensure_expanded(
runtime: std::sync::Arc<qbz_app::shell::AppRuntime<crate::adapter::SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
) {
let Some(window) = weak.upgrade() else { return };
let model = window.global::<MyQbzDetailState>().get_items();
// Snapshot the rows that still need a fetch (source-item-ids), then mark
// them loading in one pass (mutating the model while iterating is fine —
// we set_row_data the same index we read).
let mut pending: Vec<String> = Vec::new();
for i in 0..model.row_count() {
if let Some(mut it) = model.row_data(i) {
if it.can_expand && !it.tracks_loaded && !it.expand_loading {
it.expand_loading = true;
let id = it.source_item_id.to_string();
model.set_row_data(i, it);
pending.push(id);
}
}
}
for source_item_id in pending {
let Some(full_item) = full_item_by_source_id(&source_item_id) else {
// No backing item (shouldn't happen) — clear the spinner.
with_row_by_source_id(&window, &source_item_id, |it| it.expand_loading = false);
continue;
};
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
// `Vec<QueueTrack>` is `Send`; the mapped `Vec<TrackItem>` carries
// a `slint::Image` (!Send), so it must be built INSIDE the event
// loop, not moved across the thread boundary.
let tracks = crate::myqbz_play::fetch_item_tracks(&runtime, &full_item).await;
let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<TrackItem> = tracks.iter().map(track_to_item).collect();
with_row_by_source_id(&w, &source_item_id, |it| {
it.expand_loading = false;
it.tracks_loaded = true;
it.inline_tracks = ModelRc::new(VecModel::from(items));
});
});
});
}
}
/// Clear the current selection (uncheck every row + zero the count), staying in
/// select-mode. Used after a bulk action completes. UI thread.
pub fn clear_selection(window: &AppWindow) {
+214
View File
@@ -170,6 +170,52 @@ async fn resolve_single_item(
}
}
/// Resolve a single item's tracks for the **expanded view-mode inline track
/// expansion** (spec 12 §8). Same resolver path as `resolve_single_item`
/// (Qobuz album/track/playlist + local/Plex via `resolve_local`), but used for
/// DISPLAY only — no queue mutation, no `source_item_id_hint` stamping. The
/// per-(item_type, source) routing the spec's `fetchTracksForItem` matrix
/// describes already lives inside the shared `ProdItemResolver::resolve` /
/// `resolve_local_item` (qobuz album->tracks, local album->tracks, plex
/// cache->tracks; a local/Plex playlist returns its own resolver error → []),
/// so this stays a thin wrapper. Returns the resolved tracks (empty on any
/// resolver error, so the caller shows the per-item "no results" state).
pub(crate) async fn fetch_item_tracks(
runtime: &Runtime,
item: &MixtapeCollectionItem,
) -> Vec<QueueTrack> {
use qbz_mixtape::enqueue::ItemResolver;
let client_lock = runtime.core().client();
let client = {
let guard = client_lock.read().await;
match guard.as_ref() {
Some(c) => c.clone(),
None => {
log::warn!("[qbz-slint] myqbz_play: no Qobuz client; cannot fetch item tracks");
// Local/Plex items resolve without the client, but the resolver
// needs a client ref to build, so bail empty (the caller shows
// the per-item empty state).
return Vec::new();
}
}
};
let resolver = ProdItemResolver::new(&client, resolve_local);
match resolver.resolve(item).await {
Ok(tracks) => tracks,
Err(e) => {
log::warn!(
"[qbz-slint] myqbz_play: fetch_item_tracks {}/{} failed: {}",
item.source_item_id,
item.title,
e
);
Vec::new()
}
}
}
/// Best-effort `repo::touch_play` (bumps last_played_at + play_count). Errors
/// ignored, exactly like the Tauri command. Runs synchronously via `with_db` —
/// safe to call from the async context (no `&Connection` crosses an `.await`).
@@ -326,6 +372,97 @@ pub fn item_action(
});
}
/// The inline-track menu mode (expanded view-mode TrackRow actions, spec §8
/// `menuActions`): play-now / play-next / play-later for ONE track resolved
/// from its parent item. (go-to-album routes through `open-item` in main.rs,
/// not here.)
enum InlineTrackMode {
Play,
PlayNext,
PlayLater,
}
impl InlineTrackMode {
fn parse(action: &str) -> Option<Self> {
match action {
"play" => Some(Self::Play),
"play-next" | "play_next" => Some(Self::PlayNext),
"play-later" | "play_later" | "queue" | "add-to-queue" | "append" => {
Some(Self::PlayLater)
}
_ => None,
}
}
}
/// Play / queue a SINGLE inline track from an expanded item (spec 12 §8
/// `onPlayTrackFromItem` / `onPlayTrackNext` / `onPlayTrackLater`). Re-resolves
/// the parent item's tracks (the inline view holds only display rows, not the
/// numeric `QueueTrack`s) and selects the one matching `track_id`:
/// - **Play**: replace-play just that track (no queue-source stamp, per-row).
/// - **PlayNext**: insert immediately after the current track.
/// - **PlayLater**: append at the end of the queue.
pub fn play_inline_track(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
collection_id: String,
item_source_item_id: String,
track_id: String,
action: String,
) {
let Some(mode) = InlineTrackMode::parse(&action) else {
log::warn!("[qbz-slint] myqbz_play: unknown inline-track action {action}");
return;
};
let Ok(track_id) = track_id.parse::<u64>() else {
log::warn!("[qbz-slint] myqbz_play: inline-track non-numeric id {track_id}");
return;
};
handle.spawn(async move {
let Some(collection) = load_collection(&collection_id).await else {
crate::toast::error_weak(&weak, "Couldn't load this collection");
return;
};
let Some(item) = collection
.items
.iter()
.find(|it| it.source_item_id == item_source_item_id)
.cloned()
else {
log::warn!(
"[qbz-slint] myqbz_play: inline-track item {item_source_item_id} not found"
);
return;
};
let tracks = fetch_item_tracks(&runtime, &item).await;
let Some(track) = tracks.into_iter().find(|t| t.id == track_id) else {
crate::toast::error_weak(&weak, "This track is no longer available");
return;
};
match mode {
InlineTrackMode::Play => {
let first_id = track.id;
runtime.core().set_queue(vec![track], Some(0)).await;
after_track_change(&runtime, &weak, first_id).await;
refresh_sidebar(true);
}
InlineTrackMode::PlayNext => {
runtime.core().add_track_next(track).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, "Playing next");
}
InlineTrackMode::PlayLater => {
runtime.core().add_tracks(vec![track]).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, "Added to queue");
}
}
});
}
/// Load a collection (items hydrated) off the UI/event-loop thread, on a
/// blocking worker, reusing the detail module's read path. Returns `None` when
/// the DB is unavailable or the id is unknown.
@@ -336,3 +473,80 @@ pub(crate) async fn load_collection(collection_id: &str) -> Option<MixtapeCollec
.ok()
.flatten()
}
// ─────────────────────── Part B: skip-to-ITEM (boundary nav) ───────────────
//
// Spec 40 §5.6 + §6 (`v2_skip_to_next_item` / `v2_skip_to_previous_item`):
// jump the queue cursor to the START of the next / previous ITEM (album /
// playlist / track group) rather than the next / previous TRACK. The boundary
// key per track = `source_item_id_hint` (stamped by the resolver), else the
// `album_id` fallback — both already on `QueueTrack`. The math is the PURE,
// already-shared `qbz_mixtape::enqueue::{next_item_index, previous_item_index}`
// (the 3-second prev rule lives there); these helpers only read the live queue
// from `qbz-core`, call that math, and `play_index` the target.
//
// **These NEVER touch the global `playback::next()` / `previous()`** — the
// normal transport stays track-by-track. They are headless entry points over
// `qbz-core` so a future UI trigger (or QConnect / CLI) can drive them without
// re-implementing the boundary detection.
//
// **UI trigger: DEFERRED.** Tauri registers both commands
// (`src-tauri/src/lib.rs`) but has ZERO frontend callsites — there is no
// skip-album button anywhere in the Tauri UI, so there is no faithful UI home
// to port 1:1. Forcing a button into the shared next/prev transport would risk
// the global transport for a behavior Tauri itself never surfaced. So the
// helpers land headless + tested-by-shared-crate; wiring a UI trigger waits
// until the product asks for one (then it calls these, no transport rewrite).
/// Skip to the START of the NEXT item in the live queue (spec 40 §6
/// `v2_skip_to_next_item`). Reads the current queue + cursor from `qbz-core`,
/// finds the first track whose item-boundary differs from the current item via
/// `next_item_index`, and `play_index`es it. No-op at the last item / empty
/// queue / no current cursor.
pub async fn skip_to_next_item(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
let (queue, current) = runtime.core().get_all_queue_tracks().await;
let Some(current) = current else {
log::debug!("[qbz-slint] myqbz_play: skip_to_next_item — no current track");
return;
};
match qbz_mixtape::enqueue::next_item_index(&queue, current) {
Some(target) => {
if let Some(track) = runtime.core().play_index(target).await {
let track_id = track.id;
after_track_change(runtime, weak, track_id).await;
refresh_sidebar(true);
}
}
None => {
log::debug!("[qbz-slint] myqbz_play: skip_to_next_item — already at last item");
}
}
}
/// Skip to the START of the PREVIOUS item (or restart the current one) in the
/// live queue (spec 40 §6 `v2_skip_to_previous_item`). Reads the current queue
/// + cursor + elapsed position from `qbz-core`, applies the 3-second prev rule
/// via `previous_item_index` (elapsed > 3s OR mid-item → restart current item;
/// else jump to the previous item's start), and `play_index`es the target.
pub async fn skip_to_previous_item(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
let (queue, current) = runtime.core().get_all_queue_tracks().await;
let Some(current) = current else {
log::debug!("[qbz-slint] myqbz_play: skip_to_previous_item — no current track");
return;
};
// `PlaybackState.position` is in whole seconds (same unit the seek path
// multiplies against `duration`); the boundary math wants elapsed ms.
let elapsed_ms = runtime.core().get_playback_state().position * 1_000;
match qbz_mixtape::enqueue::previous_item_index(&queue, current, elapsed_ms) {
Some(target) => {
if let Some(track) = runtime.core().play_index(target).await {
let track_id = track.id;
after_track_change(runtime, weak, track_id).await;
refresh_sidebar(true);
}
}
None => {
log::debug!("[qbz-slint] myqbz_play: skip_to_previous_item — no previous item");
}
}
}
@@ -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