From 682719cab0d498572d840e3bf2db640c04c0c20f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 30 May 2026 15:40:35 -0600 Subject: [PATCH] feat(slint): library/sidebar UX overhaul + theme switching + fixes Shell & navigation: - Three-state sidebar (open/mini/closed); collapse cycles via header button. - Section nav as right-flyouts (sidebar) + compact header nav (closed) + full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay. - Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated. - Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe). - Slack-style bezel: rounded inset content panel over surface-card chrome. - Header restructure: nav buttons, window-centered search, app menu. Themes & components: - Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets). - SegmentedTabBar (Discovery-style) with count badges for Library + Local Library. - MP3 quality-badge tier (format-first classification) across badges. - QbzSelect (Group/Sort) trimmed 20px. Behavior fixes: - Artist + Local Library Folder cover art now reach the rendered (visible/ grouped) models, not just the source set. - Favorites Playlists/Labels random buttons. - Playlist track click plays from the clicked track in visible order. - Dropdown idle-close pauses while the cursor rests on the menu. cc --- crates/qbz-library/src/database.rs | 12 + crates/qbz-slint/src/artwork.rs | 12 +- crates/qbz-slint/src/favorites.rs | 68 ++++ crates/qbz-slint/src/local_library.rs | 102 +++++- crates/qbz-slint/src/main.rs | 63 +++- crates/qbz-slint/src/playlist.rs | 31 ++ crates/qbz-slint/src/sidebar.rs | 24 +- crates/qbz-slint/ui/app.slint | 2 +- .../ui/favorites/FavoritesView.slint | 336 +++++++----------- crates/qbz-slint/ui/foundation/layout.slint | 5 +- .../ui/foundation/semantic-colors.slint | 75 +++- .../qbz-slint/ui/foundation/theme-state.slint | 11 + .../ui/locallibrary/LocalLibraryView.slint | 119 ++----- .../ui/primitives/AlbumListRow.slint | 29 +- .../qbz-slint/ui/primitives/QbzSelect.slint | 9 +- .../ui/primitives/QualityBadge.slint | 19 +- .../ui/primitives/SegmentedTabBar.slint | 95 +++++ .../ui/settings/AppearanceSettings.slint | 28 +- crates/qbz-slint/ui/shell/AppShell.slint | 35 +- crates/qbz-slint/ui/shell/HeaderBar.slint | 318 ++++++++++++----- .../ui/shell/HeaderMenuOverlay.slint | 99 ++++-- crates/qbz-slint/ui/shell/NavButtons.slint | 74 +--- crates/qbz-slint/ui/shell/NavMenuItem.slint | 4 + crates/qbz-slint/ui/shell/PlayerBar.slint | 25 +- crates/qbz-slint/ui/shell/Sidebar.slint | 310 +++++++++++++--- .../ui/shell/SidebarFolderPopup.slint | 146 ++++++++ crates/qbz-slint/ui/state.slint | 63 +++- 27 files changed, 1495 insertions(+), 619 deletions(-) create mode 100644 crates/qbz-slint/ui/foundation/theme-state.slint create mode 100644 crates/qbz-slint/ui/primitives/SegmentedTabBar.slint create mode 100644 crates/qbz-slint/ui/shell/SidebarFolderPopup.slint diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index 6fb47e56e..18750969a 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -2922,6 +2922,18 @@ impl LibraryDatabase { Ok(tracks) } + /// Cheap total local-track count — the Tracks-tab badge number without + /// materializing the (potentially 16K-row) table. Mirrors the Tracks tab + /// filter (include_qobuz_downloads = true, no network exclusion, no search) + /// so the badge equals the unfiltered list length. + pub fn count_all_local_tracks(&self) -> Result { + let n: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM local_tracks", [], |row| row.get(0)) + .map_err(|e| LibraryError::Database(e.to_string()))?; + Ok(n as u64) + } + /// Get library statistics pub fn get_stats(&self, include_qobuz_downloads: bool) -> Result { let source_filter = if include_qobuz_downloads { diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index ed863997d..cfaaf51f5 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -630,9 +630,10 @@ fn apply_artwork( } ArtworkTarget::LocalFolderCard { index } => { let model = window.global::().get_folders(); - if let Some(mut item) = model.row_data(index) { - item.artwork = image; - model.set_row_data(index, item); + if let Some(item) = model.row_data(index) { + // Dual-set by id onto the full set + visible + grouped sections. + let id = item.id.to_string(); + crate::local_library::set_local_folder_artwork(window, &id, image); } } ArtworkTarget::LocalArtistAlbumCard { index } => { @@ -660,8 +661,11 @@ fn apply_artwork( ArtworkTarget::FavoriteArtist { index } => { let model = window.global::().get_artists(); if let Some(mut item) = model.row_data(index) { - item.image = image; + let id = item.id.to_string(); + item.image = image.clone(); model.set_row_data(index, item); + // Also reach the rendered (visible + grouped/sidepanel) models. + crate::favorites::set_artist_image(window, &id, image); } } ArtworkTarget::FavoriteLabel { index } => { diff --git a/crates/qbz-slint/src/favorites.rs b/crates/qbz-slint/src/favorites.rs index 7cc6f7cc1..e98eace18 100644 --- a/crates/qbz-slint/src/favorites.rs +++ b/crates/qbz-slint/src/favorites.rs @@ -927,6 +927,40 @@ pub fn random_visible_artist(window: &AppWindow) -> Option { model.row_data(idx).map(|a| a.id.to_string()) } +/// A random playlist id from the currently-visible Playlists set (for the +/// Playlists "random" button — play a random playlist). +pub fn random_visible_playlist(window: &AppWindow) -> Option { + let model = window.global::().get_playlists_visible(); + let n = model.row_count(); + if n == 0 { + return None; + } + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1); + let idx = (seed % n as u64) as usize; + model.row_data(idx).map(|p| p.id.to_string()) +} + +/// A random label (id, name) from the currently-visible Labels set (for the +/// Labels "random" button — open a random label's landing). +pub fn random_visible_label(window: &AppWindow) -> Option<(String, String)> { + let model = window.global::().get_labels_visible(); + let n = model.row_count(); + if n == 0 { + return None; + } + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1); + let idx = (seed % n as u64) as usize; + model + .row_data(idx) + .map(|l| (l.id.to_string(), l.name.to_string())) +} + // ---- Un-favorite in place: fade (set `removing`) then remove ----------- /// Flag the matching track row(s) as removing so they fade out. @@ -1040,6 +1074,40 @@ pub fn set_album_artwork(window: &AppWindow, id: &str, image: slint::Image) { } } +/// Set a freshly-decoded artist photo (by id) on the rendered favorites +/// artist models (flat `artists-visible` + every `artists-grouped` section, +/// which backs both the grouped grid and the sidepanel list). Without this +/// the photo only lands on the source `artists` model and the grouped/ +/// sidepanel views show it only after a re-derive (revisit). +pub fn set_artist_image(window: &AppWindow, id: &str, image: slint::Image) { + let st = window.global::(); + let vis = st.get_artists_visible(); + for i in 0..vis.row_count() { + if let Some(mut item) = vis.row_data(i) { + if item.id.as_str() == id { + item.image = image.clone(); + vis.set_row_data(i, item); + break; + } + } + } + let grouped = st.get_artists_grouped(); + for s in 0..grouped.row_count() { + if let Some(section) = grouped.row_data(s) { + let arts = section.artists; + for i in 0..arts.row_count() { + if let Some(mut item) = arts.row_data(i) { + if item.id.as_str() == id { + item.image = image.clone(); + arts.set_row_data(i, item); + break; + } + } + } + } + } +} + /// Set a freshly-decoded collage cover (by id + slot) on the rendered /// favorites playlists model (`playlists-visible`), which is a clone of the /// active sub-tab source whenever a search filter is active. diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs index e56d9af8f..c232e69c3 100644 --- a/crates/qbz-slint/src/local_library.rs +++ b/crates/qbz-slint/src/local_library.rs @@ -100,10 +100,16 @@ pub fn albums_gen_current(gen: u64) -> bool { /// genre per-track, not per-album); the cover PATH rides on `artwork_url` /// as the artwork-job carrier (the grid renders `artwork`, not the url). pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCard { - let tier = match a.bit_depth { - Some(b) if b >= 24 => "hires", - Some(_) => "cd", - None => "", + // Format-first classification (mirrors Tauri): a lossy format (MP3) gets + // the dedicated MP3 badge tier, never CD. + let tier = if a.format.to_string().eq_ignore_ascii_case("mp3") { + "mp3" + } else { + match a.bit_depth { + Some(b) if b >= 24 => "hires", + Some(_) => "cd", + None => "", + } }; let (quality_detail, quality_label) = local_quality(a.bit_depth, a.sample_rate); let year = a.year.map(|y| y.to_string()).unwrap_or_default(); @@ -298,6 +304,33 @@ pub fn set_local_album_artwork(window: &AppWindow, id: &str, image: slint::Image } } +/// Same, for the Folders tab: full `folders` set + `folders-visible` + each +/// `folders-grouped` section. Without this the cover only lands on the source +/// `folders` model and the rendered (visible/grouped) views miss it on first +/// load (the same bug the Albums/Artists tabs had). +pub fn set_local_folder_artwork(window: &AppWindow, id: &str, image: slint::Image) { + let s = window.global::(); + let set_in = |m: &ModelRc| { + for i in 0..m.row_count() { + if let Some(mut it) = m.row_data(i) { + if it.id.as_str() == id { + it.artwork = image.clone(); + m.set_row_data(i, it); + break; + } + } + } + }; + set_in(&s.get_folders()); + set_in(&s.get_folders_visible()); + let grouped = s.get_folders_grouped(); + for gi in 0..grouped.row_count() { + if let Some(sec) = grouped.row_data(gi) { + set_in(&sec.albums); + } + } +} + /// Re-derive the rendered Albums sets (search + quality/format/source filter + /// sort + group + A-Z) from the full `albums` card set, filtered by id against /// the raw LocalAlbum cache. Mirrors `derive_folders` plus the filter. @@ -483,6 +516,54 @@ pub fn reload_albums( }); } +/// Seed all four tab-badge counts up front (mirrors Favorites' seeded +/// counts) so the nav shows numbers without visiting each tab. Cheap: +/// bounded album/folder/artist reads + a `COUNT(*)` for the (potentially +/// huge) tracks table. Album/artist counts match each tab's own loader +/// exactly (same `get_albums_metadata_grouped` set; same `normalize_artist` +/// grouping the rail uses), so badges never jump when a tab is opened. +pub fn seed_counts(weak: slint::Weak, handle: tokio::runtime::Handle) { + handle.spawn(async move { + let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(|| { + crate::library_db::with_db(|db| { + let albums = db + .get_albums_metadata_grouped(false, true, false) + .map(|v| v.len()) + .unwrap_or(0); + let folders = db + .get_folders_with_metadata() + .map(|v| v.len()) + .unwrap_or(0); + let tracks = db.count_all_local_tracks().unwrap_or(0) as usize; + // Exact rail count = distinct non-empty normalized names + // (mirrors merge_artists' grouping key). + let artists_raw = db.get_artists().unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + for a in &artists_raw { + let n = normalize_artist(&a.name); + if !n.is_empty() { + seen.insert(n); + } + } + Ok((albums, seen.len(), folders, tracks)) + }) + }) + .await + .ok() + .flatten(); + + if let Some((albums, artists, folders, tracks)) = counts { + let _ = weak.upgrade_in_event_loop(move |w| { + let s = w.global::(); + s.set_album_count(albums as i32); + s.set_artist_count(artists as i32); + s.set_folder_count(folders as i32); + s.set_track_count(tracks as i32); + }); + } + }); +} + /// Load on first visit only (re-entry keeps the set + derived views). pub fn ensure_albums_loaded( weak: slint::Weak, @@ -530,10 +611,15 @@ fn fmt_duration(secs: u64) -> String { /// non-Send `slint::Image`). Local tracks aren't Qobuz-linkable, so the /// artist/album link ids are empty (the row renders them as plain text). fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem { - let tier = match t.bit_depth { - Some(b) if b >= 24 => "hires", - Some(_) => "cd", - None => "", + // Format-first classification (mirrors Tauri): MP3/lossy → MP3 tier. + let tier = if t.format.to_string().eq_ignore_ascii_case("mp3") { + "mp3" + } else { + match t.bit_depth { + Some(b) if b >= 24 => "hires", + Some(_) => "cd", + None => "", + } }; TrackItem { id: t.id.to_string().into(), diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index cb350ca38..fb2e8c7c4 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -941,6 +941,9 @@ fn navigate_local_library( w.global::().set_active_tab(tab_id.into()); w.global::().set_view(ContentView::LocalLibrary); }); + // Seed all four tab-badge counts up front (like Favorites) so the nav + // badges show without visiting each tab. + local_library::seed_counts(weak.clone(), handle.clone()); // Lazy per-tab load on first visit. match tab { local_library::LibTab::Albums => { @@ -2846,14 +2849,18 @@ fn main() -> Result<(), Box> { } } ("playlist-track", track_id) => { - let idx = playlist::index_of(track_id); - let runtime = runtime.clone(); - let weak = weak.clone(); - let handle = handle.clone(); - handle.clone().spawn(async move { - let tracks = playlist::current_tracks(); - playback::play_tracks(runtime, weak, handle, tracks, idx); - }); + // Queue the VISIBLE order starting at the clicked track, so + // the tracks that follow it (not track 1) play next. + if let Some(w) = weak.upgrade() { + let (tracks, idx) = playlist::visible_play_context(&w, track_id); + playback::play_tracks( + runtime.clone(), + weak.clone(), + handle.clone(), + tracks, + idx, + ); + } } _ => {} } @@ -5071,6 +5078,17 @@ fn main() -> Result<(), Box> { } }); } + { + // Populate the collapsed-sidebar folder flyout's playlist list. + let weak = window.as_weak(); + window + .global::() + .on_load_folder_popup(move |folder_id| { + if let Some(w) = weak.upgrade() { + sidebar::load_folder_popup(&w, folder_id.as_str()); + } + }); + } { let weak = window.as_weak(); window @@ -5603,6 +5621,35 @@ fn main() -> Result<(), Box> { } }); } + { + // Playlists "random" — play a random visible playlist (reuses the + // playlist-action "play" path). + let weak = window.as_weak(); + window + .global::() + .on_playlists_random(move || { + if let Some(w) = weak.upgrade() { + if let Some(id) = favorites::random_visible_playlist(&w) { + w.global::() + .invoke_playlist_action(id.into(), "play".into()); + } + } + }); + } + { + // Labels "random" — open a random visible label's landing. + let weak = window.as_weak(); + window + .global::() + .on_labels_random(move || { + if let Some(w) = weak.upgrade() { + if let Some((id, name)) = favorites::random_visible_label(&w) { + w.global::() + .invoke_open_label(id.into(), name.into()); + } + } + }); + } { // Group the favorite artists (off / A-Z) — persisted. let weak = window.as_weak(); diff --git a/crates/qbz-slint/src/playlist.rs b/crates/qbz-slint/src/playlist.rs index e2e40b717..93d2065b1 100644 --- a/crates/qbz-slint/src/playlist.rs +++ b/crates/qbz-slint/src/playlist.rs @@ -497,6 +497,37 @@ pub fn index_of(track_id: &str) -> usize { .unwrap_or(0) } +/// Build the play queue + start index from the CURRENTLY VISIBLE order +/// (search/sort applied — read straight off `PlaylistState.tracks`), starting +/// at `track_id`. This makes clicking a row begin playback there and queue the +/// tracks that VISUALLY follow it — the general app behaviour. Falls back to +/// the cached order if the visible/cache mapping comes up empty. +pub fn visible_play_context(window: &AppWindow, track_id: &str) -> (Vec, usize) { + use slint::Model; + let visible_ids: Vec = { + let m = window.global::().get_tracks(); + (0..m.row_count()) + .filter_map(|i| m.row_data(i).map(|it| it.id.to_string())) + .collect() + }; + let cur = CURRENT.lock().map(|c| c.clone()).unwrap_or_default(); + let by_id: std::collections::HashMap = + cur.iter().map(|t| (t.id.to_string(), t.clone())).collect(); + let ordered: Vec = visible_ids + .iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect(); + if ordered.is_empty() { + // Cache/visible mismatch — fall back to the cached order. + return (cur, index_of(track_id)); + } + let idx = visible_ids + .iter() + .position(|id| id == track_id) + .unwrap_or(0); + (ordered, idx) +} + // ==================== Custom artwork ==================== /// Copy `src` into the artwork cache and store it as this playlist's diff --git a/crates/qbz-slint/src/sidebar.rs b/crates/qbz-slint/src/sidebar.rs index 9dd89a12c..24d17018b 100644 --- a/crates/qbz-slint/src/sidebar.rs +++ b/crates/qbz-slint/src/sidebar.rs @@ -12,7 +12,7 @@ use qbz_core::FrontendAdapter; use slint::{ComponentHandle, ModelRc, VecModel}; use crate::folders::FolderInfo; -use crate::{AppWindow, SidebarEntry, SidebarPlaylistItem, SidebarState}; +use crate::{AppWindow, SidebarEntry, SidebarFolderPopupState, SidebarPlaylistItem, SidebarState}; #[derive(Clone)] pub struct SidebarPlaylist { @@ -238,6 +238,28 @@ fn playlist_entry(p: &SidebarPlaylist, indent: bool, folder_id: &str) -> Sidebar } } +/// Populate the collapsed-sidebar folder flyout with `folder_id`'s playlists, +/// built from the cache so it works even for collapsed folders (whose +/// children are absent from the flattened `entries`). +pub fn load_folder_popup(window: &AppWindow, folder_id: &str) { + let data = CACHE.lock().map(|c| c.clone()).unwrap_or_default(); + let sorted = sort_playlists(&data.playlists); + let entries: Vec = sorted + .iter() + .filter(|p| { + data.folder_map + .get(&p.id) + .map(|f| f.as_str() == folder_id) + .unwrap_or(false) + }) + .filter(|p| !data.hidden_playlists.contains(&p.id)) + .map(|p| playlist_entry(p, true, folder_id)) + .collect(); + window + .global::() + .set_playlists(ModelRc::new(VecModel::from(entries))); +} + /// Rebuild the flattened entries (+ the folders list for the /// move-to-folder menu) from the cache + expand state, applying the /// active sort and the playlist-name search filter. diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 2a11f3a8d..b738e3928 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -18,7 +18,7 @@ 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, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState } 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, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, 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`. diff --git a/crates/qbz-slint/ui/favorites/FavoritesView.slint b/crates/qbz-slint/ui/favorites/FavoritesView.slint index 7e854b015..6630e37a3 100644 --- a/crates/qbz-slint/ui/favorites/FavoritesView.slint +++ b/crates/qbz-slint/ui/favorites/FavoritesView.slint @@ -35,103 +35,24 @@ import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { ListView } from "std-widgets.slint"; // One tab in the favorites tab menu. -component FavTab inherits Rectangle { - in property label; - in property id; - in property count; - callback selected(string); - - property active: FavoritesState.active-tab == root.id; - height: 34px; - width: tab-row.preferred-width; - - tab-row := HorizontalLayout { - padding-left: 4px; - padding-right: 4px; - spacing: 6px; - Text { - text: root.label; - color: root.active ? Theme.text-primary : Theme.text-muted; - font-size: Typography.body; - font-weight: root.active ? Typography.semibold : Typography.regular; - vertical-alignment: center; - } - if root.count > 0: Text { - text: root.count; - color: Theme.text-muted; - font-size: Typography.legal; - vertical-alignment: center; - } - } - // Active underline. - Rectangle { - y: parent.height - 2px; - height: 2px; - width: parent.width; - background: root.active ? Theme.accent : transparent; - border-radius: 1px; - } - ta := TouchArea { - mouse-cursor: pointer; - clicked => { - root.selected(root.id); - } - } -} - -// The 5 favorites tabs as one floating group (row 1, right side). Counts -// stay live (FavoritesState.*-total are seeded up front). -component FavTabMenu inherits HorizontalLayout { - spacing: 18px; - VerticalLayout { - alignment: center; - FavTab { - label: @tr("Tracks"); - id: "tracks"; - count: FavoritesState.tracks-total; - selected(id) => { FavoritesActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - FavTab { - label: @tr("Albums"); - id: "albums"; - count: FavoritesState.albums-total; - selected(id) => { FavoritesActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - FavTab { - label: @tr("Artists"); - id: "artists"; - count: FavoritesState.artists-total; - selected(id) => { FavoritesActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - FavTab { - label: @tr("Playlists"); - id: "playlists"; - count: FavoritesState.playlists-total; - selected(id) => { FavoritesActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - FavTab { - label: @tr("Labels"); - id: "labels"; - count: FavoritesState.labels-total; - selected(id) => { FavoritesActions.select-tab(id); } - } - } +// The 5 favorites tabs as one floating group (row 1, right side) — the +// shared Discovery-style SegmentedTabBar with count badges. Counts stay +// live (FavoritesState.*-total are seeded up front). +component FavTabMenu inherits SegmentedTabBar { + active-id: FavoritesState.active-tab; + tabs: [ + { id: "tracks", label: @tr("Tracks"), count: FavoritesState.tracks-total }, + { id: "albums", label: @tr("Albums"), count: FavoritesState.albums-total }, + { id: "artists", label: @tr("Artists"), count: FavoritesState.artists-total }, + { id: "playlists", label: @tr("Playlists"), count: FavoritesState.playlists-total }, + { id: "labels", label: @tr("Labels"), count: FavoritesState.labels-total }, + ]; + selected(id) => { FavoritesActions.select-tab(id); } } // One underline sub-tab for the Playlists tab (Library / Following). @@ -619,10 +540,6 @@ export component FavoritesView inherits Rectangle { padding-right: 32px; padding-top: 11px; spacing: 16px; - VerticalLayout { - alignment: center; - NavButtons { } - } VerticalLayout { alignment: center; Text { @@ -632,11 +549,84 @@ export component FavoritesView inherits Rectangle { font-weight: Typography.bold; } } - Rectangle { horizontal-stretch: 1; } - VerticalLayout { - alignment: center; - FavTabMenu { } + // Reserve the action-button height from the start so the header + // doesn't jump when the (conditional) buttons appear after load. + Rectangle { width: 0px; height: 40px; } + // Per-tab action buttons — inline, to the right of the title. + if FavoritesState.active-tab == "tracks" + && FavoritesState.tracks-visible.length > 0: HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/play-fill.svg"); + primary: true; + clicked => { FavoritesActions.play-all-tracks(); } + } + } + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/shuffle.svg"); + clicked => { FavoritesActions.shuffle-tracks(); } + } + } + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/square-check-big.svg"); + active: FavoritesState.tracks-multi-select; + clicked => { FavoritesActions.toggle-multi-select(); } + } + } } + if FavoritesState.active-tab == "albums" + && FavoritesState.albums-visible.length > 0: HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/shuffle.svg"); + clicked => { FavoritesActions.albums-shuffle(); } + } + } + } + if FavoritesState.active-tab == "artists" + && FavoritesState.artists-visible.length > 0: HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/shuffle.svg"); + clicked => { FavoritesActions.artists-shuffle(); } + } + } + } + // Playlists — play a random playlist. + if FavoritesState.active-tab == "playlists" + && FavoritesState.playlists-visible.length > 0: HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/shuffle.svg"); + clicked => { FavoritesActions.playlists-random(); } + } + } + } + // Labels — open a random label's landing. + if FavoritesState.active-tab == "labels" + && FavoritesState.labels-visible.length > 0: HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/shuffle.svg"); + clicked => { FavoritesActions.labels-random(); } + } + } + } + Rectangle { horizontal-stretch: 1; } } Rectangle { height: 12px; } @@ -654,65 +644,10 @@ export component FavoritesView inherits Rectangle { padding-right: 32px; spacing: 8px; - // --- Left: action buttons --- - if FavoritesState.active-tab == "tracks" - && FavoritesState.tracks-visible.length > 0: HorizontalLayout { - spacing: 8px; - VerticalLayout { - alignment: center; - CircleAction { - icon: @image-url("../assets/icons/play-fill.svg"); - primary: true; - clicked => { - FavoritesActions.play-all-tracks(); - } - } - } - VerticalLayout { - alignment: center; - CircleAction { - icon: @image-url("../assets/icons/shuffle.svg"); - clicked => { - FavoritesActions.shuffle-tracks(); - } - } - } - VerticalLayout { - alignment: center; - CircleAction { - icon: @image-url("../assets/icons/square-check-big.svg"); - active: FavoritesState.tracks-multi-select; - clicked => { - FavoritesActions.toggle-multi-select(); - } - } - } - } - if FavoritesState.active-tab == "albums" - && FavoritesState.albums-visible.length > 0: HorizontalLayout { - spacing: 8px; - VerticalLayout { - alignment: center; - CircleAction { - icon: @image-url("../assets/icons/shuffle.svg"); - clicked => { - FavoritesActions.albums-shuffle(); - } - } - } - } - if FavoritesState.active-tab == "artists" - && FavoritesState.artists-visible.length > 0: HorizontalLayout { - spacing: 8px; - VerticalLayout { - alignment: center; - CircleAction { - icon: @image-url("../assets/icons/shuffle.svg"); - clicked => { - FavoritesActions.artists-shuffle(); - } - } - } + // --- Left: the section tab menu, sitting under the title --- + VerticalLayout { + alignment: center; + FavTabMenu { } } Rectangle { horizontal-stretch: 1; } @@ -886,22 +821,8 @@ export component FavoritesView inherits Rectangle { } } } - // Grid <-> sidepanel view toggle. - VerticalLayout { - alignment: center; - ToggleButton { - icon: FavoritesState.artists-view-mode == "sidepanel" - ? @image-url("../assets/icons/layout-grid.svg") - : @image-url("../assets/icons/list.svg"); - active: FavoritesState.artists-view-mode == "sidepanel"; - sm: true; - clicked => { - FavoritesActions.artists-set-view( - FavoritesState.artists-view-mode == "grid" ? "sidepanel" : "grid"); - } - } - } // Group (A-Z) — grid mode only (Tauri hides it in sidepanel). + // Sits between the search field and the view toggle. if FavoritesState.artists-view-mode == "grid": VerticalLayout { alignment: center; QbzSelect { @@ -917,6 +838,22 @@ export component FavoritesView inherits Rectangle { } } } + // Grid <-> sidepanel view toggle. active:false keeps the two + // icon states visually identical (homologated background). + VerticalLayout { + alignment: center; + ToggleButton { + icon: FavoritesState.artists-view-mode == "sidepanel" + ? @image-url("../assets/icons/layout-grid.svg") + : @image-url("../assets/icons/list.svg"); + active: false; + sm: true; + clicked => { + FavoritesActions.artists-set-view( + FavoritesState.artists-view-mode == "grid" ? "sidepanel" : "grid"); + } + } + } } } @@ -985,7 +922,9 @@ export component FavoritesView inherits Rectangle { // --- Tracks tab — virtualized self-scrolling list -------------- if !FavoritesState.loading && FavoritesState.load-error == "" - && FavoritesState.active-tab == "tracks": VerticalLayout { + && FavoritesState.active-tab == "tracks": Rectangle { + vertical-stretch: 1; + VerticalLayout { padding-left: 32px; padding-right: 32px; padding-top: 16px; @@ -1021,12 +960,9 @@ export component FavoritesView inherits Rectangle { font-size: Typography.body; } - Rectangle { + track-list := ListView { vertical-stretch: 1; - track-list := ListView { - width: 100%; - height: 100%; - vertical-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; for track[i] in FavoritesState.tracks-visible: VerticalLayout { spacing: 0px; // Group header rows when grouping by album / artist. @@ -1082,25 +1018,25 @@ export component FavoritesView inherits Rectangle { } } } - ListScrollbar { - x: parent.width - self.width - 4px; - width: 14px; - height: 100%; - viewport-height: track-list.viewport-height; - visible-height: track-list.visible-height; - viewport-y <=> track-list.viewport-y; - } - // A-Z jump strip (name grouping) — accurate (uniform rows). - if FavoritesState.tracks-group-mode == "name" - && FavoritesState.tracks-alpha.length > 0: AlphaStrip { - x: parent.width - self.width - 20px; - height: 100%; - jumps: FavoritesState.tracks-alpha; - jump(ord, idx) => { - track-list.viewport-y = -Math.min( - idx * 50px / 1px, - Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px; - } + } + ListScrollbar { + x: parent.width - self.width - 4px; + width: 14px; + height: 100%; + viewport-height: track-list.viewport-height; + visible-height: track-list.visible-height; + viewport-y <=> track-list.viewport-y; + } + // A-Z jump strip (name grouping) — accurate (uniform rows). + if FavoritesState.tracks-group-mode == "name" + && FavoritesState.tracks-alpha.length > 0: AlphaStrip { + x: parent.width - self.width - 20px; + height: 100%; + jumps: FavoritesState.tracks-alpha; + jump(ord, idx) => { + track-list.viewport-y = -Math.min( + idx * 50px / 1px, + Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px; } } } diff --git a/crates/qbz-slint/ui/foundation/layout.slint b/crates/qbz-slint/ui/foundation/layout.slint index c28262e69..8ad9a81ab 100644 --- a/crates/qbz-slint/ui/foundation/layout.slint +++ b/crates/qbz-slint/ui/foundation/layout.slint @@ -1,8 +1,9 @@ // Shell layout metrics. export global Layout { - out property sidebar-width: 280px; - out property header-height: 48px; + // Max sidebar width (also the default). The drag handle clamps to this. + out property sidebar-width: 260px; + out property header-height: 42px; out property player-bar-height: 110px; out property hairline: 1px; } diff --git a/crates/qbz-slint/ui/foundation/semantic-colors.slint b/crates/qbz-slint/ui/foundation/semantic-colors.slint index 45bf5ef50..33aeaeb67 100644 --- a/crates/qbz-slint/ui/foundation/semantic-colors.slint +++ b/crates/qbz-slint/ui/foundation/semantic-colors.slint @@ -1,28 +1,69 @@ // Semantic color tokens — the contract every component consumes. // -// Currently bound to the QBZ dark theme. When theme switching lands -// (later in M1), `Theme` becomes the surface that swaps palettes; the -// token names stay stable so components never change. +// `Theme` is the surface that swaps palettes: every token is conditional on +// ThemeState.mode (0 = Dark, 1 = OLED Black, 2 = Tokyo Night, 3 = System). +// The System mode sources colors from the std-widgets Palette, which follows +// the OS light/dark scheme. Token names stay stable so components never +// change. The fixed values mirror src/app.css. import { Palette } from "palette.slint"; +import { Palette as StdPalette } from "std-widgets.slint"; +import { ThemeState } from "theme-state.slint"; export global Theme { - // Surfaces - out property surface-main: Palette.black-0; - out property surface-card: Palette.black-1; - out property surface-elevated: Palette.black-2; + // Surfaces (--bg-primary / --bg-secondary / --bg-tertiary) + out property surface-main: + ThemeState.mode == 1 ? #000000 + : ThemeState.mode == 2 ? #1a1b26 + : ThemeState.mode == 3 ? StdPalette.background + : Palette.black-0; + out property surface-card: + ThemeState.mode == 1 ? #0a0a0a + : ThemeState.mode == 2 ? #16161e + : ThemeState.mode == 3 ? StdPalette.alternate-background + : Palette.black-1; + out property surface-elevated: + ThemeState.mode == 1 ? #1a1a1a + : ThemeState.mode == 2 ? #1c1d29 + : ThemeState.mode == 3 ? StdPalette.control-background + : Palette.black-2; - // Text - out property text-primary: Palette.white; - out property text-secondary: Palette.gray-c; - out property text-muted: Palette.gray-8; - out property text-disabled: Palette.gray-5; + // Text (Tokyo Night recolors; OLED keeps the default dark text; System + // derives muted tiers from the OS foreground via alpha) + out property text-primary: + ThemeState.mode == 2 ? #a9b1d6 + : ThemeState.mode == 3 ? StdPalette.foreground + : Palette.white; + out property text-secondary: + ThemeState.mode == 2 ? #787c99 + : ThemeState.mode == 3 ? StdPalette.foreground.with-alpha(0.78) + : Palette.gray-c; + out property text-muted: + ThemeState.mode == 2 ? #545c7e + : ThemeState.mode == 3 ? StdPalette.foreground.with-alpha(0.55) + : Palette.gray-8; + out property text-disabled: + ThemeState.mode == 2 ? #3d425e + : ThemeState.mode == 3 ? StdPalette.foreground.with-alpha(0.35) + : Palette.gray-5; - // Accent - out property accent: Palette.blue; - out property accent-hover: Palette.blue-hover; - out property accent-pressed: Palette.blue-active; - out property accent-text: Palette.white; + // Accent (--accent-primary / hover / active / btn-primary-text) + out property accent: + ThemeState.mode == 2 ? #7aa2f7 + : ThemeState.mode == 3 ? StdPalette.accent-background + : Palette.blue; + out property accent-hover: + ThemeState.mode == 2 ? #7dcfff + : ThemeState.mode == 3 ? StdPalette.accent-background.brighter(0.15) + : Palette.blue-hover; + out property accent-pressed: + ThemeState.mode == 2 ? #bb9af7 + : ThemeState.mode == 3 ? StdPalette.accent-background.darker(0.15) + : Palette.blue-active; + out property accent-text: + ThemeState.mode == 2 ? #1a1b26 + : ThemeState.mode == 3 ? StdPalette.accent-foreground + : Palette.white; // Favorite (heart) — the active "loved" state. out property favorite: Palette.danger; diff --git a/crates/qbz-slint/ui/foundation/theme-state.slint b/crates/qbz-slint/ui/foundation/theme-state.slint new file mode 100644 index 000000000..0aa63aac7 --- /dev/null +++ b/crates/qbz-slint/ui/foundation/theme-state.slint @@ -0,0 +1,11 @@ +// Active theme mode — the single switch that swaps the semantic palette. +// Kept in foundation (not state.slint) so semantic-colors.slint can read it +// without a circular import. Set from Settings > Appearance > Theme. +// +// 0 = Dark (default) 1 = OLED Black 2 = Tokyo Night 3 = System +// +// "System" (3) sources colors from the std-widgets Palette (OS light/dark). +// The Appearance dropdown lists only these ported themes. +export global ThemeState { + in-out property mode: 0; +} diff --git a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint index 801bf64b0..823e980db 100644 --- a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint @@ -29,95 +29,23 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { ToggleButton } from "../primitives/ToggleButton.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; +import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint"; import { ListView } from "std-widgets.slint"; -// One tab in the Local Library tab menu — underline style (ADR-008: NO -// pills). 1:1 with Favorites' FavTab so the chrome reads identically. -component LibTab inherits Rectangle { - in property label; - in property id; - in property count; - callback selected(string); - - property active: LocalLibraryState.active-tab == root.id; - height: 34px; - width: tab-row.preferred-width; - - tab-row := HorizontalLayout { - padding-left: 4px; - padding-right: 4px; - spacing: 6px; - Text { - text: root.label; - color: root.active ? Theme.text-primary : Theme.text-muted; - font-size: Typography.body; - font-weight: root.active ? Typography.semibold : Typography.regular; - vertical-alignment: center; - } - if root.count > 0: Text { - text: root.count; - color: Theme.text-muted; - font-size: Typography.legal; - vertical-alignment: center; - } - } - // Active underline (NOT a pill background). - Rectangle { - y: parent.height - 2px; - height: 2px; - width: parent.width; - background: root.active ? Theme.accent : transparent; - border-radius: 1px; - } - ta := TouchArea { - mouse-cursor: pointer; - clicked => { - root.selected(root.id); - } - } -} - -// The Local Library tabs as one floating group (row 1, right side). Order -// mirrors the existing HeaderBar "Local Library" menu (Albums / Artists / -// Folders / Tracks). Counts come from LocalLibraryState (0 until seeded). -component LibTabMenu inherits HorizontalLayout { - spacing: 18px; - VerticalLayout { - alignment: center; - LibTab { - label: @tr("Albums"); - id: "albums"; - count: LocalLibraryState.album-count; - selected(id) => { LocalLibraryActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - LibTab { - label: @tr("Artists"); - id: "artists"; - count: LocalLibraryState.artist-count; - selected(id) => { LocalLibraryActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - LibTab { - label: @tr("Folders"); - id: "folders"; - count: LocalLibraryState.folder-count; - selected(id) => { LocalLibraryActions.select-tab(id); } - } - } - VerticalLayout { - alignment: center; - LibTab { - label: @tr("Tracks"); - id: "tracks"; - count: LocalLibraryState.track-count; - selected(id) => { LocalLibraryActions.select-tab(id); } - } - } +// The Local Library tabs as one floating group (row 1, right side) — now +// the shared Discovery-style SegmentedTabBar, counts rendered as badges. +// Order mirrors the existing HeaderBar "Local Library" menu (Albums / +// Artists / Folders / Tracks). Counts come from LocalLibraryState (seeded +// up front so the badges show without visiting each tab). +component LibTabMenu inherits SegmentedTabBar { + active-id: LocalLibraryState.active-tab; + tabs: [ + { id: "albums", label: @tr("Albums"), count: LocalLibraryState.album-count }, + { id: "artists", label: @tr("Artists"), count: LocalLibraryState.artist-count }, + { id: "folders", label: @tr("Folders"), count: LocalLibraryState.folder-count }, + { id: "tracks", label: @tr("Tracks"), count: LocalLibraryState.track-count }, + ]; + selected(id) => { LocalLibraryActions.select-tab(id); } } // Standalone icon-only circle (ADR-008 permits a full circle for an @@ -445,10 +373,6 @@ export component LocalLibraryView inherits Rectangle { padding-right: 32px; padding-top: 11px; spacing: 16px; - VerticalLayout { - alignment: center; - NavButtons { } - } VerticalLayout { alignment: center; Text { @@ -458,11 +382,7 @@ export component LocalLibraryView inherits Rectangle { font-weight: Typography.bold; } } - Rectangle { horizontal-stretch: 1; } - VerticalLayout { - alignment: center; - LibTabMenu { } - } + // Settings gear — inline, to the right of the title. VerticalLayout { alignment: center; CircleIconBtn { @@ -472,6 +392,7 @@ export component LocalLibraryView inherits Rectangle { } } } + Rectangle { horizontal-stretch: 1; } } Rectangle { height: 12px; } @@ -486,6 +407,12 @@ export component LocalLibraryView inherits Rectangle { padding-right: 32px; spacing: 8px; + // The section tab menu sits under the title (left of row 2). + VerticalLayout { + alignment: center; + LibTabMenu { } + } + Rectangle { horizontal-stretch: 1; } // Albums toolbar — search + sort (both server-side). The diff --git a/crates/qbz-slint/ui/primitives/AlbumListRow.slint b/crates/qbz-slint/ui/primitives/AlbumListRow.slint index 177891e0d..528a9e4a6 100644 --- a/crates/qbz-slint/ui/primitives/AlbumListRow.slint +++ b/crates/qbz-slint/ui/primitives/AlbumListRow.slint @@ -266,18 +266,25 @@ export component AlbumListRow inherits Rectangle { overflow: elide; } - // --- SOURCE (hideable) ---------------------------------------- - if root.show-source: Text { + // --- SOURCE (hideable) — discreet per-source glyph, NOT text. --- + // Mirrors TrackRow's source indicator: local -> tinted hard-drive, + // qobuz/offline + plex -> their own mark; small + flat (the bold + // colored badge is reserved for the AlbumCard grid). + if root.show-source: Rectangle { width: AlbumListCols.source; - text: root.album.source == "qobuz" ? @tr("Qobuz") - : root.album.source == "qobuz_download" ? @tr("Offline") - : root.album.source == "plex" ? @tr("Plex") - : root.album.source == "local" ? @tr("Local") - : ""; - color: Theme.text-secondary; - font-size: 12px; - vertical-alignment: center; - overflow: elide; + if root.album.source != "": Image { + source: root.album.source == "plex" + ? @image-url("../assets/icons/plex-logo.svg") + : (root.album.source == "qobuz" || root.album.source == "qobuz_download" + ? @image-url("../assets/icons/qobuz-logo-filled.svg") + : @image-url("../assets/icons/hard-drive.svg")); + colorize: root.album.source == "local" ? Theme.text-muted : transparent; + width: root.album.source == "local" ? 15px : 16px; + height: self.width; + x: 0; + y: (parent.height - self.height) / 2; + image-fit: contain; + } } // --- QUALITY (shared QualityBadgeFull) ------------------------ diff --git a/crates/qbz-slint/ui/primitives/QbzSelect.slint b/crates/qbz-slint/ui/primitives/QbzSelect.slint index 19b4f4d51..eb2f70e7f 100644 --- a/crates/qbz-slint/ui/primitives/QbzSelect.slint +++ b/crates/qbz-slint/ui/primitives/QbzSelect.slint @@ -52,15 +52,18 @@ export component QbzSelect inherits Rectangle { in property sm: false; callback selected(int); + // Toolbar (sm) selects — Group / Sort / filter — were unnecessarily wide; + // trim 20px from the effective width (button + list). + private property eff-width: root.menu-width - (root.sm ? 20px : 0px); // Resolved width of the open list. private property list-width: - root.popup-width > root.menu-width ? root.popup-width : root.menu-width; + root.popup-width > root.eff-width ? root.popup-width : root.eff-width; // Height of one option row and one section-header row. private property row-height: 32px; private property header-height: 24px; - width: root.menu-width; + width: root.eff-width; height: root.sm ? 30px : 34px; // sm matches the favorites toolbar buttons (6px, no outline); the // standard size keeps the settings-style bordered look. @@ -113,7 +116,7 @@ export component QbzSelect inherits Rectangle { popup := PopupWindow { // Right-anchored so a list wider than the control grows leftward // and stays on-screen. - x: root.menu-width - root.list-width; + x: root.eff-width - root.list-width; y: root.height + 4px; width: root.list-width; height: list-layout.preferred-height; diff --git a/crates/qbz-slint/ui/primitives/QualityBadge.slint b/crates/qbz-slint/ui/primitives/QualityBadge.slint index 05b6cfb35..cbdd1238a 100644 --- a/crates/qbz-slint/ui/primitives/QualityBadge.slint +++ b/crates/qbz-slint/ui/primitives/QualityBadge.slint @@ -10,7 +10,7 @@ import { Radius } from "../foundation/radius.slint"; import { QbzIcon } from "QbzIcon.slint"; export component QualityBadge inherits Rectangle { - // "hires" | "cd" | "" — empty hides the badge entirely. + // "hires" | "cd" | "mp3" | "" — empty hides the badge entirely. in property tier; // Exact quality string for the hover tooltip. in property label; @@ -45,6 +45,23 @@ export component QualityBadge inherits Rectangle { tint: #b0b0b0; } } + if root.tier == "mp3": Rectangle { + width: 30px; + height: 30px; + border-radius: 3px; + background: Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + QbzIcon { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 16px; + height: 16px; + source: @image-url("../assets/mp3.svg"); + image-fit: contain; + tint: #b0b0b0; + } + } // Hover tooltip — floats just above the badge, right-anchored so it // extends leftward into the card and is never clipped by the next card. diff --git a/crates/qbz-slint/ui/primitives/SegmentedTabBar.slint b/crates/qbz-slint/ui/primitives/SegmentedTabBar.slint new file mode 100644 index 000000000..af2880ab0 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/SegmentedTabBar.slint @@ -0,0 +1,95 @@ +// Segmented tab navigator — the SAME design Discovery's HomeView uses (a +// small-radius rectangle group, ADR-008-compliant: NOT a capsule pill), +// promoted to a shared, data-driven primitive so Local Library and Library +// (Favorites) read identically. Each tab carries an optional count rendered +// as a compact badge with smaller-but-legible type inside the tab item. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; + +// One entry in the navigator. `count <= 0` hides the badge. +export struct SegTab { + id: string, + label: string, + count: int, +} + +// A single segment: fills with surface-main when active (Discovery's Tab), +// hover surface-hover, transparent otherwise. The count sits in a rounded +// badge to the right of the label. +component Segment inherits Rectangle { + in property label; + in property count; + in property active; + callback clicked(); + + border-radius: 4px; + background: root.active ? Theme.surface-main + : ta.has-hover ? Theme.surface-hover : transparent; + + HorizontalLayout { + padding-left: 12px; + padding-right: root.count > 0 ? 8px : 12px; + padding-top: 6px; + padding-bottom: 6px; + spacing: 7px; + Text { + text: root.label; + color: root.active ? Theme.text-primary : Theme.text-muted; + // Discovery's tab type: 13px medium. + font-size: Typography.legal; + font-weight: Typography.medium; + vertical-alignment: center; + } + // Count badge — smaller (11px) but legible. min-width keeps single + // digits balanced; the layout grows the chip for 2–3 digit counts. + if root.count > 0: VerticalLayout { + alignment: center; + Rectangle { + min-width: 18px; + height: 16px; + border-radius: 8px; + background: root.active ? #ffffff24 : #ffffff14; + HorizontalLayout { + alignment: center; + padding-left: 5px; + padding-right: 5px; + Text { + text: root.count; + color: root.active ? Theme.text-primary : Theme.text-secondary; + font-size: 11px; + font-weight: Typography.medium; + vertical-alignment: center; + } + } + } + } + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +export component SegmentedTabBar inherits Rectangle { + in property <[SegTab]> tabs; + in property active-id; + callback selected(string); + + width: row.preferred-width; + height: row.preferred-height; + background: Theme.surface-elevated; + border-radius: 6px; + + row := HorizontalLayout { + padding: 3px; + spacing: 4px; + for tab in root.tabs: Segment { + label: tab.label; + count: tab.count; + active: root.active-id == tab.id; + clicked => { root.selected(tab.id); } + } + } +} diff --git a/crates/qbz-slint/ui/settings/AppearanceSettings.slint b/crates/qbz-slint/ui/settings/AppearanceSettings.slint index 1c6a3310e..14e84ee18 100644 --- a/crates/qbz-slint/ui/settings/AppearanceSettings.slint +++ b/crates/qbz-slint/ui/settings/AppearanceSettings.slint @@ -11,9 +11,10 @@ // flags Tauri gates on (System theme, title-bar mode, etc.). import { Theme } from "../foundation/semantic-colors.slint"; +import { ThemeState } from "../foundation/theme-state.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { AppearanceState, SidebarState } from "../state.slint"; +import { AppearanceState, SidebarState, ShellState } from "../state.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; import { QbzSelect } from "../primitives/QbzSelect.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; @@ -128,7 +129,6 @@ export component AppearanceSettings inherits VerticalLayout { GroupHeader { text: @tr("THEME"); } SettingRow { - unsupported: true; label: @tr("Theme"); HorizontalLayout { spacing: 8px; @@ -164,6 +164,10 @@ export component AppearanceSettings inherits VerticalLayout { current-index: AppearanceState.theme-index; selected(i) => { AppearanceState.theme-index = i; + // The themes list is trimmed to the ported set, so the + // dropdown index maps 1:1 to the mode (0 Dark, 1 OLED, + // 2 Tokyo Night, 3 System). + ThemeState.mode = i; AppearanceState.appearance-select("theme", i); } } @@ -284,6 +288,16 @@ export component AppearanceSettings inherits VerticalLayout { // =================================================================== GroupHeader { text: @tr("LIBRARY & VISUALS"); } + SettingRow { + label: @tr("Show navigation in sidebar"); + description: @tr("Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar."); + QbzToggle { + checked: ShellState.nav-in-sidebar; + toggled(v) => { + ShellState.nav-in-sidebar = v; + } + } + } SettingRow { label: @tr("Show playlist cover collages in sidebar"); description: @tr("Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images."); @@ -632,6 +646,16 @@ export component AppearanceSettings inherits VerticalLayout { // =================================================================== GroupHeader { text: @tr("PLAYER VIEWS"); } + SettingRow { + label: @tr("Show volume +/- buttons"); + description: @tr("Add discrete plus and minus buttons next to the volume slider in the player bar."); + QbzToggle { + checked: AppearanceState.show-volume-steppers; + toggled(v) => { + AppearanceState.show-volume-steppers = v; + } + } + } SettingRow { unsupported: true; label: @tr("Mini player default view"); diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index c273c9bef..0377f3863 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -6,10 +6,12 @@ // content well); no hairline borders. import { Theme } from "../foundation/semantic-colors.slint"; +import { Radius } from "../foundation/radius.slint"; import { NavState, ContentView, ShellState } from "../state.slint"; import { Sidebar } from "Sidebar.slint"; import { HeaderBar } from "HeaderBar.slint"; import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint"; +import { SidebarFolderPopup } from "SidebarFolderPopup.slint"; import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint"; import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint"; import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint"; @@ -72,6 +74,7 @@ export component AppShell inherits Rectangle { VerticalLayout { spacing: 0px; + // Header spans the full width at the top; the sidebar starts below it. HeaderBar { logout => { root.logout(); @@ -85,12 +88,33 @@ export component AppShell inherits Rectangle { vertical-stretch: 1; spacing: 0px; - Sidebar { } + Sidebar { + navigate(route) => { + root.header-menu-navigate(route); + } + } - // Content region — Home, Album, or Artist. + // Content frame — surface-card chrome around a rounded, inset + // content panel (Slack-style bezel on all four corners). Rectangle { horizontal-stretch: 1; - background: Theme.surface-main; + background: Theme.surface-card; + VerticalLayout { + vertical-stretch: 1; + padding-left: 8px; + padding-right: 8px; + // No top padding: the contiguous surface-card gap below the + // header made the items look high (the perceived header + // band was taller than its real height). The panel now + // butts the header; its rounded top corners still bezel. + padding-top: 0px; + padding-bottom: 8px; + + Rectangle { + vertical-stretch: 1; + background: Theme.surface-main; + border-radius: Radius.md; + clip: true; if NavState.view == ContentView.home: HomeView { album-clicked(id) => { @@ -240,6 +264,8 @@ export component AppShell inherits Rectangle { root.media-action(kind, id, action); } } + } + } } // Right-side panels — layout siblings of the content area, so @@ -269,6 +295,9 @@ export component AppShell inherits Rectangle { } } + // Collapsed-sidebar folder flyout (mini state). Drawn above the content. + SidebarFolderPopup { } + // Global "Add to playlist" picker overlay, opened from any track // context menu. PlaylistPickerModal { } diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint index e6c1d581e..71e0d6125 100644 --- a/crates/qbz-slint/ui/shell/HeaderBar.slint +++ b/crates/qbz-slint/ui/shell/HeaderBar.slint @@ -16,32 +16,6 @@ import { Radius } from "../foundation/radius.slint"; import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; -// Sidebar collapse toggle — bare bright icon, no background. Sits at -// the very left of the header, before the Discover tab. -component SidebarToggle inherits Rectangle { - width: 30px; - height: 30px; - - // 14px matches the nav-tab icons; with the 30px box the centering - // math ((30-14)/2 = 8) lands on whole pixels so the glyph stays - // crisp. Tint matches the tab icons (secondary, primary on hover). - QbzIcon { - source: @image-url("../assets/icons/panel-left.svg"); - width: 14px; - height: 14px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; - tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary; - } - - ta := TouchArea { - mouse-cursor: pointer; - clicked => { - ShellState.sidebar-collapsed = !ShellState.sidebar-collapsed; - } - } -} - component NavTab inherits Rectangle { in property icon; in property label; @@ -200,6 +174,91 @@ component NavTabWithMenu inherits Rectangle { } } +// Compact icon-only nav button shown in the header ONLY while the sidebar is +// fully closed (Cerrado), so the section nav stays reachable. Opens a downward +// dropdown headed by the section name (label + hairline + the clickable rows). +component CompactNavBtn inherits Rectangle { + in property icon; + in property title; + in property <[NavMenuEntry]> items; + in property tab-index; + in property home-item: -1; + + width: 30px; + height: 30px; + border-radius: Radius.sm; + property active: HeaderMenuState.open-index == root.tab-index; + background: root.active ? Theme.surface-elevated + : cn-ta.has-hover ? Theme.surface-hover : transparent; + + function open-menu() { + HeaderMenuState.open-index = root.tab-index; + HeaderMenuState.anchor-x = root.absolute-position.x; + HeaderMenuState.from-sidebar = false; + HeaderMenuState.title = root.title; + HeaderMenuState.items = root.items; + HeaderMenuState.home-item = root.home-item; + } + + QbzIcon { + source: root.icon; + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: root.active ? Theme.text-primary : Theme.text-secondary; + } + cn-ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.open-menu(); } + changed has-hover => { + if (self.has-hover) { + HeaderMenuState.trigger-hovered = true; + root.open-menu(); + } else if (root.active) { + // Only the trigger owning the open menu clears the flag — this + // avoids a leave/enter race when sliding between triggers. + HeaderMenuState.trigger-hovered = false; + } + } + } +} + +// Circular history / collapse button for the header's right cluster — +// same look as the Discover carousel navigators. `enabled` dims + disables. +component HeaderNavBtn inherits Rectangle { + in property icon; + in property enabled: true; + callback clicked(); + + width: 28px; + height: 28px; + border-radius: 14px; + opacity: root.enabled ? 1.0 : 0.4; + background: hn-ta.has-hover && root.enabled + ? Theme.surface-hover + : Theme.surface-elevated; + + QbzIcon { + source: root.icon; + width: 15px; + height: 15px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: root.enabled ? Theme.text-primary : Theme.text-muted; + } + + hn-ta := TouchArea { + enabled: root.enabled; + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +} + // Square icon button for the header's right-side controls. `active` // gives it a highlighted look when its associated panel is open. component HeaderIconButton inherits Rectangle { @@ -254,10 +313,12 @@ export component HeaderBar inherits Rectangle { y: (root.height - self.height) / 2; height: 30px; spacing: 2px; - VerticalLayout { - alignment: center; - SidebarToggle { } - } + // Section nav lives in the header by default; when the user moves it + // to the sidebar (ShellState.nav-in-sidebar) the header tabs hide. + // The sidebar-collapse toggle no longer lives here — it moved next to + // the back/forward nav buttons (NavButtons), present in every view. + if !ShellState.nav-in-sidebar: HorizontalLayout { + spacing: 2px; NavTabWithMenu { tab-index: 0; icon: @image-url("../assets/icons/compass.svg"); @@ -308,6 +369,7 @@ export component HeaderBar inherits Rectangle { { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true }, ]; } + } } // --- Search (absolutely centered on the window) ------------------------ @@ -320,81 +382,155 @@ export component HeaderBar inherits Rectangle { border-width: 1px; border-color: Theme.border-subtle; background: Theme.surface-elevated; - HorizontalLayout { - padding-left: 10px; - padding-right: 10px; - spacing: 7px; + + // Magnifier — absolutely positioned on the left so it does NOT take + // layout space; the text/placeholder center against the full input + // width as if the icon weren't there. + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 14px; + height: 14px; + x: 10px; + y: (parent.height - self.height) / 2; + tint: Theme.text-muted; + } + + // Full-width input — text centered against the whole box. + search-input := TextInput { + width: parent.width; + height: 100%; + color: Theme.text-primary; + font-size: 13px; + vertical-alignment: center; + horizontal-alignment: center; + single-line: true; + // Enter submits to the full results page; every edit feeds the + // debounced live search (Rust side). + accepted => { SearchActions.submit(self.text); } + edited => { SearchActions.live(self.text); } + } + Text { + visible: search-input.text == ""; + text: @tr("Search"); + width: parent.width; + height: 100%; + color: Theme.text-muted; + font-size: 13px; + vertical-alignment: center; + horizontal-alignment: center; + } + } + + // --- Left controls: history nav + collapse (anchored left) ------------- + // The three navigation buttons (back / forward / sidebar-collapse) float + // at the header's LEFT edge. + left-controls := HorizontalLayout { + x: Spacing.md; + y: (root.height - self.height) / 2; + width: self.preferred-width; + height: 36px; + spacing: 6px; + VerticalLayout { + alignment: center; + HeaderNavBtn { + icon: @image-url("../assets/icons/panel-left.svg"); + clicked => { ShellState.cycle-sidebar(); } + } + } + VerticalLayout { + alignment: center; + HeaderNavBtn { + icon: @image-url("../assets/icons/chevron-left.svg"); + enabled: NavState.can-back; + clicked => { NavState.request-back(); } + } + } + VerticalLayout { + alignment: center; + HeaderNavBtn { + icon: @image-url("../assets/icons/chevron-right.svg"); + enabled: NavState.can-forward; + clicked => { NavState.request-forward(); } + } + } + // Compact section nav — only while the sidebar is fully closed + // (Cerrado), so Discover / Library / Local Library / My QBZ stay + // reachable. Icon-only; each opens a titled downward dropdown. + if ShellState.sidebar-state == 2: HorizontalLayout { + spacing: 2px; + // A small gap from the history buttons. + Rectangle { width: 6px; } VerticalLayout { alignment: center; - QbzIcon { - source: @image-url("../assets/icons/search.svg"); - width: 14px; - height: 14px; - tint: Theme.text-muted; + CompactNavBtn { + tab-index: 0; + title: @tr("Discover"); + icon: @image-url("../assets/icons/compass.svg"); + home-item: 0; + items: [ + { label: @tr("Home"), has-glyph: false, route: "discover-home" }, + { label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" }, + { label: @tr("For You"), has-glyph: false, route: "discover-forYou" }, + ]; } } - // Real text input, with a placeholder shown while empty — - // same pattern as the album track-search field. - Rectangle { - horizontal-stretch: 1; - search-input := TextInput { - width: 100%; - height: 100%; - color: Theme.text-primary; - font-size: 13px; - vertical-alignment: center; - single-line: true; - // Enter submits to the full results page; every edit - // feeds the debounced live search (Rust side). - accepted => { SearchActions.submit(self.text); } - edited => { SearchActions.live(self.text); } + VerticalLayout { + alignment: center; + CompactNavBtn { + tab-index: 1; + title: @tr("Library"); + icon: @image-url("../assets/icons/music-library-2.svg"); + items: [ + { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, + { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, + { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" }, + { label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" }, + { label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" }, + ]; } - Text { - visible: search-input.text == ""; - text: @tr("Search"); - // Span the input box so vertical-alignment centers the - // placeholder the same way the TextInput centers its - // own text — otherwise it rides at the top. - width: 100%; - height: 100%; - color: Theme.text-muted; - font-size: 13px; - vertical-alignment: center; + } + VerticalLayout { + alignment: center; + CompactNavBtn { + tab-index: 2; + title: @tr("Local Library"); + icon: @image-url("../assets/icons/hard-drive.svg"); + items: [ + { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" }, + { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" }, + { label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" }, + { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" }, + ]; + } + } + VerticalLayout { + alignment: center; + CompactNavBtn { + tab-index: 3; + title: @tr("My QBZ"); + icon: @image-url("../assets/icons/cassette-tape.svg"); + items: [ + { label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true }, + { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true }, + ]; } } } } - // --- Right controls: queue, lyrics, hamburger (anchored right) --------- - // Explicit width (3 * 36 + 2 * 2 spacing) so the right anchor lands - // correctly — a layout left to its own devices fills the parent. + // --- Right controls: app menu (anchored right) ------------------------- right-controls := HorizontalLayout { - // 2px tighter to the window edge than the standard margin. x: root.width - self.width - Spacing.md + 2px; y: (root.height - self.height) / 2; - width: 112px; + width: 36px; height: 36px; - spacing: 2px; - // Lyrics panel toggle. - HeaderIconButton { - icon: @image-url("../assets/icons/captions.svg"); - active: ShellState.right-panel == 2; - clicked => { - ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2; - } - } - // Queue panel toggle. - HeaderIconButton { - icon: @image-url("../assets/icons/list-ordered.svg"); - active: ShellState.right-panel == 1; - clicked => { - ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1; - } - } - HeaderIconButton { - icon: @image-url("../assets/icons/menu.svg"); - clicked => { - menu.show(); + VerticalLayout { + alignment: center; + HeaderIconButton { + icon: @image-url("../assets/icons/menu.svg"); + clicked => { + menu.show(); + } } } } diff --git a/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint b/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint index 7bf8b8fa6..7f21cecaa 100644 --- a/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint +++ b/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint @@ -18,7 +18,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Radius } from "../foundation/radius.slint"; import { Layout } from "../foundation/layout.slint"; -import { HeaderMenuState, NavState, ContentView } from "../state.slint"; +import { HeaderMenuState, NavState, ContentView, ShellState } from "../state.slint"; import { NavMenuItem } from "NavMenuItem.slint"; export component HeaderMenuOverlay inherits Rectangle { @@ -30,38 +30,40 @@ export component HeaderMenuOverlay inherits Rectangle { // to the shell so Rust can run the navigation + data load. callback navigate(string); - // (Re)arms the idle countdown — called on any pointer activity over - // the panel or its rows. - function reset-idle() { - idle-timer.restart(); - } + // True while the pointer rests on the panel body (set by panel-ta, which + // lives inside the conditional panel and so can't be referenced directly). + property panel-hovered: false; + // Which row is hovered (-1 = none). Rows don't overlap, so one at a time. + property hovered-row: -1; + // True while the pointer rests anywhere on the menu (panel body OR a row). + // The idle timer is paused while this holds, so resting the cursor on the + // menu — even without moving it — never lets the menu close. + property menu-hovered: + root.panel-hovered || root.hovered-row >= 0 || HeaderMenuState.trigger-hovered; - // Closes the menu and disarms the timer. + // Closes the menu. function close-menu() { HeaderMenuState.open-index = -1; - idle-timer.running = false; } - // Restart the idle countdown every time a tab (re)publishes the - // open menu — covers both the initial open and hover-switching - // between tabs. + // Clear the panel/row hover trackers whenever the open menu changes, so a + // stale state from the previous menu can't wrongly pause the timer. + // NOTE: trigger-hovered is deliberately NOT reset here — the trigger owns + // it (resetting it on open would clear the flag the instant the hovered + // trigger opens the menu, defeating the whole purpose). changed open-index => { - if (HeaderMenuState.open-index >= 0) { - idle-timer.running = true; - idle-timer.restart(); - } else { - idle-timer.running = false; - } + root.hovered-row = -1; + root.panel-hovered = false; } property open-index: HeaderMenuState.open-index; - // Closes the menu after 1s without pointer interaction (project rule: - // no menu stays open more than 1s idle). + // Closes the menu after 1s idle (project rule). The timer runs only while a + // menu is open AND the pointer is NOT over it — so hovering the menu pauses + // the countdown, and leaving it restarts a fresh 1s. idle-timer := Timer { interval: 1000ms; - running: false; + running: HeaderMenuState.open-index >= 0 && !root.menu-hovered; triggered => { - self.running = false; HeaderMenuState.open-index = -1; } } @@ -72,8 +74,13 @@ export component HeaderMenuOverlay inherits Rectangle { // events and block hover-switching, exactly like the old // PopupWindow's pointer grab did. if HeaderMenuState.open-index >= 0: TouchArea { - y: Layout.header-height; - height: parent.height - Layout.header-height; + // Header dropdowns: scrim starts below the header so the tabs stay + // hoverable. Sidebar flyouts: scrim starts right of the sidebar so the + // section rows stay clickable. + x: HeaderMenuState.from-sidebar ? ShellState.sidebar-rendered-width : 0px; + y: HeaderMenuState.from-sidebar ? 0px : Layout.header-height; + width: parent.width - self.x; + height: parent.height - self.y; clicked => { root.close-menu(); } @@ -83,9 +90,9 @@ export component HeaderMenuOverlay inherits Rectangle { // surface-main background, 1px border-muted, Radius.sm, drop shadow. if HeaderMenuState.open-index >= 0: panel := Rectangle { x: HeaderMenuState.anchor-x; - // Snug under the tabs — the 30px tab is centered in the 48px - // header, so its bottom edge sits at ~39px. - y: 45px; + // Header dropdown: snug under the tabs (~45px). Sidebar flyout: aligned + // to the row's window y, opening to the right. + y: HeaderMenuState.from-sidebar ? HeaderMenuState.anchor-y : 45px; width: 188px; height: panel-box.preferred-height; @@ -98,18 +105,36 @@ export component HeaderMenuOverlay inherits Rectangle { drop-shadow-color: Theme.card-shadow; drop-shadow-offset-y: 6px; - // Keeps the pointer "inside" the menu so the scrim's click - // does not pass through, and resets the idle countdown on - // any movement over the panel body. + // Keeps the pointer "inside" the menu so the scrim's click does + // not pass through, and reports panel-body hover so the idle timer + // pauses while the cursor rests on the menu. panel-ta := TouchArea { - moved => { - root.reset-idle(); + changed has-hover => { + root.panel-hovered = self.has-hover; } } VerticalLayout { padding-top: 6px; padding-bottom: 6px; + // Optional section-name header + hairline (used by the compact + // header nav shown when the sidebar is fully closed). + if HeaderMenuState.title != "": HorizontalLayout { + padding-left: 14px; + padding-right: 14px; + Text { + text: HeaderMenuState.title; + color: Theme.text-muted; + font-size: 11px; + font-weight: 600; + height: 26px; + vertical-alignment: center; + } + } + if HeaderMenuState.title != "": Rectangle { + height: 1px; + background: Theme.border-subtle; + } for entry[idx] in HeaderMenuState.items: NavMenuItem { label: entry.label; icon: entry.icon; @@ -122,10 +147,14 @@ export component HeaderMenuOverlay inherits Rectangle { } root.close-menu(); } - // Any pointer interaction with a row resets the - // idle-close countdown. - hovered => { - root.reset-idle(); + // Track per-row hover so the idle timer stays paused while + // the cursor rests on a row (not just while it moves). + hover-changed(h) => { + if (h) { + root.hovered-row = idx; + } else if (root.hovered-row == idx) { + root.hovered-row = -1; + } } } } diff --git a/crates/qbz-slint/ui/shell/NavButtons.slint b/crates/qbz-slint/ui/shell/NavButtons.slint index 1692c191e..de817ea6c 100644 --- a/crates/qbz-slint/ui/shell/NavButtons.slint +++ b/crates/qbz-slint/ui/shell/NavButtons.slint @@ -1,70 +1,8 @@ -// Browser-like back / forward navigation pair. -// -// The [<] [>] history pair uses the same circular elevated style as the -// Discover carousel page navigators. Used inline beside the Discover -// tabs and in place of the per-page Back button. (The sidebar-collapse -// toggle lives in the header now, not here.) - -import { Theme } from "../foundation/semantic-colors.slint"; -import { NavState } from "../state.slint"; -import { QbzIcon } from "../primitives/QbzIcon.slint"; - -// Circular elevated icon button — same look as the Discover carousel -// page navigators (Carousel.slint NavButton). -component HistoryNavBtn inherits Rectangle { - in property icon; - in property enabled: true; - callback clicked(); - - width: 28px; - height: 28px; - border-radius: 14px; - opacity: root.enabled ? 1.0 : 0.4; - background: ta.has-hover && root.enabled - ? Theme.surface-hover - : Theme.surface-elevated; - - QbzIcon { - source: root.icon; - width: 15px; - height: 15px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; - tint: root.enabled ? Theme.text-primary : Theme.text-muted; - } - - ta := TouchArea { - enabled: root.enabled; - mouse-cursor: root.enabled ? pointer : default; - clicked => { - if (root.enabled) { - root.clicked(); - } - } - } -} - +// The back / forward / collapse buttons moved to the app header (HeaderBar, +// floating right). This component is now an empty zero-size placeholder so +// the many per-view headers that still instantiate it render nothing — the +// real buttons live once, in the header. export component NavButtons inherits HorizontalLayout { - spacing: 4px; - - VerticalLayout { - alignment: center; - HistoryNavBtn { - icon: @image-url("../assets/icons/chevron-left.svg"); - enabled: NavState.can-back; - clicked => { - NavState.request-back(); - } - } - } - VerticalLayout { - alignment: center; - HistoryNavBtn { - icon: @image-url("../assets/icons/chevron-right.svg"); - enabled: NavState.can-forward; - clicked => { - NavState.request-forward(); - } - } - } + width: 0px; + height: 0px; } diff --git a/crates/qbz-slint/ui/shell/NavMenuItem.slint b/crates/qbz-slint/ui/shell/NavMenuItem.slint index 0c45b74be..a01b82154 100644 --- a/crates/qbz-slint/ui/shell/NavMenuItem.slint +++ b/crates/qbz-slint/ui/shell/NavMenuItem.slint @@ -13,6 +13,9 @@ export component NavMenuItem inherits Rectangle { // Fires whenever the pointer is over the row — drives the dropdown's // idle-close timer reset. callback hovered(); + // Fires on hover enter/leave so the host keeps the menu open while the + // pointer rests on a row (the idle timer must not close it then). + callback hover-changed(bool); height: 32px; background: ta.has-hover ? Theme.surface-hover : transparent; @@ -45,6 +48,7 @@ export component NavMenuItem inherits Rectangle { root.clicked(); } changed has-hover => { + root.hover-changed(self.has-hover); if (self.has-hover) { root.hovered(); } diff --git a/crates/qbz-slint/ui/shell/PlayerBar.slint b/crates/qbz-slint/ui/shell/PlayerBar.slint index 057f2c984..c80732e0e 100644 --- a/crates/qbz-slint/ui/shell/PlayerBar.slint +++ b/crates/qbz-slint/ui/shell/PlayerBar.slint @@ -16,7 +16,7 @@ import { Radius } from "../foundation/radius.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzSlider } from "../primitives/QbzSlider.slint"; import { SeekBar } from "SeekBar.slint"; -import { NowPlayingState } from "../state.slint"; +import { NowPlayingState, ShellState, AppearanceState } from "../state.slint"; component IconButton inherits Rectangle { in property icon; @@ -257,6 +257,24 @@ export component PlayerBar inherits Rectangle { IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); icon-size: 16px; } IconButton { icon: @image-url("../assets/icons/audio-lines.svg"); icon-size: 16px; } + // Lyrics + queue panel toggles (moved here from the header). + IconButton { + icon: @image-url("../assets/icons/captions.svg"); + icon-size: 16px; + active: ShellState.right-panel == 2; + clicked => { + ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2; + } + } + IconButton { + icon: @image-url("../assets/icons/list-ordered.svg"); + icon-size: 16px; + active: ShellState.right-panel == 1; + clicked => { + ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1; + } + } + Rectangle { width: 6px; } IconButton { @@ -281,14 +299,15 @@ export component PlayerBar inherits Rectangle { } } } - IconButton { + // Volume +/- steppers — opt-in (Settings > Appearance). + if AppearanceState.show-volume-steppers: IconButton { icon: @image-url("../assets/icons/minus.svg"); icon-size: 15px; clicked => { NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05)); } } - IconButton { + if AppearanceState.show-volume-steppers: IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 15px; clicked => { diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint index cb2a9b918..cbb39c2b3 100644 --- a/crates/qbz-slint/ui/shell/Sidebar.slint +++ b/crates/qbz-slint/ui/shell/Sidebar.slint @@ -15,7 +15,7 @@ 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 { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState } from "../state.slint"; import { ScrollView, LineEdit } from "std-widgets.slint"; // A single context-menu item row. @@ -119,6 +119,9 @@ component SidebarRow inherits Rectangle { callback edit-folder(string); callback hide-playlist(string); callback hide-folder(string); + // Fires on row hover enter/leave so the sidebar can reveal the scrollbar + // while the pointer is anywhere over the scrollable list. + callback hover-changed(bool); property is-folder: root.entry.kind == "folder"; // Live filter for the move-to-folder list (per-row search input). @@ -161,7 +164,11 @@ component SidebarRow inherits Rectangle { && root.show-collage && root.entry.cover-count > 0; - height: 32px; + // Collapsed (mini): folders behave as if closed — their nested playlists + // (indented rows) are hidden; contents open via the right-flyout instead. + property hidden-in-mini: ShellState.sidebar-mini && root.entry.indent; + height: root.hidden-in-mini ? 0px : 32px; + visible: !root.hidden-in-mini; border-radius: 6px; background: root.drop-hot ? #22c55e26 @@ -171,34 +178,47 @@ component SidebarRow inherits Rectangle { border-color: #22c55e66; HorizontalLayout { - padding-left: root.entry.indent ? 24px : 8px; - padding-right: 6px; + padding-left: ShellState.sidebar-mini ? 0px : (root.entry.indent ? 24px : 8px); + padding-right: ShellState.sidebar-mini ? 0px : 6px; spacing: 8px; + // mini -> center the lone icon; open -> stretch so the name fills and + // the chevron is pushed to a fixed x (the row's right edge). + alignment: ShellState.sidebar-mini ? center : stretch; // Leading icon — accent-tinted folder for folders, micro-collage // (or list-music fallback) for playlists. - VerticalLayout { - alignment: center; + // Fixed-width icon slot so every row's text starts at the same x, + // whether the leading glyph is a 15px folder icon or a 20px collage. + Rectangle { + width: 24px; if root.is-folder: QbzIcon { - source: root.entry.expanded + // In the collapsed (mini) state folders always read as closed + // (their contents open in the right-flyout, not inline). + source: (root.entry.expanded && !ShellState.sidebar-mini) ? @image-url("../assets/icons/folder-open.svg") : @image-url("../assets/icons/folder.svg"); width: 15px; height: 15px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; // Folder icon tinted accent; the name keeps text color. tint: Theme.accent; } if !root.is-folder && root.use-collage: SidebarPlaylistCollage { entry: root.entry; size: 20px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; } if !root.is-folder && !root.use-collage: QbzIcon { source: @image-url("../assets/icons/list-music.svg"); width: 15px; height: 15px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; tint: root.active ? Theme.text-primary : Theme.text-muted; } } - Text { + if !ShellState.sidebar-mini: Text { text: root.entry.name; color: root.active ? Theme.text-primary : Theme.text-secondary; font-size: 13px; @@ -208,7 +228,7 @@ component SidebarRow inherits Rectangle { overflow: elide; } // Folder playlist count — only visible on hover. - if root.is-folder && root.entry.count > 0: Text { + if root.is-folder && root.entry.count > 0 && !ShellState.sidebar-mini: Text { text: root.entry.count; color: Theme.text-muted; font-size: 11px; @@ -216,8 +236,8 @@ component SidebarRow inherits Rectangle { opacity: ta.has-hover ? 1.0 : 0.0; } // Folder open/close chevron — the primary affordance. Reflects - // the open/closed state (down = open, right = closed). - if root.is-folder: VerticalLayout { + // the open/closed state (down = open, right = closed). Hidden in mini. + if root.is-folder && !ShellState.sidebar-mini: VerticalLayout { alignment: center; Rectangle { width: 22px; @@ -249,9 +269,26 @@ component SidebarRow inherits Rectangle { } ta := TouchArea { mouse-cursor: pointer; + changed has-hover => { + root.hover-changed(self.has-hover); + } clicked => { if (root.is-folder) { - root.toggle-folder(root.entry.id); + // Collapsed (mini) sidebar: a folder opens a flyout to the + // right instead of expanding downward (Tauri parity). + if (ShellState.sidebar-mini) { + SidebarFolderPopupState.folder-id = root.entry.id; + SidebarFolderPopupState.folder-name = root.entry.name; + SidebarFolderPopupState.count = root.entry.count; + SidebarFolderPopupState.anchor-x = root.absolute-position.x + root.width + 6px; + SidebarFolderPopupState.anchor-y = root.absolute-position.y; + // Fill the popup's playlists from the cache (collapsed + // folders' children aren't in the flat entries). + SidebarActions.load-folder-popup(root.entry.id); + SidebarFolderPopupState.open = true; + } else { + root.toggle-folder(root.entry.id); + } } else { root.open-playlist(root.entry.id); } @@ -409,26 +446,143 @@ component SidebarRow inherits Rectangle { } } +// One section-nav row in the sidebar. Clicking (or hovering) opens the same +// dropdown the header tab used, but flown out to the RIGHT of the row. In the +// mini state the label is hidden (icon only). Mirrors NavTabWithMenu. +component SidebarNavRow inherits Rectangle { + in property icon; + in property label; + in property tab-index; + in property <[NavMenuEntry]> items; + in property home-item: -1; + property active: HeaderMenuState.open-index == root.tab-index; + + height: 34px; + border-radius: 6px; + background: nav-ta.has-hover ? Theme.surface-hover : transparent; + + // Publish this row's menu into the shared overlay, anchored to the right + // of the row at the row's window y. + function open-menu() { + HeaderMenuState.open-index = root.tab-index; + HeaderMenuState.anchor-x = root.absolute-position.x + root.width + 6px; + HeaderMenuState.anchor-y = root.absolute-position.y; + HeaderMenuState.items = root.items; + HeaderMenuState.home-item = root.home-item; + HeaderMenuState.from-sidebar = true; + HeaderMenuState.title = ""; + } + + HorizontalLayout { + padding-left: ShellState.sidebar-mini ? 0px : 8px; + padding-right: ShellState.sidebar-mini ? 0px : 8px; + spacing: 10px; + alignment: ShellState.sidebar-mini ? center : start; + VerticalLayout { + alignment: center; + QbzIcon { + source: root.icon; + width: 16px; + height: 16px; + tint: nav-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + } + } + if !ShellState.sidebar-mini: Text { + text: root.label; + color: nav-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: 13px; + font-weight: 500; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + nav-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.open-menu(); + } + changed has-hover => { + if (self.has-hover) { + HeaderMenuState.trigger-hovered = true; + root.open-menu(); + } else if (root.active) { + HeaderMenuState.trigger-hovered = false; + } + } + } +} + export component Sidebar inherits Rectangle { - // Rendered width — 0 when collapsed, otherwise the user-set width. - width: ShellState.sidebar-collapsed ? 0px : ShellState.sidebar-width; - // The sidebar sits one surface above the content area, matching the - // QBZ dark theme (--bg-secondary sidebar over --bg-primary content). + // Fired when a sidebar section-nav row is clicked — wired in AppShell to + // the same route handler the header dropdowns use. + callback navigate(string /* route */); + // Three-state rendered width: 0 (closed) / 64 (mini) / 240 (open). + width: ShellState.sidebar-rendered-width; background: Theme.surface-card; - // Clip so content doesn't spill while the width animates to 0. + // Square edges — the bezel lives on the inset content panel, not here. + // Clip keeps content from spilling while the width animates. clip: true; animate width { duration: 160ms; easing: ease-in-out; } - // Captured sidebar width at pointer-down, so drag deltas are - // measured from a stable origin. - property drag-start-width: ShellState.sidebar-width; - VerticalLayout { - padding: Spacing.md; + padding: ShellState.sidebar-mini ? 8px : Spacing.md; spacing: Spacing.md; - // ---- Header toolbar ---------------------------------------- - HorizontalLayout { + // ---- Section nav — each row opens its dropdown as a right flyout, + // exactly like the header tabs did (same item sets). ---------- + if ShellState.nav-in-sidebar: VerticalLayout { + spacing: 2px; + SidebarNavRow { + tab-index: 0; + icon: @image-url("../assets/icons/compass.svg"); + label: @tr("Discover"); + home-item: 0; + items: [ + { label: @tr("Home"), has-glyph: false, route: "discover-home" }, + { label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" }, + { label: @tr("For You"), has-glyph: false, route: "discover-forYou" }, + ]; + } + SidebarNavRow { + tab-index: 1; + icon: @image-url("../assets/icons/music-library-2.svg"); + label: @tr("Library"); + items: [ + { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, + { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, + { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" }, + { label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" }, + { label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" }, + ]; + } + SidebarNavRow { + tab-index: 2; + icon: @image-url("../assets/icons/hard-drive.svg"); + label: @tr("Local Library"); + items: [ + { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" }, + { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" }, + { label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" }, + { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" }, + ]; + } + SidebarNavRow { + tab-index: 3; + icon: @image-url("../assets/icons/cassette-tape.svg"); + label: @tr("My QBZ"); + items: [ + { label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true }, + { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true }, + ]; + } + } + if ShellState.nav-in-sidebar: Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + // ---- Header toolbar (hidden in the mini state) ------------- + if !ShellState.sidebar-mini: HorizontalLayout { spacing: 4px; height: 22px; @@ -603,13 +757,22 @@ export component Sidebar inherits Rectangle { } // ---- Playlist list (hidden when the section is collapsed) ---- - if !SidebarState.playlists-collapsed: ScrollView { + // Wheel-scrolled Flickable (interactive only while NOT dragging, so a + // playlist drag-and-drop is never hijacked into a scroll) + a thin, + // auto-hiding scrollbar pinned to the sidebar's right edge. + if !SidebarState.playlists-collapsed: Rectangle { vertical-stretch: 1; - horizontal-scrollbar-policy: ScrollBarPolicy.always-off; - VerticalLayout { - spacing: 2px; - alignment: start; - for entry in SidebarState.entries: SidebarRow { + pl-flick := Flickable { + width: 100%; + height: 100%; + interactive: !DragState.active; + viewport-width: self.width; + viewport-height: pl-content.preferred-height; + pl-content := VerticalLayout { + width: parent.width; + spacing: 2px; + alignment: start; + for entry in SidebarState.entries: SidebarRow { entry: entry; active: entry.kind == "playlist" && entry.id == SidebarState.active-id; show-collage: SidebarState.playlist-collage; @@ -637,6 +800,9 @@ export component Sidebar inherits Rectangle { hide-folder(id) => { SidebarActions.hide-folder(id); } + hover-changed(h) => { + pl-sb.hover-rows = h ? pl-sb.hover-rows + 1 : Math.max(0, pl-sb.hover-rows - 1); + } } if SidebarState.entries.length == 0 && !SidebarState.loading: Text { text: @tr("No playlists yet."); @@ -644,6 +810,61 @@ export component Sidebar inherits Rectangle { font-size: 12px; wrap: word-wrap; } + } + } + + // Thin scrollbar pinned to the sidebar's RIGHT EDGE. The +Spacing.md + // offset cancels the list's right padding so the gutter sits flush + // against the sidebar border. It appears whenever the pointer is + // over the scrollable list (hover-rows) or the gutter itself. + pl-sb := Rectangle { + // Offset by the list's real right padding (8px mini / 16px + // open) so the gutter sits flush against the sidebar edge in + // BOTH states. Narrower in the collapsed (mini) state. + x: parent.width - self.width + (ShellState.sidebar-mini ? 8px : Spacing.md); + width: ShellState.sidebar-mini ? 8px : 10px; + height: 100%; + // Count of list rows currently hovered (>0 = pointer over the + // scrollable area). Reveals the scrollbar. + property hover-rows: 0; + property max-scroll: max(0px, pl-flick.viewport-height - pl-flick.height); + property thumb-h: pl-flick.viewport-height > 0px + ? max(40px, pl-flick.height / pl-flick.viewport-height * self.height) + : self.height; + property travel: max(0px, self.height - self.thumb-h); + property show: pl-sb-ta.has-hover || pl-sb-ta.pressed || self.hover-rows > 0; + visible: self.max-scroll > 0px; + + thumb := Rectangle { + x: parent.width - self.width; + width: pl-sb-ta.has-hover || pl-sb-ta.pressed + ? (ShellState.sidebar-mini ? 4px : 6px) + : (ShellState.sidebar-mini ? 3px : 4px); + height: pl-sb.thumb-h; + y: pl-sb.max-scroll > 0px + ? (-pl-flick.viewport-y / pl-sb.max-scroll) * pl-sb.travel + : 0px; + border-radius: 3px; + background: pl-sb-ta.has-hover || pl-sb-ta.pressed ? #ffffff7a : #ffffff4d; + opacity: pl-sb.show ? 1.0 : 0.0; + animate opacity { duration: 160ms; } + animate width { duration: 100ms; } + } + pl-sb-ta := TouchArea { + mouse-cursor: pointer; + moved => { + if (pl-sb-ta.pressed && pl-sb.travel > 0px) { + pl-flick.viewport-y = -min(pl-sb.max-scroll, max(0px, + (pl-sb-ta.mouse-y - pl-sb.thumb-h / 2) / pl-sb.travel * pl-sb.max-scroll)); + } + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && pl-sb.travel > 0px) { + pl-flick.viewport-y = -min(pl-sb.max-scroll, max(0px, + (pl-sb-ta.mouse-y - pl-sb.thumb-h / 2) / pl-sb.travel * pl-sb.max-scroll)); + } + } + } } } // Keep the layout stretching even while collapsed. @@ -651,30 +872,7 @@ export component Sidebar inherits Rectangle { vertical-stretch: 1; } } - - // Drag handle — a thin strip pinned to the right edge. Resizes the - // sidebar; below ~160px it collapses instead of resizing. - drag-handle := TouchArea { - x: parent.width - self.width; - width: 6px; - height: parent.height; - mouse-cursor: ew-resize; - - pointer-event(e) => { - if (e.kind == PointerEventKind.down) { - root.drag-start-width = ShellState.sidebar-width; - } - } - moved => { - if (self.pressed) { - if (root.drag-start-width + (self.mouse-x - self.pressed-x) < 160px) { - ShellState.sidebar-collapsed = true; - } else { - ShellState.sidebar-width = Math.min(Math.max( - root.drag-start-width + (self.mouse-x - self.pressed-x), - 200px), 420px); - } - } - } - } + // The drag-to-resize handle was removed: the sidebar is now a fixed + // three-state control (open / mini / closed) cycled from the header + // collapse button. } diff --git a/crates/qbz-slint/ui/shell/SidebarFolderPopup.slint b/crates/qbz-slint/ui/shell/SidebarFolderPopup.slint new file mode 100644 index 000000000..aac4eefba --- /dev/null +++ b/crates/qbz-slint/ui/shell/SidebarFolderPopup.slint @@ -0,0 +1,146 @@ +// Collapsed-sidebar folder flyout. When the sidebar is in the Colapsado +// (mini) state, clicking a folder opens this popup to its right, listing the +// folder's playlists (max 4 rows visible; a scrollbar appears beyond four). +// Mirrors Tauri's collapsed folder popover. Rendered at the AppShell level +// because the sidebar clips its own overflow. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Radius } from "../foundation/radius.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { SidebarFolderPopupState, SidebarActions, SidebarEntry } from "../state.slint"; + +// One playlist row inside the popup. +component FpRow inherits Rectangle { + in property entry; + callback clicked(); + height: 32px; + border-radius: 5px; + background: ta.has-hover ? Theme.surface-hover : transparent; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/list-music.svg"); + width: 14px; + height: 14px; + tint: Theme.text-muted; + } + } + Text { + text: root.entry.name; + color: Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + ta := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +export component SidebarFolderPopup inherits Rectangle { + visible: SidebarFolderPopupState.open; + + property row-h: 32px; + property visible-rows: Math.min(SidebarFolderPopupState.count, 4); + property list-h: root.visible-rows * root.row-h; + + // Scrim — a click anywhere outside the panel closes the popup. + if SidebarFolderPopupState.open: TouchArea { + clicked => { + SidebarFolderPopupState.open = false; + } + } + + if SidebarFolderPopupState.open: Rectangle { + x: SidebarFolderPopupState.anchor-x; + y: SidebarFolderPopupState.anchor-y; + width: 230px; + height: 34px + 1px + root.list-h + 8px; + + Rectangle { + background: Theme.surface-main; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-muted; + drop-shadow-blur: 24px; + drop-shadow-color: Theme.card-shadow; + drop-shadow-offset-y: 6px; + + // Swallow clicks so the scrim doesn't close while interacting. + TouchArea { } + + VerticalLayout { + padding-top: 4px; + padding-bottom: 4px; + + // Header — folder name. + HorizontalLayout { + height: 30px; + padding-left: 12px; + padding-right: 12px; + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/folder-open.svg"); + width: 14px; + height: 14px; + tint: Theme.accent; + } + } + Text { + text: SidebarFolderPopupState.folder-name; + color: Theme.text-primary; + font-size: 13px; + font-weight: 600; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + // Playlist list — capped at four rows; scrolls beyond that. + Rectangle { + height: root.list-h; + fp-flick := Flickable { + width: 100%; + height: 100%; + viewport-width: self.width; + viewport-height: SidebarFolderPopupState.count * root.row-h; + VerticalLayout { + width: parent.width; + alignment: start; + for entry in SidebarFolderPopupState.playlists: FpRow { + entry: entry; + clicked => { + SidebarActions.open-playlist(entry.id); + SidebarFolderPopupState.open = false; + } + } + } + } + if SidebarFolderPopupState.count > 4: ListScrollbar { + x: parent.width - self.width; + width: 10px; + height: 100%; + viewport-height: fp-flick.viewport-height; + visible-height: fp-flick.height; + viewport-y <=> fp-flick.viewport-y; + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 47d171d22..2b0b004de 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -804,6 +804,20 @@ export struct SidebarEntry { cover4: image, } +// Flyout shown when a folder is clicked while the sidebar is collapsed +// (mini): a popup to the right listing that folder's playlists. The list is +// populated from Rust because collapsed folders' children are NOT present in +// the flat `SidebarState.entries`. +export global SidebarFolderPopupState { + in-out property open: false; + in-out property folder-id; + in-out property folder-name; + in-out property count: 0; // folder's playlist count (caps the height) + in-out property anchor-x; // window x (sidebar right edge) + in-out property anchor-y; // window y (folder row top) + in property <[SidebarEntry]> playlists: []; +} + // The sidebar's playlists + folders. Populated by crate::sidebar on // shell entry and after create/delete/move. export global SidebarState { @@ -838,6 +852,9 @@ export global SidebarState { export global SidebarActions { callback open-playlist(string /* id */); + // Populate SidebarFolderPopupState.playlists for the collapsed-sidebar + // folder flyout (the folder's children aren't in the flat entries). + callback load-folder-popup(string /* folder id */); callback create-playlist(); callback create-folder(); callback toggle-folder(string /* folder id */); @@ -1278,14 +1295,10 @@ export global SettingsState { // so the UI renders standalone even while unwired. export global AppearanceState { // --- THEME ------------------------------------------------------------- + // Only the themes that are actually ported/wired. Order is load-bearing: + // the index maps 1:1 to ThemeState.mode (0 Dark, 1 OLED, 2 Tokyo, 3 System). in-out property <[string]> themes: [ - "Dark", "OLED Black", "Light", "System", - "Warm", "Nord", "Dracula", "Tokyo Night", "Catppuccin Mocha", - "Breeze Dark", "Adwaita Dark", "Alucard", "Aurora", "Ikari", - "Ayanami", "Iscariot", "Stratego", "Rumi", "Zoey", "Mira", - "Rose Pine Dawn", "Breeze Light", "Adwaita Light", "Duotone Snow", - "Snow Storm", "Frost", "Langley", "Kurosaki", - "WCAG Light", "WCAG Dark", "High Contrast", "Colorblind" + "Dark", "OLED Black", "Tokyo Night", "System" ]; in-out property theme-index: 0; // Theme list filter cycle: 0 = All, 1 = Dark, 2 = Light. Mirrors the @@ -1316,6 +1329,8 @@ export global AppearanceState { // the panel (the only already-functional flag here). in-out property sidebar-playlist-collage: true; in-out property local-library-track-artwork: false; + // Player volume +/- stepper buttons — opt-in, hidden by default. + in-out property show-volume-steppers: false; // --- NOTIFICATIONS ----------------------------------------------------- in-out property in-app-toasts: true; @@ -1420,6 +1435,19 @@ export global HeaderMenuState { // Header x-coordinate of the open tab, so the overlay aligns the // menu panel under it. in-out property anchor-x: 0px; + // Window y-coordinate of the anchor — used for sidebar flyouts, which + // open to the RIGHT of the row rather than under the header. + in-out property anchor-y: 0px; + // True when the open menu is a sidebar section flyout (opens right of + // the row) rather than a header dropdown (opens under the tab). + in-out property from-sidebar: false; + // Optional section-name header shown as the first row (with a hairline + // separator below it). Empty = no header row. + in-out property title: ""; + // True while the pointer rests on the TRIGGER (the header tab / compact + // nav icon / sidebar nav row). Pauses the idle-close like hovering the + // menu does, so resting on the trigger never lets it close. + in-out property trigger-hovered: false; // The open tab's menu rows. in-out property <[NavMenuEntry]> items: []; // Index of the row wired to Home (-1 = none); only Discover uses it. @@ -1785,10 +1813,25 @@ export global LocationViewActions { // Globals are imported directly by the shell components, so toggles // need no callback plumbing. export global ShellState { - in-out property sidebar-collapsed: false; - in-out property sidebar-width: 280px; + // Three-state sidebar: 0 = open (240px, labels), 1 = mini (64px, icons + // only, Tauri-style), 2 = closed (0px). The collapse toggle cycles + // open -> mini -> closed -> open. + in-out property sidebar-state: 0; + out property sidebar-rendered-width: + root.sidebar-state == 2 ? 0px + : root.sidebar-state == 1 ? 64px + : 240px; + // True while in the icons-only mini state — gates label/header rendering. + out property sidebar-mini: root.sidebar-state == 1; + // Advance the sidebar through its three states. + public function cycle-sidebar() { + root.sidebar-state = Math.mod(root.sidebar-state + 1, 3); + } // 0 = none, 1 = queue, 2 = lyrics in-out property right-panel: 0; + // Section navigation placement: the sections (Discover/Library/Local + // Library/My QBZ) now live in the sidebar and open right-flyout menus. + in-out property nav-in-sidebar: true; } // Authenticated user, for the header user element. @@ -2049,6 +2092,8 @@ export global FavoritesActions { callback albums-search(string /* query */); callback search-labels(string /* query */); callback playlists-set-sub-tab(string /* favorites | following */); + callback playlists-random(); + callback labels-random(); callback search-playlists(string /* query */); callback playlists-set-view(string /* grid | list */); callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite */);