Files
qbz/crates/qbz-slint/src/playlist_manager.rs
T
vicrodh 5fd41e8026 feat(slint): offline content filtering and LocalLibrary offline default (D11/D12)
Offline, unavailable content is filtered out rather than shown broken:
sidebar, manager and folder popups list only local playlists and mixed
playlists with local sidecar rows (100% Qobuz hidden; empty folders
skipped); a mixed playlist opens an offline-subset detail rendering only
its local sidecar rows (plex rows under induced only, read-only, same row
machinery); MyQBZ grids and details retain only available items (qobuz =
cached album/track within grace, plex = induced only, local always) and
collections with zero available items drop from the grids.

Mixed-playlist names offline: kept from the session cache on a mid-session
flip; honest 'Playlist (N local)' fallback on a cold offline start (names
are not persisted anywhere locally - noted as a future membership-snapshot
roadmap item).

D12: an offline session entry roots navigation at LocalLibrary, and an
engine watcher auto-navigates there when the mode flips to offline while a
blocked Qobuz view is open (re-filtering open MyQBZ views in place). No
navigation on the offline-to-online edge.

cc
2026-06-09 22:43:23 -06:00

856 lines
31 KiB
Rust

//! 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,
/// Total playlist duration in seconds (Qobuz `duration`).
duration: 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
}
}
/// Send view of one LOCAL playlist (library.db entity, id `local:<uuid>`).
/// Listed alongside the Qobuz set with a hard-drive marker; folder
/// membership / favorite / hidden are Qobuz-side concepts and don't apply.
#[derive(Clone)]
struct PmLocalPlaylist {
id: String,
name: String,
offline_only: bool,
track_count: u32,
}
#[derive(Clone, Default)]
pub struct PmData {
playlists: Vec<PmPlaylist>,
folders: Vec<FolderFull>,
locals: Vec<PmLocalPlaylist>,
}
/// 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, locals) =
tokio::task::spawn_blocking(|| {
let locals: Vec<PmLocalPlaylist> = crate::local_playlist::list_blocking()
.into_iter()
.map(|p| PmLocalPlaylist {
id: p.id,
name: p.name,
offline_only: p.offline_only,
track_count: p.track_count,
})
.collect();
(
crate::folders::load_folders_full(),
crate::folders::playlist_settings_map(),
crate::folders::playlist_play_counts(),
crate::folders::playlist_local_counts(),
locals,
)
})
.await
.unwrap_or_else(|_| {
(Vec::new(), HashMap::new(), HashMap::new(), HashMap::new(), Vec::new())
});
let folder_ids: std::collections::HashSet<&String> = folders.iter().map(|f| &f.id).collect();
let mut 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,
duration: p.duration,
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();
// D11.b — OFFLINE: the Qobuz fetch is gate-refused (empty), so the MIXED
// playlists (>= 1 local sidecar row) are synthesized from the local
// counts to stay reachable. Their names are not stored locally anywhere
// (`playlist_settings` has no name column), so a cold offline start
// falls back to "Playlist (N local)"; a mid-session flip keeps the real
// name via the sidebar's name cache (loaded while online).
if crate::offline_mode::engine().is_offline() {
let known: std::collections::HashSet<u64> = playlists.iter().map(|p| p.id).collect();
for (&id, &count) in &local_counts {
if count == 0 || known.contains(&id) {
continue;
}
let s = settings.get(&id).cloned().unwrap_or_default();
let name = crate::sidebar::playlist_name_desc(id)
.map(|(name, _)| name)
.unwrap_or_else(|| format!("Playlist ({count} local)"));
playlists.push(PmPlaylist {
id,
name,
tracks_count: 0,
duration: 0,
local_count: count,
play_count: play_counts.get(&id).copied().unwrap_or(0),
is_favorite: s.is_favorite,
is_hidden: s.hidden,
folder_id: s.folder_id.filter(|fid| folder_ids.contains(fid)),
position: s.position,
cover_urls: Vec::new(),
});
}
}
PmData {
playlists,
folders,
locals,
}
}
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))
}
/// Total-playtime label, e.g. "1h 43m" or "12m" (mirrors Tauri's
/// `formatDuration`). Empty when the duration is zero.
fn format_duration(seconds: u32) -> String {
if seconds == 0 {
return String::new();
}
let hours = seconds / 3600;
let mins = (seconds % 3600) / 60;
if hours > 0 {
format!("{hours}h {mins}m")
} else {
format!("{mins}m")
}
}
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(),
duration_line: format_duration(p.duration).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,
is_local_playlist: false,
offline_only: false,
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(),
}
}
fn local_playlist_item(p: &PmLocalPlaylist) -> PmPlaylistItem {
PmPlaylistItem {
id: p.id.clone().into(),
name: p.name.clone().into(),
tracks_line: format!("{} tracks", p.track_count).into(),
duration_line: "".into(),
local_line: "".into(),
local_count: 0,
total_count: p.track_count as i32,
play_count: 0,
local_status: "".into(),
is_favorite: false,
is_hidden: false,
is_local_playlist: true,
offline_only: p.offline_only,
folder_id: "".into(),
cover_count: 0,
url1: Default::default(),
url2: Default::default(),
url3: Default::default(),
url4: Default::default(),
cover1: slint::Image::default(),
cover2: slint::Image::default(),
cover3: slint::Image::default(),
cover4: slint::Image::default(),
}
}
/// 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. While OFFLINE only
// the MIXED playlists (>= 1 local sidecar track) stay (D11.b).
let offline = crate::offline_mode::engine().is_offline();
let mut filtered: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| !offline || p.local_count > 0)
.filter(|p| passes(p, &query, &filter, folder_mode, &view_mode))
.cloned()
.collect();
sort_playlists(&mut filtered, &sort);
let mut playlist_items: Vec<PmPlaylistItem> = filtered.iter().map(playlist_item).collect();
// LOCAL playlists (library.db, D7) — appended after the Qobuz set,
// name-sorted, honoring the search query. They have no hidden flag, so
// the "hidden" filter excludes them; folder filtering N/A (root-only).
if filter != "hidden" {
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| query.is_empty() || p.name.to_lowercase().contains(&query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
playlist_items.extend(locals.iter().map(|p| local_playlist_item(p)));
}
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()
};
// The list-row move-to-folder menu starts unfiltered (= the full set);
// its search box narrows it via `search_menu_folders`.
let menu_folders: Vec<PmFolderItem> = data
.folders
.iter()
.map(|f| folder_item(f, folder_counts.get(&f.id).copied().unwrap_or(0)))
.collect();
state.set_folders(ModelRc::new(VecModel::from(folders)));
state.set_menu_folders(ModelRc::new(VecModel::from(menu_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);
}
/// Filter the list-row move-to-folder menu's folder list by a
/// case-insensitive substring (Slint strings have no `contains`, so this
/// lives in Rust). An empty query restores the full list. Counts mirror the
/// `rebuild` computation. UI thread only.
pub fn search_menu_folders(window: &AppWindow, query: &str) {
let q = query.trim().to_lowercase();
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
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 filtered: Vec<PmFolderItem> = data
.folders
.iter()
.filter(|f| q.is_empty() || f.name.to_lowercase().contains(&q))
.map(|f| folder_item(f, folder_counts.get(&f.id).copied().unwrap_or(0)))
.collect();
window
.global::<PlaylistManagerState>()
.set_menu_folders(ModelRc::new(VecModel::from(filtered)));
}
/// 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 offline = crate::offline_mode::engine().is_offline();
let matches = |p: &PmPlaylist| -> bool {
// D11.b: offline only the MIXED playlists stay.
if offline && p.local_count == 0 {
return false;
}
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();
// While searching — and offline, where the D11.b filter may empty a
// folder — skip folders with no visible members.
if (searching || offline) && 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,
});
}
// LOCAL playlists as root tree rows (never in folders), name-sorted,
// honoring the search query (no hidden flag — the hidden filter hides
// them like the grid does).
if filter != "hidden" {
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| !searching || p.name.to_lowercase().contains(query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
for p in locals {
rows.push(PmTreeRow {
kind: "playlist".into(),
expanded: false,
folder: PmFolderItem::default(),
playlist: local_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);
});
});
}