feat(slint): Playlist Manager view + shared nav/state/shell wiring

Port the Playlist Manager: header/toolbar (search/filter/sort/folder-flat
toggle/view-mode/new-folder/counter), FOLDERS card grid (accent icon, count,
edit), PLAYLISTS grid/list/tree, folder CRUD (FolderEditModal), move-to-
folder, per-card actions, and arrow-button reorder (custom sort). Reached
from the sidebar "Manage playlists" item; backend 100% reused via
QbzCore/library.db (no new commands). Pointer-event drag-and-drop and
gradient folder colors are deferred follow-ups.

This commit also carries the cross-cutting wiring the session's features all
share — state.slint globals/structs, NavEntry variants, main.rs dispatch +
action wiring, artwork targets, app.slint exports, AppShell mounts — so HEAD
is the verified-green tree (cargo check + release build clean).

cc
This commit is contained in:
vicrodh committed 2026-05-26 01:07:08 -06:00
1 parent 7aa3018cf6
commit 2bf6feaa6d
22 files changed
+3806 -13

No files matched your search

+60
View File
@@ -47,6 +47,8 @@ pub enum ArtworkTarget {
SearchArtist { idx: usize },
/// One collage cover slot (0-3) of `SearchState.playlists[idx]`.
SearchPlaylistCover { idx: usize, slot: usize },
/// One micro-collage cover slot (0-3) of `SidebarState.entries[idx]`.
SidebarPlaylistCover { idx: usize, slot: usize },
/// The most-popular search hero (its kind is read from SearchState).
SearchMostPopular,
/// A release card in `ArtistState.release-sections[section_idx]
@@ -62,6 +64,8 @@ pub enum ArtworkTarget {
FavoriteTrack { index: usize },
/// A card in FavoritesState.albums[index].
FavoriteAlbum { index: usize },
/// A card in DiscoverBrowseState.albums[index].
DiscoverBrowseAlbum { index: usize },
/// A card in FavoritesState.artists[index].
FavoriteArtist { index: usize },
/// A card in FavoritesState.playlists[index].
@@ -92,6 +96,12 @@ pub enum ArtworkTarget {
PlaylistTrack { index: usize },
/// The PlaylistState header cover.
PlaylistCover,
/// One collage cover slot (0-3) of
/// `PlaylistManagerState.playlists[index]`.
PmPlaylistCover { index: usize, slot: usize },
/// One collage cover slot (0-3) of a tree row's playlist
/// (`PlaylistManagerState.tree[index].playlist`).
PmTreeCover { index: usize, slot: usize },
}
impl ArtworkTarget {
@@ -105,6 +115,10 @@ impl ArtworkTarget {
| ArtworkTarget::FavoriteTrack { .. }
| ArtworkTarget::MixTrack { .. }
| ArtworkTarget::PlaylistTrack { .. } => 96,
// Sidebar micro-collage tiles render at ~10-20px; decode tiny.
ArtworkTarget::SidebarPlaylistCover { .. } => 48,
// Playlist Manager collage tiles render at ~70-140px.
ArtworkTarget::PmPlaylistCover { .. } | ArtworkTarget::PmTreeCover { .. } => 160,
_ => DECODE_SIZE,
}
}
@@ -362,6 +376,19 @@ fn apply_artwork(
model.set_row_data(idx, item);
}
}
ArtworkTarget::SidebarPlaylistCover { idx, slot } => {
let model = window.global::<crate::SidebarState>().get_entries();
if let Some(mut item) = model.row_data(idx) {
match slot {
0 => item.cover1 = image,
1 => item.cover2 = image,
2 => item.cover3 = image,
3 => item.cover4 = image,
_ => return,
}
model.set_row_data(idx, item);
}
}
ArtworkTarget::SearchMostPopular => {
let state = window.global::<SearchState>();
match state.get_most_popular_kind().as_str() {
@@ -432,6 +459,13 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::DiscoverBrowseAlbum { index } => {
let model = window.global::<crate::DiscoverBrowseState>().get_albums();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::FavoriteArtist { index } => {
let model = window.global::<crate::FavoritesState>().get_artists();
if let Some(mut item) = model.row_data(index) {
@@ -529,5 +563,31 @@ fn apply_artwork(
ArtworkTarget::PlaylistCover => {
window.global::<crate::PlaylistState>().set_cover(image);
}
ArtworkTarget::PmPlaylistCover { index, slot } => {
let model = window.global::<crate::PlaylistManagerState>().get_playlists();
if let Some(mut item) = model.row_data(index) {
match slot {
0 => item.cover1 = image,
1 => item.cover2 = image,
2 => item.cover3 = image,
3 => item.cover4 = image,
_ => return,
}
model.set_row_data(index, item);
}
}
ArtworkTarget::PmTreeCover { index, slot } => {
let model = window.global::<crate::PlaylistManagerState>().get_tree();
if let Some(mut row) = model.row_data(index) {
match slot {
0 => row.playlist.cover1 = image,
1 => row.playlist.cover2 = image,
2 => row.playlist.cover3 = image,
3 => row.playlist.cover4 = image,
_ => return,
}
model.set_row_data(index, row);
}
}
}
}
+156
View File
@@ -35,6 +35,15 @@ pub fn playlist_folder_map() -> HashMap<u64, String> {
.collect()
}
/// playlist id -> custom-sort position, for the sidebar "Custom" sort.
pub fn playlist_positions() -> HashMap<u64, i32> {
library_db::with_db(|db| db.get_all_playlist_settings())
.unwrap_or_default()
.into_iter()
.map(|s| (s.qobuz_playlist_id, s.position))
.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 {
@@ -58,3 +67,150 @@ pub fn delete_folder(id: &str) {
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));
}
// === Playlist Manager — richer folder + settings/stats access ==========
// The sidebar only needs id+name; the Playlist Manager needs the full
// folder record (icon preset/color/custom-image/hidden) plus the
// per-playlist settings/stats and local track counts.
/// Full folder record for the Playlist Manager (icon + color + hidden).
#[derive(Clone, Default)]
pub struct FolderFull {
pub id: String,
pub name: String,
pub icon_type: String,
pub icon_preset: String,
pub icon_color: String,
pub custom_image_path: Option<String>,
pub is_hidden: bool,
}
/// Per-playlist local settings the manager merges onto the remote list.
#[derive(Clone, Default)]
pub struct PlaylistSettingsLite {
pub hidden: bool,
pub is_favorite: bool,
pub position: i32,
pub folder_id: Option<String>,
}
/// All folders with their full icon/color records, ordered by position.
pub fn load_folders_full() -> Vec<FolderFull> {
library_db::with_db(|db| db.get_all_playlist_folders())
.unwrap_or_default()
.into_iter()
.map(|f| FolderFull {
id: f.id,
name: f.name,
icon_type: f.icon_type,
icon_preset: f.icon_preset,
icon_color: f.icon_color,
custom_image_path: f.custom_image_path,
is_hidden: f.is_hidden,
})
.collect()
}
/// playlist id -> its local settings (hidden/favorite/position/folder).
pub fn playlist_settings_map() -> HashMap<u64, PlaylistSettingsLite> {
library_db::with_db(|db| db.get_all_playlist_settings())
.unwrap_or_default()
.into_iter()
.map(|s| {
(
s.qobuz_playlist_id,
PlaylistSettingsLite {
hidden: s.hidden,
is_favorite: s.is_favorite,
position: s.position,
folder_id: s.folder_id,
},
)
})
.collect()
}
/// playlist id -> play count (for the "Play Count" sort + the list badge).
pub fn playlist_play_counts() -> HashMap<u64, u32> {
library_db::with_db(|db| db.get_all_playlist_stats())
.unwrap_or_default()
.into_iter()
.map(|s| (s.qobuz_playlist_id, s.play_count))
.collect()
}
/// playlist id -> local (non-Qobuz) track count.
pub fn playlist_local_counts() -> HashMap<u64, u32> {
library_db::with_db(|db| db.get_all_playlist_local_track_counts()).unwrap_or_default()
}
/// Create a folder with icon preset + color (manager create path).
pub fn create_folder_full(
name: &str,
icon_preset: &str,
icon_color: &str,
) -> Option<FolderFull> {
let preset = Some(icon_preset);
let color = if icon_color.is_empty() {
None
} else {
Some(icon_color)
};
library_db::with_db(|db| db.create_playlist_folder(name, Some("preset"), preset, color)).map(
|f| FolderFull {
id: f.id,
name: f.name,
icon_type: f.icon_type,
icon_preset: f.icon_preset,
icon_color: f.icon_color,
custom_image_path: f.custom_image_path,
is_hidden: f.is_hidden,
},
)
}
/// Update a folder (name, icon preset/type, color, custom image, hidden).
/// `custom_image_path` is `Some(Some(p))` to set, `Some(None)` to clear,
/// `None` to leave unchanged (mirrors the DB signature).
#[allow(clippy::too_many_arguments)]
pub fn update_folder_full(
id: &str,
name: &str,
icon_type: &str,
icon_preset: &str,
icon_color: &str,
custom_image_path: Option<Option<&str>>,
is_hidden: bool,
) {
let color = if icon_color.is_empty() {
None
} else {
Some(icon_color)
};
library_db::with_db(|db| {
db.update_playlist_folder(
id,
Some(name),
Some(icon_type),
Some(icon_preset),
color,
custom_image_path,
Some(is_hidden),
)
});
}
/// Set a playlist's favorite flag.
pub fn set_favorite(playlist_id: u64, favorite: bool) {
library_db::with_db(|db| db.set_playlist_favorite(playlist_id, favorite));
}
/// Set a playlist's hidden flag.
pub fn set_hidden(playlist_id: u64, hidden: bool) {
library_db::with_db(|db| db.set_playlist_hidden(playlist_id, hidden));
}
/// Persist a custom playlist order (custom-sort positions).
pub fn reorder_playlists(playlist_ids: &[u64]) {
library_db::with_db(|db| db.reorder_playlists(playlist_ids));
}
+827 -9
View File
@@ -22,6 +22,7 @@ mod auth;
mod commands;
mod custom_artwork;
mod dates;
mod discover_browse;
mod discovery_dismiss;
mod favorites;
mod foryou;
@@ -40,6 +41,7 @@ mod drag;
mod folders;
mod library_db;
mod playlist;
mod playlist_manager;
mod playlist_picker;
mod recently;
mod search;
@@ -121,6 +123,14 @@ async fn enter_shell(
reload_home(&runtime, &weak, &image_cache, "home".to_string()).await;
}
/// The shared genre-filter selection expanded to descendant ids, as the
/// `Option<Vec<u64>>` the discover endpoints take (None = no filter).
/// Shared by the home re-fetch and the DiscoverBrowse "View all" page.
fn current_genre_filter() -> Option<Vec<u64>> {
let ids = genre_filter::filter_ids();
(!ids.is_empty()).then_some(ids)
}
/// Fetch the discover index (honoring the shared genre selection),
/// apply all three tab section sets, show the requested tab, and fan
/// out artwork. Shared by the initial shell load and genre-filter /
@@ -190,6 +200,22 @@ async fn reload_home(
}
}
/// Read the current DiscoverBrowse "View all" target when that page is
/// the active view, so a genre-filter change can re-navigate it instead
/// of the Discover home index (the selection is shared across surfaces).
/// Returns None when any other view is showing. UI thread only.
fn current_browse_target(window: &AppWindow) -> Option<(String, String)> {
if window.global::<NavState>().get_view() != ContentView::DiscoverBrowse {
return None;
}
let state = window.global::<DiscoverBrowseState>();
let endpoint = state.get_endpoint().to_string();
if endpoint.is_empty() {
return None;
}
Some((endpoint, state.get_title().to_string()))
}
/// Push the navigation history flags onto `NavState`. UI thread only.
fn update_nav_flags(window: &AppWindow) {
let state = window.global::<NavState>();
@@ -505,12 +531,31 @@ fn apply_entry(
nav::NavEntry::Label { id, name } => {
navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name);
}
nav::NavEntry::DiscoverBrowse { endpoint, title } => {
discover_browse::navigate(
runtime.clone(),
weak.clone(),
handle,
image_cache.clone(),
endpoint,
title,
current_genre_filter(),
);
}
nav::NavEntry::Mix { kind } => {
navigate_mix(runtime.clone(), weak.clone(), handle, image_cache.clone(), kind);
}
nav::NavEntry::Playlist(id) => {
navigate_playlist(runtime.clone(), weak.clone(), handle, image_cache.clone(), id);
}
nav::NavEntry::PlaylistManager => {
playlist_manager::navigate(
runtime.clone(),
weak.clone(),
handle,
image_cache.clone(),
);
}
nav::NavEntry::Location {
mbid,
area_id,
@@ -737,10 +782,587 @@ fn load_sidebar_playlists(
let data = sidebar::load(&runtime).await;
let _ = weak.upgrade_in_event_loop(move |w| {
sidebar::apply(&w, data);
refresh_sidebar_covers(&w);
});
});
}
/// (Re)spawn the per-playlist micro-collage cover downloads for the
/// current `SidebarState.entries`. Called after any rebuild that replaces
/// the rows (load / toggle / move / sort / search), since `set_row_data`
/// resets the decoded cover images. Each completion updates only its own
/// row (see artwork.rs), and the shared image cache means already-fetched
/// covers resolve from disk without a re-download.
fn refresh_sidebar_covers(window: &AppWindow) {
if let Some(cache) = artwork::shared_cache() {
let jobs = sidebar::artwork_jobs(window);
if !jobs.is_empty() {
artwork::spawn_loads(jobs, window.as_weak(), cache);
}
}
}
/// Re-fire the artwork pipeline for the Playlist Manager's currently
/// rendered cards (after a rebuild swaps the models).
fn refresh_pm_covers(window: &AppWindow) {
if let Some(cache) = artwork::shared_cache() {
let jobs = playlist_manager::artwork_jobs(window);
if !jobs.is_empty() {
artwork::spawn_loads(jobs, window.as_weak(), cache);
}
let handle = tokio::runtime::Handle::current();
playlist_manager::load_folder_custom_images(window.as_weak(), &handle);
}
}
/// Build the folder-editor icon-preset + solid-color models (matches
/// Tauri's FolderEditModal presets). Run once when wiring the editor.
fn folder_editor_presets() -> (Vec<PmIconPreset>, Vec<PmColorSwatch>) {
// The icon glyphs are resolved in the .slint by id (a `@image-url`
// chain keyed on `preset.id`), so the model only carries the id; the
// image field stays default.
let presets: Vec<PmIconPreset> =
["heart", "star", "music", "folder", "disc", "library", "headphones"]
.iter()
.map(|id| PmIconPreset {
id: (*id).into(),
icon: slint::Image::default(),
})
.collect();
let parse = |hex: &str| -> slint::Color {
let h = hex.trim_start_matches('#');
let v = u32::from_str_radix(h, 16).unwrap_or(0);
slint::Color::from_rgb_u8(
((v >> 16) & 0xff) as u8,
((v >> 8) & 0xff) as u8,
(v & 0xff) as u8,
)
};
let mut swatches = vec![PmColorSwatch {
value: "".into(),
color: slint::Color::default(),
is_accent: true,
}];
for hex in [
"#ef4444", "#f97316", "#f59e0b", "#10b981", "#06b6d4", "#3b82f6", "#a855f7", "#ec4899",
"#f43f5e", "#64748b",
] {
swatches.push(PmColorSwatch {
value: hex.into(),
color: parse(hex),
is_accent: false,
});
}
(presets, swatches)
}
/// Wire all Playlist Manager + folder-editor callbacks. Mirrors the
/// favorites + sidebar wiring: optimistic local mutations (rebuild from
/// cache) plus a backend write on a blocking thread.
fn wire_playlist_manager(
window: &AppWindow,
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
tokio_rt: &tokio::runtime::Runtime,
image_cache: &artwork::ImageCache,
) {
// The folder-editor preset + color grids (built once, never change).
{
let (presets, swatches) = folder_editor_presets();
let fes = window.global::<FolderEditState>();
fes.set_icon_presets(slint::ModelRc::new(slint::VecModel::from(presets)));
fes.set_color_swatches(slint::ModelRc::new(slint::VecModel::from(swatches)));
}
// --- Open playlist ---------------------------------------------------
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<PlaylistManagerActions>()
.on_open_playlist(move |id| {
nav::record(nav::NavEntry::Playlist(id.to_string()));
navigate_playlist(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
id.to_string(),
);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
});
}
// --- Toolbar ---------------------------------------------------------
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_search_changed(move |query| {
if let Some(w) = weak.upgrade() {
w.global::<PlaylistManagerState>().set_search_query(query);
playlist_manager::rebuild(&w);
refresh_pm_covers(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_set_filter(move |value| {
if let Some(w) = weak.upgrade() {
w.global::<PlaylistManagerState>().set_filter(value);
playlist_manager::rebuild(&w);
refresh_pm_covers(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_set_sort(move |value| {
if let Some(w) = weak.upgrade() {
w.global::<PlaylistManagerState>().set_sort(value);
playlist_manager::rebuild(&w);
refresh_pm_covers(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_set_view_mode(move |value| {
if let Some(w) = weak.upgrade() {
w.global::<PlaylistManagerState>().set_view_mode(value);
playlist_manager::rebuild(&w);
refresh_pm_covers(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_toggle_folder_mode(move || {
if let Some(w) = weak.upgrade() {
let st = w.global::<PlaylistManagerState>();
let next = !st.get_folder_mode();
st.set_folder_mode(next);
// Leaving folder mode while in tree falls back to grid.
if !next && st.get_view_mode() == "tree" {
st.set_view_mode("grid".into());
}
playlist_manager::rebuild(&w);
refresh_pm_covers(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_toggle_folders_collapsed(move || {
if let Some(w) = weak.upgrade() {
let st = w.global::<PlaylistManagerState>();
st.set_folders_collapsed(!st.get_folders_collapsed());
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_toggle_tree_folder(move |id| {
if let Some(w) = weak.upgrade() {
playlist_manager::toggle_tree_folder(&w, id.as_str());
refresh_pm_covers(&w);
}
});
}
// --- Per-card playlist actions --------------------------------------
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistManagerActions>()
.on_toggle_favorite(move |id| {
let Some(w) = weak.upgrade() else { return };
let Ok(pid) = id.parse::<u64>() else { return };
let value = playlist_manager::toggle_favorite_local(&w, pid);
refresh_pm_covers(&w);
handle.spawn(async move {
tokio::task::spawn_blocking(move || folders::set_favorite(pid, value))
.await
.ok();
});
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistManagerActions>()
.on_toggle_hidden(move |id| {
let Some(w) = weak.upgrade() else { return };
let Ok(pid) = id.parse::<u64>() else { return };
let value = playlist_manager::toggle_hidden_local(&w, pid);
refresh_pm_covers(&w);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
tokio::task::spawn_blocking(move || folders::set_hidden(pid, value))
.await
.ok();
// The sidebar reflects hidden playlists, so refresh it.
load_sidebar_playlists(runtime, weak, &handle);
});
});
}
{
// Open the shared edit-playlist modal, prefilled from the card.
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_edit_playlist(move |id| {
use slint::Model;
let Some(w) = weak.upgrade() else { return };
let model = w.global::<PlaylistManagerState>().get_playlists();
let name = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.find(|it| it.id == id)
.map(|it| it.name)
.unwrap_or_default();
let es = w.global::<EditPlaylistState>();
es.set_id(id);
es.set_name(name);
es.set_description("".into());
es.set_open(true);
});
}
{
// Add-to-mixtape — the mixtape system is not yet ported to Slint;
// log and no-op (matches the deferred mixtape surface).
window
.global::<PlaylistManagerActions>()
.on_add_to_mixtape(move |id| {
log::info!("[qbz-slint] add-to-mixtape not yet implemented (playlist {id})");
});
}
// --- Arrow reorder (custom sort) ------------------------------------
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistManagerActions>()
.on_move_up(move |id| {
let Some(w) = weak.upgrade() else { return };
let Ok(pid) = id.parse::<u64>() else { return };
let order = playlist_manager::move_up(&w, pid);
refresh_pm_covers(&w);
if !order.is_empty() {
handle.spawn(async move {
tokio::task::spawn_blocking(move || folders::reorder_playlists(&order))
.await
.ok();
});
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistManagerActions>()
.on_move_down(move |id| {
let Some(w) = weak.upgrade() else { return };
let Ok(pid) = id.parse::<u64>() else { return };
let order = playlist_manager::move_down(&w, pid);
refresh_pm_covers(&w);
if !order.is_empty() {
handle.spawn(async move {
tokio::task::spawn_blocking(move || folders::reorder_playlists(&order))
.await
.ok();
});
}
});
}
{
// Move a playlist into a folder ("" = root).
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistManagerActions>()
.on_move_to_folder(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();
playlist_manager::move_to_folder_local(&w, pid, &fid);
refresh_pm_covers(&w);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let opt = fid.clone();
tokio::task::spawn_blocking(move || {
let o = if opt.is_empty() { None } else { Some(opt.as_str()) };
folders::move_playlist(pid, o);
})
.await
.ok();
load_sidebar_playlists(runtime, weak, &handle);
});
});
}
// --- Folder editor: open (new + edit) -------------------------------
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_new_folder(move || {
if let Some(w) = weak.upgrade() {
let fes = w.global::<FolderEditState>();
fes.set_id("".into());
fes.set_name("".into());
fes.set_icon_preset("folder".into());
fes.set_icon_color("".into());
fes.set_is_hidden(false);
fes.set_custom_image_path("".into());
fes.set_open(true);
}
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistManagerActions>()
.on_edit_folder(move |id| {
let Some(w) = weak.upgrade() else { return };
let fid = id.to_string();
if let Some(f) = playlist_manager::folder_for_edit(&fid) {
let fes = w.global::<FolderEditState>();
fes.set_id(id);
fes.set_name(f.name.into());
fes.set_icon_preset(f.icon_preset.into());
fes.set_icon_color(f.icon_color.into());
fes.set_is_hidden(f.is_hidden);
fes.set_custom_image_path(
f.custom_image_path.clone().unwrap_or_default().into(),
);
fes.set_open(true);
// Decode the existing custom image, if any.
if let Some(path) = f.custom_image_path {
playlist_manager::load_editor_custom_image(w.as_weak(), path);
}
}
});
}
// --- Folder editor: field changes -----------------------------------
{
let weak = window.as_weak();
window
.global::<FolderEditActions>()
.on_select_preset(move |id| {
if let Some(w) = weak.upgrade() {
let fes = w.global::<FolderEditState>();
fes.set_icon_preset(id);
// Choosing a preset clears the custom image.
fes.set_custom_image_path("".into());
}
});
}
{
let weak = window.as_weak();
window
.global::<FolderEditActions>()
.on_select_color(move |hex| {
if let Some(w) = weak.upgrade() {
w.global::<FolderEditState>().set_icon_color(hex);
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<FolderEditActions>()
.on_pick_image(move || {
let weak = weak.clone();
handle.spawn(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "gif"])
.pick_file()
.await
else {
return;
};
let path = file.path().to_string_lossy().to_string();
let path2 = path.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<FolderEditState>().set_custom_image_path(path2.into());
playlist_manager::load_editor_custom_image(w.as_weak(), path);
});
});
});
}
{
let weak = window.as_weak();
window
.global::<FolderEditActions>()
.on_clear_image(move || {
if let Some(w) = weak.upgrade() {
w.global::<FolderEditState>().set_custom_image_path("".into());
}
});
}
{
let weak = window.as_weak();
window
.global::<FolderEditActions>()
.on_toggle_hidden(move || {
if let Some(w) = weak.upgrade() {
let fes = w.global::<FolderEditState>();
fes.set_is_hidden(!fes.get_is_hidden());
}
});
}
{
let weak = window.as_weak();
window
.global::<FolderEditActions>()
.on_close(move || {
if let Some(w) = weak.upgrade() {
w.global::<FolderEditState>().set_open(false);
}
});
}
{
// Save (create or update) the folder, then reload PM + sidebar.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<FolderEditActions>()
.on_save(move || {
let Some(w) = weak.upgrade() else { return };
let fes = w.global::<FolderEditState>();
let id = fes.get_id().to_string();
let name = fes.get_name().to_string();
if name.trim().is_empty() {
return;
}
let preset = fes.get_icon_preset().to_string();
let color = fes.get_icon_color().to_string();
let hidden = fes.get_is_hidden();
let image_path = fes.get_custom_image_path().to_string();
fes.set_open(false);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
let nm = name.trim().to_string();
tokio::task::spawn_blocking(move || {
if id.is_empty() {
folders::create_folder_full(&nm, &preset, &color);
// A custom image on a brand-new folder: set it
// in a follow-up update once we have the id.
// (Rare path; the create flow defaults to a
// preset icon — image edits use the edit path.)
} else {
let icon_type = if image_path.is_empty() { "preset" } else { "custom" };
let img = if image_path.is_empty() {
Some(None)
} else {
Some(Some(image_path.as_str()))
};
folders::update_folder_full(
&id, &nm, icon_type, &preset, &color, img, hidden,
);
}
})
.await
.ok();
// Reload the manager data + sidebar.
let data = playlist_manager::load(&runtime).await;
let weak2 = weak.clone();
let r2 = runtime.clone();
let h2 = handle.clone();
let ic = image_cache.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
playlist_manager::apply(&w, data);
refresh_pm_covers(&w);
load_sidebar_playlists(r2, weak2, &h2);
let _ = ic;
});
});
});
}
{
// Delete the folder (Tauri ask() confirm), then reload.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<FolderEditActions>()
.on_delete(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<FolderEditState>().get_id().to_string();
let name = w.global::<FolderEditState>().get_name().to_string();
if id.is_empty() {
return;
}
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let confirmed = rfd::AsyncMessageDialog::new()
.set_title("Delete folder")
.set_description(format!(
"Delete the folder \u{201c}{name}\u{201d}? Its playlists move back to the root."
))
.set_buttons(rfd::MessageButtons::YesNo)
.show()
.await;
if confirmed != rfd::MessageDialogResult::Yes {
return;
}
let fid = id.clone();
tokio::task::spawn_blocking(move || folders::delete_folder(&fid))
.await
.ok();
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<FolderEditState>().set_open(false);
});
let data = playlist_manager::load(&runtime).await;
let weak2 = weak.clone();
let r2 = runtime.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
playlist_manager::apply(&w, data);
refresh_pm_covers(&w);
load_sidebar_playlists(r2, weak2, &h2);
});
});
});
}
}
/// Open a MusicianPageView for `name + role`. Routes to the artist
/// page instead when the resolved musician has a Confirmed Qobuz
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
@@ -2335,12 +2957,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
};
genre_filter::apply_state(&w);
w.global::<HomeState>().set_loading(true);
// When the DiscoverBrowse "View all" page is showing, the
// genre change re-fetches THAT page; otherwise it reloads
// the Discover home index.
let browse_target = current_browse_target(&w);
if browse_target.is_none() {
w.global::<HomeState>().set_loading(true);
}
let active = w.global::<HomeState>().get_active_tab().to_string();
let id = id.to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
let handle2 = handle.clone();
handle.spawn(async move {
// On a newly-selected genre, eager-load its descendants
// so filter_ids covers the child genres.
@@ -2352,7 +2981,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
}
reload_home(&runtime, &weak, &image_cache, active).await;
if let Some((endpoint, title)) = browse_target {
discover_browse::navigate(
runtime.clone(),
weak.clone(),
&handle2,
image_cache.clone(),
endpoint,
title,
current_genre_filter(),
);
} else {
reload_home(&runtime, &weak, &image_cache, active).await;
}
});
});
}
@@ -2409,13 +3050,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
};
genre_filter::apply_state(&w);
w.global::<HomeState>().set_loading(true);
let browse_target = current_browse_target(&w);
if browse_target.is_none() {
w.global::<HomeState>().set_loading(true);
}
let active = w.global::<HomeState>().get_active_tab().to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
let handle2 = handle.clone();
handle.spawn(async move {
reload_home(&runtime, &weak, &image_cache, active).await;
if let Some((endpoint, title)) = browse_target {
discover_browse::navigate(
runtime.clone(),
weak.clone(),
&handle2,
image_cache.clone(),
endpoint,
title,
current_genre_filter(),
);
} else {
reload_home(&runtime, &weak, &image_cache, active).await;
}
});
});
}
@@ -2507,6 +3164,67 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Discover "View all" — open the full-list page for a section,
// recording it as a history entry (mirrors the favorites branch
// of on_header_menu_navigate).
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.on_discover_view_all(move |endpoint, title| {
nav::record(nav::NavEntry::DiscoverBrowse {
endpoint: endpoint.to_string(),
title: title.to_string(),
});
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
discover_browse::navigate(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
endpoint.to_string(),
title.to_string(),
current_genre_filter(),
);
});
}
// Discover "View all" pagination — load the next album page when
// the grid scrolls near the bottom.
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<DiscoverBrowseActions>()
.on_load_more(move || {
discover_browse::load_more(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
current_genre_filter(),
);
});
}
// Discover "View all" search — re-filter the loaded albums
// client-side after the search box changes (UI thread).
{
let weak = window.as_weak();
window
.global::<DiscoverBrowseActions>()
.on_search_changed(move || {
if let Some(w) = weak.upgrade() {
discover_browse::apply_filter(&w);
}
});
}
// Favorites view actions — tab switch (lazy-load), open album /
// artist, and per-row track actions routed to the media-action
// "Add to playlist" picker — pick adds the pending track to the
@@ -2768,10 +3486,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.global::<SidebarActions>()
.on_create_playlist(move || {
if let Some(w) = weak.upgrade() {
w.global::<CreatePlaylistState>().set_name("".into());
w.global::<CreatePlaylistState>().set_is_public(false);
w.global::<CreatePlaylistState>().set_creating(false);
w.global::<CreatePlaylistState>().set_open(true);
use slint::Model;
let cps = w.global::<CreatePlaylistState>();
cps.set_name("".into());
cps.set_description("".into());
cps.set_is_public(false);
cps.set_creating(false);
cps.set_folder_index(0);
// Build the folder dropdown from the sidebar's folder
// list: index 0 = "No folder" (id ""), then each folder.
let folders = w.global::<SidebarState>().get_folders();
let mut opts: Vec<slint::SharedString> = vec!["No folder".into()];
let mut ids: Vec<slint::SharedString> = vec!["".into()];
for i in 0..folders.row_count() {
if let Some(f) = folders.row_data(i) {
opts.push(f.name);
ids.push(f.id);
}
}
cps.set_folder_options(slint::ModelRc::new(slint::VecModel::from(opts)));
cps.set_folder_ids(slint::ModelRc::new(slint::VecModel::from(ids)));
cps.set_open(true);
}
});
}
@@ -2828,6 +3563,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.on_toggle_folder(move |id| {
if let Some(w) = weak.upgrade() {
sidebar::toggle_folder(&w, id.as_str());
refresh_sidebar_covers(&w);
}
});
}
@@ -2878,6 +3614,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let Ok(pid) = playlist_id.parse::<u64>() else { return; };
let fid = folder_id.to_string();
sidebar::move_playlist_local(&w, pid, &fid);
refresh_sidebar_covers(&w);
handle.spawn(async move {
tokio::task::spawn_blocking(move || {
let opt = if fid.is_empty() { None } else { Some(fid.as_str()) };
@@ -2888,6 +3625,65 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
});
}
{
// Pick a playlist sort option (name/recent/tracks/playcount/custom).
let weak = window.as_weak();
window
.global::<SidebarActions>()
.on_set_sort(move |option| {
if let Some(w) = weak.upgrade() {
sidebar::set_sort(&w, option.as_str());
refresh_sidebar_covers(&w);
}
});
}
{
// Re-run the playlist-name filter as the search input edits.
let weak = window.as_weak();
window
.global::<SidebarActions>()
.on_search_changed(move |query| {
if let Some(w) = weak.upgrade() {
sidebar::set_search(&w, query.as_str());
refresh_sidebar_covers(&w);
}
});
}
{
// Refresh — re-fetch the playlist list from the network.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<SidebarActions>()
.on_refresh_playlists(move || {
load_sidebar_playlists(runtime.clone(), weak.clone(), &handle);
});
}
{
// Manage playlists — open the full Playlist Manager view.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<SidebarActions>()
.on_manage_playlists(move || {
nav::record(nav::NavEntry::PlaylistManager);
playlist_manager::navigate(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
});
}
// === Playlist Manager actions ======================================
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
{
let weak = window.as_weak();
window
@@ -2910,9 +3706,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let Some(w) = weak.upgrade() else {
return;
};
use slint::Model;
let state = w.global::<CreatePlaylistState>();
let name = state.get_name().to_string();
let description = state.get_description().to_string();
let is_public = state.get_is_public();
// Resolve the selected folder id ("" = No folder).
let folder_idx = state.get_folder_index();
let folder_id = state
.get_folder_ids()
.row_data(folder_idx.max(0) as usize)
.map(|s| s.to_string())
.unwrap_or_default();
if name.trim().is_empty() || state.get_creating() {
return;
}
@@ -2922,9 +3727,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
match runtime.core().create_playlist(name.trim(), None, is_public).await {
let desc = description.trim();
let desc_opt = if desc.is_empty() { None } else { Some(desc) };
match runtime.core().create_playlist(name.trim(), desc_opt, is_public).await {
Ok(playlist) => {
let new_id = playlist.id.to_string();
// Assign to the chosen folder (local DB) before
// the sidebar reloads, mirroring Tauri.
if !folder_id.is_empty() {
let pid = playlist.id;
let fid = folder_id.clone();
tokio::task::spawn_blocking(move || {
folders::move_playlist(pid, Some(fid.as_str()));
})
.await
.ok();
}
let weak2 = weak.clone();
let r2 = runtime.clone();
let h2 = handle.clone();
+13
View File
@@ -25,12 +25,25 @@ pub enum NavEntry {
Favorites {
tab: String,
},
/// A Discover "View all" full-list page — one album module
/// (new releases, qobuzissimes, ...) opened from a Carousel's
/// "View all" link. Carries the /discover/<x> endpoint + the
/// section title used as the page heading.
DiscoverBrowse {
endpoint: String,
title: String,
},
/// A Qobuz mix detail page ("daily" | "weekly" | "fav" | "top").
Mix {
kind: String,
},
/// A playlist detail page; the string is the playlist id.
Playlist(String),
/// The Playlist Manager — the full playlist + folder organization
/// surface (Tauri's PlaylistManagerView). Toolbar state
/// (filter/sort/view/folder-mode) is session-scoped in the
/// controller, so the entry carries no payload.
PlaylistManager,
Album(String),
Artist(String),
Settings,
+667
View File
@@ -0,0 +1,667 @@
//! Playlist Manager controller — the full playlist + folder organization
//! surface (Tauri's `PlaylistManagerView`). Local organization layer over
//! the user's Qobuz playlists: folders (icon + color + hidden), per-playlist
//! favorite / hidden / custom-order / folder membership, search / filter /
//! sort, and three view modes (grid / list / tree).
//!
//! The backend is 100% reusable: playlists come from
//! `QbzCore::get_user_playlists`, and folders / settings / stats / local
//! counts come from the per-user `library.db` via `crate::folders` (the
//! same data the Tauri `v2_*` commands back). All DB ops are blocking, so
//! the loader runs them on `spawn_blocking`.
//!
//! Merged row structs are precomputed in Rust (Send) and pushed as
//! ready-to-render Slint models — the view does NO per-row map lookups.
//! Toolbar state (filter / sort / view / folder-mode) is session-scoped,
//! mirrored in this module's statics so rebuilds don't re-hit the network.
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::folders::FolderFull;
use crate::{
AppWindow, ContentView, NavState, PlaylistManagerState, PmFolderItem, PmPlaylistItem, PmTreeRow,
};
/// Send view of one playlist merged with its local settings + stats.
#[derive(Clone)]
struct PmPlaylist {
id: u64,
name: String,
/// Remote (Qobuz) track count.
tracks_count: u32,
/// Local (non-Qobuz) track count.
local_count: u32,
play_count: u32,
is_favorite: bool,
is_hidden: bool,
folder_id: Option<String>,
position: i32,
/// Up to four de-duplicated cover URLs (same scheme as the sidebar).
cover_urls: Vec<String>,
}
impl PmPlaylist {
fn total_count(&self) -> u32 {
self.tracks_count + self.local_count
}
}
#[derive(Clone, Default)]
pub struct PmData {
playlists: Vec<PmPlaylist>,
folders: Vec<FolderFull>,
}
/// Last-loaded data (so toolbar changes rebuild from cache, no refetch).
static CACHE: LazyLock<Mutex<PmData>> = LazyLock::new(|| Mutex::new(PmData::default()));
/// Session folder-expand state for the tree view (Tauri: not persisted).
static EXPANDED: LazyLock<Mutex<std::collections::HashSet<String>>> =
LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
/// True once the tree has auto-expanded folders on first open.
static TREE_INIT: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
/// Pick up to four de-duplicated cover URLs (images150 > images300 >
/// images), mirroring `crate::sidebar::playlist_cover_urls`.
fn 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
}
/// Fetch playlists (Qobuz) + folders + settings + stats + local counts
/// (local, library.db) and merge into the Send `PmData`.
pub async fn load<A>(runtime: &AppRuntime<A>) -> PmData
where
A: FrontendAdapter + Send + Sync + 'static,
{
let remote = runtime.core().get_user_playlists().await.unwrap_or_else(|e| {
log::warn!("[qbz-slint] playlist-manager playlists load failed: {e}");
Vec::new()
});
let (folders, settings, play_counts, local_counts) = tokio::task::spawn_blocking(|| {
(
crate::folders::load_folders_full(),
crate::folders::playlist_settings_map(),
crate::folders::playlist_play_counts(),
crate::folders::playlist_local_counts(),
)
})
.await
.unwrap_or_else(|_| (Vec::new(), HashMap::new(), HashMap::new(), HashMap::new()));
let folder_ids: std::collections::HashSet<&String> = folders.iter().map(|f| &f.id).collect();
let playlists: Vec<PmPlaylist> = remote
.iter()
.map(|p| {
let s = settings.get(&p.id).cloned().unwrap_or_default();
// A folder that no longer exists falls back to root (matches the
// sidebar's `folder_ids.contains` guard).
let folder_id = s
.folder_id
.filter(|fid| folder_ids.contains(fid));
PmPlaylist {
id: p.id,
name: p.name.clone(),
tracks_count: p.tracks_count,
local_count: local_counts.get(&p.id).copied().unwrap_or(0),
play_count: play_counts.get(&p.id).copied().unwrap_or(0),
is_favorite: s.is_favorite,
is_hidden: s.hidden,
folder_id,
position: s.position,
cover_urls: cover_urls(p),
}
})
.collect();
PmData { playlists, folders }
}
pub fn set_loading(window: &AppWindow, loading: bool) {
window.global::<PlaylistManagerState>().set_loading(loading);
}
/// Store freshly-loaded data and render it.
pub fn apply(window: &AppWindow, data: PmData) {
if let Ok(mut c) = CACHE.lock() {
*c = data;
}
rebuild(window);
}
/// Reset the per-session tree-expand init so a fresh navigation
/// re-expands folders on first tree open.
pub fn reset_session(_window: &AppWindow) {
if let Ok(mut init) = TREE_INIT.lock() {
*init = false;
}
}
// --- sort / filter ------------------------------------------------------
/// Order playlists by the active sort (mirrors `applySortToList`):
/// name (locale-ish), playcount desc, tracks (remote+local) desc, custom
/// (position asc); `recent` keeps the API order.
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
match sort {
"name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"playcount" => list.sort_by(|a, b| b.play_count.cmp(&a.play_count)),
"tracks" => list.sort_by(|a, b| b.total_count().cmp(&a.total_count())),
"custom" => list.sort_by(|a, b| a.position.cmp(&b.position)),
// "recent" — keep insertion (API) order.
_ => {}
}
}
/// Whether a playlist passes the search + visibility + folder filters.
/// `current_folder` is None for the flat / root view (we never enter a
/// folder in the Slint port — folder navigation is via the tree).
fn passes(p: &PmPlaylist, query: &str, filter: &str, folder_mode: bool, view_mode: &str) -> bool {
if !query.is_empty() && !p.name.to_lowercase().contains(query) {
return false;
}
// Folder filter: in folder mode (non-tree), the grid/list shows ONLY
// root playlists (folders own their members; opening a folder is the
// tree's job in this port).
if folder_mode && view_mode != "tree" && p.folder_id.is_some() {
return false;
}
match filter {
"visible" => !p.is_hidden,
"hidden" => p.is_hidden,
_ => true,
}
}
// --- model builders -----------------------------------------------------
/// Parse a stored folder color into a Slint color. Only solid `#rgb` /
/// `#rrggbb` hex is representable; gradients ("linear-gradient(...)") and
/// CSS vars ("var(--accent-primary)") and empty values return None so the
/// card falls back to the accent.
fn parse_color(s: &str) -> Option<slint::Color> {
let hex = s.strip_prefix('#')?;
let (r, g, b) = match hex.len() {
3 => {
let v = u32::from_str_radix(hex, 16).ok()?;
let r = ((v >> 8) & 0xf) as u8;
let g = ((v >> 4) & 0xf) as u8;
let b = (v & 0xf) as u8;
(r * 17, g * 17, b * 17)
}
6 => {
let v = u32::from_str_radix(hex, 16).ok()?;
(((v >> 16) & 0xff) as u8, ((v >> 8) & 0xff) as u8, (v & 0xff) as u8)
}
_ => return None,
};
Some(slint::Color::from_rgb_u8(r, g, b))
}
fn folder_item(f: &FolderFull, count: usize) -> PmFolderItem {
let color = parse_color(&f.icon_color);
PmFolderItem {
id: f.id.clone().into(),
name: f.name.clone().into(),
count: count as i32,
icon_type: f.icon_type.clone().into(),
icon_preset: f.icon_preset.clone().into(),
icon_color: color.unwrap_or_default(),
has_color: color.is_some(),
is_hidden: f.is_hidden,
custom_image: slint::Image::default(),
has_custom_image: f.icon_type == "custom" && f.custom_image_path.is_some(),
}
}
fn playlist_item(p: &PmPlaylist) -> PmPlaylistItem {
let url = |i: usize| -> slint::SharedString {
p.cover_urls.get(i).cloned().unwrap_or_default().into()
};
let local_status = if p.local_count == 0 {
"no"
} else if p.tracks_count == 0 {
"all_local"
} else {
"some_local"
};
let local_line = if p.local_count > 0 {
format!("({} local)", p.local_count)
} else {
String::new()
};
PmPlaylistItem {
id: p.id.to_string().into(),
name: p.name.clone().into(),
tracks_line: format!("{} tracks", p.total_count()).into(),
local_line: local_line.into(),
local_count: p.local_count as i32,
total_count: p.total_count() as i32,
play_count: p.play_count as i32,
local_status: local_status.into(),
is_favorite: p.is_favorite,
is_hidden: p.is_hidden,
folder_id: p.folder_id.clone().unwrap_or_default().into(),
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 visible grid/list model + the folder model + the tree
/// from the cache, honoring the active toolbar state. UI thread only.
pub fn rebuild(window: &AppWindow) {
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
let state = window.global::<PlaylistManagerState>();
let query = state.get_search_query().trim().to_lowercase();
let filter = state.get_filter().to_string();
let sort = state.get_sort().to_string();
let view_mode = state.get_view_mode().to_string();
let folder_mode = state.get_folder_mode();
// Folder counts (members regardless of search/visibility, like Tauri).
let mut folder_counts: HashMap<String, usize> = HashMap::new();
for p in &data.playlists {
if let Some(fid) = &p.folder_id {
*folder_counts.entry(fid.clone()).or_insert(0) += 1;
}
}
let folders: Vec<PmFolderItem> = data
.folders
.iter()
.map(|f| folder_item(f, folder_counts.get(&f.id).copied().unwrap_or(0)))
.collect();
// Filtered + sorted playlists for the grid / list.
let mut filtered: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| passes(p, &query, &filter, folder_mode, &view_mode))
.cloned()
.collect();
sort_playlists(&mut filtered, &sort);
let playlist_items: Vec<PmPlaylistItem> = filtered.iter().map(playlist_item).collect();
let visible_count = playlist_items.len();
// Tree rows (folder headers + nested + root playlists). Built only
// when the tree view is active; otherwise an empty model.
let tree = if folder_mode && view_mode == "tree" {
build_tree(&data, &query, &filter, &sort)
} else {
Vec::new()
};
state.set_folders(ModelRc::new(VecModel::from(folders)));
state.set_playlists(ModelRc::new(VecModel::from(playlist_items)));
state.set_tree(ModelRc::new(VecModel::from(tree)));
state.set_folder_count(data.folders.len() as i32);
state.set_playlist_count(visible_count as i32);
state.set_can_reorder(sort == "custom" && query.is_empty());
state.set_loading(false);
}
/// Flatten folders + their (expanded) playlists + root playlists into the
/// tree model. Auto-expands all folders the first time the tree opens.
fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTreeRow> {
// Auto-expand on first tree open (Tauri's treeInitialized).
{
let mut init = TREE_INIT.lock().unwrap_or_else(|e| e.into_inner());
if !*init {
if let Ok(mut exp) = EXPANDED.lock() {
for f in &data.folders {
exp.insert(f.id.clone());
}
}
*init = true;
}
}
let expanded = EXPANDED.lock().map(|e| e.clone()).unwrap_or_default();
let searching = !query.is_empty();
let matches = |p: &PmPlaylist| -> bool {
if searching && !p.name.to_lowercase().contains(query) {
return false;
}
match filter {
"visible" => !p.is_hidden,
"hidden" => p.is_hidden,
_ => true,
}
};
let mut rows: Vec<PmTreeRow> = Vec::new();
for f in &data.folders {
let mut members: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| p.folder_id.as_deref() == Some(f.id.as_str()))
.filter(|p| matches(p))
.cloned()
.collect();
if searching && members.is_empty() {
continue;
}
sort_playlists(&mut members, sort);
let is_exp = searching || expanded.contains(&f.id);
rows.push(PmTreeRow {
kind: "folder".into(),
expanded: is_exp,
folder: folder_item(f, members.len()),
playlist: PmPlaylistItem::default(),
indent: false,
});
if is_exp {
for p in &members {
rows.push(PmTreeRow {
kind: "playlist".into(),
expanded: false,
folder: PmFolderItem::default(),
playlist: playlist_item(p),
indent: true,
});
}
}
}
// Root playlists (no folder).
let mut root: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| p.folder_id.is_none())
.filter(|p| matches(p))
.cloned()
.collect();
sort_playlists(&mut root, sort);
for p in &root {
rows.push(PmTreeRow {
kind: "playlist".into(),
expanded: false,
folder: PmFolderItem::default(),
playlist: playlist_item(p),
indent: false,
});
}
rows
}
/// Toggle a tree folder's expand state, then rebuild (cheap, from cache).
pub fn toggle_tree_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);
}
// --- artwork ------------------------------------------------------------
/// Build artwork jobs for every visible grid/list playlist card's collage
/// covers (targeting `PlaylistManagerState.playlists` by row index) plus
/// the tree rows' playlists. Plus the folders' decoded custom images.
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
let state = window.global::<PlaylistManagerState>();
let mut jobs = Vec::new();
let playlists = state.get_playlists();
for index in 0..playlists.row_count() {
let Some(p) = playlists.row_data(index) else {
continue;
};
for (slot, url) in [p.url1, p.url2, p.url3, p.url4].iter().enumerate() {
if !url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::PmPlaylistCover { index, slot },
url: url.to_string(),
});
}
}
}
let tree = state.get_tree();
for index in 0..tree.row_count() {
let Some(row) = tree.row_data(index) else {
continue;
};
if row.kind != "playlist" {
continue;
}
let p = row.playlist;
for (slot, url) in [p.url1, p.url2, p.url3, p.url4].iter().enumerate() {
if !url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::PmTreeCover { index, slot },
url: url.to_string(),
});
}
}
}
jobs
}
/// Decode the folder cards' custom images (local files) on a worker and
/// push them into the folder model. Folder custom images come from
/// `library.db`; the URL pipeline only handles http(s), so these are read
/// + decoded directly here.
pub fn load_folder_custom_images(weak: slint::Weak<AppWindow>, handle: &tokio::runtime::Handle) {
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
let with_images: Vec<(String, String)> = data
.folders
.iter()
.filter(|f| f.icon_type == "custom")
.filter_map(|f| f.custom_image_path.clone().map(|p| (f.id.clone(), p)))
.collect();
if with_images.is_empty() {
return;
}
handle.spawn(async move {
for (folder_id, path) in with_images {
let path2 = path.clone();
let decoded =
tokio::task::spawn_blocking(move || decode_local_image(&path2, 160)).await;
if let Ok(Some((pixels, w, h))) = decoded {
let fid = folder_id.clone();
let _ = weak.upgrade_in_event_loop(move |win| {
set_folder_image(&win, &fid, &pixels, w, h);
});
}
}
});
}
/// Read + decode a local image file to RGBA8, downscaled to `size`.
fn decode_local_image(path: &str, size: u32) -> Option<(Vec<u8>, u32, u32)> {
let img = image::open(path).ok()?.thumbnail(size, size).to_rgba8();
let (w, h) = img.dimensions();
Some((img.into_raw(), w, h))
}
/// Look up a folder's full record (from the cache) for the editor.
pub fn folder_for_edit(folder_id: &str) -> Option<FolderFull> {
CACHE
.lock()
.ok()?
.folders
.iter()
.find(|f| f.id == folder_id)
.cloned()
}
/// Decode a local image file and push it into the folder-editor preview
/// (FolderEditState.custom-image). Used when opening the editor on a
/// folder with an existing custom image, and after the user picks one.
pub fn load_editor_custom_image(weak: slint::Weak<AppWindow>, path: String) {
std::thread::spawn(move || {
if let Some((pixels, w, h)) = decode_local_image(&path, 160) {
let _ = weak.upgrade_in_event_loop(move |win| {
let image = artwork::pixels_to_image(&pixels, w, h);
win.global::<crate::FolderEditState>().set_custom_image(image);
});
}
});
}
fn set_folder_image(window: &AppWindow, folder_id: &str, pixels: &[u8], w: u32, h: u32) {
let image = artwork::pixels_to_image(pixels, w, h);
let model = window.global::<PlaylistManagerState>().get_folders();
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == folder_id {
item.custom_image = image.clone();
model.set_row_data(i, item);
}
}
}
// Mirror into the tree's folder rows.
let tree = window.global::<PlaylistManagerState>().get_tree();
for i in 0..tree.row_count() {
if let Some(mut row) = tree.row_data(i) {
if row.kind == "folder" && row.folder.id == folder_id {
row.folder.custom_image = image.clone();
tree.set_row_data(i, row);
}
}
}
}
// --- optimistic local mutations ----------------------------------------
/// Flip a playlist's favorite flag in the cache + rebuild.
pub fn toggle_favorite_local(window: &AppWindow, playlist_id: u64) -> bool {
let mut new_val = false;
if let Ok(mut c) = CACHE.lock() {
if let Some(p) = c.playlists.iter_mut().find(|p| p.id == playlist_id) {
p.is_favorite = !p.is_favorite;
new_val = p.is_favorite;
}
}
rebuild(window);
new_val
}
/// Flip a playlist's hidden flag in the cache + rebuild.
pub fn toggle_hidden_local(window: &AppWindow, playlist_id: u64) -> bool {
let mut new_val = false;
if let Ok(mut c) = CACHE.lock() {
if let Some(p) = c.playlists.iter_mut().find(|p| p.id == playlist_id) {
p.is_hidden = !p.is_hidden;
new_val = p.is_hidden;
}
}
rebuild(window);
new_val
}
/// Move a playlist into a folder ("" = root) in the cache + rebuild.
pub fn move_to_folder_local(window: &AppWindow, playlist_id: u64, folder_id: &str) {
if let Ok(mut c) = CACHE.lock() {
if let Some(p) = c.playlists.iter_mut().find(|p| p.id == playlist_id) {
p.folder_id = if folder_id.is_empty() {
None
} else {
Some(folder_id.to_string())
};
}
}
rebuild(window);
}
/// Move a playlist one slot up (custom sort): swap with its predecessor in
/// the current visible order, write the new positions back to the cache,
/// rebuild, and return the new full id order for persistence (empty when
/// the move is a no-op, e.g. already first).
pub fn move_up(window: &AppWindow, playlist_id: u64) -> Vec<u64> {
reorder_step(window, playlist_id, -1)
}
/// Move a playlist one slot down (custom sort).
pub fn move_down(window: &AppWindow, playlist_id: u64) -> Vec<u64> {
reorder_step(window, playlist_id, 1)
}
/// Shared up/down logic: reorder the currently-visible list (root, custom
/// sort) and assign fresh positions to the *full* playlist set so the
/// `position` field stays a total order. Returns the new id order for the
/// DB write, or empty on a no-op.
fn reorder_step(window: &AppWindow, playlist_id: u64, delta: i32) -> Vec<u64> {
let model = window.global::<PlaylistManagerState>().get_playlists();
let mut ids: Vec<u64> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter_map(|it| it.id.parse::<u64>().ok())
.collect();
let Some(pos) = ids.iter().position(|&id| id == playlist_id) else {
return Vec::new();
};
let target = pos as i32 + delta;
if target < 0 || target as usize >= ids.len() {
return Vec::new();
}
ids.swap(pos, target as usize);
// Write fresh positions back into the cache for the reordered ids, then
// rebuild so the move is reflected immediately under the custom sort.
if let Ok(mut c) = CACHE.lock() {
for (i, id) in ids.iter().enumerate() {
if let Some(p) = c.playlists.iter_mut().find(|p| p.id == *id) {
p.position = i as i32;
}
}
}
rebuild(window);
ids
}
// --- navigation ---------------------------------------------------------
/// Open the Playlist Manager and load its data. Mirrors `navigate_favorites`.
pub fn navigate(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
) {
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(|w| {
reset_session(&w);
set_loading(&w, true);
w.global::<NavState>().set_view(ContentView::PlaylistManager);
});
let data = load(&runtime).await;
let handle2 = tokio::runtime::Handle::current();
let weak2 = weak.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
apply(&w, data);
let jobs = artwork_jobs(&w);
artwork::spawn_loads(jobs, weak2.clone(), image_cache.clone());
load_folder_custom_images(weak2.clone(), &handle2);
});
});
}
+5 -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, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, FolderEditState, FolderEditActions } 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`.
@@ -47,6 +47,7 @@ export component AppWindow inherits Window {
callback open-artist(string);
callback media-action(string, string, string);
callback header-menu-navigate(string);
callback discover-view-all(string, string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback settings-slider(string, int);
@@ -84,6 +85,9 @@ export component AppWindow inherits Window {
header-menu-navigate(route) => {
root.header-menu-navigate(route);
}
discover-view-all(e, t) => {
root.discover-view-all(e, t);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
@@ -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">
<line x1="18" x2="18" y1="20" y2="10" />
<line x1="12" x2="12" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>

After

Width:  |  Height:  |  Size: 310 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="m2 2 20 20" />
<path d="M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193" />
<path d="M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07" />
</svg>

After

Width:  |  Height:  |  Size: 364 B

@@ -0,0 +1,6 @@
<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="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

+4
View File
@@ -0,0 +1,4 @@
<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="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,8 @@
<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">
<circle cx="9" cy="12" r="1" />
<circle cx="9" cy="5" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

@@ -0,0 +1,6 @@
<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="m16 6 4 14" />
<path d="M12 6v14" />
<path d="M8 8v12" />
<path d="M4 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 280 B

@@ -0,0 +1,7 @@
<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">
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</svg>

After

Width:  |  Height:  |  Size: 421 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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -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="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

After

Width:  |  Height:  |  Size: 564 B

@@ -0,0 +1,6 @@
<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 20h.01" />
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.859a10 10 0 0 1 14 0" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
</svg>

After

Width:  |  Height:  |  Size: 333 B

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,272 @@
// Folder editor modal — create + edit a playlist folder (Tauri's
// FolderEditModal). Name field, an icon-preset grid, a solid-color swatch
// grid (gradients intentionally omitted — Slint has no gradient brush in a
// struct), a custom-image picker, a hidden toggle, and save/delete.
//
// Driven by FolderEditState/FolderEditActions. The icon-preset + color
// grids are built once by the Rust controller and pushed as models.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { FolderEditState, FolderEditActions, PmIconPreset, PmColorSwatch } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { QbzToggle } from "QbzToggle.slint";
import { LineEdit } from "std-widgets.slint";
export component FolderEditModal inherits Rectangle {
visible: FolderEditState.open;
if FolderEditState.open: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => { FolderEditActions.close(); }
}
Rectangle {
width: Math.min(root.width - 80px, 480px);
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;
// Title + close.
HorizontalLayout {
Text {
text: FolderEditState.id == "" ? @tr("New folder") : @tr("Edit 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 => { FolderEditActions.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.
VerticalLayout {
spacing: 6px;
Text {
text: @tr("Folder name");
color: Theme.text-muted;
font-size: Typography.legal;
}
name-input := LineEdit {
placeholder-text: @tr("Enter folder name");
text <=> FolderEditState.name;
accepted => { FolderEditActions.save(); }
}
}
// Icon preset grid.
VerticalLayout {
spacing: 6px;
Text {
text: @tr("Icon");
color: Theme.text-muted;
font-size: Typography.legal;
}
HorizontalLayout {
spacing: 8px;
for preset[i] in FolderEditState.icon-presets: Rectangle {
width: 40px;
height: 40px;
border-radius: 8px;
property <bool> active: FolderEditState.custom-image-path == ""
&& FolderEditState.icon-preset == preset.id;
background: self.active ? Theme.accent
: (preset-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
QbzIcon {
source:
preset.id == "heart" ? @image-url("../assets/icons/heart.svg")
: preset.id == "star" ? @image-url("../assets/icons/star.svg")
: preset.id == "music" ? @image-url("../assets/icons/music.svg")
: preset.id == "disc" ? @image-url("../assets/icons/disc.svg")
: preset.id == "library" ? @image-url("../assets/icons/library.svg")
: preset.id == "headphones" ? @image-url("../assets/icons/audio-lines.svg")
: @image-url("../assets/icons/folder.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: parent.active ? #ffffff : Theme.text-secondary;
}
preset-ta := TouchArea {
mouse-cursor: pointer;
clicked => { FolderEditActions.select-preset(preset.id); }
}
}
// Custom image picker.
Rectangle {
width: 40px;
height: 40px;
border-radius: 8px;
property <bool> active: FolderEditState.custom-image-path != "";
background: self.active ? Theme.accent
: (img-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
clip: true;
if FolderEditState.custom-image-path != "": Image {
width: 100%;
height: 100%;
source: FolderEditState.custom-image;
image-fit: cover;
}
if FolderEditState.custom-image-path == "": QbzIcon {
source: @image-url("../assets/icons/image-plus.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-secondary;
}
img-ta := TouchArea {
mouse-cursor: pointer;
clicked => { FolderEditActions.pick-image(); }
}
}
Rectangle { horizontal-stretch: 1; }
}
}
// Color swatches.
VerticalLayout {
spacing: 6px;
Text {
text: @tr("Color");
color: Theme.text-muted;
font-size: Typography.legal;
}
// Two rows of swatches, wrapped manually via a grid.
swatch-grid := Rectangle {
property <int> columns: 11;
property <length> sw: 28px;
property <length> gap: 8px;
property <int> rows: Math.ceil(FolderEditState.color-swatches.length / self.columns);
height: self.rows * self.sw + Math.max(0, self.rows - 1) * self.gap;
for sw[i] in FolderEditState.color-swatches: Rectangle {
x: Math.mod(i, swatch-grid.columns) * (swatch-grid.sw + swatch-grid.gap);
y: Math.floor(i / swatch-grid.columns) * (swatch-grid.sw + swatch-grid.gap);
width: swatch-grid.sw;
height: swatch-grid.sw;
border-radius: 14px;
property <bool> active: FolderEditState.icon-color == sw.value;
background: sw.is-accent ? Theme.accent : sw.color;
border-width: self.active ? 2px : 0px;
border-color: #ffffff;
if self.active: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
sw-ta := TouchArea {
mouse-cursor: pointer;
clicked => { FolderEditActions.select-color(sw.value); }
}
}
}
}
// Hidden toggle.
HorizontalLayout {
spacing: 10px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Hide from sidebar");
color: Theme.text-secondary;
font-size: Typography.body;
}
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: FolderEditState.is-hidden;
toggled => { FolderEditActions.toggle-hidden(); }
}
}
}
// Buttons.
HorizontalLayout {
spacing: 10px;
// Delete (edit mode only).
if FolderEditState.id != "": Rectangle {
width: del-label.preferred-width + 28px;
height: 36px;
border-radius: Radius.sm;
background: del-ta.has-hover ? #ef444433 : transparent;
border-width: 1px;
border-color: #ef4444;
del-label := Text {
text: @tr("Delete");
color: #ef4444;
font-size: Typography.body;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
del-ta := TouchArea {
mouse-cursor: FolderEditState.busy ? default : pointer;
clicked => {
if (!FolderEditState.busy) { FolderEditActions.delete(); }
}
}
}
Rectangle { horizontal-stretch: 1; }
// Save.
Rectangle {
width: save-label.preferred-width + 36px;
height: 36px;
border-radius: Radius.sm;
opacity: (FolderEditState.name != "" && !FolderEditState.busy) ? 1.0 : 0.5;
background: save-ta.has-hover ? #ffffffd6 : Theme.accent;
save-label := Text {
text: @tr("Save");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
save-ta := TouchArea {
mouse-cursor: (FolderEditState.name != "" && !FolderEditState.busy)
? pointer : default;
clicked => {
if (FolderEditState.name != "" && !FolderEditState.busy) {
FolderEditActions.save();
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,81 @@
// Playlist Manager cover collage — a square thumbnail of a playlist's
// track covers, mirroring Tauri's PlaylistCollage:
// - 0 covers -> fallback list-music glyph
// - 1-3 covers -> a single full-bleed cover
// - 4+ covers -> a 2x2 grid (1px gap)
//
// The covers + decoded images live on the PmPlaylistItem (cover-count,
// cover1..cover4). This component only lays them out.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { PmPlaylistItem } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
export component PmCollage inherits Rectangle {
in property <PmPlaylistItem> item;
in property <length> size: 140px;
in property <length> radius: Radius.sm;
width: root.size;
height: root.size;
border-radius: root.radius;
clip: true;
background: Theme.surface-elevated;
property <int> n: root.item.cover-count;
property <length> gap: 1px;
// 0 covers -> fallback glyph.
if root.n == 0: QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: root.size * 0.32;
height: root.size * 0.32;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
// 1-3 covers -> single full-bleed cover.
if root.n >= 1 && root.n < 4: Image {
width: 100%;
height: 100%;
source: root.item.cover1;
image-fit: cover;
}
// 4+ covers -> 2x2 grid.
if root.n >= 4: VerticalLayout {
spacing: root.gap;
HorizontalLayout {
spacing: root.gap;
Image {
source: root.item.cover1;
image-fit: cover;
width: (root.size - root.gap) / 2;
height: (root.size - root.gap) / 2;
}
Image {
source: root.item.cover2;
image-fit: cover;
width: (root.size - root.gap) / 2;
height: (root.size - root.gap) / 2;
}
}
HorizontalLayout {
spacing: root.gap;
Image {
source: root.item.cover3;
image-fit: cover;
width: (root.size - root.gap) / 2;
height: (root.size - root.gap) / 2;
}
Image {
source: root.item.cover4;
image-fit: cover;
width: (root.size - root.gap) / 2;
height: (root.size - root.gap) / 2;
}
}
}
}
@@ -0,0 +1,48 @@
// Playlist Manager folder icon square — a rounded color tile with either
// a custom image or one of the lucide preset glyphs. The background color
// is `folder.icon-color` (solid hex) or the accent when empty. Gradient
// folder colors are not supported (Slint brush limitation) and fall back
// to the accent in the Rust controller.
import { Theme } from "../foundation/semantic-colors.slint";
import { PmFolderItem } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
export component PmFolderIcon inherits Rectangle {
in property <PmFolderItem> folder;
in property <length> size: 64px;
in property <length> glyph-size: 32px;
in property <length> radius: 12px;
width: root.size;
height: root.size;
border-radius: root.radius;
clip: true;
// Resolved color -> that color; otherwise accent.
background: root.folder.has-color ? root.folder.icon-color : Theme.accent;
// Custom image (icon-type == "custom").
if root.folder.has-custom-image: Image {
width: 100%;
height: 100%;
source: root.folder.custom-image;
image-fit: cover;
}
// Preset glyph (white over the color tile).
if !root.folder.has-custom-image: QbzIcon {
source:
root.folder.icon-preset == "heart" ? @image-url("../assets/icons/heart.svg")
: root.folder.icon-preset == "star" ? @image-url("../assets/icons/star.svg")
: root.folder.icon-preset == "music" ? @image-url("../assets/icons/music.svg")
: root.folder.icon-preset == "disc" ? @image-url("../assets/icons/disc.svg")
: root.folder.icon-preset == "library" ? @image-url("../assets/icons/library.svg")
: root.folder.icon-preset == "headphones" ? @image-url("../assets/icons/audio-lines.svg")
: @image-url("../assets/icons/folder.svg");
width: root.glyph-size;
height: root.glyph-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
}
+22
View File
@@ -14,11 +14,13 @@ 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 { FolderEditModal } from "../primitives/FolderEditModal.slint";
import { DragGhost } from "../primitives/DragGhost.slint";
import { PlayerBar } from "PlayerBar.slint";
import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint";
import { HomeView } from "../discover/HomeView.slint";
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
import { AlbumPageView } from "../album/AlbumPageView.slint";
import { ArtistPageView } from "../artist/ArtistPageView.slint";
import { MusicianPageView } from "../musician/MusicianPageView.slint";
@@ -27,6 +29,7 @@ import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
import { FavoritesView } from "../favorites/FavoritesView.slint";
import { MixView } from "../mix/MixView.slint";
import { PlaylistView } from "../playlist/PlaylistView.slint";
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
import { SettingsView } from "../settings/SettingsView.slint";
import { SearchResultsView } from "../search/SearchResultsView.slint";
@@ -35,6 +38,7 @@ export component AppShell inherits Rectangle {
callback open-artist(string);
callback media-action(string, string, string);
callback header-menu-navigate(string);
callback discover-view-all(string, string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback settings-slider(string, int);
@@ -92,6 +96,20 @@ export component AppShell inherits Rectangle {
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
discover-view-all(e, t) => {
root.discover-view-all(e, t);
}
}
if NavState.view == ContentView.discover-browse: DiscoverBrowseView {
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.album: AlbumPageView {
open-artist(id) => {
@@ -135,6 +153,7 @@ export component AppShell inherits Rectangle {
root.open-artist(id);
}
}
if NavState.view == ContentView.playlist-manager: PlaylistManagerView { }
if NavState.view == ContentView.mix: MixView {
play-track(id) => {
root.media-action("mix-track", id, "play");
@@ -239,6 +258,9 @@ export component AppShell inherits Rectangle {
// Global "Create folder" modal.
CreateFolderModal { }
// Global "Folder editor" modal (Playlist Manager create/edit folder).
FolderEditModal { }
// Floating track-drag ghost (follows the cursor, on top of all).
DragGhost { }
}
+392 -3
View File
@@ -12,17 +12,27 @@ export struct AlbumCardItem {
artist: string,
artist-id: string, // for the clickable artist name -> artist page
genre: string, // shown in the hover overlay
year: string, // shown in the hover overlay
year: string, // localized "MMM D, YYYY" — shown in the hover overlay
quality-tier: string, // "hires" | "cd" | "" — drives the badge icon
quality-label: string, // "Hi-Res: 24-bit / 96 kHz" — badge hover tooltip
ribbon: string,
ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | ""
artwork-url: string,
artwork: image, // empty until the artwork pipeline resolves it
// --- List-row extras (AlbumListRow; ignored by the grid AlbumCard) ----
release-type: string, // "Album" | "EP" | "Single" | "Live" | "Compilation"
source: string, // "qobuz" | "local" | "plex" | "" — hideable column
quality-detail: string,// "24-bit / 96 kHz" — bare detail for QualityBadgeFull
track-count: string, // "12" — TRACKS column
plain-year: string, // "1973" — bare year for the list-row YEAR column
}
export struct DiscoverSection {
title: string,
// Discover endpoint path this section maps to (e.g. "/discover/newReleases").
// Empty for sections with no full-list page; non-empty shows the "View all"
// link and drives the DiscoverBrowse page.
endpoint: string,
albums: [AlbumCardItem],
}
@@ -57,6 +67,39 @@ export global HomeActions {
callback select-tab(string);
}
// Discover "View all" full-list page — one album module (new releases,
// qobuzissimes, ...) opened from a Carousel's See-All link. Paginated.
export global DiscoverBrowseState {
in-out property <string> title;
// Rust-managed: the /discover/<x> endpoint + the next page offset, so
// DiscoverBrowseActions.load-more knows what to fetch next.
in-out property <string> endpoint;
in-out property <int> next-offset;
// Full (unfiltered) loaded page set. The view binds to `visible`
// below; the Rust side rebuilds `visible` from `albums` whenever the
// search query changes (client-side title/artist filter).
in-out property <[AlbumCardItem]> albums;
// The albums actually rendered — equals `albums` with no search, or a
// filtered subset while the search box is non-empty.
in-out property <[AlbumCardItem]> visible;
in-out property <bool> loading;
in-out property <bool> loading-more;
in-out property <bool> has-more;
// Header tools (1:1 with Tauri DiscoverBrowseView).
// Client-side search over the loaded albums; while non-empty,
// infinite-scroll load-more is disabled (Tauri does the same).
in-out property <string> search-query;
// "grid" | "list" — the view toggle.
in-out property <string> view-mode: "grid";
}
export global DiscoverBrowseActions {
// Fetch the next page (offset = DiscoverBrowseState.next-offset).
callback load-more();
// Re-filter `visible` after `search-query` changed (UI thread).
callback search-changed();
}
// Discover > For You state. The For You tab has heterogeneous,
// ordered sections (album carousels, a track carousel, artist
// carousels), so it renders from its own state rather than the
@@ -222,7 +265,7 @@ export global DragActions {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist }
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist, discover-browse, playlist-manager }
// One row in the sidebar playlists list (also used for the folder
// list in the move-to-folder menu).
@@ -233,6 +276,12 @@ export struct SidebarPlaylistItem {
// One flattened entry in the sidebar nav list — a folder header or a
// playlist row (indented when inside a folder).
//
// Playlist rows carry up to four cover-art URLs + decoded images so the
// row can render a micro-collage instead of the generic list-music glyph
// (mirrors Tauri's `sidebarPlaylistCollage`). The covers come from the
// same `get_user_playlists()` payload (images300 / images150 / images),
// so no extra per-playlist fetch is needed.
export struct SidebarEntry {
kind: string, // "folder" | "playlist"
id: string,
@@ -240,6 +289,15 @@ export struct SidebarEntry {
expanded: bool, // folders
count: int, // folder playlist count
indent: bool, // playlist nested in a folder
cover-count: int, // number of cover images, 0-4 (playlists)
url1: string,
url2: string,
url3: string,
url4: string,
cover1: image,
cover2: image,
cover3: image,
cover4: image,
}
// The sidebar's playlists + folders. Populated by crate::sidebar on
@@ -251,6 +309,20 @@ export global SidebarState {
in property <bool> loading: false;
// The currently-open playlist id, for the active-row highlight.
in property <string> active-id;
// PLAYLISTS section collapse (the folder+playlist list hides). Mirrors
// Tauri's `playlistsCollapsed`.
in-out property <bool> playlists-collapsed: false;
// Inline playlist-name search in the header. When open the title is
// hidden and the input takes the full header width.
in-out property <bool> search-open: false;
in-out property <string> search-query;
// Active sort option: "name" | "recent" | "tracks" | "playcount"
// | "custom" (mirrors Tauri's SortOption). Persisted by crate::sidebar.
in-out property <string> sort-option: "name";
// Micro-collage of track covers on playlist rows. Opt-out (ON by
// default) to match Tauri's `sidebarPlaylistCollage`. The Settings >
// Appearance toggle wires into this in a later step.
in-out property <bool> playlist-collage: true;
}
export global SidebarActions {
@@ -261,14 +333,32 @@ export global SidebarActions {
callback delete-folder(string /* folder id */);
// Move a playlist into a folder ("" folder id = root).
callback move-playlist(string /* playlist id */, string /* folder id */);
// Pick a sort option (re-sorts the list). Same five values as Tauri.
callback set-sort(string /* option */);
// Re-run the playlist-name filter (called as the search input edits).
callback search-changed(string /* query */);
// Re-fetch the playlist list from the network.
callback refresh-playlists();
// Open the full Playlist Manager view.
callback manage-playlists();
}
// "Create playlist" modal state.
// "Create playlist" modal state. Mirrors Tauri's PlaylistModal (create
// mode): name, optional description, target folder, public flag.
export global CreatePlaylistState {
in-out property <bool> open: false;
in-out property <string> name;
in-out property <string> description;
in-out property <bool> is-public: false;
in property <bool> creating: false;
// Folder dropdown options — index 0 is always "No folder", the rest
// are the user's folder names (parallel to `folder-ids`). Built by
// the create-playlist handler from the sidebar folder list.
in property <[string]> folder-options: ["No folder"];
// Folder ids parallel to `folder-options` ("" for "No folder").
in property <[string]> folder-ids: [""];
// Selected index into `folder-options` (0 = No folder).
in-out property <int> folder-index: 0;
}
export global CreatePlaylistActions {
@@ -342,6 +432,177 @@ export global EditPlaylistActions {
callback close();
}
// === Playlist Manager ===================================================
// A full playlist + folder organization surface (Tauri's
// PlaylistManagerView). Rows are precomputed in Rust (crate::playlist_manager)
// and pushed as ready-to-render models — no per-row map lookups in .slint.
// One folder card / chip / tree-folder. `icon-color` is a solid hex (or
// "" → use the accent). Gradient folder colors are not supported here
// (Slint brush limitation); a gradient string falls back to the accent.
export struct PmFolderItem {
id: string,
name: string,
count: int, // playlists in this folder
// "preset" or "custom".
icon-type: string,
// Lucide preset name ("heart" | "star" | "music" | "folder" | "disc"
// | "library" | "headphones"); drives which glyph the card shows.
icon-preset: string,
// Resolved solid color for the icon square (parsed from the stored
// hex by the Rust controller). When `has-color` is false the card
// uses the accent instead (gradient / empty / unparseable colors
// fall back to accent — Slint has no gradient-brush-in-struct).
icon-color: color,
has-color: bool,
is-hidden: bool,
// Decoded custom image (icon-type == "custom"); empty otherwise.
custom-image: image,
has-custom-image: bool,
}
// One playlist card / list-row / tree-row. All display strings are
// precomputed (track line, local line) so the view never does lookups.
export struct PmPlaylistItem {
id: string, // playlist id (may be negative for offline pending)
name: string,
// "12 tracks" — total (remote + local).
tracks-line: string,
// "(3 local)" — local-only suffix, "" when none.
local-line: string,
local-count: int,
total-count: int,
play-count: int,
// "" | "all_local" | "some_local" | "no" — availability badge.
local-status: string,
is-favorite: bool,
is-hidden: bool,
folder-id: string, // "" = root
// Micro-collage covers (same 0-4 scheme as the sidebar).
cover-count: int,
url1: string,
url2: string,
url3: string,
url4: string,
cover1: image,
cover2: image,
cover3: image,
cover4: image,
}
// One flattened row of the tree view — a folder header or a nested /
// root playlist. The Rust side emits only currently-visible rows
// (collapsed folders hide their children).
export struct PmTreeRow {
kind: string, // "folder" | "playlist"
expanded: bool, // folders
folder: PmFolderItem, // populated when kind == "folder"
playlist: PmPlaylistItem, // populated when kind == "playlist"
indent: bool, // playlist nested under a folder
}
export global PlaylistManagerState {
in property <[PmFolderItem]> folders: [];
in property <[PmPlaylistItem]> playlists: []; // the filtered+sorted grid/list set
in property <[PmTreeRow]> tree: [];
// Header counters: total folders / total playlists (post-filter for
// the visible playlist count).
in property <int> folder-count: 0;
in property <int> playlist-count: 0;
in property <bool> loading: false;
// Toolbar state (session-scoped; persisted per-user where cheap).
in-out property <string> search-query;
// "all" | "visible" | "hidden".
in-out property <string> filter: "all";
// "name" | "recent" | "playcount" | "tracks" | "custom".
in-out property <string> sort: "name";
// "grid" | "list" | "tree".
in-out property <string> view-mode: "grid";
// Folder mode on (folders shown + folder filtering) vs flat.
in-out property <bool> folder-mode: true;
// Folders section collapse.
in-out property <bool> folders-collapsed: false;
// Reorder is only allowed when sort == "custom".
in property <bool> can-reorder: false;
}
export global PlaylistManagerActions {
// Open a playlist (-> PlaylistView).
callback open-playlist(string /* id */);
// Toolbar.
callback search-changed(string /* query */);
callback set-filter(string);
callback set-sort(string);
callback set-view-mode(string);
callback toggle-folder-mode();
callback toggle-folders-collapsed();
// Tree-view folder expand/collapse.
callback toggle-tree-folder(string /* folder id */);
// Folder CRUD — "new folder" opens the editor in create mode; edit
// opens it for an existing folder.
callback new-folder();
callback edit-folder(string /* folder id */);
// Per-card playlist actions.
callback toggle-favorite(string /* playlist id */);
callback toggle-hidden(string /* playlist id */);
callback edit-playlist(string /* playlist id */);
callback add-to-mixtape(string /* playlist id */);
// Arrow reorder (custom sort only): move a playlist up / down within
// the current visible order, persisting the new order.
callback move-up(string /* playlist id */);
callback move-down(string /* playlist id */);
// Move a playlist into a folder ("" = root). Used by the tree's
// folder rows and the future DnD path.
callback move-to-folder(string /* playlist id */, string /* folder id */);
}
// Folder editor modal — create + edit (icon preset, solid color, custom
// image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient
// color presets are intentionally omitted (Slint brush limitation).
export struct PmIconPreset {
id: string,
icon: image,
}
export struct PmColorSwatch {
// The stored hex value ("" = the "Accent" preset), used as the
// selection key and persisted into the folder record.
value: string,
// Resolved Slint color for the swatch fill (ignored when is-accent).
color: color,
is-accent: bool,
}
export global FolderEditState {
in-out property <bool> open: false;
// Empty id = create mode; non-empty = editing that folder.
in property <string> id: "";
in-out property <string> name;
in-out property <string> icon-preset: "folder";
// Selected solid color hex; "" = accent.
in-out property <string> icon-color: "";
in-out property <bool> is-hidden: false;
// Custom image path (icon-type == "custom" when set) + decoded image.
in-out property <string> custom-image-path: "";
in property <image> custom-image;
in property <bool> busy: false;
// The icon-preset grid + the solid-color swatch grid, built once by
// the Rust controller.
in property <[PmIconPreset]> icon-presets: [];
in property <[PmColorSwatch]> color-swatches: [];
}
export global FolderEditActions {
callback select-preset(string /* preset id */);
callback select-color(string /* color hex, "" = accent */);
callback pick-image();
callback clear-image();
callback toggle-hidden();
callback save();
callback delete();
callback close();
}
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
// changes flow back through the settings-bool / settings-select /
@@ -407,6 +668,134 @@ export global SettingsState {
in-out property <int> retry-behavior-index: 0;
}
// Appearance settings — backs the Settings > Appearance panel. 1:1 with
// Tauri's appearance section. This is a visual/structural replica: every
// row is bound to a property here, but the Rust side does NOT yet persist
// or apply most of them (wiring is a later step). The few that map onto an
// existing, already-functional flag are wired through (see notes inline).
//
// Dropdowns follow the same pattern as SettingsState: an option list paired
// with a selected index; the Rust controller (when wired) owns the
// index -> value mapping. Lists are populated with sensible defaults below
// so the UI renders standalone even while unwired.
export global AppearanceState {
// --- THEME -------------------------------------------------------------
in-out property <[string]> themes: [
"Dark", "OLED Black", "Light", "System",
"Warm", "Nord", "Dracula", "Tokyo Night", "Catppuccin Mocha",
"Breeze Dark", "Adwaita Dark", "Alucard", "Aurora", "Ikari",
"Ayanami", "Iscariot", "Stratego", "Rumi", "Zoey", "Mira",
"Rose Pine Dawn", "Breeze Light", "Adwaita Light", "Duotone Snow",
"Snow Storm", "Frost", "Langley", "Kurosaki",
"WCAG Light", "WCAG Dark", "High Contrast", "Colorblind"
];
in-out property <int> theme-index: 0;
// Theme list filter cycle: 0 = All, 1 = Dark, 2 = Light. Mirrors the
// SunMoon/Moon/Sun toggle button next to the theme dropdown.
in-out property <int> theme-filter: 0;
in-out property <bool> album-header-gradient: true;
// Auto-Theme (shown when the "System" theme is selected). Source:
// 0 = System Colors, 1 = Wallpaper Sync, 2 = Custom Image.
in-out property <bool> theme-is-system: false;
in-out property <[string]> auto-theme-sources: ["System Colors", "Wallpaper Sync", "Custom Image"];
in-out property <int> auto-theme-source-index: 0;
in-out property <string> auto-theme-custom-path: "";
in-out property <bool> auto-theme-generating: false;
in-out property <string> auto-theme-detected-de: "";
// --- TYPOGRAPHY / SCALE / LANGUAGE -------------------------------------
in-out property <[string]> languages: ["Auto", "English", "Español", "Français", "Deutsch", "Português"];
in-out property <int> language-index: 0;
in-out property <[string]> fonts: ["LINE Seed JP", "Montserrat", "Noto Sans", "Source Sans 3", "System"];
in-out property <int> font-index: 0;
in-out property <[string]> ui-scales: ["80%", "85%", "90%", "95%", "100%", "110%", "125%", "150%", "175%"];
in-out property <int> ui-scale-index: 4;
// --- VISUAL EXTRAS -----------------------------------------------------
// sidebar-playlist-collage is wired to SidebarState.playlist-collage in
// the panel (the only already-functional flag here).
in-out property <bool> sidebar-playlist-collage: true;
in-out property <bool> local-library-track-artwork: false;
// --- NOTIFICATIONS -----------------------------------------------------
in-out property <bool> in-app-toasts: true;
in-out property <bool> system-notifications: true;
// --- WINDOW TITLE ------------------------------------------------------
in-out property <bool> window-title-show: false;
in-out property <string> window-title-template: "";
// --- TITLE BAR / WINDOW CHROME -----------------------------------------
in-out property <bool> use-system-title-bar: false;
in-out property <bool> hide-title-bar: false;
in-out property <bool> match-system-chrome: false;
// True when GPU acceleration is on (gates match-system-chrome). Default
// true so the row is interactive in the unwired replica.
in-out property <bool> hardware-accel-enabled: true;
in-out property <bool> search-in-title-bar: false;
// Navigation-in-title-bar group: the parent label row plus per-target
// toggles (Discover/Home, Favorites, Library, My QBZ, Purchases).
in-out property <bool> nav-tb-discover: false;
in-out property <bool> nav-tb-favorites: false;
in-out property <bool> nav-tb-library: false;
in-out property <bool> nav-tb-myqbz: false;
in-out property <bool> nav-tb-purchases: false;
// Nav buttons position: 0 = Auto, 1 = Left, 2 = Right.
in-out property <[string]> nav-tb-positions: ["Auto (opposite to window controls)", "Left", "Right"];
in-out property <int> nav-tb-position-index: 0;
// Window controls group.
in-out property <[string]> wc-positions: ["Left", "Right"];
in-out property <int> wc-position-index: 1;
in-out property <[string]> wc-styles: ["Rectangular", "Full-height rounded", "Circular", "Square"];
in-out property <int> wc-style-index: 0;
in-out property <[string]> wc-sizes: ["Small", "Normal", "Large"];
in-out property <int> wc-size-index: 1;
in-out property <[string]> wc-color-presets: ["Default", "macOS", "Adwaita", "Monochrome", "Custom"];
in-out property <int> wc-color-preset-index: 0;
in-out property <bool> show-window-controls: true;
// --- MINIPLAYER --------------------------------------------------------
in-out property <[string]> miniplayer-views: ["Remember last used", "Micro", "Compact", "Artwork", "Queue", "Lyrics"];
in-out property <int> miniplayer-view-index: 0;
// --- STARTUP -----------------------------------------------------------
in-out property <[string]> startup-pages: ["Home", "Where you left off"];
in-out property <int> startup-page-index: 0;
in-out property <bool> show-purchases: false;
// --- SYSTEM TRAY -------------------------------------------------------
in-out property <bool> tray-enable: true;
in-out property <bool> tray-minimize-to-tray: false;
in-out property <bool> tray-close-to-tray: false;
in-out property <[string]> tray-icon-themes: ["Auto", "Mono light", "Mono dark", "Color"];
in-out property <int> tray-icon-theme-index: 0;
// --- IMMERSIVE ---------------------------------------------------------
in-out property <[string]> immersive-views: [
"Remember last used", "Coverflow", "Static", "Vinyl", "Visualizer",
"Neon Flow", "Tunnel", "Comet", "Lyrics", "Queue",
"Split: Lyrics", "Split: Track Info", "Split: Suggestions", "Split: Queue"
];
in-out property <int> immersive-view-index: 0;
in-out property <[string]> immersive-background-modes: ["Full", "Lite", "Off"];
in-out property <int> immersive-background-mode-index: 0;
// Collapsible sub-panels (collapsed by default, matching Tauri).
in-out property <bool> immersive-panels-collapsed: true;
in-out property <bool> immersive-fps-collapsed: true;
// Callbacks. Unwired in this replica — the Rust side may bind them
// later. Kept generic (key + value) so the controller can dispatch.
callback appearance-bool(string /* key */, bool /* value */);
callback appearance-select(string /* key */, int /* index */);
callback appearance-text(string /* key */, string /* value */);
callback appearance-action(string /* key */);
}
// One entry of a nav-tab dropdown.
export struct NavMenuEntry {
label: string,