From de826bf560211ea5c0225d6ddf862e5383c8625b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 22 May 2026 18:26:55 -0600 Subject: [PATCH] feat(slint): playlist detail polish, sort (incl. custom) + WIP track drag Polish + consistency: - Compact playlist header (cover 150px; nav->header spacing homologated with AlbumView); description truncated to ~2 lines + "Read more" modal; custom-artwork buttons only on cover hover. - New Typography.section (18px) token for hero/section titles, applied across Album / Artist / Mix / Playlist + Home carousels. - Shorter track rows app-wide (50px row, 36px artwork, -1 fonts); +3px column-header/divider gap; wider track-number column. - Reusable ExpandableSearch (the JumpNavBar page-search pattern: magnifier -> opens left -> X to close) in the playlist action row. - Custom ListScrollbar (thicker, min thumb height, own gutter) since Slint's ListView scrollbar can't be styled. Sort: - Default / Title / Artist / Album / Duration with asc/desc per field, plus Custom (manual order persisted in library.db with up/down reorder). Client-side search + sort combined. WIP: track drag-and-drop onto sidebar playlists - DragState/DragActions globals, drag source on the shared TrackRow, follow-cursor DragGhost, sidebar playlist rows self-detect the drop via absolute-position, multi-select group drag, add_tracks_to_playlist on drop, Flickables freeze scroll during a drag. - KNOWN ISSUE: the ghost is offset from the cursor (the row absolute-position + pointer mapping is off, esp. inside Flickables). Diagnostic logging is left in (TrackRow debug() + a start log) to pin down the wrong term before the generic fix. cc --- crates/Cargo.lock | 34 ++ crates/qbz-slint/src/artwork.rs | 8 +- crates/qbz-slint/src/drag.rs | 24 ++ crates/qbz-slint/src/main.rs | 144 +++++++ crates/qbz-slint/src/playlist.rs | 250 +++++++++++- crates/qbz-slint/ui/album/AlbumPageView.slint | 6 +- crates/qbz-slint/ui/app.slint | 2 +- .../qbz-slint/ui/artist/ArtistPageView.slint | 8 +- crates/qbz-slint/ui/discover/Carousel.slint | 2 +- .../ui/favorites/FavoritesView.slint | 2 + .../qbz-slint/ui/foundation/typography.slint | 4 + crates/qbz-slint/ui/mix/MixView.slint | 6 +- .../qbz-slint/ui/playlist/PlaylistView.slint | 358 ++++++++++++++++-- .../qbz-slint/ui/primitives/DragGhost.slint | 45 +++ .../ui/primitives/ExpandableSearch.slint | 137 +++++++ .../ui/primitives/ListScrollbar.slint | 65 ++++ crates/qbz-slint/ui/primitives/TrackRow.slint | 104 ++++- .../ui/search/SearchResultsView.slint | 3 +- crates/qbz-slint/ui/shell/AppShell.slint | 4 + crates/qbz-slint/ui/shell/Sidebar.slint | 42 +- crates/qbz-slint/ui/state.slint | 40 ++ 21 files changed, 1223 insertions(+), 65 deletions(-) create mode 100644 crates/qbz-slint/src/drag.rs create mode 100644 crates/qbz-slint/ui/primitives/DragGhost.slint create mode 100644 crates/qbz-slint/ui/primitives/ExpandableSearch.slint create mode 100644 crates/qbz-slint/ui/primitives/ListScrollbar.slint diff --git a/crates/Cargo.lock b/crates/Cargo.lock index a647fc93c..a1c4010fc 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -323,6 +323,26 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -5047,6 +5067,7 @@ dependencies = [ "qbz-models", "qbz-player", "qbz-qobuz", + "qbz-radio", "serde", "serde_json", "thiserror 2.0.18", @@ -5157,6 +5178,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "qbz-radio" +version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "log", + "qbz-models", + "qbz-qobuz", + "rusqlite", +] + [[package]] name = "qbz-secrets" version = "0.1.0" @@ -5178,6 +5210,7 @@ dependencies = [ name = "qbz-slint" version = "0.1.0" dependencies = [ + "arboard", "async-trait", "dirs 5.0.1", "env_logger", @@ -5190,6 +5223,7 @@ dependencies = [ "qbz-core", "qbz-credentials", "qbz-integrations", + "qbz-library", "qbz-models", "reqwest", "rfd", diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 4abbb4dd3..0dc340855 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -522,11 +522,9 @@ fn apply_artwork( } } ArtworkTarget::PlaylistTrack { index } => { - let model = window.global::().get_tracks(); - if let Some(mut item) = model.row_data(index) { - item.artwork = image; - model.set_row_data(index, item); - } + // Resolve into the stable FULL_ITEMS + the visible row (by + // id) so sorting/filtering keeps the artwork. + crate::playlist::set_track_artwork(window, index, image); } ArtworkTarget::PlaylistCover => { window.global::().set_cover(image); diff --git a/crates/qbz-slint/src/drag.rs b/crates/qbz-slint/src/drag.rs new file mode 100644 index 000000000..94091b833 --- /dev/null +++ b/crates/qbz-slint/src/drag.rs @@ -0,0 +1,24 @@ +//! Track drag-and-drop state — the ids currently being dragged onto a +//! sidebar playlist. The UI (DragState global) drives the ghost + +//! drop highlight; the actual track ids live here (the global only +//! carries a count + ghost text). + +use std::sync::{LazyLock, Mutex}; + +static DRAGGED: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +pub fn set_dragged(ids: Vec) { + if let Ok(mut d) = DRAGGED.lock() { + *d = ids; + } +} + +pub fn dragged() -> Vec { + DRAGGED.lock().map(|d| d.clone()).unwrap_or_default() +} + +pub fn clear() { + if let Ok(mut d) = DRAGGED.lock() { + d.clear(); + } +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 97f308e22..f59e5ea0f 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -35,6 +35,7 @@ mod play_history; mod strip_html; mod playback; mod queue; +mod drag; mod folders; mod library_db; mod playlist; @@ -687,6 +688,31 @@ fn navigate_playlist( }); } +/// Resolve the track ids for a drag started on `track_id`. If the +/// current view has a multi-selection that includes the dragged row +/// (and is >1), the whole selection is dragged; otherwise just the +/// row. Mirrors Tauri's group-drag rule. +fn gather_drag_ids(w: &AppWindow, track_id: &str) -> Vec { + use slint::Model; + let view = w.global::().get_view(); + let model = match view { + ContentView::Playlist => Some(w.global::().get_tracks()), + ContentView::Artist => Some(w.global::().get_top_tracks()), + _ => None, + }; + if let Some(model) = model { + let selected: Vec = (0..model.row_count()) + .filter_map(|i| model.row_data(i)) + .filter(|t| t.selected) + .filter_map(|t| t.id.parse::().ok()) + .collect(); + if selected.len() > 1 && selected.iter().any(|&id| id.to_string() == track_id) { + return selected; + } + } + track_id.parse::().map(|id| vec![id]).unwrap_or_default() +} + /// Load (or reload) the sidebar playlists list off-thread. fn load_sidebar_playlists( runtime: Arc>, @@ -1678,6 +1704,26 @@ fn main() -> Result<(), Box> { es.set_open(true); } } + ("track", "move-up") | ("track", "move-down") => { + // Custom-order reorder (playlist view). Optimistic UI + // move, then persist the full order off-thread. + if let Some(w) = weak.upgrade() { + let up = action == "move-up"; + let orders = playlist::move_track(&w, id.as_str(), up); + let pid = w.global::().get_id().to_string(); + if !orders.is_empty() { + if let Ok(pid) = pid.parse::() { + handle.spawn(async move { + tokio::task::spawn_blocking(move || { + playlist::persist_custom(pid, orders); + }) + .await + .ok(); + }); + } + } + } + } ("playlist-track", track_id) => { let idx = playlist::index_of(track_id); let runtime = runtime.clone(); @@ -2483,6 +2529,104 @@ fn main() -> Result<(), Box> { }); } + // Track drag onto sidebar playlists (a star QBZ feature). + { + let weak = window.as_weak(); + window.global::().on_start( + move |track_id, title, subtitle, gx, gy| { + let Some(w) = weak.upgrade() else { return }; + log::info!("[qbz-slint][drag] start gx={gx} gy={gy} (cursor should be here)"); + let ids = gather_drag_ids(&w, track_id.as_str()); + drag::set_dragged(ids.clone()); + let ds = w.global::(); + ds.set_count(ids.len() as i32); + ds.set_ghost_title(title); + ds.set_ghost_subtitle(subtitle); + ds.set_pointer_x(gx); + ds.set_pointer_y(gy); + ds.set_over_playlist_id("".into()); + ds.set_active(true); + }, + ); + } + { + let weak = window.as_weak(); + window.global::().on_move(move |gx, gy| { + if let Some(w) = weak.upgrade() { + let ds = w.global::(); + ds.set_pointer_x(gx); + ds.set_pointer_y(gy); + } + }); + } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_end(move || { + let Some(w) = weak.upgrade() else { return }; + let ds = w.global::(); + let pid = ds.get_over_playlist_id().to_string(); + ds.set_active(false); + ds.set_over_playlist_id("".into()); + let ids = drag::dragged(); + drag::clear(); + if let (Ok(pid), false) = (pid.parse::(), ids.is_empty()) { + let runtime = runtime.clone(); + handle.spawn(async move { + match runtime.core().add_tracks_to_playlist(pid, &ids).await { + Ok(()) => log::info!( + "[qbz-slint] dropped {} track(s) onto playlist {pid}", + ids.len() + ), + Err(e) => log::error!("[qbz-slint] drop add to playlist failed: {e}"), + } + }); + } + }); + } + + // Playlist in-page track search (client-side filter). + { + let weak = window.as_weak(); + window + .global::() + .on_search(move |query| { + if let Some(w) = weak.upgrade() { + playlist::filter_tracks(&w, query.as_str()); + } + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_set_sort(move |field| { + let Some(w) = weak.upgrade() else { return; }; + playlist::set_sort(&w, field.as_str()); + // Entering custom: load (or seed) the local order, then + // re-render. Off-thread (opens library.db). + if field.as_str() == "custom" { + let pid = w.global::().get_id().to_string(); + if let Ok(pid) = pid.parse::() { + let ids = playlist::current_track_ids(); + let weak = weak.clone(); + handle.spawn(async move { + let orders = tokio::task::spawn_blocking(move || { + playlist::load_or_init_custom(pid, ids) + }) + .await + .unwrap_or_default(); + let _ = weak.upgrade_in_event_loop(move |w| { + playlist::apply_custom_order(&w, orders); + }); + }); + } + } + }); + } + // Edit playlist (rename / delete). { let weak = window.as_weak(); diff --git a/crates/qbz-slint/src/playlist.rs b/crates/qbz-slint/src/playlist.rs index e81be75ed..5a9b442fa 100644 --- a/crates/qbz-slint/src/playlist.rs +++ b/crates/qbz-slint/src/playlist.rs @@ -19,12 +19,233 @@ use crate::{AppWindow, PlaylistState, TrackItem}; /// The currently-loaded playlist tracks, for play-all / per-track play. static CURRENT: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +thread_local! { + /// The full, original-order row list — the canonical source the + /// search + sort derive the visible list from. UI thread only. + static FULL_ITEMS: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + /// Active in-page search query. + static QUERY: std::cell::RefCell = std::cell::RefCell::new(String::new()); + /// Active sort: (field, ascending). field "default" = playlist order. + static SORT: std::cell::RefCell<(String, bool)> = + std::cell::RefCell::new(("default".to_string(), true)); + /// Custom order positions: track id -> position. Empty until the + /// custom sort is entered (loaded/initialized from library.db). + static CUSTOM_ORDER: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// "m:ss" / "h:mm:ss" -> seconds, for duration sorting. +fn duration_secs(s: &str) -> u32 { + s.split(':') + .filter_map(|p| p.parse::().ok()) + .fold(0, |acc, n| acc * 60 + n) +} + +/// Re-derive the visible track list from FULL_ITEMS by applying the +/// active search filter, then the active sort. Runs on the event loop. +fn refresh_view(window: &AppWindow) { + let needle = QUERY.with(|q| q.borrow().trim().to_lowercase()); + let (field, asc) = SORT.with(|s| s.borrow().clone()); + let mut view: Vec = FULL_ITEMS.with(|cell| { + cell.borrow() + .iter() + .filter(|t| { + needle.is_empty() + || t.title.as_str().to_lowercase().contains(&needle) + || t.artist.as_str().to_lowercase().contains(&needle) + || t.album.as_str().to_lowercase().contains(&needle) + }) + .cloned() + .collect() + }); + if field == "custom" { + // Order by the local custom positions; tracks not yet in the + // map fall back to their natural index (newly-added tracks). + let order = CUSTOM_ORDER.with(|c| c.borrow().clone()); + view.sort_by_key(|t| { + let id = t.id.parse::().unwrap_or(0); + order.get(&id).copied().unwrap_or(i32::MAX) + }); + } else if field != "default" { + view.sort_by(|a, b| { + let ord = match field.as_str() { + "title" => a.title.to_lowercase().cmp(&b.title.to_lowercase()), + "artist" => a.artist.to_lowercase().cmp(&b.artist.to_lowercase()), + "album" => a.album.to_lowercase().cmp(&b.album.to_lowercase()), + "duration" => { + duration_secs(a.duration.as_str()).cmp(&duration_secs(b.duration.as_str())) + } + _ => std::cmp::Ordering::Equal, + }; + if asc { + ord + } else { + ord.reverse() + } + }); + } + window + .global::() + .set_tracks(ModelRc::new(VecModel::from(view))); +} + +/// Resolve a row's artwork into BOTH the stable FULL_ITEMS list (so a +/// later sort/filter keeps it — they rebuild from FULL_ITEMS) and the +/// visible model row, matched by id (the displayed order may differ +/// from FULL_ITEMS after a sort). Called by the artwork pipeline. +pub fn set_track_artwork(window: &AppWindow, full_index: usize, image: slint::Image) { + use slint::Model; + let id = FULL_ITEMS.with(|c| { + let mut b = c.borrow_mut(); + b.get_mut(full_index).map(|it| { + it.artwork = image.clone(); + it.id.clone() + }) + }); + let Some(id) = id else { return }; + let model = window.global::().get_tracks(); + for i in 0..model.row_count() { + if let Some(mut it) = model.row_data(i) { + if it.id == id { + it.artwork = image; + model.set_row_data(i, it); + break; + } + } + } +} + +/// Update the search query and re-render. +pub fn filter_tracks(window: &AppWindow, query: &str) { + QUERY.with(|q| *q.borrow_mut() = query.to_string()); + refresh_view(window); +} + +/// Set the sort field. Re-selecting the active field toggles asc/desc; +/// "default" restores playlist order. Mirrors Tauri's behaviour. +pub fn set_sort(window: &AppWindow, field: &str) { + SORT.with(|s| { + let mut cur = s.borrow_mut(); + if field == "default" || field == "custom" { + *cur = (field.to_string(), true); + } else if cur.0 == field { + cur.1 = !cur.1; + } else { + *cur = (field.to_string(), true); + } + }); + let (field, asc) = SORT.with(|s| s.borrow().clone()); + let state = window.global::(); + state.set_sort_field(field.into()); + state.set_sort_asc(asc); + refresh_view(window); +} + +// ==================== Custom (manual) order ==================== + +/// The current full track ids in natural (playlist) order — used to +/// seed the custom order on first entry. +pub fn current_track_ids() -> Vec { + FULL_ITEMS.with(|cell| { + cell.borrow() + .iter() + .filter_map(|t| t.id.parse::().ok()) + .collect() + }) +} + +/// Load the playlist's custom order from library.db, seeding it from +/// `current_ids` if none exists. Blocking — run on a worker thread. +pub fn load_or_init_custom(playlist_id: u64, current_ids: Vec) -> Vec<(u64, i32)> { + crate::library_db::with_db(|db| { + let has = db.has_playlist_custom_order(playlist_id)?; + if !has { + let seed: Vec<(i64, bool)> = current_ids.iter().map(|&id| (id as i64, false)).collect(); + db.init_playlist_custom_order(playlist_id, &seed)?; + } + db.get_playlist_custom_order(playlist_id) + }) + .unwrap_or_default() + .into_iter() + .map(|(id, _is_local, pos)| (id as u64, pos)) + .collect() +} + +/// Persist the full custom order (DELETE + INSERT — self-healing). +/// Blocking. +pub fn persist_custom(playlist_id: u64, orders: Vec<(u64, i32)>) { + let rows: Vec<(i64, bool, i32)> = + orders.into_iter().map(|(id, pos)| (id as i64, false, pos)).collect(); + crate::library_db::with_db(|db| db.set_playlist_custom_order(playlist_id, &rows)); +} + +/// Store a freshly-loaded custom order + re-render. UI thread. +pub fn apply_custom_order(window: &AppWindow, orders: Vec<(u64, i32)>) { + CUSTOM_ORDER.with(|c| { + let mut m = c.borrow_mut(); + m.clear(); + for (id, pos) in orders { + m.insert(id, pos); + } + }); + refresh_view(window); +} + +/// Move a track one slot up/down in the custom order. Rebuilds the +/// whole order with clean 0..N-1 positions (self-healing), re-renders, +/// and returns the new orders to persist. UI thread. +pub fn move_track(window: &AppWindow, track_id: &str, up: bool) -> Vec<(u64, i32)> { + let Ok(target) = track_id.parse::() else { + return Vec::new(); + }; + // Current visible custom order of ids. + let order = CUSTOM_ORDER.with(|c| c.borrow().clone()); + let mut ids: Vec = FULL_ITEMS.with(|cell| { + cell.borrow() + .iter() + .filter_map(|t| t.id.parse::().ok()) + .collect() + }); + ids.sort_by_key(|id| order.get(id).copied().unwrap_or(i32::MAX)); + let Some(idx) = ids.iter().position(|&id| id == target) else { + return Vec::new(); + }; + let swap = if up { + if idx == 0 { + return Vec::new(); + } + idx - 1 + } else { + if idx + 1 >= ids.len() { + return Vec::new(); + } + idx + 1 + }; + ids.swap(idx, swap); + // Rebuild contiguous positions. + let orders: Vec<(u64, i32)> = ids + .iter() + .enumerate() + .map(|(i, &id)| (id, i as i32)) + .collect(); + CUSTOM_ORDER.with(|c| { + let mut m = c.borrow_mut(); + m.clear(); + for &(id, pos) in &orders { + m.insert(id, pos); + } + }); + refresh_view(window); + orders +} + /// Plain, `Send` playlist data produced on the worker thread. pub struct PlaylistData { pub id: String, pub name: String, pub owner: String, pub description: String, + pub description_short: String, pub cover_url: String, /// Local custom artwork path (from playlist_settings), if the user /// set one — overrides the collage / server image. @@ -67,20 +288,33 @@ where .await .ok() .flatten(); + let description = pl + .description + .map(|d| crate::strip_html::strip_html(&d)) + .unwrap_or_default(); Some(PlaylistData { id: pl.id.to_string(), name: pl.name, owner: pl.owner.name, - description: pl - .description - .map(|d| crate::strip_html::strip_html(&d)) - .unwrap_or_default(), + description: description.clone(), + description_short: truncate_words(&description, 160), cover_url, custom_artwork_path, tracks, }) } +/// Word-boundary truncation for the 2-line header description (the +/// full text lives in the Read-more modal). +fn truncate_words(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let truncated: String = text.chars().take(max).collect(); + let cut = truncated.rfind(' ').unwrap_or(truncated.len()); + format!("{}…", truncated[..cut].trim_end()) +} + fn mmss(secs: u32) -> String { format!("{}:{:02}", secs / 60, secs % 60) } @@ -138,11 +372,17 @@ fn to_item(track: &Track) -> TrackItem { } pub fn reset(window: &AppWindow) { + FULL_ITEMS.with(|cell| cell.borrow_mut().clear()); + QUERY.with(|q| q.borrow_mut().clear()); + SORT.with(|s| *s.borrow_mut() = ("default".to_string(), true)); + CUSTOM_ORDER.with(|c| c.borrow_mut().clear()); let state = window.global::(); state.set_tracks(ModelRc::new(VecModel::from(Vec::::new()))); state.set_track_count(0); state.set_total_duration("".into()); state.set_cover(slint::Image::default()); + state.set_sort_field("default".into()); + state.set_sort_asc(true); state.set_loading(true); } @@ -150,6 +390,7 @@ pub fn apply(window: &AppWindow, data: PlaylistData) { let items: Vec = data.tracks.iter().map(to_item).collect(); let count = data.tracks.len() as i32; let duration = total_duration(&data.tracks); + FULL_ITEMS.with(|cell| *cell.borrow_mut() = items.clone()); if let Ok(mut cur) = CURRENT.lock() { *cur = data.tracks; } @@ -165,6 +406,7 @@ pub fn apply(window: &AppWindow, data: PlaylistData) { state.set_name(data.name.into()); state.set_owner(data.owner.into()); state.set_description(data.description.into()); + state.set_description_short(data.description_short.into()); if let Some(img) = custom { state.set_cover(img); state.set_cover_url(data.custom_artwork_path.clone().unwrap_or_default().into()); diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint index be2776098..8aae8feaf 100644 --- a/crates/qbz-slint/ui/album/AlbumPageView.slint +++ b/crates/qbz-slint/ui/album/AlbumPageView.slint @@ -9,7 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { Spacing } from "../foundation/spacing.slint"; -import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView } from "../state.slint"; +import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState } from "../state.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; @@ -122,6 +122,8 @@ export component AlbumPageView inherits Rectangle { background: Theme.surface-main; Flickable { + // Freeze scroll during a track drag so the ghost doesn't drift. + interactive: !DragState.active; viewport-width: self.width; viewport-height: page.preferred-height; @@ -251,7 +253,7 @@ export component AlbumPageView inherits Rectangle { Text { text: AlbumState.title; color: Theme.text-primary; - font-size: Typography.title; + font-size: Typography.section; font-weight: Typography.bold; overflow: elide; } diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 90fe21a42..11be27352 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions } from "state.slint"; +export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint index e61a1cb56..2ce8f39c0 100644 --- a/crates/qbz-slint/ui/artist/ArtistPageView.slint +++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint @@ -17,6 +17,7 @@ import { ContentView, NetworkSidebarState, NetworkSidebarActions, + DragState, } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; @@ -159,6 +160,9 @@ export component ArtistPageView inherits Rectangle { // it can stay anchored on the right and fill the available height // independently of the page scroll. page-flickable := Flickable { + // Freeze scrolling while a track drag is in flight, so the + // dragged row (and thus the ghost position) doesn't drift. + interactive: !DragState.active; viewport-width: self.width; viewport-height: page.preferred-height; @@ -290,7 +294,7 @@ export component ArtistPageView inherits Rectangle { Text { text: ArtistState.name; color: Theme.text-primary; - font-size: Typography.title; + font-size: Typography.section; font-weight: Typography.bold; overflow: elide; } @@ -534,7 +538,7 @@ export component ArtistPageView inherits Rectangle { for track[index] in ArtistState.top-tracks: TrackRow { visible: root.top-tracks-expanded || index < root.top-tracks-preview; - height: self.visible ? 56px : 0px; + height: self.visible ? 50px : 0px; track: track; index: index; // Artist Popular Tracks: number cell (no cover), with diff --git a/crates/qbz-slint/ui/discover/Carousel.slint b/crates/qbz-slint/ui/discover/Carousel.slint index b404088b9..e3f894820 100644 --- a/crates/qbz-slint/ui/discover/Carousel.slint +++ b/crates/qbz-slint/ui/discover/Carousel.slint @@ -93,7 +93,7 @@ export component Carousel inherits VerticalLayout { Text { text: root.section.title; color: Theme.text-primary; - font-size: 18px; + font-size: Typography.section; font-weight: Typography.semibold; vertical-alignment: center; horizontal-stretch: 1; diff --git a/crates/qbz-slint/ui/favorites/FavoritesView.slint b/crates/qbz-slint/ui/favorites/FavoritesView.slint index 5a531b933..e4cefca57 100644 --- a/crates/qbz-slint/ui/favorites/FavoritesView.slint +++ b/crates/qbz-slint/ui/favorites/FavoritesView.slint @@ -16,6 +16,7 @@ import { FavoriteArtistItem, FavoritePlaylistItem, FavoriteLabelItem, + DragState, } from "../state.slint"; import { AlbumCard } from "../discover/AlbumCard.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; @@ -247,6 +248,7 @@ export component FavoritesView inherits Rectangle { background: Theme.surface-main; Flickable { + interactive: !DragState.active; viewport-width: self.width; viewport-height: page.preferred-height; diff --git a/crates/qbz-slint/ui/foundation/typography.slint b/crates/qbz-slint/ui/foundation/typography.slint index 7fc7eddc2..76724a44c 100644 --- a/crates/qbz-slint/ui/foundation/typography.slint +++ b/crates/qbz-slint/ui/foundation/typography.slint @@ -6,6 +6,10 @@ export global Typography { out property body: 15px; out property subtitle: 15px; out property button: 17px; + // Canonical page / section title — Home section headings ("New + // Releases", ...) and the hero titles on Album / Artist / Playlist + // / Mixtape detail pages. One size for all, for consistency. + out property section: 18px; out property heading: 19px; out property title: 25px; out property wordmark: 29px; diff --git a/crates/qbz-slint/ui/mix/MixView.slint b/crates/qbz-slint/ui/mix/MixView.slint index e4f6ba00b..ea7de65da 100644 --- a/crates/qbz-slint/ui/mix/MixView.slint +++ b/crates/qbz-slint/ui/mix/MixView.slint @@ -8,7 +8,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { MixState } from "../state.slint"; +import { MixState, DragState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; @@ -67,6 +67,7 @@ export component MixView inherits Rectangle { background: Theme.surface-main; Flickable { + interactive: !DragState.active; viewport-width: self.width; viewport-height: page.preferred-height; @@ -121,7 +122,7 @@ export component MixView inherits Rectangle { Text { text: MixState.title; color: Theme.text-primary; - font-size: Typography.title; + font-size: Typography.section; font-weight: Typography.bold; } Rectangle { height: 8px; } @@ -235,6 +236,7 @@ export component MixView inherits Rectangle { Rectangle { width: 28px; } Rectangle { width: 32px; } } + if MixState.tracks.length > 0: Rectangle { height: 3px; } if MixState.tracks.length > 0: Rectangle { height: 1px; background: Theme.border-subtle; diff --git a/crates/qbz-slint/ui/playlist/PlaylistView.slint b/crates/qbz-slint/ui/playlist/PlaylistView.slint index 71903fab3..798e613d7 100644 --- a/crates/qbz-slint/ui/playlist/PlaylistView.slint +++ b/crates/qbz-slint/ui/playlist/PlaylistView.slint @@ -6,9 +6,11 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { PlaylistState } from "../state.slint"; +import { PlaylistState, PlaylistActions, DragState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; +import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { ListView } from "std-widgets.slint"; @@ -48,6 +50,48 @@ component CircleAction inherits Rectangle { } } +// One row in the Sort menu. The active field shows its direction arrow +// (asc/desc); re-selecting it toggles direction. +component SortOption inherits Rectangle { + in property label; + in property active: false; + in property asc: true; + callback chosen(); + height: 30px; + border-radius: 5px; + background: so-ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 10px; + padding-right: 8px; + spacing: 6px; + Text { + text: root.label; + color: root.active ? Theme.text-primary : Theme.text-secondary; + font-size: Typography.legal; + font-weight: root.active ? Typography.semibold : Typography.regular; + vertical-alignment: center; + horizontal-stretch: 1; + } + VerticalLayout { + alignment: center; + if root.active: QbzIcon { + source: root.asc + ? @image-url("../assets/icons/chevron-up.svg") + : @image-url("../assets/icons/chevron-down.svg"); + width: 13px; + height: 13px; + tint: Theme.accent; + } + } + } + so-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.chosen(); + } + } +} + export component PlaylistView inherits Rectangle { callback play-track(string); callback play-all(); @@ -56,33 +100,54 @@ export component PlaylistView inherits Rectangle { callback edit(); callback media-action(string, string, string); + // Read-more description modal. + property show-description: false; + + // Label for the active sort field. + property sort-label: + PlaylistState.sort-field == "title" ? @tr("Title") + : PlaylistState.sort-field == "artist" ? @tr("Artist") + : PlaylistState.sort-field == "album" ? @tr("Album") + : PlaylistState.sort-field == "duration" ? @tr("Duration") + : PlaylistState.sort-field == "custom" ? @tr("Custom") + : @tr("Default"); + background: Theme.surface-main; page := VerticalLayout { + // Fill PlaylistView so the ListView's vertical-stretch has + // real height (otherwise it collapses to a single row). + width: 100%; + height: 100%; padding-left: 32px; - padding-right: 32px; + // Smaller right padding so the track list + scrollbar use + // the space instead of leaving a dead band on the right. + padding-right: 16px; padding-top: 11px; + padding-bottom: 16px; spacing: 0px; - alignment: start; HorizontalLayout { + vertical-stretch: 0; alignment: start; NavButtons { } } + // Same nav -> header separation as AlbumView (homologated). Rectangle { height: 22px; } // --- Header ------------------------------------------------ HorizontalLayout { - spacing: 32px; + vertical-stretch: 0; + spacing: 24px; alignment: start; // Cover — custom artwork if set, else a 4-up collage of // the first tracks' covers (Tauri parity), else the // server playlist image, else the first track, else a // placeholder. cover-box := Rectangle { - width: 224px; - height: 224px; + width: 150px; + height: 150px; border-radius: Radius.md; background: Theme.surface-elevated; clip: true; @@ -112,12 +177,12 @@ export component PlaylistView inherits Rectangle { HorizontalLayout { spacing: 2px; Image { - width: 111px; height: 111px; + width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2; source: PlaylistState.tracks[0].artwork; image-fit: cover; } Image { - width: 111px; height: 111px; + width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2; source: PlaylistState.tracks[1].artwork; image-fit: cover; } @@ -125,12 +190,12 @@ export component PlaylistView inherits Rectangle { HorizontalLayout { spacing: 2px; Image { - width: 111px; height: 111px; + width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2; source: PlaylistState.tracks[2].artwork; image-fit: cover; } Image { - width: 111px; height: 111px; + width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2; source: PlaylistState.tracks[3].artwork; image-fit: cover; } @@ -226,24 +291,40 @@ export component PlaylistView inherits Rectangle { font-weight: Typography.semibold; letter-spacing: 1.5px; } - Rectangle { height: 6px; } + Rectangle { height: 4px; } Text { text: PlaylistState.name; color: Theme.text-primary; - font-size: Typography.title; + font-size: Typography.section; font-weight: Typography.bold; wrap: word-wrap; } - if PlaylistState.description != "": Rectangle { height: 8px; } + if PlaylistState.description != "": Rectangle { height: 6px; } if PlaylistState.description != "": Text { - width: root.width - 320px; - text: PlaylistState.description; + width: root.width - 250px; + text: PlaylistState.description-short; color: Theme.text-secondary; - font-size: Typography.body; + font-size: Typography.legal; wrap: word-wrap; - overflow: elide; } - Rectangle { height: 10px; } + if PlaylistState.description != PlaylistState.description-short: Rectangle { height: 3px; } + if PlaylistState.description != PlaylistState.description-short: read-more := TouchArea { + width: rm-row.preferred-width; + height: 18px; + mouse-cursor: pointer; + clicked => { + root.show-description = true; + } + rm-row := HorizontalLayout { + Text { + text: @tr("Read more"); + color: read-more.has-hover ? Theme.accent-hover : Theme.accent; + font-size: Typography.legal; + vertical-alignment: center; + } + } + } + Rectangle { height: 8px; } Text { text: PlaylistState.owner + " • " + PlaylistState.track-count + " " + @tr("tracks") @@ -251,10 +332,10 @@ export component PlaylistView inherits Rectangle { color: Theme.text-secondary; font-size: Typography.legal; } - Rectangle { height: 20px; } - // Actions. + Rectangle { height: 14px; } + // Actions — buttons left, search + sort floating right. HorizontalLayout { - alignment: start; + vertical-stretch: 0; spacing: 12px; CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); @@ -266,6 +347,7 @@ export component PlaylistView inherits Rectangle { } } VerticalLayout { + horizontal-stretch: 0; alignment: center; CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); @@ -276,6 +358,7 @@ export component PlaylistView inherits Rectangle { } } VerticalLayout { + horizontal-stretch: 0; alignment: center; CircleAction { icon: @image-url("../assets/icons/heart.svg"); @@ -287,6 +370,7 @@ export component PlaylistView inherits Rectangle { } // Edit (rename / delete) — only the user's own. if PlaylistState.is-owner: VerticalLayout { + horizontal-stretch: 0; alignment: center; CircleAction { icon: @image-url("../assets/icons/pen-line.svg"); @@ -297,6 +381,7 @@ export component PlaylistView inherits Rectangle { } // Select tracks (multi-select edit mode). if PlaylistState.is-owner && PlaylistState.tracks.length > 0: VerticalLayout { + horizontal-stretch: 0; alignment: center; CircleAction { icon: @image-url("../assets/icons/square-check-big.svg"); @@ -306,13 +391,127 @@ export component PlaylistView inherits Rectangle { } } } + + // Spacer floats the search + sort to the right. + Rectangle { horizontal-stretch: 1; } + + // In-playlist search (shared ExpandableSearch). + VerticalLayout { + horizontal-stretch: 0; + alignment: center; + ExpandableSearch { + placeholder: @tr("Search tracks"); + max-open-width: 220px; + edited(q) => { + PlaylistActions.search(q); + } + } + } + + // Sort dropdown — fixed width so it never grows + // when the active option changes. + VerticalLayout { + horizontal-stretch: 0; + alignment: center; + sort-anchor := Rectangle { + height: 34px; + width: 132px; + border-radius: 6px; + background: sort-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + sort-row := HorizontalLayout { + padding-left: 11px; + padding-right: 8px; + spacing: 5px; + Text { + text: @tr("Sort") + ": " + root.sort-label; + color: Theme.text-secondary; + font-size: Typography.legal; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + VerticalLayout { + horizontal-stretch: 0; + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/chevron-down.svg"); + width: 13px; + height: 13px; + tint: Theme.text-muted; + } + } + } + sort-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + sort-menu.show(); + } + } + sort-menu := PopupWindow { + x: sort-anchor.width - 172px; + y: 38px; + width: 172px; + Rectangle { + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + border-radius: 8px; + drop-shadow-blur: 20px; + drop-shadow-color: #00000080; + VerticalLayout { + padding: 6px; + spacing: 1px; + SortOption { + label: @tr("Default"); + active: PlaylistState.sort-field == "default"; + chosen => { PlaylistActions.set-sort("default"); } + } + SortOption { + label: @tr("Title"); + active: PlaylistState.sort-field == "title"; + asc: PlaylistState.sort-asc; + chosen => { PlaylistActions.set-sort("title"); } + } + SortOption { + label: @tr("Artist"); + active: PlaylistState.sort-field == "artist"; + asc: PlaylistState.sort-asc; + chosen => { PlaylistActions.set-sort("artist"); } + } + SortOption { + label: @tr("Album"); + active: PlaylistState.sort-field == "album"; + asc: PlaylistState.sort-asc; + chosen => { PlaylistActions.set-sort("album"); } + } + SortOption { + label: @tr("Duration"); + active: PlaylistState.sort-field == "duration"; + asc: PlaylistState.sort-asc; + chosen => { PlaylistActions.set-sort("duration"); } + } + // Manual order — enables the per-row + // up/down reorder controls. + SortOption { + label: @tr("Custom"); + active: PlaylistState.sort-field == "custom"; + chosen => { PlaylistActions.set-sort("custom"); } + } + } + } + } + } + } } } } - Rectangle { height: 28px; } + Rectangle { height: 18px; } if PlaylistState.loading: HorizontalLayout { + vertical-stretch: 0; alignment: center; LoadingSpinner { size: 36px; } } @@ -323,9 +522,12 @@ export component PlaylistView inherits Rectangle { } // --- Track list column header ------------------------------ + // Extra right padding (12 + 14px gutter) aligns the column + // labels with the rows, which are inset by the scrollbar. if PlaylistState.tracks.length > 0: HorizontalLayout { + vertical-stretch: 0; padding-left: 12px; - padding-right: 12px; + padding-right: 26px; spacing: 14px; Text { text: "#"; @@ -364,6 +566,8 @@ export component PlaylistView inherits Rectangle { Rectangle { width: 28px; } Rectangle { width: 32px; } } + // Breathing room between the column labels and the divider. + if PlaylistState.tracks.length > 0: Rectangle { height: 3px; } if PlaylistState.tracks.length > 0: Rectangle { height: 1px; background: Theme.border-subtle; @@ -434,25 +638,37 @@ export component PlaylistView inherits Rectangle { } if PlaylistState.multi-select-mode: Rectangle { height: 8px; } - // Virtualized track list — only the visible rows are - // instantiated, so a 2000-track playlist stays smooth. - track-list := ListView { + // Virtualized track list (built-in scrollbar hidden — a + // custom one sits in its own gutter to the right). + HorizontalLayout { vertical-stretch: 1; - for track[i] in PlaylistState.tracks: TrackRow { - track: track; - index: i; - show-artwork: true; - show-album: true; - show-favorite: true; - multi-select-mode: PlaylistState.multi-select-mode; - media-action(kind, id, action) => { - if (action == "play") { - root.play-track(id); - } else { - root.media-action(kind, id, action); + spacing: 0px; + track-list := ListView { + horizontal-stretch: 1; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + for track[i] in PlaylistState.tracks: TrackRow { + track: track; + index: i; + show-artwork: true; + show-album: true; + show-favorite: true; + show-reorder: PlaylistState.sort-field == "custom"; + multi-select-mode: PlaylistState.multi-select-mode; + media-action(kind, id, action) => { + if (action == "play") { + root.play-track(id); + } else { + root.media-action(kind, id, action); + } } } } + ListScrollbar { + horizontal-stretch: 0; + viewport-height: track-list.viewport-height; + visible-height: track-list.visible-height; + viewport-y <=> track-list.viewport-y; + } } } @@ -484,4 +700,70 @@ export component PlaylistView inherits Rectangle { } } } + + // Read-more description modal (same treatment as Album / Artist). + if root.show-description: Rectangle { + background: #000000bf; + TouchArea { + mouse-cursor: default; + clicked => { + root.show-description = false; + } + } + Rectangle { + width: Math.min(root.width - 80px, 560px); + height: Math.min(root.height - 120px, 460px); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: Radius.md; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + TouchArea { } + VerticalLayout { + padding: 24px; + spacing: 14px; + HorizontalLayout { + Text { + text: @tr("About this playlist"); + color: Theme.text-primary; + font-size: Typography.section; + font-weight: Typography.semibold; + horizontal-stretch: 1; + vertical-alignment: center; + } + desc-close := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + root.show-description = false; + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 18px; + height: 18px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: desc-close.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: desc-full.preferred-height; + desc-full := Text { + width: parent.width; + text: PlaylistState.description; + color: Theme.text-secondary; + font-size: Typography.body; + wrap: word-wrap; + } + } + } + } + } } diff --git a/crates/qbz-slint/ui/primitives/DragGhost.slint b/crates/qbz-slint/ui/primitives/DragGhost.slint new file mode 100644 index 000000000..97387b4f2 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/DragGhost.slint @@ -0,0 +1,45 @@ +// Floating drag ghost — the dark pill that follows the cursor while +// tracks are being dragged onto the sidebar. Mirrors Tauri's +// setDragImage pill: "N tracks" for a group, or title + "artist · +// album" for one. Visual only (no TouchArea, so it never blocks the +// pointer). Rendered once at the top of AppShell. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { DragState } from "../state.slint"; + +export component DragGhost inherits Rectangle { + if DragState.active: Rectangle { + x: DragState.pointer-x + 10px; + y: DragState.pointer-y + 14px; + width: pill.preferred-width; + height: pill.preferred-height; + border-radius: 8px; + background: #1e1e28e0; + border-width: 1px; + border-color: #ffffff1f; + drop-shadow-blur: 14px; + drop-shadow-color: #00000059; + + pill := VerticalLayout { + padding-left: 14px; + padding-right: 14px; + padding-top: 8px; + padding-bottom: 8px; + spacing: 2px; + Text { + text: DragState.count > 1 + ? DragState.count + " " + @tr("tracks") + : DragState.ghost-title; + color: #ffffff; + font-size: 12px; + font-weight: Typography.medium; + } + if DragState.count == 1 && DragState.ghost-subtitle != "": Text { + text: DragState.ghost-subtitle; + color: #ffffff8c; + font-size: 10px; + } + } + } +} diff --git a/crates/qbz-slint/ui/primitives/ExpandableSearch.slint b/crates/qbz-slint/ui/primitives/ExpandableSearch.slint new file mode 100644 index 000000000..78d22d851 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/ExpandableSearch.slint @@ -0,0 +1,137 @@ +// Reusable expandable page-search — the exact pattern from the JUMP TO +// bar (ArtistView): a magnifier that, on click, opens a right-anchored +// input that grows to the LEFT (over whatever sits to its left), with +// a leading magnifier glyph + a trailing X that clears and closes. +// +// Self-contained: occupies a small fixed footprint when closed; the +// open overlay overflows to the left, so nothing to its right moves. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { QbzIcon } from "QbzIcon.slint"; + +export component ExpandableSearch inherits Rectangle { + in property placeholder: "Search…"; + in property max-open-width: 216px; + in-out property open: false; + in-out property query; + callback edited(string); + + changed open => { + if (root.open) { + input.focus(); + } + } + + // Closed footprint — just the magnifier slot. + width: 34px; + height: 34px; + + // Closed: the magnifier toggle. + Rectangle { + visible: !root.open; + width: 34px; + height: 34px; + border-radius: 6px; + background: open-ta.has-hover ? Theme.surface-hover : transparent; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: open-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + open-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.open = true; + } + } + } + + // Open: right-anchored overlay that grows left. + overlay := Rectangle { + visible: root.open; + x: parent.width - self.width; + width: root.open ? root.max-open-width : 0px; + height: 34px; + background: Theme.surface-card; + border-radius: 6px; + border-width: 1px; + border-color: input.has-focus ? Theme.accent : Theme.border-subtle; + clip: true; + animate width { duration: 200ms; easing: ease-in-out; } + + HorizontalLayout { + padding-left: 11px; + padding-right: 5px; + spacing: 8px; + // Leading magnifier glyph (decorative). + VerticalLayout { + horizontal-stretch: 0; + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 14px; + height: 14px; + tint: Theme.text-muted; + } + } + // Input + placeholder. + Rectangle { + horizontal-stretch: 1; + clip: true; + input := TextInput { + width: 100%; + height: 100%; + color: Theme.text-primary; + font-size: Typography.body; + vertical-alignment: center; + single-line: true; + text <=> root.query; + edited => { + root.edited(self.text); + } + } + Text { + visible: input.text == ""; + width: 100%; + height: 100%; + text: root.placeholder; + color: Theme.text-muted; + font-size: Typography.body; + vertical-alignment: center; + overflow: elide; + } + } + // Trailing X — clears + closes (back to the magnifier). + VerticalLayout { + horizontal-stretch: 0; + alignment: center; + Rectangle { + width: 24px; + height: 24px; + border-radius: 4px; + background: clear-ta.has-hover ? Theme.surface-elevated : transparent; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 12px; + height: 12px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + clear-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.query = ""; + root.edited(""); + root.open = false; + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/primitives/ListScrollbar.slint b/crates/qbz-slint/ui/primitives/ListScrollbar.slint new file mode 100644 index 000000000..d3d0eaab0 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/ListScrollbar.slint @@ -0,0 +1,65 @@ +// Custom vertical scrollbar for a ListView — Slint's built-in one is +// too thin and its thumb has no minimum size, so on a thousands-row +// list it shrinks to a sliver. This sits in its own gutter (not over +// the rows), is thicker, keeps a minimum thumb height, and is +// draggable. Bind `viewport-y` two-way to the ListView and feed it the +// list's viewport/visible heights. + +import { Theme } from "../foundation/semantic-colors.slint"; + +export component ListScrollbar inherits Rectangle { + in property viewport-height; + in property visible-height; + in-out property viewport-y; + + // Only meaningful when the content overflows. + property max-scroll: max(0px, root.viewport-height - root.visible-height); + property thumb-h: + root.viewport-height > 0px + ? max(48px, root.visible-height / root.viewport-height * root.height) + : root.height; + property travel: max(0px, root.height - root.thumb-h); + + width: 14px; + visible: root.max-scroll > 0px; + + // Track. + Rectangle { + width: 6px; + x: (parent.width - self.width) / 2; + height: 100%; + background: #ffffff12; + border-radius: 3px; + } + + // Thumb. + thumb := Rectangle { + width: bar-ta.has-hover || bar-ta.pressed ? 10px : 8px; + x: (parent.width - self.width) / 2; + height: root.thumb-h; + y: root.max-scroll > 0px + ? (-root.viewport-y / root.max-scroll) * root.travel + : 0px; + border-radius: 5px; + background: bar-ta.has-hover || bar-ta.pressed ? #ffffff77 : #ffffff44; + animate width { duration: 100ms; } + } + + // Drag / click-to-position over the whole gutter — centers the + // thumb on the pointer. + bar-ta := TouchArea { + mouse-cursor: pointer; + moved => { + if (bar-ta.pressed && root.travel > 0px) { + root.viewport-y = -min(root.max-scroll, max(0px, + (bar-ta.mouse-y - root.thumb-h / 2) / root.travel * root.max-scroll)); + } + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && root.travel > 0px) { + root.viewport-y = -min(root.max-scroll, max(0px, + (bar-ta.mouse-y - root.thumb-h / 2) / root.travel * root.max-scroll)); + } + } + } +} diff --git a/crates/qbz-slint/ui/primitives/TrackRow.slint b/crates/qbz-slint/ui/primitives/TrackRow.slint index 0c18d8e43..30b9f621f 100644 --- a/crates/qbz-slint/ui/primitives/TrackRow.slint +++ b/crates/qbz-slint/ui/primitives/TrackRow.slint @@ -18,7 +18,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { TrackItem } from "../state.slint"; +import { TrackItem, DragActions } from "../state.slint"; import { QbzIcon } from "QbzIcon.slint"; import { TrackPlayCell } from "TrackPlayCell.slint"; import { TrackContextMenu } from "TrackContextMenu.slint"; @@ -31,6 +31,11 @@ export component TrackRow inherits Rectangle { in property show-favorite: false; in property show-download: false; in property multi-select-mode: false; + // Custom-order reorder controls (up/down chevrons) at the leading + // edge — used by the playlist custom sort. + in property show-reorder: false; + // Whether the row can be dragged onto sidebar playlists. + in property draggable: true; callback media-action(string, string, string); // Explicit track number, else the 1-based list position. @@ -39,14 +44,58 @@ export component TrackRow inherits Rectangle { property row-hovered: ta.has-hover || play-cell.hovered || more-ta.has-hover; - height: 56px; + height: 50px; border-radius: 8px; background: root.row-hovered ? #ffffff16 : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); + // Background TouchArea — also the drag source. Pressing the row + // body (not the play/fav/more controls, which sit on top) and + // moving past a threshold begins a track drag onto the sidebar + // playlists. Global coords = the row's absolute-position + the + // pointer offset; the TouchArea grabs on press so moves keep + // arriving even once the pointer leaves the row. ta := TouchArea { mouse-cursor: default; + property down-x; + property down-y; + property dragging: false; + pointer-event(e) => { + if (e.kind == PointerEventKind.down) { + ta.down-x = root.absolute-position.x + ta.mouse-x; + ta.down-y = root.absolute-position.y + ta.mouse-y; + ta.dragging = false; + } + if (e.kind == PointerEventKind.up && ta.dragging) { + DragActions.end(); + ta.dragging = false; + } + } + moved => { + if (ta.pressed && root.draggable) { + if (!ta.dragging + && (Math.abs(root.absolute-position.x + ta.mouse-x - ta.down-x) > 6px + || Math.abs(root.absolute-position.y + ta.mouse-y - ta.down-y) > 6px)) { + ta.dragging = true; + debug("DRAG absx=", root.absolute-position.x, " absy=", root.absolute-position.y, + " mx=", ta.mouse-x, " my=", ta.mouse-y); + DragActions.start( + root.track.id, + root.track.title, + root.track.album != "" + ? root.track.artist + " · " + root.track.album + : root.track.artist, + root.absolute-position.x + ta.mouse-x, + root.absolute-position.y + ta.mouse-y); + } + if (ta.dragging) { + DragActions.move( + root.absolute-position.x + ta.mouse-x, + root.absolute-position.y + ta.mouse-y); + } + } + } } HorizontalLayout { @@ -54,6 +103,48 @@ export component TrackRow inherits Rectangle { padding-right: 12px; spacing: 14px; + // Reorder chevrons (custom-order mode) — leading edge. + if root.show-reorder: VerticalLayout { + alignment: center; + spacing: 0px; + Rectangle { + width: 22px; + height: 18px; + QbzIcon { + source: @image-url("../assets/icons/chevron-up.svg"); + width: 14px; + height: 14px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: up-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + up-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("track", root.track.id, "move-up"); + } + } + } + Rectangle { + width: 22px; + height: 18px; + QbzIcon { + source: @image-url("../assets/icons/chevron-down.svg"); + width: 14px; + height: 14px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: down-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + down-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("track", root.track.id, "move-down"); + } + } + } + } + // Position number — only for artwork rows (the cover doesn't // carry the number; non-artwork rows show it in the play cell). if root.show-artwork && !root.multi-select-mode: VerticalLayout { @@ -75,13 +166,14 @@ export component TrackRow inherits Rectangle { VerticalLayout { alignment: center; Rectangle { - width: root.show-artwork ? 40px : 32px; - height: root.show-artwork ? 40px : 28px; + width: root.show-artwork ? 36px : 32px; + height: root.show-artwork ? 36px : 28px; play-cell := TrackPlayCell { track-id: root.track.id; number: root.number-text; artwork: root.track.artwork; show-artwork: root.show-artwork; + art-size: 36px; number-width: 32px; row-hovered: root.row-hovered && !root.multi-select-mode; visible: !root.multi-select-mode; @@ -129,7 +221,7 @@ export component TrackRow inherits Rectangle { Text { text: root.track.title; color: Theme.text-primary; - font-size: Typography.body; + font-size: Typography.link; font-weight: Typography.medium; vertical-alignment: center; overflow: elide; @@ -155,7 +247,7 @@ export component TrackRow inherits Rectangle { if root.track.artist != "": Text { text: root.track.artist; color: Theme.text-muted; - font-size: Typography.link; + font-size: Typography.legal; overflow: elide; } } diff --git a/crates/qbz-slint/ui/search/SearchResultsView.slint b/crates/qbz-slint/ui/search/SearchResultsView.slint index 5426ea399..ee990f573 100644 --- a/crates/qbz-slint/ui/search/SearchResultsView.slint +++ b/crates/qbz-slint/ui/search/SearchResultsView.slint @@ -8,7 +8,7 @@ // per-type tabs show the full loaded set as a card grid or row list with // a load-more button. -import { SearchState, SearchActions, TrackItem, SearchPlaylistItem, AlbumCardItem, SlimItem } from "../state.slint"; +import { SearchState, SearchActions, TrackItem, SearchPlaylistItem, AlbumCardItem, SlimItem, DragState } from "../state.slint"; import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; @@ -420,6 +420,7 @@ export component SearchResultsView inherits Rectangle { // --- Scrollable results body ------------------------------------ Flickable { + interactive: !DragState.active; vertical-stretch: 1; viewport-width: self.width; viewport-height: body.preferred-height; diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index e5fa25b27..b335152bb 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -14,6 +14,7 @@ import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint"; import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint"; import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint"; import { CreateFolderModal } from "../primitives/CreateFolderModal.slint"; +import { DragGhost } from "../primitives/DragGhost.slint"; import { PlayerBar } from "PlayerBar.slint"; import { QueueSidebar } from "QueueSidebar.slint"; import { LyricsSidebar } from "LyricsSidebar.slint"; @@ -237,4 +238,7 @@ export component AppShell inherits Rectangle { // Global "Create folder" modal. CreateFolderModal { } + + // Floating track-drag ghost (follows the cursor, on top of all). + DragGhost { } } diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint index 600763052..447c40228 100644 --- a/crates/qbz-slint/ui/shell/Sidebar.slint +++ b/crates/qbz-slint/ui/shell/Sidebar.slint @@ -12,7 +12,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; -import { ShellState, SidebarState, SidebarActions, SidebarEntry } from "../state.slint"; +import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState } from "../state.slint"; import { ScrollView } from "std-widgets.slint"; // A single context-menu item row. @@ -55,10 +55,46 @@ component SidebarRow inherits Rectangle { callback delete-folder(string); property is-folder: root.entry.kind == "folder"; + + // Track-drag drop target: a playlist row self-detects whether the + // global drag pointer is within its bounds (the source grabs the + // pointer, so normal hover doesn't fire during a drag). It claims + // the drop target while hot, and releases it when the pointer + // leaves. + // + // absolute-position must be read in an event handler (recompute), + // NOT a declarative binding — reading it during the layout phase + // is a recursion. So we mirror the drag pointer/active flag and + // recompute on their change. + property drag-px: DragState.pointer-x; + property drag-py: DragState.pointer-y; + property drag-on: DragState.active; + property drop-hot: false; + function recompute-drop() { + root.drop-hot = root.drag-on + && !root.is-folder + && root.drag-px >= root.absolute-position.x + && root.drag-px <= root.absolute-position.x + root.width + && root.drag-py >= root.absolute-position.y + && root.drag-py <= root.absolute-position.y + root.height; + if (root.drop-hot) { + DragState.over-playlist-id = root.entry.id; + } else if (DragState.over-playlist-id == root.entry.id) { + DragState.over-playlist-id = ""; + } + } + changed drag-px => { root.recompute-drop(); } + changed drag-py => { root.recompute-drop(); } + changed drag-on => { root.recompute-drop(); } + height: 32px; border-radius: 6px; - background: ta.has-hover || more-ta.has-hover || root.active - ? Theme.surface-hover : transparent; + background: root.drop-hot + ? #22c55e26 + : (ta.has-hover || more-ta.has-hover || root.active + ? Theme.surface-hover : transparent); + border-width: root.drop-hot ? 1px : 0px; + border-color: #22c55e66; HorizontalLayout { padding-left: root.entry.indent ? 24px : 8px; diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index b40f4de88..8dc7a7a8f 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -195,6 +195,32 @@ export global AlbumActions { callback search(string); } +// Track drag-and-drop onto sidebar playlists (a star QBZ feature). +// Slint has no HTML5 DnD, so this is pointer-based: the source row +// grabs the pointer and feeds global coords here; the floating ghost +// + each sidebar playlist row read this to self-detect the drop. +export global DragState { + in-out property active: false; + // Dragged-track count + ghost text (count == 1 shows title + + // "artist · album"; count > 1 shows "N tracks"). + in-out property count: 0; + in-out property ghost-title; + in-out property ghost-subtitle; + // Global pointer position (window coords) while dragging. + in-out property pointer-x; + in-out property pointer-y; + // The sidebar playlist id currently under the pointer (drop target). + in-out property over-playlist-id; +} + +export global DragActions { + // Begin a track drag from a row. `track-id` is the row; the Rust + // side expands it to the multi-selection when applicable. + callback start(string /* track id */, string /* title */, string /* subtitle */, length, length); + callback move(length /* gx */, length /* gy */); + callback end(); +} + // Which content view the shell shows. export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist } @@ -269,6 +295,9 @@ export global PlaylistState { in property name; in property owner; in property description; + // Truncated description for the header (~2 lines); full text in + // the Read-more modal. Equal to description when it fits. + in property description-short; in property track-count: 0; in property total-duration; in property cover-url; @@ -285,6 +314,17 @@ export global PlaylistState { in property selected-count: 0; in property <[TrackItem]> tracks: []; in property loading: false; + // Sort: "" / "default" = playlist order; else title|artist|album| + // duration. `sort-asc` flips on re-selecting the active field. + in property sort-field: "default"; + in property sort-asc: true; +} + +export global PlaylistActions { + // Client-side filter of the visible track list by query. + callback search(string); + // Sort by a field (re-selecting the active field toggles asc/desc). + callback set-sort(string); } // "Edit playlist" modal state (rename + description + delete).