Per the user's visual review. Replaces the reinvented MyQBZ controls with the app's existing primitives and resolves the per-item data left unresolved. Cross-cutting (all MyQBZ views): - Removed the per-view "Back" header (the app uses the global < > nav). - 32px L/R content padding so the ListScrollbar never rides over rows/cards. - Homologated page padding + Typography tokens to Favorites/LocalLibrary. - No more blue accent-filled pills / non-app colors. Component homologation (deleted the reinvented locals + dead code): - Grid/list + detail view-mode toggle -> the app ViewToggle (ToggleButton, surface-hover active, never accent). Builder ORDER-BY segmented de-blued too. - Sort/filter -> QbzSelect (sm); search -> ExpandableSearch (sm); "+ New" -> NewActionBtn (surface-elevated circle, single centered glyph). Header "+ New" hidden when empty so the empty-state CTA is the only one (no duplicate). - Deleted PrimaryCta / MyQbzSearch / ControlBtn / ViewSeg / ViewModeGroup / SortItem / ColMenuItem + the hand-rolled sort/filter popups + reset. Data (resolveItems, was deferred): - Quality column populated from the resolved tracks (QualityBadgeFull). - New ui/primitives/SourceGlyph.slint: full-color Qobuz/Plex glyphs + a text-muted local hard-drive -- distinguishes Local vs Plex, no accent blob. Reused in the detail + builder rows. - Type column shows the real release type (ALBUM/EP/SINGLE), not always ALBUM. - Local/Plex album art now loads in the detail rows (split artwork dispatch). - Collection grid card renders custom_artwork_path. - Builder rows: 44px + a hairline so quality badges read separated. Backend crates untouched. cargo check -p qbz-slint green. cc
8866 lines
354 KiB
Rust
8866 lines
354 KiB
Rust
//! QBZ Slint MVP binary.
|
|
//!
|
|
//! A native Slint front end for QBZ built on the framework-agnostic
|
|
//! `qbz-app` / `qbz-core` stack — no Tauri, no WebView. See the MVP ADR
|
|
//! (`qbz-nix-docs/qbz-adr/qbz_slint_functional_poc_adr.md`).
|
|
//!
|
|
//! Lives only on the private `slint-mvp` branch (ADR-007). The Slint UI
|
|
//! tree is compiled from `ui/app.slint` by `build.rs`; `include_modules!`
|
|
//! pulls in the generated Rust bindings.
|
|
//!
|
|
//! Status: foundation tokens, login screen, app shell, functional
|
|
//! system-browser OAuth, saved-session restore, and a real Discover /
|
|
//! Home view fed by the Qobuz discover index with cached artwork.
|
|
|
|
slint::include_modules!();
|
|
|
|
mod adapter;
|
|
mod album;
|
|
mod album_map;
|
|
mod artist;
|
|
mod artwork;
|
|
mod auth;
|
|
mod commands;
|
|
mod custom_artwork;
|
|
mod dates;
|
|
mod discover_browse;
|
|
mod discovery_dismiss;
|
|
mod fav_cache;
|
|
mod favorites;
|
|
mod favorites_prefs;
|
|
mod foryou;
|
|
mod genre_filter;
|
|
mod home;
|
|
mod label;
|
|
mod location_view;
|
|
mod mix;
|
|
mod musician;
|
|
mod myqbz;
|
|
mod myqbz_add;
|
|
mod myqbz_builder;
|
|
mod myqbz_cover;
|
|
mod myqbz_detail;
|
|
mod myqbz_edit;
|
|
mod myqbz_mix;
|
|
mod myqbz_play;
|
|
mod myqbz_prefs;
|
|
mod myqbz_view_prefs;
|
|
mod nav;
|
|
mod play_history;
|
|
mod strip_html;
|
|
mod playback;
|
|
mod qconnect_engine;
|
|
mod qconnect_event_sink;
|
|
mod qconnect_service;
|
|
mod qconnect_transport;
|
|
mod queue;
|
|
mod remote_stream;
|
|
mod drag;
|
|
mod ephemeral;
|
|
mod folders;
|
|
mod library_db;
|
|
mod local_library;
|
|
mod local_library_settings;
|
|
mod media_controls;
|
|
mod locallibrary_prefs;
|
|
mod tag_editor;
|
|
mod offline;
|
|
mod offline_cache;
|
|
mod offline_manager;
|
|
mod playlist;
|
|
mod playlist_manager;
|
|
mod plex_auth;
|
|
mod plex_settings;
|
|
mod playlist_picker;
|
|
mod quality;
|
|
mod recently;
|
|
mod search;
|
|
mod settings;
|
|
mod share;
|
|
mod sidebar;
|
|
mod toast;
|
|
mod tray;
|
|
mod tray_settings;
|
|
mod ui_prefs;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use slint::Model;
|
|
|
|
use adapter::SlintAdapter;
|
|
use commands::AppCommand;
|
|
use qbz_app::shell::AppRuntime;
|
|
|
|
/// Login Terms-of-Service link target.
|
|
const QOBUZ_TOS_URL: &str = "https://www.qobuz.com/us-en/legal/terms";
|
|
|
|
fn dispatch(command: AppCommand) {
|
|
log::info!("[qbz-slint] AppCommand::{} dispatched", command.id());
|
|
}
|
|
|
|
/// Reveal the shell and load the Discover / Home view with real data,
|
|
/// then kick off cached artwork downloads.
|
|
async fn enter_shell(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
image_cache: artwork::ImageCache,
|
|
settings_ctx: Arc<settings::SettingsCtx>,
|
|
session: auth::SessionInfo,
|
|
) {
|
|
// Bind the local library DB to this user (folders / playlist
|
|
// settings live in the per-user library.db).
|
|
library_db::set_user(session.user_id);
|
|
|
|
// Run the Mixtapes & Collections schema migrations against the same
|
|
// per-user library.db (the mixtape tables live in that file). Mirrors
|
|
// the Tauri build's session_lifecycle.rs `run_mixtape_migrations`.
|
|
// Best-effort: log on error, never block shell entry.
|
|
library_db::with_db(|db| {
|
|
Ok(db.with_connection(|conn| {
|
|
if let Err(e) = qbz_mixtape::schema::run_mixtape_migrations(conn) {
|
|
log::error!("[qbz-slint] mixtape migrations failed: {e}");
|
|
}
|
|
}))
|
|
});
|
|
|
|
// Bind tray settings to this user (per-user tray_settings.db, shared with
|
|
// the Tauri build) and snapshot them to seed the settings UI.
|
|
tray_settings::init_for_user(session.user_id);
|
|
let tray = tray_settings::get();
|
|
|
|
// Bind Plex connection settings to this user (per-user plex_settings.db,
|
|
// Slint-only). Seeded into PlexSettingsState lazily on panel open.
|
|
plex_settings::init_for_user(session.user_id);
|
|
|
|
// Bind "My QBZ" nav branding (custom label + icon) to this user
|
|
// (per-user myqbz_branding.json). Seeded into MyQbzBrandingState below so
|
|
// the sidebar row + Settings row reflect the persisted values on entry.
|
|
myqbz_prefs::init_for_user(session.user_id);
|
|
|
|
// Bind per-collection DETAIL view-prefs (toolbar viewMode/sort/filter) to
|
|
// this user (per-user collection_view_prefs.json). Restored on collection
|
|
// open, cleared on delete (spec 12 §18).
|
|
myqbz_view_prefs::init_for_user(session.user_id);
|
|
|
|
// Create the system tray from this user's persisted settings (gated by
|
|
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
|
|
// service runs on its own thread; macOS/Windows are no-ops until the
|
|
// tray-icon slice lands.
|
|
tray::init(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
tokio::runtime::Handle::current(),
|
|
tray.tray_icon_theme.clone(),
|
|
tray.enable_tray,
|
|
);
|
|
|
|
// System media controls — MPRIS on Linux (publishes DesktopEntry so GNOME
|
|
// shows the app icon), SMTC/MediaRemote on macOS/Windows. Independent of
|
|
// the tray; pushes metadata/state from the playback paths.
|
|
media_controls::init(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
tokio::runtime::Handle::current(),
|
|
);
|
|
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let state = w.global::<SessionState>();
|
|
state.set_user_name(session.display_name.into());
|
|
state.set_subscription(session.subscription.into());
|
|
// Seed the tray settings UI from the persisted per-user store.
|
|
let appearance = w.global::<AppearanceState>();
|
|
appearance.set_tray_enable(tray.enable_tray);
|
|
appearance.set_tray_minimize_to_tray(tray.minimize_to_tray);
|
|
appearance.set_tray_close_to_tray(tray.close_to_tray);
|
|
appearance.set_tray_mac_hide_dock(tray.mac_hide_dock);
|
|
appearance.set_tray_icon_theme_index(tray_settings::icon_theme_index(&tray.tray_icon_theme));
|
|
// Seed the My QBZ branding (label + icon) from the per-user store so
|
|
// the sidebar row + Settings row paint the custom values immediately.
|
|
myqbz_prefs::seed(&w);
|
|
w.global::<HomeState>().set_loading(true);
|
|
w.set_screen(AppScreen::Shell);
|
|
});
|
|
|
|
// Start the playback poll loop — it runs for the app lifetime,
|
|
// ticking position/progress onto NowPlayingState and auto-advancing
|
|
// the queue on track end. Safe to start once per shell entry.
|
|
playback::start_poll_loop(runtime.clone(), weak.clone(), tokio::runtime::Handle::current());
|
|
|
|
// Load the sidebar playlists list.
|
|
load_sidebar_playlists(runtime.clone(), weak.clone(), &tokio::runtime::Handle::current());
|
|
|
|
// Warm the shared favorite-track cache so track rows can show the
|
|
// correct heart state from their first paint (album / artist / search
|
|
// / playlist / mix / favorites all read it). Background-fetched once.
|
|
{
|
|
let runtime = runtime.clone();
|
|
tokio::spawn(async move {
|
|
match runtime.core().favorite_track_ids().await {
|
|
Ok(ids) => fav_cache::set_all(ids),
|
|
Err(e) => log::warn!("[qbz-slint] favorite cache load failed: {e}"),
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load Audio + Playback settings into the Settings page in the
|
|
// background — store reads and device enumeration are blocking.
|
|
{
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = weak.clone();
|
|
let runtime = runtime.clone();
|
|
tokio::spawn(async move {
|
|
let ctx_for_load = settings_ctx.clone();
|
|
match tokio::task::spawn_blocking(move || settings::load_snapshot(&ctx_for_load)).await
|
|
{
|
|
Ok(snap) => {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
settings::apply_snapshot(&w, snap);
|
|
});
|
|
}
|
|
Err(e) => log::error!("[qbz-slint] settings load task failed: {e}"),
|
|
}
|
|
// Bit-perfect (ALSA + hw) forces local volume to 100% at startup so
|
|
// the bar reflects unity gain before Settings is ever opened. No-op
|
|
// otherwise (and while controlling a peer). Mirrors Tauri.
|
|
settings::apply_startup_bitperfect_volume(&settings_ctx, &runtime, &weak).await;
|
|
});
|
|
}
|
|
|
|
// Load the genre-filter parents + persisted selection, then seed
|
|
// the popup state. Done before the discover load so the first
|
|
// fetch honors a remembered genre selection.
|
|
genre_filter::load_parents(&runtime).await;
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
genre_filter::apply_state(&w);
|
|
});
|
|
|
|
reload_home(&runtime, &weak, &image_cache, "home".to_string()).await;
|
|
|
|
// Seed the favorites tab counts so the badges are ready before the
|
|
// user opens each tab (they otherwise only count on first visit).
|
|
let counts = favorites::load_counts(&runtime).await;
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
favorites::apply_counts(&w, counts);
|
|
});
|
|
}
|
|
|
|
/// 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("discover");
|
|
(!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 /
|
|
/// tab re-fetches.
|
|
async fn reload_home(
|
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
weak: &slint::Weak<AppWindow>,
|
|
image_cache: &artwork::ImageCache,
|
|
active_tab: String,
|
|
) {
|
|
// Expand the selection to descendants so a parent selection
|
|
// covers its child genres (the child-genre filtering recovery).
|
|
let genre_ids = genre_filter::filter_ids("discover");
|
|
let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids);
|
|
|
|
match home::load_home(runtime, genre_ids).await {
|
|
Ok(data) => {
|
|
// Artwork for the active tab's section set (Section-targeted,
|
|
// so it lands in HomeState.sections once select_tab swaps it
|
|
// in below). Built before `data` is moved. For You renders
|
|
// from its own view, so it has no discover-index section
|
|
// set here.
|
|
let empty: Vec<home::SectionData> = Vec::new();
|
|
let active_set = match active_tab.as_str() {
|
|
"editorPicks" => &data.editor_sections,
|
|
"forYou" => &empty,
|
|
_ => &data.sections,
|
|
};
|
|
let mut jobs = home::section_artwork_jobs(active_set);
|
|
// Home-only slim grids (their models are populated regardless
|
|
// of the visible tab; harmless to prefetch).
|
|
jobs.extend(data.popular.iter().enumerate().filter_map(|(idx, slim)| {
|
|
(!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
|
target: artwork::ArtworkTarget::Popular { idx },
|
|
url: slim.artwork_url.clone(),
|
|
})
|
|
}));
|
|
jobs.extend(data.recent.iter().enumerate().filter_map(|(idx, slim)| {
|
|
(!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
|
target: artwork::ArtworkTarget::Recent { idx },
|
|
url: slim.artwork_url.clone(),
|
|
})
|
|
}));
|
|
// Recently-played album covers: Qobuz covers use the plain loader;
|
|
// Plex/local covers need the source-aware funnel (PlexThumb
|
|
// tokenization / local file read), else they never resolve.
|
|
let mut plex_album_jobs: Vec<artwork::ArtworkJob> = Vec::new();
|
|
for (idx, card) in data.recent_albums.iter().enumerate() {
|
|
if card.artwork_url.is_empty() {
|
|
continue;
|
|
}
|
|
let job = artwork::ArtworkJob {
|
|
target: artwork::ArtworkTarget::RecentAlbum { idx },
|
|
url: card.artwork_url.clone(),
|
|
};
|
|
if card.source == "plex" || card.source == "local" {
|
|
plex_album_jobs.push(job);
|
|
} else {
|
|
jobs.push(job);
|
|
}
|
|
}
|
|
|
|
let weak_for_artwork = weak.clone();
|
|
let weak_for_local = weak.clone();
|
|
let image_cache_local = image_cache.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
home::apply_home(&w, data);
|
|
// apply_home shows the home set; swap to the requested
|
|
// tab (no-op when it is "home").
|
|
home::select_tab(&w, &active_tab);
|
|
w.global::<HomeState>().set_loading(false);
|
|
});
|
|
artwork::spawn_loads(jobs, weak_for_artwork, image_cache.clone());
|
|
if !plex_album_jobs.is_empty() {
|
|
let plex = crate::plex_settings::get();
|
|
artwork::spawn_local_or_plex_loads(
|
|
plex_album_jobs,
|
|
plex.base_url,
|
|
plex.token,
|
|
weak_for_local,
|
|
image_cache_local,
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] discover load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<HomeState>().set_loading(false);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>();
|
|
state.set_can_back(nav::can_back());
|
|
state.set_can_forward(nav::can_forward());
|
|
}
|
|
|
|
/// Flip the `is-favorite` flag on every visible row matching `track_id`,
|
|
/// across all track-list surfaces (album, artist Popular, search,
|
|
/// playlist, mix, favorites). Used for the optimistic favorite toggle so
|
|
/// the heart updates the instant the user clicks, regardless of which
|
|
/// view they are on.
|
|
fn set_row_favorite(window: &AppWindow, track_id: &str, favorite: bool) {
|
|
let flip = |model: &slint::ModelRc<TrackItem>| {
|
|
if let Some(vm) = model.as_any().downcast_ref::<slint::VecModel<TrackItem>>() {
|
|
for i in 0..vm.row_count() {
|
|
if let Some(mut item) = vm.row_data(i) {
|
|
if item.id == track_id {
|
|
if item.is_favorite != favorite {
|
|
item.is_favorite = favorite;
|
|
vm.set_row_data(i, item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
flip(&window.global::<AlbumState>().get_tracks());
|
|
flip(&window.global::<ArtistState>().get_top_tracks());
|
|
flip(&window.global::<SearchState>().get_tracks());
|
|
flip(&window.global::<PlaylistState>().get_tracks());
|
|
flip(&window.global::<MixState>().get_tracks());
|
|
flip(&window.global::<FavoritesState>().get_tracks());
|
|
|
|
// Search's most-popular track hero is a standalone TrackItem.
|
|
let search = window.global::<SearchState>();
|
|
let mut hero = search.get_most_popular_track();
|
|
if hero.id == track_id && hero.is_favorite != favorite {
|
|
hero.is_favorite = favorite;
|
|
search.set_most_popular_track(hero);
|
|
}
|
|
}
|
|
|
|
/// Look up the display name of an "Add to Mixtape/Collection" picker row by id
|
|
/// (for the post-add toast). Returns "" if not found.
|
|
fn myqbz_add_row_name(window: &AppWindow, collection_id: &str) -> String {
|
|
use slint::Model;
|
|
let model = window.global::<MyQbzAddState>().get_rows();
|
|
(0..model.row_count())
|
|
.filter_map(|i| model.row_data(i))
|
|
.find(|r| r.id == collection_id)
|
|
.map(|r| r.name.to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Open the global "Add to Mixtape/Collection" picker for `items` (mirrors
|
|
/// Tauri's `openAddToMixtape`). Hops onto the event loop to show the modal,
|
|
/// then loads the picker rows (kind-restricted + recency-sorted +
|
|
/// `item_exists`-resolved) on a blocking worker. Empty `items` is a no-op
|
|
/// (the controller guards too). Callable from any thread.
|
|
fn open_add_to_mixtape(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
items: Vec<myqbz_add::AddItem>,
|
|
) {
|
|
if items.is_empty() {
|
|
return;
|
|
}
|
|
let restrict = items.iter().any(|it| it.item_type != "album");
|
|
let items_for_open = items.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_add::open(&w, items_for_open);
|
|
});
|
|
handle.spawn(async move {
|
|
let rows =
|
|
tokio::task::spawn_blocking(move || myqbz_add::load_rows(restrict, &items))
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_add::apply_rows(&w, rows);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Update the offline cache-status (+ progress) of every visible row matching
|
|
/// `track_id`. Mirrors `set_row_favorite`. status: 0 none / 1 queued / 2
|
|
/// downloading / 3 ready / 4 failed; `progress` is 0.0..1.0.
|
|
fn set_row_cache_status(window: &AppWindow, track_id: &str, status: i32, progress: f32) {
|
|
let apply = |model: &slint::ModelRc<TrackItem>| {
|
|
if let Some(vm) = model.as_any().downcast_ref::<slint::VecModel<TrackItem>>() {
|
|
for i in 0..vm.row_count() {
|
|
if let Some(mut item) = vm.row_data(i) {
|
|
if item.id == track_id
|
|
&& (item.cache_status != status || item.cache_progress != progress)
|
|
{
|
|
item.cache_status = status;
|
|
item.cache_progress = progress;
|
|
vm.set_row_data(i, item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
apply(&window.global::<AlbumState>().get_tracks());
|
|
apply(&window.global::<ArtistState>().get_top_tracks());
|
|
apply(&window.global::<SearchState>().get_tracks());
|
|
apply(&window.global::<PlaylistState>().get_tracks());
|
|
apply(&window.global::<MixState>().get_tracks());
|
|
apply(&window.global::<FavoritesState>().get_tracks());
|
|
|
|
let search = window.global::<SearchState>();
|
|
let mut hero = search.get_most_popular_track();
|
|
if hero.id == track_id {
|
|
hero.cache_status = status;
|
|
hero.cache_progress = progress;
|
|
search.set_most_popular_track(hero);
|
|
}
|
|
}
|
|
|
|
/// Toggle the unlocking (padlock) flag of every visible row matching
|
|
/// `track_id`. Drives the offline-decrypt animation on the row.
|
|
fn set_row_unlocking(window: &AppWindow, track_id: &str, unlocking: bool) {
|
|
let apply = |model: &slint::ModelRc<TrackItem>| {
|
|
if let Some(vm) = model.as_any().downcast_ref::<slint::VecModel<TrackItem>>() {
|
|
for i in 0..vm.row_count() {
|
|
if let Some(mut item) = vm.row_data(i) {
|
|
if item.id == track_id && item.unlocking != unlocking {
|
|
item.unlocking = unlocking;
|
|
vm.set_row_data(i, item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
apply(&window.global::<AlbumState>().get_tracks());
|
|
apply(&window.global::<ArtistState>().get_top_tracks());
|
|
apply(&window.global::<SearchState>().get_tracks());
|
|
apply(&window.global::<PlaylistState>().get_tracks());
|
|
apply(&window.global::<MixState>().get_tracks());
|
|
apply(&window.global::<FavoritesState>().get_tracks());
|
|
}
|
|
|
|
/// Lazy-load the Discover > For You sections the first time the tab is
|
|
/// opened. No-op once loaded (the data persists for the session).
|
|
fn ensure_for_you_loaded(
|
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
weak: &slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: &artwork::ImageCache,
|
|
) {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
if w.global::<ForYouState>().get_loaded() {
|
|
return;
|
|
}
|
|
foryou::reset_loading(&w);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.spawn(async move {
|
|
let data = foryou::load_for_you(&runtime).await;
|
|
let jobs = foryou::artwork_jobs(&data);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
foryou::apply_for_you(&w, &data);
|
|
});
|
|
artwork::spawn_loads(jobs, weak, image_cache);
|
|
});
|
|
}
|
|
|
|
/// Load an album and show the album view, then fetch its artwork. Shared
|
|
/// by the `open-album` callback and by history back/forward.
|
|
fn navigate_album(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
album_id: String,
|
|
) {
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
album::reset_album(&w);
|
|
w.global::<NavState>().set_view(ContentView::Album);
|
|
});
|
|
match album::load_album(&runtime, &album_id).await {
|
|
Ok(data) => {
|
|
let artwork_url = data.artwork_url.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
album::apply_album(&w, data);
|
|
w.global::<AlbumState>().set_loading(false);
|
|
});
|
|
if !artwork_url.is_empty() {
|
|
if let Some((pixels, width, height)) =
|
|
artwork::fetch_and_decode(&artwork_url, &image_cache, 448).await
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
album::apply_artwork(&w, &pixels, width, height);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] album load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<AlbumState>().set_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open a LOCAL album's detail in the shared AlbumPageView: load its tracks
|
|
/// (metadata-grouped), populate AlbumState with `is-local` set, then resolve
|
|
/// the folder/embedded cover from disk. `group_key` is the album's metadata
|
|
/// group key.
|
|
fn navigate_local_album(
|
|
_runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
group_key: String,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<NavState>().set_view(ContentView::LocalAlbum);
|
|
update_nav_flags(&w);
|
|
});
|
|
// The dedicated local album view owns the load (versions + cover).
|
|
local_library::open_local_album(weak, handle.clone(), image_cache, group_key);
|
|
}
|
|
|
|
/// True when an "album id" is actually a Local-Library / Plex metadata group
|
|
/// key rather than a numeric Qobuz album id. Qobuz album ids are numeric
|
|
/// strings; local group keys are `album|artist`, a folder path, the
|
|
/// `__unknown_album__` sentinel, or a `plex:` cache key (see
|
|
/// qbz_library::album_grouping + local_queue_track / map_plex_cached_to_local_track).
|
|
/// Lets the shared `open-album` callback route Plex/local items (now-playing
|
|
/// bar, Home "Recently played", etc.) to the LocalAlbum view instead of the
|
|
/// empty Qobuz album view.
|
|
fn is_local_album_key(id: &str) -> bool {
|
|
id.starts_with("plex:") || id.contains('|') || id.contains('/') || id == "__unknown_album__"
|
|
}
|
|
|
|
/// Load an artist page and show the artist view, then fetch the portrait.
|
|
/// Shared by the `open-artist` callback and by history back/forward.
|
|
fn navigate_artist(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
artist_id: String,
|
|
) {
|
|
let artist_id_for_state = artist_id.clone();
|
|
handle.spawn(async move {
|
|
let id_for_apply = artist_id_for_state.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
artist::reset_artist(&w);
|
|
artist::reset_network_sidebar(&w);
|
|
w.global::<ArtistState>().set_id(id_for_apply.into());
|
|
w.global::<NavState>().set_view(ContentView::Artist);
|
|
});
|
|
match artist::load_artist(&runtime, &artist_id).await {
|
|
Ok(data) => {
|
|
let artwork_url = data.artwork_url.clone();
|
|
let jobs = artist::artwork_jobs(&data);
|
|
let artist_name = data.name.clone();
|
|
let similar_names_for_discovery: Vec<String> = data
|
|
.similar_artists
|
|
.iter()
|
|
.map(|s| s.name.clone())
|
|
.collect();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
artist::apply_artist(&w, data);
|
|
w.global::<ArtistState>().set_loading(false);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
|
|
// Network sidebar — kick the MB enrichment off in
|
|
// parallel with artwork. Origin shows a loading state
|
|
// until the resolve + metadata calls return; on success
|
|
// the resolved mbid is used to fetch relationships and
|
|
// discovery candidates in sequence (the V2 cache, when
|
|
// wired, will collapse repeat visits to a single shot).
|
|
let runtime_mb = runtime.clone();
|
|
let weak_mb = weak.clone();
|
|
tokio::spawn(async move {
|
|
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
|
let state = w.global::<NetworkSidebarState>();
|
|
state.set_origin_loading(true);
|
|
state.set_relationships_loading(true);
|
|
state.set_discovery_loading(true);
|
|
});
|
|
match artist::load_mb_metadata(&runtime_mb, &artist_name).await {
|
|
Ok(Some(meta)) => {
|
|
let mbid = meta.mbid.clone();
|
|
let _ = weak_mb.upgrade_in_event_loop(move |w| {
|
|
artist::apply_mb_metadata(&w, meta);
|
|
});
|
|
match artist::load_mb_relationships(&runtime_mb, &mbid).await {
|
|
Ok(data) => {
|
|
let _ = weak_mb.upgrade_in_event_loop(move |w| {
|
|
artist::apply_mb_relationships(&w, data);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[qbz-slint] MB relationships failed: {e}");
|
|
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
|
w.global::<NetworkSidebarState>()
|
|
.set_relationships_loading(false);
|
|
});
|
|
}
|
|
}
|
|
match artist::load_mb_discovery(
|
|
&runtime_mb,
|
|
&mbid,
|
|
&artist_name,
|
|
similar_names_for_discovery,
|
|
)
|
|
.await
|
|
{
|
|
Ok(disc) => {
|
|
let _ = weak_mb.upgrade_in_event_loop(move |w| {
|
|
artist::apply_mb_discovery(&w, disc);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[qbz-slint] MB discovery failed: {e}");
|
|
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
|
w.global::<NetworkSidebarState>()
|
|
.set_discovery_loading(false);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
|
artist::apply_mb_unavailable(&w);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[qbz-slint] MB metadata load failed: {e}");
|
|
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
|
artist::apply_mb_unavailable(&w);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
if !artwork_url.is_empty() {
|
|
if let Some((pixels, width, height)) =
|
|
artwork::fetch_and_decode(&artwork_url, &image_cache, 440).await
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
artist::apply_artwork(&w, &pixels, width, height);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] artist load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<ArtistState>().set_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open the Discography Builder for `artist_id` (spec 13). Fetches the artist's
|
|
/// releases from Qobuz (sets name + avatar), then local + Plex by that name
|
|
/// (sequential — parallelizing drops local matches against an empty name),
|
|
/// dedupes into groups, installs the default selection, and decodes the avatar.
|
|
/// Plex gets a single 2-second cold-start retry when enabled and the first
|
|
/// fetch returns nothing.
|
|
fn navigate_discography_builder(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
artist_id: String,
|
|
) {
|
|
let id_for_reset = artist_id.clone();
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_builder::reset(&w, &id_for_reset);
|
|
w.global::<NavState>().set_view(ContentView::DiscographyBuilder);
|
|
});
|
|
|
|
// 1. Qobuz first — sets artist name + avatar URL (side effect).
|
|
match myqbz_builder::fetch_qobuz(&runtime, &artist_id).await {
|
|
Ok((qobuz, artist_name, avatar_url)) => {
|
|
// 2. Local + Plex by the resolved name (sequential, mandatory).
|
|
let name_for_local = artist_name.clone();
|
|
let mut local = tokio::task::spawn_blocking(move || {
|
|
myqbz_builder::fetch_local_and_plex(&name_for_local)
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
// 2b. Plex cold-start retry: if Plex is enabled and we got
|
|
// nothing from the Plex source, wait 2s and refetch once.
|
|
let plex_enabled = crate::plex_settings::get().enabled;
|
|
let got_plex = local.iter().any(|c| c.source == "plex");
|
|
if plex_enabled && !got_plex {
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
let name_retry = artist_name.clone();
|
|
let retried = tokio::task::spawn_blocking(move || {
|
|
myqbz_builder::fetch_local_and_plex(&name_retry)
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
if retried.iter().any(|c| c.source == "plex") {
|
|
local = retried;
|
|
}
|
|
}
|
|
|
|
// 3. Merge + group (Qobuz first so it wins primary ties).
|
|
let mut all = qobuz;
|
|
all.extend(local);
|
|
let groups = myqbz_builder::build_groups(all);
|
|
|
|
let avatar_for_fetch = avatar_url.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_builder::install(&w, artist_name, avatar_url, groups);
|
|
});
|
|
|
|
// 4. Decode the avatar (72px circle).
|
|
if !avatar_for_fetch.is_empty() {
|
|
if let Some((pixels, width, height)) =
|
|
artwork::fetch_and_decode(&avatar_for_fetch, &image_cache, 144).await
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_builder::apply_avatar(&w, &pixels, width, height);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] discography builder load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_builder::fail(&w, e);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
thread_local! {
|
|
/// Debounce timer for the header live search — restarted on every
|
|
/// keystroke, fires the search 300 ms after typing stops.
|
|
static SEARCH_DEBOUNCE: slint::Timer = slint::Timer::default();
|
|
}
|
|
|
|
/// Run a search and show the results view. Shared by the search-submit
|
|
/// callback, the live-search debounce, and history back/forward.
|
|
fn navigate_search(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
query: String,
|
|
) {
|
|
// Capture a version so a slow, stale load cannot overwrite a newer
|
|
// search's results (the user kept typing).
|
|
let version = search::next_search_version();
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
search::reset_search(&w);
|
|
w.global::<NavState>().set_view(ContentView::Search);
|
|
});
|
|
match search::load_search(&runtime, &query).await {
|
|
Ok(data) => {
|
|
let jobs = search::artwork_jobs(&data);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
if search::is_current_version(version) {
|
|
search::apply_search(&w, data);
|
|
w.global::<SearchState>().set_loading(false);
|
|
}
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] search load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
if search::is_current_version(version) {
|
|
w.global::<SearchState>().set_loading(false);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Apply a history entry — set the view and re-load entity pages.
|
|
/// Stable scroll-restore id for an entry's primary list container, matching
|
|
/// the `restore-scope` strings the Slint scroll containers compare against.
|
|
/// Returns `""` for views without a wired scroll memory (no container will
|
|
/// match, so nothing restores). Tab/sub-page views carry the tab in the id so
|
|
/// each tab keeps its own position. Keep in sync with the `.slint` views.
|
|
fn scope_for(entry: &nav::NavEntry) -> String {
|
|
match entry {
|
|
// HomeView is one persistent Flickable shared by the Discover tabs;
|
|
// a single scope is enough (each tab entry stores its own scroll).
|
|
nav::NavEntry::Home | nav::NavEntry::Discover { .. } => "home".into(),
|
|
nav::NavEntry::Favorites { tab } => format!("fav:{tab}"),
|
|
nav::NavEntry::LocalLibrary { tab } => format!("ll:{tab}"),
|
|
nav::NavEntry::DiscoverBrowse { .. } => "discover-browse".into(),
|
|
nav::NavEntry::Mix { .. } => "mix".into(),
|
|
nav::NavEntry::Playlist(_) => "playlist".into(),
|
|
nav::NavEntry::PlaylistManager => "playlist-manager".into(),
|
|
nav::NavEntry::OfflineManager => "offline-manager".into(),
|
|
nav::NavEntry::Mixtapes => "mixtapes".into(),
|
|
nav::NavEntry::Collections => "collections".into(),
|
|
nav::NavEntry::MixtapeDetail(_) => "mixtape-detail".into(),
|
|
nav::NavEntry::DiscographyBuilder(_) => "discography-builder".into(),
|
|
nav::NavEntry::Album(_) => "album".into(),
|
|
nav::NavEntry::LocalAlbum(_) => "local-album".into(),
|
|
nav::NavEntry::Artist(_) => "artist".into(),
|
|
nav::NavEntry::Settings => "settings".into(),
|
|
nav::NavEntry::Search(_) => "search".into(),
|
|
nav::NavEntry::Musician { .. } => "musician".into(),
|
|
nav::NavEntry::Label { .. } => "label".into(),
|
|
nav::NavEntry::LabelReleases { .. } => "label-releases".into(),
|
|
nav::NavEntry::Location { .. } => "location".into(),
|
|
}
|
|
}
|
|
|
|
/// Arm `NavState` so the destination scroll container restores its saved
|
|
/// position once it mounts. Must run before `apply_entry` switches the view.
|
|
fn arm_scroll_restore(weak: &slint::Weak<AppWindow>, entry: &nav::NavEntry, scroll: f32) {
|
|
if let Some(w) = weak.upgrade() {
|
|
let ns = w.global::<NavState>();
|
|
ns.set_restore_scope(scope_for(entry).into());
|
|
ns.set_scroll_restore(scroll);
|
|
}
|
|
}
|
|
|
|
fn apply_entry(
|
|
entry: nav::NavEntry,
|
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
weak: &slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: &artwork::ImageCache,
|
|
) {
|
|
match entry {
|
|
nav::NavEntry::Home => {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<NavState>().set_view(ContentView::Home);
|
|
});
|
|
}
|
|
nav::NavEntry::Discover { tab } => {
|
|
let for_you = tab == "forYou";
|
|
{
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
let _ = weak.clone().upgrade_in_event_loop(move |w| {
|
|
w.global::<NavState>().set_view(ContentView::Home);
|
|
let jobs = home::select_tab(&w, &tab);
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
});
|
|
}
|
|
if for_you {
|
|
ensure_for_you_loaded(runtime, weak, handle, image_cache);
|
|
}
|
|
}
|
|
nav::NavEntry::Favorites { tab } => {
|
|
if let Some(fav_tab) = favorites::FavTab::from_tab_id(&tab) {
|
|
navigate_favorites(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle,
|
|
image_cache.clone(),
|
|
fav_tab,
|
|
&tab,
|
|
);
|
|
}
|
|
}
|
|
nav::NavEntry::LocalLibrary { tab } => {
|
|
if let Some(lib_tab) = local_library::LibTab::from_tab_id(&tab) {
|
|
navigate_local_library(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle,
|
|
image_cache.clone(),
|
|
lib_tab,
|
|
);
|
|
}
|
|
}
|
|
nav::NavEntry::Settings => {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<NavState>().set_view(ContentView::Settings);
|
|
});
|
|
}
|
|
nav::NavEntry::Album(id) => {
|
|
navigate_album(runtime.clone(), weak.clone(), handle, image_cache.clone(), id);
|
|
}
|
|
nav::NavEntry::LocalAlbum(gk) => {
|
|
navigate_local_album(runtime.clone(), weak.clone(), handle, image_cache.clone(), gk);
|
|
}
|
|
nav::NavEntry::Artist(id) => {
|
|
navigate_artist(runtime.clone(), weak.clone(), handle, image_cache.clone(), id);
|
|
}
|
|
nav::NavEntry::Search(query) => {
|
|
navigate_search(runtime.clone(), weak.clone(), handle, image_cache.clone(), query);
|
|
}
|
|
nav::NavEntry::Musician { name, role } => {
|
|
navigate_musician(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle,
|
|
image_cache.clone(),
|
|
name,
|
|
role,
|
|
);
|
|
}
|
|
nav::NavEntry::Label { id, name } => {
|
|
navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name);
|
|
}
|
|
nav::NavEntry::LabelReleases { id, name } => {
|
|
navigate_label_releases(
|
|
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::OfflineManager => {
|
|
let w2 = weak.clone();
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<NavState>().set_view(ContentView::OfflineManager);
|
|
});
|
|
offline_manager::load(w2, handle.clone());
|
|
}
|
|
nav::NavEntry::Mixtapes => {
|
|
myqbz::navigate(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
qbz_models::mixtape::CollectionKind::Mixtape,
|
|
);
|
|
}
|
|
nav::NavEntry::Collections => {
|
|
myqbz::navigate(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
qbz_models::mixtape::CollectionKind::Collection,
|
|
);
|
|
}
|
|
nav::NavEntry::MixtapeDetail(id) => {
|
|
myqbz_detail::navigate(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
id,
|
|
);
|
|
}
|
|
nav::NavEntry::DiscographyBuilder(artist_id) => {
|
|
navigate_discography_builder(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle,
|
|
image_cache.clone(),
|
|
artist_id,
|
|
);
|
|
}
|
|
nav::NavEntry::Location {
|
|
mbid,
|
|
area_id,
|
|
area_name,
|
|
country,
|
|
genres,
|
|
tags,
|
|
} => {
|
|
let params = artist::LocationParams {
|
|
mbid,
|
|
area_id,
|
|
area_name,
|
|
country,
|
|
genres,
|
|
tags,
|
|
};
|
|
navigate_location(runtime.clone(), weak.clone(), handle, image_cache.clone(), params);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Open an ArtistsByLocationView for the given scene params. Runs the
|
|
/// discovery on a worker, applies the validated artist grid, then
|
|
/// fans out artwork jobs for the candidates' Qobuz thumbnails.
|
|
fn navigate_location(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
params: artist::LocationParams,
|
|
) {
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
location_view::reset_scene(&w);
|
|
w.global::<NavState>().set_view(ContentView::Location);
|
|
});
|
|
match location_view::load_scene(&runtime, ¶ms, 0).await {
|
|
Ok(data) => {
|
|
let jobs = location_view::artwork_jobs(&data);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
location_view::apply_scene(&w, data);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] scene discovery failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<LocationViewState>().set_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open the LabelView landing — the rich label page (header + popular
|
|
/// tracks + releases/critics/playlists/artists/more-labels carousels).
|
|
/// Reached by clicking a label anywhere.
|
|
fn navigate_label(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
label_id: u64,
|
|
name: String,
|
|
) {
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
label::reset_label_page(&w);
|
|
w.global::<NavState>().set_view(ContentView::Label);
|
|
});
|
|
match label::load_label_page(&runtime, label_id, &name).await {
|
|
Ok(payload) => {
|
|
let jobs = label::page_artwork_jobs(&payload);
|
|
let image_url = payload.image_url.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
label::apply_label_page(&w, payload);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
if !image_url.is_empty() {
|
|
if let Some((pixels, width, height)) =
|
|
artwork::fetch_and_decode(&image_url, &image_cache, 240).await
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
label::apply_image(&w, &pixels, width, height);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] label page load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
let s = w.global::<LabelState>();
|
|
s.set_loading(false);
|
|
s.set_page_loaded(true);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open the full "See all releases" sub-view for `label_id`. Fetches the
|
|
/// label header + first album page, then the header image.
|
|
fn navigate_label_releases(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
label_id: u64,
|
|
name: String,
|
|
) {
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
label::reset_label(&w);
|
|
w.global::<NavState>().set_view(ContentView::LabelReleases);
|
|
});
|
|
match label::load_label(&runtime, label_id, &name).await {
|
|
Ok(data) => {
|
|
let jobs = label::artwork_jobs(&data);
|
|
let image_url = data.image_url.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
label::apply_label(&w, data);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
if !image_url.is_empty() {
|
|
if let Some((pixels, width, height)) =
|
|
artwork::fetch_and_decode(&image_url, &image_cache, 240).await
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
label::apply_image(&w, &pixels, width, height);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] label releases load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<LabelState>().set_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open Library > Favorites on `tab` and lazy-load that tab's data.
|
|
/// Switching the active tab also routes here so each tab fetches on
|
|
/// first view (Tauri's loadTabIfNeeded).
|
|
fn navigate_favorites(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
tab: favorites::FavTab,
|
|
tab_id: &str,
|
|
) {
|
|
let tab_id = tab_id.to_string();
|
|
handle.spawn(async move {
|
|
let tab_id_for_ui = tab_id.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let state = w.global::<FavoritesState>();
|
|
state.set_active_tab(tab_id_for_ui.into());
|
|
favorites::reset_loading(&w);
|
|
w.global::<NavState>().set_view(ContentView::Favorites);
|
|
// The genre popup edits the favorites context here, and the
|
|
// toolbar genre button shows the favorites selection count.
|
|
genre_filter::set_context("favorites");
|
|
genre_filter::apply_state(&w);
|
|
// Restore persisted toolbar choices before the data applies +
|
|
// derives, so the loaded view honors them.
|
|
favorites_prefs::load(&w);
|
|
});
|
|
match favorites::load_favorites(&runtime, tab).await {
|
|
Ok(data) => {
|
|
let jobs = favorites::artwork_jobs(&data);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
favorites::apply_favorites(&w, data);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] favorites load failed: {e}");
|
|
let msg = e.to_string();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let st = w.global::<FavoritesState>();
|
|
st.set_loading(false);
|
|
st.set_load_error(msg.into());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Navigate to the LocalLibrary Artists tab and auto-select `name`. Local/Plex
|
|
/// artists have no id — they're keyed by NAME. The selection is latched and
|
|
/// consumed by `ensure_artists_loaded` once the tab's data is ready (handles
|
|
/// both the already-loaded and still-loading cases). Used by the LocalAlbum
|
|
/// header artist link, the now-playing "Go to artist", and local track menus.
|
|
fn open_local_artist(
|
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
weak: &slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: &artwork::ImageCache,
|
|
name: String,
|
|
) {
|
|
if name.trim().is_empty() {
|
|
return;
|
|
}
|
|
local_library::set_pending_artist(name);
|
|
nav::record(nav::NavEntry::LocalLibrary {
|
|
tab: "artists".to_string(),
|
|
});
|
|
navigate_local_library(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle,
|
|
image_cache.clone(),
|
|
local_library::LibTab::Artists,
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
}
|
|
|
|
/// Open a Local Library browse tab (Albums / Artists / Folders / Tracks).
|
|
///
|
|
/// Sets the active tab + switches the view, then lazily loads the tab's data
|
|
/// on first visit. Albums is the first slice (chunked grid); the other tabs
|
|
/// render their shell + a placeholder until their slices land.
|
|
fn navigate_local_library(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
tab: local_library::LibTab,
|
|
) {
|
|
let tab_id = tab.tab_id().to_string();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
// Restore the persisted Tracks group-by before the tab derives.
|
|
locallibrary_prefs::load(&w);
|
|
w.global::<LocalLibraryState>().set_active_tab(tab_id.into());
|
|
w.global::<NavState>().set_view(ContentView::LocalLibrary);
|
|
});
|
|
// Seed all four tab-badge counts up front (like Favorites) so the nav
|
|
// badges show without visiting each tab.
|
|
local_library::seed_counts(weak.clone(), handle.clone());
|
|
// Lazy per-tab load on first visit.
|
|
match tab {
|
|
local_library::LibTab::Albums => {
|
|
local_library::ensure_albums_loaded(weak, handle.clone(), image_cache);
|
|
}
|
|
local_library::LibTab::Folders => {
|
|
// Tree is the default mode → load the tree roots too (the flat set
|
|
// stays loaded so toggling to flat is instant).
|
|
local_library::ensure_folders_loaded(weak.clone(), handle.clone(), image_cache);
|
|
local_library::ensure_folder_tree_loaded(weak, handle.clone());
|
|
}
|
|
local_library::LibTab::Tracks => {
|
|
local_library::ensure_tracks_loaded(weak, handle.clone());
|
|
}
|
|
local_library::LibTab::Artists => {
|
|
local_library::ensure_artists_loaded(runtime, weak, handle.clone(), image_cache);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Open a Qobuz mix detail view (daily / weekly / fav / top) and load
|
|
/// its tracks.
|
|
fn navigate_mix(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
kind: String,
|
|
) {
|
|
handle.spawn(async move {
|
|
let kind_for_reset = kind.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
mix::reset_mix(&w, &kind_for_reset);
|
|
w.global::<NavState>().set_view(ContentView::Mix);
|
|
});
|
|
let tracks = mix::load_mix(&runtime, &kind).await;
|
|
let jobs = mix::artwork_jobs(&tracks);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
mix::apply_mix(&w, &kind, tracks);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
});
|
|
}
|
|
|
|
fn navigate_playlist(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
playlist_id: String,
|
|
) {
|
|
let Ok(id) = playlist_id.parse::<u64>() else {
|
|
log::warn!("[qbz-slint] navigate_playlist: bad id {playlist_id}");
|
|
return;
|
|
};
|
|
handle.spawn(async move {
|
|
let active = playlist_id.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist::reset(&w);
|
|
sidebar::set_active(&w, &active);
|
|
w.global::<NavState>().set_view(ContentView::Playlist);
|
|
});
|
|
if let Some(data) = playlist::load(&runtime, id).await {
|
|
let jobs = playlist::artwork_jobs(&data);
|
|
let pid = data.id.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist::apply(&w, data);
|
|
let owned = sidebar::contains(&w, &pid);
|
|
w.global::<PlaylistState>().set_is_owner(owned);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Resolve the track ids for a drag started on `track_id`. If the
|
|
/// current view has a multi-selection that includes the dragged row
|
|
/// (and is >1), the whole selection is dragged; otherwise just the
|
|
/// row. Mirrors Tauri's group-drag rule.
|
|
fn gather_drag_ids(w: &AppWindow, track_id: &str) -> Vec<u64> {
|
|
use slint::Model;
|
|
let view = w.global::<NavState>().get_view();
|
|
let model = match view {
|
|
ContentView::Playlist => Some(w.global::<PlaylistState>().get_tracks()),
|
|
ContentView::Artist => Some(w.global::<ArtistState>().get_top_tracks()),
|
|
_ => None,
|
|
};
|
|
if let Some(model) = model {
|
|
let selected: Vec<u64> = (0..model.row_count())
|
|
.filter_map(|i| model.row_data(i))
|
|
.filter(|t| t.selected)
|
|
.filter_map(|t| t.id.parse::<u64>().ok())
|
|
.collect();
|
|
if selected.len() > 1 && selected.iter().any(|&id| id.to_string() == track_id) {
|
|
return selected;
|
|
}
|
|
}
|
|
track_id.parse::<u64>().map(|id| vec![id]).unwrap_or_default()
|
|
}
|
|
|
|
/// Load (or reload) the sidebar playlists list off-thread.
|
|
fn load_sidebar_playlists(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(|w| sidebar::set_loading(&w, true));
|
|
handle.spawn(async move {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Open the folder editor modal for an existing folder, populating
|
|
/// `FolderEditState` from the stored record. Shared by the Playlist
|
|
/// Manager edit-folder action and the sidebar context menu so both open
|
|
/// the same editor. The icon-preset/color grids are populated once at
|
|
/// startup, so the editor works from anywhere.
|
|
fn open_folder_editor(window: &AppWindow, id: slint::SharedString) {
|
|
let fid = id.to_string();
|
|
if let Some(f) = playlist_manager::folder_for_edit(&fid) {
|
|
let fes = window.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(window.as_weak(), path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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_search_folders(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
playlist_manager::search_menu_folders(&w, &query);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
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 a whole playlist to a Mixtape/Collection (callsite O). Builds the
|
|
// `playlist` payload from the PM grid row (id / name / track count /
|
|
// first cover); the owner subtitle isn't carried in the PM model, so it
|
|
// is omitted (optional in the contract).
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlaylistManagerActions>()
|
|
.on_add_to_mixtape(move |id| {
|
|
use slint::Model;
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let model = w.global::<PlaylistManagerState>().get_playlists();
|
|
let Some(row) = (0..model.row_count())
|
|
.filter_map(|i| model.row_data(i))
|
|
.find(|it| it.id == id)
|
|
else {
|
|
return;
|
|
};
|
|
let artwork = row.url1.to_string();
|
|
let item = myqbz_add::AddItem {
|
|
item_type: "playlist".into(),
|
|
source: "qobuz".into(),
|
|
source_item_id: id.to_string(),
|
|
title: row.name.to_string(),
|
|
subtitle: None,
|
|
artwork_url: (!artwork.is_empty()).then_some(artwork),
|
|
year: None,
|
|
track_count: (row.total_count > 0).then_some(row.total_count),
|
|
};
|
|
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
|
});
|
|
}
|
|
|
|
// --- 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 };
|
|
open_folder_editor(&w, id);
|
|
});
|
|
}
|
|
|
|
// --- 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);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Wire the My QBZ (Mixtapes & Collections) index grids. READ-ONLY slice:
|
|
/// `open-card` / `create-*` are logging STUBS; the toolbar callbacks
|
|
/// (search / sort / view / kind-filter / reset) drive `crate::myqbz` rebuilds
|
|
/// + re-issue mosaic artwork jobs. Mirrors `wire_playlist_manager`.
|
|
fn wire_myqbz(
|
|
window: &AppWindow,
|
|
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
tokio_rt: &tokio::runtime::Runtime,
|
|
image_cache: &artwork::ImageCache,
|
|
) {
|
|
use myqbz::Grid;
|
|
|
|
// Re-issue mosaic artwork jobs for a grid after a toolbar rebuild (the
|
|
// row set / order changed, so visible cards need their covers reloaded).
|
|
fn refresh_covers(window: &AppWindow, grid: Grid, image_cache: &artwork::ImageCache) {
|
|
let jobs = myqbz::artwork_jobs(window, grid);
|
|
artwork::spawn_loads(jobs, window.as_weak(), image_cache.clone());
|
|
}
|
|
|
|
// --- Open a card -> the collection-detail view (Phase-2 Slice 3) -----
|
|
// NAV-IN: record history + navigate (loads via myqbz_detail::navigate),
|
|
// mirroring the grid's own nav and the album/playlist detail openers.
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
window.global::<MyQbzActions>().on_open_card(move |id| {
|
|
nav::record(nav::NavEntry::MixtapeDetail(id.to_string()));
|
|
myqbz_detail::navigate(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
id.to_string(),
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- Create CTAs: open the create modal pre-set to the right kind ---
|
|
// The kind is fixed by which grid opened it (Mixtapes -> mixtape;
|
|
// Collections -> collection); the modal radio can flip it. Mirrors
|
|
// Tauri's `openCreateModal(kind)`.
|
|
fn open_create_modal(window: &AppWindow, kind: &str) {
|
|
let st = window.global::<MyQbzCreateState>();
|
|
st.set_kind(kind.into());
|
|
st.set_name("".into());
|
|
st.set_creating(false);
|
|
st.set_open(true);
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzActions>().on_create_mixtape(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
open_create_modal(&w, "mixtape");
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzActions>().on_create_collection(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
open_create_modal(&w, "collection");
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Create modal: cancel / submit ----------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzCreateActions>().on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzCreateState>().set_open(false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Submit: create the collection on a blocking worker, then close the
|
|
// modal + drop the user straight into the new collection's detail
|
|
// view (mirrors Tauri's `submitCreateModal` → `openMixtapeDetail`).
|
|
// The grid is reloaded from the DB on back-nav, so the prepended row
|
|
// shows up there.
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
window.global::<MyQbzCreateActions>().on_submit(move || {
|
|
let Some(w) = weak.upgrade() else { return; };
|
|
let st = w.global::<MyQbzCreateState>();
|
|
let name = st.get_name().to_string();
|
|
if name.trim().is_empty() || st.get_creating() {
|
|
return;
|
|
}
|
|
let kind = myqbz::kind_from_str(st.get_kind().as_str());
|
|
st.set_creating(true);
|
|
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = runtime.clone();
|
|
handle.clone().spawn(async move {
|
|
let nm = name.trim().to_string();
|
|
let created =
|
|
tokio::task::spawn_blocking(move || myqbz::create_collection(kind, &nm))
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let weak2 = weak.clone();
|
|
let handle2 = handle.clone();
|
|
let image_cache2 = image_cache.clone();
|
|
let runtime2 = runtime.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let st = w.global::<MyQbzCreateState>();
|
|
st.set_creating(false);
|
|
match created {
|
|
Some(c) => {
|
|
st.set_open(false);
|
|
st.set_name("".into());
|
|
// Drop into the new collection's detail view.
|
|
nav::record(nav::NavEntry::MixtapeDetail(c.id.clone()));
|
|
myqbz_detail::navigate(
|
|
runtime2.clone(),
|
|
weak2.clone(),
|
|
handle2.clone(),
|
|
image_cache2.clone(),
|
|
c.id.clone(),
|
|
);
|
|
}
|
|
None => {
|
|
crate::toast::error(&w, "Failed to create collection");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Add to Mixtape/Collection picker (global singleton) ------------
|
|
{
|
|
// close — clear the pending payload + hide.
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzAddActions>().on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_add::close(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// search — re-filter the loaded rows client-side.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<MyQbzAddActions>()
|
|
.on_search_changed(move |_query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_add::rebuild(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// show-create — open the create sub-panel preset to a kind.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<MyQbzAddActions>()
|
|
.on_show_create(move |kind| {
|
|
if let Some(w) = weak.upgrade() {
|
|
let st = w.global::<MyQbzAddState>();
|
|
st.set_create_kind(kind);
|
|
st.set_create_name("".into());
|
|
st.set_creating(true);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// create-back — return to the picker list.
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzAddActions>().on_create_back(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzAddState>().set_creating(false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// pick — add the pending items to the chosen collection.
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<MyQbzAddActions>()
|
|
.on_pick(move |collection_id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let st = w.global::<MyQbzAddState>();
|
|
if st.get_busy_id() != "" {
|
|
return;
|
|
}
|
|
st.set_busy_id(collection_id.clone());
|
|
// The chosen collection's display name (for the toast).
|
|
let name = myqbz_add_row_name(&w, collection_id.as_str());
|
|
let items = myqbz_add::take_pending();
|
|
let cid = collection_id.to_string();
|
|
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let outcome = tokio::task::spawn_blocking(move || {
|
|
myqbz_add::add_items(&cid, &items)
|
|
})
|
|
.await
|
|
.unwrap_or(myqbz_add::AddOutcome { added: 0, skipped: 0 });
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_add::toast_outcome(&w, &name, &outcome);
|
|
myqbz_add::close(&w);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// create-and-add — create a new collection then add the items.
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<MyQbzAddActions>().on_create_and_add(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let st = w.global::<MyQbzAddState>();
|
|
let name = st.get_create_name().trim().to_string();
|
|
if name.is_empty() || st.get_create_busy() {
|
|
return;
|
|
}
|
|
let kind = st.get_create_kind().to_string();
|
|
st.set_create_busy(true);
|
|
let items = myqbz_add::take_pending();
|
|
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let created = {
|
|
let kind = kind.clone();
|
|
let name = name.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
myqbz_add::create_collection(&kind, &name)
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
};
|
|
match created {
|
|
Some((cid, cname)) => {
|
|
let outcome = tokio::task::spawn_blocking(move || {
|
|
myqbz_add::add_items(&cid, &items)
|
|
})
|
|
.await
|
|
.unwrap_or(myqbz_add::AddOutcome { added: 0, skipped: 0 });
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_add::toast_outcome(&w, &cname, &outcome);
|
|
myqbz_add::close(&w);
|
|
});
|
|
}
|
|
None => {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.global::<MyQbzAddState>().set_create_busy(false);
|
|
crate::toast::error(&w, "Failed to create");
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Mixtapes toolbar -----------------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<MyQbzActions>()
|
|
.on_mix_search_changed(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzState>().set_mix_search(query);
|
|
myqbz::rebuild(&w, Grid::Mixtapes);
|
|
refresh_covers(&w, Grid::Mixtapes, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<MyQbzActions>().on_mix_set_sort(move |field| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz::set_sort(&w, Grid::Mixtapes, field.as_str());
|
|
refresh_covers(&w, Grid::Mixtapes, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzActions>().on_mix_set_view(move |view| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzState>().set_mix_view(view);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<MyQbzActions>().on_mix_reset(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz::reset(&w, Grid::Mixtapes);
|
|
refresh_covers(&w, Grid::Mixtapes, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Collections toolbar --------------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<MyQbzActions>()
|
|
.on_col_search_changed(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzState>().set_col_search(query);
|
|
myqbz::rebuild(&w, Grid::Collections);
|
|
refresh_covers(&w, Grid::Collections, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<MyQbzActions>().on_col_set_sort(move |field| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz::set_sort(&w, Grid::Collections, field.as_str());
|
|
refresh_covers(&w, Grid::Collections, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzActions>().on_col_set_view(move |view| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzState>().set_col_view(view);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<MyQbzActions>()
|
|
.on_col_set_kind_filter(move |kind| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzState>().set_col_kind_filter(kind);
|
|
myqbz::rebuild(&w, Grid::Collections);
|
|
refresh_covers(&w, Grid::Collections, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<MyQbzActions>().on_col_reset(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz::reset(&w, Grid::Collections);
|
|
refresh_covers(&w, Grid::Collections, &image_cache);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Wire the My QBZ collection-DETAIL view (Phase-2 Slice 3, read-only). The
|
|
/// toolbar callbacks (search / sort / type-filter / source-filter / view-mode /
|
|
/// select / reset) drive `crate::myqbz_detail` re-derives + re-issue row
|
|
/// artwork; `open-item` / `open-artist` route to the existing
|
|
/// album/playlist/artist navigators (reusing the top-level open-album /
|
|
/// open-artist callbacks so local-vs-qobuz routing + history stay in one
|
|
/// place). Every hero CTA + per-row context action is a logging STUB — the
|
|
/// read-only boundary for this slice.
|
|
fn wire_myqbz_detail(
|
|
window: &AppWindow,
|
|
app_runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
tokio_rt: &tokio::runtime::Runtime,
|
|
image_cache: &artwork::ImageCache,
|
|
) {
|
|
use MyQbzDetailActions as Act;
|
|
|
|
// Stash the runtime for the mutation-reload paths (cover/edit) that re-run
|
|
// `myqbz_detail::navigate` (whose resolveItems pass needs it) without
|
|
// threading it through every entry point.
|
|
myqbz_detail::set_runtime(app_runtime.clone());
|
|
|
|
// After a toolbar re-derive the rendered model changed, so the visible
|
|
// rows need their thumbnails reloaded — through the SOURCE-SPLIT dispatch
|
|
// (Qobuz CDN urls via HTTP; local/Plex paths via the source-aware decoder).
|
|
fn refresh_row_covers(window: &AppWindow, image_cache: &artwork::ImageCache) {
|
|
let split = myqbz_detail::artwork_jobs(window);
|
|
myqbz_detail::dispatch_artwork(split, window.as_weak(), image_cache.clone());
|
|
}
|
|
|
|
// A toolbar re-derive rebuilds the rendered model with fresh rows
|
|
// (tracks_loaded reset to false). While in expanded view-mode the new
|
|
// visible rows must (re-)fetch their inline tracks (spec §8 auto-fetch).
|
|
fn ensure_expanded_if_active(
|
|
window: &AppWindow,
|
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
|
handle: &tokio::runtime::Handle,
|
|
) {
|
|
if window.global::<MyQbzDetailState>().get_view_mode() == "expanded" {
|
|
myqbz_detail::ensure_expanded(runtime.clone(), window.as_weak(), handle.clone());
|
|
}
|
|
}
|
|
|
|
// --- Toolbar (client-side re-derive) --------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_search_changed(move |q| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::search(&w, q.as_str());
|
|
refresh_row_covers(&w, &image_cache);
|
|
ensure_expanded_if_active(&w, &runtime, &handle);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_set_sort(move |field| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::set_sort(&w, field.as_str());
|
|
refresh_row_covers(&w, &image_cache);
|
|
ensure_expanded_if_active(&w, &runtime, &handle);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_set_type_filter(move |value| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::set_type_filter(&w, value.as_str());
|
|
refresh_row_covers(&w, &image_cache);
|
|
ensure_expanded_if_active(&w, &runtime, &handle);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_toggle_source_filter(move |kind| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::toggle_source_filter(&w, kind.as_str());
|
|
refresh_row_covers(&w, &image_cache);
|
|
ensure_expanded_if_active(&w, &runtime, &handle);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_set_view_mode(move |mode| {
|
|
if let Some(w) = weak.upgrade() {
|
|
// Sets view-mode + persists the per-collection prefs (spec §18).
|
|
myqbz_detail::set_view_mode(&w, mode.as_str());
|
|
// Entering expanded mode: fetch every expandable item's tracks
|
|
// (spec §8 — tracks render directly under each row).
|
|
if mode == "expanded" {
|
|
myqbz_detail::ensure_expanded(runtime.clone(), weak.clone(), handle.clone());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_toggle_select_mode(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::toggle_select_mode(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_toggle_item_select(move |position| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::toggle_item_select(&w, position);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_reset_filters(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_detail::reset_filters(&w);
|
|
refresh_row_covers(&w, &image_cache);
|
|
ensure_expanded_if_active(&w, &runtime, &handle);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Open an item -> album / local-album / 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::<Act>()
|
|
.on_open_item(move |_source, item_type, source_item_id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = source_item_id.to_string();
|
|
match item_type.as_str() {
|
|
// Album / track items both open an album view; the top-level
|
|
// open-album callback handles Qobuz-vs-local routing + history.
|
|
"album" | "track" => {
|
|
w.invoke_open_album(id.into());
|
|
}
|
|
"playlist" => {
|
|
nav::record(nav::NavEntry::Playlist(id.clone()));
|
|
navigate_playlist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
id,
|
|
);
|
|
update_nav_flags(&w);
|
|
}
|
|
other => {
|
|
log::warn!("[qbz-slint] myqbz_detail open-item: unknown type {other}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Open an item's artist (qobuz items only) -----------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<Act>()
|
|
.on_open_artist(move |_source, artist_name| {
|
|
if let Some(w) = weak.upgrade() {
|
|
// The top-level open-artist callback routes a name to the
|
|
// Local Library Artists tab and a numeric id to the Qobuz
|
|
// artist page. Collection items carry an artist NAME.
|
|
if !artist_name.trim().is_empty() {
|
|
w.invoke_open_artist(artist_name);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Hero PLAY / SHUFFLE (Slice 5: detail playback) -----------------
|
|
// Resolve the collection's items through the qbz-mixtape ENQUEUE resolver
|
|
// and drive the queue (replace + auto-play). DJ-mix / edit / delete / sync
|
|
// stay logging stubs (later slices).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_play_all(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_play::play_all(runtime.clone(), weak.clone(), handle.clone(), id);
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<Act>().on_shuffle(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_play::shuffle(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
id,
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- Hero DJ-mix CTA — open the "Random queue" sampler modal --------
|
|
// Resolves the collection in-order + counts unique tracks (the slider max),
|
|
// then the modal samples + replace-plays on confirm (myqbz_mix).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_dj_mix(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_mix::open(runtime.clone(), weak.clone(), handle.clone(), id);
|
|
});
|
|
}
|
|
|
|
// --- STILL-STUBBED hero CTA: discography sync -----------------------
|
|
// Sync: artist_discography has NO sync impl (spec §8) — no-op stub (the
|
|
// hero button is shown only for artist_collection for Tauri parity).
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_sync(move || {
|
|
let id = weak
|
|
.upgrade()
|
|
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
|
|
.unwrap_or_default();
|
|
log::info!("[qbz-slint] myqbz_detail sync({id}) — no discography sync impl (spec §8)");
|
|
});
|
|
}
|
|
|
|
// --- DJ-mix modal actions (slider / cancel / confirm) ---------------
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzMixActions>().on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_mix::close(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzMixActions>().on_set_index(move |index| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_mix::apply_index(&w, index);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<MyQbzMixActions>().on_shuffle(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ms = w.global::<MyQbzMixState>();
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
let size = ms.get_selected_size();
|
|
if id.is_empty() || size <= 0 {
|
|
return;
|
|
}
|
|
myqbz_mix::shuffle(runtime.clone(), weak.clone(), handle.clone(), id, size);
|
|
});
|
|
}
|
|
|
|
// --- Bulk action bar (select-mode, spec 12 §13.1) ------------------
|
|
// The full §13.1 group set:
|
|
// - "add-to-queue" / "play-next": resolve the selected items via the shared
|
|
// enqueue resolver + append / insert-next (no replace, no queue-source
|
|
// stamp — mirrors the per-row contract).
|
|
// - "add-to-playlist": resolve the selected items to their Qobuz track ids
|
|
// and open the existing playlist picker (Qobuz mode) with them.
|
|
// - "add-to-mixtape": open the global AddToMixtapeModal with the payloads.
|
|
// - "remove-selected": remove each selected position (highest-first) then
|
|
// reload the detail + clear selection.
|
|
// - "clear": clear the selection (exit-select / uncheck all).
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
let runtime = app_runtime.clone();
|
|
window.global::<Act>().on_bulk_action(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
match id.as_str() {
|
|
"add-to-queue" | "play-next" => {
|
|
let selected = myqbz_detail::selected_full_items(&w);
|
|
if selected.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_play::bulk_enqueue(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
selected,
|
|
id.as_str() == "play-next",
|
|
);
|
|
}
|
|
"add-to-playlist" => {
|
|
let selected = myqbz_detail::selected_full_items(&w);
|
|
if selected.is_empty() {
|
|
return;
|
|
}
|
|
// Resolve to Qobuz track ids on a worker, then open the
|
|
// global picker (Qobuz mode) + load the user's playlists.
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let ids =
|
|
myqbz_play::resolve_bulk_qobuz_track_ids(&runtime, &selected).await;
|
|
if ids.is_empty() {
|
|
crate::toast::error_weak(
|
|
&weak,
|
|
"No Qobuz tracks in the selection to add to a playlist",
|
|
);
|
|
return;
|
|
}
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::open_multi(&w, &ids, false);
|
|
});
|
|
let playlists = playlist_picker::load(&runtime).await;
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::apply(&w, playlists);
|
|
});
|
|
});
|
|
}
|
|
"add-to-mixtape" => {
|
|
let selected = myqbz_detail::selected_full_items(&w);
|
|
let items: Vec<myqbz_add::AddItem> = selected
|
|
.iter()
|
|
.map(|it| myqbz_add::AddItem {
|
|
item_type: myqbz_detail::item_type_str(it.item_type).to_string(),
|
|
source: myqbz_detail::source_str(it.source).to_string(),
|
|
source_item_id: it.source_item_id.clone(),
|
|
title: it.title.clone(),
|
|
subtitle: it.subtitle.clone(),
|
|
artwork_url: it.artwork_url.clone(),
|
|
year: it.year,
|
|
track_count: it.track_count,
|
|
})
|
|
.collect();
|
|
open_add_to_mixtape(weak.clone(), handle.clone(), items);
|
|
}
|
|
"remove-selected" => {
|
|
let cid = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
let positions = myqbz_detail::selected_positions(&w);
|
|
myqbz_edit::remove_selected(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
cid,
|
|
positions,
|
|
);
|
|
}
|
|
"clear" => {
|
|
// Clear-X: uncheck every row + zero the count, staying in
|
|
// select-mode (spec §13.1 clear control).
|
|
myqbz_detail::clear_selection(&w);
|
|
}
|
|
other => {
|
|
log::warn!("[qbz-slint] myqbz_detail bulk-action: unknown id {other}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Hero overflow (⋯) menu — open the edit modals (spec 12 §10/§11) ---
|
|
// Rename / Edit description / Delete-confirm open the shared MyQbzEditModal
|
|
// with the right mode + prefill; the mutations + reload run on submit.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_open_rename(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ds = w.global::<MyQbzDetailState>();
|
|
let es = w.global::<MyQbzEditState>();
|
|
es.set_mode("rename".into());
|
|
es.set_name(ds.get_name());
|
|
es.set_draft_name(ds.get_name());
|
|
es.set_busy(false);
|
|
es.set_open(true);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_open_description(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ds = w.global::<MyQbzDetailState>();
|
|
let es = w.global::<MyQbzEditState>();
|
|
es.set_mode("description".into());
|
|
es.set_name(ds.get_name());
|
|
es.set_draft_description(ds.get_description());
|
|
es.set_busy(false);
|
|
es.set_open(true);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_open_delete(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ds = w.global::<MyQbzDetailState>();
|
|
let es = w.global::<MyQbzEditState>();
|
|
es.set_mode("delete".into());
|
|
es.set_name(ds.get_name());
|
|
es.set_busy(false);
|
|
es.set_open(true);
|
|
});
|
|
}
|
|
|
|
// --- Hero overflow — custom cover (set / remove) --------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<Act>().on_upload_cover(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_cover::upload(weak.clone(), handle.clone(), image_cache.clone(), id);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<Act>().on_remove_cover(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_cover::remove(weak.clone(), handle.clone(), image_cache.clone(), id);
|
|
});
|
|
}
|
|
|
|
// --- Hero overflow — play-mode toggle / convert kind ---------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<Act>().on_toggle_play_mode(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ds = w.global::<MyQbzDetailState>();
|
|
let id = ds.get_id().to_string();
|
|
let mode = ds.get_play_mode().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_edit::toggle_play_mode(weak.clone(), handle.clone(), image_cache.clone(), id, mode);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<Act>().on_convert_kind(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ds = w.global::<MyQbzDetailState>();
|
|
let id = ds.get_id().to_string();
|
|
let kind = ds.get_kind().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_edit::convert_kind(weak.clone(), handle.clone(), image_cache.clone(), id, kind);
|
|
});
|
|
}
|
|
|
|
// --- Edit modals — submit (rename / description / delete) ----------
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<MyQbzEditActions>().on_submit_rename(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
let name = w.global::<MyQbzEditState>().get_draft_name().to_string();
|
|
myqbz_edit::rename(weak.clone(), handle.clone(), image_cache.clone(), id, name);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<MyQbzEditActions>()
|
|
.on_submit_description(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
let desc = w.global::<MyQbzEditState>().get_draft_description().to_string();
|
|
myqbz_edit::set_description(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
id,
|
|
desc,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<MyQbzEditActions>().on_confirm_delete(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
myqbz_edit::delete(weak.clone(), handle.clone(), id);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<MyQbzEditActions>().on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let es = w.global::<MyQbzEditState>();
|
|
es.set_open(false);
|
|
es.set_mode("".into());
|
|
es.set_busy(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Per-row PLAY (default) -----------------------------------------
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_play_item(move |source_item_id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_play::play_item(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
source_item_id.to_string(),
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- Per-row context menu (play / play-next / add-to-queue) ---------
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<Act>()
|
|
.on_item_action(move |source_item_id, action| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_play::item_action(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
source_item_id.to_string(),
|
|
action.to_string(),
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- Expanded view-mode: inline tracks under every album/playlist (§8) -
|
|
// Fired when the expanded view-mode becomes active; fetches each
|
|
// expandable item's tracks (skipping already-cached rows).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<Act>().on_ensure_expanded(move || {
|
|
myqbz_detail::ensure_expanded(runtime.clone(), weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
// Inline-track row actions (play / play-next / play-later / go-to-album).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<Act>()
|
|
.on_inline_track_action(move |item_source_item_id, track_id, action| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
// go-to-album routes through the existing open-item path (Qobuz
|
|
// album view vs local-album by id), keeping nav in one place.
|
|
// It must open the PARENT item (spec 12 §8) — so route with the
|
|
// parent's REAL item_type (album/playlist), not a hardcoded
|
|
// "album": a playlist parent must reach the playlist view, not
|
|
// be mis-routed to the album view. The parent's type is read off
|
|
// the rendered row carrying this source-item-id.
|
|
if action == "go-to-album" {
|
|
let parent_type = {
|
|
let model = w.global::<MyQbzDetailState>().get_items();
|
|
(0..model.row_count())
|
|
.filter_map(|i| model.row_data(i))
|
|
.find(|it| it.source_item_id == item_source_item_id)
|
|
.map(|it| it.item_type.to_string())
|
|
.unwrap_or_else(|| "album".to_string())
|
|
};
|
|
w.global::<Act>().invoke_open_item(
|
|
"".into(),
|
|
parent_type.into(),
|
|
item_source_item_id,
|
|
);
|
|
return;
|
|
}
|
|
let id = w.global::<MyQbzDetailState>().get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
myqbz_play::play_inline_track(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
item_source_item_id.to_string(),
|
|
track_id.to_string(),
|
|
action.to_string(),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Wire the Discography Builder (spec 13). Back -> browser-back (returns to the
|
|
/// artist page). Name / order / checkbox / select-all / type-override drive the
|
|
/// `crate::myqbz_builder` session re-renders. Open-album routes through the
|
|
/// top-level `open-album` (Qobuz album view vs local-album by id). Create runs
|
|
/// the save flow on a blocking worker then navigates to the new collection.
|
|
fn wire_disco_builder(
|
|
window: &AppWindow,
|
|
tokio_rt: &tokio::runtime::Runtime,
|
|
image_cache: &artwork::ImageCache,
|
|
) {
|
|
use DiscoBuilderActions as Act;
|
|
|
|
// Back / Cancel -> browser-back to the artist page.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_back(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<NavState>().invoke_request_back();
|
|
}
|
|
});
|
|
}
|
|
// Collection-name input.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_name_changed(move |name| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_builder::name_changed(&w, name.as_str());
|
|
}
|
|
});
|
|
}
|
|
// Order segmented control.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_set_order(move |order| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_builder::set_order(&w, order.as_str());
|
|
}
|
|
});
|
|
}
|
|
// Per-row checkbox.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<Act>()
|
|
.on_toggle_checked(move |group_key, cand_key| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_builder::toggle_checked(&w, group_key.as_str(), cand_key.as_str());
|
|
}
|
|
});
|
|
}
|
|
// Header select-all.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<Act>().on_toggle_all(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_builder::toggle_all(&w);
|
|
}
|
|
});
|
|
}
|
|
// Open an album (Qobuz album view, or local-album by group key).
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<Act>()
|
|
.on_open_album(move |_source, source_item_id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
if !source_item_id.trim().is_empty() {
|
|
w.invoke_open_album(source_item_id);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// Release-type override set / reset.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<Act>()
|
|
.on_set_type_override(move |source, id, choice| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_builder::set_type_override(
|
|
&w,
|
|
source.as_str(),
|
|
id.as_str(),
|
|
choice.as_str(),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<Act>()
|
|
.on_reset_type_override(move |source, id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_builder::reset_type_override(&w, source.as_str(), id.as_str());
|
|
}
|
|
});
|
|
}
|
|
// Create — save the artist_collection + bulk-add, then navigate to detail.
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<Act>().on_create(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
// Snapshot the selection in current sort order (UI thread).
|
|
let Some(payload) = myqbz_builder::save_payload(&w) else {
|
|
return;
|
|
};
|
|
if w.global::<DiscoBuilderState>().get_creating() {
|
|
return;
|
|
}
|
|
myqbz_builder::set_creating(&w, true);
|
|
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
myqbz_builder::create_collection(&payload)
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
myqbz_builder::set_creating(&w, false);
|
|
match result {
|
|
Some(collection_id) => {
|
|
myqbz_builder::toast_created(&w);
|
|
// Navigate to the new collection's detail.
|
|
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
|
|
if let Some(runtime) = myqbz_detail::global_runtime() {
|
|
myqbz_detail::navigate(
|
|
runtime,
|
|
w.as_weak(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
collection_id,
|
|
);
|
|
}
|
|
update_nav_flags(&w);
|
|
}
|
|
None => {
|
|
myqbz_builder::toast_failed(&w);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// the first page of appearances inline; subsequent pages come
|
|
/// through the MusicianActions::load-more handler.
|
|
fn navigate_musician(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: &tokio::runtime::Handle,
|
|
image_cache: artwork::ImageCache,
|
|
name: String,
|
|
role: String,
|
|
) {
|
|
handle.spawn(async move {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
musician::reset_musician(&w);
|
|
w.global::<NavState>().set_view(ContentView::Musician);
|
|
});
|
|
match musician::load_musician(&runtime, &name, &role).await {
|
|
Ok(data) => {
|
|
let jobs = musician::artwork_jobs(&data);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
musician::apply_musician(&w, data);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] musician load failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<MusicianState>().set_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Resolve the desktop environment's UI font family. Reads the KDE
|
|
/// Plasma general font from `kdeglobals` (`[General] font=`), whose
|
|
/// value is a Qt font string `Family,pointSize,...` — only the family
|
|
/// is taken. Returns `None` off KDE or when the key is absent, so the
|
|
/// caller can fall back to the Slint default.
|
|
fn system_font_family() -> Option<String> {
|
|
let home = std::env::var_os("HOME")?;
|
|
let path = std::path::Path::new(&home).join(".config/kdeglobals");
|
|
let content = std::fs::read_to_string(path).ok()?;
|
|
let mut in_general = false;
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with('[') {
|
|
in_general = line == "[General]";
|
|
continue;
|
|
}
|
|
if in_general {
|
|
if let Some(rest) = line.strip_prefix("font=") {
|
|
let family = rest.split(',').next().unwrap_or("").trim();
|
|
if !family.is_empty() {
|
|
return Some(family.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
|
|
let tokio_rt = tokio::runtime::Runtime::new()?;
|
|
let _enter = tokio_rt.enter();
|
|
|
|
let window = AppWindow::new()?;
|
|
// FONT TEST (slint-mvp): render with bundled Inter 18pt. Inter is a
|
|
// clean, screen-tuned UI face; combined with the femtovg #5177/#11335
|
|
// text fixes this is the candidate for the final look. Flip
|
|
// `FONT_TEST_INTER` to false to fall back to the KDE system font.
|
|
const FONT_TEST_INTER: bool = true;
|
|
if FONT_TEST_INTER {
|
|
log::info!("[qbz-slint] font test: using bundled Inter 18pt");
|
|
window.set_system_font("Inter 18pt".into());
|
|
} else if let Some(font) = system_font_family() {
|
|
log::info!("[qbz-slint] using system font: {font}");
|
|
window.set_system_font(font.into());
|
|
}
|
|
|
|
// Now-playing bar layout (New = 0 / Classic = 1) — restore the persisted
|
|
// choice before the shell renders so the bar opens in the right mode.
|
|
window
|
|
.global::<ShellState>()
|
|
.set_npb_mode(crate::ui_prefs::npb_mode_index(&crate::ui_prefs::load().npb_mode));
|
|
|
|
// Tell the tray settings UI which platform it's on so it can show the
|
|
// macOS-only controls ("Menu Bar" header, hide-Dock toggle) and hide the
|
|
// Linux/Windows-only minimize-to-tray row.
|
|
window
|
|
.global::<AppearanceState>()
|
|
.set_is_macos(cfg!(target_os = "macos"));
|
|
|
|
let app_runtime = Arc::new(AppRuntime::new(SlintAdapter::new(window.as_weak())));
|
|
|
|
// MusicBrainz cache — opens a SQLite store at
|
|
// <data-dir>/qbz/cache/musicbrainz_cache.db so artist metadata
|
|
// and relationships persist across sessions (matches Tauri's
|
|
// MusicBrainzCache init path). Failure to open just degrades to
|
|
// direct network calls — the methods skip the cache when none
|
|
// is set.
|
|
if let Some(data_dir) = dirs::data_dir() {
|
|
let cache_dir = data_dir.join("qbz").join("cache");
|
|
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
|
|
log::warn!("[qbz-slint] MB cache dir create failed: {e}");
|
|
} else {
|
|
let db_path = cache_dir.join("musicbrainz_cache.db");
|
|
match qbz_integrations::musicbrainz::cache::MusicBrainzCache::new(&db_path) {
|
|
Ok(cache) => {
|
|
app_runtime.core().set_musicbrainz_cache(cache);
|
|
log::info!("[qbz-slint] MB cache opened at {db_path:?}");
|
|
}
|
|
Err(e) => log::warn!("[qbz-slint] MB cache open failed: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shared QBZ image cache for album artwork; trim it on startup.
|
|
let image_cache = artwork::open_cache();
|
|
artwork::spawn_evict(image_cache.clone());
|
|
// Publish it so the playback controller can resolve now-playing /
|
|
// queue cover art without the cache being threaded through.
|
|
artwork::set_shared_cache(image_cache.clone());
|
|
|
|
// Audio + Playback settings stores, opened once for the app lifetime.
|
|
let settings_ctx = settings::SettingsCtx::open();
|
|
|
|
// Startup: initialize the core, then try to restore a saved session.
|
|
// A valid saved token jumps straight to the shell; otherwise the
|
|
// login screen stays.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let image_cache = image_cache.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
tokio_rt.spawn(async move {
|
|
if let Err(e) = runtime.init().await {
|
|
log::error!("[qbz-slint] core init failed: {e}");
|
|
}
|
|
match auth::restore_saved_session(&runtime).await {
|
|
Ok(Some(session)) => {
|
|
log::info!(
|
|
"[qbz-slint] session restored for user {}",
|
|
session.user_id
|
|
);
|
|
enter_shell(runtime, weak, image_cache, settings_ctx, session).await;
|
|
}
|
|
Ok(None) => {
|
|
log::info!("[qbz-slint] no saved session — showing login");
|
|
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Login));
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] session restore failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Login));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sign in via the system browser → real OAuth → shell.
|
|
// "Sign in via Browser" and "Use your system browser instead" are the
|
|
// same flow in the MVP (the in-app webview path is intentionally absent).
|
|
let on_browser_login = {
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
handle.spawn(async move {
|
|
match auth::login_via_system_browser(&runtime).await {
|
|
Ok(session) => {
|
|
log::info!(
|
|
"[qbz-slint] authenticated as user {}",
|
|
session.user_id
|
|
);
|
|
enter_shell(runtime, weak, image_cache, settings_ctx, session).await;
|
|
}
|
|
Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"),
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
{
|
|
let login = on_browser_login.clone();
|
|
window.on_sign_in_via_browser(move || {
|
|
dispatch(AppCommand::SignInViaBrowser);
|
|
login();
|
|
});
|
|
}
|
|
{
|
|
let login = on_browser_login.clone();
|
|
window.on_use_system_browser(move || {
|
|
dispatch(AppCommand::UseSystemBrowser);
|
|
login();
|
|
});
|
|
}
|
|
|
|
// Offline: activate an offline-only session, then show the shell.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_start_offline(move || {
|
|
dispatch(AppCommand::StartOffline);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
match runtime.activate_offline().await {
|
|
Ok(()) => {
|
|
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Shell));
|
|
}
|
|
Err(e) => log::error!("[qbz-slint] offline start failed: {e}"),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Open an album: record history, then load and show it.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.on_open_album(move |album_id| {
|
|
let album_id = album_id.to_string();
|
|
// A Plex/local item carries a metadata group key, not a Qobuz id —
|
|
// route it to the LocalAlbum view (Home "Recently played", the
|
|
// now-playing bar's "Go to album", etc.) instead of the empty
|
|
// Qobuz album view.
|
|
if is_local_album_key(&album_id) {
|
|
nav::record(nav::NavEntry::LocalAlbum(album_id.clone()));
|
|
navigate_local_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
album_id,
|
|
);
|
|
} else {
|
|
nav::record(nav::NavEntry::Album(album_id.clone()));
|
|
navigate_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
album_id,
|
|
);
|
|
}
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Open an artist: record history, then load and show the page.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.on_open_artist(move |artist_ref| {
|
|
let artist_ref = artist_ref.to_string();
|
|
// Qobuz artists are numeric ids → the Qobuz artist page. Local/Plex
|
|
// artists have no id, so their surfaces (LocalAlbum link, now-playing
|
|
// "Go to artist") pass the NAME instead → the LocalLibrary Artists
|
|
// tab, focused on that artist.
|
|
if artist_ref.parse::<u64>().is_ok() {
|
|
nav::record(nav::NavEntry::Artist(artist_ref.clone()));
|
|
navigate_artist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
artist_ref,
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
} else if !artist_ref.trim().is_empty() {
|
|
open_local_artist(&runtime, &weak, &handle, &image_cache, artist_ref);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Submit search (Enter): record history and show the results page.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<SearchActions>().on_submit(move |query| {
|
|
let q = query.trim().to_string();
|
|
if q.len() < 2 {
|
|
return;
|
|
}
|
|
SEARCH_DEBOUNCE.with(|t| t.stop());
|
|
nav::push_or_replace_search(q.clone());
|
|
navigate_search(runtime.clone(), weak.clone(), &handle, image_cache.clone(), q);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Live search: debounce 300 ms, minimum 2 characters. Does not record
|
|
// history (per-keystroke entries would pollute the back stack).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<SearchActions>().on_live(move |query| {
|
|
let q = query.trim().to_string();
|
|
if q.len() < 2 {
|
|
SEARCH_DEBOUNCE.with(|t| t.stop());
|
|
return;
|
|
}
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
SEARCH_DEBOUNCE.with(|t| {
|
|
t.start(
|
|
slint::TimerMode::SingleShot,
|
|
std::time::Duration::from_millis(300),
|
|
move || {
|
|
// Record (or replace) the Search history entry so
|
|
// back/forward returns to this search instead of
|
|
// skipping past it.
|
|
nav::push_or_replace_search(q.clone());
|
|
navigate_search(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
q.clone(),
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Switch search results tab. search_all already loaded every
|
|
// category, so this only changes which one the view renders.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<SearchActions>().on_tab_changed(move |tab| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<SearchState>().set_tab(tab);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load more results for the active per-type tab. The offset is the
|
|
// count already loaded into that category's list.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<SearchActions>().on_load_more(move |tab| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let Some(category) = search::category_for_tab(tab) else {
|
|
return;
|
|
};
|
|
let st = w.global::<SearchState>();
|
|
let query = st.get_query().to_string();
|
|
let filter = search::search_type_for_filter(st.get_filter_index());
|
|
let offset = match category {
|
|
search::SearchCategory::Albums => st.get_albums().row_count(),
|
|
search::SearchCategory::Tracks => st.get_tracks().row_count(),
|
|
search::SearchCategory::Artists => st.get_artists().row_count(),
|
|
search::SearchCategory::Playlists => st.get_playlists().row_count(),
|
|
} as u32;
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.spawn(async move {
|
|
match search::load_more(&runtime, &query, category, filter, offset).await {
|
|
Ok(more) => {
|
|
let jobs = search::artwork_jobs_for_more(&more, offset as usize);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
search::append_results(&w, more);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache);
|
|
}
|
|
Err(e) => log::error!("[qbz-slint] search load-more failed: {e}"),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Change the searchType filter: re-query the three filterable
|
|
// categories (albums / tracks / artists) and replace their lists, so
|
|
// the filter takes effect on every tab including All.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<SearchActions>().on_filter_changed(move |index| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let st = w.global::<SearchState>();
|
|
st.set_filter_index(index);
|
|
let query = st.get_query().to_string();
|
|
if query.trim().is_empty() {
|
|
return;
|
|
}
|
|
let search_type = search::search_type_for_filter(index);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.spawn(async move {
|
|
for category in [
|
|
search::SearchCategory::Albums,
|
|
search::SearchCategory::Tracks,
|
|
search::SearchCategory::Artists,
|
|
] {
|
|
match search::load_more(&runtime, &query, category, search_type.clone(), 0)
|
|
.await
|
|
{
|
|
Ok(more) => {
|
|
let jobs = search::artwork_jobs_for_more(&more, 0);
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
search::replace_category(&w, more);
|
|
});
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
}
|
|
Err(e) => log::error!("[qbz-slint] search filter failed: {e}"),
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// History navigation — back / forward / settings, all recorded by the
|
|
// nav module so the [<] [>] pair and the mouse buttons stay in sync.
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<NavState>().on_request_settings(move || {
|
|
nav::record(nav::NavEntry::Settings);
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<NavState>().set_view(ContentView::Settings);
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<NavState>().on_request_back(move || {
|
|
if let Some((entry, scroll)) = nav::go_back() {
|
|
arm_scroll_restore(&weak, &entry, scroll);
|
|
apply_entry(entry, &runtime, &weak, &handle, &image_cache);
|
|
}
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.global::<NavState>().on_request_forward(move || {
|
|
if let Some((entry, scroll)) = nav::go_forward() {
|
|
arm_scroll_restore(&weak, &entry, scroll);
|
|
apply_entry(entry, &runtime, &weak, &handle, &image_cache);
|
|
}
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// The mounted scroll container reports its live viewport-y here so the
|
|
// nav module can stamp the outgoing entry on the next navigation.
|
|
window
|
|
.global::<NavState>()
|
|
.on_report_scroll(|y| nav::set_live_scroll(y));
|
|
}
|
|
|
|
// Log out: clear the session and return to the login screen.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_logout(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
if let Err(e) = auth::logout(&runtime).await {
|
|
log::error!("[qbz-slint] logout failed: {e}");
|
|
}
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<NavState>().set_view(ContentView::Home);
|
|
w.global::<SessionState>().set_user_name("".into());
|
|
w.set_screen(AppScreen::Login);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Settings — a toggle changed: persist it and apply audio ones to the
|
|
// live player.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_settings_bool(move |key, value| {
|
|
let runtime = runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = weak.clone();
|
|
let key = key.to_string();
|
|
handle.spawn(async move {
|
|
settings::handle_bool(settings_ctx, runtime, weak, key, value).await;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Settings — a dropdown changed: persist it, apply audio ones, and
|
|
// re-enumerate devices on a backend switch.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_settings_select(move |key, index| {
|
|
let runtime = runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = weak.clone();
|
|
let key = key.to_string();
|
|
let index = index.max(0) as usize;
|
|
handle.spawn(async move {
|
|
settings::handle_select(settings_ctx, runtime, weak, key, index).await;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Settings — a slider changed (Initial Buffer Size): persist it and
|
|
// reload the player settings.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_settings_slider(move |key, value| {
|
|
let runtime = runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let key = key.to_string();
|
|
handle.spawn(async move {
|
|
settings::handle_slider(&settings_ctx, &runtime, &key, value);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Settings — Reset: restore Audio + Playback defaults, rebuild the
|
|
// snapshot, and re-apply the audio settings to the player.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_settings_reset(move || {
|
|
let runtime = runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
settings::handle_reset(settings_ctx, runtime, weak).await;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Settings — the output-device refresh/release button: free a device QBZ
|
|
// holds exclusively (ALSA Direct) and re-enumerate, so a freed or
|
|
// hot-plugged DAC reappears without an app restart.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.on_settings_release_device(move || {
|
|
let runtime = runtime.clone();
|
|
let settings_ctx = settings_ctx.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
settings::handle_release_device(settings_ctx, runtime, weak).await;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Appearance settings persistence. The toggles/selects set their
|
|
// AppearanceState property locally, then fire these generic callbacks so
|
|
// the choice survives restart. Tray keys persist to the shared per-user
|
|
// tray_settings store; unknown keys are logged (other appearance settings
|
|
// are wired as they land).
|
|
{
|
|
let appearance = window.global::<AppearanceState>();
|
|
appearance.on_appearance_bool(|key, value| match key.as_str() {
|
|
"tray-enable" => tray_settings::set_enable_tray(value),
|
|
"tray-minimize-to-tray" => tray_settings::set_minimize_to_tray(value),
|
|
"tray-close-to-tray" => tray_settings::set_close_to_tray(value),
|
|
"tray-mac-hide-dock" => tray_settings::set_mac_hide_dock(value),
|
|
other => log::debug!("[qbz-slint] unhandled appearance-bool '{other}'"),
|
|
});
|
|
appearance.on_appearance_select(|key, index| match key.as_str() {
|
|
"tray-icon-theme" => {
|
|
tray_settings::set_icon_theme_index(index);
|
|
// Re-theme the running tray icon live (no restart).
|
|
if let Some(t) = tray::handle() {
|
|
t.set_icon_theme(tray_settings::theme_for_index(index));
|
|
}
|
|
}
|
|
other => log::debug!("[qbz-slint] unhandled appearance-select '{other}'"),
|
|
});
|
|
}
|
|
|
|
// "My QBZ" nav branding (Settings > Appearance) — persist the label /
|
|
// custom icon per-user and re-seed MyQbzBrandingState so the sidebar row
|
|
// updates live. Re-homed from the Tauri sidebar context-menu modal (DQ3).
|
|
{
|
|
let branding = window.global::<MyQbzBrandingState>();
|
|
// Label: persist (blank coerces to "My QBZ" in the store) and push the
|
|
// coerced value onto the shared `label` property so the sidebar row
|
|
// updates live. We set only `label` (not a full re-seed) so the bound
|
|
// LineEdit isn't disturbed mid-edit beyond the documented blank->default
|
|
// coercion. The icon state is left untouched here.
|
|
let weak = window.as_weak();
|
|
branding.on_set_label(move |label| {
|
|
let coerced = myqbz_prefs::set_label(label.as_str());
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<MyQbzBrandingState>().set_label(coerced.into());
|
|
}
|
|
});
|
|
// Change icon: async native picker; persists + re-seeds on pick.
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
branding.on_pick_icon(move || {
|
|
myqbz_prefs::pick_icon(weak.clone(), handle.clone());
|
|
});
|
|
// Reset icon: clear the custom path, re-seed to the default glyph.
|
|
let weak = window.as_weak();
|
|
branding.on_reset_icon(move || {
|
|
myqbz_prefs::reset_icon();
|
|
if let Some(w) = weak.upgrade() {
|
|
myqbz_prefs::seed(&w);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Context-menu / overlay media actions — route play / queue actions
|
|
// into the playback controller; favorite / download stay logged.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.on_media_action(move |kind, id, action| {
|
|
let kind = kind.to_string();
|
|
let id = id.to_string();
|
|
let action = action.to_string();
|
|
log::info!("[qbz-slint] media-action: kind={kind} id={id} action={action}");
|
|
// Local Library album detail reuses AlbumPageView. Route its play
|
|
// actions to local playback — guarded to the album view + is-local
|
|
// so Qobuz album/track play is untouched.
|
|
if action == "play" && (kind == "album" || kind == "track") {
|
|
if let Some(w) = weak.upgrade() {
|
|
let album_state = w.global::<AlbumState>();
|
|
if matches!(w.global::<NavState>().get_view(), ContentView::Album)
|
|
&& album_state.get_is_local()
|
|
{
|
|
let album_id = album_state.get_id().to_string();
|
|
let start = if kind == "track" {
|
|
id.parse::<i64>().ok()
|
|
} else {
|
|
None
|
|
};
|
|
playback::play_local_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
album_id,
|
|
start,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
match (kind.as_str(), action.as_str()) {
|
|
// Now-playing bar layout switch (New / Classic). Persisted to
|
|
// ui_prefs so the choice survives restarts. Small/Large/window
|
|
// modes are disabled in the menu until those layouts land.
|
|
("npb-view", mode @ ("new" | "classic")) => {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<ShellState>()
|
|
.set_npb_mode(crate::ui_prefs::npb_mode_index(mode));
|
|
let mut prefs = crate::ui_prefs::load();
|
|
prefs.npb_mode = mode.to_string();
|
|
crate::ui_prefs::save(&prefs);
|
|
}
|
|
}
|
|
("album", "play") => {
|
|
// A Plex/local id is a metadata group key, not a Qobuz id —
|
|
// play it from the local/Plex cache (Home "Recently played",
|
|
// etc.) instead of trying to fetch a Qobuz album.
|
|
if is_local_album_key(&id) {
|
|
playback::play_local_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
None,
|
|
);
|
|
} else {
|
|
playback::play_album(runtime.clone(), weak.clone(), handle.clone(), id, 0);
|
|
}
|
|
}
|
|
("track", "play") => {
|
|
// Universal per-row play: queue the current view's VISIBLE
|
|
// tracklist starting at the clicked track (see
|
|
// playback::play_track_in_context). Every tracklist surface
|
|
// routes here — album, playlist, favorites, label, mix,
|
|
// artist, search.
|
|
if let Some(w) = weak.upgrade() {
|
|
playback::play_track_in_context(
|
|
&w,
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
&id,
|
|
);
|
|
}
|
|
}
|
|
("album", "queue") => playback::enqueue_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
),
|
|
("track", "queue") => {
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
playback::enqueue_track(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
track_id,
|
|
);
|
|
}
|
|
}
|
|
("album", "play-next") => playback::enqueue_album_next(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
),
|
|
("album", "shuffle") => playback::play_album_shuffled(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
),
|
|
("album", "edit") => {
|
|
// Open the local-album tag editor (group_key == directory_path
|
|
// for folder-grouped local albums).
|
|
tag_editor::open_tag_editor(weak.clone(), handle.clone(), id.clone(), id);
|
|
}
|
|
("album", "add-to-mixtape") => {
|
|
// The cassette button on the album header. Local albums
|
|
// (incl. Plex, stored as source "local") build the payload
|
|
// from AlbumState + the loaded tracks; Qobuz albums resolve
|
|
// via get_album (the proven fail-safe resolver).
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let st = w.global::<AlbumState>();
|
|
if st.get_is_local() {
|
|
let item = myqbz_add::AddItem {
|
|
item_type: "album".into(),
|
|
source: "local".into(),
|
|
source_item_id: st.get_id().to_string(),
|
|
title: st.get_title().to_string(),
|
|
subtitle: {
|
|
let a = st.get_artist().to_string();
|
|
(!a.is_empty()).then_some(a)
|
|
},
|
|
artwork_url: None, // local albums omit artwork_url (1:1 PSD)
|
|
year: None,
|
|
track_count: {
|
|
use slint::Model;
|
|
let n = st.get_tracks().row_count();
|
|
(n > 0).then_some(n as i32)
|
|
},
|
|
};
|
|
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
|
} else {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle2 = handle.clone();
|
|
let album_id = id.clone();
|
|
handle.spawn(async move {
|
|
let item = match runtime.core().get_album(&album_id).await {
|
|
Ok(album) => {
|
|
let artwork_url = album
|
|
.image
|
|
.thumbnail
|
|
.clone()
|
|
.or_else(|| album.image.small.clone());
|
|
let year = album
|
|
.release_date_original
|
|
.as_deref()
|
|
.and_then(|d| d.get(0..4))
|
|
.and_then(|y| y.parse::<i32>().ok());
|
|
let track_count = album
|
|
.tracks_count
|
|
.or(album.track_count)
|
|
.map(|n| n as i32);
|
|
myqbz_add::AddItem {
|
|
item_type: "album".into(),
|
|
source: "qobuz".into(),
|
|
source_item_id: album.id.clone(),
|
|
title: album.title.clone(),
|
|
subtitle: {
|
|
let a = album.artist.name.clone();
|
|
(!a.is_empty()).then_some(a)
|
|
},
|
|
artwork_url,
|
|
year,
|
|
track_count,
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[qbz-slint] add-to-mixtape: get_album {album_id} failed: {e}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
open_add_to_mixtape(weak, handle2, vec![item]);
|
|
});
|
|
}
|
|
}
|
|
("album", "radio") => playback::play_album_radio(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id.clone(),
|
|
),
|
|
("album", "favorite") => {
|
|
// Add the album to the user's favorites. The album cards
|
|
// (grid hover heart + the "…" menu) all bubble this; the
|
|
// shared component means one handler covers the app.
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let album_id = id.clone();
|
|
handle.spawn(async move {
|
|
match runtime.core().add_favorite("album", &album_id).await {
|
|
Ok(()) => {
|
|
crate::toast::success_weak(&weak, "Added to favorites");
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] favorite album failed: {e}");
|
|
crate::toast::error_weak(&weak, "Couldn't add to favorites");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
("album", "cache") => offline_cache::cache_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
),
|
|
("track", "play-next") => {
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
playback::play_track_next(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
track_id,
|
|
);
|
|
}
|
|
}
|
|
("track", "favorite") => {
|
|
// Toggle (not just add): read the cached state, flip it
|
|
// optimistically across every visible track model + the
|
|
// shared cache, then add/remove on the network.
|
|
let was_fav = fav_cache::is_favorite(&id);
|
|
let make_fav = !was_fav;
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
fav_cache::set(track_id, make_fav);
|
|
}
|
|
if let Some(w) = weak.upgrade() {
|
|
set_row_favorite(&w, &id, make_fav);
|
|
}
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let track_id = id.clone();
|
|
handle.spawn(async move {
|
|
let res = if make_fav {
|
|
runtime.core().add_favorite("track", &track_id).await
|
|
} else {
|
|
runtime.core().remove_favorite("track", &track_id).await
|
|
};
|
|
if let Err(e) = res {
|
|
log::error!("[qbz-slint] toggle track favorite failed: {e}");
|
|
// Roll the optimistic change back on failure.
|
|
if let Ok(tid) = track_id.parse::<u64>() {
|
|
fav_cache::set(tid, was_fav);
|
|
}
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
set_row_favorite(&w, &track_id, was_fav);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// Offline cache: "download"/"cache" make a track available
|
|
// offline; "uncache" removes the local copy. The row affordance
|
|
// and the context menu both bubble these.
|
|
("track", "cache") | ("track", "download") => {
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
offline_cache::cache_track(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
track_id,
|
|
);
|
|
}
|
|
}
|
|
("track", "uncache") => {
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
offline_cache::remove_cached(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
track_id,
|
|
);
|
|
}
|
|
}
|
|
("track", "create-radio") => playback::play_track_radio(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id.clone(),
|
|
),
|
|
("track", "add-to-playlist") => {
|
|
// Open the global picker for this track + load the
|
|
// user's playlists.
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
playlist_picker::open(&w, &id);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let playlists = playlist_picker::load(&runtime).await;
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::apply(&w, playlists);
|
|
});
|
|
});
|
|
}
|
|
("track", "add-to-mixtape") => {
|
|
// The menu only carries the track id; resolve the Qobuz
|
|
// track (this entry is gated to Qobuz/offline in the menu)
|
|
// to build the AddToMixtape payload, then open the picker.
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle2 = handle.clone();
|
|
handle.spawn(async move {
|
|
let item = match runtime.core().get_track(track_id).await {
|
|
Ok(track) => {
|
|
let artist = track
|
|
.performer
|
|
.as_ref()
|
|
.map(|p| p.name.clone())
|
|
.unwrap_or_default();
|
|
let album = track
|
|
.album
|
|
.as_ref()
|
|
.map(|a| a.title.clone())
|
|
.unwrap_or_default();
|
|
let subtitle = [artist, album]
|
|
.into_iter()
|
|
.filter(|s| !s.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(" · ");
|
|
let artwork_url = track.album.as_ref().and_then(|a| {
|
|
a.image
|
|
.thumbnail
|
|
.clone()
|
|
.or_else(|| a.image.small.clone())
|
|
});
|
|
myqbz_add::AddItem {
|
|
item_type: "track".into(),
|
|
source: "qobuz".into(),
|
|
source_item_id: track_id.to_string(),
|
|
title: track.title.clone(),
|
|
subtitle: (!subtitle.is_empty()).then_some(subtitle),
|
|
artwork_url,
|
|
year: None,
|
|
track_count: None,
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[qbz-slint] add-to-mixtape: get_track {track_id} failed: {e}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
open_add_to_mixtape(weak, handle2, vec![item]);
|
|
});
|
|
}
|
|
}
|
|
("track", "share-qobuz") => {
|
|
share::copy_to_clipboard(share::qobuz_track_url(&id));
|
|
log::info!("[qbz-slint] copied Qobuz link for track {id}");
|
|
}
|
|
("track", "share-songlink") => {
|
|
let source = share::qobuz_track_url(&id);
|
|
let track = id.clone();
|
|
handle.spawn(async move {
|
|
match share::songlink_url(&source).await {
|
|
Some(url) => {
|
|
share::copy_to_clipboard(url);
|
|
log::info!("[qbz-slint] copied Song.link for track {track}");
|
|
}
|
|
None => log::warn!("[qbz-slint] Song.link resolution failed for {track}"),
|
|
}
|
|
});
|
|
}
|
|
("track", "go-to-album") => {
|
|
// The menu only carries the track id — resolve the
|
|
// track to find its album, then open it.
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
if let Ok(track) = runtime.core().get_track(track_id).await {
|
|
if let Some(album_id) =
|
|
track.album.as_ref().map(|a| a.id.clone())
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.invoke_open_album(album_id.into());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
("track", "go-to-artist") => {
|
|
if let Ok(track_id) = id.parse::<u64>() {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
if let Ok(track) = runtime.core().get_track(track_id).await {
|
|
if let Some(artist_id) =
|
|
track.performer.as_ref().map(|p| p.id)
|
|
{
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.invoke_open_artist(artist_id.to_string().into());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
// Clickable artist name (album cards) -> artist page.
|
|
("artist", "open") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_open_artist(id.clone().into());
|
|
}
|
|
}
|
|
// Clickable album name (track rows) -> album page.
|
|
("album", "open") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_open_album(id.clone().into());
|
|
}
|
|
}
|
|
// Now-playing context (song-card layers button) -> playlist page.
|
|
("playlist", "open") => {
|
|
nav::record(nav::NavEntry::Playlist(id.clone()));
|
|
navigate_playlist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
id.clone(),
|
|
);
|
|
}
|
|
// Build Artist Collection — open the Discography Builder for the
|
|
// current artist (the button passes an empty id, so resolve it
|
|
// from ArtistState). Records a history entry then routes.
|
|
("artist", "build-collection") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
let artist_id = if id.is_empty() {
|
|
w.global::<ArtistState>().get_id().to_string()
|
|
} else {
|
|
id.clone()
|
|
};
|
|
if !artist_id.is_empty() {
|
|
nav::record(nav::NavEntry::DiscographyBuilder(artist_id.clone()));
|
|
navigate_discography_builder(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
artist_id,
|
|
);
|
|
update_nav_flags(&w);
|
|
}
|
|
}
|
|
}
|
|
("artist", "play-top") => playback::play_artist_top_tracks(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id.clone(),
|
|
),
|
|
// Artist radio uses the smart qbz-radio pool builder
|
|
// (the Qobuz /radio/artist endpoint remains available
|
|
// via playback::play_artist_radio for an alternative).
|
|
("artist", "radio") => playback::play_smart_artist_radio(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id.clone(),
|
|
),
|
|
("artist", "follow") => {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let artist_id = id.clone();
|
|
handle.spawn(async move {
|
|
match runtime.core().add_favorite("artist", &artist_id).await {
|
|
Ok(()) => {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
search::mark_artist_followed(&w, &artist_id, true);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] follow artist failed: {e}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// === Label landing actions ===============================
|
|
("label", "follow") => {
|
|
// Toggle the label favorite, optimistically flipping the
|
|
// header + any matching More-Labels card.
|
|
if let Some(w) = weak.upgrade() {
|
|
let make = !label::label_following_state(&w, &id);
|
|
label::mark_label_followed(&w, &id, make);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let label_id = id.clone();
|
|
handle.spawn(async move {
|
|
let res = if make {
|
|
runtime.core().add_favorite("label", &label_id).await
|
|
} else {
|
|
runtime.core().remove_favorite("label", &label_id).await
|
|
};
|
|
if let Err(e) = res {
|
|
log::error!("[qbz-slint] toggle label favorite failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
label::mark_label_followed(&w, &label_id, !make);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
("label", "play-top") => {
|
|
// Popular tracks are cached on the UI thread by
|
|
// apply_label_page; read them here (UI thread) + queue.
|
|
let tracks = label::top_tracks_for_play();
|
|
if tracks.is_empty() {
|
|
crate::toast::error_weak(&weak, "No popular tracks for this label");
|
|
} else {
|
|
playback::play_tracks(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
tracks,
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
// More-Labels card click -> open that label's landing.
|
|
("label", "open") => {
|
|
if let Ok(label_id) = id.parse::<u64>() {
|
|
let name = weak
|
|
.upgrade()
|
|
.map(|w| label::more_label_name(&w, &id))
|
|
.unwrap_or_default();
|
|
nav::record(nav::NavEntry::Label {
|
|
id: label_id,
|
|
name: name.clone(),
|
|
});
|
|
navigate_label(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
label_id,
|
|
name,
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
}
|
|
}
|
|
// "See all" -> the full releases sub-view for the open label.
|
|
("label", "see-all-releases") => {
|
|
if let (Some(w), Ok(label_id)) = (weak.upgrade(), id.parse::<u64>()) {
|
|
let name = w.global::<LabelState>().get_name().to_string();
|
|
nav::record(nav::NavEntry::LabelReleases {
|
|
id: label_id,
|
|
name: name.clone(),
|
|
});
|
|
navigate_label_releases(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
label_id,
|
|
name,
|
|
);
|
|
update_nav_flags(&w);
|
|
}
|
|
}
|
|
("track", "toggle-select") => {
|
|
// Flip `selected` on the matching row, in whichever
|
|
// multi-select surface is showing: the playlist detail,
|
|
// the artist Popular Tracks, or the label Popular Tracks.
|
|
if let Some(w) = weak.upgrade() {
|
|
let model = match w.global::<NavState>().get_view() {
|
|
ContentView::Playlist => w.global::<PlaylistState>().get_tracks(),
|
|
ContentView::Label => w.global::<LabelState>().get_top_tracks(),
|
|
ContentView::Favorites => {
|
|
w.global::<FavoritesState>().get_tracks_visible()
|
|
}
|
|
_ => w.global::<ArtistState>().get_top_tracks(),
|
|
};
|
|
if let Some(vm) = model
|
|
.as_any()
|
|
.downcast_ref::<slint::VecModel<TrackItem>>()
|
|
{
|
|
for i in 0..vm.row_count() {
|
|
if let Some(mut item) = vm.row_data(i) {
|
|
if item.id == id.as_str() {
|
|
item.selected = !item.selected;
|
|
vm.set_row_data(i, item);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
match w.global::<NavState>().get_view() {
|
|
ContentView::Playlist => playlist::recount_selected(&w),
|
|
ContentView::Favorites => favorites::recount_selected(&w),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
// The mix tile sends id = mix kind, action = "open".
|
|
("mix", "open") => {
|
|
nav::record(nav::NavEntry::Mix { kind: id.clone() });
|
|
navigate_mix(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
id.clone(),
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
}
|
|
("mix", "play-all") => {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let tracks = mix::current_tracks();
|
|
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
|
});
|
|
}
|
|
("mix", "shuffle") => {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let tracks = mix::shuffled_tracks();
|
|
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
|
});
|
|
}
|
|
("mix", "refresh") => {
|
|
// Re-load the current mix (re-fetch its tracks).
|
|
if let Some(w) = weak.upgrade() {
|
|
let kind = w.global::<MixState>().get_kind().to_string();
|
|
if !kind.is_empty() {
|
|
navigate_mix(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
kind,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
("playlist", "cache") => {
|
|
if let Ok(pid) = id.parse::<u64>() {
|
|
offline_cache::cache_playlist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
pid,
|
|
);
|
|
}
|
|
}
|
|
("playlist", "play-all") => {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let tracks = playlist::current_tracks();
|
|
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
|
});
|
|
}
|
|
("playlist", "shuffle") => {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let tracks = playlist::shuffled_tracks();
|
|
playback::play_tracks(runtime, weak, handle, tracks, 0);
|
|
});
|
|
}
|
|
("playlist", "queue") => playback::enqueue_playlist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
false,
|
|
),
|
|
("playlist", "play-next") => playback::enqueue_playlist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
true,
|
|
),
|
|
("playlist", "favorite") => {
|
|
// Favorite/unfavorite the open playlist.
|
|
if let Some(w) = weak.upgrade() {
|
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
|
let was_fav = w.global::<PlaylistState>().get_is_favorite();
|
|
w.global::<PlaylistState>().set_is_favorite(!was_fav);
|
|
let runtime = runtime.clone();
|
|
handle.spawn(async move {
|
|
let res = if was_fav {
|
|
runtime.core().remove_favorite("playlist", &pid).await
|
|
} else {
|
|
runtime.core().add_favorite("playlist", &pid).await
|
|
};
|
|
if let Err(e) = res {
|
|
log::error!("[qbz-slint] toggle playlist favorite failed: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
("playlist", "select-toggle") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
let on = w.global::<PlaylistState>().get_multi_select_mode();
|
|
playlist::set_multi_select(&w, !on);
|
|
}
|
|
}
|
|
("playlist", "select-all") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
playlist::select_all(&w);
|
|
}
|
|
}
|
|
("playlist", "remove-selected") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
|
let ids = playlist::selected_ids(&w);
|
|
if let (Ok(pid), false) = (pid.parse::<u64>(), ids.is_empty()) {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
match runtime
|
|
.core()
|
|
.remove_tracks_from_playlist(pid, &ids)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
// Reload the playlist + leave edit mode.
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
playlist::set_multi_select(&w, false);
|
|
});
|
|
navigate_playlist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
pid.to_string(),
|
|
);
|
|
}
|
|
Err(e) => log::error!(
|
|
"[qbz-slint] remove tracks from playlist failed: {e}"
|
|
),
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
("playlist", "set-artwork") => {
|
|
// Pick an image, copy it into the artwork cache, store
|
|
// it as the playlist's custom cover, then reload.
|
|
if let Some(w) = weak.upgrade() {
|
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
|
if let Ok(pid) = pid.parse::<u64>() {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
let Some(file) = rfd::AsyncFileDialog::new()
|
|
.add_filter("Images", &["png", "jpg", "jpeg", "webp"])
|
|
.pick_file()
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let src = file.path().to_string_lossy().into_owned();
|
|
let ok = tokio::task::spawn_blocking(move || {
|
|
playlist::set_custom_artwork(pid, &src).is_some()
|
|
})
|
|
.await
|
|
.unwrap_or(false);
|
|
if ok {
|
|
navigate_playlist(
|
|
runtime, weak, &handle, image_cache, pid.to_string(),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
("playlist", "clear-artwork") => {
|
|
if let Some(w) = weak.upgrade() {
|
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
|
if let Ok(pid) = pid.parse::<u64>() {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
playlist::clear_custom_artwork(pid);
|
|
})
|
|
.await
|
|
.ok();
|
|
navigate_playlist(
|
|
runtime, weak, &handle, image_cache, pid.to_string(),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
("playlist", "edit") => {
|
|
// Open the edit modal, prefilled from the open playlist.
|
|
if let Some(w) = weak.upgrade() {
|
|
let ps = w.global::<PlaylistState>();
|
|
let pid = ps.get_id();
|
|
let name = ps.get_name();
|
|
let desc = ps.get_description();
|
|
let es = w.global::<EditPlaylistState>();
|
|
es.set_id(pid);
|
|
es.set_name(name);
|
|
es.set_description(desc);
|
|
es.set_open(true);
|
|
}
|
|
}
|
|
("track", "move-up") | ("track", "move-down") => {
|
|
// Custom-order reorder (playlist view). Optimistic UI
|
|
// move, then persist the full order off-thread.
|
|
if let Some(w) = weak.upgrade() {
|
|
let up = action == "move-up";
|
|
let orders = playlist::move_track(&w, id.as_str(), up);
|
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
|
if !orders.is_empty() {
|
|
if let Ok(pid) = pid.parse::<u64>() {
|
|
handle.spawn(async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
playlist::persist_custom(pid, orders);
|
|
})
|
|
.await
|
|
.ok();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Qobuz Connect — initialize the service singleton and wire the bar's
|
|
// connect/disconnect toggle. The session loop + renderer engine inherit the
|
|
// shared qconnect-app hardening (watchdog/takeover/resync). Transport
|
|
// `*_if_remote` routing + device picker land in the QConnect UI-polish step.
|
|
{
|
|
let _ = qconnect_service::init_service(app_runtime.clone(), window.as_weak());
|
|
let handle = tokio_rt.handle().clone();
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_qconnect_toggle(move || {
|
|
let Some(svc) = qconnect_service::service() else {
|
|
return;
|
|
};
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let connected = if svc.is_running().await {
|
|
if let Err(err) = svc.disconnect().await {
|
|
log::warn!("[QConnect] disconnect failed: {err}");
|
|
}
|
|
false
|
|
} else {
|
|
match svc.connect().await {
|
|
Ok(()) => true,
|
|
Err(err) => {
|
|
log::warn!("[QConnect] connect failed: {err}");
|
|
false
|
|
}
|
|
}
|
|
};
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.global::<NowPlayingState>()
|
|
.set_qconnect_connected(connected);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<QconnectDevState>()
|
|
.on_clear(move || {
|
|
qconnect_service::dev_clear(&weak);
|
|
});
|
|
}
|
|
// Device picker — switch the active renderer (or pull playback back to QBZ
|
|
// via the local id = "Play here"). The topology refresh arrives on the next
|
|
// session event and re-renders the picker + is-remote state.
|
|
{
|
|
let handle = tokio_rt.handle().clone();
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<QconnectDevState>()
|
|
.on_set_active(move |renderer_id| {
|
|
let Some(svc) = qconnect_service::service() else {
|
|
return;
|
|
};
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
if let Err(e) = svc.set_active_renderer(renderer_id).await {
|
|
log::warn!("[QConnect] set_active_renderer({renderer_id}): {e}");
|
|
crate::toast::error_weak(&weak, "Failed to switch renderer");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Transport — wired through the NowPlayingState global callbacks.
|
|
//
|
|
// QConnect CONTROLLER gating: each callback first tries the remote handoff
|
|
// (`*_if_remote`). When a PEER renderer is active the command is forwarded
|
|
// to it and `Ok(true)` short-circuits. In EVERY non-controller situation
|
|
// (disconnected, RENDERER mode where active==local, or no active renderer)
|
|
// the remote method returns `Ok(false)` and the existing local `playback::*`
|
|
// call runs unchanged — see qconnect_service.rs §safety. This cannot regress
|
|
// renderer/local playback because the gate is `is_peer_renderer_active`.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_toggle_play(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
match svc.toggle_remote_renderer_playback_if_active().await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] toggle_play handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::toggle_play_pause(runtime, weak, handle);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<NowPlayingState>().on_next(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
match svc.skip_next_if_remote().await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] next handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::next(runtime, weak, handle);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<NowPlayingState>().on_previous(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
match svc.skip_previous_if_remote().await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] previous handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::previous(runtime, weak, handle);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_seek(move |fraction| {
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
// Remote API wants absolute position in ms; the bar gives
|
|
// a 0..1 fraction. Derive ms from the locally-known
|
|
// duration (seconds). Until Slice 4 reflects the peer's
|
|
// duration on the bar, this is the local track's duration
|
|
// (acceptable interim).
|
|
let fraction = fraction.clamp(0.0, 1.0);
|
|
let duration_secs = runtime.core().get_playback_state().duration;
|
|
let position_ms =
|
|
(fraction as f64 * duration_secs as f64 * 1000.0).round() as i64;
|
|
match svc.set_position_if_remote(position_ms).await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] seek handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::seek(runtime, handle, fraction);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_set_volume(move |fraction| {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
// Remote API wants 0..100; the bar gives a 0..1 fraction.
|
|
let volume = (fraction.clamp(0.0, 1.0) * 100.0).round() as i32;
|
|
match svc.set_volume_if_remote(volume).await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] set_volume handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::set_volume(runtime, weak, handle, fraction);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_toggle_mute(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
// Remote API wants the target value; send the negation of
|
|
// the authoritative local MUTED flag.
|
|
let target = !playback::is_muted();
|
|
match svc.mute_if_remote(target).await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] toggle_mute handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::toggle_mute(runtime, weak, handle);
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_toggle_shuffle(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
match svc.toggle_shuffle_if_remote().await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] toggle_shuffle handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::toggle_shuffle(runtime, weak, handle);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<NowPlayingState>()
|
|
.on_cycle_repeat(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
if let Some(svc) = qconnect_service::service() {
|
|
match svc.cycle_repeat_if_remote().await {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
log::warn!("[QConnect] cycle_repeat handoff: {e}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
playback::cycle_repeat(runtime, weak, handle);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Queue sidebar — build the controller and wire every callback.
|
|
{
|
|
let controller = queue::QueueController::new(
|
|
app_runtime.clone(),
|
|
window.as_weak(),
|
|
tokio_rt.handle().clone(),
|
|
settings_ctx.playback_prefs(),
|
|
);
|
|
// Publish it so the playback paths refresh the sidebar after every
|
|
// queue mutation (play / skip / auto-advance / enqueue).
|
|
playback::set_queue_controller(controller.clone());
|
|
|
|
let qs = window.global::<QueueState>();
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_play_upcoming(move |index| c.play_upcoming(index.max(0) as usize));
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_play_history(move |index| c.play_history(index.max(0) as usize));
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_remove_upcoming(move |index| c.remove_upcoming(index.max(0) as usize));
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_reorder(move |from, to| {
|
|
c.reorder(from.max(0) as usize, to.max(0) as usize);
|
|
});
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_clear_queue(move || c.clear());
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_toggle_now_playing_favorite(move || c.toggle_favorite());
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_save_as_playlist(move || c.save_as_playlist());
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_toggle_infinite_play(move || c.toggle_infinite_play());
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
let weak = window.as_weak();
|
|
qs.on_search_changed(move || {
|
|
let query = weak
|
|
.upgrade()
|
|
.map(|w| w.global::<QueueState>().get_search_query().to_string())
|
|
.unwrap_or_default();
|
|
c.search_changed(query);
|
|
});
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_prev_page(move || c.prev_page());
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_next_page(move || c.next_page());
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
qs.on_set_tab(move |tab| c.set_tab(tab));
|
|
}
|
|
{
|
|
let c = controller.clone();
|
|
// On open, also re-pull favorites so the heart is accurate.
|
|
qs.on_panel_opened(move || c.refresh_with_favorites());
|
|
}
|
|
}
|
|
|
|
// Album track search — client-side filter, no backend round-trip.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<AlbumActions>()
|
|
.on_search(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
album::filter_tracks(&w, query.as_str());
|
|
}
|
|
});
|
|
}
|
|
|
|
// Artist in-page search — client-side filter over Popular Tracks
|
|
// and every release-section album.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<ArtistActions>()
|
|
.on_search(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
artist::filter_artist(&w, query.as_str());
|
|
}
|
|
});
|
|
}
|
|
|
|
// Artist network sidebar — no persistence. Default open, user can
|
|
// close per-session, and reset_network_sidebar reopens it on every
|
|
// artist navigation. The toggle callback stays a no-op on the
|
|
// Rust side — Slint already flips NetworkSidebarState.open
|
|
// directly in the click handler.
|
|
window
|
|
.global::<NetworkSidebarActions>()
|
|
.on_toggle(|| {});
|
|
|
|
// Network sidebar — typed click callbacks. Each delivers the
|
|
// minimum payload the future target views (ArtistsByLocation,
|
|
// LabelReleases, MusicianPage) will need. Logged-only until those
|
|
// views land in Slint.
|
|
// Location click — open ArtistsByLocationView using the cached
|
|
// location params from the Origin metadata (area, genres, tags).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<NetworkSidebarActions>()
|
|
.on_location_clicked(move |mbid| {
|
|
let Some(params) = artist::location_params() else {
|
|
log::warn!(
|
|
"[qbz-slint] location clicked but no cached params (mbid={mbid})"
|
|
);
|
|
return;
|
|
};
|
|
nav::record(nav::NavEntry::Location {
|
|
mbid: params.mbid.clone(),
|
|
area_id: params.area_id.clone(),
|
|
area_name: params.area_name.clone(),
|
|
country: params.country.clone(),
|
|
genres: params.genres.clone(),
|
|
tags: params.tags.clone(),
|
|
});
|
|
navigate_location(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
params,
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
// Label click — open LabelReleasesView.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<NetworkSidebarActions>()
|
|
.on_label_clicked(move |id, name| {
|
|
let Ok(label_id) = id.parse::<u64>() else {
|
|
log::warn!("[qbz-slint] label clicked: invalid id {id}");
|
|
return;
|
|
};
|
|
let name = name.to_string();
|
|
nav::record(nav::NavEntry::Label {
|
|
id: label_id,
|
|
name: name.clone(),
|
|
});
|
|
navigate_label(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
label_id,
|
|
name,
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
// artist-clicked actually navigates — the target view (artist page)
|
|
// already exists in Slint, unlike LabelReleases / ArtistsByLocation /
|
|
// MusicianPage. Same flow as the top-level on_open_artist handler.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<NetworkSidebarActions>()
|
|
.on_artist_clicked(move |id| {
|
|
let artist_id = id.to_string();
|
|
nav::record(nav::NavEntry::Artist(artist_id.clone()));
|
|
navigate_artist(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
artist_id,
|
|
);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
// Musician click — resolve the (name, role) first; if Qobuz has
|
|
// a confirmed exact match, jump straight to that artist's page.
|
|
// Otherwise open MusicianPageView (Contextual / Weak / None).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<NetworkSidebarActions>()
|
|
.on_musician_clicked(move |name, role| {
|
|
let name = name.to_string();
|
|
let role = role.to_string();
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
tokio::spawn(async move {
|
|
let resolved =
|
|
runtime.core().musicbrainz_resolve_musician(&name, &role).await;
|
|
match resolved {
|
|
Ok(r) if matches!(
|
|
r.confidence,
|
|
qbz_integrations::musicbrainz::MusicianConfidence::Confirmed
|
|
) =>
|
|
{
|
|
if let Some(id) = r.qobuz_artist_id {
|
|
let artist_id = id.to_string();
|
|
let weak2 = weak.clone();
|
|
let _ = weak.clone().upgrade_in_event_loop(move |_| {
|
|
nav::record(nav::NavEntry::Artist(artist_id.clone()));
|
|
});
|
|
navigate_artist(
|
|
runtime,
|
|
weak2,
|
|
&handle,
|
|
image_cache,
|
|
id.to_string(),
|
|
);
|
|
return;
|
|
}
|
|
log::warn!(
|
|
"[qbz-slint] musician confirmed but no qobuz id"
|
|
);
|
|
}
|
|
Ok(_) => {
|
|
// Fall through to MusicianPageView for
|
|
// Contextual / Weak / None.
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[qbz-slint] musician resolve failed: {e}");
|
|
}
|
|
}
|
|
nav::record(nav::NavEntry::Musician {
|
|
name: name.clone(),
|
|
role: role.clone(),
|
|
});
|
|
navigate_musician(runtime, weak, &handle, image_cache, name, role);
|
|
});
|
|
});
|
|
}
|
|
// discovery-dismissed — persist the rejection under the current
|
|
// tag, then remove the row from the visible list.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<NetworkSidebarActions>()
|
|
.on_discovery_dismissed(move |mbid, name| {
|
|
if let Some(w) = weak.upgrade() {
|
|
let tag = w
|
|
.global::<NetworkSidebarState>()
|
|
.get_discovery_tag()
|
|
.to_string()
|
|
.to_lowercase();
|
|
if !tag.is_empty() {
|
|
let normalized =
|
|
qbz_core::normalize_artist_name(name.as_str());
|
|
discovery_dismiss::dismiss(&tag, &normalized);
|
|
}
|
|
artist::remove_discovery_artist(&w, mbid.as_str());
|
|
}
|
|
});
|
|
}
|
|
|
|
// Musician appearances pagination — Load more in
|
|
// MusicianPageView appends the next 20 albums onto the existing
|
|
// grid.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<MusicianActions>()
|
|
.on_load_more(move || {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let state = w.global::<MusicianState>();
|
|
let name = state.get_name().to_string();
|
|
let role = state.get_role().to_string();
|
|
let offset = state.get_appearances().row_count() as u32;
|
|
if name.is_empty() {
|
|
return;
|
|
}
|
|
state.set_load_more_loading(true);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
match musician::load_more_appearances(&runtime, &name, &role, offset).await {
|
|
Ok((data, total)) => {
|
|
let jobs: Vec<artwork::ArtworkJob> = data
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, a)| !a.artwork_url.is_empty())
|
|
.map(|(i, a)| artwork::ArtworkJob {
|
|
url: a.artwork_url.clone(),
|
|
target: artwork::ArtworkTarget::MusicianAppearance {
|
|
index: offset as usize + i,
|
|
},
|
|
})
|
|
.collect();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
musician::append_appearances(&w, data, total);
|
|
});
|
|
artwork::spawn_loads(jobs, weak, image_cache);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] musician load-more failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<MusicianState>().set_load_more_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Label album pagination — Load more in LabelReleasesView
|
|
// appends the next page onto the grid.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LabelActions>()
|
|
.on_load_more(move || {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let state = w.global::<LabelState>();
|
|
let Ok(label_id) = state.get_id().to_string().parse::<u64>() else {
|
|
return;
|
|
};
|
|
let offset = state.get_albums().row_count() as u32;
|
|
state.set_load_more_loading(true);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.spawn(async move {
|
|
match label::load_more_albums(&runtime, label_id, offset).await {
|
|
Ok((data, total, has_more)) => {
|
|
let jobs: Vec<artwork::ArtworkJob> = data
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, a)| !a.artwork_url.is_empty())
|
|
.map(|(i, a)| artwork::ArtworkJob {
|
|
url: a.artwork_url.clone(),
|
|
target: artwork::ArtworkTarget::LabelAlbum {
|
|
index: offset as usize + i,
|
|
},
|
|
})
|
|
.collect();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
label::append_albums(&w, data, total, has_more);
|
|
});
|
|
artwork::spawn_loads(jobs, weak, image_cache);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] label load-more failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<LabelState>().set_load_more_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Label releases sub-view toolbar — sort / Hi-Res filter /
|
|
// group-by-artist / search. The markup updates the bound LabelState
|
|
// property first; each callback just re-derives the rendered list
|
|
// (local filter over the loaded catalog).
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<LabelActions>().on_set_sort(move |_| {
|
|
if let Some(w) = weak.upgrade() {
|
|
label::derive_releases(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<LabelActions>().on_set_hires(move |_| {
|
|
if let Some(w) = weak.upgrade() {
|
|
label::derive_releases(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<LabelActions>().on_set_group(move |_| {
|
|
if let Some(w) = weak.upgrade() {
|
|
label::derive_releases(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<LabelActions>().on_search(move |_| {
|
|
if let Some(w) = weak.upgrade() {
|
|
label::derive_releases(&w);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Offline Cache Manager actions.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let handle = tokio_rt.handle().clone();
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window.global::<OfflineManagerActions>().on_open(move || {
|
|
nav::record(nav::NavEntry::OfflineManager);
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<NavState>().set_view(ContentView::OfflineManager);
|
|
update_nav_flags(&w);
|
|
}
|
|
offline_manager::load(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window.global::<OfflineManagerActions>().on_refresh(move || {
|
|
offline_manager::load(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_select_artist(move |name| {
|
|
offline_manager::select_artist(weak.clone(), handle.clone(), name.to_string());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_set_sort(move |i| {
|
|
offline_manager::set_sort(weak.clone(), handle.clone(), i);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_toggle_failed(move || {
|
|
offline_manager::toggle_failed(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_toggle_select(move |id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
offline_manager::toggle_select(&w, &id);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_select_all(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
offline_manager::set_all_selected(&w, true);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_clear_selection(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
offline_manager::set_all_selected(&w, false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_bulk_redownload(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
for id in offline_manager::selected_track_ids(&w) {
|
|
offline_cache::redownload_track(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_bulk_remove(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
for id in offline_manager::selected_track_ids(&w) {
|
|
offline_cache::remove_cached(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_remove_track(move |id| {
|
|
if let Ok(tid) = id.parse::<u64>() {
|
|
offline_cache::remove_cached(runtime.clone(), weak.clone(), handle.clone(), tid);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_remove_album(move |aid| {
|
|
offline_cache::remove_album(weak.clone(), handle.clone(), aid.to_string());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_redownload_track(move |id| {
|
|
if let Ok(tid) = id.parse::<u64>() {
|
|
offline_cache::redownload_track(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
tid,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_redownload_album(move |aid| {
|
|
offline_cache::redownload_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
aid.to_string(),
|
|
false,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_redownload_failed(move |aid| {
|
|
offline_cache::redownload_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
aid.to_string(),
|
|
true,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_set_limit(move |gb| {
|
|
offline_manager::set_limit(weak.clone(), handle.clone(), gb);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = handle.clone();
|
|
window.global::<OfflineManagerActions>().on_clear_all(move || {
|
|
offline_cache::clear_all(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_open_folder(move || {
|
|
offline_cache::open_folder(handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let runtime = runtime.clone();
|
|
let handle = handle.clone();
|
|
window
|
|
.global::<OfflineManagerActions>()
|
|
.on_play_track(move |id| {
|
|
if let Ok(tid) = id.parse::<u64>() {
|
|
playback::play_track_now(runtime.clone(), weak.clone(), handle.clone(), tid);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Scene (location) view actions — open-artist routes to the
|
|
// artist page, load-more validates the next page of candidates.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocationViewActions>()
|
|
.on_open_artist(move |id| {
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_open_artist(id);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocationViewActions>()
|
|
.on_load_more(move || {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let Some(params) = artist::location_params() else {
|
|
return;
|
|
};
|
|
let offset = w.global::<LocationViewState>().get_artists().row_count();
|
|
w.global::<LocationViewState>().set_load_more_loading(true);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.spawn(async move {
|
|
match location_view::load_scene(&runtime, ¶ms, offset).await {
|
|
Ok(data) => {
|
|
let jobs: Vec<artwork::ArtworkJob> = data
|
|
.artists
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, a)| !a.image_url.is_empty())
|
|
.map(|(i, a)| artwork::ArtworkJob {
|
|
url: a.image_url.clone(),
|
|
target: artwork::ArtworkTarget::LocationArtist {
|
|
index: offset + i,
|
|
},
|
|
})
|
|
.collect();
|
|
let total = data.total;
|
|
let artists = data.artists.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
location_view::append_scene(&w, artists, total);
|
|
});
|
|
artwork::spawn_loads(jobs, weak, image_cache);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] scene load-more failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<LocationViewState>().set_load_more_loading(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Discover tab switch (the in-view Home / Editor's Picks / For
|
|
// You pill). Swaps the cached section set + re-fires artwork; For
|
|
// You lazy-loads its dedicated sections on first open.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<HomeActions>()
|
|
.on_select_tab(move |tab| {
|
|
if let Some(w) = weak.upgrade() {
|
|
nav::record(nav::NavEntry::Discover {
|
|
tab: tab.to_string(),
|
|
});
|
|
let jobs = home::select_tab(&w, tab.as_str());
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
update_nav_flags(&w);
|
|
if tab.as_str() == "forYou" {
|
|
ensure_for_you_loaded(&runtime, &weak, &handle, &image_cache);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Case-insensitive substring test backing the searchable QbzSelect
|
|
// (Slint 1.16 has no `contains` builtin). Pure + stateless, so a single
|
|
// registration at setup serves every searchable list.
|
|
window
|
|
.global::<TextUtil>()
|
|
.on_contains_ci(|haystack: slint::SharedString, needle: slint::SharedString| {
|
|
haystack
|
|
.to_lowercase()
|
|
.contains(needle.to_lowercase().as_str())
|
|
});
|
|
|
|
// Genre filter — selection is per context ("discover" / "favorites").
|
|
// Toggling / clearing re-fetches the discover index (discover context)
|
|
// or re-derives the favorites tab (favorites context).
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_set_context(move |ctx| {
|
|
genre_filter::set_context(ctx.as_str());
|
|
if let Some(w) = weak.upgrade() {
|
|
genre_filter::apply_state(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_toggle(move |id| {
|
|
let was_selected = genre_filter::selected_ids()
|
|
.iter()
|
|
.any(|x| x.to_string() == id.as_str());
|
|
if !genre_filter::toggle(id.as_str()) {
|
|
return;
|
|
}
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
genre_filter::apply_state(&w);
|
|
// Favorites: client-side genre filter — re-derive the active
|
|
// favorites tab instead of re-fetching the discover index.
|
|
if genre_filter::current_context() == "favorites" {
|
|
let runtime_f = runtime.clone();
|
|
let weak_f = weak.clone();
|
|
let id_f = id.to_string();
|
|
handle.spawn(async move {
|
|
if !was_selected {
|
|
if let Ok(gid) = id_f.parse::<u64>() {
|
|
genre_filter::load_descendants(&runtime_f, gid).await;
|
|
}
|
|
}
|
|
let _ = weak_f.upgrade_in_event_loop(|w| {
|
|
genre_filter::apply_state(&w);
|
|
if w.global::<FavoritesState>().get_active_tab().as_str() == "albums" {
|
|
favorites::derive_albums(&w);
|
|
} else {
|
|
favorites::derive_tracks(&w);
|
|
}
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
// 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.
|
|
if !was_selected {
|
|
if let Ok(gid) = id.parse::<u64>() {
|
|
genre_filter::load_descendants(&runtime, gid).await;
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
genre_filter::apply_state(&w);
|
|
});
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_toggle_expand(move |id| {
|
|
let now_expanded = genre_filter::toggle_expand(id.as_str());
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
genre_filter::apply_state(&w);
|
|
// Lazy-load the node's children the first time it expands.
|
|
if now_expanded {
|
|
if let Ok(gid) = id.to_string().parse::<u64>() {
|
|
if !genre_filter::children_loaded(gid) {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
genre_filter::load_children(&runtime, gid).await;
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
genre_filter::apply_state(&w);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_search(move |query| {
|
|
genre_filter::set_search(query.as_str());
|
|
if let Some(w) = weak.upgrade() {
|
|
genre_filter::apply_state(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_clear(move || {
|
|
genre_filter::clear();
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
genre_filter::apply_state(&w);
|
|
if genre_filter::current_context() == "favorites" {
|
|
if w.global::<FavoritesState>().get_active_tab().as_str() == "albums" {
|
|
favorites::derive_albums(&w);
|
|
} else {
|
|
favorites::derive_tracks(&w);
|
|
}
|
|
return;
|
|
}
|
|
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 {
|
|
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;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_set_remember(move |v| {
|
|
genre_filter::set_remember(v);
|
|
if let Some(w) = weak.upgrade() {
|
|
genre_filter::apply_state(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<GenreFilterActions>()
|
|
.on_set_advanced(move |v| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
w.global::<GenreFilterState>().set_advanced(v);
|
|
// First time advanced view opens, eager-load every
|
|
// parent's children so the tree shows child counts.
|
|
if v {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
genre_filter::load_all_parent_children(&runtime).await;
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
genre_filter::apply_state(&w);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Header nav-menu navigation — currently routes the Library
|
|
// dropdown rows into Library > Favorites tabs.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window.on_header_menu_navigate(move |route| {
|
|
if route == "home" {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<NavState>().set_view(ContentView::Home);
|
|
}
|
|
return;
|
|
}
|
|
// My QBZ — Mixtapes / Collections index grids (read-only slice).
|
|
// Record history + navigate (loads via myqbz::navigate), mirroring
|
|
// the Favorites / Local Library per-route pattern.
|
|
if route == "myqbz-mixtapes" {
|
|
nav::record(nav::NavEntry::Mixtapes);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
myqbz::navigate(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
qbz_models::mixtape::CollectionKind::Mixtape,
|
|
);
|
|
return;
|
|
}
|
|
if route == "myqbz-collections" {
|
|
nav::record(nav::NavEntry::Collections);
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
myqbz::navigate(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
qbz_models::mixtape::CollectionKind::Collection,
|
|
);
|
|
return;
|
|
}
|
|
// Discover tabs — switch to Home and select the tab. The
|
|
// section sets are already cached from the initial load,
|
|
// so this just swaps the visible set + re-fires artwork.
|
|
if let Some(tab) = route.strip_prefix("discover-") {
|
|
let tab = tab.to_string();
|
|
if let Some(w) = weak.upgrade() {
|
|
nav::record(nav::NavEntry::Discover { tab: tab.clone() });
|
|
w.global::<NavState>().set_view(ContentView::Home);
|
|
let jobs = home::select_tab(&w, &tab);
|
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
|
update_nav_flags(&w);
|
|
if tab == "forYou" {
|
|
ensure_for_you_loaded(&runtime, &weak, &handle, &image_cache);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if let Some(tab) = favorites::FavTab::from_route(route.as_str()) {
|
|
let tab_id = route.strip_prefix("favorites-").unwrap_or("tracks");
|
|
nav::record(nav::NavEntry::Favorites {
|
|
tab: tab_id.to_string(),
|
|
});
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
navigate_favorites(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
tab,
|
|
tab_id,
|
|
);
|
|
return;
|
|
}
|
|
// Local Library tabs — same per-tab history pattern as Favorites.
|
|
if let Some(tab) = local_library::LibTab::from_route(route.as_str()) {
|
|
nav::record(nav::NavEntry::LocalLibrary {
|
|
tab: tab.tab_id().to_string(),
|
|
});
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
navigate_local_library(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
tab,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Local Library — in-view tab bar (select-tab) + the gear button
|
|
// (open-settings -> Settings > Local Library). Same per-tab history
|
|
// pattern as Favorites.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_select_tab(move |tab_id| {
|
|
if let Some(tab) = local_library::LibTab::from_tab_id(tab_id.as_str()) {
|
|
nav::record(nav::NavEntry::LocalLibrary {
|
|
tab: tab.tab_id().to_string(),
|
|
});
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
navigate_local_library(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
tab,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_open_settings(move || {
|
|
// Management/maintenance/danger live under Settings > Local
|
|
// Library — pre-select that sub-section (index 4). The panel's
|
|
// `init` lazy-loads the folder list.
|
|
nav::record(nav::NavEntry::Settings);
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<SettingsState>().set_section(4);
|
|
w.global::<NavState>().set_view(ContentView::Settings);
|
|
update_nav_flags(&w);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Settings > Local Library — folder management + maintenance + danger.
|
|
// (Scan callbacks scan-all/scan-folder/stop-scan are wired with Slice B.)
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_load(move || local_library_settings::load_folders(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_add_folder(move || local_library_settings::add_folder(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_remove_folders(move || {
|
|
local_library_settings::remove_folders(weak.clone(), handle.clone())
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_remove_folder(move |id| {
|
|
local_library_settings::remove_folder(weak.clone(), handle.clone(), id as i64)
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_toggle_folder_select(move |id| {
|
|
local_library_settings::toggle_select(weak.clone(), id as i64)
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_edit_folder(move |id| {
|
|
local_library_settings::edit_folder(weak.clone(), handle.clone(), id as i64)
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_save_folder_settings(move |id, alias, enabled, is_network, fs_type, user_override| {
|
|
local_library_settings::save_folder_settings(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id as i64,
|
|
alias.to_string(),
|
|
enabled,
|
|
is_network,
|
|
fs_type.to_string(),
|
|
user_override,
|
|
)
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_change_folder_path(move |id| {
|
|
local_library_settings::change_folder_path(weak.clone(), handle.clone(), id as i64)
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_cleanup_missing(move || {
|
|
local_library_settings::cleanup_missing(weak.clone(), handle.clone())
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_clear_library(move || {
|
|
local_library_settings::clear_library(weak.clone(), handle.clone())
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_set_filter(move |_q| local_library_settings::set_filter(weak.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_scan_all(move || local_library_settings::scan_all(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_scan_folder(move |id| {
|
|
local_library_settings::scan_folder(weak.clone(), handle.clone(), id as i64)
|
|
});
|
|
}
|
|
{
|
|
window
|
|
.global::<LibraryManageActions>()
|
|
.on_stop_scan(move || local_library_settings::stop_scan());
|
|
}
|
|
|
|
// Settings > Plex — connection + library selection (LAN-only). The PIN
|
|
// poll, ping, library sync, and disconnect/clear-cache all live in
|
|
// `plex_auth`; the persisted store is the per-user `plex_settings.db`.
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_load(move || plex_auth::load(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_enable_toggle(move |b| plex_auth::enable_toggle(weak.clone(), handle.clone(), b));
|
|
}
|
|
{
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_collapse_toggle(move |b| plex_auth::collapse_toggle(b));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_set_server_url(move |url| plex_auth::set_server_url(weak.clone(), url.to_string()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_generate_code(move || plex_auth::generate_code(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_open_auth_url(move || plex_auth::open_auth_url(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_copy_code(move || plex_auth::copy_code(weak.clone()));
|
|
}
|
|
{
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_manual_token_toggle(move |b| plex_auth::manual_token_toggle(b));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_set_token(move |tok| plex_auth::set_token(weak.clone(), handle.clone(), tok.to_string()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_ping(move || plex_auth::ping(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_load_sections(move || plex_auth::load_sections(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_toggle_section(move |key| {
|
|
plex_auth::toggle_section(weak.clone(), handle.clone(), key.to_string())
|
|
});
|
|
}
|
|
{
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_metadata_write_toggle(move |b| plex_auth::metadata_write_toggle(b));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_disconnect(move || plex_auth::disconnect(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlexAuthActions>()
|
|
.on_clear_cache(move || plex_auth::clear_cache(weak.clone(), handle.clone()));
|
|
}
|
|
|
|
// Tag editor (local album metadata) — open via on_media_action("album",
|
|
// "edit"); these wire the modal's own actions.
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_close(move || tag_editor::close_tag_editor(weak.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_save(move || tag_editor::save_tags(weak.clone(), handle.clone(), image_cache.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_set_persistence(move |i| {
|
|
if let Some(w) = weak.upgrade() {
|
|
let s = w.global::<TagEditorState>();
|
|
// Ignore selecting Direct when unavailable (CUE album).
|
|
if i == 1 && !s.get_can_direct_write() {
|
|
s.set_persistence_index(0);
|
|
} else {
|
|
s.set_persistence_index(i);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_set_provider(move |i| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<TagEditorState>().set_remote_provider_index(i);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_search_remote(move || tag_editor::search_remote(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_select_result(move |id| tag_editor::select_result(weak.clone(), id.to_string()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_apply_remote(move || tag_editor::apply_remote(weak.clone(), handle.clone()));
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<TagEditorActions>()
|
|
.on_open_in_browser(move || tag_editor::open_in_browser(weak.clone()));
|
|
}
|
|
|
|
// Dedicated Local album view actions (play / shuffle / edit / add / version).
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<LocalAlbumActions>().on_play_all(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let tracks = local_library::current_album_version_tracks(&w);
|
|
playback::play_local_tracks(runtime.clone(), weak.clone(), handle.clone(), tracks, 0, false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<LocalAlbumActions>().on_shuffle(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let tracks = local_library::current_album_version_tracks(&w);
|
|
playback::play_local_tracks(runtime.clone(), weak.clone(), handle.clone(), tracks, 0, true);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<LocalAlbumActions>().on_play_track(move |id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
let tracks = local_library::current_album_version_tracks(&w);
|
|
let start = id
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(|tid| tracks.iter().position(|t| t.id == tid))
|
|
.unwrap_or(0);
|
|
playback::play_local_tracks(runtime.clone(), weak.clone(), handle.clone(), tracks, start, false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<LocalAlbumActions>().on_edit_tags(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let idx = w.global::<LocalAlbumState>().get_version_index();
|
|
if let Some(dir) = local_library::album_version_dir(idx) {
|
|
tag_editor::open_tag_editor(weak.clone(), handle.clone(), dir.clone(), dir);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<LocalAlbumActions>().on_add_to_playlist(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let tracks = local_library::current_album_version_tracks(&w);
|
|
let ids: Vec<String> = tracks
|
|
.iter()
|
|
.filter(|t| t.source.as_deref() != Some("plex"))
|
|
.map(|t| t.id.to_string())
|
|
.collect();
|
|
if !ids.is_empty() {
|
|
playlist_picker::open_multi(&w, &ids, true);
|
|
let runtime = runtime.clone();
|
|
let weak2 = weak.clone();
|
|
handle.spawn(async move {
|
|
let pls = playlist_picker::load(&runtime).await;
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::apply(&w, pls);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Add the whole local/Plex album to a Mixtape/Collection. Builds the
|
|
// `album` payload (source "local", no artwork_url — 1:1 PSD) from the
|
|
// LocalAlbumState header + the current version's track count.
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<LocalAlbumActions>().on_add_to_mixtape(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let st = w.global::<LocalAlbumState>();
|
|
let id = st.get_id().to_string();
|
|
if id.is_empty() {
|
|
return;
|
|
}
|
|
let tracks = local_library::current_album_version_tracks(&w);
|
|
let item = myqbz_add::AddItem {
|
|
item_type: "album".into(),
|
|
source: "local".into(),
|
|
source_item_id: id,
|
|
title: st.get_title().to_string(),
|
|
subtitle: {
|
|
let a = st.get_artist().to_string();
|
|
(!a.is_empty()).then_some(a)
|
|
},
|
|
artwork_url: None,
|
|
year: None,
|
|
track_count: (!tracks.is_empty()).then_some(tracks.len() as i32),
|
|
};
|
|
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<LocalAlbumActions>().on_select_version(move |i| {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::apply_album_version(&w, i);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<LocalAlbumActions>().on_search(move |q| {
|
|
local_library::search_album(weak.clone(), q.to_string());
|
|
});
|
|
}
|
|
|
|
// Local Library — Albums tab controls (search / sort re-query page 1;
|
|
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
|
// open / per-card actions (album-detail + playback land with later slices).
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_search(move |_query| {
|
|
// Two-way bound to albums-search; re-derive in memory (full-load).
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::derive_albums(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_set_sort(move |sort| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<LocalLibraryState>().set_albums_sort(sort);
|
|
local_library::derive_albums(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_set_group(move |mode| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<LocalLibraryState>().set_albums_group(mode);
|
|
local_library::derive_albums(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_set_view(move |mode| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<LocalLibraryState>().set_albums_view_mode(mode);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_filter_changed(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::derive_albums(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_clear_filter(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::clear_album_filter(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_albums_retry(move || {
|
|
local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone());
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_open_album(move |id| {
|
|
nav::record(nav::NavEntry::LocalAlbum(id.to_string()));
|
|
navigate_local_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
id.to_string(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_open_artist(move |name| {
|
|
// `name` is the artist NAME (local/Plex artists have no id).
|
|
open_local_artist(&runtime, &weak, &handle, &image_cache, name.to_string());
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_album_action(move |id, action| {
|
|
if action.as_str() == "play" {
|
|
// The whole album becomes the queue and auto-advances.
|
|
playback::play_local_album(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
id.to_string(),
|
|
None,
|
|
);
|
|
} else {
|
|
// play-next / queue land with a later slice.
|
|
log::debug!("[qbz-slint] local album action (queue slice pending): {id} {action}");
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_tracks_search(move |_query| {
|
|
// The query is two-way bound to tracks-search; reload page 1.
|
|
local_library::reload_tracks(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_tracks_load_more(move || {
|
|
local_library::load_more_tracks(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_tracks_retry(move || {
|
|
local_library::reload_tracks(weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_track_action(move |id, action| {
|
|
match action.as_str() {
|
|
"play" => {
|
|
if let Ok(row_id) = id.parse::<i64>() {
|
|
// Queue the already-loaded rows (instant — no DB
|
|
// re-query / cover-fill that delayed the queue) so
|
|
// playback continues down the list from the click.
|
|
let tracks = local_library::tracks_current_snapshot();
|
|
if !tracks.is_empty() {
|
|
let start = tracks
|
|
.iter()
|
|
.position(|t| t.id == row_id)
|
|
.unwrap_or(0);
|
|
playback::play_local_tracks(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
tracks,
|
|
start,
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
"toggle-select" => {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::toggle_track_select(&w, id.as_str());
|
|
}
|
|
}
|
|
"play-next" | "queue" => {
|
|
// Resolve the row from the loaded cache (no DB) and enqueue.
|
|
if let Some(row) = local_library::local_track_by_id(id.as_str()) {
|
|
playback::enqueue_local_tracks(
|
|
runtime.clone(),
|
|
handle.clone(),
|
|
vec![row],
|
|
action.as_str() == "play-next",
|
|
);
|
|
}
|
|
}
|
|
_ => {
|
|
log::debug!("[qbz-slint] unhandled local track action: {id} {action}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Tracks tab: group-by + multi-select + bulk ----
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_tracks_set_group(move |mode| {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::set_tracks_group(&w, mode.as_str());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_tracks_toggle_multi_select(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let on = w.global::<LocalLibraryState>().get_tracks_multi_select();
|
|
local_library::set_tracks_multi_select(&w, !on);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_tracks_bulk_action(move |action| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
match action.as_str() {
|
|
"select-all" => local_library::select_all_tracks(&w),
|
|
"clear" => local_library::clear_tracks_selection(&w),
|
|
"queue" => {
|
|
let rows = local_library::selected_local_tracks(&w);
|
|
playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, false);
|
|
local_library::clear_tracks_selection(&w);
|
|
}
|
|
"play-next" => {
|
|
let rows = local_library::selected_local_tracks(&w);
|
|
playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, true);
|
|
local_library::clear_tracks_selection(&w);
|
|
}
|
|
"add-to-playlist" => {
|
|
// Plex rows can't be added (no local add path); skip them,
|
|
// deciding on the underlying LocalTrack.source.
|
|
let rows = local_library::selected_local_tracks(&w);
|
|
let ids: Vec<String> = rows
|
|
.iter()
|
|
.filter(|t| t.source.as_deref() != Some("plex"))
|
|
.map(|t| t.id.to_string())
|
|
.collect();
|
|
let skipped = rows.len() - ids.len();
|
|
if skipped > 0 {
|
|
log::info!("[qbz-slint] add-to-playlist skipped {skipped} Plex track(s)");
|
|
}
|
|
if !ids.is_empty() {
|
|
playlist_picker::open_multi(&w, &ids, true);
|
|
let runtime = runtime.clone();
|
|
let weak2 = weak.clone();
|
|
handle.spawn(async move {
|
|
let playlists = playlist_picker::load(&runtime).await;
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::apply(&w, playlists);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
"add-to-mixtape" => {
|
|
// All selected tracks (Plex INCLUDED — Plex rows are
|
|
// stored as source "local" in the mixtape contract).
|
|
let rows = local_library::selected_local_tracks(&w);
|
|
let items = myqbz_add::track_items_from_local(&rows);
|
|
if !items.is_empty() {
|
|
open_add_to_mixtape(weak.clone(), handle.clone(), items);
|
|
local_library::clear_tracks_selection(&w);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Folders tree rail: search / collapse / multi-select ----
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_tree_search(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::folders_tree_search(&w, query.as_str());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_collapse_all(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::collapse_all_tree(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_toggle_select_mode(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::toggle_tree_select_mode(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_toggle_folder_select(move |path| {
|
|
local_library::toggle_tree_folder_select(weak.clone(), handle.clone(), path.to_string());
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_toggle_track_select(move |path| {
|
|
local_library::toggle_tree_track_select(weak.clone(), handle.clone(), path.to_string());
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_bulk_action(move |action| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
match action.as_str() {
|
|
"select-all" => {
|
|
local_library::tree_select_all(weak.clone(), handle.clone());
|
|
}
|
|
"clear" => local_library::tree_clear_selection(&w),
|
|
"queue" => {
|
|
let rows = local_library::tree_selected_snapshot();
|
|
playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, false);
|
|
local_library::tree_clear_selection(&w);
|
|
}
|
|
"play-next" => {
|
|
let rows = local_library::tree_selected_snapshot();
|
|
playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, true);
|
|
local_library::tree_clear_selection(&w);
|
|
}
|
|
"add-to-playlist" => {
|
|
let rows = local_library::tree_selected_snapshot();
|
|
let ids: Vec<String> = rows
|
|
.iter()
|
|
.filter(|t| t.source.as_deref() != Some("plex"))
|
|
.map(|t| t.id.to_string())
|
|
.collect();
|
|
if !ids.is_empty() {
|
|
playlist_picker::open_multi(&w, &ids, true);
|
|
let runtime = runtime.clone();
|
|
let weak2 = weak.clone();
|
|
handle.spawn(async move {
|
|
let playlists = playlist_picker::load(&runtime).await;
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::apply(&w, playlists);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
"add-to-mixtape" => {
|
|
// All selected tracks (Plex included — stored as "local").
|
|
let rows = local_library::tree_selected_snapshot();
|
|
let items = myqbz_add::track_items_from_local(&rows);
|
|
if !items.is_empty() {
|
|
open_add_to_mixtape(weak.clone(), handle.clone(), items);
|
|
local_library::tree_clear_selection(&w);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Folders tab actions ----
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_search(move |_query| {
|
|
// Query is two-way bound to folders-search; re-derive in place.
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::derive_folders(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_set_sort(move |sort| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<LocalLibraryState>().set_folders_sort(sort);
|
|
local_library::derive_folders(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_set_group(move |group| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<LocalLibraryState>().set_folders_group(group);
|
|
local_library::derive_folders(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_set_mode(move |mode| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<LocalLibraryState>()
|
|
.set_folders_view_mode(mode.clone());
|
|
}
|
|
// Lazy-load the tree roots the first time tree mode is shown.
|
|
if mode.as_str() == "tree" {
|
|
local_library::ensure_folder_tree_loaded(weak.clone(), handle.clone());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_toggle_node(move |path, expand| {
|
|
local_library::toggle_folder_node(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
path.to_string(),
|
|
expand,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_select(move |path, segment| {
|
|
local_library::select_folder(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
path.to_string(),
|
|
segment.to_string(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folder_detail_search(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::folder_detail_search(&w, query.as_str());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_play_node(move |path| {
|
|
playback::play_local_folder_recursive(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
path.to_string(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_folders_play_track(move |id| {
|
|
if let Ok(row_id) = id.parse::<i64>() {
|
|
let path = weak
|
|
.upgrade()
|
|
.map(|w| {
|
|
w.global::<LocalLibraryState>()
|
|
.get_folders_selected_path()
|
|
.to_string()
|
|
})
|
|
.unwrap_or_default();
|
|
if !path.is_empty() {
|
|
playback::play_local_folder_tracks_from(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
path,
|
|
row_id,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Ephemeral folder actions ----
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_ephemeral_open(move || {
|
|
local_library::open_ephemeral(runtime.clone(), weak.clone(), handle.clone());
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_ephemeral_play_all(move || {
|
|
playback::ephemeral_play_or_prompt(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
"all".to_string(),
|
|
String::new(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_ephemeral_play_track(move |id| {
|
|
playback::ephemeral_play_or_prompt(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
"track".to_string(),
|
|
id.to_string(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_ephemeral_play_album(move |key| {
|
|
playback::ephemeral_play_or_prompt(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
"album".to_string(),
|
|
key.to_string(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_ephemeral_clear(move || {
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
// Stop a playing ephemeral track before dropping the session
|
|
// so its (about-to-be-reused) id can't false-highlight rows.
|
|
playback::wipe_ephemeral_if_playing(&runtime, &weak).await;
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
local_library::clear_ephemeral(&w);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
// Ephemeral "already playing" choice modal — clear-and-play vs add-to-queue.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<EphemeralPlayChoiceActions>()
|
|
.on_replace(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let s = w.global::<EphemeralPlayChoiceState>();
|
|
let kind = s.get_intent_kind().to_string();
|
|
let arg = s.get_intent_arg().to_string();
|
|
s.set_open(false);
|
|
playback::ephemeral_play(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
kind,
|
|
arg,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<EphemeralPlayChoiceActions>()
|
|
.on_enqueue(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let s = w.global::<EphemeralPlayChoiceState>();
|
|
let kind = s.get_intent_kind().to_string();
|
|
let arg = s.get_intent_arg().to_string();
|
|
s.set_open(false);
|
|
playback::ephemeral_enqueue(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
kind,
|
|
arg,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<EphemeralPlayChoiceActions>()
|
|
.on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<EphemeralPlayChoiceState>().set_open(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Restore a previously-open ephemeral folder (re-scans the path; does NOT
|
|
// switch the landing view). Runs once at startup.
|
|
local_library::rehydrate_ephemeral(window.as_weak(), tokio_rt.handle().clone());
|
|
|
|
// ---- Artists tab actions ----
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_artists_search(move |_query| {
|
|
// Query is two-way bound to artists-search; re-derive in place.
|
|
if let Some(w) = weak.upgrade() {
|
|
local_library::derive_artists(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<LocalLibraryActions>()
|
|
.on_artists_select(move |name| {
|
|
local_library::select_local_artist(
|
|
weak.clone(),
|
|
handle.clone(),
|
|
image_cache.clone(),
|
|
name.to_string(),
|
|
);
|
|
});
|
|
}
|
|
|
|
// 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
|
|
// chosen playlist; close dismisses.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlaylistPickerActions>()
|
|
.on_pick(move |playlist_id| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let picker = w.global::<PlaylistPickerState>();
|
|
let is_local = picker.get_local_mode();
|
|
// Bulk add carries track-ids; single add carries track-id.
|
|
let ids_model = picker.get_track_ids();
|
|
let track_id_single = picker.get_track_id().to_string();
|
|
picker.set_open(false);
|
|
let Ok(pid) = playlist_id.parse::<u64>() else {
|
|
return;
|
|
};
|
|
|
|
if is_local {
|
|
// Local row ids are i64 -> add_local_track_to_playlist.
|
|
let ids_i64: Vec<i64> = (0..ids_model.row_count())
|
|
.filter_map(|i| ids_model.row_data(i))
|
|
.filter_map(|s| s.parse::<i64>().ok())
|
|
.collect();
|
|
if ids_i64.is_empty() {
|
|
return;
|
|
}
|
|
handle.spawn(async move {
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
crate::library_db::with_db(|db| {
|
|
for (pos, lid) in ids_i64.iter().enumerate() {
|
|
db.add_local_track_to_playlist(pid, *lid, pos as i32)?;
|
|
}
|
|
Ok(())
|
|
})
|
|
})
|
|
.await;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Qobuz path.
|
|
let mut ids: Vec<u64> = (0..ids_model.row_count())
|
|
.filter_map(|i| ids_model.row_data(i))
|
|
.filter_map(|s| s.parse::<u64>().ok())
|
|
.collect();
|
|
if ids.is_empty() {
|
|
if let Ok(tid) = track_id_single.parse::<u64>() {
|
|
ids.push(tid);
|
|
}
|
|
}
|
|
if ids.is_empty() {
|
|
return;
|
|
}
|
|
let runtime = runtime.clone();
|
|
handle.spawn(async move {
|
|
if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &ids).await {
|
|
log::error!("[qbz-slint] add to playlist failed: {e}");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<PlaylistPickerActions>()
|
|
.on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<PlaylistPickerState>().set_open(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Track drag onto sidebar playlists (a star QBZ feature).
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<DragActions>().on_start(
|
|
move |track_id, title, subtitle, gx, gy| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
log::info!("[qbz-slint][drag] start gx={gx} gy={gy} (cursor should be here)");
|
|
let ids = gather_drag_ids(&w, track_id.as_str());
|
|
drag::set_dragged(ids.clone());
|
|
let ds = w.global::<DragState>();
|
|
ds.set_count(ids.len() as i32);
|
|
ds.set_ghost_title(title);
|
|
ds.set_ghost_subtitle(subtitle);
|
|
ds.set_pointer_x(gx);
|
|
ds.set_pointer_y(gy);
|
|
ds.set_over_playlist_id("".into());
|
|
ds.set_active(true);
|
|
},
|
|
);
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window.global::<DragActions>().on_move(move |gx, gy| {
|
|
if let Some(w) = weak.upgrade() {
|
|
let ds = w.global::<DragState>();
|
|
ds.set_pointer_x(gx);
|
|
ds.set_pointer_y(gy);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window.global::<DragActions>().on_end(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ds = w.global::<DragState>();
|
|
let pid = ds.get_over_playlist_id().to_string();
|
|
ds.set_active(false);
|
|
ds.set_over_playlist_id("".into());
|
|
let ids = drag::dragged();
|
|
drag::clear();
|
|
if let (Ok(pid), false) = (pid.parse::<u64>(), ids.is_empty()) {
|
|
let runtime = runtime.clone();
|
|
handle.spawn(async move {
|
|
match runtime.core().add_tracks_to_playlist(pid, &ids).await {
|
|
Ok(()) => log::info!(
|
|
"[qbz-slint] dropped {} track(s) onto playlist {pid}",
|
|
ids.len()
|
|
),
|
|
Err(e) => log::error!("[qbz-slint] drop add to playlist failed: {e}"),
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Playlist in-page track search (client-side filter).
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<PlaylistActions>()
|
|
.on_search(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
playlist::filter_tracks(&w, query.as_str());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<PlaylistActions>()
|
|
.on_set_sort(move |field| {
|
|
let Some(w) = weak.upgrade() else { return; };
|
|
playlist::set_sort(&w, field.as_str());
|
|
// Entering custom: load (or seed) the local order, then
|
|
// re-render. Off-thread (opens library.db).
|
|
if field.as_str() == "custom" {
|
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
|
if let Ok(pid) = pid.parse::<u64>() {
|
|
let ids = playlist::current_track_ids();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let orders = tokio::task::spawn_blocking(move || {
|
|
playlist::load_or_init_custom(pid, ids)
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist::apply_custom_order(&w, orders);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Edit playlist (rename / delete).
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<EditPlaylistActions>()
|
|
.on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<EditPlaylistState>().set_open(false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Rename the playlist, then refresh the detail header + sidebar.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<EditPlaylistActions>()
|
|
.on_save(move || {
|
|
let Some(w) = weak.upgrade() else { return; };
|
|
let es = w.global::<EditPlaylistState>();
|
|
let name = es.get_name().to_string();
|
|
let description = es.get_description().to_string();
|
|
let id = es.get_id().to_string();
|
|
if name.trim().is_empty() || es.get_busy() {
|
|
return;
|
|
}
|
|
let (Ok(pid),) = (id.parse::<u64>(),) else { return; };
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let desc_opt = Some(description.trim());
|
|
match runtime
|
|
.core()
|
|
.update_playlist(pid, Some(name.trim()), desc_opt, None)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
let nm = name.trim().to_string();
|
|
let ds = description.trim().to_string();
|
|
let r2 = runtime.clone();
|
|
let w2 = weak.clone();
|
|
let h2 = handle.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.global::<PlaylistState>().set_name(nm.into());
|
|
w.global::<PlaylistState>().set_description(ds.into());
|
|
w.global::<EditPlaylistState>().set_open(false);
|
|
load_sidebar_playlists(r2, w2, &h2);
|
|
});
|
|
}
|
|
Err(e) => log::error!("[qbz-slint] update playlist failed: {e}"),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Delete the playlist, then navigate back + refresh the sidebar.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<EditPlaylistActions>()
|
|
.on_delete(move || {
|
|
let Some(w) = weak.upgrade() else { return; };
|
|
let id = w.global::<EditPlaylistState>().get_id().to_string();
|
|
let Ok(pid) = id.parse::<u64>() else { return; };
|
|
w.global::<EditPlaylistState>().set_busy(true);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
match runtime.core().delete_playlist(pid).await {
|
|
Ok(()) => {
|
|
let r2 = runtime.clone();
|
|
let w2 = weak.clone();
|
|
let h2 = handle.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.global::<EditPlaylistState>().set_busy(false);
|
|
w.global::<EditPlaylistState>().set_open(false);
|
|
load_sidebar_playlists(r2, w2, &h2);
|
|
w.global::<NavState>().invoke_request_back();
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] delete playlist failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<EditPlaylistState>().set_busy(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Sidebar playlists — open a row, or create a new 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::<SidebarActions>()
|
|
.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);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Populate the collapsed-sidebar folder flyout's playlist list.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_load_folder_popup(move |folder_id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
sidebar::load_folder_popup(&w, folder_id.as_str());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_create_playlist(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<CreateFolderActions>()
|
|
.on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<CreateFolderState>().set_open(false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Create a folder, then refresh the sidebar.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<CreateFolderActions>()
|
|
.on_submit(move || {
|
|
let Some(w) = weak.upgrade() else { return; };
|
|
let name = w.global::<CreateFolderState>().get_name().to_string();
|
|
if name.trim().is_empty() || w.global::<CreateFolderState>().get_creating() {
|
|
return;
|
|
}
|
|
w.global::<CreateFolderState>().set_creating(true);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let nm = name.trim().to_string();
|
|
tokio::task::spawn_blocking(move || {
|
|
folders::create_folder(&nm);
|
|
})
|
|
.await
|
|
.ok();
|
|
let r2 = runtime.clone();
|
|
let w2 = weak.clone();
|
|
let h2 = handle.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.global::<CreateFolderState>().set_creating(false);
|
|
w.global::<CreateFolderState>().set_open(false);
|
|
load_sidebar_playlists(r2, w2, &h2);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Toggle a folder's expanded state (cheap, rebuilds from cache).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_toggle_folder(move |id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
sidebar::toggle_folder(&w, id.as_str());
|
|
refresh_sidebar_covers(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Open the create-folder modal.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_create_folder(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<CreateFolderState>().set_name("".into());
|
|
w.global::<CreateFolderState>().set_creating(false);
|
|
w.global::<CreateFolderState>().set_open(true);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Delete a folder (its playlists fall back to root via the
|
|
// library DB's ON DELETE SET NULL), then reload the sidebar.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_delete_folder(move |id| {
|
|
let id = id.to_string();
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
let fid = id.clone();
|
|
tokio::task::spawn_blocking(move || folders::delete_folder(&fid))
|
|
.await
|
|
.ok();
|
|
load_sidebar_playlists(runtime, weak, &handle);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Move a playlist into a folder ("" = root). Optimistic local
|
|
// rebuild + a DB write.
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_move_playlist(move |playlist_id, folder_id| {
|
|
let Some(w) = weak.upgrade() else { return; };
|
|
let Ok(pid) = playlist_id.parse::<u64>() else { return; };
|
|
let fid = folder_id.to_string();
|
|
sidebar::move_playlist_local(&w, pid, &fid);
|
|
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()) };
|
|
folders::move_playlist(pid, opt);
|
|
})
|
|
.await
|
|
.ok();
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// 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);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Edit playlist (sidebar context menu) — open the edit-playlist
|
|
// modal, prefilled from the cached name + description.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_edit_playlist(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let (name, description) = id
|
|
.parse::<u64>()
|
|
.ok()
|
|
.and_then(sidebar::playlist_name_desc)
|
|
.unwrap_or_else(|| (id.to_string(), String::new()));
|
|
let es = w.global::<EditPlaylistState>();
|
|
es.set_id(id);
|
|
es.set_name(name.into());
|
|
es.set_description(description.into());
|
|
es.set_busy(false);
|
|
es.set_open(true);
|
|
});
|
|
}
|
|
{
|
|
// Edit folder (sidebar context menu) — open the folder editor.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_edit_folder(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
open_folder_editor(&w, id);
|
|
});
|
|
}
|
|
{
|
|
// Filter the move-to-folder menu list by a substring query.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_search_folders(move |query| {
|
|
if let Some(w) = weak.upgrade() {
|
|
sidebar::search_menu_folders(&w, query.as_str());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Hide playlist from the sidebar (local setting), then reload.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_hide_playlist(move |id| {
|
|
let Ok(pid) = id.parse::<u64>() else { return };
|
|
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, true))
|
|
.await
|
|
.ok();
|
|
load_sidebar_playlists(runtime, weak, &handle);
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Hide folder from the sidebar (local setting), then reload.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<SidebarActions>()
|
|
.on_hide_folder(move |id| {
|
|
let fid = id.to_string();
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
handle.clone().spawn(async move {
|
|
tokio::task::spawn_blocking(move || folders::set_folder_hidden(&fid, true))
|
|
.await
|
|
.ok();
|
|
load_sidebar_playlists(runtime, weak, &handle);
|
|
});
|
|
});
|
|
}
|
|
|
|
// === Playlist Manager actions ======================================
|
|
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
|
|
wire_myqbz(&window, &app_runtime, &tokio_rt, &image_cache);
|
|
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
|
|
wire_disco_builder(&window, &tokio_rt, &image_cache);
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<CreatePlaylistActions>()
|
|
.on_close(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<CreatePlaylistState>().set_open(false);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Create the playlist, then refresh the sidebar + open it.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<CreatePlaylistActions>()
|
|
.on_submit(move || {
|
|
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;
|
|
}
|
|
state.set_creating(true);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
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();
|
|
let ic2 = image_cache.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
w.global::<CreatePlaylistState>().set_creating(false);
|
|
w.global::<CreatePlaylistState>().set_open(false);
|
|
load_sidebar_playlists(r2.clone(), weak2.clone(), &h2);
|
|
nav::record(nav::NavEntry::Playlist(new_id.clone()));
|
|
navigate_playlist(r2, weak2.clone(), &h2, ic2, new_id);
|
|
update_nav_flags(&w);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] create playlist failed: {e}");
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<CreatePlaylistState>().set_creating(false);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// handler.
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_select_tab(move |id| {
|
|
let Some(tab) = favorites::FavTab::from_tab_id(id.as_str()) else {
|
|
// Playlists / Labels: just switch the visible tab,
|
|
// their content is not implemented yet.
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_active_tab(id);
|
|
}
|
|
return;
|
|
};
|
|
// Each favorites tab is its own history page (mirrors the
|
|
// Discover tabs) so back/forward moves between them.
|
|
nav::record(nav::NavEntry::Favorites { tab: id.to_string() });
|
|
if let Some(w) = weak.upgrade() {
|
|
update_nav_flags(&w);
|
|
}
|
|
navigate_favorites(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
tab,
|
|
id.as_str(),
|
|
);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_open_album(move |id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_open_album(id);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_open_artist(move |id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_open_artist(id);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_open_label(move |id, name| {
|
|
let Ok(label_id) = id.parse::<u64>() else {
|
|
return;
|
|
};
|
|
let name = name.to_string();
|
|
nav::record(nav::NavEntry::Label {
|
|
id: label_id,
|
|
name: name.clone(),
|
|
});
|
|
navigate_label(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
label_id,
|
|
name,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
// Favorite playlist click — open the playlist detail 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::<FavoritesActions>()
|
|
.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);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Switch the Playlists sub-tab (Library / Following) + re-derive.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_playlists_set_sub_tab(move |sub| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_playlists_sub_tab(sub);
|
|
favorites::derive_playlists(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Local search over the loaded favorite playlists (name | owner).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_search_playlists(move |q| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_playlists_search(q);
|
|
favorites::derive_playlists(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Playlists grid/list view toggle (persisted).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_playlists_set_view(move |v| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_playlists_view_mode(v);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Local search over the loaded favorite artists (name).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_search_artists(move |q| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_artists_search(q);
|
|
favorites::derive_artists(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Artists header Shuffle = open a random visible artist (random
|
|
// ARTIST, not a random album — matches Tauri).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_artists_shuffle(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
if let Some(id) = favorites::random_visible_artist(&w) {
|
|
w.invoke_open_artist(id.into());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Playlists "random" — play a random visible playlist (reuses the
|
|
// playlist-action "play" path).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_playlists_random(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
if let Some(id) = favorites::random_visible_playlist(&w) {
|
|
w.global::<FavoritesActions>()
|
|
.invoke_playlist_action(id.into(), "play".into());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Labels "random" — open a random visible label's landing.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_labels_random(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
if let Some((id, name)) = favorites::random_visible_label(&w) {
|
|
w.global::<FavoritesActions>()
|
|
.invoke_open_label(id.into(), name.into());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Group the favorite artists (off / A-Z) — persisted.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_artists_set_group(move |g| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>()
|
|
.set_artists_group_enabled(g == "alpha");
|
|
favorites::derive_artists(&w);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Artists grid <-> sidepanel view toggle (persisted). Switching back to
|
|
// grid clears the sidepanel selection (matches Tauri).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_artists_set_view(move |v| {
|
|
if let Some(w) = weak.upgrade() {
|
|
let st = w.global::<FavoritesState>();
|
|
st.set_artists_view_mode(v.clone());
|
|
if v == "grid" {
|
|
st.set_selected_artist_id("".into());
|
|
}
|
|
// Rebuild grouped/alpha for the new mode (the sidepanel
|
|
// left list is always grouped).
|
|
favorites::derive_artists(&w);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Sidepanel: load + show the selected artist's albums, reusing the
|
|
// standalone artist page's /artist/page release_type classifier.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_select_artist(move |id, name| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let st = w.global::<FavoritesState>();
|
|
st.set_selected_artist_id(id.clone());
|
|
st.set_selected_artist_name(name);
|
|
st.set_selected_albums_loading(true);
|
|
st.set_selected_albums_error("".into());
|
|
let runtime = runtime.clone();
|
|
let weak2 = weak.clone();
|
|
let image_cache = image_cache.clone();
|
|
let id_s = id.to_string();
|
|
handle.spawn(async move {
|
|
match artist::load_artist(&runtime, &id_s).await {
|
|
Ok(data) => {
|
|
let sections = data.release_sections;
|
|
let jobs = favorites::selected_artist_artwork_jobs(§ions);
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
favorites::apply_selected_artist(&w, sections);
|
|
});
|
|
artwork::spawn_loads(jobs, weak2.clone(), image_cache.clone());
|
|
}
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] sidepanel artist {id_s} load failed: {e}");
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
let st = w.global::<FavoritesState>();
|
|
st.set_selected_albums_loading(false);
|
|
st.set_selected_albums_error(e.into());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Playlist card actions: play / play-next / queue / share / favorite.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_playlist_action(move |id, action| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
match action.as_str() {
|
|
"share" => share::copy_to_clipboard(share::qobuz_playlist_url(&id)),
|
|
"favorite" => {
|
|
// Library sub-tab: un-favorite in place (remove the
|
|
// local favorite + drop the row). Following sub-tab:
|
|
// add to the local Library (per user decision).
|
|
let library = w
|
|
.global::<FavoritesState>()
|
|
.get_playlists_sub_tab()
|
|
.to_string()
|
|
!= "following";
|
|
if let Ok(pid) = id.parse::<u64>() {
|
|
let fav = !library;
|
|
handle.spawn_blocking(move || {
|
|
crate::library_db::with_db(|db| db.set_playlist_favorite(pid, fav));
|
|
});
|
|
}
|
|
if library {
|
|
favorites::remove_playlist_row(&w, &id);
|
|
}
|
|
}
|
|
act => {
|
|
// play / play-next / queue: fetch the playlist's tracks,
|
|
// then play or enqueue.
|
|
let Ok(pid) = id.parse::<u64>() else {
|
|
return;
|
|
};
|
|
let runtime = runtime.clone();
|
|
let weak2 = weak.clone();
|
|
let handle2 = handle.clone();
|
|
let act = act.to_string();
|
|
handle.spawn(async move {
|
|
let tracks = match runtime.core().get_playlist(pid).await {
|
|
Ok(p) => p.tracks.map(|t| t.items).unwrap_or_default(),
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] playlist {pid} load failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if tracks.is_empty() {
|
|
return;
|
|
}
|
|
match act.as_str() {
|
|
"play-next" => {
|
|
playback::enqueue_tracks(runtime, handle2, tracks, true)
|
|
}
|
|
"queue" => {
|
|
playback::enqueue_tracks(runtime, handle2, tracks, false)
|
|
}
|
|
_ => {
|
|
playback::play_tracks(runtime, weak2, handle2, tracks, 0);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_play_track(move |id| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_media_action("track".into(), id, "play".into());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_track_action(move |id, action| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_media_action("track".into(), id, action);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Favorite album card actions (play / queue / favorite / go-to)
|
|
// route through the album media-action arms.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_album_action(move |id, action| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.invoke_media_action("album".into(), id, action);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Local search over the loaded favorite albums (title / artist).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_albums_search(move |q| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_albums_search(q);
|
|
favorites::derive_albums(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Sort the favorite albums (default / title / artist).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_albums_set_sort(move |s| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_albums_sort_by(s);
|
|
favorites::derive_albums(&w);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Albums grid/list view toggle (persisted).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_albums_set_view(move |v| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_albums_view_mode(v);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Group the favorite albums (off / alpha / artist).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_albums_set_group(move |g| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_albums_group_mode(g);
|
|
favorites::derive_albums(&w);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Play a random album from the visible favorites set.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_albums_shuffle(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
if let Some(id) = favorites::random_visible_album(&w) {
|
|
w.invoke_media_action("album".into(), id.into(), "play".into());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Un-favorite a track from the favorites list: fade the row, remove
|
|
// the favorite on the server, then drop the row after the fade.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_unfavorite_track(move |id| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
favorites::mark_track_removing(&w, &id);
|
|
if let Ok(tid) = id.parse::<u64>() {
|
|
crate::fav_cache::set(tid, false);
|
|
}
|
|
let id_srv = id.to_string();
|
|
let runtime = runtime.clone();
|
|
handle.spawn(async move {
|
|
if let Err(e) = runtime.core().remove_favorite("track", &id_srv).await {
|
|
log::error!("[qbz-slint] unfavorite track {id_srv} failed: {e}");
|
|
}
|
|
});
|
|
let weak2 = weak.clone();
|
|
let id_rm = id.to_string();
|
|
slint::Timer::single_shot(std::time::Duration::from_millis(280), move || {
|
|
if let Some(w) = weak2.upgrade() {
|
|
favorites::remove_track_row(&w, &id_rm);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Un-favorite an album from the favorites list (same fade + remove).
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_unfavorite_album(move |id| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
favorites::mark_album_removing(&w, &id);
|
|
let id_srv = id.to_string();
|
|
let runtime = runtime.clone();
|
|
handle.spawn(async move {
|
|
if let Err(e) = runtime.core().remove_favorite("album", &id_srv).await {
|
|
log::error!("[qbz-slint] unfavorite album {id_srv} failed: {e}");
|
|
}
|
|
});
|
|
let weak2 = weak.clone();
|
|
let id_rm = id.to_string();
|
|
slint::Timer::single_shot(std::time::Duration::from_millis(280), move || {
|
|
if let Some(w) = weak2.upgrade() {
|
|
favorites::remove_album_row(&w, &id_rm);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
// Retry loading the current favorites tab after a load error.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_retry_load(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let tab_id = w.global::<FavoritesState>().get_active_tab().to_string();
|
|
if let Some(tab) = favorites::FavTab::from_tab_id(&tab_id) {
|
|
navigate_favorites(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
tab,
|
|
&tab_id,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Local search over the loaded favorite tracks (title / artist /
|
|
// album), re-deriving the rendered list.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_search_tracks(move |q| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_tracks_search(q);
|
|
favorites::derive_tracks(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Local search over the loaded favorite labels (name).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_search_labels(move |q| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_labels_search(q);
|
|
favorites::derive_labels(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Group the favorite tracks (off / album / artist / name).
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_tracks_set_group(move |g| {
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<FavoritesState>().set_tracks_group_mode(g);
|
|
favorites::derive_tracks(&w);
|
|
favorites_prefs::save(&w);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Play all favorite tracks as a fresh queue.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_play_all_tracks(move || {
|
|
playback::play_tracks(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
favorites::play_tracks(),
|
|
0,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
// Shuffle-play the favorite tracks.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_shuffle_tracks(move || {
|
|
playback::play_tracks(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
favorites::shuffled_tracks(),
|
|
0,
|
|
);
|
|
});
|
|
}
|
|
{
|
|
// Enter / leave the tracks multi-select edit mode.
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_toggle_multi_select(move || {
|
|
if let Some(w) = weak.upgrade() {
|
|
let on = w.global::<FavoritesState>().get_tracks_multi_select();
|
|
favorites::set_multi_select(&w, !on);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
// Bulk bar actions over the selected favorite tracks.
|
|
let runtime = app_runtime.clone();
|
|
let weak = window.as_weak();
|
|
let handle = tokio_rt.handle().clone();
|
|
let image_cache = image_cache.clone();
|
|
window
|
|
.global::<FavoritesActions>()
|
|
.on_bulk_action(move |action| {
|
|
let Some(w) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
match action.as_str() {
|
|
"select-all" => favorites::select_all(&w),
|
|
"clear" => favorites::clear_selection(&w),
|
|
"queue" => {
|
|
let tracks = favorites::selected_tracks(&w);
|
|
playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, false);
|
|
}
|
|
"play-next" => {
|
|
let tracks = favorites::selected_tracks(&w);
|
|
playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, true);
|
|
}
|
|
"make-offline" => {
|
|
let tracks = favorites::selected_tracks(&w);
|
|
offline_cache::cache_tracks(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
handle.clone(),
|
|
tracks,
|
|
);
|
|
favorites::clear_selection(&w);
|
|
}
|
|
"add-to-playlist" => {
|
|
let ids = favorites::selected_ids(&w);
|
|
if !ids.is_empty() {
|
|
playlist_picker::open_multi(&w, &ids, false);
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let playlists = playlist_picker::load(&runtime).await;
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
playlist_picker::apply(&w, playlists);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
"remove-selected" => {
|
|
let ids = favorites::selected_ids(&w);
|
|
if ids.is_empty() {
|
|
return;
|
|
}
|
|
let runtime = runtime.clone();
|
|
let weak = weak.clone();
|
|
let handle = handle.clone();
|
|
let image_cache = image_cache.clone();
|
|
handle.clone().spawn(async move {
|
|
for id in &ids {
|
|
if let Err(e) =
|
|
runtime.core().remove_favorite("track", id).await
|
|
{
|
|
log::error!(
|
|
"[qbz-slint] bulk remove favorite {id} failed: {e}"
|
|
);
|
|
}
|
|
if let Ok(tid) = id.parse::<u64>() {
|
|
crate::fav_cache::set(tid, false);
|
|
}
|
|
}
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
favorites::set_multi_select(&w, false);
|
|
});
|
|
navigate_favorites(
|
|
runtime.clone(),
|
|
weak.clone(),
|
|
&handle,
|
|
image_cache.clone(),
|
|
favorites::FavTab::Tracks,
|
|
"tracks",
|
|
);
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Artwork right-click menu wiring — Open in browser / Save as /
|
|
// Add custom / Remove custom. Mirrors the v2_library_* + native
|
|
// dialog flow Tauri uses on artist portraits + album covers.
|
|
window
|
|
.global::<ArtworkActions>()
|
|
.on_open_in_browser(|url| {
|
|
if url.is_empty() {
|
|
return;
|
|
}
|
|
if let Err(e) = open::that(url.as_str()) {
|
|
log::error!("[qbz-slint] artwork open-in-browser failed: {e}");
|
|
}
|
|
});
|
|
{
|
|
let handle = tokio_rt.handle().clone();
|
|
window
|
|
.global::<ArtworkActions>()
|
|
.on_save_as(move |url, default_name| {
|
|
if url.is_empty() {
|
|
return;
|
|
}
|
|
let url = url.to_string();
|
|
let default = default_name.to_string();
|
|
handle.spawn(async move {
|
|
let Some(dest) = rfd::AsyncFileDialog::new()
|
|
.set_file_name(&default)
|
|
.add_filter("Images", &["jpg", "jpeg", "png"])
|
|
.save_file()
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let bytes = match reqwest::get(&url).await {
|
|
Ok(resp) => match resp.bytes().await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
log::error!(
|
|
"[qbz-slint] artwork save-as fetch body: {e}"
|
|
);
|
|
return;
|
|
}
|
|
},
|
|
Err(e) => {
|
|
log::error!("[qbz-slint] artwork save-as request: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if let Err(e) = tokio::fs::write(dest.path(), &bytes).await {
|
|
log::error!("[qbz-slint] artwork save-as write: {e}");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let handle = tokio_rt.handle().clone();
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<ArtworkActions>()
|
|
.on_add_custom(move |kind, key| {
|
|
let kind = kind.to_string();
|
|
let key = key.to_string();
|
|
let weak = weak.clone();
|
|
handle.spawn(async move {
|
|
let Some(file) = rfd::AsyncFileDialog::new()
|
|
.add_filter("Images", &["png", "jpg", "jpeg", "webp"])
|
|
.pick_file()
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
let path = file.path().to_string_lossy().into_owned();
|
|
match kind.as_str() {
|
|
"artist" => {
|
|
custom_artwork::set_artist_image(&key, &path);
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<ArtistState>().set_has_custom_image(true);
|
|
});
|
|
}
|
|
"album" => {
|
|
custom_artwork::set_album_cover(&key, &path);
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<AlbumState>().set_has_custom_cover(true);
|
|
});
|
|
}
|
|
_ => log::warn!(
|
|
"[qbz-slint] artwork add-custom: unknown kind {kind}"
|
|
),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
window
|
|
.global::<ArtworkActions>()
|
|
.on_remove_custom(move |kind, key| {
|
|
match kind.as_str() {
|
|
"artist" => {
|
|
custom_artwork::remove_artist_image(key.as_str());
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<ArtistState>().set_has_custom_image(false);
|
|
}
|
|
}
|
|
"album" => {
|
|
custom_artwork::remove_album_cover(key.as_str());
|
|
if let Some(w) = weak.upgrade() {
|
|
w.global::<AlbumState>().set_has_custom_cover(false);
|
|
}
|
|
}
|
|
_ => log::warn!(
|
|
"[qbz-slint] artwork remove-custom: unknown kind {kind}"
|
|
),
|
|
}
|
|
});
|
|
}
|
|
|
|
window.on_close_app({
|
|
let weak = window.as_weak();
|
|
move || {
|
|
// Custom titlebar close button. Hide to tray when close-to-tray is
|
|
// enabled and the tray is live; otherwise quit.
|
|
if tray_settings::get().close_to_tray && tray::handle().is_some() {
|
|
log::info!("[qbz-slint] close-to-tray (titlebar): hiding to tray");
|
|
tray::hide_window(&weak);
|
|
} else {
|
|
log::info!("[qbz-slint] closing");
|
|
let _ = slint::quit_event_loop();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Intercept the window-manager close (native titlebar X / compositor
|
|
// close). Mirrors the custom titlebar: hide to tray when close-to-tray is
|
|
// on + the tray is live, otherwise quit. Required because the loop runs
|
|
// with quit_on_last_window_closed = false (so a tray-hide keeps the app
|
|
// alive) — without this, the native close would leave a headless process.
|
|
window.window().on_close_requested(move || {
|
|
let settings = tray_settings::get();
|
|
if settings.close_to_tray && tray::handle().is_some() {
|
|
// Slint performs the hide (destroys the surface) for HideWindow;
|
|
// we only sync the shown flag so the next tray toggle shows it.
|
|
log::info!("[qbz-slint] close-to-tray (WM close): hiding to tray");
|
|
tray::set_window_shown(false);
|
|
// macOS: drop the Dock icon if the user opted in (no-op elsewhere).
|
|
if settings.mac_hide_dock {
|
|
tray::set_mac_dock_hidden(true);
|
|
}
|
|
slint::CloseRequestResponse::HideWindow
|
|
} else {
|
|
log::info!("[qbz-slint] WM close requested: quitting");
|
|
let _ = slint::quit_event_loop();
|
|
slint::CloseRequestResponse::HideWindow
|
|
}
|
|
});
|
|
|
|
window.on_open_tos(|| {
|
|
dispatch(AppCommand::OpenTermsOfService);
|
|
if let Err(e) = open::that(QOBUZ_TOS_URL) {
|
|
log::error!("[qbz-slint] failed to open Terms of Service: {e}");
|
|
}
|
|
});
|
|
|
|
log::info!("[qbz-slint] window ready");
|
|
// NOT `window.run()`: that quits the event loop when the last window
|
|
// closes, which would kill the app the moment the window hides to tray.
|
|
// `run_event_loop_until_quit()` keeps the loop alive until an explicit
|
|
// `quit_event_loop()` (custom titlebar / WM close when not close-to-tray /
|
|
// tray Quit), so hide-to-tray works.
|
|
window.show()?;
|
|
slint::run_event_loop_until_quit()?;
|
|
Ok(())
|
|
}
|