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
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||