feat(slint): Label landing page + All Releases sub-view
Port the Tauri LabelView module to native Slint, reusing existing components and extending where needed (no duplication). Landing (new LabelPageView): circular header + description read-more + Follow toggle, sticky JUMP TO bar, Popular Tracks (multi-select, 5->20->50 reveal, Play all), and Releases (+ See all) / Critics' Picks / Playlists / Artists / More Labels carousels. All Releases sub-view (LabelReleasesView): circular header + album count, toolbar (local search, group-by-artist, Hi-Res filter, sort) + grid/list toggle, load-more, states. TYPE column now populated. Split the label route: ContentView::label -> LabelPageView (landing), ContentView::label-releases -> LabelReleasesView; add NavEntry::LabelReleases. label.rs load_label_page parses /label/page + getAlbums + explore + the favorite-labels set; derive_releases re-derives the sub-view list. Reuse Carousel/PlaylistCarousel/ArtistCarousel/TrackRow/AlbumCard/ AlbumListRow/JumpNavBar/QbzSelect/ExpandableSearch; extend ArtistCard/ ArtistCarousel (follow-mode/follow-kind); promote CircleAction to a shared primitive; JumpNavBar gains show-search. Wire label media-actions: follow, play-top, open (more-labels), see-all. cc
This commit is contained in:
13 files changed
+1986
-152
No files matched your search
@@ -0,0 +1,493 @@
|
||||
// Label landing page — the rich label view (Tauri's LabelView.svelte):
|
||||
// a circular-portrait header with description + Follow, a sticky JUMP TO
|
||||
// bar, a Popular Tracks list, and Releases / Critics' Picks / Playlists /
|
||||
// Artists / More Labels carousels.
|
||||
//
|
||||
// Faithful 1:1 port assembled from existing primitives — the header,
|
||||
// bio modal and Popular Tracks block mirror ArtistPageView; the carousels
|
||||
// reuse Carousel / PlaylistCarousel / ArtistCarousel verbatim (More Labels
|
||||
// reuses ArtistCarousel with the follow-toggle + label kind). No new
|
||||
// widgets; the only new piece is this composition.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { LabelState, LabelActions, TrackItem } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { CircleAction } from "../primitives/CircleAction.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { JumpNavBar } from "../primitives/JumpNavBar.slint";
|
||||
import { Carousel } from "../discover/Carousel.slint";
|
||||
import { ArtistCarousel } from "../discover/ArtistCarousel.slint";
|
||||
import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint";
|
||||
|
||||
component SectionTitle inherits Text {
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
|
||||
export component LabelPageView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
// Full-description modal (same pattern as ArtistPageView's bio modal).
|
||||
property <bool> show-desc: false;
|
||||
// Active JUMP TO tab (set on click; passive scroll-tracking is a
|
||||
// follow-up, matching ArtistPageView).
|
||||
property <string> active-jump-tab: "about";
|
||||
// Popular Tracks progressive reveal: 5 -> 20 -> 50 (Tauri's steps).
|
||||
property <int> preview-count: 5;
|
||||
property <bool> multi-select-mode: false;
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
page-flickable := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 11px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
// --- History navigation --------------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
NavButtons { }
|
||||
}
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Label header --------------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: 32px;
|
||||
alignment: start;
|
||||
|
||||
// Circular 180px portrait + Disc-3 placeholder.
|
||||
Rectangle {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 90px;
|
||||
background: Theme.surface-elevated;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
drop-shadow-offset-y: 8px;
|
||||
clip: true;
|
||||
if LabelState.image-url == "": QbzIcon {
|
||||
source: @image-url("../assets/icons/disc-3.svg");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: LabelState.image;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
padding-top: 8px;
|
||||
spacing: 0px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
Text {
|
||||
text: @tr("LABEL");
|
||||
color: Theme.text-muted;
|
||||
font-size: 10px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
Rectangle { height: 6px; }
|
||||
Text {
|
||||
text: LabelState.name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// Description (HTML-stripped) + READ MORE -> modal.
|
||||
if LabelState.description != "": Rectangle { height: 12px; }
|
||||
if LabelState.description != "": Text {
|
||||
width: root.width - 296px;
|
||||
text: LabelState.description-short;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if LabelState.description-truncated: Rectangle { height: 4px; }
|
||||
if LabelState.description-truncated: read-more := TouchArea {
|
||||
width: read-more-row.preferred-width;
|
||||
height: 20px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.show-desc = 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: 18px; }
|
||||
|
||||
// Follow / Following — ghost circle, accent-tinted when
|
||||
// followed. Toggles via the global media-action channel.
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
CircleAction {
|
||||
diameter: 44px;
|
||||
active: LabelState.is-following;
|
||||
icon: LabelState.is-following
|
||||
? @image-url("../assets/icons/check.svg")
|
||||
: @image-url("../assets/icons/user-plus.svg");
|
||||
clicked => {
|
||||
root.media-action("label", LabelState.id, "follow");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reservation for the sticky JUMP TO overlay (its 44px + gap).
|
||||
// The overlay anchors its natural y to this element.
|
||||
jump-anchor := Rectangle { height: 52px; }
|
||||
|
||||
// --- Loading (first paint) -----------------------------------
|
||||
if LabelState.loading && LabelState.top-tracks.length == 0
|
||||
&& !LabelState.page-loaded: Rectangle {
|
||||
height: 280px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 18px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
Text {
|
||||
text: @tr("Loading…");
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Popular Tracks ------------------------------------------
|
||||
if LabelState.top-tracks.length > 0: HorizontalLayout {
|
||||
spacing: 12px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
SectionTitle {
|
||||
text: @tr("Popular Tracks");
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
horizontal-stretch: 0;
|
||||
spacing: 12px;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
primary: true;
|
||||
clicked => {
|
||||
root.media-action("label", LabelState.id, "play-top");
|
||||
}
|
||||
}
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/square-check-big.svg");
|
||||
active: root.multi-select-mode;
|
||||
clicked => {
|
||||
root.multi-select-mode = !root.multi-select-mode;
|
||||
}
|
||||
}
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/ellipsis.svg");
|
||||
clicked => {
|
||||
root.media-action("label", "", "top-tracks-menu");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if LabelState.top-tracks.length > 0: Rectangle { height: 10px; }
|
||||
|
||||
// Select-all row (multi-select mode).
|
||||
if root.multi-select-mode && LabelState.top-tracks.length > 0: HorizontalLayout {
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
border-width: 1.5px;
|
||||
border-color: check-all-ta.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
check-all-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("label", "", "select-all-top-tracks");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: @tr("Select all");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for track[index] in LabelState.top-tracks: TrackRow {
|
||||
visible: index < root.preview-count;
|
||||
height: self.visible ? 50px : 0px;
|
||||
track: track;
|
||||
index: index;
|
||||
// Label tracks carry the album thumbnail (Tauri shows it).
|
||||
show-artwork: true;
|
||||
multi-select-mode: root.multi-select-mode;
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
// Load more (5 -> 20 -> 50) / View less.
|
||||
if LabelState.top-tracks.length > 5: Rectangle { height: 4px; }
|
||||
if LabelState.top-tracks.length > 5: HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: reveal-text.preferred-width + 24px;
|
||||
height: 28px;
|
||||
reveal-text := Text {
|
||||
text: (root.preview-count < 50
|
||||
&& LabelState.top-tracks.length > root.preview-count)
|
||||
? @tr("Load more")
|
||||
: @tr("View less");
|
||||
color: reveal-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
reveal-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (root.preview-count < 50
|
||||
&& LabelState.top-tracks.length > root.preview-count) {
|
||||
root.preview-count = root.preview-count == 5 ? 20 : 50;
|
||||
} else {
|
||||
root.preview-count = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Releases carousel (+ See all) ---------------------------
|
||||
if LabelState.releases-section.albums.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.releases-section.albums.length > 0: Carousel {
|
||||
section: LabelState.releases-section;
|
||||
show-view-all: true;
|
||||
album-clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
view-all-clicked => {
|
||||
root.media-action("label", LabelState.id, "see-all-releases");
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Critics' Picks carousel ---------------------------------
|
||||
if LabelState.critics-section.albums.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.critics-section.albums.length > 0: Carousel {
|
||||
section: LabelState.critics-section;
|
||||
show-view-all: false;
|
||||
album-clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Playlists carousel --------------------------------------
|
||||
if LabelState.playlists.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.playlists.length > 0: PlaylistCarousel {
|
||||
title: @tr("Playlists");
|
||||
playlists: LabelState.playlists;
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Artists carousel ----------------------------------------
|
||||
if LabelState.artists.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.artists.length > 0: ArtistCarousel {
|
||||
title: @tr("Artists");
|
||||
artists: LabelState.artists;
|
||||
// No follow chip in the label Artists carousel (matches Tauri).
|
||||
card-follow-mode: "none";
|
||||
artist-clicked(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// --- More Labels carousel ------------------------------------
|
||||
if LabelState.more-labels.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.more-labels.length > 0: ArtistCarousel {
|
||||
title: @tr("More labels");
|
||||
artists: LabelState.more-labels;
|
||||
// Round cards with a Follow/Following toggle, kind = label.
|
||||
card-follow-mode: "toggle";
|
||||
card-follow-kind: "label";
|
||||
artist-clicked(id) => {
|
||||
root.media-action("label", id, "open");
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: page-flickable.viewport-height;
|
||||
visible-height: page-flickable.height;
|
||||
viewport-y <=> page-flickable.viewport-y;
|
||||
}
|
||||
|
||||
// --- Sticky JUMP TO bar -------------------------------------------------
|
||||
// Same sticky math as ArtistPageView: tracks the page scroll until the
|
||||
// header scrolls away, then pins to y = 0. No inline search (Tauri's
|
||||
// label jump-nav has none).
|
||||
if LabelState.jump-tabs.length > 1: JumpNavBar {
|
||||
property <length> natural-top:
|
||||
jump-anchor.absolute-position.y - page-flickable.absolute-position.y;
|
||||
x: 0;
|
||||
y: max(self.natural-top, 0px);
|
||||
width: root.width;
|
||||
show-search: false;
|
||||
tabs: LabelState.jump-tabs;
|
||||
active-tab-id: root.active-jump-tab;
|
||||
tab-clicked(id, anchor-y) => {
|
||||
root.active-jump-tab = id;
|
||||
page-flickable.viewport-y = -(anchor-y - self.height);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-description modal ---------------------------------------------
|
||||
if root.show-desc: Rectangle {
|
||||
background: #000000bf;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
root.show-desc = false;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80px, 560px);
|
||||
height: Math.min(root.height - 120px, 460px);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
TouchArea { }
|
||||
VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 14px;
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: @tr("About");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-stretch: 1;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
close-desc := TouchArea {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.show-desc = false;
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: close-desc.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: desc-content.preferred-height;
|
||||
desc-content := VerticalLayout {
|
||||
width: parent.width;
|
||||
Text {
|
||||
text: LabelState.description;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user