feat(locallibrary): Tracks tab — group-by + multi-select + bulk actions
Bring the Tracks tab up to the Favorites precedent. - Group-by (Off/Album/Artist/Name) with visible album/artist header rows + an A-Z jump strip for Name (over the loaded set); show-album hidden under album grouping. Group mode persists via a new locallibrary_prefs.rs (favorites_prefs precedent; default "off"), loaded in navigate_local_library before the tab derives. - Multi-select toggle (toolbar) + per-row checkbox (TrackRow multi-select-mode) + MultiSelectBar with the Tauri Tracks subset: Select all / Add to queue / Play next / Add to playlist / Clear (no make-offline, no remove). - derive_tracks (reduced port: local folder_alpha_key, no genre filter) + TRACKS_CURRENT selection-source cache (cloned in apply/append before the move) so bulk resolves ids -> LocalTrack in DISPLAY order with no DB round-trip. - playback::enqueue_local_tracks (source-aware via local_queue_track; reversed for play-next); per-row play-next/queue wired in on_track_action. - Add-to-playlist for local + qobuz_download via PlaylistPickerState.local-mode + on_pick i64 branch -> db.add_local_track_to_playlist; Plex rows skipped (decided on the underlying LocalTrack.source, not the remapped TrackItem one). - No-match empty state added. cc
This commit is contained in:
7 files changed
+577
-57
No files matched your search
@@ -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<Mutex<Vec<qbz_library::LocalTrack>>> =
|
||||
LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
fn tracks_current() -> std::sync::MutexGuard<'static, Vec<qbz_library::LocalTrack>> {
|
||||
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<qbz_library::Loc
|
||||
}
|
||||
|
||||
fn apply_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, has_more: bool) {
|
||||
// Keep the selection-source cache in lockstep (clone BEFORE the move).
|
||||
*tracks_current() = rows.clone();
|
||||
let items: Vec<TrackItem> = rows.into_iter().map(map_local_track).collect();
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let n = items.len() as i32;
|
||||
@@ -492,9 +504,11 @@ fn apply_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, 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<qbz_library::LocalTrack>, has_more: bool) {
|
||||
tracks_current().extend(rows.clone());
|
||||
let new_items: Vec<TrackItem> = rows.into_iter().map(map_local_track).collect();
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let model = s.get_tracks();
|
||||
@@ -507,6 +521,164 @@ fn append_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, 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::<LocalLibraryState>();
|
||||
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::<AlphaJump>::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<TrackItem> = (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<AlphaJump> = 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::<LocalLibraryState>().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::<LocalLibraryState>().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::<LocalLibraryState>();
|
||||
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::<LocalLibraryState>().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::<LocalLibraryState>().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::<LocalLibraryState>();
|
||||
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<qbz_library::LocalTrack> {
|
||||
let model = window.global::<LocalLibraryState>().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<qbz_library::LocalTrack> {
|
||||
tracks_current().iter().find(|t| t.id.to_string() == id).cloned()
|
||||
}
|
||||
|
||||
fn spawn_tracks_page_load(window: &AppWindow, handle: tokio::runtime::Handle, gen: u64) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Persistence for the LocalLibrary toolbar choices (Tracks group mode) across
|
||||
//! restarts. A small json under `<data-dir>/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<PathBuf> {
|
||||
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::<LocalLibraryState>()
|
||||
.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::<LocalLibraryState>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
+154
-29
@@ -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::<LocalLibraryState>().set_active_tab(tab_id.into());
|
||||
w.global::<NavState>().set_view(ContentView::LocalLibrary);
|
||||
});
|
||||
@@ -4314,29 +4317,122 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window
|
||||
.global::<LocalLibraryActions>()
|
||||
.on_track_action(move |id, action| {
|
||||
if action.as_str() == "play" {
|
||||
if let Ok(row_id) = id.parse::<i64>() {
|
||||
// Queue the current (searched) list so playback
|
||||
// continues down it from the clicked row.
|
||||
let query = weak
|
||||
.upgrade()
|
||||
.map(|w| {
|
||||
w.global::<LocalLibraryState>()
|
||||
.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::<i64>() {
|
||||
// Queue the current (searched) list so playback
|
||||
// continues down it from the clicked row.
|
||||
let query = weak
|
||||
.upgrade()
|
||||
.map(|w| {
|
||||
w.global::<LocalLibraryState>()
|
||||
.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::<LocalLibraryActions>()
|
||||
.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::<LocalLibraryActions>()
|
||||
.on_tracks_toggle_multi_select(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let on = w.global::<LocalLibraryState>().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::<LocalLibraryActions>()
|
||||
.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<String> = 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<dyn std::error::Error>> {
|
||||
return;
|
||||
};
|
||||
let picker = w.global::<PlaylistPickerState>();
|
||||
// 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::<u64>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if is_local {
|
||||
// Local row ids are i64 -> add_local_track_to_playlist.
|
||||
let ids_i64: Vec<i64> = (0..ids_model.row_count())
|
||||
.filter_map(|i| ids_model.row_data(i))
|
||||
.filter_map(|s| s.parse::<i64>().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<u64> = (0..ids_model.row_count())
|
||||
.filter_map(|i| ids_model.row_data(i))
|
||||
.filter_map(|s| s.parse::<u64>().ok())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
if let Ok(tid) = picker.get_track_id().parse::<u64>() {
|
||||
if let Ok(tid) = track_id_single.parse::<u64>() {
|
||||
ids.push(tid);
|
||||
}
|
||||
}
|
||||
picker.set_open(false);
|
||||
let (Ok(pid), false) = (playlist_id.parse::<u64>(), 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<dyn std::error::Error>> {
|
||||
"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 {
|
||||
|
||||
@@ -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<qbz_library::LocalTrack>,
|
||||
next: bool,
|
||||
) {
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
handle.spawn(async move {
|
||||
let ordered: Vec<qbz_library::LocalTrack> = 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 {
|
||||
|
||||
@@ -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::<slint::SharedString>::new())));
|
||||
state.set_playlists(ModelRc::new(VecModel::from(Vec::<PlaylistPickItem>::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::<PlaylistPickerState>();
|
||||
state.set_track_id("".into());
|
||||
let model: Vec<slint::SharedString> = 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::<PlaylistPickItem>::new())));
|
||||
state.set_local_mode(local);
|
||||
state.set_loading(true);
|
||||
state.set_open(true);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -414,6 +414,13 @@ export global LocalLibraryState {
|
||||
in property <bool> tracks-load-failed: false;
|
||||
in-out property <int> tracks-next-offset: 0;
|
||||
in-out property <string> 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 <string> tracks-group-mode: "off"; // off | album | artist | name
|
||||
in property <[AlphaJump]> tracks-alpha: []; // A-Z jumps (name grouping)
|
||||
in-out property <bool> tracks-multi-select: false; // checkbox column on
|
||||
in property <int> 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 <bool> 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 <bool> local-mode: false;
|
||||
}
|
||||
|
||||
export global PlaylistPickerActions {
|
||||
|
||||
Reference in new issue
Block a user