diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs index f83842795..2c83512bd 100644 --- a/crates/qbz-slint/src/local_library.rs +++ b/crates/qbz-slint/src/local_library.rs @@ -12,7 +12,7 @@ //! button routes there. use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock, Mutex}; use qbz_app::shell::AppRuntime; use slint::{ComponentHandle, Model, ModelRc, VecModel}; @@ -429,6 +429,16 @@ const TRACKS_PAGE: u64 = 200; static TRACKS_GEN: AtomicU64 = AtomicU64::new(0); +/// The loaded `LocalTrack` rows backing `tracks`, kept in lockstep with the +/// paged model in apply/append. The selection-source for bulk queue/play-next/ +/// add-to-playlist (resolves ids -> LocalTrack with no DB round-trip). +static TRACKS_CURRENT: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +fn tracks_current() -> std::sync::MutexGuard<'static, Vec> { + TRACKS_CURRENT.lock().unwrap_or_else(|e| e.into_inner()) +} + fn fmt_duration(secs: u64) -> String { format!("{}:{:02}", secs / 60, secs % 60) } @@ -483,6 +493,8 @@ fn fetch_tracks_page(query: String, offset: u64) -> Option<(Vec, has_more: bool) { + // Keep the selection-source cache in lockstep (clone BEFORE the move). + *tracks_current() = rows.clone(); let items: Vec = rows.into_iter().map(map_local_track).collect(); let s = window.global::(); let n = items.len() as i32; @@ -492,9 +504,11 @@ fn apply_tracks(window: &AppWindow, rows: Vec, has_more s.set_tracks_loading(false); s.set_tracks_loading_more(false); s.set_tracks_load_failed(false); + derive_tracks(window); } fn append_tracks(window: &AppWindow, rows: Vec, has_more: bool) { + tracks_current().extend(rows.clone()); let new_items: Vec = rows.into_iter().map(map_local_track).collect(); let s = window.global::(); let model = s.get_tracks(); @@ -507,6 +521,164 @@ fn append_tracks(window: &AppWindow, rows: Vec, has_mor s.set_tracks_next_offset(s.get_tracks_next_offset() + added); s.set_tracks_has_more(has_more); s.set_tracks_loading_more(false); + derive_tracks(window); +} + +// --- Tracks group-by + multi-select (reduced port of favorites helpers) --- + +/// Re-derive the group-ordered + search-filtered `tracks-visible` render model +/// from the loaded `tracks`, plus the A-Z jump strip for name grouping. Uses +/// the local `folder_alpha_key`; no genre filter (no local genre surface). +pub fn derive_tracks(window: &AppWindow) { + let s = window.global::(); + let query_owned = s.get_tracks_search().to_lowercase(); + let query = query_owned.trim(); + let group = s.get_tracks_group_mode().to_string(); + let all = s.get_tracks(); + s.set_tracks_alpha(ModelRc::new(VecModel::from(Vec::::new()))); + + // Fast path: no search + no grouping -> share the loaded model. + if query.is_empty() && group == "off" { + s.set_tracks_visible(all); + return; + } + let mut filtered: Vec = (0..all.row_count()) + .filter_map(|i| all.row_data(i)) + .filter(|t| { + query.is_empty() + || t.title.to_lowercase().contains(query) + || t.artist.to_lowercase().contains(query) + || t.album.to_lowercase().contains(query) + }) + .collect(); + let lc = |s: &slint::SharedString| s.to_lowercase(); + match group.as_str() { + "album" => filtered + .sort_by(|a, b| lc(&a.album).cmp(&lc(&b.album)).then(lc(&a.title).cmp(&lc(&b.title)))), + "artist" => filtered.sort_by(|a, b| { + lc(&a.artist) + .cmp(&lc(&b.artist)) + .then(lc(&a.album).cmp(&lc(&b.album))) + .then(lc(&a.title).cmp(&lc(&b.title))) + }), + "name" => filtered.sort_by(|a, b| lc(&a.title).cmp(&lc(&b.title))), + _ => {} + } + if group == "name" { + let mut jumps: Vec = Vec::new(); + let mut last = String::new(); + for (i, t) in filtered.iter().enumerate() { + let key = folder_alpha_key(t.title.as_str()); + if key != last { + jumps.push(AlphaJump { + letter: key.clone().into(), + index: i as i32, + }); + last = key; + } + } + s.set_tracks_alpha(ModelRc::new(VecModel::from(jumps))); + } + s.set_tracks_visible(ModelRc::new(VecModel::from(filtered))); +} + +/// Set the group mode, persist it, re-derive. +pub fn set_tracks_group(window: &AppWindow, mode: &str) { + window.global::().set_tracks_group_mode(mode.into()); + crate::locallibrary_prefs::save(window); + derive_tracks(window); +} + +/// Enter/leave multi-select; leaving clears the selection. +pub fn set_tracks_multi_select(window: &AppWindow, on: bool) { + window.global::().set_tracks_multi_select(on); + if !on { + clear_tracks_selection(window); + } +} + +/// Recount selected visible rows into `tracks-selected-count`. +pub fn recount_tracks_selected(window: &AppWindow) { + let s = window.global::(); + let model = s.get_tracks_visible(); + let count = (0..model.row_count()) + .filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false)) + .count(); + s.set_tracks_selected_count(count as i32); +} + +/// Toggle one row's selection (by id) in the visible model. +pub fn toggle_track_select(window: &AppWindow, id: &str) { + let model = window.global::().get_tracks_visible(); + for i in 0..model.row_count() { + if let Some(mut item) = model.row_data(i) { + if item.id.as_str() == id { + item.selected = !item.selected; + model.set_row_data(i, item); + break; + } + } + } + recount_tracks_selected(window); +} + +/// Select-all toggle: select every visible row, or clear if all selected. +pub fn select_all_tracks(window: &AppWindow) { + let model = window.global::().get_tracks_visible(); + let total = model.row_count(); + let selected = (0..total) + .filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false)) + .count(); + let target = selected != total; + for i in 0..total { + if let Some(mut item) = model.row_data(i) { + if item.selected != target { + item.selected = target; + model.set_row_data(i, item); + } + } + } + recount_tracks_selected(window); +} + +/// Deselect every visible row (multi-select mode stays on). +pub fn clear_tracks_selection(window: &AppWindow) { + let s = window.global::(); + let model = s.get_tracks_visible(); + for i in 0..model.row_count() { + if let Some(mut item) = model.row_data(i) { + if item.selected { + item.selected = false; + model.set_row_data(i, item); + } + } + } + s.set_tracks_selected_count(0); +} + +/// The selected rows resolved to `LocalTrack`, in DISPLAY order (iterate the +/// visible model, look each selected id up in `TRACKS_CURRENT`). Deviates from +/// favorites' load-order resolution so the queue matches what the user sees. +pub fn selected_local_tracks(window: &AppWindow) -> Vec { + let model = window.global::().get_tracks_visible(); + let cache = tracks_current(); + let mut out = Vec::new(); + for i in 0..model.row_count() { + if let Some(item) = model.row_data(i) { + if item.selected { + let id = item.id.to_string(); + if let Some(t) = cache.iter().find(|t| t.id.to_string() == id) { + out.push(t.clone()); + } + } + } + } + out +} + +/// Resolve a single row id (display) to its `LocalTrack` from the cache. +pub fn local_track_by_id(id: &str) -> Option { + tracks_current().iter().find(|t| t.id.to_string() == id).cloned() } fn spawn_tracks_page_load(window: &AppWindow, handle: tokio::runtime::Handle, gen: u64) { diff --git a/crates/qbz-slint/src/locallibrary_prefs.rs b/crates/qbz-slint/src/locallibrary_prefs.rs new file mode 100644 index 000000000..29ddeeaee --- /dev/null +++ b/crates/qbz-slint/src/locallibrary_prefs.rs @@ -0,0 +1,69 @@ +//! Persistence for the LocalLibrary toolbar choices (Tracks group mode) across +//! restarts. A small json under `/qbz/locallibrary_ui.json`, mirroring +//! `favorites_prefs.rs`. Search queries are transient, not persisted. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use slint::ComponentHandle; + +use crate::{AppWindow, LocalLibraryState}; + +#[derive(Serialize, Deserialize)] +struct Prefs { + #[serde(default = "d_off")] + tracks_group: String, +} + +impl Default for Prefs { + fn default() -> Self { + Self { + tracks_group: d_off(), + } + } +} + +fn d_off() -> String { + "off".to_string() +} + +fn store_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("locallibrary_ui.json")) +} + +fn read() -> Prefs { + let Some(path) = store_path() else { + return Prefs::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Prefs::default(), + } +} + +/// Apply persisted toolbar choices to LocalLibraryState. UI thread. +pub fn load(window: &AppWindow) { + let p = read(); + window + .global::() + .set_tracks_group_mode(p.tracks_group.into()); +} + +/// Persist the current toolbar choices read from LocalLibraryState. +pub fn save(window: &AppWindow) { + let Some(path) = store_path() else { + return; + }; + let p = Prefs { + tracks_group: window + .global::() + .get_tracks_group_mode() + .into(), + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_vec_pretty(&p) { + let _ = std::fs::write(&path, json); + } +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 0af474016..348c8b09d 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -45,6 +45,7 @@ mod folders; mod library_db; mod local_library; mod local_library_settings; +mod locallibrary_prefs; mod tag_editor; mod offline; mod offline_cache; @@ -959,6 +960,8 @@ fn navigate_local_library( ) { let tab_id = tab.tab_id().to_string(); let _ = weak.upgrade_in_event_loop(move |w| { + // Restore the persisted Tracks group-by before the tab derives. + locallibrary_prefs::load(&w); w.global::().set_active_tab(tab_id.into()); w.global::().set_view(ContentView::LocalLibrary); }); @@ -4314,29 +4317,122 @@ fn main() -> Result<(), Box> { window .global::() .on_track_action(move |id, action| { - if action.as_str() == "play" { - if let Ok(row_id) = id.parse::() { - // Queue the current (searched) list so playback - // continues down it from the clicked row. - let query = weak - .upgrade() - .map(|w| { - w.global::() - .get_tracks_search() - .to_string() - }) - .unwrap_or_default(); - playback::play_local_tracks_from( - runtime.clone(), - weak.clone(), - handle.clone(), - query, - row_id, - ); + match action.as_str() { + "play" => { + if let Ok(row_id) = id.parse::() { + // Queue the current (searched) list so playback + // continues down it from the clicked row. + let query = weak + .upgrade() + .map(|w| { + w.global::() + .get_tracks_search() + .to_string() + }) + .unwrap_or_default(); + playback::play_local_tracks_from( + runtime.clone(), + weak.clone(), + handle.clone(), + query, + row_id, + ); + } } - } else { - // play-next / queue land with a later slice. - log::debug!("[qbz-slint] local track action (queue slice pending): {id} {action}"); + "toggle-select" => { + if let Some(w) = weak.upgrade() { + local_library::toggle_track_select(&w, id.as_str()); + } + } + "play-next" | "queue" => { + // Resolve the row from the loaded cache (no DB) and enqueue. + if let Some(row) = local_library::local_track_by_id(id.as_str()) { + playback::enqueue_local_tracks( + runtime.clone(), + handle.clone(), + vec![row], + action.as_str() == "play-next", + ); + } + } + _ => { + log::debug!("[qbz-slint] unhandled local track action: {id} {action}"); + } + } + }); + } + + // ---- Tracks tab: group-by + multi-select + bulk ---- + { + let weak = window.as_weak(); + window + .global::() + .on_tracks_set_group(move |mode| { + if let Some(w) = weak.upgrade() { + local_library::set_tracks_group(&w, mode.as_str()); + } + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_tracks_toggle_multi_select(move || { + if let Some(w) = weak.upgrade() { + let on = w.global::().get_tracks_multi_select(); + local_library::set_tracks_multi_select(&w, !on); + } + }); + } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_tracks_bulk_action(move |action| { + let Some(w) = weak.upgrade() else { + return; + }; + match action.as_str() { + "select-all" => local_library::select_all_tracks(&w), + "clear" => local_library::clear_tracks_selection(&w), + "queue" => { + let rows = local_library::selected_local_tracks(&w); + playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, false); + local_library::clear_tracks_selection(&w); + } + "play-next" => { + let rows = local_library::selected_local_tracks(&w); + playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, true); + local_library::clear_tracks_selection(&w); + } + "add-to-playlist" => { + // Plex rows can't be added (no local add path); skip them, + // deciding on the underlying LocalTrack.source. + let rows = local_library::selected_local_tracks(&w); + let ids: Vec = rows + .iter() + .filter(|t| t.source.as_deref() != Some("plex")) + .map(|t| t.id.to_string()) + .collect(); + let skipped = rows.len() - ids.len(); + if skipped > 0 { + log::info!("[qbz-slint] add-to-playlist skipped {skipped} Plex track(s)"); + } + if !ids.is_empty() { + playlist_picker::open_multi(&w, &ids, true); + let runtime = runtime.clone(); + let weak2 = weak.clone(); + handle.spawn(async move { + let playlists = playlist_picker::load(&runtime).await; + let _ = weak2.upgrade_in_event_loop(move |w| { + playlist_picker::apply(&w, playlists); + }); + }); + } + } + _ => {} } }); } @@ -4567,22 +4663,51 @@ fn main() -> Result<(), Box> { return; }; let picker = w.global::(); - // Bulk add (favorites) carries track-ids; single add carries - // track-id. + let is_local = picker.get_local_mode(); + // Bulk add carries track-ids; single add carries track-id. let ids_model = picker.get_track_ids(); + let track_id_single = picker.get_track_id().to_string(); + picker.set_open(false); + let Ok(pid) = playlist_id.parse::() else { + return; + }; + + if is_local { + // Local row ids are i64 -> add_local_track_to_playlist. + let ids_i64: Vec = (0..ids_model.row_count()) + .filter_map(|i| ids_model.row_data(i)) + .filter_map(|s| s.parse::().ok()) + .collect(); + if ids_i64.is_empty() { + return; + } + handle.spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + crate::library_db::with_db(|db| { + for (pos, lid) in ids_i64.iter().enumerate() { + db.add_local_track_to_playlist(pid, *lid, pos as i32)?; + } + Ok(()) + }) + }) + .await; + }); + return; + } + + // Qobuz path. let mut ids: Vec = (0..ids_model.row_count()) .filter_map(|i| ids_model.row_data(i)) .filter_map(|s| s.parse::().ok()) .collect(); if ids.is_empty() { - if let Ok(tid) = picker.get_track_id().parse::() { + if let Ok(tid) = track_id_single.parse::() { ids.push(tid); } } - picker.set_open(false); - let (Ok(pid), false) = (playlist_id.parse::(), ids.is_empty()) else { + if ids.is_empty() { return; - }; + } let runtime = runtime.clone(); handle.spawn(async move { if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &ids).await { @@ -5795,7 +5920,7 @@ fn main() -> Result<(), Box> { "add-to-playlist" => { let ids = favorites::selected_ids(&w); if !ids.is_empty() { - playlist_picker::open_multi(&w, &ids); + playlist_picker::open_multi(&w, &ids, false); let runtime = runtime.clone(); let weak = weak.clone(); handle.spawn(async move { diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 50679293a..e60895117 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -1164,6 +1164,39 @@ pub fn enqueue_tracks( }); } +/// Append (or insert-next) a batch of already-loaded LocalLibrary rows to the +/// queue. Mirrors `enqueue_tracks` but for `LocalTrack`: `local_queue_track` +/// builds source-aware QueueTracks (is_local=true; "local"/"qobuz_download") +/// so `play_audible` routes user files through the protected `play_data` seam +/// and offline copies through `play_track_resolved`. Reversed for "play next" +/// to preserve selection order. +pub fn enqueue_local_tracks( + runtime: Runtime, + handle: tokio::runtime::Handle, + tracks: Vec, + next: bool, +) { + if tracks.is_empty() { + return; + } + handle.spawn(async move { + let ordered: Vec = if next { + tracks.into_iter().rev().collect() + } else { + tracks + }; + for track in &ordered { + let qt = local_queue_track(track); + if next { + runtime.core().add_track_next(qt).await; + } else { + runtime.core().add_track(qt).await; + } + } + refresh_sidebar(false); + }); +} + /// Toggle play / pause on the live player. pub fn toggle_play_pause(runtime: Runtime, handle: tokio::runtime::Handle) { handle.spawn(async move { diff --git a/crates/qbz-slint/src/playlist_picker.rs b/crates/qbz-slint/src/playlist_picker.rs index 78f1524cf..bac58e440 100644 --- a/crates/qbz-slint/src/playlist_picker.rs +++ b/crates/qbz-slint/src/playlist_picker.rs @@ -20,17 +20,21 @@ pub fn open(window: &AppWindow, track_id: &str) { state.set_track_id(track_id.into()); state.set_track_ids(ModelRc::new(VecModel::from(Vec::::new()))); state.set_playlists(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_local_mode(false); state.set_loading(true); state.set_open(true); } -/// Open the picker for a batch of track ids (favorites bulk add). UI thread. -pub fn open_multi(window: &AppWindow, ids: &[String]) { +/// Open the picker for a batch of track ids (bulk add). `local` routes the ids +/// as LocalLibrary row ids (i64) to `add_local_track_to_playlist` instead of +/// the Qobuz endpoint. UI thread. +pub fn open_multi(window: &AppWindow, ids: &[String], local: bool) { let state = window.global::(); state.set_track_id("".into()); let model: Vec = ids.iter().map(|s| s.clone().into()).collect(); state.set_track_ids(ModelRc::new(VecModel::from(model))); state.set_playlists(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_local_mode(local); state.set_loading(true); state.set_open(true); } diff --git a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint index a6d87c2e2..d07c59615 100644 --- a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint @@ -28,6 +28,7 @@ import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { ToggleButton } from "../primitives/ToggleButton.slint"; +import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { ListView } from "std-widgets.slint"; // One tab in the Local Library tab menu — underline style (ADR-008: NO @@ -506,11 +507,20 @@ export component LocalLibraryView inherits Rectangle { } } - // Tracks toolbar — search. + // Tracks toolbar — multi-select toggle + search + group-by. if LocalLibraryState.active-tab == "tracks" && (LocalLibraryState.tracks.length > 0 || LocalLibraryState.tracks-search != ""): HorizontalLayout { spacing: 8px; + VerticalLayout { + alignment: center; + CircleIconBtn { + icon: @image-url("../assets/icons/square-check-big.svg"); + clicked => { + LocalLibraryActions.tracks-toggle-multi-select(); + } + } + } VerticalLayout { alignment: center; ExpandableSearch { @@ -522,6 +532,27 @@ export component LocalLibraryView inherits Rectangle { } } } + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 160px; + sm: true; + options: [ + @tr("No grouping"), + @tr("By album"), + @tr("By artist"), + @tr("By name"), + ]; + current-index: LocalLibraryState.tracks-group-mode == "album" ? 1 + : LocalLibraryState.tracks-group-mode == "artist" ? 2 + : LocalLibraryState.tracks-group-mode == "name" ? 3 + : 0; + selected(i) => { + LocalLibraryActions.tracks-set-group( + i == 1 ? "album" : i == 2 ? "artist" : i == 3 ? "name" : "off"); + } + } + } } // Folders toolbar — search (both modes); flat adds sort + group + @@ -846,20 +877,43 @@ export component LocalLibraryView inherits Rectangle { horizontal-alignment: center; } } + // No-match (search/filter emptied the visible set). if !LocalLibraryState.tracks-loading && !LocalLibraryState.tracks-load-failed - && LocalLibraryState.tracks.length > 0: Rectangle { - // Padded content, but the scrollbar floats at the view's - // TRUE right edge — matching the Albums grid, which is the - // chrome-bezel standard. (Previously it was inset by the - // 32px content padding, sitting too far into the content.) + && LocalLibraryState.tracks.length > 0 + && LocalLibraryState.tracks-visible.length == 0: VerticalLayout { + alignment: center; + Text { + text: @tr("No tracks match your search."); + color: Theme.text-muted; + font-size: Typography.body; + horizontal-alignment: center; + } + } + if !LocalLibraryState.tracks-loading + && !LocalLibraryState.tracks-load-failed + && LocalLibraryState.tracks-visible.length > 0: Rectangle { VerticalLayout { padding-left: 32px; padding-right: 32px; padding-top: 16px; + // Bulk action bar (Tauri Tracks subset — no make-offline/remove). + if LocalLibraryState.tracks-multi-select: MultiSelectBar { + selected-count: LocalLibraryState.tracks-selected-count; + actions: [ + { id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false }, + { id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true }, + { id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true }, + { id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true }, + { id: "clear", label: @tr("Clear"), danger: false, needs-selection: true }, + ]; + action(id) => { + LocalLibraryActions.tracks-bulk-action(id); + } + } + if LocalLibraryState.tracks-multi-select: Rectangle { height: 8px; } tracks-list := ListView { - width: 100%; - height: 100%; + vertical-stretch: 1; vertical-scrollbar-policy: ScrollBarPolicy.always-off; changed viewport-y => { if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px @@ -869,28 +923,75 @@ export component LocalLibraryView inherits Rectangle { LocalLibraryActions.tracks-load-more(); } } - for track[i] in LocalLibraryState.tracks: TrackRow { - track: track; - index: i; - show-artwork: false; - show-album: true; - show-favorite: false; - show-download: false; - show-source: true; - media-action(kind, id, action) => { - if (kind == "album" && action == "open") { - LocalLibraryActions.open-album(id); - } else if (kind == "artist" && action == "open") { - LocalLibraryActions.open-artist(id); - } else { - LocalLibraryActions.track-action(id, action); + for track[i] in LocalLibraryState.tracks-visible: VerticalLayout { + spacing: 0px; + // Group header rows for album / artist grouping. + if LocalLibraryState.tracks-group-mode == "album" && track.album != "" + && (i == 0 || LocalLibraryState.tracks-visible[i - 1].album != track.album): Rectangle { + height: 32px; + Text { + x: 2px; + width: parent.width - 4px; + height: 100%; + text: track.album; + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.semibold; + vertical-alignment: center; + overflow: elide; + } + } + if LocalLibraryState.tracks-group-mode == "artist" && track.artist != "" + && (i == 0 || LocalLibraryState.tracks-visible[i - 1].artist != track.artist): Rectangle { + height: 32px; + Text { + x: 2px; + width: parent.width - 4px; + height: 100%; + text: track.artist; + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.semibold; + vertical-alignment: center; + overflow: elide; + } + } + TrackRow { + track: track; + index: i; + show-artwork: false; + show-album: LocalLibraryState.tracks-group-mode != "album"; + show-favorite: false; + show-download: false; + show-source: true; + multi-select-mode: LocalLibraryState.tracks-multi-select; + media-action(kind, id, action) => { + if (kind == "album" && action == "open") { + LocalLibraryActions.open-album(id); + } else if (kind == "artist" && action == "open") { + LocalLibraryActions.open-artist(id); + } else { + LocalLibraryActions.track-action(id, action); + } } } } } } + // A-Z jump strip for name grouping (far right; scrollbar insets). + if LocalLibraryState.tracks-group-mode == "name" + && LocalLibraryState.tracks-alpha.length > 0: AlphaStrip { + x: parent.width - self.width - 2px; + height: 100%; + jumps: LocalLibraryState.tracks-alpha; + jump(ord, idx) => { + tracks-list.viewport-y = -idx * 50px; + } + } ListScrollbar { - x: parent.width - self.width - 4px; + x: LocalLibraryState.tracks-group-mode == "name" + ? parent.width - self.width - 22px + : parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: tracks-list.viewport-height; diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index b198d704b..ef8067797 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -414,6 +414,13 @@ export global LocalLibraryState { in property tracks-load-failed: false; in-out property tracks-next-offset: 0; in-out property tracks-search: ""; + // Group-by + multi-select (this slice). `tracks-visible` is the + // group-ordered + search-filtered render model (the list renders THIS). + in property <[TrackItem]> tracks-visible: []; + in-out property tracks-group-mode: "off"; // off | album | artist | name + in property <[AlphaJump]> tracks-alpha: []; // A-Z jumps (name grouping) + in-out property tracks-multi-select: false; // checkbox column on + in property tracks-selected-count: 0; // bar count + select-all // --- Folders tab --------------------------------------------------- // flat = album grid grouped by directory; tree = two-pane lazy filesystem. @@ -491,8 +498,14 @@ export global LocalLibraryActions { callback tracks-search(string /* query */); callback tracks-load-more(); callback tracks-retry(); - // Per-row play/queue — local playback lands with the source-aware slice. + // Per-row play/queue/toggle-select (action: play|play-next|queue|toggle-select). callback track-action(string /* track id */, string /* action */); + // Group-by mode (off|album|artist|name), persisted. + callback tracks-set-group(string /* mode */); + // Toggle the multi-select checkbox column. + callback tracks-toggle-multi-select(); + // Bulk bar action: select-all | clear | queue | play-next | add-to-playlist. + callback tracks-bulk-action(string /* action */); // --- Folders tab --- callback folders-search(string /* query */); @@ -1809,6 +1822,9 @@ export global PlaylistPickerState { in property <[string]> track-ids: []; // bulk add (favorites multi-select) in property <[PlaylistPickItem]> playlists: []; in property loading: false; + // True when track-ids are LocalLibrary row ids (i64) -> route to + // add_local_track_to_playlist instead of the Qobuz endpoint. + in property local-mode: false; } export global PlaylistPickerActions {