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:
vicrodh committed 2026-05-26 01:07:08 -06:00
1 parent c5c124fb0c
commit 9f70a5ac5a
4 files changed
+601 -102

No files matched your search

+176 -25
View File
@@ -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;
}
}
}
}
+273 -67
View File
@@ -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