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:
vicrodh committed 2026-05-26 12:21:59 -06:00
1 parent 2bf6feaa6d
commit d30e3d658d
18 files changed
+1143 -282

No files matched your search

+11 -1
View File
@@ -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
View File
@@ -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);
+4
View File
@@ -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;
+85 -16
View File
@@ -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.
+105
View File
@@ -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);
}
+10 -1
View File
@@ -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: "";
}
}
}
}
}
}
+115
View File
@@ -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;
}
}
}
}
}
}
}
+132 -127
View File
@@ -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); }
}
}
+186 -68
View File
@@ -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.");
+34
View File
@@ -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).