feat(qbz-slint): header redesign, Lucide icons, scrollbar fixes
- Replace every Unicode glyph placeholder with Lucide icons via QbzIcon (player transport, carousel nav, album-card overlay, album-page actions, gear, search, checkbox, sidebar). - Header: the search field is centered to the window width and gets a search icon; the header and player bar now use the elevated surface, so all chrome (header, sidebar, player bar) shares one color over the darker content area. - The sidebar user card moves to the header as a user element (name + QBZ logo) that opens a dropdown — Settings, Keyboard Shortcuts, Documentation, Report an Issue, About, plus Log Out and Close. Log Out clears the session and returns to login; Close quits the app. - Content ScrollViews disable the horizontal scrollbar — no view scrolls horizontally, so the phantom reserved lane is gone. cc
This commit is contained in:
13 files changed
+389
-165
No files matched your search
@@ -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<A>(runtime: &Arc<AppRuntime<A>>) -> Result<u64, String>
|
||||
/// `(user_id, display_name)` on success.
|
||||
pub async fn login_via_system_browser<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
) -> 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<A>(runtime: &Arc<AppRuntime<A>>) -> Result<Option<u64>, 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<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
) -> Result<Option<(u64, String)>, 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<A>(runtime: &Arc<AppRuntime<A>>) -> 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.
|
||||
|
||||
@@ -40,8 +40,10 @@ async fn enter_shell(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
image_cache: artwork::ImageCache,
|
||||
user_name: String,
|
||||
) {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
w.global::<SessionState>().set_user_name(user_name.into());
|
||||
w.global::<HomeState>().set_loading(true);
|
||||
w.set_screen(AppScreen::Shell);
|
||||
});
|
||||
@@ -112,9 +114,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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::<NavState>().set_showing_album(false);
|
||||
w.global::<SessionState>().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) {
|
||||
|
||||
@@ -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 <string> glyph;
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
in property <length> 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"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <string> glyph;
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
in property <length> diameter: 36px;
|
||||
in property <length> glyph-size: 16px;
|
||||
in property <length> 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"); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <string> glyph;
|
||||
in property <image> icon;
|
||||
in property <bool> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <bool> 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <string> 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 <image> icon;
|
||||
in property <string> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <string> glyph;
|
||||
in property <length> glyph-size: 18px;
|
||||
component IconButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <length> 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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,8 @@ export global AlbumState {
|
||||
export global NavState {
|
||||
in-out property <bool> showing-album: false;
|
||||
}
|
||||
|
||||
// Authenticated user, for the header user element.
|
||||
export global SessionState {
|
||||
in property <string> user-name: "";
|
||||
}
|
||||
Reference in new issue
Block a user