diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint index ea78365ed..58c96f8fb 100644 --- a/crates/qbz-slint/ui/album/AlbumPageView.slint +++ b/crates/qbz-slint/ui/album/AlbumPageView.slint @@ -56,9 +56,11 @@ component TrackRow inherits Rectangle { height: 56px; border-radius: 8px; + // The even-row tint is clearly fainter than the hover tint so the two + // never read as the same state. background: ta.has-hover - ? Theme.surface-hover - : (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent); + ? #ffffff16 + : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); // Row hover/background detector — declared before the content so the // more-button keeps its own clicks. @@ -144,28 +146,52 @@ component TrackRow inherits Rectangle { vertical-alignment: center; } - // Favorite column (visual). - Rectangle { - width: 28px; - QbzIcon { - source: @image-url("../assets/icons/heart.svg"); - width: 16px; - height: 16px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; - tint: Theme.text-muted; + // Favorite button. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 28px; + border-radius: Radius.sm; + background: fav-ta.has-hover ? Theme.surface-elevated : transparent; + QbzIcon { + source: @image-url("../assets/icons/heart.svg"); + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: fav-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + fav-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("track", root.track.id, "favorite"); + } + } } } - // Offline-cache column (visual). - Rectangle { - width: 28px; - QbzIcon { - source: @image-url("../assets/icons/cloud-download.svg"); - width: 16px; - height: 16px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; - tint: Theme.text-muted; + // Offline-cache button. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 28px; + border-radius: Radius.sm; + background: dl-ta.has-hover ? Theme.surface-elevated : transparent; + QbzIcon { + source: @image-url("../assets/icons/cloud-download.svg"); + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: dl-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + dl-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("track", root.track.id, "download"); + } + } } } @@ -377,11 +403,13 @@ export component AlbumPageView inherits Rectangle { } artist-text := Text { text: AlbumState.artist; + // Same color as the surrounding metadata, + // bold to read as a link. color: artist-area.has-hover - ? Theme.accent-hover - : Theme.accent; + ? Theme.text-primary + : Theme.text-secondary; font-size: Typography.heading; - font-weight: Typography.medium; + font-weight: Typography.bold; } } } @@ -393,34 +421,6 @@ export component AlbumPageView inherits Rectangle { overflow: elide; } - // Editorial description — fills the metadata column's - // width (the dead space right of the artwork), truncated, - // with a Read more link to the full modal. - if AlbumState.description != "": Rectangle { height: 12px; } - if AlbumState.description != "": Text { - text: AlbumState.description-short; - color: Theme.text-secondary; - font-size: Typography.legal; - wrap: word-wrap; - } - if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; } - if AlbumState.description != AlbumState.description-short: read-more := TouchArea { - width: read-more-row.preferred-width; - height: 20px; - mouse-cursor: pointer; - clicked => { - root.show-description = true; - } - read-more-row := HorizontalLayout { - Text { - text: @tr("Read more"); - color: read-more.has-hover ? Theme.accent-hover : Theme.accent; - font-size: Typography.legal; - vertical-alignment: center; - } - } - } - Rectangle { height: 20px; } HorizontalLayout { alignment: start; @@ -474,6 +474,33 @@ export component AlbumPageView inherits Rectangle { } } + // Editorial description — full content width (the artwork no + // longer caps it), truncated, with a Read more link. + if AlbumState.description != "": Rectangle { height: 18px; } + if AlbumState.description != "": Text { + text: AlbumState.description-short; + color: Theme.text-secondary; + font-size: Typography.body; + wrap: word-wrap; + } + if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; } + if AlbumState.description != AlbumState.description-short: read-more := TouchArea { + width: read-more-row.preferred-width; + height: 20px; + mouse-cursor: pointer; + clicked => { + root.show-description = true; + } + read-more-row := HorizontalLayout { + Text { + text: @tr("Read more"); + color: read-more.has-hover ? Theme.accent-hover : Theme.accent; + font-size: Typography.legal; + vertical-alignment: center; + } + } + } + Rectangle { height: 20px; } Rectangle { height: 1px; diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint index c332b12ea..a7bdc946b 100644 --- a/crates/qbz-slint/ui/artist/ArtistPageView.slint +++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint @@ -54,9 +54,11 @@ component TrackRow inherits Rectangle { height: 52px; border-radius: 8px; + // The even-row tint is clearly fainter than the hover tint so the two + // never read as the same state. background: ta.has-hover - ? Theme.surface-hover - : (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent); + ? #ffffff16 + : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); ta := TouchArea { mouse-cursor: default; @@ -253,14 +255,6 @@ export component ArtistPageView inherits Rectangle { font-weight: Typography.bold; overflow: elide; } - Rectangle { height: 12px; } - Text { - text: ArtistState.bio; - visible: ArtistState.bio != ""; - color: Theme.text-secondary; - font-size: Typography.body; - wrap: word-wrap; - } Rectangle { height: 18px; } HorizontalLayout { alignment: start; @@ -290,6 +284,15 @@ export component ArtistPageView inherits Rectangle { } } + // Biography — full content width, below the header block. + if ArtistState.bio != "": Rectangle { height: 18px; } + if ArtistState.bio != "": Text { + text: ArtistState.bio; + color: Theme.text-secondary; + font-size: Typography.body; + wrap: word-wrap; + } + Rectangle { height: 36px; } if ArtistState.loading && ArtistState.top-tracks.length == 0: Text { diff --git a/crates/qbz-slint/ui/assets/icons/panel-left.svg b/crates/qbz-slint/ui/assets/icons/panel-left.svg new file mode 100644 index 000000000..bd423196c --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 10e1d1019..a439e5878 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 { NavState, ContentView } from "../state.slint"; +import { NavState, ContentView, ShellState } from "../state.slint"; import { Sidebar } from "Sidebar.slint"; import { HeaderBar } from "HeaderBar.slint"; import { PlayerBar } from "PlayerBar.slint"; +import { QueueSidebar } from "QueueSidebar.slint"; +import { LyricsSidebar } from "LyricsSidebar.slint"; import { HomeView } from "../discover/HomeView.slint"; import { AlbumPageView } from "../album/AlbumPageView.slint"; import { ArtistPageView } from "../artist/ArtistPageView.slint"; @@ -105,6 +107,11 @@ export component AppShell inherits Rectangle { } } } + + // Right-side panel — a layout sibling of the content area, + // so it shrinks the content rather than overlaying it. + if ShellState.right-panel == 1: QueueSidebar { } + if ShellState.right-panel == 2: LyricsSidebar { } } PlayerBar { } diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint index b34dc5ace..47eead81d 100644 --- a/crates/qbz-slint/ui/shell/HeaderBar.slint +++ b/crates/qbz-slint/ui/shell/HeaderBar.slint @@ -13,7 +13,7 @@ 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 { SessionState, NavState, ContentView } from "../state.slint"; +import { SessionState, NavState, ContentView, ShellState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; component NavTab inherits Rectangle { @@ -97,15 +97,104 @@ component MenuItem inherits Rectangle { } } -// Square icon button for the header's right-side controls. +// Text-only menu row for the nav-tab dropdowns (no leading icon). +component NavMenuItem inherits Rectangle { + in property label; + callback clicked(); + + height: 32px; + background: ta.has-hover ? Theme.surface-hover : transparent; + + HorizontalLayout { + padding-left: 14px; + padding-right: 22px; + Text { + text: root.label; + color: Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + +// A nav tab plus its dropdown menu. Clicking the tab opens a +// PopupWindow positioned just below it, styled like the hamburger menu. +// Menu rows are visual placeholders for now; the optional `home-item` +// index marks the one row that keeps the app on the working Home view. +component NavTabWithMenu inherits Rectangle { + in property icon; + in property label; + in property active: false; + in property show-icon: true; + in property <[string]> items; + // Index of the row wired to Home (-1 = none). Clicking it switches + // to ContentView.home; all other rows are harmless no-ops. + in property home-item: -1; + + width: tab.width; + height: 30px; + + tab := NavTab { + icon: root.icon; + label: root.label; + active: root.active; + show-icon: root.show-icon; + clicked => { + menu.show(); + } + } + + menu := PopupWindow { + x: 0; + y: parent.height + 6px; + width: 188px; + height: menu-box.preferred-height; + + menu-box := 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; + for entry[idx] in root.items: NavMenuItem { + label: entry; + clicked => { + if (idx == root.home-item) { + NavState.view = ContentView.home; + } + } + } + } + } + } +} + +// 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 { in property icon; + in property active: false; callback clicked(); width: 36px; height: 36px; border-radius: Radius.sm; - background: ta.has-hover ? Theme.surface-hover : transparent; + background: root.active ? Theme.surface-elevated + : ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: root.icon; @@ -113,7 +202,8 @@ component HeaderIconButton inherits Rectangle { height: 19px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary; + tint: root.active ? Theme.accent + : ta.has-hover ? Theme.text-primary : Theme.text-secondary; } ta := TouchArea { @@ -141,26 +231,34 @@ export component HeaderBar inherits Rectangle { y: (root.height - self.height) / 2; height: 30px; spacing: Spacing.xs; - NavTab { + NavTabWithMenu { icon: @image-url("../assets/icons/compass.svg"); - label: "Discover"; + label: @tr("Discover"); active: true; show-icon: root.show-tab-icons; + items: [@tr("Home"), @tr("Editor's Picks"), @tr("For You")]; + // Row 0 = Home, the one working destination. + home-item: 0; } - NavTab { + NavTabWithMenu { icon: @image-url("../assets/icons/library-big.svg"); - label: "Library"; + label: @tr("Library"); show-icon: root.show-tab-icons; + items: [@tr("Tracks"), @tr("Albums"), @tr("Artists"), + @tr("Playlists"), @tr("Labels")]; } - NavTab { + NavTabWithMenu { icon: @image-url("../assets/icons/hard-drive.svg"); - label: "Local Library"; + label: @tr("Local Library"); show-icon: root.show-tab-icons; + items: [@tr("Albums"), @tr("Artists"), @tr("Folders"), + @tr("Tracks")]; } - NavTab { + NavTabWithMenu { icon: @image-url("../assets/icons/cassette-tape.svg"); - label: "My QBZ"; + label: @tr("My QBZ"); show-icon: root.show-tab-icons; + items: [@tr("Collections"), @tr("Mixtapes")]; } } @@ -206,11 +304,21 @@ export component HeaderBar inherits Rectangle { width: 112px; 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"); diff --git a/crates/qbz-slint/ui/shell/LyricsSidebar.slint b/crates/qbz-slint/ui/shell/LyricsSidebar.slint new file mode 100644 index 000000000..c8145287e --- /dev/null +++ b/crates/qbz-slint/ui/shell/LyricsSidebar.slint @@ -0,0 +1,85 @@ +// Right-side Lyrics panel. +// +// Rendered as a layout sibling of the content area (see AppShell), so it +// shrinks the content rather than overlaying it. Closing sets +// ShellState.right-panel = 0. The body is a structural placeholder; +// lyric contents are wired in a later milestone. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Spacing } from "../foundation/spacing.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { ShellState } from "../state.slint"; + +component CloseButton inherits Rectangle { + callback clicked(); + width: 28px; + height: 28px; + border-radius: Radius.sm; + background: ta.has-hover ? Theme.surface-hover : transparent; + + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 15px; + height: 15px; + 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 => { + root.clicked(); + } + } +} + +export component LyricsSidebar inherits Rectangle { + width: 320px; + background: Theme.surface-card; + + VerticalLayout { + spacing: 0px; + + // Header row — title + close button. + HorizontalLayout { + padding-left: Spacing.md; + padding-right: Spacing.sm; + padding-top: Spacing.sm; + padding-bottom: Spacing.sm; + height: 44px; + Text { + text: @tr("Lyrics"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + VerticalLayout { + alignment: center; + CloseButton { + clicked => { + ShellState.right-panel = 0; + } + } + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + // Body — placeholder until lyric contents are wired. + Rectangle { + vertical-stretch: 1; + Text { + text: @tr("No lyrics"); + color: Theme.text-muted; + font-size: Typography.body; + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/NavButtons.slint b/crates/qbz-slint/ui/shell/NavButtons.slint index 38af2eecf..85487b5eb 100644 --- a/crates/qbz-slint/ui/shell/NavButtons.slint +++ b/crates/qbz-slint/ui/shell/NavButtons.slint @@ -6,7 +6,7 @@ // Rust nav history records every move. import { Theme } from "../foundation/semantic-colors.slint"; -import { NavState } from "../state.slint"; +import { NavState, ShellState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; component NavBtn inherits Rectangle { @@ -48,6 +48,19 @@ export component NavButtons inherits Rectangle { btn-row := HorizontalLayout { padding: 3px; spacing: 4px; + // Leading button: collapses / expands the left sidebar. Lives + // here so it appears just left of the [<] [>] pair in every view. + NavBtn { + icon: @image-url("../assets/icons/panel-left.svg"); + clicked => { + ShellState.sidebar-collapsed = !ShellState.sidebar-collapsed; + } + } + // 1px separator between the collapse button and the history pair. + Rectangle { + width: 1px; + background: Theme.border-subtle; + } NavBtn { icon: @image-url("../assets/icons/chevron-left.svg"); enabled: NavState.can-back; diff --git a/crates/qbz-slint/ui/shell/QueueSidebar.slint b/crates/qbz-slint/ui/shell/QueueSidebar.slint new file mode 100644 index 000000000..beb0995a9 --- /dev/null +++ b/crates/qbz-slint/ui/shell/QueueSidebar.slint @@ -0,0 +1,85 @@ +// Right-side Queue panel. +// +// Rendered as a layout sibling of the content area (see AppShell), so it +// shrinks the content rather than overlaying it. Closing sets +// ShellState.right-panel = 0. The body is a structural placeholder; +// queue contents are wired in a later milestone. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Spacing } from "../foundation/spacing.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { ShellState } from "../state.slint"; + +component CloseButton inherits Rectangle { + callback clicked(); + width: 28px; + height: 28px; + border-radius: Radius.sm; + background: ta.has-hover ? Theme.surface-hover : transparent; + + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 15px; + height: 15px; + 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 => { + root.clicked(); + } + } +} + +export component QueueSidebar inherits Rectangle { + width: 320px; + background: Theme.surface-card; + + VerticalLayout { + spacing: 0px; + + // Header row — title + close button. + HorizontalLayout { + padding-left: Spacing.md; + padding-right: Spacing.sm; + padding-top: Spacing.sm; + padding-bottom: Spacing.sm; + height: 44px; + Text { + text: @tr("Queue"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + VerticalLayout { + alignment: center; + CloseButton { + clicked => { + ShellState.right-panel = 0; + } + } + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + // Body — placeholder until queue contents are wired. + Rectangle { + vertical-stretch: 1; + Text { + text: @tr("Nothing queued"); + color: Theme.text-muted; + font-size: Typography.body; + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint index 936afcbc7..8757afb72 100644 --- a/crates/qbz-slint/ui/shell/Sidebar.slint +++ b/crates/qbz-slint/ui/shell/Sidebar.slint @@ -3,18 +3,31 @@ // The playlist list is empty until real playlist data is wired (the // sidebar is a later milestone). The user element now lives in the // header, not at the bottom of the sidebar. +// +// The sidebar is collapsible: its rendered width is driven by +// ShellState (0px when collapsed, otherwise the user-set width). A thin +// drag handle on the right edge resizes it, clamped to [200px, 420px]; +// dragging below ~160px collapses it entirely. import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; -import { Layout } from "../foundation/layout.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { ShellState } from "../state.slint"; import { ScrollView } from "std-widgets.slint"; export component Sidebar inherits Rectangle { - width: Layout.sidebar-width; + // 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). background: Theme.surface-card; + // Clip so content doesn't spill while the width animates to 0. + 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; @@ -22,7 +35,7 @@ export component Sidebar inherits Rectangle { HorizontalLayout { Text { - text: "PLAYLISTS"; + text: @tr("PLAYLISTS"); color: Theme.text-muted; font-size: 11px; letter-spacing: 1.5px; @@ -50,4 +63,30 @@ export component Sidebar inherits Rectangle { } } } + + // 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); + } + } + } + } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 804e28afc..9008ec944 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -168,6 +168,16 @@ export global ArtistState { in property loading: false; } +// Shell chrome state — collapsible left sidebar and right-side panels. +// 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; + // 0 = none, 1 = queue, 2 = lyrics + in-out property right-panel: 0; +} + // Authenticated user, for the header user element. export global SessionState { in property user-name: "";