diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 5e19291d8..eee42d59f 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -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>, + handle: &tokio::runtime::Handle, + ) { + if window.global::().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::().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::().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::().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::().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::().on_set_view_mode(move |mode| { if let Some(w) = weak.upgrade() { - w.global::().set_view_mode(mode); + w.global::().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::().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::().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::() + .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::() + .invoke_open_item("".into(), "album".into(), item_source_item_id); + return; + } + let id = w.global::().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 diff --git a/crates/qbz-slint/src/myqbz_detail.rs b/crates/qbz-slint/src/myqbz_detail.rs index b3ec07da7..29defabf0 100644 --- a/crates/qbz-slint/src/myqbz_detail.rs +++ b/crates/qbz-slint/src/myqbz_detail.rs @@ -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::::new())), } } @@ -424,6 +430,154 @@ pub fn selected_full_items(window: &AppWindow) -> Vec { }) } +// ──────────────────────── 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 { + 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( + window: &AppWindow, + source_item_id: &str, + f: F, +) { + let model = window.global::().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>, + weak: slint::Weak, + handle: tokio::runtime::Handle, +) { + let Some(window) = weak.upgrade() else { return }; + let model = window.global::().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 = 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` is `Send`; the mapped `Vec` 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 = 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) { diff --git a/crates/qbz-slint/src/myqbz_play.rs b/crates/qbz-slint/src/myqbz_play.rs index 4e3e8aa83..3ea14f9cc 100644 --- a/crates/qbz-slint/src/myqbz_play.rs +++ b/crates/qbz-slint/src/myqbz_play.rs @@ -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 { + 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 { + 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, + 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::() 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) { + 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) { + 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"); + } + } +} diff --git a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint index 160a193e6..09afdd1a0 100644 --- a/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint +++ b/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint @@ -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); + } + } + } + } } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index db83174bf..42ae1a4e7 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -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