From c50ae1710275e5576c1664ecad592cb7fd1a7a93 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 28 May 2026 17:51:52 -0600 Subject: [PATCH] feat(locallibrary): nav scaffolding for the Local Library Slint view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield Local Library port, slice 1 — the view now mounts and navigates end to end (empty placeholder body; per-tab data lands next): - state.slint: `ContentView::local-library` + `LocalLibraryState` (active-tab / loading / load-error / per-category counts) + `LocalLibraryActions` (select-tab, open-settings). Re-exported from app.slint so the globals reach the Rust API. - ui/locallibrary/LocalLibraryView.slint: header (title + stats + gear) + 4-tab bar (Albums / Artists / Folders / Tracks) + placeholder body. - shell: AppShell mounts the view on `local-library`; HeaderBar's Local Library menu items now carry routes (local-albums / -artists / -folders / -tracks) instead of being inert. - nav.rs: `NavEntry::LocalLibrary { tab }` — per-tab history entry, mirroring Favorites / Discover (back/forward moves between tabs). - src/local_library.rs: `LibTab` controller stub (route/tab-id parsing, round-trip tested). - main.rs: apply_entry arm + `navigate_local_library` + header-route branch + in-view tab-bar (select-tab) and gear (open-settings -> Settings; management lives under Settings > Local Library) wiring. qbz-slint cargo check green. Built blind — needs a runtime smoke test (./scripts/slint-dev.sh): click the Local Library header menu, confirm the view shows and the 4 tabs switch. cc --- crates/qbz-slint/src/local_library.rs | 79 +++++++++++ crates/qbz-slint/src/main.rs | 93 +++++++++++++ crates/qbz-slint/src/nav.rs | 6 + crates/qbz-slint/ui/app.slint | 2 +- .../ui/locallibrary/LocalLibraryView.slint | 123 ++++++++++++++++++ crates/qbz-slint/ui/shell/AppShell.slint | 2 + crates/qbz-slint/ui/shell/HeaderBar.slint | 8 +- crates/qbz-slint/ui/state.slint | 26 +++- 8 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 crates/qbz-slint/src/local_library.rs create mode 100644 crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs new file mode 100644 index 000000000..58ac43840 --- /dev/null +++ b/crates/qbz-slint/src/local_library.rs @@ -0,0 +1,79 @@ +//! Local Library controller (Slint) — greenfield port of Tauri's +//! `LocalLibraryView`. +//! +//! This module owns the per-tab navigation and (slice by slice) the data +//! loading for the four browse tabs: Albums / Artists / Folders / Tracks. +//! It reads the shared per-user `library.db` through the already +//! frontend-agnostic `qbz-library` crate (see `library_db::with_db`), and +//! Plex through the `qbz-plex` core crate. +//! +//! Folder management, scan, maintenance, and the danger zone do NOT live in +//! this view — they belong under Settings > Local Library. The view's gear +//! button routes there. + +/// The four browse tabs. Order mirrors Tauri's default tab order +/// (`tracks / folders / albums / artists`); the visible order is a user +/// preference layered on top later. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LibTab { + Tracks, + Folders, + Albums, + Artists, +} + +impl LibTab { + /// Parse a HeaderBar menu route (`"local-albums"` etc.). + pub fn from_route(route: &str) -> Option { + match route { + "local-tracks" => Some(Self::Tracks), + "local-folders" => Some(Self::Folders), + "local-albums" => Some(Self::Albums), + "local-artists" => Some(Self::Artists), + _ => None, + } + } + + /// Parse a tab id (`"albums"` etc.) as carried by a `NavEntry` and by + /// `LocalLibraryState.active-tab`. + pub fn from_tab_id(id: &str) -> Option { + match id { + "tracks" => Some(Self::Tracks), + "folders" => Some(Self::Folders), + "albums" => Some(Self::Albums), + "artists" => Some(Self::Artists), + _ => None, + } + } + + /// The canonical tab id used in nav entries + `LocalLibraryState.active-tab`. + pub fn tab_id(self) -> &'static str { + match self { + Self::Tracks => "tracks", + Self::Folders => "folders", + Self::Albums => "albums", + Self::Artists => "artists", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_and_tab_id_round_trip() { + for (route, id, tab) in [ + ("local-tracks", "tracks", LibTab::Tracks), + ("local-folders", "folders", LibTab::Folders), + ("local-albums", "albums", LibTab::Albums), + ("local-artists", "artists", LibTab::Artists), + ] { + assert_eq!(LibTab::from_route(route), Some(tab)); + assert_eq!(LibTab::from_tab_id(id), Some(tab)); + assert_eq!(tab.tab_id(), id); + } + assert_eq!(LibTab::from_route("favorites-albums"), None); + assert_eq!(LibTab::from_tab_id("bogus"), None); + } +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 5f13763b2..9ce916c75 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -43,6 +43,7 @@ mod queue; mod drag; mod folders; mod library_db; +mod local_library; mod offline; mod offline_cache; mod offline_manager; @@ -625,6 +626,17 @@ fn apply_entry( ); } } + nav::NavEntry::LocalLibrary { tab } => { + if let Some(lib_tab) = local_library::LibTab::from_tab_id(&tab) { + navigate_local_library( + runtime.clone(), + weak.clone(), + handle, + image_cache.clone(), + lib_tab, + ); + } + } nav::NavEntry::Settings => { let _ = weak.upgrade_in_event_loop(|w| { w.global::().set_view(ContentView::Settings); @@ -885,6 +897,25 @@ fn navigate_favorites( }); } +/// Open a Local Library browse tab (Albums / Artists / Folders / Tracks). +/// +/// Sets the active tab + switches the view. Per-tab data loading lands with +/// each tab slice (Albums first); for now the view renders its shell + an +/// empty placeholder, so the navigation is verifiable end to end. +fn navigate_local_library( + _runtime: Arc>, + weak: slint::Weak, + _handle: &tokio::runtime::Handle, + _image_cache: artwork::ImageCache, + tab: local_library::LibTab, +) { + let tab_id = tab.tab_id().to_string(); + let _ = weak.upgrade_in_event_loop(move |w| { + w.global::().set_active_tab(tab_id.into()); + w.global::().set_view(ContentView::LocalLibrary); + }); +} + /// Open a Qobuz mix detail view (daily / weekly / fav / top) and load /// its tracks. fn navigate_mix( @@ -3847,10 +3878,72 @@ fn main() -> Result<(), Box> { tab, tab_id, ); + return; + } + // Local Library tabs — same per-tab history pattern as Favorites. + if let Some(tab) = local_library::LibTab::from_route(route.as_str()) { + nav::record(nav::NavEntry::LocalLibrary { + tab: tab.tab_id().to_string(), + }); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + navigate_local_library( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + tab, + ); } }); } + // Local Library — in-view tab bar (select-tab) + the gear button + // (open-settings -> Settings > Local Library). Same per-tab history + // pattern as Favorites. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_select_tab(move |tab_id| { + if let Some(tab) = local_library::LibTab::from_tab_id(tab_id.as_str()) { + nav::record(nav::NavEntry::LocalLibrary { + tab: tab.tab_id().to_string(), + }); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + navigate_local_library( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + tab, + ); + } + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_open_settings(move || { + // Management/maintenance/danger actions live under + // Settings > Local Library. TODO(locallibrary): pre-open the + // Local Library settings sub-page via target-tab routing once + // that page exists. + nav::record(nav::NavEntry::Settings); + if let Some(w) = weak.upgrade() { + w.global::().set_view(ContentView::Settings); + update_nav_flags(&w); + } + }); + } + // Discover "View all" — open the full-list page for a section, // recording it as a history entry (mirrors the favorites branch // of on_header_menu_navigate). diff --git a/crates/qbz-slint/src/nav.rs b/crates/qbz-slint/src/nav.rs index 70d13b368..393c518bf 100644 --- a/crates/qbz-slint/src/nav.rs +++ b/crates/qbz-slint/src/nav.rs @@ -25,6 +25,12 @@ pub enum NavEntry { Favorites { tab: String, }, + /// A Local Library browse tab page ("tracks" | "folders" | "albums" | + /// "artists"). Each tab is its own history entry so back/forward moves + /// between the Local Library tabs, mirroring Favorites / Discover. + LocalLibrary { + tab: String, + }, /// A Discover "View all" full-list page — one album module /// (new releases, qobuzissimes, ...) opened from a Carousel's /// "View all" link. Carries the /discover/ endpoint + the diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 9bdaae7ac..ba52ee228 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, 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, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, 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/locallibrary/LocalLibraryView.slint b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint new file mode 100644 index 000000000..d78456d04 --- /dev/null +++ b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint @@ -0,0 +1,123 @@ +// Local Library — the user's on-disk + Plex music, browsed inside QBZ. +// +// Greenfield Slint port. This shell renders the header stats + the four +// browse tabs (Albums / Artists / Folders / Tracks). All folder management, +// scan, maintenance, and danger-zone actions live under Settings > Local +// Library, NOT in this view — the gear button routes there. +// +// The per-tab bodies are filled slice by slice (Albums first). For now each +// tab shows an empty placeholder so the wiring is visible end to end. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { LocalLibraryState, LocalLibraryActions } from "../state.slint"; + +component TabButton inherits TouchArea { + in property label; + in property active; + height: 36px; + horizontal-stretch: 0; + Rectangle { + border-radius: 18px; + background: root.active + ? Theme.accent + : (root.has-hover ? Theme.surface-hover : transparent); + HorizontalLayout { + padding-left: 16px; + padding-right: 16px; + Text { + text: root.label; + vertical-alignment: center; + color: root.active ? Theme.accent-text : Theme.text-secondary; + font-size: 14px; + font-weight: root.active ? 700 : 500; + } + } + } +} + +export component LocalLibraryView inherits Rectangle { + background: transparent; + + VerticalLayout { + padding: 24px; + spacing: 20px; + + // ---- Header: title + stats + gear (-> Settings > Local Library) ---- + HorizontalLayout { + spacing: 16px; + VerticalLayout { + horizontal-stretch: 1; + spacing: 2px; + Text { + text: @tr("Local Library"); + color: Theme.text-primary; + font-size: 28px; + font-weight: 800; + } + Text { + text: @tr("{} albums · {} artists · {} tracks", + LocalLibraryState.album-count, + LocalLibraryState.artist-count, + LocalLibraryState.track-count); + color: Theme.text-muted; + font-size: 13px; + } + } + gear := TouchArea { + width: 38px; + height: 38px; + clicked => { LocalLibraryActions.open-settings(); } + Rectangle { + border-radius: 10px; + background: gear.has-hover ? Theme.surface-hover : transparent; + Image { + source: @image-url("../assets/icons/settings.svg"); + width: 20px; + height: 20px; + colorize: Theme.text-secondary; + } + } + } + } + + // ---- Tab bar (Albums / Artists / Folders / Tracks) ---- + HorizontalLayout { + spacing: 8px; + alignment: start; + for tab in [ + { id: "albums", label: @tr("Albums") }, + { id: "artists", label: @tr("Artists") }, + { id: "folders", label: @tr("Folders") }, + { id: "tracks", label: @tr("Tracks") }, + ] : TabButton { + label: tab.label; + active: LocalLibraryState.active-tab == tab.id; + clicked => { LocalLibraryActions.select-tab(tab.id); } + } + } + + // ---- Body (placeholder until each tab slice lands) ---- + Rectangle { + vertical-stretch: 1; + background: Theme.surface-card; + border-radius: 12px; + VerticalLayout { + alignment: center; + spacing: 6px; + Text { + text: @tr("Local Library port in progress"); + horizontal-alignment: center; + color: Theme.text-secondary; + font-size: 16px; + font-weight: 600; + } + Text { + text: @tr("The “{}” tab is coming next.", LocalLibraryState.active-tab); + horizontal-alignment: center; + color: Theme.text-muted; + font-size: 13px; + } + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 761c2e82e..cfd7cbe47 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -32,6 +32,7 @@ import { MixView } from "../mix/MixView.slint"; import { PlaylistView } from "../playlist/PlaylistView.slint"; import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint"; import { OfflineManagerView } from "../offline/OfflineManagerView.slint"; +import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint"; import { SettingsView } from "../settings/SettingsView.slint"; import { SearchResultsView } from "../search/SearchResultsView.slint"; @@ -171,6 +172,7 @@ export component AppShell inherits Rectangle { } if NavState.view == ContentView.playlist-manager: PlaylistManagerView { } if NavState.view == ContentView.offline-manager: OfflineManagerView { } + if NavState.view == ContentView.local-library: LocalLibraryView { } if NavState.view == ContentView.mix: MixView { play-track(id) => { root.media-action("mix-track", id, "play"); diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint index b3edce878..e6c1d581e 100644 --- a/crates/qbz-slint/ui/shell/HeaderBar.slint +++ b/crates/qbz-slint/ui/shell/HeaderBar.slint @@ -292,10 +292,10 @@ export component HeaderBar inherits Rectangle { label: @tr("Local Library"); show-icon: root.show-tab-icons; items: [ - { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true }, - { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true }, - { label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true }, - { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true }, + { 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" }, ]; } NavTabWithMenu { diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 840096852..7b064122b 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -323,13 +323,37 @@ export global DragActions { } // Which content view the shell shows. -export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager } +export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library } // In-app toast (transient notification). Mirrors Tauri's toastStore: // a single toast at a time (a new one replaces the current), auto-hidden // by the Rust layer per kind. `buffering` is persistent (no auto-hide). export enum ToastKind { success, error, info, warning, buffering } +// Local Library (on-disk + Plex music). Greenfield Slint port — the view +// shows header stats + the browse tabs (Albums / Artists / Folders / +// Tracks). All management/maintenance/danger actions live under +// Settings > Local Library; the gear button routes there. +export global LocalLibraryState { + // "albums" | "artists" | "folders" | "tracks" + in-out property active-tab: "albums"; + in property loading: false; + in property load-error: ""; + // Header stats / per-tab badge counts. + in property album-count: 0; + in property artist-count: 0; + in property track-count: 0; + in property folder-count: 0; +} + +export global LocalLibraryActions { + // Switch browse tab — records a per-tab history entry (mirrors Favorites + // / Discover) so back/forward moves between Local Library tabs. + callback select-tab(string /* "albums"|"artists"|"folders"|"tracks" */); + // Open Settings > Local Library (the gear button — management lives there). + callback open-settings(); +} + // Global toast state, driven by crate::toast. The Toast overlay (mounted // at the app root) renders whatever is here. The Rust layer owns timing // (show + auto-hide); the UI only sets `visible = false` on the close