NVIDIA shader fix (root-caused via diagnostic bisection on the discrete GPU):
- ambient.wgsl + plasma.wgsl: replace the sin-based hash `fract(sin(dot(p,k))*N)`
with the sin-free Dave Hoskins hash. On NVIDIA, as noise coords grow with
time, sin's argument gets huge and range-reduction precision diverges from
Intel's, degenerating the noise into big blocks. Confirmed via a solid-color
clear (clean) then a uniform-passthrough (clean) — isolating it to the shader
hash, not the texture handoff.
- ImmersiveView: the 3 immersive-exit paths (X button, in-menu exit, ESC) kept
the drain alive only for the Large NPB dock, freezing the shader texture on
the last immersive frame when the app-wide Ambient background was on. Now they
also keep it alive for Ambient so it renders the ambient scene after exit.
UI batch:
- Auto-hide scrollbar (Discover's ListScrollbar auto-hide + pointer hit-test)
on AlbumView, ArtistView, PlaylistView, PlaylistManagerView.
- #631: artist Popular Tracks now show the album cover thumbnail (thread
artwork_url through TrackData -> track_data_to_item + a new
ArtworkTarget::ArtistTopTrack decode job, mirroring LabelTopTrack); label
Popular Tracks fall back to the album's main artist for the name+id. Quality
badge already rendered by TrackRow — reused, not reinvented.
- #446: "Add to Mixtape/Collection" bulk button in the Favorites/Artist/Label/
Mix selection bars, mirroring the existing add-to-mixtape path (track items
auto-restrict the picker to mixtapes, not collections).
slintcheck + Opus Rust review green (no compile blockers). Base: 8c2f84b7.
cc
500 lines
22 KiB
Plaintext
500 lines
22 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, NavState, BulkAction, ShellState, AppearanceState } 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 { MultiSelectBar } from "../primitives/MultiSelectBar.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;
|
|
|
|
background: ShellState.app-background-active ? transparent : 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;
|
|
// Scroll-position restore on back/forward (see nav::scope_for).
|
|
property <string> sr-armed: NavState.restore-scope;
|
|
function sr-restore() {
|
|
if (NavState.restore-scope == "label" && self.viewport-height > self.height) {
|
|
self.viewport-y = NavState.scroll-restore;
|
|
NavState.restore-scope = "";
|
|
}
|
|
}
|
|
init => { self.sr-restore(); }
|
|
changed sr-armed => { self.sr-restore(); }
|
|
changed viewport-height => { self.sr-restore(); }
|
|
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
|
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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 {
|
|
active: LabelState.is-following;
|
|
// Plain page background — theme-aware variant so it
|
|
// reads on light themes.
|
|
on-surface: true;
|
|
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;
|
|
on-surface: true;
|
|
clicked => {
|
|
root.media-action("label", LabelState.id, "play-top");
|
|
}
|
|
}
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
|
active: LabelState.multi-select;
|
|
on-surface: true;
|
|
clicked => {
|
|
root.media-action("label", "", "select-toggle");
|
|
}
|
|
}
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/ellipsis.svg");
|
|
on-surface: true;
|
|
clicked => {
|
|
root.media-action("label", "", "top-tracks-menu");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if LabelState.top-tracks.length > 0: Rectangle { height: 10px; }
|
|
|
|
// Bulk action bar — visible in multi-select mode. Actions bubble
|
|
// via media-action("label",...). Replaces the old dead Select-all
|
|
// row (the checkbox now lives on each TrackRow + this bar).
|
|
if LabelState.multi-select && LabelState.top-tracks.length > 0: MultiSelectBar {
|
|
selected-count: LabelState.selected-count;
|
|
actions: [
|
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
|
];
|
|
action(id) => {
|
|
root.media-action("label", "", id);
|
|
}
|
|
}
|
|
if LabelState.multi-select && LabelState.top-tracks.length > 0: Rectangle { height: 8px; }
|
|
|
|
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: LabelState.multi-select;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
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;
|
|
body-opens: true; // body click opens the playlist; the overlay button plays
|
|
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;
|
|
// Labels, not artists — keep the generic legacy card (the grid
|
|
// card's artist play / "Open artist" actions don't apply).
|
|
grid-cards: false;
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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 {
|
|
bar-bg: ShellState.app-background-active
|
|
? Theme.surface-main.with-alpha(AppearanceState.app-background-bar-alpha)
|
|
: Theme.surface-main;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Shared scrollbar in the right gutter (homologated cross-view).
|
|
// Declared AFTER the sticky JUMP TO bar so it paints ON TOP of it (the
|
|
// full-width jump bar was overlapping it), but BEFORE the modal below
|
|
// so the modal still stacks above the scrollbar.
|
|
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;
|
|
}
|
|
|
|
// --- 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|