feat(label): web-parity header actions, catalog/library toggle, label cards

LabelView parity with the revamped Qobuz web label page (semantics
verified live against play.qobuz.com via Playwright):

- Header: Follow + Shuffle (new play_label_top_shuffled, shuffle-then-
  delegate to play_tracks_ctx) + overflow menu (Play next / Add to queue /
  Share -> play.qobuz.com/label/<id>; no Song.link equivalent for labels).
  No header Play by owner decision (the Popular Tracks play does the same).
- The Popular Tracks section '...' button was dead (fired an unhandled
  'top-tracks-menu' action): now a popup with Play next / Add to queue
  over all cached popular tracks. Section buttons wrapped so the 44px
  primary and 32px secondaries share one vertical centre.
- Catalog/library toggle: the ArtistPage SegmentedTabBar verbatim, floated
  right of the header cluster (visible only with library items; mirrors
  the web's A/B-rolled 'Del catalogo / En la biblioteca'). Library tab
  renders added tracks + albums on the label, backed by the new
  library_by_label session index (AlbumCard.label_id / TrackCard.label_id,
  seeded from the SAME two favorites fetches as library_by_artist).
- More Labels carousel now renders the Library LabelCard (new label-cards
  mode in ArtistCarousel) instead of the legacy generic card.
- Artists carousel images: /label/page top_artists often carry no parseable
  image, leaving placeholders forever. Restored the Tauri fallback wave:
  custom artwork first, then v2/artist/get, fed through the stable
  LabelArtist artwork targets.

i18n: zero new msgids (all reused across the 8 locales). slintcheck green.

km
This commit is contained in:
vicrodh committed 2026-07-21 12:30:40 -06:00
1 parent 70d8dc85f2
commit d4a4f30e91
13 files changed
+558 -42

No files matched your search

+22 -4
View File
@@ -7,6 +7,7 @@ import { Typography } from "../foundation/typography.slint";
import { SlimItem, ShellState, AppearanceState } from "../state.slint";
import { ArtistCard } from "ArtistCard.slint";
import { ArtistGridCard } from "ArtistGridCard.slint";
import { LabelCard } from "LabelCard.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavButton inherits Rectangle {
@@ -77,16 +78,21 @@ export component ArtistCarousel inherits VerticalLayout {
// the legacy 160x220 ArtistCard, used by non-artist carousels whose items
// are labels/awards (the artist-specific play/open actions don't apply).
in property <bool> grid-cards: true;
// Render the 200x246 LabelCard (Library-favorites parity — the label
// twin of the album card: logo + name, no hover actions) for carousels
// whose items are LABELS (e.g. LabelView's More Labels). Wins over
// grid-cards. Items stay SlimItem; the card fields are mapped inline.
in property <bool> label-cards: false;
callback artist-clicked(string);
callback media-action(string, string, string);
callback view-all-clicked();
// Grid cards are wider (200) and taller (246); the legacy card stays 160.
property <length> card-width: root.grid-cards ? 200px : 160px;
property <length> card-width: root.grid-cards || root.label-cards ? 200px : 160px;
// Overridable so a host view (Discover Recommendations, whose cards carry a
// "Similar to…" subtitle) can give the row a taller viewport + card.
in property <length> card-height: 220px;
property <length> eff-height: root.grid-cards ? 246px : root.card-height;
property <length> eff-height: root.grid-cards || root.label-cards ? 246px : root.card-height;
property <length> gap: 16px;
property <int> count: root.artists.length;
@@ -160,7 +166,19 @@ export component ArtistCarousel inherits VerticalLayout {
for artist in root.artists: Rectangle {
width: root.card-width;
height: root.eff-height;
if root.grid-cards: ArtistGridCard {
if root.label-cards: LabelCard {
label: {
id: artist.id,
name: artist.title,
albums-line: artist.subtitle,
image-url: artist.artwork-url,
image: artist.artwork,
};
clicked(id, name) => {
root.artist-clicked(id);
}
}
if !root.label-cards && root.grid-cards: ArtistGridCard {
artist: artist;
follow-mode: root.card-follow-mode;
follow-kind: root.card-follow-kind;
@@ -171,7 +189,7 @@ export component ArtistCarousel inherits VerticalLayout {
root.media-action(k, i, a);
}
}
if !root.grid-cards: ArtistCard {
if !root.label-cards && !root.grid-cards: ArtistCard {
artist: artist;
card-height: root.card-height;
follow-mode: root.card-follow-mode;
+203 -29
View File
@@ -24,6 +24,10 @@ import { JumpNavBar } from "../primitives/JumpNavBar.slint";
import { Carousel } from "../discover/Carousel.slint";
import { ArtistCarousel } from "../discover/ArtistCarousel.slint";
import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
import { SegmentedTabBar } from "../primitives/SegmentedTabBar.slint";
component SectionTitle inherits Text {
color: Theme.text-primary;
@@ -164,10 +168,19 @@ export component LabelPageView inherits Rectangle {
Rectangle { height: 18px; }
// Follow / Following — ghost circle, accent-tinted when
// followed. Toggles via the global media-action channel.
// Header action cluster (Qobuz-web parity): Follow +
// Shuffle + overflow menu, with the catalog/library
// toggle floated right. NO header Play — the Popular
// Tracks section already has it and it does the same
// (owner call 2026-07-21). All 32px secondaries (no
// primary), so the row is uniform — no "roller coaster".
// Default alignment (stretch) so the spacer below can
// push the toggle to the right edge (an `alignment:
// start` row ignores stretch factors and butt-joined it).
HorizontalLayout {
alignment: start;
spacing: 12px;
// Follow / Following — ghost circle, accent-tinted when
// followed. Toggles via the global media-action channel.
CircleAction {
active: LabelState.is-following;
// Plain page background — theme-aware variant so it
@@ -180,13 +193,94 @@ export component LabelPageView inherits Rectangle {
root.media-action("label", LabelState.id, "follow");
}
}
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
on-surface: true;
tooltip: @tr("Shuffle");
clicked => {
root.media-action("label", LabelState.id, "shuffle");
}
}
// ⋯ Overflow — Play next / Add to queue / Share (the
// Qobuz web label menu). Share is Qobuz-link only:
// Song.link/Album.link have no label equivalent.
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
on-surface: true;
tooltip: @tr("More options");
clicked => {
label-overflow-menu.show();
}
}
// Opens below the ⋯ with the left edge aligned to
// the button (the artist header overflow anchors
// the same way).
label-overflow-menu := PopupWindow {
x: 0px;
y: 38px;
width: 224px;
height: 3 * 33px + 10px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("label", "", "top-play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("label", "", "top-queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/link.svg");
label: @tr("Share");
clicked => {
root.media-action("label", LabelState.id, "share");
}
}
}
}
}
// Catalog / Library toggle — floated to the RIGHT of the
// header action cluster (artist-page parity). Only when
// the user has library items for this label.
Rectangle { horizontal-stretch: 1; }
if LabelState.library-count > 0: VerticalLayout {
alignment: center;
SegmentedTabBar {
active-id: LabelState.label-tab;
tabs: [
{ id: "catalog", label: @tr("From catalog"), count: 0 },
{ id: "library", label: @tr("In library"), count: LabelState.library-count },
];
selected(id) => { LabelState.label-tab = id; }
}
}
}
}
}
// Reservation for the sticky JUMP TO overlay (its 44px + gap).
// The overlay anchors its natural y to this element.
jump-anchor := Rectangle { height: 52px; }
// The overlay anchors its natural y to this element. Only the
// catalog tab has jump sections — collapse the reservation in
// library mode.
jump-anchor := Rectangle {
height: LabelState.label-tab == "catalog" ? 52px : 0px;
}
// Catalog content — the whole /label/page stack renders only in
// "catalog" mode; "library" swaps to the user's added albums
// below (artist-page parity, same wrap-without-reindent idiom).
if LabelState.label-tab == "catalog": VerticalLayout {
spacing: 0px;
// --- Loading (first paint) -----------------------------------
if LabelState.loading && LabelState.top-tracks.length == 0
@@ -221,27 +315,73 @@ export component LabelPageView inherits Rectangle {
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");
// Every button wrapped in VerticalLayout (alignment:center)
// so the 44px primary and the 32px secondaries share one
// vertical centre (the artist section's "giant button"
// homologation — an unwrapped CircleAction here
// roller-coasters).
VerticalLayout {
alignment: center;
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");
VerticalLayout {
alignment: center;
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");
// Popular Tracks section menu (all-tracks actions) —
// was a dead button (fired an unhandled media-action).
// Opens below the ⋯ with right edges aligned (artist-page
// parity).
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
on-surface: true;
tooltip: @tr("More options");
clicked => {
top-tracks-menu.show();
}
}
top-tracks-menu := PopupWindow {
x: 32px - self.width;
y: 38px;
width: 224px;
height: 2 * 33px + 10px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("label", "", "top-play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("label", "", "top-queue");
}
}
}
}
}
}
}
@@ -380,12 +520,9 @@ export component LabelPageView inherits Rectangle {
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";
// The NEW LabelCard (Library-favorites parity): logo + name,
// opened on click — replaces the legacy generic ArtistCard.
label-cards: true;
artist-clicked(id) => {
root.media-action("label", id, "open");
}
@@ -393,6 +530,43 @@ export component LabelPageView inherits Rectangle {
root.media-action(kind, id, action);
}
}
} // close the "catalog" wrapper VerticalLayout
// --- In library — the user's added items on this label ----------
// Mirrors the artist library tab: Pistas (track rows) + Álbumes
// (grid). Web-parity copy ("Pistas añadidas" / "Álbumes
// añadidos") uses the same msgids as the artist tab.
if LabelState.label-tab == "library": VerticalLayout {
spacing: 0px;
if LabelState.library-tracks.length > 0: SectionTitle {
text: @tr("Tracks");
}
if LabelState.library-tracks.length > 0: Rectangle { height: 10px; }
for track[index] in LabelState.library-tracks: TrackRow {
track: track;
index: index;
show-artwork: true;
show-album: true;
multi-select-mode: false;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if LabelState.library-albums.length > 0: Rectangle { height: 24px; }
if LabelState.library-albums.length > 0: SectionTitle {
text: @tr("Albums");
}
if LabelState.library-albums.length > 0: Rectangle { height: 10px; }
if LabelState.library-albums.length > 0: AlbumCollectionView {
albums: LabelState.library-albums;
view-mode: "grid";
show-favorite: true;
open-album(id) => { root.open-album(id); }
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
}
}
@@ -400,7 +574,7 @@ export component LabelPageView inherits Rectangle {
// 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 {
if LabelState.jump-tabs.length > 1 && LabelState.label-tab == "catalog": JumpNavBar {
bar-bg: ShellState.app-background-active
? Theme.surface-main.with-alpha(AppearanceState.app-background-bar-alpha)
: Theme.surface-main;
+8
View File
@@ -3760,6 +3760,14 @@ export global LabelState {
in property <bool> description-truncated: false;
// Header follow state — true when the label is in the user's favorites.
in property <bool> is-following: false;
// Catalog/library toggle (SegmentedTabBar, mirrors ArtistState.artist-tab
// + library-*): "catalog" renders the /label/page sections; "library"
// renders the user's favorite albums on this label (the session
// library_by_label index). The toggle only shows when count > 0.
in-out property <string> label-tab: "catalog";
in property <int> library-count: 0;
in property <[AlbumCardItem]> library-albums: [];
in property <[TrackItem]> library-tracks: [];
// Popular tracks (album-thumbnail rows, multi-select capable).
in property <[TrackItem]> top-tracks: [];
// Multi-select (mirrors AlbumState): mode flag + count. Per-row toggle