Ports Tauri's QobuzMixesRow as the first For You section — four 220px navigation tiles (DailyQ / WeeklyQ / FavQ / TopQ) with the 1:1 gradients, badge, name, and description. Pure navigation, no per-tile data. The mix detail views they open don't exist in the Slint MVP yet, so the click is logged for now. cc
361 lines
13 KiB
Plaintext
361 lines
13 KiB
Plaintext
// Discover — Home view: a fixed 56px toolbar (tab pill + genre filter +
|
|
// settings gear) over a scrollable column of section carousels.
|
|
//
|
|
// Section data is supplied by the Rust home controller through the
|
|
// HomeState global. The Editor's Picks / For You tabs, the genre filter,
|
|
// and the settings gear are visual for now; wiring them is a later
|
|
// increment.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { HomeState, HomeActions, GenreFilterState, NavState, ContentView } from "../state.slint";
|
|
import { Carousel } from "Carousel.slint";
|
|
import { SlimCarousel } from "SlimCarousel.slint";
|
|
import { HomeSkeleton } from "HomeSkeleton.slint";
|
|
import { ForYouView } from "ForYouView.slint";
|
|
import { GenreFilterPopup } from "GenreFilterPopup.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
|
|
// Header + hint shown for a recently-played module while it has no data
|
|
// yet (the play history fills once the playback session records plays).
|
|
component RecentPlaceholder inherits VerticalLayout {
|
|
in property <string> title;
|
|
in property <string> hint;
|
|
spacing: 10px;
|
|
Text {
|
|
text: root.title;
|
|
color: Theme.text-primary;
|
|
font-size: 18px;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
Text {
|
|
text: root.hint;
|
|
color: Theme.text-muted;
|
|
font-size: 13px;
|
|
}
|
|
}
|
|
|
|
component Tab inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> active: false;
|
|
callback clicked();
|
|
|
|
border-radius: 4px;
|
|
background: root.active ? Theme.surface-main
|
|
: ta.has-hover ? Theme.surface-hover : transparent;
|
|
|
|
HorizontalLayout {
|
|
padding-left: 14px;
|
|
padding-right: 14px;
|
|
padding-top: 6px;
|
|
padding-bottom: 6px;
|
|
Text {
|
|
text: root.label;
|
|
color: root.active ? Theme.text-primary : Theme.text-muted;
|
|
font-size: 13px;
|
|
font-weight: Typography.medium;
|
|
}
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
component GenreButton inherits Rectangle {
|
|
callback clicked();
|
|
|
|
// Accent-filled + "N genres" label when a filter is active,
|
|
// matching Tauri.
|
|
property <bool> active: GenreFilterState.selected-count > 0;
|
|
|
|
// Hug the content — the button is only as wide as its label.
|
|
width: genre-row.preferred-width;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: root.active
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
|
|
|
genre-row := HorizontalLayout {
|
|
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: root.active ? #ffffff : Theme.text-secondary;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.active
|
|
? (GenreFilterState.selected-count == 1
|
|
? @tr("1 genre")
|
|
: @tr("{} genres", GenreFilterState.selected-count))
|
|
: @tr("Filter by genre");
|
|
color: root.active ? #ffffff : Theme.text-secondary;
|
|
font-size: 13px;
|
|
font-weight: root.active ? Typography.medium : Typography.regular;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
component GearButton inherits Rectangle {
|
|
callback clicked();
|
|
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 4px;
|
|
background: ta.has-hover ? Theme.surface-hover : transparent;
|
|
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/home-gear.svg");
|
|
width: 20px;
|
|
height: 20px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
export component HomeView inherits Rectangle {
|
|
callback album-clicked(string);
|
|
callback open-artist(string);
|
|
callback media-action(string, string, string);
|
|
|
|
background: Theme.surface-main;
|
|
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// --- Toolbar (fixed 56px) ----------------------------------------
|
|
// Side controls are anchored to the toolbar edges; the three-tab
|
|
// pill is absolutely centered on the toolbar so it stays at the
|
|
// true horizontal center regardless of the side controls' widths.
|
|
Rectangle {
|
|
height: 56px;
|
|
background: Theme.surface-main;
|
|
|
|
// History pair + the three-tab nav bar, grouped at the left
|
|
// with a gap delimiting the two clusters. Explicit width
|
|
// (= content) so the layout does not fill the toolbar and
|
|
// overlap the right controls.
|
|
left-controls := HorizontalLayout {
|
|
x: 32px;
|
|
// Align the toolbar content's vertical center to y=25 —
|
|
// 11px (the back/forward padding-top used by the album /
|
|
// artist / settings views) + 14px (half the 28px nav
|
|
// buttons). Keeps the [<] [>] pair at the same screen
|
|
// position across every view, with no jump on navigation.
|
|
y: 25px - self.height / 2;
|
|
width: self.preferred-width;
|
|
// Explicit height too — without it the layout fills the
|
|
// 56px toolbar, so `y` resolves to 0 and the tab pill
|
|
// sticks to the top instead of centering.
|
|
height: self.preferred-height;
|
|
spacing: 16px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
NavButtons { }
|
|
}
|
|
tab-pill := Rectangle {
|
|
width: tab-row.preferred-width;
|
|
height: tab-row.preferred-height;
|
|
background: Theme.surface-elevated;
|
|
border-radius: 6px;
|
|
tab-row := HorizontalLayout {
|
|
padding: 3px;
|
|
spacing: 4px;
|
|
Tab {
|
|
label: @tr("Home");
|
|
active: HomeState.active-tab == "home";
|
|
clicked => { HomeActions.select-tab("home"); }
|
|
}
|
|
Tab {
|
|
label: @tr("Editor's Picks");
|
|
active: HomeState.active-tab == "editorPicks";
|
|
clicked => { HomeActions.select-tab("editorPicks"); }
|
|
}
|
|
Tab {
|
|
label: @tr("For You");
|
|
active: HomeState.active-tab == "forYou";
|
|
clicked => { HomeActions.select-tab("forYou"); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Genre filter + sort gear, anchored right. Explicit width
|
|
// (= content) so `x` resolves correctly — without it the
|
|
// layout fills the toolbar and the cluster lands far left,
|
|
// on top of the history arrows.
|
|
right-controls := HorizontalLayout {
|
|
x: parent.width - self.width - 32px;
|
|
// Same y baseline as the left controls (see above).
|
|
y: 25px - self.height / 2;
|
|
width: self.preferred-width;
|
|
height: self.preferred-height;
|
|
spacing: 6px;
|
|
GenreButton {
|
|
clicked => {
|
|
GenreFilterState.open = !GenreFilterState.open;
|
|
}
|
|
}
|
|
// Per-tab sort / show-hide control for the Discover
|
|
// sections (not global Settings — that lives in the
|
|
// header menu). Its menu is a later increment.
|
|
GearButton { }
|
|
}
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// --- Scrollable sections -----------------------------------------
|
|
// A Flickable (not a ScrollView): wheel scrolling with no scrollbar
|
|
// gutters at all. viewport-width is pinned to the Flickable width so
|
|
// the carousels' clipped content can never scroll horizontally.
|
|
Flickable {
|
|
vertical-stretch: 1;
|
|
viewport-width: self.width;
|
|
viewport-height: home-content.preferred-height;
|
|
|
|
home-content := VerticalLayout {
|
|
padding: 32px;
|
|
spacing: 40px;
|
|
alignment: start;
|
|
|
|
if HomeState.active-tab != "forYou"
|
|
&& HomeState.loading
|
|
&& HomeState.sections.length == 0: HomeSkeleton { }
|
|
|
|
for section in HomeState.sections: Carousel {
|
|
section: section;
|
|
album-clicked(id) => {
|
|
root.album-clicked(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
|
|
// For You renders its own ordered, heterogeneous
|
|
// sections from ForYouState.
|
|
if HomeState.active-tab == "forYou": ForYouView {
|
|
open-album(id) => {
|
|
root.album-clicked(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
play-track(id) => {
|
|
root.media-action("track", id, "play");
|
|
}
|
|
play-top-tracks(id) => {
|
|
root.media-action("artist", id, "play-top");
|
|
}
|
|
start-radio(id) => {
|
|
root.media-action("artist", id, "radio");
|
|
}
|
|
open-mix(which) => {
|
|
root.media-action("mix", which, "open");
|
|
}
|
|
}
|
|
|
|
// The Recently Played + Popular sections below belong
|
|
// to the Home tab only; Editor's Picks / For You show
|
|
// just their own cached section set above.
|
|
|
|
// Recently Played Albums — always present; shows a
|
|
// placeholder until the play history has data.
|
|
if HomeState.active-tab == "home"
|
|
&& HomeState.recent-albums.length > 0: Carousel {
|
|
section: {
|
|
title: @tr("Recently Played Albums"),
|
|
albums: HomeState.recent-albums,
|
|
};
|
|
album-clicked(id) => {
|
|
root.album-clicked(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if HomeState.active-tab == "home"
|
|
&& HomeState.recent-albums.length == 0: RecentPlaceholder {
|
|
title: @tr("Recently Played Albums");
|
|
hint: @tr("Albums you play will appear here.");
|
|
}
|
|
|
|
// Recently Played Tracks — paginated 4x3 carousel
|
|
// (12 per page, up to two pages).
|
|
if HomeState.active-tab == "home"
|
|
&& HomeState.recent.length > 0: SlimCarousel {
|
|
title: @tr("Recently Played Tracks");
|
|
items: HomeState.recent;
|
|
item-clicked(id) => {
|
|
root.media-action("track", id, "play");
|
|
}
|
|
}
|
|
if HomeState.active-tab == "home"
|
|
&& HomeState.recent.length == 0: RecentPlaceholder {
|
|
title: @tr("Recently Played Tracks");
|
|
hint: @tr("Tracks you play will appear here.");
|
|
}
|
|
|
|
if HomeState.active-tab == "home"
|
|
&& HomeState.popular.length > 0: SlimCarousel {
|
|
title: @tr("Popular albums");
|
|
items: HomeState.popular;
|
|
item-clicked(id) => {
|
|
root.album-clicked(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Genre filter overlay --------------------------------------------
|
|
// Backdrop closes the popup on outside click; the panel anchors to
|
|
// the top-right under the Filter-by-genre button.
|
|
if GenreFilterState.open: Rectangle {
|
|
width: 100%;
|
|
height: 100%;
|
|
TouchArea {
|
|
clicked => {
|
|
GenreFilterState.open = false;
|
|
}
|
|
}
|
|
}
|
|
if GenreFilterState.open: GenreFilterPopup {
|
|
x: root.width - self.width - 32px;
|
|
y: 52px;
|
|
}
|
|
}
|