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:
vicrodh committed 2026-05-22 18:26:55 -06:00
1 parent 85223b03ff
commit de826bf560
21 files changed
+1223 -65

No files matched your search

+34
View File
@@ -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",
+3 -5
View File
@@ -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);
+24
View File
@@ -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();
}
}
+144
View File
@@ -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();
+246 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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;
+4 -2
View File
@@ -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;
+320 -38
View File
@@ -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));
}
}
}
}
+98 -6
View File
@@ -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;
+4
View File
@@ -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 { }
}
+39 -3
View File
@@ -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;
+40
View File
@@ -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).