feat(slint): sidebar context menus, in-app toast, SearchView + Playlist Manager polish
Sidebar: - Fix "Recursion detected" panic from focusing the playlist search input in init (defer via a single-shot Timer). - Right-click context menus: playlist (Edit, Hide from sidebar, Move to folder with folder search + Move to root) and folder (Edit, Hide from sidebar), built on ContextMenu/ContextMenuItem; remove the unreachable hover "..." button. - Filter hidden playlists/folders out of the list; add folders::set_folder_hidden. - Folder list in the move-to-folder menu scrolls (cap 5 rows); close-on-click-outside so the folder-search input no longer dismisses the menu. - Sort-menu Refresh now uses a dedicated refresh-cw icon. Toast: in-app ToastKind/ToastState + Toast overlay mounted at the app root, crate::toast (show/hide, per-kind auto-hide via a reusable Timer, cross-thread *_weak helpers); surface play album/artist load failures. SearchView: title inline with NavButtons + searchType filters floated right; tab strip on its own row; Most-popular hero for a track uses the new SearchTrackHero, sized to the artist card. Playlist Manager: list view renders compact PmListRow rows instead of cards; grid/folder card fonts tightened to match Tauri; folder card centered with hover-reveal edit; search input narrowed to 70%. cc
This commit is contained in:
18 files changed
+1143
-282
No files matched your search
@@ -14,7 +14,10 @@ pub struct FolderInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// All folders, ordered by their stored position.
|
||||
/// All folders, ordered by their stored position. The sidebar now uses
|
||||
/// `load_folders_full` (it needs the hidden flag to exclude hidden
|
||||
/// folders); kept as a lightweight id+name helper for other callers.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_folders() -> Vec<FolderInfo> {
|
||||
library_db::with_db(|db| db.get_all_playlist_folders())
|
||||
.unwrap_or_default()
|
||||
@@ -210,6 +213,13 @@ pub fn set_hidden(playlist_id: u64, hidden: bool) {
|
||||
library_db::with_db(|db| db.set_playlist_hidden(playlist_id, hidden));
|
||||
}
|
||||
|
||||
/// Set a folder's hidden flag (leaves all other fields unchanged).
|
||||
pub fn set_folder_hidden(id: &str, hidden: bool) {
|
||||
library_db::with_db(|db| {
|
||||
db.update_playlist_folder(id, None, None, None, None, None, Some(hidden))
|
||||
});
|
||||
}
|
||||
|
||||
/// Persist a custom playlist order (custom-sort positions).
|
||||
pub fn reorder_playlists(playlist_ids: &[u64]) {
|
||||
library_db::with_db(|db| db.reorder_playlists(playlist_ids));
|
||||
|
||||
+107
-17
@@ -48,6 +48,7 @@ mod search;
|
||||
mod settings;
|
||||
mod share;
|
||||
mod sidebar;
|
||||
mod toast;
|
||||
mod ui_prefs;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -802,6 +803,29 @@ fn refresh_sidebar_covers(window: &AppWindow) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -1151,23 +1175,7 @@ fn wire_playlist_manager(
|
||||
.global::<PlaylistManagerActions>()
|
||||
.on_edit_folder(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let fid = id.to_string();
|
||||
if let Some(f) = playlist_manager::folder_for_edit(&fid) {
|
||||
let fes = w.global::<FolderEditState>();
|
||||
fes.set_id(id);
|
||||
fes.set_name(f.name.into());
|
||||
fes.set_icon_preset(f.icon_preset.into());
|
||||
fes.set_icon_color(f.icon_color.into());
|
||||
fes.set_is_hidden(f.is_hidden);
|
||||
fes.set_custom_image_path(
|
||||
f.custom_image_path.clone().unwrap_or_default().into(),
|
||||
);
|
||||
fes.set_open(true);
|
||||
// Decode the existing custom image, if any.
|
||||
if let Some(path) = f.custom_image_path {
|
||||
playlist_manager::load_editor_custom_image(w.as_weak(), path);
|
||||
}
|
||||
}
|
||||
open_folder_editor(&w, id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3681,6 +3689,88 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// 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);
|
||||
|
||||
@@ -370,6 +370,7 @@ pub fn play_album(
|
||||
Ok(album) => album,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] playback: get_album {album_id} failed: {e}");
|
||||
crate::toast::error_weak(&weak, "Couldn't load this album");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -400,6 +401,7 @@ pub fn play_album(
|
||||
|
||||
if tracks.is_empty() {
|
||||
log::warn!("[qbz-slint] playback: album {album_id} has no tracks");
|
||||
crate::toast::error_weak(&weak, "This album has no playable tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -434,6 +436,7 @@ pub fn play_artist_top_tracks(
|
||||
Ok(page) => page,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] play-top: get_artist_page failed: {e}");
|
||||
crate::toast::error_weak(&weak, "Couldn't load this artist");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -446,6 +449,7 @@ pub fn play_artist_top_tracks(
|
||||
.collect();
|
||||
if tracks.is_empty() {
|
||||
log::warn!("[qbz-slint] play-top: artist {artist_id} has no top tracks");
|
||||
crate::toast::error_weak(&weak, "No top tracks available for this artist");
|
||||
return;
|
||||
}
|
||||
let start_track_id = tracks[0].id;
|
||||
|
||||
@@ -18,6 +18,9 @@ use crate::{AppWindow, SidebarEntry, SidebarPlaylistItem, SidebarState};
|
||||
pub struct SidebarPlaylist {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Playlist description (Qobuz). Empty when none. Used to prefill the
|
||||
/// edit-playlist modal opened from the sidebar context menu.
|
||||
pub description: String,
|
||||
/// Total track count (Qobuz). Used by the "# of tracks" sort.
|
||||
pub tracks_count: u32,
|
||||
/// Up to four cover-art URLs for the micro-collage, sourced from the
|
||||
@@ -56,6 +59,8 @@ pub struct SidebarData {
|
||||
pub playlists: Vec<SidebarPlaylist>,
|
||||
pub folders: Vec<FolderInfo>,
|
||||
pub folder_map: HashMap<u64, String>,
|
||||
/// Playlist ids the user has hidden from the sidebar (local settings).
|
||||
pub hidden_playlists: HashSet<u64>,
|
||||
}
|
||||
|
||||
/// Session-only folder expand state (matches Tauri — not persisted).
|
||||
@@ -69,6 +74,11 @@ static SORT: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new("name".to_str
|
||||
/// Active playlist-name search query (lowercased), mirrored from
|
||||
/// `SidebarState.search-query`. Filters the rebuilt list recursively.
|
||||
static SEARCH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||
/// playlist id -> (name, description) from the last loaded payload, so the
|
||||
/// sidebar context menu can prefill the edit-playlist modal without a
|
||||
/// refetch.
|
||||
static NAME_DESC: LazyLock<Mutex<HashMap<u64, (String, String)>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub fn set_loading(window: &AppWindow, loading: bool) {
|
||||
window.global::<SidebarState>().set_loading(loading);
|
||||
@@ -126,6 +136,7 @@ where
|
||||
.map(|p| SidebarPlaylist {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
description: p.description.clone().unwrap_or_default(),
|
||||
tracks_count: p.tracks_count,
|
||||
cover_urls: playlist_cover_urls(&p),
|
||||
position: 0,
|
||||
@@ -136,30 +147,62 @@ where
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// Folders + folder membership + per-playlist custom-sort positions
|
||||
// (all local, library.db).
|
||||
let (folders, folder_map, positions) = tokio::task::spawn_blocking(|| {
|
||||
(
|
||||
crate::folders::load_folders(),
|
||||
crate::folders::playlist_folder_map(),
|
||||
crate::folders::playlist_positions(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Folders (hidden folders excluded) + folder membership +
|
||||
// per-playlist custom-sort positions + hidden-playlist set (all
|
||||
// local, library.db).
|
||||
let (folders, folder_map, positions, hidden_playlists) =
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let folders: Vec<FolderInfo> = crate::folders::load_folders_full()
|
||||
.into_iter()
|
||||
.filter(|f| !f.is_hidden)
|
||||
.map(|f| FolderInfo {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
})
|
||||
.collect();
|
||||
let hidden_playlists: HashSet<u64> = crate::folders::playlist_settings_map()
|
||||
.into_iter()
|
||||
.filter(|(_, s)| s.hidden)
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
(
|
||||
folders,
|
||||
crate::folders::playlist_folder_map(),
|
||||
crate::folders::playlist_positions(),
|
||||
hidden_playlists,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut playlists = playlists;
|
||||
for p in &mut playlists {
|
||||
if let Some(pos) = positions.get(&p.id) {
|
||||
p.position = *pos;
|
||||
}
|
||||
}
|
||||
// Cache the loaded playlists' name+description for the sidebar
|
||||
// context-menu edit modal (no extra fetch on right-click).
|
||||
if let Ok(mut nd) = NAME_DESC.lock() {
|
||||
nd.clear();
|
||||
for p in &playlists {
|
||||
nd.insert(p.id, (p.name.clone(), p.description.clone()));
|
||||
}
|
||||
}
|
||||
SidebarData {
|
||||
playlists,
|
||||
folders,
|
||||
folder_map,
|
||||
hidden_playlists,
|
||||
}
|
||||
}
|
||||
|
||||
/// Name + description for `id`, from the last loaded playlist payload.
|
||||
/// Used by the sidebar context menu to prefill the edit-playlist modal
|
||||
/// without a refetch. Returns None when the playlist is unknown.
|
||||
pub fn playlist_name_desc(id: u64) -> Option<(String, String)> {
|
||||
NAME_DESC.lock().ok().and_then(|nd| nd.get(&id).cloned())
|
||||
}
|
||||
|
||||
/// Store the freshly-loaded data and render it.
|
||||
pub fn apply(window: &AppWindow, data: SidebarData) {
|
||||
if let Ok(mut cache) = CACHE.lock() {
|
||||
@@ -171,7 +214,7 @@ pub fn apply(window: &AppWindow, data: SidebarData) {
|
||||
/// Build a playlist `SidebarEntry` (with its cover URLs for the
|
||||
/// micro-collage). The decoded `cover*` images stay default here and are
|
||||
/// filled asynchronously by the artwork pipeline (see `artwork_jobs`).
|
||||
fn playlist_entry(p: &SidebarPlaylist, indent: bool) -> SidebarEntry {
|
||||
fn playlist_entry(p: &SidebarPlaylist, indent: bool, folder_id: &str) -> SidebarEntry {
|
||||
let url = |i: usize| -> slint::SharedString {
|
||||
p.cover_urls.get(i).cloned().unwrap_or_default().into()
|
||||
};
|
||||
@@ -182,6 +225,7 @@ fn playlist_entry(p: &SidebarPlaylist, indent: bool) -> SidebarEntry {
|
||||
expanded: false,
|
||||
count: 0,
|
||||
indent,
|
||||
folder_id: folder_id.into(),
|
||||
cover_count: p.cover_urls.len().min(4) as i32,
|
||||
url1: url(0),
|
||||
url2: url(1),
|
||||
@@ -215,6 +259,7 @@ pub fn rebuild(window: &AppWindow) {
|
||||
.iter()
|
||||
.filter(|p| data.folder_map.get(&p.id).map(|f| f == &folder.id).unwrap_or(false))
|
||||
.filter(|p| matches(p))
|
||||
.filter(|p| !data.hidden_playlists.contains(&p.id))
|
||||
.collect();
|
||||
// While searching, skip folders with no matching playlists
|
||||
// (mirrors Tauri's `if (isSearching && folderPlaylists.length === 0) continue`).
|
||||
@@ -230,6 +275,7 @@ pub fn rebuild(window: &AppWindow) {
|
||||
expanded: is_exp,
|
||||
count: members.len() as i32,
|
||||
indent: false,
|
||||
folder_id: "".into(),
|
||||
cover_count: 0,
|
||||
url1: Default::default(),
|
||||
url2: Default::default(),
|
||||
@@ -242,7 +288,7 @@ pub fn rebuild(window: &AppWindow) {
|
||||
});
|
||||
if is_exp {
|
||||
for p in members {
|
||||
entries.push(playlist_entry(p, true));
|
||||
entries.push(playlist_entry(p, true, &folder.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,8 +299,8 @@ pub fn rebuild(window: &AppWindow) {
|
||||
.get(&p.id)
|
||||
.map(|f| folder_ids.contains(f))
|
||||
.unwrap_or(false);
|
||||
if !in_folder && matches(p) {
|
||||
entries.push(playlist_entry(p, false));
|
||||
if !in_folder && matches(p) && !data.hidden_playlists.contains(&p.id) {
|
||||
entries.push(playlist_entry(p, false, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,10 +315,33 @@ pub fn rebuild(window: &AppWindow) {
|
||||
|
||||
let state = window.global::<SidebarState>();
|
||||
state.set_entries(ModelRc::new(VecModel::from(entries)));
|
||||
state.set_folders(ModelRc::new(VecModel::from(folders)));
|
||||
state.set_folders(ModelRc::new(VecModel::from(folders.clone())));
|
||||
// The move-to-folder menu starts unfiltered (= the full folder set);
|
||||
// the search box narrows it via `search_menu_folders`.
|
||||
state.set_menu_folders(ModelRc::new(VecModel::from(folders)));
|
||||
state.set_loading(false);
|
||||
}
|
||||
|
||||
/// Filter the move-to-folder menu's folder list by a case-insensitive
|
||||
/// substring of the search query (Slint strings have no `contains`, so
|
||||
/// this lives in Rust). An empty query restores the full list.
|
||||
pub fn search_menu_folders(window: &AppWindow, query: &str) {
|
||||
let q = query.trim().to_lowercase();
|
||||
let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default();
|
||||
let filtered: Vec<SidebarPlaylistItem> = data
|
||||
.folders
|
||||
.iter()
|
||||
.filter(|f| q.is_empty() || f.name.to_lowercase().contains(&q))
|
||||
.map(|f| SidebarPlaylistItem {
|
||||
id: f.id.clone().into(),
|
||||
name: f.name.clone().into(),
|
||||
})
|
||||
.collect();
|
||||
window
|
||||
.global::<SidebarState>()
|
||||
.set_menu_folders(ModelRc::new(VecModel::from(filtered)));
|
||||
}
|
||||
|
||||
/// Build artwork-download jobs for every playlist row's collage covers,
|
||||
/// targeting `SidebarState.entries` by row index. Call after `apply` /
|
||||
/// `rebuild` updates the entries.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! In-app toast controller — the Rust side of the Slint port of Tauri's
|
||||
//! `toastStore`. One toast at a time (a new one replaces the current),
|
||||
//! auto-hidden after a per-kind delay. `buffering` is persistent.
|
||||
//!
|
||||
//! All functions touch Slint globals and the shared hide `Timer`, so they
|
||||
//! must run on the event-loop thread. From background threads / async
|
||||
//! tasks use [`show_weak`] / [`error_weak`], which hop onto the event loop
|
||||
//! for you (the established `upgrade_in_event_loop` pattern).
|
||||
//!
|
||||
//! This is the shared toast API surface; not every helper has a call site
|
||||
//! yet (error surfacing is being wired in incrementally), so the unused
|
||||
//! ones are intentionally allowed rather than removed.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use slint::{ComponentHandle, Timer, TimerMode, Weak};
|
||||
|
||||
use crate::{AppWindow, ToastKind, ToastState};
|
||||
|
||||
thread_local! {
|
||||
// Single reusable auto-hide timer. Reused (stop + start) so a fresh
|
||||
// toast cancels the previous toast's pending hide instead of letting
|
||||
// it fire and dismiss the newer one early — mirrors the
|
||||
// `clearTimeout` in Tauri's toastStore.
|
||||
static HIDE_TIMER: Timer = Timer::default();
|
||||
}
|
||||
|
||||
/// Auto-hide delay per kind. `None` = persistent (buffering). Matches
|
||||
/// Tauri's `defaultDurations`.
|
||||
fn auto_hide(kind: ToastKind) -> Option<Duration> {
|
||||
let ms = match kind {
|
||||
ToastKind::Success => 3000,
|
||||
ToastKind::Info => 3000,
|
||||
ToastKind::Warning => 4000,
|
||||
ToastKind::Error => 5000,
|
||||
ToastKind::Buffering => return None,
|
||||
};
|
||||
Some(Duration::from_millis(ms))
|
||||
}
|
||||
|
||||
/// Show a toast. Must be called on the event-loop thread.
|
||||
pub fn show(window: &AppWindow, message: impl Into<String>, kind: ToastKind) {
|
||||
let message: String = message.into();
|
||||
let state = window.global::<ToastState>();
|
||||
|
||||
// Non-error toasts honor the global toggle; errors always show
|
||||
// (parity with Tauri's `showToast`).
|
||||
if !state.get_enabled() && kind != ToastKind::Error {
|
||||
return;
|
||||
}
|
||||
|
||||
state.set_message(message.as_str().into());
|
||||
state.set_kind(kind);
|
||||
state.set_persistent(kind == ToastKind::Buffering);
|
||||
state.set_visible(true);
|
||||
|
||||
HIDE_TIMER.with(|t| t.stop());
|
||||
if let Some(delay) = auto_hide(kind) {
|
||||
let weak = window.as_weak();
|
||||
HIDE_TIMER.with(|t| {
|
||||
t.start(TimerMode::SingleShot, delay, move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<ToastState>().set_visible(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the current toast immediately (also stops the pending auto-hide).
|
||||
pub fn hide(window: &AppWindow) {
|
||||
HIDE_TIMER.with(|t| t.stop());
|
||||
window.global::<ToastState>().set_visible(false);
|
||||
}
|
||||
|
||||
/// Show a toast from any thread (async / background error paths). Hops
|
||||
/// onto the event loop, so the message must be owned/`Send`.
|
||||
pub fn show_weak(weak: &Weak<AppWindow>, message: impl Into<String>, kind: ToastKind) {
|
||||
let message: String = message.into();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| show(&w, message, kind));
|
||||
}
|
||||
|
||||
// ---- Convenience wrappers -------------------------------------------------
|
||||
|
||||
pub fn error(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Error);
|
||||
}
|
||||
|
||||
pub fn success(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Success);
|
||||
}
|
||||
|
||||
pub fn info(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Info);
|
||||
}
|
||||
|
||||
pub fn warning(window: &AppWindow, message: impl Into<String>) {
|
||||
show(window, message, ToastKind::Warning);
|
||||
}
|
||||
|
||||
/// Error toast from any thread.
|
||||
pub fn error_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
|
||||
show_weak(weak, message, ToastKind::Error);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import { Theme } from "foundation/semantic-colors.slint";
|
||||
import { SplashScreen } from "splash/SplashScreen.slint";
|
||||
import { LoginScreen } from "login/LoginScreen.slint";
|
||||
import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, FolderEditState, FolderEditActions } from "state.slint";
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
@@ -107,4 +108,12 @@ export component AppWindow inherits Window {
|
||||
root.close-app();
|
||||
}
|
||||
}
|
||||
|
||||
// App-global in-app toast overlay. Declared last so it floats over
|
||||
// every screen (splash / login / shell). Driven by ToastState via
|
||||
// crate::toast.
|
||||
Toast {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.801 10A10 10 0 1 1 17 3.335" />
|
||||
<path d="m9 11 3 3L22 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .622.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -29,7 +29,8 @@ import { PmFolderIcon } from "../primitives/PmFolderIcon.slint";
|
||||
// Search field — leading magnifier, input, trailing X to clear.
|
||||
component PmSearch inherits Rectangle {
|
||||
height: 34px;
|
||||
width: 240px;
|
||||
// 70% of the former 240px width (task: shrink the PM search input).
|
||||
width: 168px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
@@ -267,6 +268,9 @@ component PmFolderCard inherits Rectangle {
|
||||
in property <PmFolderItem> folder;
|
||||
callback open();
|
||||
callback edit();
|
||||
// Matches Tauri's `.folder-card`: 160 wide, 16px padding, centered
|
||||
// icon/name/count column. Hover reveals the edit pencil (opacity 0->1)
|
||||
// and lifts the surface — same affordance as Tauri.
|
||||
width: 160px;
|
||||
height: 128px;
|
||||
border-radius: 10px;
|
||||
@@ -275,43 +279,56 @@ component PmFolderCard inherits Rectangle {
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
PmFolderIcon {
|
||||
folder: root.folder;
|
||||
size: 64px;
|
||||
spacing: 8px;
|
||||
alignment: center;
|
||||
// Centered icon.
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
PmFolderIcon {
|
||||
folder: root.folder;
|
||||
size: 64px;
|
||||
}
|
||||
}
|
||||
// Centered single-line name (13px, elided — matches `.folder-name`).
|
||||
Text {
|
||||
text: root.folder.name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
horizontal-alignment: center;
|
||||
overflow: elide;
|
||||
height: 18px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// Centered count (12px — matches `.folder-count`).
|
||||
Text {
|
||||
text: root.folder.count == 1
|
||||
? @tr("1 playlist")
|
||||
: @tr("{} playlists", root.folder.count);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-size: 12px;
|
||||
horizontal-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
// Hover edit pencil (top-right).
|
||||
// Hover edit pencil (top-right). Hidden until the card (or the button)
|
||||
// is hovered, exactly like Tauri's `.folder-card:hover .folder-edit-btn`.
|
||||
edit-btn := Rectangle {
|
||||
x: parent.width - self.width - 8px;
|
||||
y: 8px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
opacity: (ta.has-hover || edit-ta.has-hover) ? 1.0 : 0.0;
|
||||
background: edit-ta.has-hover ? Theme.surface-card : #00000066;
|
||||
animate opacity { duration: 150ms; }
|
||||
background: edit-ta.has-hover ? Theme.surface-card : Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/pen-line.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-primary;
|
||||
tint: edit-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
edit-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
@@ -344,9 +361,10 @@ component PmGridCard inherits Rectangle {
|
||||
callback move-down();
|
||||
|
||||
width: 160px;
|
||||
// pad(10) + [reorder 22 + gap 8] + collage 140 + gap 8 + name 36 +
|
||||
// gap 8 + footer 24 + pad(10).
|
||||
height: root.can-reorder ? 266px : 236px;
|
||||
// pad(10) + [reorder 22 + gap 8] + collage 140 + gap 8 + name 18 +
|
||||
// gap 8 + footer 22 + pad(10). Tauri's name is a single-line 13px
|
||||
// title (the .grid-item .name); we elide instead of 2-line clamp.
|
||||
height: root.can-reorder ? 250px : 220px;
|
||||
border-radius: 8px;
|
||||
background: ta.has-hover ? Theme.surface-hover : Theme.surface-card;
|
||||
opacity: root.item.is-hidden ? 0.6 : 1.0;
|
||||
@@ -400,24 +418,26 @@ component PmGridCard inherits Rectangle {
|
||||
Text {
|
||||
text: root.item.name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
wrap: word-wrap;
|
||||
overflow: elide;
|
||||
width: 140px;
|
||||
height: 36px;
|
||||
height: 18px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// Footer: meta + actions.
|
||||
HorizontalLayout {
|
||||
width: 140px;
|
||||
height: 22px;
|
||||
alignment: space-between;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: root.item.tracks-line;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-size: 11px;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
@@ -460,8 +480,8 @@ component PmGridCard inherits Rectangle {
|
||||
// card-body click opens the playlist while the footer buttons keep
|
||||
// their own clicks. Sits below the reorder header when present.
|
||||
width: parent.width;
|
||||
// pad(10) + collage(140) + gap(8) + name(36) = 194.
|
||||
height: 194px;
|
||||
// pad(10) + collage(140) + gap(8) + name(18) = 176.
|
||||
height: 176px;
|
||||
y: root.can-reorder ? 30px : 0px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
@@ -487,11 +507,11 @@ component PmListRow inherits Rectangle {
|
||||
callback move-down();
|
||||
callback move-to-folder(string /* folder id, "" = root */);
|
||||
|
||||
// Matches Tauri's `.list-item`: a solid surface row (bg-secondary),
|
||||
// hover lifts to bg-tertiary. Compact: 8px/12px padding, 48px collage.
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
background: ta.has-hover
|
||||
? Theme.surface-hover
|
||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||
border-radius: 6px;
|
||||
background: ta.has-hover ? Theme.surface-hover : Theme.surface-card;
|
||||
opacity: root.item.is-hidden ? 0.6 : 1.0;
|
||||
|
||||
HorizontalLayout {
|
||||
@@ -528,57 +548,69 @@ component PmListRow inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
// Name — single-line 14px elided (matches `.list-item .name`).
|
||||
Text {
|
||||
text: root.item.name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-size: 14px;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
// Meta line — 12px muted (matches `.list-item .meta`).
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
Text {
|
||||
text: root.item.tracks-line;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-size: 12px;
|
||||
}
|
||||
if root.item.local-line != "": Text {
|
||||
text: root.item.local-line;
|
||||
color: #22c55e;
|
||||
font-size: Typography.legal;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Play-count badge.
|
||||
// Play-count badge — rounded chip (matches `.play-count-badge`).
|
||||
if root.item.play-count > 0: VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/chart-no-axes-column.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
tint: Theme.text-muted;
|
||||
Rectangle {
|
||||
width: pc-row.preferred-width + 16px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
pc-row := HorizontalLayout {
|
||||
x: 8px;
|
||||
width: self.preferred-width;
|
||||
height: parent.height;
|
||||
spacing: 4px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/chart-no-axes-column.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: root.item.play-count;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: root.item.play-count;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Actions (hover).
|
||||
// Actions — always visible (matches Tauri's `.list-item` buttons).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
spacing: 0px;
|
||||
opacity: ta.has-hover ? 1.0 : 0.35;
|
||||
PmActionButton {
|
||||
icon: root.item.is-favorite
|
||||
? @image-url("../assets/icons/heart-filled.svg")
|
||||
@@ -846,7 +878,7 @@ export component PlaylistManagerView inherits Rectangle {
|
||||
property <length> card-w: 160px;
|
||||
// Card height depends on whether reorder controls show (matches
|
||||
// PmGridCard's height).
|
||||
property <length> card-h: PlaylistManagerState.can-reorder ? 266px : 236px;
|
||||
property <length> card-h: PlaylistManagerState.can-reorder ? 250px : 220px;
|
||||
|
||||
// The view-mode cycle button shows the NEXT mode's icon (Tauri).
|
||||
property <image> next-view-icon:
|
||||
|
||||
@@ -7,9 +7,15 @@ import { QbzIcon } from "QbzIcon.slint";
|
||||
export component ContextMenuItem inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <string> label;
|
||||
// When false the row collapses to zero height and stops rendering /
|
||||
// capturing input — lets a `for` build a filtered list without an
|
||||
// inner `if` (which Slint does not allow as a direct `for` child).
|
||||
in property <bool> shown: true;
|
||||
callback clicked();
|
||||
|
||||
height: 33px;
|
||||
height: root.shown ? 33px : 0px;
|
||||
visible: root.shown;
|
||||
clip: true;
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Most-popular hero card for a TRACK top result.
|
||||
//
|
||||
// Mirrors the Tauri SearchView `.popular-card` track case: a vertical
|
||||
// card with a square artwork on top (hover scrim + favorite / play /
|
||||
// more overlay buttons), then a tight title / artist stack alongside an
|
||||
// icon-only quality badge. Sized to match the search-dashboard ArtistCard
|
||||
// (160x220) so the Most-popular slot sits proportionate next to the
|
||||
// Artists carousel cards. Tauri's `.most-popular-card` is 160x220 with a
|
||||
// 142x142 artwork (`.popular-card-artwork`), padding 6px — replicated here.
|
||||
//
|
||||
// Reusable: takes a TrackItem and bubbles every interaction up through one
|
||||
// media-action(kind, id, action) — "play" | "favorite" | the context-menu
|
||||
// actions — keyed by "track".
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { TrackItem } from "../state.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
import { QualityBadge } from "QualityBadge.slint";
|
||||
import { TrackContextMenu } from "TrackContextMenu.slint";
|
||||
|
||||
// Circular hover-overlay action button — identical visual to AlbumCard's
|
||||
// OverlayButton so the hero cards share one look.
|
||||
component OverlayButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
in property <length> diameter: 36px;
|
||||
in property <length> icon-size: 16px;
|
||||
out property <bool> hovered: ta.has-hover;
|
||||
callback clicked();
|
||||
|
||||
width: root.diameter;
|
||||
height: root.diameter;
|
||||
border-radius: root.diameter / 2;
|
||||
background: root.primary
|
||||
? (ta.has-hover ? #ffffffd6 : #ffffff)
|
||||
: (ta.has-hover ? #ffffff3d : #ffffff24);
|
||||
border-width: root.primary ? 0px : 1.5px;
|
||||
border-color: #ffffffcc;
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: root.icon-size;
|
||||
height: root.icon-size;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: root.primary ? #000000 : #ffffff;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component SearchTrackHero inherits Rectangle {
|
||||
in property <TrackItem> track;
|
||||
// kind ("track"), track id, action ("play" | "favorite" | menu actions).
|
||||
callback media-action(string, string, string);
|
||||
|
||||
// Match the search-dashboard ArtistCard footprint (160x220) so the
|
||||
// Most-popular hero is proportionate next to the artist cards. Tauri's
|
||||
// `.popular-card`: 160 wide, 6px padding, 142x142 artwork.
|
||||
width: 160px;
|
||||
height: 220px;
|
||||
border-radius: 12px;
|
||||
background: hover.has-hover || root.overlay-on
|
||||
? Theme.surface-elevated
|
||||
: Theme.surface-card;
|
||||
animate background { duration: 150ms; }
|
||||
clip: true;
|
||||
|
||||
// Overlay stays up while the pointer is over the artwork OR a button.
|
||||
property <bool> overlay-on: hover.has-hover
|
||||
|| fav-btn.hovered || play-btn.hovered || more-btn.hovered;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 6px;
|
||||
alignment: start;
|
||||
spacing: 0px;
|
||||
|
||||
// --- Artwork + hover overlay -------------------------------------
|
||||
Rectangle {
|
||||
width: 142px;
|
||||
height: 142px;
|
||||
border-radius: Radius.sm;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
|
||||
// Cover art (track's album cover). Empty until the artwork
|
||||
// pipeline resolves it, leaving the placeholder visible.
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.track.artwork;
|
||||
image-fit: cover;
|
||||
}
|
||||
// Placeholder music glyph behind the cover.
|
||||
if root.track.artwork.width == 0: QbzIcon {
|
||||
source: @image-url("../assets/icons/music.svg");
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
|
||||
// Hover scrim.
|
||||
Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000000;
|
||||
opacity: root.overlay-on ? 0.6 : 0.0;
|
||||
animate opacity { duration: 150ms; }
|
||||
}
|
||||
|
||||
// Card-open (play) + hover detector. Declared before the action
|
||||
// buttons so those, when interactive, win the pointer.
|
||||
hover := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("track", root.track.id, "play");
|
||||
}
|
||||
}
|
||||
|
||||
// Hover action buttons — favorite / play / more. Centered in
|
||||
// the 142px artwork.
|
||||
HorizontalLayout {
|
||||
x: 0px;
|
||||
y: (142px - self.height) / 2;
|
||||
width: 142px;
|
||||
height: 38px;
|
||||
alignment: center;
|
||||
spacing: 8px;
|
||||
opacity: root.overlay-on ? 1.0 : 0.0;
|
||||
animate opacity { duration: 150ms; }
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
fav-btn := OverlayButton {
|
||||
icon: @image-url("../assets/icons/heart.svg");
|
||||
diameter: 30px;
|
||||
icon-size: 14px;
|
||||
clicked => {
|
||||
root.media-action("track", root.track.id, "favorite");
|
||||
}
|
||||
}
|
||||
}
|
||||
play-btn := OverlayButton {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
primary: true;
|
||||
diameter: 38px;
|
||||
icon-size: 16px;
|
||||
clicked => {
|
||||
root.media-action("track", root.track.id, "play");
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
more-btn := OverlayButton {
|
||||
icon: @image-url("../assets/icons/ellipsis.svg");
|
||||
diameter: 30px;
|
||||
icon-size: 14px;
|
||||
clicked => {
|
||||
track-menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu for the "more" button — the shared track menu.
|
||||
track-menu := TrackContextMenu {
|
||||
x: 20px;
|
||||
y: 48px;
|
||||
track-id: root.track.id;
|
||||
media-action(k, i, a) => {
|
||||
root.media-action(k, i, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 6px; }
|
||||
|
||||
// --- Title / artist + quality badge ------------------------------
|
||||
// 142px wide to align with the artwork. The badge is given a fixed
|
||||
// column so it stays inside the card; the title/artist stack takes
|
||||
// the remaining width and elides.
|
||||
HorizontalLayout {
|
||||
width: 142px;
|
||||
height: 52px;
|
||||
spacing: 6px;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 1px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
Text {
|
||||
text: root.track.title;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
font-weight: Typography.medium;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
if root.track.artist != "": Text {
|
||||
text: root.track.artist;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon-only quality badge — fixed-width column, kept inside the
|
||||
// card (the hi-res badge is 42px wide).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
width: 44px;
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
QualityBadge {
|
||||
tier: root.track.quality-tier;
|
||||
label: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// In-app toast overlay — the Slint port of Tauri's Toast.svelte.
|
||||
//
|
||||
// Mounted once at the app root (app.slint) so it floats over every
|
||||
// screen. It renders whatever `ToastState` holds; the Rust layer
|
||||
// (crate::toast) owns showing + auto-hide timing. A single toast at a
|
||||
// time, anchored bottom-center, matching Tauri (bottom: 100px, centered).
|
||||
//
|
||||
// The toast is always in the tree (never `if`-gated) so the entry
|
||||
// animation runs: opacity + a small upward slide are driven off
|
||||
// `ToastState.visible`. The close TouchArea is disabled while hidden so
|
||||
// the (transparent) overlay never eats clicks meant for the content
|
||||
// below it.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
import { ToastState, ToastKind } from "../state.slint";
|
||||
|
||||
export component Toast inherits Rectangle {
|
||||
// Full-bleed, non-interactive layer (no background, no TouchArea on
|
||||
// the root, so pointer events pass through to the app underneath).
|
||||
property <ToastKind> kind: ToastState.kind;
|
||||
|
||||
// Per-kind icon, matching Tauri's lucide set.
|
||||
property <image> icon-source:
|
||||
root.kind == ToastKind.success ? @image-url("../assets/icons/circle-check-big.svg")
|
||||
: root.kind == ToastKind.error ? @image-url("../assets/icons/circle-alert.svg")
|
||||
: root.kind == ToastKind.warning ? @image-url("../assets/icons/triangle-alert.svg")
|
||||
: root.kind == ToastKind.buffering ? @image-url("../assets/icons/loader-circle.svg")
|
||||
: @image-url("../assets/icons/info.svg");
|
||||
|
||||
// Per-kind accent, matching Tauri's colors.
|
||||
property <color> icon-color:
|
||||
root.kind == ToastKind.success ? #4CAF50
|
||||
: root.kind == ToastKind.error ? #ff6b6b
|
||||
: root.kind == ToastKind.warning ? #FFA726
|
||||
: Theme.accent;
|
||||
|
||||
toast := Rectangle {
|
||||
// Hug the content, but never spill past the window edges.
|
||||
width: min(content.preferred-width, root.width - 80px);
|
||||
height: 44px;
|
||||
x: (root.width - self.width) / 2;
|
||||
// Bottom-center, ~100px up from the bottom (Tauri parity). Slides
|
||||
// up the last 16px as it appears.
|
||||
y: parent.height - self.height - (ToastState.visible ? 100px : 84px);
|
||||
opacity: ToastState.visible ? 1.0 : 0.0;
|
||||
animate opacity { duration: 200ms; easing: ease-out; }
|
||||
animate y { duration: 200ms; easing: ease-out; }
|
||||
|
||||
background: Theme.surface-elevated;
|
||||
border-radius: 8px;
|
||||
drop-shadow-blur: 16px;
|
||||
drop-shadow-color: #00000066;
|
||||
|
||||
content := HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
spacing: 12px;
|
||||
|
||||
// Type icon.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 0;
|
||||
QbzIcon {
|
||||
source: root.icon-source;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
tint: root.icon-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Message. Single line, elided if it would overflow the cap.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: ToastState.message;
|
||||
color: Theme.text-primary;
|
||||
font-size: 14px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
// Close button.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 0;
|
||||
Rectangle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: close-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: close-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
close-ta := TouchArea {
|
||||
// Only live while a toast is actually showing, so
|
||||
// the hidden overlay doesn't capture clicks.
|
||||
enabled: ToastState.visible;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
ToastState.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { AlbumCard } from "../discover/AlbumCard.slint";
|
||||
import { ArtistCard } from "../discover/ArtistCard.slint";
|
||||
import { PlaylistCard } from "../discover/PlaylistCard.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { SearchTrackHero } from "../primitives/SearchTrackHero.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
@@ -279,141 +280,151 @@ export component SearchResultsView inherits Rectangle {
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// --- Toolbar: history nav pair ----------------------------------
|
||||
// --- Row 1: NavButtons + "Search" title (left), searchType filter
|
||||
// radios floated right. Same toolbar bar as DiscoverBrowseView — the
|
||||
// back/forward buttons keep the same screen position as every page
|
||||
// (center at y=25 = 11px top + 14px half-button). The title sits
|
||||
// inline right after the nav pair.
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: Theme.surface-main;
|
||||
|
||||
// Left cluster — nav buttons then the title, inline.
|
||||
HorizontalLayout {
|
||||
x: 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButtons { }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("Search");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right cluster — searchType filter radios as one group, anchored
|
||||
// right. Shown once results exist (mirrors the previous gate).
|
||||
if root.has-results: HorizontalLayout {
|
||||
x: parent.width - self.preferred-width - 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Main Artist");
|
||||
selected: SearchState.filter-index == 1;
|
||||
clicked => { SearchActions.filter-changed(1); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Performer");
|
||||
selected: SearchState.filter-index == 2;
|
||||
clicked => { SearchActions.filter-changed(2); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Composer");
|
||||
selected: SearchState.filter-index == 3;
|
||||
clicked => { SearchActions.filter-changed(3); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Label");
|
||||
selected: SearchState.filter-index == 4;
|
||||
clicked => { SearchActions.filter-changed(4); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Release Name");
|
||||
selected: SearchState.filter-index == 5;
|
||||
clicked => { SearchActions.filter-changed(5); }
|
||||
}
|
||||
}
|
||||
// Clear-filter button — visible only when a filter is set.
|
||||
if SearchState.filter-index != 0: VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: clear-ta.has-hover
|
||||
? Theme.surface-hover
|
||||
: Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: clear-ta.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
}
|
||||
clear-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { SearchActions.filter-changed(0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fixed header: title, tabs + filter radios ------------------
|
||||
// Collapsed during the initial load so the empty title + tabs do
|
||||
// not show through behind the loading state.
|
||||
if !(SearchState.loading && !root.has-results): VerticalLayout {
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-bottom: 12px;
|
||||
spacing: 16px;
|
||||
|
||||
Text {
|
||||
text: @tr("Search Results");
|
||||
color: Theme.text-primary;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// --- Row 2: the five-tab strip on its own row -------------------
|
||||
// Collapsed during the initial load so the empty tabs do not show
|
||||
// through behind the loading state. Gated only on the initial load
|
||||
// (loading && no results); once any results exist the strip stays
|
||||
// visible across tab switches and re-searches.
|
||||
if !(SearchState.loading && !root.has-results): Rectangle {
|
||||
height: 40px;
|
||||
HorizontalLayout {
|
||||
// Tabs, left.
|
||||
HorizontalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 22px;
|
||||
alignment: start;
|
||||
SearchTab {
|
||||
label: @tr("All");
|
||||
active: SearchState.tab == 0;
|
||||
clicked => { SearchActions.tab-changed(0); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Albums");
|
||||
active: SearchState.tab == 1;
|
||||
clicked => { SearchActions.tab-changed(1); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Tracks");
|
||||
active: SearchState.tab == 2;
|
||||
clicked => { SearchActions.tab-changed(2); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Artists");
|
||||
active: SearchState.tab == 3;
|
||||
clicked => { SearchActions.tab-changed(3); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Playlists");
|
||||
active: SearchState.tab == 4;
|
||||
clicked => { SearchActions.tab-changed(4); }
|
||||
}
|
||||
x: 32px;
|
||||
padding-bottom: 8px;
|
||||
spacing: 22px;
|
||||
alignment: start;
|
||||
SearchTab {
|
||||
label: @tr("All");
|
||||
active: SearchState.tab == 0;
|
||||
clicked => { SearchActions.tab-changed(0); }
|
||||
}
|
||||
|
||||
// searchType filter radios, right.
|
||||
if root.has-results: HorizontalLayout {
|
||||
spacing: 16px;
|
||||
alignment: end;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Main Artist");
|
||||
selected: SearchState.filter-index == 1;
|
||||
clicked => { SearchActions.filter-changed(1); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Performer");
|
||||
selected: SearchState.filter-index == 2;
|
||||
clicked => { SearchActions.filter-changed(2); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Composer");
|
||||
selected: SearchState.filter-index == 3;
|
||||
clicked => { SearchActions.filter-changed(3); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Label");
|
||||
selected: SearchState.filter-index == 4;
|
||||
clicked => { SearchActions.filter-changed(4); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
FilterRadio {
|
||||
label: @tr("Release Name");
|
||||
selected: SearchState.filter-index == 5;
|
||||
clicked => { SearchActions.filter-changed(5); }
|
||||
}
|
||||
}
|
||||
// Clear-filter button — visible only when a filter is set.
|
||||
if SearchState.filter-index != 0: VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: clear-ta.has-hover
|
||||
? Theme.surface-hover
|
||||
: Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: clear-ta.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
}
|
||||
clear-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { SearchActions.filter-changed(0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Albums");
|
||||
active: SearchState.tab == 1;
|
||||
clicked => { SearchActions.tab-changed(1); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Tracks");
|
||||
active: SearchState.tab == 2;
|
||||
clicked => { SearchActions.tab-changed(2); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Artists");
|
||||
active: SearchState.tab == 3;
|
||||
clicked => { SearchActions.tab-changed(3); }
|
||||
}
|
||||
SearchTab {
|
||||
label: @tr("Playlists");
|
||||
active: SearchState.tab == 4;
|
||||
clicked => { SearchActions.tab-changed(4); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,15 +489,9 @@ export component SearchResultsView inherits Rectangle {
|
||||
clicked(id) => { root.open-album(id); }
|
||||
media-action(k, i, a) => { root.media-action(k, i, a); }
|
||||
}
|
||||
if SearchState.most-popular-kind == "track": Rectangle {
|
||||
width: 320px;
|
||||
height: 56px;
|
||||
TrackRow {
|
||||
width: 320px;
|
||||
track: SearchState.most-popular-track;
|
||||
index: 0;
|
||||
media-action(k, i, a) => { root.media-action(k, i, a); }
|
||||
}
|
||||
if SearchState.most-popular-kind == "track": SearchTrackHero {
|
||||
track: SearchState.most-popular-track;
|
||||
media-action(k, i, a) => { root.media-action(k, i, a); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Spacing } from "../foundation/spacing.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
|
||||
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
||||
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
|
||||
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState } from "../state.slint";
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
|
||||
@@ -113,8 +115,14 @@ component SidebarRow inherits Rectangle {
|
||||
callback toggle-folder(string);
|
||||
callback move-playlist(string, string);
|
||||
callback delete-folder(string);
|
||||
callback edit-playlist(string);
|
||||
callback edit-folder(string);
|
||||
callback hide-playlist(string);
|
||||
callback hide-folder(string);
|
||||
|
||||
property <bool> is-folder: root.entry.kind == "folder";
|
||||
// Live filter for the move-to-folder list (per-row search input).
|
||||
property <string> folder-query;
|
||||
|
||||
// Track-drag drop target: a playlist row self-detects whether the
|
||||
// global drag pointer is within its bounds (the source grabs the
|
||||
@@ -234,33 +242,10 @@ component SidebarRow inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Playlist "..." (hover) — move-to-folder only. Folders use just
|
||||
// the open/close chevron; folder management lives in the Playlist
|
||||
// Manager, so folders show no "..." here.
|
||||
if !root.is-folder: VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
opacity: (ta.has-hover || more-ta.has-hover) ? 1.0 : 0.0;
|
||||
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/ellipsis.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
more-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Playlists no longer show a hover "..." button (it cluttered the
|
||||
// row, like the folders' removed one). Move-to-folder is reached by
|
||||
// right-clicking the row instead (see the `ta` pointer-event below);
|
||||
// folder management itself lives in the Playlist Manager.
|
||||
}
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
@@ -271,46 +256,155 @@ component SidebarRow inherits Rectangle {
|
||||
root.open-playlist(root.entry.id);
|
||||
}
|
||||
}
|
||||
// Right-click any row (folder or playlist) to open its context
|
||||
// menu. The old hover "..." button lived at the row's right edge,
|
||||
// where the scrollbar overlapped it and ate the click — so it was
|
||||
// removed and the menu rehomed here, reachable from anywhere on
|
||||
// the row.
|
||||
pointer-event(event) => {
|
||||
if (event.button == PointerEventButton.right
|
||||
&& event.kind == PointerEventKind.up) {
|
||||
root.folder-query = "";
|
||||
// Reset the filtered folder list to the full set (Rust
|
||||
// owns the substring filter; an empty query = everything).
|
||||
SidebarActions.search-folders("");
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click context menu — a folder menu (Edit / Hide) or a
|
||||
// playlist menu (Edit / Hide / divider / Move-to-folder). Ports the
|
||||
// Tauri sidebar row menus 1:1.
|
||||
menu := PopupWindow {
|
||||
x: root.width - 196px;
|
||||
x: root.width - 210px;
|
||||
y: 28px;
|
||||
width: 190px;
|
||||
Rectangle {
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
border-radius: 8px;
|
||||
drop-shadow-blur: 20px;
|
||||
drop-shadow-color: #00000080;
|
||||
VerticalLayout {
|
||||
padding: 6px;
|
||||
spacing: 1px;
|
||||
// Playlist: move to folder. Wrapped so the `for` is not
|
||||
// a direct child of an `if` (not allowed in Slint).
|
||||
if !root.is-folder: VerticalLayout {
|
||||
spacing: 1px;
|
||||
Text {
|
||||
text: @tr("Move to folder");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: left;
|
||||
width: 210px;
|
||||
// Default close-policy closes on ANY click (even inside), which
|
||||
// dismissed the menu the instant you clicked the folder-search
|
||||
// input. Close only on outside clicks; items still close it
|
||||
// explicitly via menu.close().
|
||||
close-policy: PopupClosePolicy.close-on-click-outside;
|
||||
ContextMenu {
|
||||
// ---- Folder menu --------------------------------------------
|
||||
if root.is-folder: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/pencil.svg");
|
||||
label: @tr("Edit folder");
|
||||
clicked => {
|
||||
root.edit-folder(root.entry.id);
|
||||
menu.close();
|
||||
}
|
||||
}
|
||||
if root.is-folder: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/eye-off.svg");
|
||||
label: @tr("Hide from sidebar");
|
||||
clicked => {
|
||||
root.hide-folder(root.entry.id);
|
||||
menu.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Playlist menu ------------------------------------------
|
||||
if !root.is-folder: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/pencil.svg");
|
||||
label: @tr("Edit playlist");
|
||||
clicked => {
|
||||
root.edit-playlist(root.entry.id);
|
||||
menu.close();
|
||||
}
|
||||
}
|
||||
if !root.is-folder: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/eye-off.svg");
|
||||
label: @tr("Hide from sidebar");
|
||||
clicked => {
|
||||
root.hide-playlist(root.entry.id);
|
||||
menu.close();
|
||||
}
|
||||
}
|
||||
// Divider before the move-to-folder section.
|
||||
if !root.is-folder: Rectangle {
|
||||
height: 7px;
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
}
|
||||
// MOVE TO FOLDER section (wrapped so the `for` is not a direct
|
||||
// child of an `if`, which Slint disallows).
|
||||
if !root.is-folder: VerticalLayout {
|
||||
padding-left: 11px;
|
||||
padding-right: 16px;
|
||||
padding-bottom: 3px;
|
||||
Text {
|
||||
text: @tr("Move to folder");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: left;
|
||||
height: 18px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
// Folder search input — only when there are enough folders to
|
||||
// warrant it. No auto-focus (calling focus() in init panics
|
||||
// with "Recursion detected"). The substring filter runs in
|
||||
// Rust (Slint strings have no `contains`); it fills
|
||||
// `menu-folders`, which the list below iterates.
|
||||
if !root.is-folder && SidebarState.folders.length >= 8: HorizontalLayout {
|
||||
padding-left: 11px;
|
||||
padding-right: 16px;
|
||||
padding-bottom: 4px;
|
||||
LineEdit {
|
||||
height: 26px;
|
||||
placeholder-text: @tr("Search folders");
|
||||
text <=> root.folder-query;
|
||||
edited => {
|
||||
SidebarActions.search-folders(self.text);
|
||||
}
|
||||
MenuItem {
|
||||
label: @tr("No folder");
|
||||
activated => {
|
||||
root.move-playlist(root.entry.id, "");
|
||||
}
|
||||
}
|
||||
for f in SidebarState.folders: MenuItem {
|
||||
}
|
||||
}
|
||||
// The folder list — each folder except the playlist's current
|
||||
// one. The query filter is already applied to `menu-folders`
|
||||
// by Rust; the current-folder exclusion stays in markup.
|
||||
if !root.is-folder: ScrollView {
|
||||
// Cap the list at ~5 rows (33px each); the rest scroll.
|
||||
height: min(SidebarState.menu-folders.length * 33px, 165px);
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.as-needed;
|
||||
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
VerticalLayout {
|
||||
for f in SidebarState.menu-folders: ContextMenuItem {
|
||||
shown: f.id != root.entry.folder-id;
|
||||
icon: @image-url("../assets/icons/folder.svg");
|
||||
label: f.name;
|
||||
activated => {
|
||||
clicked => {
|
||||
root.move-playlist(root.entry.id, f.id);
|
||||
menu.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Move to root — only when the playlist is currently in a folder.
|
||||
if !root.is-folder && root.entry.folder-id != "": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/chevron-left.svg");
|
||||
label: @tr("Move to root");
|
||||
clicked => {
|
||||
root.move-playlist(root.entry.id, "");
|
||||
menu.close();
|
||||
}
|
||||
}
|
||||
// No folders at all.
|
||||
if !root.is-folder && SidebarState.folders.length == 0: HorizontalLayout {
|
||||
padding-left: 11px;
|
||||
padding-right: 16px;
|
||||
padding-bottom: 4px;
|
||||
Text {
|
||||
text: @tr("No folders yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
height: 22px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,19 +446,31 @@ export component Sidebar inherits Rectangle {
|
||||
// Inline search input — takes the full header width to the
|
||||
// left of the actions when open. Filters playlists (the
|
||||
// filter is applied in crate::sidebar, recursively across
|
||||
// folders).
|
||||
if SidebarState.search-open: search-edit := LineEdit {
|
||||
placeholder-text: @tr("Search playlists");
|
||||
text <=> SidebarState.search-query;
|
||||
// folders). Wrapped in a Rectangle so the auto-focus can be
|
||||
// deferred: calling self.focus() directly from the LineEdit's
|
||||
// `init` re-enters property evaluation during the mount/layout
|
||||
// pass and panics with "Recursion detected" (i-slint-core
|
||||
// properties.rs). A single-shot Timer fires the focus on a
|
||||
// later event-loop tick, after the element tree has settled
|
||||
// (same end effect as Tauri's focus()).
|
||||
if SidebarState.search-open: Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
height: 22px;
|
||||
// Auto-focus when the input mounts (it is re-created each
|
||||
// time the search opens), matching Tauri's focus().
|
||||
init => {
|
||||
self.focus();
|
||||
search-edit := LineEdit {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
placeholder-text: @tr("Search playlists");
|
||||
text <=> SidebarState.search-query;
|
||||
edited => {
|
||||
SidebarActions.search-changed(self.text);
|
||||
}
|
||||
}
|
||||
edited => {
|
||||
SidebarActions.search-changed(self.text);
|
||||
focus-timer := Timer {
|
||||
interval: 1ms;
|
||||
running: true;
|
||||
triggered => {
|
||||
search-edit.focus();
|
||||
self.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +594,7 @@ export component Sidebar inherits Rectangle {
|
||||
MenuItem {
|
||||
label: @tr("Refresh");
|
||||
has-icon: true;
|
||||
icon: @image-url("../assets/icons/list-music.svg");
|
||||
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||
activated => { SidebarActions.refresh-playlists(); }
|
||||
}
|
||||
}
|
||||
@@ -519,6 +625,18 @@ export component Sidebar inherits Rectangle {
|
||||
delete-folder(id) => {
|
||||
SidebarActions.delete-folder(id);
|
||||
}
|
||||
edit-playlist(id) => {
|
||||
SidebarActions.edit-playlist(id);
|
||||
}
|
||||
edit-folder(id) => {
|
||||
SidebarActions.edit-folder(id);
|
||||
}
|
||||
hide-playlist(id) => {
|
||||
SidebarActions.hide-playlist(id);
|
||||
}
|
||||
hide-folder(id) => {
|
||||
SidebarActions.hide-folder(id);
|
||||
}
|
||||
}
|
||||
if SidebarState.entries.length == 0 && !SidebarState.loading: Text {
|
||||
text: @tr("No playlists yet.");
|
||||
|
||||
@@ -267,6 +267,24 @@ export global DragActions {
|
||||
// Which content view the shell shows.
|
||||
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist, discover-browse, playlist-manager }
|
||||
|
||||
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
||||
// a single toast at a time (a new one replaces the current), auto-hidden
|
||||
// by the Rust layer per kind. `buffering` is persistent (no auto-hide).
|
||||
export enum ToastKind { success, error, info, warning, buffering }
|
||||
|
||||
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
||||
// at the app root) renders whatever is here. The Rust layer owns timing
|
||||
// (show + auto-hide); the UI only sets `visible = false` on the close
|
||||
// button. `enabled` gates non-error toasts (errors always show), to match
|
||||
// Tauri's "in-app notifications" preference.
|
||||
export global ToastState {
|
||||
in-out property <bool> visible: false;
|
||||
in-out property <string> message;
|
||||
in-out property <ToastKind> kind: ToastKind.info;
|
||||
in-out property <bool> persistent: false;
|
||||
in-out property <bool> enabled: true;
|
||||
}
|
||||
|
||||
// One row in the sidebar playlists list (also used for the folder
|
||||
// list in the move-to-folder menu).
|
||||
export struct SidebarPlaylistItem {
|
||||
@@ -289,6 +307,7 @@ export struct SidebarEntry {
|
||||
expanded: bool, // folders
|
||||
count: int, // folder playlist count
|
||||
indent: bool, // playlist nested in a folder
|
||||
folder-id: string, // playlist's current folder id ("" = root; folders "")
|
||||
cover-count: int, // number of cover images, 0-4 (playlists)
|
||||
url1: string,
|
||||
url2: string,
|
||||
@@ -306,6 +325,13 @@ export global SidebarState {
|
||||
in property <[SidebarEntry]> entries: [];
|
||||
// Flat folder list, for the move-to-folder menu.
|
||||
in property <[SidebarPlaylistItem]> folders: [];
|
||||
// Folder list filtered by the move-to-folder search box. Equals
|
||||
// `folders` when the query is empty; a case-insensitive substring
|
||||
// subset otherwise. Filled by the Rust side (Slint strings have no
|
||||
// `contains`, so the substring filter can't live in markup). The
|
||||
// context menu iterates this; the current-folder exclusion stays in
|
||||
// markup via the per-row `folder-id`.
|
||||
in property <[SidebarPlaylistItem]> menu-folders: [];
|
||||
in property <bool> loading: false;
|
||||
// The currently-open playlist id, for the active-row highlight.
|
||||
in property <string> active-id;
|
||||
@@ -333,6 +359,14 @@ export global SidebarActions {
|
||||
callback delete-folder(string /* folder id */);
|
||||
// Move a playlist into a folder ("" folder id = root).
|
||||
callback move-playlist(string /* playlist id */, string /* folder id */);
|
||||
// Right-click context menu actions (folder + playlist rows).
|
||||
callback edit-playlist(string /* id */);
|
||||
callback edit-folder(string /* id */);
|
||||
callback hide-playlist(string /* id */);
|
||||
callback hide-folder(string /* id */);
|
||||
// Filter `menu-folders` by a case-insensitive substring of the
|
||||
// move-to-folder search box (Rust does the substring match).
|
||||
callback search-folders(string /* query */);
|
||||
// Pick a sort option (re-sorts the list). Same five values as Tauri.
|
||||
callback set-sort(string /* option */);
|
||||
// Re-run the playlist-name filter (called as the search input edits).
|
||||
|
||||
Reference in new issue
Block a user