feat(slint): collapsible sidebar, Queue/Lyrics panels, nav menus
Shell chrome (steps 1-3): - Left sidebar is collapsible — a toggle in the nav cluster (left of the [<] [>] pair) and a draggable right edge that resizes 200-420px and collapses below 160px. ShellState global drives it. - Queue and Lyrics right sidebars: 320px panels rendered as layout siblings, so opening one pushes the content area narrower rather than overlaying it. The header captions/queue buttons toggle them (mutually exclusive) and light up when active. - The four nav tabs (Discover/Library/Local Library/My QBZ) open dropdown menus listing their sub-sections; Discover -> Home is wired. Detail views (steps 4-5): - Track rows: the even-row tint is now clearly fainter than the hover tint so the two states no longer look identical. - The favorite and offline-cache glyphs in album track rows are real buttons (hover state, emit media-action). - Album header artist link uses the metadata text color, bold, instead of the accent blue. - Album description and artist biography span the full content width below the header block. cc
This commit is contained in:
10 files changed
+461
-80
No files matched your search
@@ -56,9 +56,11 @@ component TrackRow inherits Rectangle {
|
|||||||
|
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 8px;
|
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
|
background: ta.has-hover
|
||||||
? Theme.surface-hover
|
? #ffffff16
|
||||||
: (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent);
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
// Row hover/background detector — declared before the content so the
|
// Row hover/background detector — declared before the content so the
|
||||||
// more-button keeps its own clicks.
|
// more-button keeps its own clicks.
|
||||||
@@ -144,28 +146,52 @@ component TrackRow inherits Rectangle {
|
|||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorite column (visual).
|
// Favorite button.
|
||||||
Rectangle {
|
VerticalLayout {
|
||||||
width: 28px;
|
alignment: center;
|
||||||
QbzIcon {
|
Rectangle {
|
||||||
source: @image-url("../assets/icons/heart.svg");
|
width: 28px;
|
||||||
width: 16px;
|
height: 28px;
|
||||||
height: 16px;
|
border-radius: Radius.sm;
|
||||||
x: (parent.width - self.width) / 2;
|
background: fav-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||||
y: (parent.height - self.height) / 2;
|
QbzIcon {
|
||||||
tint: Theme.text-muted;
|
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).
|
// Offline-cache button.
|
||||||
Rectangle {
|
VerticalLayout {
|
||||||
width: 28px;
|
alignment: center;
|
||||||
QbzIcon {
|
Rectangle {
|
||||||
source: @image-url("../assets/icons/cloud-download.svg");
|
width: 28px;
|
||||||
width: 16px;
|
height: 28px;
|
||||||
height: 16px;
|
border-radius: Radius.sm;
|
||||||
x: (parent.width - self.width) / 2;
|
background: dl-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||||
y: (parent.height - self.height) / 2;
|
QbzIcon {
|
||||||
tint: Theme.text-muted;
|
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 {
|
artist-text := Text {
|
||||||
text: AlbumState.artist;
|
text: AlbumState.artist;
|
||||||
|
// Same color as the surrounding metadata,
|
||||||
|
// bold to read as a link.
|
||||||
color: artist-area.has-hover
|
color: artist-area.has-hover
|
||||||
? Theme.accent-hover
|
? Theme.text-primary
|
||||||
: Theme.accent;
|
: Theme.text-secondary;
|
||||||
font-size: Typography.heading;
|
font-size: Typography.heading;
|
||||||
font-weight: Typography.medium;
|
font-weight: Typography.bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,34 +421,6 @@ export component AlbumPageView inherits Rectangle {
|
|||||||
overflow: elide;
|
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; }
|
Rectangle { height: 20px; }
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: start;
|
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: 20px; }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ component TrackRow inherits Rectangle {
|
|||||||
|
|
||||||
height: 52px;
|
height: 52px;
|
||||||
border-radius: 8px;
|
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
|
background: ta.has-hover
|
||||||
? Theme.surface-hover
|
? #ffffff16
|
||||||
: (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent);
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
ta := TouchArea {
|
ta := TouchArea {
|
||||||
mouse-cursor: default;
|
mouse-cursor: default;
|
||||||
@@ -253,14 +255,6 @@ export component ArtistPageView inherits Rectangle {
|
|||||||
font-weight: Typography.bold;
|
font-weight: Typography.bold;
|
||||||
overflow: elide;
|
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; }
|
Rectangle { height: 18px; }
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: start;
|
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; }
|
Rectangle { height: 36px; }
|
||||||
|
|
||||||
if ArtistState.loading && ArtistState.top-tracks.length == 0: Text {
|
if ArtistState.loading && ArtistState.top-tracks.length == 0: Text {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M9 3v18" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 260 B |
@@ -6,10 +6,12 @@
|
|||||||
// content well); no hairline borders.
|
// content well); no hairline borders.
|
||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
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 { Sidebar } from "Sidebar.slint";
|
||||||
import { HeaderBar } from "HeaderBar.slint";
|
import { HeaderBar } from "HeaderBar.slint";
|
||||||
import { PlayerBar } from "PlayerBar.slint";
|
import { PlayerBar } from "PlayerBar.slint";
|
||||||
|
import { QueueSidebar } from "QueueSidebar.slint";
|
||||||
|
import { LyricsSidebar } from "LyricsSidebar.slint";
|
||||||
import { HomeView } from "../discover/HomeView.slint";
|
import { HomeView } from "../discover/HomeView.slint";
|
||||||
import { AlbumPageView } from "../album/AlbumPageView.slint";
|
import { AlbumPageView } from "../album/AlbumPageView.slint";
|
||||||
import { ArtistPageView } from "../artist/ArtistPageView.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 { }
|
PlayerBar { }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Spacing } from "../foundation/spacing.slint";
|
|||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Layout } from "../foundation/layout.slint";
|
import { Layout } from "../foundation/layout.slint";
|
||||||
import { Radius } from "../foundation/radius.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";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
|
||||||
component NavTab inherits Rectangle {
|
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 <string> 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 <image> icon;
|
||||||
|
in property <string> label;
|
||||||
|
in property <bool> active: false;
|
||||||
|
in property <bool> 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 <int> 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 {
|
component HeaderIconButton inherits Rectangle {
|
||||||
in property <image> icon;
|
in property <image> icon;
|
||||||
|
in property <bool> active: false;
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: Radius.sm;
|
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 {
|
QbzIcon {
|
||||||
source: root.icon;
|
source: root.icon;
|
||||||
@@ -113,7 +202,8 @@ component HeaderIconButton inherits Rectangle {
|
|||||||
height: 19px;
|
height: 19px;
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 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 {
|
ta := TouchArea {
|
||||||
@@ -141,26 +231,34 @@ export component HeaderBar inherits Rectangle {
|
|||||||
y: (root.height - self.height) / 2;
|
y: (root.height - self.height) / 2;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
spacing: Spacing.xs;
|
spacing: Spacing.xs;
|
||||||
NavTab {
|
NavTabWithMenu {
|
||||||
icon: @image-url("../assets/icons/compass.svg");
|
icon: @image-url("../assets/icons/compass.svg");
|
||||||
label: "Discover";
|
label: @tr("Discover");
|
||||||
active: true;
|
active: true;
|
||||||
show-icon: root.show-tab-icons;
|
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");
|
icon: @image-url("../assets/icons/library-big.svg");
|
||||||
label: "Library";
|
label: @tr("Library");
|
||||||
show-icon: root.show-tab-icons;
|
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");
|
icon: @image-url("../assets/icons/hard-drive.svg");
|
||||||
label: "Local Library";
|
label: @tr("Local Library");
|
||||||
show-icon: root.show-tab-icons;
|
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");
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||||
label: "My QBZ";
|
label: @tr("My QBZ");
|
||||||
show-icon: root.show-tab-icons;
|
show-icon: root.show-tab-icons;
|
||||||
|
items: [@tr("Collections"), @tr("Mixtapes")];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +304,21 @@ export component HeaderBar inherits Rectangle {
|
|||||||
width: 112px;
|
width: 112px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
|
// Lyrics panel toggle.
|
||||||
HeaderIconButton {
|
HeaderIconButton {
|
||||||
icon: @image-url("../assets/icons/captions.svg");
|
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 {
|
HeaderIconButton {
|
||||||
icon: @image-url("../assets/icons/list-ordered.svg");
|
icon: @image-url("../assets/icons/list-ordered.svg");
|
||||||
|
active: ShellState.right-panel == 1;
|
||||||
|
clicked => {
|
||||||
|
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HeaderIconButton {
|
HeaderIconButton {
|
||||||
icon: @image-url("../assets/icons/menu.svg");
|
icon: @image-url("../assets/icons/menu.svg");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
// Rust nav history records every move.
|
// Rust nav history records every move.
|
||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { NavState } from "../state.slint";
|
import { NavState, ShellState } from "../state.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
|
||||||
component NavBtn inherits Rectangle {
|
component NavBtn inherits Rectangle {
|
||||||
@@ -48,6 +48,19 @@ export component NavButtons inherits Rectangle {
|
|||||||
btn-row := HorizontalLayout {
|
btn-row := HorizontalLayout {
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
spacing: 4px;
|
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 {
|
NavBtn {
|
||||||
icon: @image-url("../assets/icons/chevron-left.svg");
|
icon: @image-url("../assets/icons/chevron-left.svg");
|
||||||
enabled: NavState.can-back;
|
enabled: NavState.can-back;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,31 @@
|
|||||||
// The playlist list is empty until real playlist data is wired (the
|
// The playlist list is empty until real playlist data is wired (the
|
||||||
// sidebar is a later milestone). The user element now lives in the
|
// sidebar is a later milestone). The user element now lives in the
|
||||||
// header, not at the bottom of the sidebar.
|
// 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 { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Spacing } from "../foundation/spacing.slint";
|
import { Spacing } from "../foundation/spacing.slint";
|
||||||
import { Layout } from "../foundation/layout.slint";
|
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { ShellState } from "../state.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
|
|
||||||
export component Sidebar inherits Rectangle {
|
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
|
// The sidebar sits one surface above the content area, matching the
|
||||||
// QBZ dark theme (--bg-secondary sidebar over --bg-primary content).
|
// QBZ dark theme (--bg-secondary sidebar over --bg-primary content).
|
||||||
background: Theme.surface-card;
|
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 <length> drag-start-width: ShellState.sidebar-width;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding: Spacing.md;
|
padding: Spacing.md;
|
||||||
@@ -22,7 +35,7 @@ export component Sidebar inherits Rectangle {
|
|||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
Text {
|
Text {
|
||||||
text: "PLAYLISTS";
|
text: @tr("PLAYLISTS");
|
||||||
color: Theme.text-muted;
|
color: Theme.text-muted;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 1.5px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +168,16 @@ export global ArtistState {
|
|||||||
in property <bool> loading: false;
|
in property <bool> 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 <bool> sidebar-collapsed: false;
|
||||||
|
in-out property <length> sidebar-width: 280px;
|
||||||
|
// 0 = none, 1 = queue, 2 = lyrics
|
||||||
|
in-out property <int> right-panel: 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticated user, for the header user element.
|
// Authenticated user, for the header user element.
|
||||||
export global SessionState {
|
export global SessionState {
|
||||||
in property <string> user-name: "";
|
in property <string> user-name: "";
|
||||||
|
|||||||
Reference in new issue
Block a user