feat(discover): ArtistGridCard for mixed carousels, used app-wide

New 200x246 artist card (framed round portrait, follow/play/more hover overlay,
centered wrap-capable name with an optional reco subtitle) matching the album/
playlist card footprint. ArtistCarousel renders it by default across the app
(search "Most popular" hero included); the legacy 160x220 ArtistCard stays for
the label/award carousels whose artist actions don't apply.

cc
This commit is contained in:
vicrodh committed 2026-07-16 16:30:30 -06:00
1 parent 64920a76e4
commit d87ae8a65f
5 files changed
+364 -13

No files matched your search

+3
View File
@@ -320,6 +320,9 @@ export component AwardView inherits Rectangle {
if AwardState.other-awards.length > 0: ArtistCarousel {
title: @tr("Other awards");
artists: AwardState.other-awards;
// Awards, not artists — keep the generic legacy card (the grid
// card's play / "Open artist" actions don't apply here).
grid-cards: false;
card-follow-mode: "none";
artist-clicked(id) => {
AwardActions.open-award(id, "");
+37 -12
View File
@@ -6,6 +6,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { SlimItem } from "../state.slint";
import { ArtistCard } from "ArtistCard.slint";
import { ArtistGridCard } from "ArtistGridCard.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavButton inherits Rectangle {
@@ -69,14 +70,21 @@ export component ArtistCarousel inherits VerticalLayout {
// Defaults keep the artist-search usage unchanged.
in property <string> card-follow-mode: "auto";
in property <string> card-follow-kind: "artist";
// Render the 200x246 ArtistGridCard (album/playlist parity, hover overlay
// with follow/play/more) — the default across the app. Set false to keep
// 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;
callback artist-clicked(string);
callback media-action(string, string, string);
callback view-all-clicked();
property <length> card-width: 160px;
// Grid cards are wider (200) and taller (246); the legacy card stays 160.
property <length> card-width: root.grid-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> gap: 16px;
property <int> count: root.artists.length;
@@ -133,28 +141,45 @@ export component ArtistCarousel inherits VerticalLayout {
}
viewport := Rectangle {
height: root.card-height;
height: root.eff-height;
clip: true;
Rectangle {
x: -root.current-page * root.step;
y: 0px;
width: root.content-width;
height: root.card-height;
height: root.eff-height;
animate x { duration: 220ms; easing: ease-in-out; }
HorizontalLayout {
spacing: root.gap;
for artist in root.artists: ArtistCard {
artist: artist;
card-height: root.card-height;
follow-mode: root.card-follow-mode;
follow-kind: root.card-follow-kind;
clicked(id) => {
root.artist-clicked(id);
// One fixed-width slot per artist; the slot hosts the grid card
// OR the legacy card (each sets its own width to match).
for artist in root.artists: Rectangle {
width: root.card-width;
height: root.eff-height;
if root.grid-cards: ArtistGridCard {
artist: artist;
follow-mode: root.card-follow-mode;
follow-kind: root.card-follow-kind;
clicked(id) => {
root.artist-clicked(id);
}
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
media-action(k, i, a) => {
root.media-action(k, i, a);
if !root.grid-cards: ArtistCard {
artist: artist;
card-height: root.card-height;
follow-mode: root.card-follow-mode;
follow-kind: root.card-follow-kind;
clicked(id) => {
root.artist-clicked(id);
}
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
@@ -0,0 +1,319 @@
// Artist card sized to match AlbumCard / PlaylistCard (200x246) so albums,
// playlists and artists can share one mixed carousel row. A rounded-square
// art zone (surface fill, AlbumCard parity) frames a 190px round portrait —
// the small surround reads like an album tile while keeping the round photo.
// A hover overlay carries follow / play / more (same three-button pattern as
// AlbumCard) over a centered, wrap-capable name.
//
// Reuses the SlimItem model and the media-action(kind, id, action) contract,
// so a mixed carousel drives it exactly like an AlbumCard/PlaylistCard.
//
// This is ADDITIVE — the legacy 160x220 ArtistCard is untouched.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { SlimItem, NavState, PinnedActions } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
// Circular hover-overlay action button — solid (semi-transparent) fill so the
// whole circle is a stable hit/hover surface. Copied from AlbumCard (the card
// files each carry their own; there is no shared primitive to import).
component OverlayButton inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <length> diameter: 36px;
in property <length> icon-size: 16px;
in property <bool> active: false;
out property <bool> hovered: ta.has-hover;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
background: root.primary
? (ta.has-hover ? #ffffffd6 : #ffffff)
: (ta.has-hover ? #ffffff3d : #ffffff24);
border-width: root.primary ? 0px : 1.5px;
border-color: #ffffffcc;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (root.primary ? #000000 : #ffffff);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
// Forward mouse back/forward to history nav (a TouchArea swallows them).
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.back) {
NavState.request-back();
}
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.forward) {
NavState.request-forward();
}
}
}
}
export component ArtistGridCard inherits Rectangle {
in property <SlimItem> artist;
// Follow button visibility: "toggle"/"auto" show it (always visible in the
// overlay so the 3-button row never gains a gap; label reflects state),
// "none" hides it (label-artist carousels have no follow affordance).
in property <string> follow-mode: "toggle";
// kind passed in the follow media-action ("artist" | "label").
in property <string> follow-kind: "artist";
callback clicked(string);
// kind ("artist" | "label"), id, action ("follow" | "play").
callback media-action(string, string, string);
// Deterministic height: 200 photo zone + 6 spacer + 40 meta = 246
// (AlbumCard parity). The 40px meta reserves room for a 2-line name.
in property <length> card-height: 246px;
width: 200px;
height: root.card-height;
property <bool> show-follow: root.follow-mode != "none";
// Overlay stays up while the pointer is over the photo OR any of its
// buttons (a child button steals has-hover from the photo detector).
property <bool> overlay-on: hover.has-hover
|| follow-btn.hovered || play-btn.hovered || more-btn.hovered
|| pin-ta.has-hover;
VerticalLayout {
alignment: start;
spacing: 0px;
// --- Framed photo zone + hover overlay ---------------------------
Rectangle {
width: 200px;
height: 200px;
border-radius: Radius.sm;
background: Theme.surface-card;
// 190px round portrait, centered (5px surround = the album-tile
// frame). All overlay content lives inside so it clips to the circle.
Rectangle {
width: 190px;
height: 190px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 95px;
clip: true;
background: @linear-gradient(
135deg,
Theme.surface-elevated 0%,
Theme.surface-card 100%);
// Placeholder glyph, shown until the artwork resolves on top.
QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 54px;
height: 54px;
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: root.artist.artwork;
image-fit: cover;
}
// Hover scrim (clipped to the circle by the parent's clip).
Rectangle {
width: 100%;
height: 100%;
background: #000000;
opacity: root.overlay-on ? 0.55 : 0.0;
animate opacity { duration: 150ms; }
}
// Card-open + hover detector. Declared before the buttons so
// those win the pointer when interactive.
hover := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: pointer;
clicked => {
root.clicked(root.artist.id);
}
}
// Hover action buttons — follow / play / more.
HorizontalLayout {
x: 0px;
y: 113px;
width: 190px;
height: 44px;
alignment: center;
spacing: 12px;
opacity: root.overlay-on ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
VerticalLayout {
alignment: center;
follow-btn := OverlayButton {
visible: root.show-follow;
icon: root.artist.following
? @image-url("../assets/icons/check.svg")
: @image-url("../assets/icons/user-plus.svg");
active: root.artist.following;
clicked => {
root.media-action(root.follow-kind, root.artist.id, "follow");
}
}
}
play-btn := OverlayButton {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
icon-size: 18px;
clicked => {
root.media-action("artist", root.artist.id, "play");
}
}
VerticalLayout {
alignment: center;
more-btn := OverlayButton {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
artist-menu.show();
}
}
}
}
// Context menu for the "more" button.
artist-menu := PopupWindow {
x: 21px;
y: 143px;
width: 196px;
height: 3 * 33px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/user.svg");
label: @tr("Open artist");
clicked => {
root.clicked(root.artist.id);
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("artist", root.artist.id, "play");
}
}
if root.show-follow: ContextMenuItem {
icon: root.artist.following
? @image-url("../assets/icons/check.svg")
: @image-url("../assets/icons/user-plus.svg");
label: root.artist.following ? @tr("Following") : @tr("Follow");
clicked => {
root.media-action(root.follow-kind, root.artist.id, "follow");
}
}
}
}
}
// Pin glyph — top-right of the tile frame, kissing the circle's
// arc (AlbumCard corner parity). Lives on the OUTER 200px rect as
// a sibling AFTER the circle so the circle's clip cannot cut it.
// Filled accent pin always visible when pinned; outline pin
// hover-revealed otherwise. Always-mounted (opacity, not `if`)
// so its TouchArea's has-hover can join `overlay-on` above.
pin-badge := Rectangle {
x: parent.width - self.width - 8px;
y: 8px;
width: 26px;
height: 26px;
border-radius: 13px;
background: pin-ta.has-hover ? #000000cc : #00000099;
opacity: root.overlay-on ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: root.artist.is-pinned
? @image-url("../assets/icons/pin-filled.svg")
: @image-url("../assets/icons/pin.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.artist.is-pinned ? Theme.accent : #ffffff;
}
pin-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
// Kind is hardcoded "artist" (like the play button) —
// NOT follow-kind: the pinned store has no label kind.
PinnedActions.toggle-pin("artist", root.artist.id,
root.artist.title, root.artist.subtitle,
root.artist.artwork-url);
}
}
}
}
Rectangle { height: 6px; }
// --- Name (+ optional subtitle), centered ------------------------
// The 40px meta is shared: with a subtitle (reco "Similar to…") the
// name stays one line and the subtitle takes the second; WITHOUT one
// the name wraps into both lines. Either way the card height is fixed.
HorizontalLayout {
height: 40px;
padding-left: Spacing.sm;
padding-right: Spacing.sm;
VerticalLayout {
alignment: center;
spacing: 2px;
name-area := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.artist.id);
}
Text {
width: 100%;
text: root.artist.title;
color: name-area.has-hover ? Theme.accent : Theme.text-primary;
font-size: Typography.body - 2px;
font-weight: Typography.medium;
horizontal-alignment: center;
vertical-alignment: center;
// One line when a subtitle claims the second row; wrap
// into two lines when the name is on its own.
wrap: root.artist.subtitle != "" ? no-wrap : word-wrap;
overflow: elide;
}
}
// Reco "Similar to…" subtitle — shown only when provided.
if root.artist.subtitle != "": Text {
width: 100%;
text: root.artist.subtitle;
color: Theme.text-muted;
font-size: Typography.link - 1px;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
@@ -377,6 +377,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";
@@ -17,6 +17,7 @@ import { ArtistCarousel } from "../discover/ArtistCarousel.slint";
import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint";
import { AlbumCard } from "../discover/AlbumCard.slint";
import { ArtistCard } from "../discover/ArtistCard.slint";
import { ArtistGridCard } from "../discover/ArtistGridCard.slint";
import { PlaylistCard } from "../discover/PlaylistCard.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { SearchTrackHero } from "../primitives/SearchTrackHero.slint";
@@ -594,7 +595,7 @@ export component SearchResultsView inherits Rectangle {
}
HorizontalLayout {
alignment: center;
if SearchState.most-popular-kind == "artist": ArtistCard {
if SearchState.most-popular-kind == "artist": ArtistGridCard {
artist: SearchState.most-popular-artist;
clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }