The drag-end relied on the row TouchArea's pointer-up, which only arrives if the row still holds the grab. Inside a ListView (or a Flickable that stays interactive) the scroll gesture can steal the grab mid-drag, so the up never reached the row and the ghost stayed glued to the cursor. Add a `changed pressed` safety net that ends the drag on any release or grab cancel (both paths guard on ta.dragging, so end() runs once). Also give LabelPageView's Flickable the interactive: !DragState.active flag the other track views already have. cc
497 lines
20 KiB
Plaintext
497 lines
20 KiB
Plaintext
// 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, DragState } 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 {
|
|
// Don't scroll while a track is being dragged out (keeps the row's
|
|
// pointer grab so the drag-end fires) — matches the other track views.
|
|
interactive: !DragState.active;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|