feat(slint): playlist folders in the sidebar

Local, Tauri-shared playlist folders (library.db) now organize the
sidebar:

  - New folders.rs controller (create / rename / delete / move via
    qbz-library); sidebar.rs reworked to a flattened entries model
    (folder headers + grouped playlists + root playlists) with a
    session expand cache, rebuilt without a refetch on expand/move.
  - Sidebar renders folders (expandable, with playlist counts) and
    playlists (indented inside folders); a hover "..." opens a context
    popup: Move to folder (root + each folder) for playlists, Delete
    folder for folders.
  - Header gains a New-folder button + a CreateFolderModal; folders
    reload after create/delete; moves are optimistic + persisted.

Adds folder-open / folder-plus icons.

cc
This commit is contained in:
vicrodh committed 2026-05-22 08:44:37 -06:00
1 parent 5ac5c60c57
commit 4fe034d86b
10 files changed
+663 -61

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
//! Playlist folders — local-only organization stored in library.db
//! (shared with the Tauri app). Folders are flat (no nesting); a
//! playlist belongs to at most one folder via
//! `playlist_settings.folder_id`. All ops are blocking (they open the
//! DB), so async callers wrap them in `tokio::task::spawn_blocking`.
use std::collections::HashMap;
use crate::library_db;
#[derive(Clone)]
pub struct FolderInfo {
pub id: String,
pub name: String,
}
/// All folders, ordered by their stored position.
pub fn load_folders() -> Vec<FolderInfo> {
library_db::with_db(|db| db.get_all_playlist_folders())
.unwrap_or_default()
.into_iter()
.map(|f| FolderInfo {
id: f.id,
name: f.name,
})
.collect()
}
/// playlist id -> folder id, for grouping playlists under folders.
pub fn playlist_folder_map() -> HashMap<u64, String> {
library_db::with_db(|db| db.get_all_playlist_settings())
.unwrap_or_default()
.into_iter()
.filter_map(|s| s.folder_id.map(|fid| (s.qobuz_playlist_id, fid)))
.collect()
}
pub fn create_folder(name: &str) -> Option<FolderInfo> {
library_db::with_db(|db| db.create_playlist_folder(name, None, None, None)).map(|f| {
FolderInfo {
id: f.id,
name: f.name,
}
})
}
pub fn rename_folder(id: &str, name: &str) {
library_db::with_db(|db| {
db.update_playlist_folder(id, Some(name), None, None, None, None, None)
});
}
pub fn delete_folder(id: &str) {
library_db::with_db(|db| db.delete_playlist_folder(id));
}
/// Move a playlist into `folder_id`, or to root when None.
pub fn move_playlist(playlist_id: u64, folder_id: Option<&str>) {
library_db::with_db(|db| db.move_playlist_to_folder(playlist_id, folder_id));
}
+116 -2
View File
@@ -35,6 +35,7 @@ mod play_history;
mod strip_html;
mod playback;
mod queue;
mod folders;
mod library_db;
mod playlist;
mod playlist_picker;
@@ -694,9 +695,9 @@ fn load_sidebar_playlists(
) {
let _ = weak.upgrade_in_event_loop(|w| sidebar::set_loading(&w, true));
handle.spawn(async move {
let playlists = sidebar::load(&runtime).await;
let data = sidebar::load(&runtime).await;
let _ = weak.upgrade_in_event_loop(move |w| {
sidebar::apply(&w, playlists);
sidebar::apply(&w, data);
});
});
}
@@ -2612,6 +2613,119 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
let weak = window.as_weak();
window
.global::<CreateFolderActions>()
.on_close(move || {
if let Some(w) = weak.upgrade() {
w.global::<CreateFolderState>().set_open(false);
}
});
}
{
// Create a folder, then refresh the sidebar.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<CreateFolderActions>()
.on_submit(move || {
let Some(w) = weak.upgrade() else { return; };
let name = w.global::<CreateFolderState>().get_name().to_string();
if name.trim().is_empty() || w.global::<CreateFolderState>().get_creating() {
return;
}
w.global::<CreateFolderState>().set_creating(true);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let nm = name.trim().to_string();
tokio::task::spawn_blocking(move || {
folders::create_folder(&nm);
})
.await
.ok();
let r2 = runtime.clone();
let w2 = weak.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<CreateFolderState>().set_creating(false);
w.global::<CreateFolderState>().set_open(false);
load_sidebar_playlists(r2, w2, &h2);
});
});
});
}
{
// Toggle a folder's expanded state (cheap, rebuilds from cache).
let weak = window.as_weak();
window
.global::<SidebarActions>()
.on_toggle_folder(move |id| {
if let Some(w) = weak.upgrade() {
sidebar::toggle_folder(&w, id.as_str());
}
});
}
{
// Open the create-folder modal.
let weak = window.as_weak();
window
.global::<SidebarActions>()
.on_create_folder(move || {
if let Some(w) = weak.upgrade() {
w.global::<CreateFolderState>().set_name("".into());
w.global::<CreateFolderState>().set_creating(false);
w.global::<CreateFolderState>().set_open(true);
}
});
}
{
// Delete a folder (its playlists fall back to root via the
// library DB's ON DELETE SET NULL), then reload the sidebar.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<SidebarActions>()
.on_delete_folder(move |id| {
let id = id.to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let fid = id.clone();
tokio::task::spawn_blocking(move || folders::delete_folder(&fid))
.await
.ok();
load_sidebar_playlists(runtime, weak, &handle);
});
});
}
{
// Move a playlist into a folder ("" = root). Optimistic local
// rebuild + a DB write.
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<SidebarActions>()
.on_move_playlist(move |playlist_id, folder_id| {
let Some(w) = weak.upgrade() else { return; };
let Ok(pid) = playlist_id.parse::<u64>() else { return; };
let fid = folder_id.to_string();
sidebar::move_playlist_local(&w, pid, &fid);
handle.spawn(async move {
tokio::task::spawn_blocking(move || {
let opt = if fid.is_empty() { None } else { Some(fid.as_str()) };
folders::move_playlist(pid, opt);
})
.await
.ok();
});
});
}
{
let weak = window.as_weak();
window
+144 -25
View File
@@ -1,28 +1,51 @@
//! Sidebar playlists controller. Loads the user's own playlists into
//! SidebarState for the left-nav list (clicking a row opens the
//! playlist detail view). Reloaded on shell entry and after a playlist
//! is created or deleted.
//! Sidebar playlists + folders controller. Builds the flattened
//! left-nav list (folder headers with their playlists + root
//! playlists) from the user's Qobuz playlists and the local folder
//! organization (library.db). The loaded data is cached so expand /
//! move operations rebuild the list without re-hitting the network.
use std::collections::{HashMap, HashSet};
use std::sync::{LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{AppWindow, SidebarPlaylistItem, SidebarState};
use crate::folders::FolderInfo;
use crate::{AppWindow, SidebarEntry, SidebarPlaylistItem, SidebarState};
#[derive(Clone)]
pub struct SidebarPlaylist {
pub id: String,
pub id: u64,
pub name: String,
}
pub async fn load<A>(runtime: &AppRuntime<A>) -> Vec<SidebarPlaylist>
#[derive(Clone, Default)]
pub struct SidebarData {
pub playlists: Vec<SidebarPlaylist>,
pub folders: Vec<FolderInfo>,
pub folder_map: HashMap<u64, String>,
}
/// Session-only folder expand state (matches Tauri — not persisted).
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()));
pub fn set_loading(window: &AppWindow, loading: bool) {
window.global::<SidebarState>().set_loading(loading);
}
/// Fetch playlists (Qobuz) + folders + folder membership (local).
pub async fn load<A>(runtime: &AppRuntime<A>) -> SidebarData
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_user_playlists().await {
Ok(playlists) => playlists
let playlists = match runtime.core().get_user_playlists().await {
Ok(pls) => pls
.into_iter()
.map(|p| SidebarPlaylist {
id: p.id.to_string(),
id: p.id,
name: p.name,
})
.collect(),
@@ -30,38 +53,134 @@ where
log::warn!("[qbz-slint] sidebar playlists load failed: {e}");
Vec::new()
}
};
let (folders, folder_map) = tokio::task::spawn_blocking(|| {
(
crate::folders::load_folders(),
crate::folders::playlist_folder_map(),
)
})
.await
.unwrap_or_default();
SidebarData {
playlists,
folders,
folder_map,
}
}
pub fn set_loading(window: &AppWindow, loading: bool) {
window.global::<SidebarState>().set_loading(loading);
/// Store the freshly-loaded data and render it.
pub fn apply(window: &AppWindow, data: SidebarData) {
if let Ok(mut cache) = CACHE.lock() {
*cache = data;
}
rebuild(window);
}
pub fn apply(window: &AppWindow, playlists: Vec<SidebarPlaylist>) {
let items: Vec<SidebarPlaylistItem> = playlists
.into_iter()
.map(|p| SidebarPlaylistItem {
id: p.id.into(),
name: p.name.into(),
/// Rebuild the flattened entries (+ the folders list for the
/// move-to-folder menu) from the cache + expand state.
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 folder_ids: HashSet<&String> = data.folders.iter().map(|f| &f.id).collect();
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
.iter()
.filter(|p| data.folder_map.get(&p.id).map(|f| f == &folder.id).unwrap_or(false))
.collect();
entries.push(SidebarEntry {
kind: "folder".into(),
id: folder.id.clone().into(),
name: folder.name.clone().into(),
expanded: is_exp,
count: members.len() as i32,
indent: false,
});
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,
});
}
}
}
// Root playlists — no folder, or a folder that no longer exists.
for p in &data.playlists {
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,
});
}
}
let folders: Vec<SidebarPlaylistItem> = data
.folders
.iter()
.map(|f| SidebarPlaylistItem {
id: f.id.clone().into(),
name: f.name.clone().into(),
})
.collect();
let state = window.global::<SidebarState>();
state.set_playlists(ModelRc::new(VecModel::from(items)));
state.set_entries(ModelRc::new(VecModel::from(entries)));
state.set_folders(ModelRc::new(VecModel::from(folders)));
state.set_loading(false);
}
/// 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() {
if !exp.remove(folder_id) {
exp.insert(folder_id.to_string());
}
}
rebuild(window);
}
/// Optimistically move a playlist in the cache (folder_id "" = root)
/// and re-render. The DB write happens separately.
pub fn move_playlist_local(window: &AppWindow, playlist_id: u64, folder_id: &str) {
if let Ok(mut cache) = CACHE.lock() {
if folder_id.is_empty() {
cache.folder_map.remove(&playlist_id);
} else {
cache.folder_map.insert(playlist_id, folder_id.to_string());
}
}
rebuild(window);
}
/// Highlight the open playlist in the sidebar (or clear with "").
pub fn set_active(window: &AppWindow, id: &str) {
window.global::<SidebarState>().set_active_id(id.into());
}
/// Whether `id` is one of the user's own playlists (in the sidebar
/// list) — used to gate playlist editing.
/// Whether `id` is one of the user's own playlists — used to gate
/// playlist editing.
pub fn contains(window: &AppWindow, id: &str) -> bool {
use slint::Model;
window
.global::<SidebarState>()
.get_playlists()
let entries = window.global::<SidebarState>().get_entries();
entries
.iter()
.any(|p| p.id == id)
.any(|e| e.kind == "playlist" && e.id == id)
}
+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 } 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, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions } 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`.
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2" />
</svg>

After

Width:  |  Height:  |  Size: 378 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 10v6" />
<path d="M9 13h6" />
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

@@ -0,0 +1,104 @@
// "Create folder" modal — a name field + Create. Opened from the
// sidebar's "New folder" action (CreateFolderState.open).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { CreateFolderState, CreateFolderActions } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { LineEdit } from "std-widgets.slint";
export component CreateFolderModal inherits Rectangle {
visible: CreateFolderState.open;
if CreateFolderState.open: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
CreateFolderActions.close();
}
}
Rectangle {
width: Math.min(root.width - 80px, 400px);
height: panel.preferred-height;
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 { }
panel := VerticalLayout {
padding: 20px;
spacing: 16px;
HorizontalLayout {
Text {
text: @tr("New folder");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
CreateFolderActions.close();
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
name-input := LineEdit {
placeholder-text: @tr("Folder name");
text <=> CreateFolderState.name;
accepted => {
CreateFolderActions.submit();
}
}
HorizontalLayout {
alignment: end;
Rectangle {
width: create-label.preferred-width + 36px;
height: 36px;
border-radius: Radius.sm;
opacity: (CreateFolderState.name != "" && !CreateFolderState.creating) ? 1.0 : 0.5;
background: create-ta.has-hover ? #ffffffd6 : Theme.accent;
create-label := Text {
text: CreateFolderState.creating ? @tr("Creating...") : @tr("Create");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
create-ta := TouchArea {
mouse-cursor: (CreateFolderState.name != "" && !CreateFolderState.creating)
? pointer : default;
clicked => {
if (CreateFolderState.name != "" && !CreateFolderState.creating) {
CreateFolderActions.submit();
}
}
}
}
}
}
}
}
}
+4
View File
@@ -13,6 +13,7 @@ import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
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 { PlayerBar } from "PlayerBar.slint";
import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint";
@@ -233,4 +234,7 @@ export component AppShell inherits Rectangle {
// Global "Edit playlist" modal (rename / delete).
EditPlaylistModal { }
// Global "Create folder" modal.
CreateFolderModal { }
}
+191 -29
View File
@@ -12,45 +12,172 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ShellState, SidebarState, SidebarActions, SidebarPlaylistItem } from "../state.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry } from "../state.slint";
import { ScrollView } from "std-widgets.slint";
// One playlist row in the sidebar.
component PlaylistRow inherits Rectangle {
in property <SidebarPlaylistItem> item;
in property <bool> active: false;
callback clicked(string);
height: 32px;
border-radius: 6px;
background: ta.has-hover || root.active ? Theme.surface-hover : transparent;
// A single context-menu item row.
component MenuItem inherits Rectangle {
in property <string> label;
in property <bool> danger: false;
callback activated();
height: 30px;
border-radius: 5px;
background: mi-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 15px;
height: 15px;
tint: root.active ? Theme.text-primary : Theme.text-muted;
}
}
Text {
text: root.item.name;
color: root.active ? Theme.text-primary : Theme.text-secondary;
text: root.label;
color: root.danger ? #ef4444 : Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
mi-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.activated();
}
}
}
// 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.
component SidebarRow inherits Rectangle {
in property <SidebarEntry> entry;
in property <bool> active: false;
callback open-playlist(string);
callback toggle-folder(string);
callback move-playlist(string, string);
callback delete-folder(string);
property <bool> is-folder: root.entry.kind == "folder";
height: 32px;
border-radius: 6px;
background: ta.has-hover || more-ta.has-hover || root.active
? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: root.entry.indent ? 24px : 8px;
padding-right: 6px;
spacing: 8px;
// Leading icon — chevron+folder for folders, list-music 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");
width: 15px;
height: 15px;
tint: root.active ? Theme.text-primary : Theme.text-muted;
}
}
Text {
text: root.entry.name;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
font-weight: root.is-folder ? 600 : 400;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
// Folder playlist count.
if root.is-folder && root.entry.count > 0: Text {
text: root.entry.count;
color: Theme.text-muted;
font-size: 11px;
vertical-alignment: center;
}
// Context "..." button (hover).
VerticalLayout {
alignment: center;
Rectangle {
width: 22px;
height: 22px;
border-radius: 4px;
opacity: (ta.has-hover || more-ta.has-hover) ? 1.0 : 0.0;
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
menu.show();
}
}
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.item.id);
if (root.is-folder) {
root.toggle-folder(root.entry.id);
} else {
root.open-playlist(root.entry.id);
}
}
}
menu := PopupWindow {
x: root.width - 196px;
y: 28px;
width: 190px;
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;
// 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 {
spacing: 1px;
Text {
text: @tr("Move to folder");
color: Theme.text-muted;
font-size: 11px;
horizontal-alignment: left;
}
MenuItem {
label: @tr("No folder");
activated => {
root.move-playlist(root.entry.id, "");
}
}
for f in SidebarState.folders: MenuItem {
label: f.name;
activated => {
root.move-playlist(root.entry.id, f.id);
}
}
}
}
}
}
}
@@ -74,6 +201,7 @@ export component Sidebar inherits Rectangle {
spacing: Spacing.md;
HorizontalLayout {
spacing: 4px;
Text {
text: @tr("PLAYLISTS");
color: Theme.text-muted;
@@ -82,6 +210,31 @@ export component Sidebar inherits Rectangle {
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();
}
}
}
}
// New playlist.
VerticalLayout {
alignment: center;
Rectangle {
@@ -113,14 +266,23 @@ export component Sidebar inherits Rectangle {
VerticalLayout {
spacing: 2px;
alignment: start;
for pl in SidebarState.playlists: PlaylistRow {
item: pl;
active: pl.id == SidebarState.active-id;
clicked(id) => {
for entry in SidebarState.entries: SidebarRow {
entry: entry;
active: entry.kind == "playlist" && entry.id == SidebarState.active-id;
open-playlist(id) => {
SidebarActions.open-playlist(id);
}
toggle-folder(id) => {
SidebarActions.toggle-folder(id);
}
move-playlist(pid, fid) => {
SidebarActions.move-playlist(pid, fid);
}
delete-folder(id) => {
SidebarActions.delete-folder(id);
}
}
if SidebarState.playlists.length == 0 && !SidebarState.loading: Text {
if SidebarState.entries.length == 0 && !SidebarState.loading: Text {
text: @tr("No playlists yet.");
color: Theme.text-muted;
font-size: 12px;
+35 -4
View File
@@ -198,16 +198,30 @@ export global AlbumActions {
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist }
// One row in the sidebar playlists list.
// One row in the sidebar playlists list (also used for the folder
// list in the move-to-folder menu).
export struct SidebarPlaylistItem {
id: string,
name: string,
}
// The sidebar's playlists list (the user's own playlists). Populated
// by crate::sidebar on shell entry and after create/delete.
// One flattened entry in the sidebar nav list — a folder header or a
// playlist row (indented when inside a folder).
export struct SidebarEntry {
kind: string, // "folder" | "playlist"
id: string,
name: string,
expanded: bool, // folders
count: int, // folder playlist count
indent: bool, // playlist nested in a folder
}
// The sidebar's playlists + folders. Populated by crate::sidebar on
// shell entry and after create/delete/move.
export global SidebarState {
in property <[SidebarPlaylistItem]> playlists: [];
in property <[SidebarEntry]> entries: [];
// Flat folder list, for the move-to-folder menu.
in property <[SidebarPlaylistItem]> folders: [];
in property <bool> loading: false;
// The currently-open playlist id, for the active-row highlight.
in property <string> active-id;
@@ -216,6 +230,11 @@ export global SidebarState {
export global SidebarActions {
callback open-playlist(string /* id */);
callback create-playlist();
callback create-folder();
callback toggle-folder(string /* folder id */);
callback delete-folder(string /* folder id */);
// Move a playlist into a folder ("" folder id = root).
callback move-playlist(string /* playlist id */, string /* folder id */);
}
// "Create playlist" modal state.
@@ -231,6 +250,18 @@ export global CreatePlaylistActions {
callback close();
}
// "Create folder" modal state.
export global CreateFolderState {
in-out property <bool> open: false;
in-out property <string> name;
in property <bool> creating: false;
}
export global CreateFolderActions {
callback submit();
callback close();
}
// Playlist detail screen state. Rows reuse the shared TrackRow
// (show-artwork + show-album); populated by crate::playlist.
export global PlaylistState {