diff --git a/crates/qbz-slint/src/auth.rs b/crates/qbz-slint/src/auth.rs index 2011b50f8..36b5ea341 100644 --- a/crates/qbz-slint/src/auth.rs +++ b/crates/qbz-slint/src/auth.rs @@ -17,8 +17,10 @@ use tokio::net::TcpListener; const OAUTH_TIMEOUT: Duration = Duration::from_secs(180); /// Run the full system-browser OAuth login. Returns the authenticated -/// user id on success. -pub async fn login_via_system_browser(runtime: &Arc>) -> Result +/// `(user_id, display_name)` on success. +pub async fn login_via_system_browser( + runtime: &Arc>, +) -> Result<(u64, String), String> where A: FrontendAdapter + Send + Sync + 'static, { @@ -68,6 +70,7 @@ where .map_err(|e| e.to_string())? }; let user_id = session.user_id; + let display_name = session.display_name.clone(); let token = session.user_auth_token.clone(); // Emit LoggedIn through the core (idempotent set_session). @@ -82,16 +85,18 @@ where } log::info!("[qbz-slint] login complete for user {user_id}"); - Ok(user_id) + Ok((user_id, display_name)) } /// Restore a previously saved session from the encrypted token store /// (keyring + AES-256-GCM file — the same store the Tauri app uses). /// -/// Returns `Ok(Some(user_id))` when a saved token is valid and the session -/// is activated, `Ok(None)` when there is no token. A token that exists -/// but is rejected by Qobuz is cleared and treated as `None`. -pub async fn restore_saved_session(runtime: &Arc>) -> Result, String> +/// Returns `Ok(Some((user_id, display_name)))` when a saved token is valid +/// and the session is activated, `Ok(None)` when there is no token. A token +/// that exists but is rejected by Qobuz is cleared and treated as `None`. +pub async fn restore_saved_session( + runtime: &Arc>, +) -> Result, String> where A: FrontendAdapter + Send + Sync + 'static, { @@ -112,10 +117,11 @@ where match core.login_with_token(&token).await { Ok(session) => { let user_id = session.user_id; + let display_name = session.display_name.clone(); core.set_session(session).await.map_err(|e| e.to_string())?; runtime.activate(user_id).await?; log::info!("[qbz-slint] restored saved session for user {user_id}"); - Ok(Some(user_id)) + Ok(Some((user_id, display_name))) } Err(e) => { log::warn!("[qbz-slint] saved token rejected, clearing: {e}"); @@ -125,6 +131,19 @@ where } } +/// Log out: clear the saved token, deactivate the per-user session, and +/// drop the Qobuz client session. +pub async fn logout(runtime: &Arc>) -> Result<(), String> +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let _ = qbz_credentials::clear_oauth_token(); + let _ = runtime.core().logout().await; + runtime.deactivate().await?; + log::info!("[qbz-slint] logged out"); + Ok(()) +} + /// Accept connections until one carries the OAuth code, replying with a /// minimal success page. Browser noise (favicon requests, etc.) is answered /// and skipped. diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index ca4cf7584..ab1ff5502 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -40,8 +40,10 @@ async fn enter_shell( runtime: Arc>, weak: slint::Weak, image_cache: artwork::ImageCache, + user_name: String, ) { - let _ = weak.upgrade_in_event_loop(|w| { + let _ = weak.upgrade_in_event_loop(move |w| { + w.global::().set_user_name(user_name.into()); w.global::().set_loading(true); w.set_screen(AppScreen::Shell); }); @@ -112,9 +114,9 @@ fn main() -> Result<(), Box> { log::error!("[qbz-slint] core init failed: {e}"); } match auth::restore_saved_session(&runtime).await { - Ok(Some(user_id)) => { + Ok(Some((user_id, user_name))) => { log::info!("[qbz-slint] session restored for user {user_id}"); - enter_shell(runtime, weak, image_cache).await; + enter_shell(runtime, weak, image_cache, user_name).await; } Ok(None) => { log::info!("[qbz-slint] no saved session — showing login"); @@ -142,9 +144,9 @@ fn main() -> Result<(), Box> { let image_cache = image_cache.clone(); handle.spawn(async move { match auth::login_via_system_browser(&runtime).await { - Ok(user_id) => { + Ok((user_id, user_name)) => { log::info!("[qbz-slint] authenticated as user {user_id}"); - enter_shell(runtime, weak, image_cache).await; + enter_shell(runtime, weak, image_cache, user_name).await; } Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"), } @@ -231,6 +233,32 @@ fn main() -> Result<(), Box> { }); } + // Log out: clear the session and return to the login screen. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.on_logout(move || { + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + if let Err(e) = auth::logout(&runtime).await { + log::error!("[qbz-slint] logout failed: {e}"); + } + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_showing_album(false); + w.global::().set_user_name("".into()); + w.set_screen(AppScreen::Login); + }); + }); + }); + } + + window.on_close_app(|| { + log::info!("[qbz-slint] closing"); + let _ = slint::quit_event_loop(); + }); + window.on_open_tos(|| { dispatch(AppCommand::OpenTermsOfService); if let Err(e) = open::that(QOBUZ_TOS_URL) { diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint index 37ebb1fea..8d1b46c10 100644 --- a/crates/qbz-slint/ui/album/AlbumPageView.slint +++ b/crates/qbz-slint/ui/album/AlbumPageView.slint @@ -10,10 +10,11 @@ import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { Spacing } from "../foundation/spacing.slint"; import { AlbumState, AlbumTrackItem, NavState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ScrollView } from "std-widgets.slint"; component CircleAction inherits Rectangle { - in property glyph; + in property icon; in property primary: false; in property diameter: 40px; callback clicked(); @@ -25,12 +26,13 @@ component CircleAction inherits Rectangle { ? (ta.has-hover ? Theme.accent-hover : Theme.accent) : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); - Text { - text: root.glyph; - color: root.primary ? Theme.accent-text : Theme.text-secondary; - font-size: 16px; - horizontal-alignment: center; - vertical-alignment: center; + QbzIcon { + source: root.icon; + width: 17px; + height: 17px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: root.primary ? Theme.accent-text : Theme.text-secondary; } ta := TouchArea { @@ -133,6 +135,7 @@ export component AlbumPageView inherits Rectangle { background: Theme.surface-main; ScrollView { + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; VerticalLayout { padding-left: 24px; padding-right: 24px; @@ -151,11 +154,14 @@ export component AlbumPageView inherits Rectangle { } back-row := HorizontalLayout { spacing: 6px; - Text { - text: "‹"; - color: back.has-hover ? Theme.text-secondary : Theme.text-muted; - font-size: 18px; - vertical-alignment: center; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/arrow-left.svg"); + width: 16px; + height: 16px; + tint: back.has-hover ? Theme.text-secondary : Theme.text-muted; + } } Text { text: @tr("Back"); @@ -222,18 +228,22 @@ export component AlbumPageView inherits Rectangle { HorizontalLayout { alignment: start; spacing: 12px; - CircleAction { glyph: "▶"; primary: true; diameter: 44px; } - VerticalLayout { - alignment: center; - CircleAction { glyph: "⇄"; } + CircleAction { + icon: @image-url("../assets/icons/play.svg"); + primary: true; + diameter: 44px; } VerticalLayout { alignment: center; - CircleAction { glyph: "♡"; } + CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); } } VerticalLayout { alignment: center; - CircleAction { glyph: "⋯"; } + CircleAction { icon: @image-url("../assets/icons/heart.svg"); } + } + VerticalLayout { + alignment: center; + CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); } } } } diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 83c45171f..ad327fb30 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -10,7 +10,7 @@ import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, AlbumState, NavState } from "state.slint"; +export { HomeState, AlbumState, NavState, SessionState } 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`. @@ -32,6 +32,8 @@ export component AppWindow inherits Window { callback start-offline(); callback open-tos(); callback open-album(string); + callback logout(); + callback close-app(); if root.screen == AppScreen.splash: SplashScreen { } @@ -54,5 +56,11 @@ export component AppWindow inherits Window { open-album(id) => { root.open-album(id); } + logout => { + root.logout(); + } + close-app => { + root.close-app(); + } } } diff --git a/crates/qbz-slint/ui/discover/AlbumCard.slint b/crates/qbz-slint/ui/discover/AlbumCard.slint index 6a4261b01..f506a3863 100644 --- a/crates/qbz-slint/ui/discover/AlbumCard.slint +++ b/crates/qbz-slint/ui/discover/AlbumCard.slint @@ -11,13 +11,14 @@ import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { Spacing } from "../foundation/spacing.slint"; import { AlbumCardItem } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; // Circular hover-overlay action button. component OverlayButton inherits Rectangle { - in property glyph; + in property icon; in property primary: false; in property diameter: 36px; - in property glyph-size: 16px; + in property icon-size: 16px; width: root.diameter; height: root.diameter; @@ -26,12 +27,13 @@ component OverlayButton inherits Rectangle { border-width: root.primary ? 0px : 1.5px; border-color: #ffffffe6; - Text { - text: root.glyph; - color: root.primary ? #000000 : #ffffff; - font-size: root.glyph-size; - horizontal-alignment: center; - vertical-alignment: center; + QbzIcon { + source: root.icon; + width: root.icon-size; + height: root.icon-size; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: root.primary ? #000000 : #ffffff; } } @@ -109,17 +111,17 @@ export component AlbumCard inherits Rectangle { VerticalLayout { alignment: center; - OverlayButton { glyph: "♡"; } + OverlayButton { icon: @image-url("../assets/icons/heart.svg"); } } OverlayButton { - glyph: "▶"; + icon: @image-url("../assets/icons/play.svg"); primary: true; diameter: 44px; - glyph-size: 17px; + icon-size: 18px; } VerticalLayout { alignment: center; - OverlayButton { glyph: "⋯"; glyph-size: 18px; } + OverlayButton { icon: @image-url("../assets/icons/ellipsis.svg"); } } } diff --git a/crates/qbz-slint/ui/discover/Carousel.slint b/crates/qbz-slint/ui/discover/Carousel.slint index 3d2b30e18..589cce3c2 100644 --- a/crates/qbz-slint/ui/discover/Carousel.slint +++ b/crates/qbz-slint/ui/discover/Carousel.slint @@ -9,9 +9,10 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { DiscoverSection } from "../state.slint"; import { AlbumCard } from "AlbumCard.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; component NavButton inherits Rectangle { - in property glyph; + in property icon; in property enabled: true; callback clicked(); @@ -23,12 +24,13 @@ component NavButton inherits Rectangle { ? Theme.surface-hover : Theme.surface-elevated; - Text { - text: root.glyph; - color: root.enabled ? Theme.text-primary : Theme.text-muted; - font-size: 16px; - horizontal-alignment: center; - vertical-alignment: center; + 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 { @@ -77,7 +79,7 @@ export component Carousel inherits VerticalLayout { VerticalLayout { alignment: center; NavButton { - glyph: "‹"; + icon: @image-url("../assets/icons/chevron-left.svg"); enabled: root.current-page > 0; clicked => { root.page = root.current-page - 1; @@ -87,7 +89,7 @@ export component Carousel inherits VerticalLayout { VerticalLayout { alignment: center; NavButton { - glyph: "›"; + icon: @image-url("../assets/icons/chevron-right.svg"); enabled: root.current-page < root.page-count - 1; clicked => { root.page = root.current-page + 1; diff --git a/crates/qbz-slint/ui/discover/HomeView.slint b/crates/qbz-slint/ui/discover/HomeView.slint index 14379ff75..f526f36ce 100644 --- a/crates/qbz-slint/ui/discover/HomeView.slint +++ b/crates/qbz-slint/ui/discover/HomeView.slint @@ -10,6 +10,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { HomeState } from "../state.slint"; import { Carousel } from "Carousel.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ScrollView } from "std-widgets.slint"; component Tab inherits Rectangle { @@ -50,8 +51,18 @@ component GenreButton inherits Rectangle { background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; HorizontalLayout { - padding-left: 14px; + padding-left: 12px; padding-right: 14px; + spacing: 7px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/list-filter.svg"); + width: 14px; + height: 14px; + tint: Theme.text-secondary; + } + } Text { text: @tr("Filter by genre"); color: Theme.text-secondary; @@ -76,12 +87,13 @@ component GearButton inherits Rectangle { border-radius: 4px; background: ta.has-hover ? Theme.surface-hover : transparent; - Text { - text: "⚙"; - color: ta.has-hover ? Theme.text-primary : Theme.text-muted; - font-size: 17px; - horizontal-alignment: center; - vertical-alignment: center; + QbzIcon { + source: @image-url("../assets/icons/settings.svg"); + width: 18px; + height: 18px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: ta.has-hover ? Theme.text-primary : Theme.text-muted; } ta := TouchArea { @@ -144,6 +156,7 @@ export component HomeView inherits Rectangle { // --- Scrollable sections ----------------------------------------- ScrollView { vertical-stretch: 1; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; VerticalLayout { padding: 32px; spacing: 28px; diff --git a/crates/qbz-slint/ui/primitives/QbzCheckbox.slint b/crates/qbz-slint/ui/primitives/QbzCheckbox.slint index e2a76d02c..879610d48 100644 --- a/crates/qbz-slint/ui/primitives/QbzCheckbox.slint +++ b/crates/qbz-slint/ui/primitives/QbzCheckbox.slint @@ -1,6 +1,7 @@ // Small square checkbox primitive matching the QBZ dark theme. import { Theme } from "../foundation/semantic-colors.slint"; +import { QbzIcon } from "QbzIcon.slint"; export component QbzCheckbox inherits Rectangle { in-out property checked: false; @@ -13,14 +14,14 @@ export component QbzCheckbox inherits Rectangle { border-width: root.checked ? 0px : 1.5px; border-color: Theme.text-muted; - Text { - text: "✓"; + QbzIcon { + source: @image-url("../assets/icons/check.svg"); visible: root.checked; - color: Theme.accent-text; - font-size: 13px; - font-weight: 700; - horizontal-alignment: center; - vertical-alignment: center; + width: 12px; + height: 12px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: Theme.accent-text; } TouchArea { diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 6e7722779..3862663dc 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -13,13 +13,22 @@ import { AlbumPageView } from "../album/AlbumPageView.slint"; export component AppShell inherits Rectangle { callback open-album(string); + callback logout(); + callback close-app(); background: Theme.surface-main; VerticalLayout { spacing: 0px; - HeaderBar { } + HeaderBar { + logout => { + root.logout(); + } + close-app => { + root.close-app(); + } + } Rectangle { height: Layout.hairline; background: Theme.border-subtle; diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint index 7dee7f621..9f0513ba4 100644 --- a/crates/qbz-slint/ui/shell/HeaderBar.slint +++ b/crates/qbz-slint/ui/shell/HeaderBar.slint @@ -1,11 +1,13 @@ -// Top header bar — primary navigation tabs and the search field. +// Top header bar — primary navigation tabs, a window-centered search +// field, and the user element with its dropdown menu. import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; import { Typography } from "../foundation/typography.slint"; import { Layout } from "../foundation/layout.slint"; import { Radius } from "../foundation/radius.slint"; -import { LineEdit } from "std-widgets.slint"; +import { SessionState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; component NavTab inherits Rectangle { in property label; @@ -37,35 +39,196 @@ component NavTab inherits Rectangle { } } -export component HeaderBar inherits Rectangle { - height: Layout.header-height; - background: Theme.surface-main; +component MenuItem inherits Rectangle { + in property icon; + in property label; + callback clicked(); + + height: 34px; + background: ta.has-hover ? Theme.surface-hover : transparent; HorizontalLayout { - padding-left: Spacing.md; - padding-right: Spacing.md; - spacing: Spacing.xs; - + padding-left: 12px; + padding-right: 18px; + spacing: 10px; VerticalLayout { alignment: center; - HorizontalLayout { - spacing: Spacing.xs; - NavTab { label: "Discover"; active: true; } - NavTab { label: "Library"; } - NavTab { label: "Local Library"; } - NavTab { label: "My QBZ"; } + QbzIcon { + source: root.icon; + width: 15px; + height: 15px; + tint: Theme.text-secondary; } } - - VerticalLayout { - alignment: center; + Text { + text: root.label; + color: Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; horizontal-stretch: 1; - HorizontalLayout { + } + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + +export component HeaderBar inherits Rectangle { + callback logout(); + callback close-app(); + + height: Layout.header-height; + background: Theme.surface-card; + + // --- Nav tabs (left) ------------------------------------------------- + VerticalLayout { + x: Spacing.md; + height: parent.height; + alignment: center; + HorizontalLayout { + spacing: Spacing.xs; + NavTab { label: "Discover"; active: true; } + NavTab { label: "Library"; } + NavTab { label: "Local Library"; } + NavTab { label: "My QBZ"; } + } + } + + // --- Search field, centered to the header width ---------------------- + Rectangle { + width: 320px; + height: 32px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: 6px; + border-width: 1px; + border-color: Theme.border-subtle; + background: Theme.surface-elevated; + + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + spacing: 7px; + VerticalLayout { alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 14px; + height: 14px; + tint: Theme.text-muted; + } + } + Text { + text: @tr("Search"); + color: Theme.text-muted; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + } + + // --- User element + dropdown (right) --------------------------------- + user-area := TouchArea { + width: user-row.preferred-width + 16px; + height: 36px; + x: parent.width - self.width - Spacing.md; + y: (parent.height - self.height) / 2; + mouse-cursor: pointer; + clicked => { + menu.show(); + } + + Rectangle { + width: 100%; + height: 100%; + border-radius: Radius.sm; + background: user-area.has-hover ? Theme.surface-hover : transparent; + } + user-row := HorizontalLayout { + x: 8px; + height: parent.height; + spacing: 8px; + VerticalLayout { + alignment: center; + Text { + text: SessionState.user-name == "" + ? @tr("Guest") + : SessionState.user-name; + color: Theme.text-primary; + font-size: Typography.body; + } + } + VerticalLayout { + alignment: center; + Image { + source: @image-url("../assets/qbz-logo.png"); + width: 28px; + height: 28px; + image-fit: contain; + } + } + } + } + + menu := PopupWindow { + x: parent.width - 234px - Spacing.md; + y: Layout.header-height - 4px; + width: 234px; + height: 254px; + + Rectangle { + background: Theme.surface-elevated; + border-radius: Radius.md; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 24px; + drop-shadow-color: Theme.card-shadow; + drop-shadow-offset-y: 6px; + + VerticalLayout { + padding-top: 6px; + padding-bottom: 6px; + MenuItem { + icon: @image-url("../assets/icons/settings.svg"); + label: @tr("Settings"); + } + MenuItem { + icon: @image-url("../assets/icons/keyboard.svg"); + label: @tr("Keyboard Shortcuts"); + } + MenuItem { + icon: @image-url("../assets/icons/book-open.svg"); + label: @tr("Documentation"); + } + MenuItem { + icon: @image-url("../assets/icons/bug.svg"); + label: @tr("Report an Issue"); + } + MenuItem { + icon: @image-url("../assets/icons/info.svg"); + label: @tr("About"); + } Rectangle { - width: 360px; - search := LineEdit { - placeholder-text: "Search"; + height: 1px; + background: Theme.border-subtle; + } + MenuItem { + icon: @image-url("../assets/icons/log-out.svg"); + label: @tr("Log Out"); + clicked => { + root.logout(); + } + } + MenuItem { + icon: @image-url("../assets/icons/x.svg"); + label: @tr("Close"); + clicked => { + root.close-app(); } } } diff --git a/crates/qbz-slint/ui/shell/PlayerBar.slint b/crates/qbz-slint/ui/shell/PlayerBar.slint index 7c9887377..5ef322287 100644 --- a/crates/qbz-slint/ui/shell/PlayerBar.slint +++ b/crates/qbz-slint/ui/shell/PlayerBar.slint @@ -1,18 +1,18 @@ // Bottom now-playing / transport bar. // -// Shell-frame increment: controls are laid out and hover-reactive but -// not yet wired to playback. Real playback state arrives in a later -// milestone. +// Controls are laid out and hover-reactive but not yet wired to +// playback. Real playback state arrives in a later milestone. import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; import { Typography } from "../foundation/typography.slint"; import { Layout } from "../foundation/layout.slint"; import { Radius } from "../foundation/radius.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; -component GlyphButton inherits Rectangle { - in property glyph; - in property glyph-size: 18px; +component IconButton inherits Rectangle { + in property icon; + in property icon-size: 18px; callback clicked(); width: 34px; @@ -20,12 +20,13 @@ component GlyphButton inherits Rectangle { border-radius: Radius.sm; background: ta.has-hover ? Theme.surface-hover : transparent; - Text { - text: root.glyph; - font-size: root.glyph-size; - color: ta.has-hover ? Theme.text-primary : Theme.text-secondary; - horizontal-alignment: center; - vertical-alignment: center; + QbzIcon { + source: root.icon; + width: root.icon-size; + height: root.icon-size; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary; } ta := TouchArea { @@ -38,7 +39,7 @@ component GlyphButton inherits Rectangle { export component PlayerBar inherits Rectangle { height: Layout.player-bar-height; - background: Theme.surface-main; + background: Theme.surface-card; VerticalLayout { spacing: 0px; @@ -65,11 +66,14 @@ export component PlayerBar inherits Rectangle { alignment: center; HorizontalLayout { spacing: 2px; - GlyphButton { glyph: "⇄"; } - GlyphButton { glyph: "⏮"; } - GlyphButton { glyph: "▶"; glyph-size: 22px; } - GlyphButton { glyph: "⏭"; } - GlyphButton { glyph: "↻"; } + IconButton { icon: @image-url("../assets/icons/shuffle.svg"); } + IconButton { icon: @image-url("../assets/icons/skip-back.svg"); } + IconButton { + icon: @image-url("../assets/icons/play.svg"); + icon-size: 22px; + } + IconButton { icon: @image-url("../assets/icons/skip-forward.svg"); } + IconButton { icon: @image-url("../assets/icons/repeat.svg"); } } } @@ -94,7 +98,7 @@ export component PlayerBar inherits Rectangle { font-size: Typography.body; } Text { - text: "QBZ Slint POC"; + text: "QBZ MVP"; color: Theme.text-muted; font-size: 12px; } @@ -108,8 +112,9 @@ export component PlayerBar inherits Rectangle { HorizontalLayout { alignment: end; spacing: Spacing.sm; - GlyphButton { glyph: "♡"; } - GlyphButton { glyph: "⤢"; } + IconButton { icon: @image-url("../assets/icons/heart.svg"); } + IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); } + IconButton { icon: @image-url("../assets/icons/volume-2.svg"); } VerticalLayout { alignment: center; Rectangle { diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint index c5306069f..936afcbc7 100644 --- a/crates/qbz-slint/ui/shell/Sidebar.slint +++ b/crates/qbz-slint/ui/shell/Sidebar.slint @@ -1,13 +1,13 @@ -// Left navigation sidebar — playlists list plus the user card. +// Left navigation sidebar — the playlists list. // // The playlist list is empty until real playlist data is wired (the -// sidebar is a later milestone). No placeholder/fixture rows. +// sidebar is a later milestone). The user element now lives in the +// header, not at the bottom of the sidebar. import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; -import { Typography } from "../foundation/typography.slint"; import { Layout } from "../foundation/layout.slint"; -import { Radius } from "../foundation/radius.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ScrollView } from "std-widgets.slint"; export component Sidebar inherits Rectangle { @@ -29,66 +29,25 @@ export component Sidebar inherits Rectangle { vertical-alignment: center; horizontal-stretch: 1; } - Text { - text: "+"; - color: Theme.text-muted; - font-size: 18px; - vertical-alignment: center; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/plus.svg"); + width: 16px; + height: 16px; + tint: Theme.text-muted; + } } } ScrollView { vertical-stretch: 1; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; VerticalLayout { spacing: 2px; // Real playlist rows are populated when the sidebar // milestone wires playlist data. No fixtures. } } - - Rectangle { - height: 56px; - border-radius: Radius.md; - background: Theme.surface-elevated; - - HorizontalLayout { - padding: 10px; - spacing: Spacing.sm; - - Rectangle { - width: 36px; - height: 36px; - border-radius: Radius.pill; - background: Theme.accent; - Text { - text: "Q"; - color: Theme.accent-text; - font-weight: Typography.semibold; - horizontal-alignment: center; - vertical-alignment: center; - } - } - VerticalLayout { - alignment: center; - horizontal-stretch: 1; - Text { - text: "QBZ User"; - color: Theme.text-primary; - font-size: Typography.body; - } - Text { - text: "QBZ MVP"; - color: Theme.text-muted; - font-size: 11px; - } - } - Text { - text: "⚙"; - color: Theme.text-muted; - font-size: 16px; - vertical-alignment: center; - } - } - } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index cae14fa4f..0af44422d 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -51,3 +51,8 @@ export global AlbumState { export global NavState { in-out property showing-album: false; } + +// Authenticated user, for the header user element. +export global SessionState { + in property user-name: ""; +}