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:
5 files changed
+555
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user