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
This commit is contained in:
21 files changed
+1223
-65
No files matched your search
Generated
+34
@@ -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",
|
||||
|
||||
@@ -522,11 +522,9 @@ fn apply_artwork(
|
||||
}
|
||||
}
|
||||
ArtworkTarget::PlaylistTrack { index } => {
|
||||
let model = window.global::<crate::PlaylistState>().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::<crate::PlaylistState>().set_cover(image);
|
||||
|
||||
@@ -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<Mutex<Vec<u64>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
pub fn set_dragged(ids: Vec<u64>) {
|
||||
if let Ok(mut d) = DRAGGED.lock() {
|
||||
*d = ids;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dragged() -> Vec<u64> {
|
||||
DRAGGED.lock().map(|d| d.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
if let Ok(mut d) = DRAGGED.lock() {
|
||||
d.clear();
|
||||
}
|
||||
}
|
||||
@@ -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<u64> {
|
||||
use slint::Model;
|
||||
let view = w.global::<NavState>().get_view();
|
||||
let model = match view {
|
||||
ContentView::Playlist => Some(w.global::<PlaylistState>().get_tracks()),
|
||||
ContentView::Artist => Some(w.global::<ArtistState>().get_top_tracks()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(model) = model {
|
||||
let selected: Vec<u64> = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.filter(|t| t.selected)
|
||||
.filter_map(|t| t.id.parse::<u64>().ok())
|
||||
.collect();
|
||||
if selected.len() > 1 && selected.iter().any(|&id| id.to_string() == track_id) {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
track_id.parse::<u64>().map(|id| vec![id]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Load (or reload) the sidebar playlists list off-thread.
|
||||
fn load_sidebar_playlists(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
@@ -1678,6 +1704,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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::<PlaylistState>().get_id().to_string();
|
||||
if !orders.is_empty() {
|
||||
if let Ok(pid) = pid.parse::<u64>() {
|
||||
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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Track drag onto sidebar playlists (a star QBZ feature).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<DragActions>().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::<DragState>();
|
||||
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::<DragActions>().on_move(move |gx, gy| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let ds = w.global::<DragState>();
|
||||
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::<DragActions>().on_end(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ds = w.global::<DragState>();
|
||||
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::<u64>(), 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::<PlaylistActions>()
|
||||
.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::<PlaylistActions>()
|
||||
.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::<PlaylistState>().get_id().to_string();
|
||||
if let Ok(pid) = pid.parse::<u64>() {
|
||||
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();
|
||||
|
||||
@@ -19,12 +19,233 @@ use crate::{AppWindow, PlaylistState, TrackItem};
|
||||
/// The currently-loaded playlist tracks, for play-all / per-track play.
|
||||
static CURRENT: LazyLock<Mutex<Vec<Track>>> = 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<Vec<TrackItem>> = std::cell::RefCell::new(Vec::new());
|
||||
/// Active in-page search query.
|
||||
static QUERY: std::cell::RefCell<String> = 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::collections::HashMap<u64, i32>> =
|
||||
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::<u32>().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<TrackItem> = 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::<u64>().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::<PlaylistState>()
|
||||
.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::<PlaylistState>().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::<PlaylistState>();
|
||||
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<u64> {
|
||||
FULL_ITEMS.with(|cell| {
|
||||
cell.borrow()
|
||||
.iter()
|
||||
.filter_map(|t| t.id.parse::<u64>().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<u64>) -> 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::<u64>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Current visible custom order of ids.
|
||||
let order = CUSTOM_ORDER.with(|c| c.borrow().clone());
|
||||
let mut ids: Vec<u64> = FULL_ITEMS.with(|cell| {
|
||||
cell.borrow()
|
||||
.iter()
|
||||
.filter_map(|t| t.id.parse::<u64>().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::<PlaylistState>();
|
||||
state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::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<TrackItem> = 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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ export global Typography {
|
||||
out property <length> body: 15px;
|
||||
out property <length> subtitle: 15px;
|
||||
out property <length> 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 <length> section: 18px;
|
||||
out property <length> heading: 19px;
|
||||
out property <length> title: 25px;
|
||||
out property <length> wordmark: 29px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <string> label;
|
||||
in property <bool> active: false;
|
||||
in property <bool> 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 <bool> show-description: false;
|
||||
|
||||
// Label for the active sort field.
|
||||
property <string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <string> placeholder: "Search…";
|
||||
in property <length> max-open-width: 216px;
|
||||
in-out property <bool> open: false;
|
||||
in-out property <string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <length> viewport-height;
|
||||
in property <length> visible-height;
|
||||
in-out property <length> viewport-y;
|
||||
|
||||
// Only meaningful when the content overflows.
|
||||
property <length> max-scroll: max(0px, root.viewport-height - root.visible-height);
|
||||
property <length> thumb-h:
|
||||
root.viewport-height > 0px
|
||||
? max(48px, root.visible-height / root.viewport-height * root.height)
|
||||
: root.height;
|
||||
property <length> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <bool> show-favorite: false;
|
||||
in property <bool> show-download: false;
|
||||
in property <bool> multi-select-mode: false;
|
||||
// Custom-order reorder controls (up/down chevrons) at the leading
|
||||
// edge — used by the playlist custom sort.
|
||||
in property <bool> show-reorder: false;
|
||||
// Whether the row can be dragged onto sidebar playlists.
|
||||
in property <bool> 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 <bool> 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 <length> down-x;
|
||||
property <length> down-y;
|
||||
property <bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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 <bool> 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 <length> drag-px: DragState.pointer-x;
|
||||
property <length> drag-py: DragState.pointer-y;
|
||||
property <bool> drag-on: DragState.active;
|
||||
property <bool> 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;
|
||||
|
||||
@@ -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 <bool> active: false;
|
||||
// Dragged-track count + ghost text (count == 1 shows title +
|
||||
// "artist · album"; count > 1 shows "N tracks").
|
||||
in-out property <int> count: 0;
|
||||
in-out property <string> ghost-title;
|
||||
in-out property <string> ghost-subtitle;
|
||||
// Global pointer position (window coords) while dragging.
|
||||
in-out property <length> pointer-x;
|
||||
in-out property <length> pointer-y;
|
||||
// The sidebar playlist id currently under the pointer (drop target).
|
||||
in-out property <string> 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 <string> name;
|
||||
in property <string> owner;
|
||||
in property <string> description;
|
||||
// Truncated description for the header (~2 lines); full text in
|
||||
// the Read-more modal. Equal to description when it fits.
|
||||
in property <string> description-short;
|
||||
in property <int> track-count: 0;
|
||||
in property <string> total-duration;
|
||||
in property <string> cover-url;
|
||||
@@ -285,6 +314,17 @@ export global PlaylistState {
|
||||
in property <int> selected-count: 0;
|
||||
in property <[TrackItem]> tracks: [];
|
||||
in property <bool> loading: false;
|
||||
// Sort: "" / "default" = playlist order; else title|artist|album|
|
||||
// duration. `sort-asc` flips on re-selecting the active field.
|
||||
in property <string> sort-field: "default";
|
||||
in property <bool> 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).
|
||||
|
||||
Reference in new issue
Block a user