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:
13 files changed
+558
-42
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user