feat(slint): sidebar Playlists toolbar + folders + micro-collage refinements
PLAYLISTS header: smaller title, recursive search (hides title), create modal at 1:1 with Tauri (name/description/folder/public), sort menu, and a collapse chevron. Folders: accent-tinted icon, open/close chevron as the primary affordance (no more purposeless "..."), count shown on hover only. Playlists render a 2x2 micro-collage of track covers (opt-out) instead of the glyph, sourced from the existing playlist images (no extra fetch). cc
This commit is contained in:
4 files changed
+601
-102
No files matched your search
+176
-25
@@ -18,6 +18,37 @@ use crate::{AppWindow, SidebarEntry, SidebarPlaylistItem, SidebarState};
|
||||
pub struct SidebarPlaylist {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Total track count (Qobuz). Used by the "# of tracks" sort.
|
||||
pub tracks_count: u32,
|
||||
/// Up to four cover-art URLs for the micro-collage, sourced from the
|
||||
/// `get_user_playlists()` payload (images300 / images150 / images).
|
||||
/// No extra fetch — same as Tauri's `images150 ?? images300 ?? images`.
|
||||
pub cover_urls: Vec<String>,
|
||||
/// Custom-sort position (from `playlist_settings.position`); the
|
||||
/// `Custom` sort orders by this ascending.
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
/// Pick up to four de-duplicated cover URLs for a playlist, preferring the
|
||||
/// largest available list (mirrors Tauri's `images150 ?? images300 ??
|
||||
/// images`, but de-duplicated and capped at four for the 2x2 collage).
|
||||
fn playlist_cover_urls(p: &qbz_models::Playlist) -> Vec<String> {
|
||||
let source = [&p.images300, &p.images150, &p.images]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|v| !v.is_empty());
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if let Some(list) = source {
|
||||
for url in list {
|
||||
if !url.is_empty() && !out.contains(url) {
|
||||
out.push(url.clone());
|
||||
}
|
||||
if out.len() == 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -31,22 +62,73 @@ pub struct SidebarData {
|
||||
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
/// Last loaded data, so expand/move rebuild without a refetch.
|
||||
static CACHE: LazyLock<Mutex<SidebarData>> = LazyLock::new(|| Mutex::new(SidebarData::default()));
|
||||
/// Active sort option, mirrored from `SidebarState.sort-option`. Session
|
||||
/// scope (Tauri persists in localStorage; we have no equivalent store
|
||||
/// here yet, so this matches the in-session behavior).
|
||||
static SORT: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new("name".to_string()));
|
||||
/// Active playlist-name search query (lowercased), mirrored from
|
||||
/// `SidebarState.search-query`. Filters the rebuilt list recursively.
|
||||
static SEARCH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||
|
||||
pub fn set_loading(window: &AppWindow, loading: bool) {
|
||||
window.global::<SidebarState>().set_loading(loading);
|
||||
}
|
||||
|
||||
/// Update the active sort option and re-render (matches Tauri's five
|
||||
/// options). Unknown values fall back to "name".
|
||||
pub fn set_sort(window: &AppWindow, option: &str) {
|
||||
let opt = match option {
|
||||
"name" | "recent" | "tracks" | "playcount" | "custom" => option,
|
||||
_ => "name",
|
||||
};
|
||||
if let Ok(mut s) = SORT.lock() {
|
||||
*s = opt.to_string();
|
||||
}
|
||||
window.global::<SidebarState>().set_sort_option(opt.into());
|
||||
rebuild(window);
|
||||
}
|
||||
|
||||
/// Update the playlist-name search filter and re-render. An empty query
|
||||
/// shows everything.
|
||||
pub fn set_search(window: &AppWindow, query: &str) {
|
||||
if let Ok(mut q) = SEARCH.lock() {
|
||||
*q = query.trim().to_lowercase();
|
||||
}
|
||||
rebuild(window);
|
||||
}
|
||||
|
||||
/// Order playlists by the active sort option, mirroring Tauri's
|
||||
/// comparators. `recent` keeps reverse insertion order (most-recently
|
||||
/// added first); `playcount` has no per-playlist count source here, so it
|
||||
/// stays stable like Tauri does when `play_count` is absent (0).
|
||||
fn sort_playlists(playlists: &[SidebarPlaylist]) -> Vec<SidebarPlaylist> {
|
||||
let sort = SORT.lock().map(|s| s.clone()).unwrap_or_else(|_| "name".into());
|
||||
let mut out: Vec<SidebarPlaylist> = playlists.to_vec();
|
||||
match sort.as_str() {
|
||||
"name" => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
||||
"recent" => out.reverse(),
|
||||
"tracks" => out.sort_by(|a, b| b.tracks_count.cmp(&a.tracks_count)),
|
||||
"playcount" => { /* no play_count source — stable, like Tauri's absent field */ }
|
||||
"custom" => out.sort_by(|a, b| a.position.cmp(&b.position)),
|
||||
_ => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Fetch playlists (Qobuz) + folders + folder membership (local).
|
||||
pub async fn load<A>(runtime: &AppRuntime<A>) -> SidebarData
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
let playlists = match runtime.core().get_user_playlists().await {
|
||||
let playlists: Vec<SidebarPlaylist> = match runtime.core().get_user_playlists().await {
|
||||
Ok(pls) => pls
|
||||
.into_iter()
|
||||
.map(|p| SidebarPlaylist {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
name: p.name.clone(),
|
||||
tracks_count: p.tracks_count,
|
||||
cover_urls: playlist_cover_urls(&p),
|
||||
position: 0,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
@@ -54,14 +136,23 @@ where
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let (folders, folder_map) = tokio::task::spawn_blocking(|| {
|
||||
// Folders + folder membership + per-playlist custom-sort positions
|
||||
// (all local, library.db).
|
||||
let (folders, folder_map, positions) = tokio::task::spawn_blocking(|| {
|
||||
(
|
||||
crate::folders::load_folders(),
|
||||
crate::folders::playlist_folder_map(),
|
||||
crate::folders::playlist_positions(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut playlists = playlists;
|
||||
for p in &mut playlists {
|
||||
if let Some(pos) = positions.get(&p.id) {
|
||||
p.position = *pos;
|
||||
}
|
||||
}
|
||||
SidebarData {
|
||||
playlists,
|
||||
folders,
|
||||
@@ -77,21 +168,61 @@ pub fn apply(window: &AppWindow, data: SidebarData) {
|
||||
rebuild(window);
|
||||
}
|
||||
|
||||
/// Build a playlist `SidebarEntry` (with its cover URLs for the
|
||||
/// micro-collage). The decoded `cover*` images stay default here and are
|
||||
/// filled asynchronously by the artwork pipeline (see `artwork_jobs`).
|
||||
fn playlist_entry(p: &SidebarPlaylist, indent: bool) -> SidebarEntry {
|
||||
let url = |i: usize| -> slint::SharedString {
|
||||
p.cover_urls.get(i).cloned().unwrap_or_default().into()
|
||||
};
|
||||
SidebarEntry {
|
||||
kind: "playlist".into(),
|
||||
id: p.id.to_string().into(),
|
||||
name: p.name.clone().into(),
|
||||
expanded: false,
|
||||
count: 0,
|
||||
indent,
|
||||
cover_count: p.cover_urls.len().min(4) as i32,
|
||||
url1: url(0),
|
||||
url2: url(1),
|
||||
url3: url(2),
|
||||
url4: url(3),
|
||||
cover1: slint::Image::default(),
|
||||
cover2: slint::Image::default(),
|
||||
cover3: slint::Image::default(),
|
||||
cover4: slint::Image::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the flattened entries (+ the folders list for the
|
||||
/// move-to-folder menu) from the cache + expand state.
|
||||
/// move-to-folder menu) from the cache + expand state, applying the
|
||||
/// active sort and the playlist-name search filter.
|
||||
pub fn rebuild(window: &AppWindow) {
|
||||
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
|
||||
let expanded = EXPANDED.lock().map(|e| e.clone()).unwrap_or_default();
|
||||
let query = SEARCH.lock().map(|q| q.clone()).unwrap_or_default();
|
||||
let searching = !query.is_empty();
|
||||
let folder_ids: HashSet<&String> = data.folders.iter().map(|f| &f.id).collect();
|
||||
|
||||
// Sort then filter by the playlist-name query (recursive — the same
|
||||
// filter applies to playlists nested in folders).
|
||||
let sorted = sort_playlists(&data.playlists);
|
||||
let matches = |p: &SidebarPlaylist| !searching || p.name.to_lowercase().contains(&query);
|
||||
|
||||
let mut entries: Vec<SidebarEntry> = Vec::new();
|
||||
for folder in &data.folders {
|
||||
let is_exp = expanded.contains(&folder.id);
|
||||
let members: Vec<&SidebarPlaylist> = data
|
||||
.playlists
|
||||
let members: Vec<&SidebarPlaylist> = sorted
|
||||
.iter()
|
||||
.filter(|p| data.folder_map.get(&p.id).map(|f| f == &folder.id).unwrap_or(false))
|
||||
.filter(|p| matches(p))
|
||||
.collect();
|
||||
// While searching, skip folders with no matching playlists
|
||||
// (mirrors Tauri's `if (isSearching && folderPlaylists.length === 0) continue`).
|
||||
if searching && members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// When searching, force-expand so matches inside are visible.
|
||||
let is_exp = searching || expanded.contains(&folder.id);
|
||||
entries.push(SidebarEntry {
|
||||
kind: "folder".into(),
|
||||
id: folder.id.clone().into(),
|
||||
@@ -99,36 +230,31 @@ pub fn rebuild(window: &AppWindow) {
|
||||
expanded: is_exp,
|
||||
count: members.len() as i32,
|
||||
indent: false,
|
||||
cover_count: 0,
|
||||
url1: Default::default(),
|
||||
url2: Default::default(),
|
||||
url3: Default::default(),
|
||||
url4: Default::default(),
|
||||
cover1: slint::Image::default(),
|
||||
cover2: slint::Image::default(),
|
||||
cover3: slint::Image::default(),
|
||||
cover4: slint::Image::default(),
|
||||
});
|
||||
if is_exp {
|
||||
for p in members {
|
||||
entries.push(SidebarEntry {
|
||||
kind: "playlist".into(),
|
||||
id: p.id.to_string().into(),
|
||||
name: p.name.clone().into(),
|
||||
expanded: false,
|
||||
count: 0,
|
||||
indent: true,
|
||||
});
|
||||
entries.push(playlist_entry(p, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Root playlists — no folder, or a folder that no longer exists.
|
||||
for p in &data.playlists {
|
||||
for p in &sorted {
|
||||
let in_folder = data
|
||||
.folder_map
|
||||
.get(&p.id)
|
||||
.map(|f| folder_ids.contains(f))
|
||||
.unwrap_or(false);
|
||||
if !in_folder {
|
||||
entries.push(SidebarEntry {
|
||||
kind: "playlist".into(),
|
||||
id: p.id.to_string().into(),
|
||||
name: p.name.clone().into(),
|
||||
expanded: false,
|
||||
count: 0,
|
||||
indent: false,
|
||||
});
|
||||
if !in_folder && matches(p) {
|
||||
entries.push(playlist_entry(p, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +273,31 @@ pub fn rebuild(window: &AppWindow) {
|
||||
state.set_loading(false);
|
||||
}
|
||||
|
||||
/// Build artwork-download jobs for every playlist row's collage covers,
|
||||
/// targeting `SidebarState.entries` by row index. Call after `apply` /
|
||||
/// `rebuild` updates the entries.
|
||||
pub fn artwork_jobs(window: &AppWindow) -> Vec<crate::artwork::ArtworkJob> {
|
||||
use slint::Model;
|
||||
let mut jobs = Vec::new();
|
||||
let entries = window.global::<SidebarState>().get_entries();
|
||||
for idx in 0..entries.row_count() {
|
||||
let Some(e) = entries.row_data(idx) else { continue };
|
||||
if e.kind != "playlist" {
|
||||
continue;
|
||||
}
|
||||
let urls = [e.url1, e.url2, e.url3, e.url4];
|
||||
for (slot, url) in urls.iter().enumerate() {
|
||||
if !url.is_empty() {
|
||||
jobs.push(crate::artwork::ArtworkJob {
|
||||
target: crate::artwork::ArtworkTarget::SidebarPlaylistCover { idx, slot },
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
/// Toggle a folder's expanded state, then re-render from cache.
|
||||
pub fn toggle_folder(window: &AppWindow, folder_id: &str) {
|
||||
if let Ok(mut exp) = EXPANDED.lock() {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// "Create playlist" modal — name field + public toggle + Create.
|
||||
// "Create playlist" modal — 1:1 with Tauri's PlaylistModal (create
|
||||
// mode): Name, optional multiline Description, a Folder dropdown
|
||||
// ("No folder" + the user's folders), a "Make playlist public"
|
||||
// checkbox, and Cancel / Create buttons.
|
||||
//
|
||||
// Opened from the sidebar "+" button (CreatePlaylistState.open).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
@@ -6,7 +10,8 @@ import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { CreatePlaylistState, CreatePlaylistActions } from "../state.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
import { QbzSelect } from "QbzSelect.slint";
|
||||
import { LineEdit, TextEdit } from "std-widgets.slint";
|
||||
|
||||
export component CreatePlaylistModal inherits Rectangle {
|
||||
visible: CreatePlaylistState.open;
|
||||
@@ -63,15 +68,62 @@ export component CreatePlaylistModal inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
name-input := LineEdit {
|
||||
placeholder-text: @tr("Playlist name");
|
||||
text <=> CreatePlaylistState.name;
|
||||
accepted => {
|
||||
CreatePlaylistActions.submit();
|
||||
// Name.
|
||||
VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("Name");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
name-input := LineEdit {
|
||||
placeholder-text: @tr("My Playlist");
|
||||
text <=> CreatePlaylistState.name;
|
||||
accepted => {
|
||||
CreatePlaylistActions.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public toggle row.
|
||||
// Description (optional, multiline).
|
||||
VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("Description (optional)");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
desc-input := TextEdit {
|
||||
height: 80px;
|
||||
wrap: word-wrap;
|
||||
text <=> CreatePlaylistState.description;
|
||||
}
|
||||
}
|
||||
|
||||
// Folder dropdown — only shown when the user has folders
|
||||
// (mirrors Tauri, which hides the row when folders.length
|
||||
// === 0).
|
||||
if CreatePlaylistState.folder-options.length > 1: VerticalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: @tr("Folder");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
QbzSelect {
|
||||
options: CreatePlaylistState.folder-options;
|
||||
current-index: CreatePlaylistState.folder-index;
|
||||
menu-width: 380px;
|
||||
selected(i) => {
|
||||
CreatePlaylistState.folder-index = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public checkbox row.
|
||||
HorizontalLayout {
|
||||
spacing: 10px;
|
||||
toggle := TouchArea {
|
||||
@@ -101,7 +153,7 @@ export component CreatePlaylistModal inherits Rectangle {
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: @tr("Public playlist");
|
||||
text: @tr("Make playlist public");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
vertical-alignment: center;
|
||||
@@ -109,10 +161,31 @@ export component CreatePlaylistModal inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Create button.
|
||||
// Cancel + Create.
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
spacing: 10px;
|
||||
// Cancel.
|
||||
Rectangle {
|
||||
width: cancel-label.preferred-width + 28px;
|
||||
height: 36px;
|
||||
border-radius: Radius.sm;
|
||||
background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
cancel-label := Text {
|
||||
text: @tr("Cancel");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
cancel-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
CreatePlaylistActions.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create.
|
||||
Rectangle {
|
||||
width: create-label.preferred-width + 36px;
|
||||
height: 36px;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Sidebar playlist micro-collage. A tiny (20px) thumbnail of a
|
||||
// playlist's track covers shown in place of the generic list-music
|
||||
// glyph, mirroring Tauri's PlaylistCoverCollage:
|
||||
// - 0 covers -> parent renders the fallback glyph (nothing here)
|
||||
// - 1-3 covers -> a single cover (the first tile), full bleed
|
||||
// - 4+ covers -> a 2x2 grid, 1px gap, 3px radius
|
||||
//
|
||||
// The covers + decoded images live on the SidebarEntry (cover-count,
|
||||
// cover1..cover4). This component only lays them out.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { SidebarEntry } from "../state.slint";
|
||||
|
||||
export component SidebarPlaylistCollage inherits Rectangle {
|
||||
in property <SidebarEntry> entry;
|
||||
in property <length> size: 20px;
|
||||
|
||||
width: root.size;
|
||||
height: root.size;
|
||||
border-radius: 3px;
|
||||
clip: true;
|
||||
background: Theme.surface-elevated;
|
||||
|
||||
property <int> n: root.entry.cover-count;
|
||||
property <length> gap: 1px;
|
||||
|
||||
// 1-3 covers -> single full-bleed cover (Tauri: tiles.length < 4).
|
||||
if root.n >= 1 && root.n < 4: Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.entry.cover1;
|
||||
image-fit: cover;
|
||||
}
|
||||
|
||||
// 4+ covers -> 2x2 grid (Tauri: tiles.slice(0, 4)).
|
||||
if root.n >= 4: VerticalLayout {
|
||||
spacing: root.gap;
|
||||
HorizontalLayout {
|
||||
spacing: root.gap;
|
||||
Image {
|
||||
source: root.entry.cover1;
|
||||
image-fit: cover;
|
||||
width: (root.size - root.gap) / 2;
|
||||
height: (root.size - root.gap) / 2;
|
||||
}
|
||||
Image {
|
||||
source: root.entry.cover2;
|
||||
image-fit: cover;
|
||||
width: (root.size - root.gap) / 2;
|
||||
height: (root.size - root.gap) / 2;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: root.gap;
|
||||
Image {
|
||||
source: root.entry.cover3;
|
||||
image-fit: cover;
|
||||
width: (root.size - root.gap) / 2;
|
||||
height: (root.size - root.gap) / 2;
|
||||
}
|
||||
Image {
|
||||
source: root.entry.cover4;
|
||||
image-fit: cover;
|
||||
width: (root.size - root.gap) / 2;
|
||||
height: (root.size - root.gap) / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,17 @@
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
|
||||
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState } from "../state.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
|
||||
// A single context-menu item row.
|
||||
component MenuItem inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> danger: false;
|
||||
in property <bool> selected: false;
|
||||
in property <image> icon;
|
||||
in property <bool> has-icon: false;
|
||||
callback activated();
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
@@ -26,14 +30,35 @@ component MenuItem inherits Rectangle {
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
spacing: 8px;
|
||||
if root.has-icon: VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: root.danger ? #ef4444 : Theme.text-secondary;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: root.label;
|
||||
color: root.danger ? #ef4444 : Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
font-weight: root.selected ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
// Selected check (sort options).
|
||||
if root.selected: VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/check.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
mi-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
@@ -43,12 +68,47 @@ component MenuItem inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// A square header toolbar button (search / + / ... / collapse).
|
||||
component HeaderIconButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> active: false;
|
||||
in property <length> icon-size: 15px;
|
||||
callback clicked();
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: (btn-ta.has-hover || root.active) ? Theme.surface-hover : transparent;
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: root.icon-size;
|
||||
height: root.icon-size;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: (btn-ta.has-hover || root.active) ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
btn-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One sidebar row — a folder header or a playlist (indented when in a
|
||||
// folder), driven by the entry's `kind`. A hover "..." button opens a
|
||||
// context popup: move-to-folder for playlists, delete for folders.
|
||||
// folder), driven by the entry's `kind`.
|
||||
//
|
||||
// Folders: the leading icon is accent-tinted; the row's primary
|
||||
// affordance is a trailing chevron that opens/closes the folder. The
|
||||
// playlist count is hidden until the row is hovered. Folder actions
|
||||
// (delete) remain reachable through the hover "..." button.
|
||||
//
|
||||
// Playlists: render a micro-collage of track covers (opt-out, via
|
||||
// SidebarState.playlist-collage) in place of the list-music glyph; a
|
||||
// hover "..." button opens the move-to-folder menu.
|
||||
component SidebarRow inherits Rectangle {
|
||||
in property <SidebarEntry> entry;
|
||||
in property <bool> active: false;
|
||||
in property <bool> show-collage: true;
|
||||
callback open-playlist(string);
|
||||
callback toggle-folder(string);
|
||||
callback move-playlist(string, string);
|
||||
@@ -87,11 +147,17 @@ component SidebarRow inherits Rectangle {
|
||||
changed drag-py => { root.recompute-drop(); }
|
||||
changed drag-on => { root.recompute-drop(); }
|
||||
|
||||
// True when this playlist row should show its micro-collage instead
|
||||
// of the generic glyph (opt-out + at least one cover available).
|
||||
property <bool> use-collage: !root.is-folder
|
||||
&& root.show-collage
|
||||
&& root.entry.cover-count > 0;
|
||||
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: root.drop-hot
|
||||
? #22c55e26
|
||||
: (ta.has-hover || more-ta.has-hover || root.active
|
||||
: (ta.has-hover || root.active
|
||||
? Theme.surface-hover : transparent);
|
||||
border-width: root.drop-hot ? 1px : 0px;
|
||||
border-color: #22c55e66;
|
||||
@@ -100,16 +166,25 @@ component SidebarRow inherits Rectangle {
|
||||
padding-left: root.entry.indent ? 24px : 8px;
|
||||
padding-right: 6px;
|
||||
spacing: 8px;
|
||||
// Leading icon — chevron+folder for folders, list-music for
|
||||
// playlists.
|
||||
// Leading icon — accent-tinted folder for folders, micro-collage
|
||||
// (or list-music fallback) for playlists.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: root.is-folder
|
||||
? (root.entry.expanded
|
||||
? @image-url("../assets/icons/folder-open.svg")
|
||||
: @image-url("../assets/icons/folder.svg"))
|
||||
: @image-url("../assets/icons/list-music.svg");
|
||||
if root.is-folder: QbzIcon {
|
||||
source: root.entry.expanded
|
||||
? @image-url("../assets/icons/folder-open.svg")
|
||||
: @image-url("../assets/icons/folder.svg");
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
// Folder icon tinted accent; the name keeps text color.
|
||||
tint: Theme.accent;
|
||||
}
|
||||
if !root.is-folder && root.use-collage: SidebarPlaylistCollage {
|
||||
entry: root.entry;
|
||||
size: 20px;
|
||||
}
|
||||
if !root.is-folder && !root.use-collage: QbzIcon {
|
||||
source: @image-url("../assets/icons/list-music.svg");
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
tint: root.active ? Theme.text-primary : Theme.text-muted;
|
||||
@@ -124,15 +199,45 @@ component SidebarRow inherits Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
// Folder playlist count.
|
||||
// Folder playlist count — only visible on hover.
|
||||
if root.is-folder && root.entry.count > 0: Text {
|
||||
text: root.entry.count;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
opacity: ta.has-hover ? 1.0 : 0.0;
|
||||
}
|
||||
// Context "..." button (hover).
|
||||
VerticalLayout {
|
||||
// Folder open/close chevron — the primary affordance. Reflects
|
||||
// the open/closed state (down = open, right = closed).
|
||||
if root.is-folder: VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: chevron-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: root.entry.expanded
|
||||
? @image-url("../assets/icons/chevron-down.svg")
|
||||
: @image-url("../assets/icons/chevron-right.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: chevron-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
chevron-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.toggle-folder(root.entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Playlist "..." (hover) — move-to-folder only. Folders use just
|
||||
// the open/close chevron; folder management lives in the Playlist
|
||||
// Manager, so folders show no "..." here.
|
||||
if !root.is-folder: VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
@@ -182,14 +287,6 @@ component SidebarRow inherits Rectangle {
|
||||
VerticalLayout {
|
||||
padding: 6px;
|
||||
spacing: 1px;
|
||||
// Folder: delete.
|
||||
if root.is-folder: MenuItem {
|
||||
label: @tr("Delete folder");
|
||||
danger: true;
|
||||
activated => {
|
||||
root.delete-folder(root.entry.id);
|
||||
}
|
||||
}
|
||||
// Playlist: move to folder. Wrapped so the `for` is not
|
||||
// a direct child of an `if` (not allowed in Slint).
|
||||
if !root.is-folder: VerticalLayout {
|
||||
@@ -236,67 +333,171 @@ export component Sidebar inherits Rectangle {
|
||||
padding: Spacing.md;
|
||||
spacing: Spacing.md;
|
||||
|
||||
// ---- Header toolbar ----------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
Text {
|
||||
height: 22px;
|
||||
|
||||
// Title (hidden while the search input is open). 1pt smaller
|
||||
// + tighter letter-spacing than the rest of the muted caps.
|
||||
if !SidebarState.search-open: Text {
|
||||
text: @tr("PLAYLISTS");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
// New folder.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: folder-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/folder-plus.svg");
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: folder-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
folder-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
SidebarActions.create-folder();
|
||||
}
|
||||
|
||||
// Inline search input — takes the full header width to the
|
||||
// left of the actions when open. Filters playlists (the
|
||||
// filter is applied in crate::sidebar, recursively across
|
||||
// folders).
|
||||
if SidebarState.search-open: search-edit := LineEdit {
|
||||
placeholder-text: @tr("Search playlists");
|
||||
text <=> SidebarState.search-query;
|
||||
horizontal-stretch: 1;
|
||||
height: 22px;
|
||||
// Auto-focus when the input mounts (it is re-created each
|
||||
// time the search opens), matching Tauri's focus().
|
||||
init => {
|
||||
self.focus();
|
||||
}
|
||||
edited => {
|
||||
SidebarActions.search-changed(self.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Search toggle.
|
||||
HeaderIconButton {
|
||||
icon: @image-url("../assets/icons/search.svg");
|
||||
active: SidebarState.search-open;
|
||||
clicked => {
|
||||
SidebarState.search-open = !SidebarState.search-open;
|
||||
if (!SidebarState.search-open) {
|
||||
SidebarState.search-query = "";
|
||||
SidebarActions.search-changed("");
|
||||
}
|
||||
}
|
||||
}
|
||||
// New playlist.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
// New playlist (+).
|
||||
HeaderIconButton {
|
||||
icon: @image-url("../assets/icons/plus.svg");
|
||||
icon-size: 16px;
|
||||
clicked => {
|
||||
SidebarActions.create-playlist();
|
||||
}
|
||||
}
|
||||
// Sort / more (...).
|
||||
sort-btn := HeaderIconButton {
|
||||
icon: @image-url("../assets/icons/ellipsis.svg");
|
||||
clicked => {
|
||||
sort-menu.show();
|
||||
}
|
||||
}
|
||||
// Collapse chevron (down = expanded, right = collapsed).
|
||||
HeaderIconButton {
|
||||
icon: SidebarState.playlists-collapsed
|
||||
? @image-url("../assets/icons/chevron-right.svg")
|
||||
: @image-url("../assets/icons/chevron-down.svg");
|
||||
clicked => {
|
||||
SidebarState.playlists-collapsed = !SidebarState.playlists-collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort + actions context menu. Anchored under the "..."
|
||||
// button. Sort options mirror Tauri exactly.
|
||||
sort-menu := PopupWindow {
|
||||
x: parent.width - 210px;
|
||||
y: 26px;
|
||||
width: 200px;
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: plus-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/plus.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: plus-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
plus-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
SidebarActions.create-playlist();
|
||||
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;
|
||||
// "Sort by" section label.
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 2px;
|
||||
spacing: 6px;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/arrow-up-down.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
Text {
|
||||
text: @tr("Sort by");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
MenuItem {
|
||||
label: @tr("Name (A-Z)");
|
||||
selected: SidebarState.sort-option == "name";
|
||||
activated => { SidebarActions.set-sort("name"); }
|
||||
}
|
||||
MenuItem {
|
||||
label: @tr("Recent");
|
||||
selected: SidebarState.sort-option == "recent";
|
||||
activated => { SidebarActions.set-sort("recent"); }
|
||||
}
|
||||
MenuItem {
|
||||
label: @tr("# of tracks");
|
||||
selected: SidebarState.sort-option == "tracks";
|
||||
activated => { SidebarActions.set-sort("tracks"); }
|
||||
}
|
||||
MenuItem {
|
||||
label: @tr("Play Count");
|
||||
selected: SidebarState.sort-option == "playcount";
|
||||
activated => { SidebarActions.set-sort("playcount"); }
|
||||
}
|
||||
MenuItem {
|
||||
label: @tr("Custom");
|
||||
selected: SidebarState.sort-option == "custom";
|
||||
activated => { SidebarActions.set-sort("custom"); }
|
||||
}
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
// New folder.
|
||||
MenuItem {
|
||||
label: @tr("New folder");
|
||||
has-icon: true;
|
||||
icon: @image-url("../assets/icons/folder-plus.svg");
|
||||
activated => { SidebarActions.create-folder(); }
|
||||
}
|
||||
// Manage playlists — open the full Playlist Manager.
|
||||
MenuItem {
|
||||
label: @tr("Manage playlists");
|
||||
has-icon: true;
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
activated => { SidebarActions.manage-playlists(); }
|
||||
}
|
||||
// Refresh — re-fetch the playlist list.
|
||||
MenuItem {
|
||||
label: @tr("Refresh");
|
||||
has-icon: true;
|
||||
icon: @image-url("../assets/icons/list-music.svg");
|
||||
activated => { SidebarActions.refresh-playlists(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
// ---- Playlist list (hidden when the section is collapsed) ----
|
||||
if !SidebarState.playlists-collapsed: ScrollView {
|
||||
vertical-stretch: 1;
|
||||
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
VerticalLayout {
|
||||
@@ -305,6 +506,7 @@ export component Sidebar inherits Rectangle {
|
||||
for entry in SidebarState.entries: SidebarRow {
|
||||
entry: entry;
|
||||
active: entry.kind == "playlist" && entry.id == SidebarState.active-id;
|
||||
show-collage: SidebarState.playlist-collage;
|
||||
open-playlist(id) => {
|
||||
SidebarActions.open-playlist(id);
|
||||
}
|
||||
@@ -326,6 +528,10 @@ export component Sidebar inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep the layout stretching even while collapsed.
|
||||
if SidebarState.playlists-collapsed: Rectangle {
|
||||
vertical-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag handle — a thin strip pinned to the right edge. Resizes the
|
||||
|
||||
Reference in new issue
Block a user