Makes the three-tab Discover pill (Home / Editor's Picks / For You)
functional, plus the matching Discover header-dropdown rows. Mirrors
Tauri's DiscoveryView: all tabs draw from one fetch and swap which
curated section set is shown — no extra round-trips on tab switch.
home.rs:
- load_home now builds three section sets from a single
get_discover_index call (cloning the editorial containers):
* home — the existing balanced set.
* editorPicks — editorial only (New Releases, Qobuzissimes,
Press Accolades, Most Streamed, Ideal Discography, Albums of
the Week), ordered per Tauri's DEFAULT_PREFS.editorPicks.
* forYou — Release Watch (get_release_watch "artists"). The
deeper personalized sections (Qobuz mixes, similar albums,
spotlight) need reco-DB backend the Slint MVP lacks, so this
is the honest first cut.
- apply_home caches all three sets in a thread-local; select_tab
swaps HomeState.sections to the chosen set and returns the
Section-targeted artwork jobs to re-fire. Reuses the existing
Section artwork target — no new targets.
- map_full_album maps release-watch's catalog Album to a card.
Slint:
- HomeState gains active-tab; HomeActions.select-tab wired to the
pill (active state derived from active-tab) and to the Discover
dropdown rows (route "discover-<tab>") via header-menu-navigate.
- The Home-only sections (Recently Played albums/tracks, Popular)
are gated to active-tab == "home" so Editor's Picks / For You
render just their own set.
cc
497 lines
18 KiB
Plaintext
497 lines
18 KiB
Plaintext
// Top header bar — navigation tabs anchored left, the user element
|
|
// anchored right, and the search field absolutely centered on the window.
|
|
//
|
|
// The search field is positioned at the true window center (not inside a
|
|
// layout column) so it stays centered regardless of how wide the tab row
|
|
// or the user element become.
|
|
//
|
|
// Responsive: below the first breakpoint the tabs drop their icons;
|
|
// below the second the search field narrows.
|
|
|
|
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 { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions } from "../state.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
|
|
// Sidebar collapse toggle — bare bright icon, no background. Sits at
|
|
// the very left of the header, before the Discover tab.
|
|
component SidebarToggle inherits Rectangle {
|
|
width: 30px;
|
|
height: 30px;
|
|
|
|
// 14px matches the nav-tab icons; with the 30px box the centering
|
|
// math ((30-14)/2 = 8) lands on whole pixels so the glyph stays
|
|
// crisp. Tint matches the tab icons (secondary, primary on hover).
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/panel-left.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
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 => {
|
|
ShellState.sidebar-collapsed = !ShellState.sidebar-collapsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
component NavTab inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <string> label;
|
|
in property <bool> active: false;
|
|
in property <bool> show-icon: true;
|
|
callback clicked();
|
|
// Fires when the pointer first enters the tab — drives hover-to-open.
|
|
callback hovered();
|
|
|
|
height: 30px;
|
|
width: tab-row.preferred-width;
|
|
border-radius: Radius.sm;
|
|
background: root.active ? Theme.surface-elevated
|
|
: ta.has-hover ? Theme.surface-hover : transparent;
|
|
|
|
tab-row := HorizontalLayout {
|
|
padding-left: root.show-icon ? 9px : 11px;
|
|
padding-right: 7px;
|
|
spacing: 6px;
|
|
if root.show-icon: VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: root.active ? Theme.text-primary : Theme.text-secondary;
|
|
}
|
|
}
|
|
// An explicit row-height box plus vertical-alignment centers the
|
|
// label optically against the glyph and caret — a bare Text box
|
|
// rides high because of the font's descender space.
|
|
Text {
|
|
text: root.label;
|
|
height: root.height;
|
|
vertical-alignment: center;
|
|
color: Theme.text-primary;
|
|
font-size: 11px;
|
|
font-weight: root.active ? Typography.semibold : Typography.regular;
|
|
}
|
|
// Caret marking the tab as a dropdown.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/chevron-down.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
tint: root.active ? Theme.text-secondary : Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
changed has-hover => {
|
|
if (self.has-hover) {
|
|
root.hovered();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: 12px;
|
|
padding-right: 18px;
|
|
spacing: 10px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 15px;
|
|
height: 15px;
|
|
tint: Theme.text-secondary;
|
|
}
|
|
}
|
|
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.
|
|
//
|
|
// The tab no longer owns a PopupWindow — a PopupWindow grabs the pointer
|
|
// while open, so hovering a sibling tab delivered no hover event and the
|
|
// menus could not be hover-switched. Instead, hovering or clicking the
|
|
// tab writes this tab's identity (index, header x-position, menu rows,
|
|
// home-item) into the HeaderMenuState global. The actual menu panel is
|
|
// drawn by HeaderMenuOverlay, a late child of the AppShell root, so it
|
|
// renders on top of the content area and does not grab the pointer.
|
|
//
|
|
// Hovering a different tab simply overwrites HeaderMenuState, so the new
|
|
// menu opens and the previous one closes instantly. Single-open is
|
|
// automatic — there is one global `open-index`.
|
|
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 <[NavMenuEntry]> items;
|
|
// This tab's index (0..3) — written into HeaderMenuState.open-index.
|
|
in property <int> tab-index;
|
|
// 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;
|
|
|
|
// Publishes this tab's menu into the shared overlay state. The
|
|
// overlay aligns the panel under `anchor-x`; `absolute-position`
|
|
// resolves to a window-relative coordinate, which is what the
|
|
// AppShell-level overlay needs (the tab itself sits inside the
|
|
// header's tab-row layout, several offsets deep).
|
|
function open-menu() {
|
|
HeaderMenuState.open-index = root.tab-index;
|
|
HeaderMenuState.anchor-x = root.absolute-position.x;
|
|
HeaderMenuState.items = root.items;
|
|
HeaderMenuState.home-item = root.home-item;
|
|
}
|
|
|
|
tab := NavTab {
|
|
icon: root.icon;
|
|
label: root.label;
|
|
active: root.active;
|
|
show-icon: root.show-icon;
|
|
clicked => {
|
|
root.open-menu();
|
|
}
|
|
// Open the dropdown on hover, no click needed. Hovering a
|
|
// different tab overwrites the global → instant switch.
|
|
hovered => {
|
|
root.open-menu();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 <image> icon;
|
|
in property <bool> active: false;
|
|
callback clicked();
|
|
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: Radius.sm;
|
|
background: root.active ? Theme.surface-elevated
|
|
: ta.has-hover ? Theme.surface-hover : transparent;
|
|
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 19px;
|
|
height: 19px;
|
|
// Round the centering to whole pixels — a fractional offset
|
|
// (here 8.5px) makes the glyph render blurry.
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: root.active ? Theme.accent
|
|
: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
|
}
|
|
|
|
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;
|
|
|
|
// Responsive breakpoints.
|
|
property <bool> show-tab-icons: root.width >= 1140px;
|
|
// 80% of the prior search width (was 224px / 320px).
|
|
property <length> search-width: root.width < 960px ? 179px : 256px;
|
|
|
|
// --- Tabs (anchored left) ----------------------------------------------
|
|
tabs := HorizontalLayout {
|
|
// xs (not md): the sidebar toggle is a bare icon with ~10px of
|
|
// breathing room to the Discover tab; the window-edge margin is
|
|
// matched to that so the toggle glyph sits symmetrically.
|
|
x: Spacing.xs;
|
|
y: (root.height - self.height) / 2;
|
|
height: 30px;
|
|
spacing: 2px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
SidebarToggle { }
|
|
}
|
|
NavTabWithMenu {
|
|
tab-index: 0;
|
|
icon: @image-url("../assets/icons/compass.svg");
|
|
label: @tr("Discover");
|
|
active: true;
|
|
show-icon: root.show-tab-icons;
|
|
// Discover rows are text-only (no glyphs).
|
|
items: [
|
|
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
|
|
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
|
|
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
|
|
];
|
|
// Row 0 = Home, the one working destination.
|
|
home-item: 0;
|
|
}
|
|
NavTabWithMenu {
|
|
tab-index: 1;
|
|
icon: @image-url("../assets/icons/music-library-2.svg");
|
|
label: @tr("Library");
|
|
show-icon: root.show-tab-icons;
|
|
items: [
|
|
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
|
|
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
|
|
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
|
|
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
|
|
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
|
|
];
|
|
}
|
|
NavTabWithMenu {
|
|
tab-index: 2;
|
|
icon: @image-url("../assets/icons/hard-drive.svg");
|
|
label: @tr("Local Library");
|
|
show-icon: root.show-tab-icons;
|
|
items: [
|
|
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true },
|
|
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true },
|
|
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true },
|
|
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true },
|
|
];
|
|
}
|
|
NavTabWithMenu {
|
|
tab-index: 3;
|
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
|
label: @tr("My QBZ");
|
|
show-icon: root.show-tab-icons;
|
|
items: [
|
|
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
|
|
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
|
|
];
|
|
}
|
|
}
|
|
|
|
// --- Search (absolutely centered on the window) ------------------------
|
|
Rectangle {
|
|
x: (root.width - self.width) / 2;
|
|
y: (root.height - self.height) / 2;
|
|
width: root.search-width;
|
|
height: 32px;
|
|
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;
|
|
}
|
|
}
|
|
// Real text input, with a placeholder shown while empty —
|
|
// same pattern as the album track-search field.
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
search-input := TextInput {
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
single-line: true;
|
|
// Enter submits to the full results page; every edit
|
|
// feeds the debounced live search (Rust side).
|
|
accepted => { SearchActions.submit(self.text); }
|
|
edited => { SearchActions.live(self.text); }
|
|
}
|
|
Text {
|
|
visible: search-input.text == "";
|
|
text: @tr("Search");
|
|
// Span the input box so vertical-alignment centers the
|
|
// placeholder the same way the TextInput centers its
|
|
// own text — otherwise it rides at the top.
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-muted;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Right controls: queue, lyrics, hamburger (anchored right) ---------
|
|
// Explicit width (3 * 36 + 2 * 2 spacing) so the right anchor lands
|
|
// correctly — a layout left to its own devices fills the parent.
|
|
right-controls := HorizontalLayout {
|
|
// 2px tighter to the window edge than the standard margin.
|
|
x: root.width - self.width - Spacing.md + 2px;
|
|
y: (root.height - self.height) / 2;
|
|
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");
|
|
clicked => {
|
|
menu.show();
|
|
}
|
|
}
|
|
}
|
|
|
|
menu := PopupWindow {
|
|
x: parent.width - 234px - Spacing.md;
|
|
y: Layout.header-height - 4px;
|
|
width: 234px;
|
|
height: 310px;
|
|
|
|
Rectangle {
|
|
background: Theme.surface-main;
|
|
border-radius: Radius.sm;
|
|
border-width: 1px;
|
|
border-color: Theme.border-muted;
|
|
drop-shadow-blur: 24px;
|
|
drop-shadow-color: Theme.card-shadow;
|
|
drop-shadow-offset-y: 6px;
|
|
|
|
VerticalLayout {
|
|
padding-top: 6px;
|
|
padding-bottom: 6px;
|
|
|
|
// Signed-in user — name and subscription tier.
|
|
HorizontalLayout {
|
|
padding-left: 14px;
|
|
padding-right: 14px;
|
|
padding-top: 6px;
|
|
padding-bottom: 10px;
|
|
VerticalLayout {
|
|
spacing: 2px;
|
|
Text {
|
|
text: SessionState.user-name == ""
|
|
? @tr("Guest")
|
|
: SessionState.user-name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
Text {
|
|
text: SessionState.subscription == ""
|
|
? @tr("Not signed in")
|
|
: SessionState.subscription;
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
Rectangle { height: 4px; }
|
|
|
|
MenuItem {
|
|
icon: @image-url("../assets/icons/settings.svg");
|
|
label: @tr("Settings");
|
|
clicked => {
|
|
NavState.request-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 {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|