Files
qbz/crates/qbz-ui/ui/label/LabelPageView.slint
T
vicrodh 6c41f58f44 fix(shader)+feat(ui): nvidia shader tiling fix + scrollbar/mixtape/track-listing batch
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
2026-07-17 14:51:18 -06:00

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;
}
}
}
}
}
}
}