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:
10 files changed
+663
-61
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user