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:
vicrodh committed 2026-05-29 15:18:09 -06:00
1 parent 4e79386daf
commit 5a076187e7
7 files changed
+577 -57

No files matched your search

+173 -1
View File
@@ -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
View File
@@ -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 {
+33
View File
@@ -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 {
+6 -2
View File
@@ -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;
+17 -1
View File
@@ -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 {