Files
qbz/crates/qbz-slint/ui/artist/ArtistPageView.slint
T
vicrodh d107d4f577 feat(slint): origin section + MB metadata wiring on artist navigate
Wires the artist controller to call QbzCore's MusicBrainz orchestration
and feed the Origin section of the network sidebar, plus typed click
callbacks for every section (logged-only until the target views land).

Controller (`crates/qbz-slint/src/artist.rs`):
  - reset_network_sidebar() — clears sidebar MB state on artist change.
  - load_mb_metadata(runtime, name) — checks the client's enabled flag,
    resolves name -> mbid via musicbrainz_resolve_artist, fetches
    metadata via musicbrainz_get_artist_metadata, maps into MbOrigin
    (is_person derived from artist_type, dates formatted as "1976",
    "May 1976" or "May 14, 1976" matching Tauri's en-US output).
  - apply_mb_metadata() / apply_mb_unavailable() — push into
    NetworkSidebarState on the event loop.

main.rs:
  - navigate_artist now resets the sidebar before loading and spawns
    the MB enrichment in parallel with artwork. Origin loading flag
    flips to true before the resolve, back to false on success or
    on MB-unavailable.
  - NetworkSidebarActions click callbacks (location, label, artist,
    musician, discovery dismiss) wired to log() with full payload —
    placeholder until ArtistsByLocation/LabelReleases/MusicianPage land
    in Slint.

ArtistPageView.slint:
  - Sidebar body becomes a Flickable + VerticalLayout (so the upcoming
    Labels / Similar / Relationships / Discovery sections can overflow
    cleanly).
  - Origin section renders three optional rows — Born/Founded,
    Born in/Founded in, Died/Disbanded — with the same uppercased
    label + right-aligned value pattern Tauri uses. The location row
    is clickable when the location precision is finer than country
    or a city is present; click emits NetworkSidebarActions.location-
    clicked with the artist's mbid.
  - Section is gated by NetworkSidebarState.mb-available, so a
    disabled MB integration or an unmatched artist hides Origin
    entirely (next phases fill Labels/Similar with Qobuz data that
    does not depend on MB).

Adds qbz-integrations as a direct dep of qbz-slint so the controller
can match on ArtistType / LocationPrecision without going through a
re-export shim.

cc
2026-05-20 09:51:22 -06:00

780 lines
33 KiB
Plaintext

// Artist detail page.
//
// An artwork-tinted gradient header (circular portrait, name, biography,
// action buttons), a Popular Tracks list, and the discography split into
// titled release sections (Albums, EPs & Singles, Live, ...). Data comes
// from the ArtistState global; action buttons are visual for now.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import {
ArtistState,
AlbumTrackItem,
NavState,
ContentView,
NetworkSidebarState,
NetworkSidebarActions,
} from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { Carousel } from "../discover/Carousel.slint";
import { NavButtons } from "../shell/NavButtons.slint";
component CircleAction inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
// Stays "pressed" — same surface as a hover, icon tinted accent.
// Used by the Network button while the sidebar is open.
in property <bool> active: false;
in property <length> diameter: 40px;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
// Ghost circle — translucent fill with a faint ring, never a solid
// accent fill (mirrors the Tauri transport controls).
background: (ta.has-hover || root.active) ? Theme.surface-hover : transparent;
border-width: 1px;
border-color: (ta.has-hover || root.active) ? Theme.text-disabled : Theme.border-subtle;
QbzIcon {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
component TrackRow inherits Rectangle {
in property <AlbumTrackItem> track;
in property <int> index;
// When true, the row shows a leading checkbox column and hides the
// per-row hover affordances — driven by the Popular Tracks "Select"
// header toggle, which mirrors Tauri's multiSelectMode flow.
in property <bool> multi-select-mode: false;
callback media-action(string, string, string);
// Any-hover detector — Slint does not propagate has-hover to ancestor
// TouchAreas, so OR the row TouchArea with the nested ones to drive
// the play overlay on the cell from anywhere over the row.
property <bool> row-hovered:
ta.has-hover || play-cell.hovered || more-ta.has-hover;
height: 52px;
border-radius: 8px;
// The even-row tint is clearly fainter than the hover tint so the two
// never read as the same state. Use row-hovered so hovering the cell
// or the more button keeps the row tint.
background: root.row-hovered
? #ffffff16
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
ta := TouchArea {
mouse-cursor: default;
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
VerticalLayout {
alignment: center;
play-cell := TrackPlayCell {
track-id: root.track.id;
number: root.track.number;
show-artwork: false;
number-width: 28px;
row-hovered: root.row-hovered;
play => {
root.media-action("track", root.track.id, "play");
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.track.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
}
Text {
text: root.track.duration;
width: 70px;
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
text: root.track.quality-tier == "hires"
? "Hi-Res"
: (root.track.quality-tier == "cd" ? "CD" : "");
width: 70px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
}
// Select checkbox — only visible while the section is in
// multi-select mode (toggled by the section header's Select
// button). Mirrors Tauri's #if multiSelectMode gate.
if root.multi-select-mode: VerticalLayout {
alignment: center;
Rectangle {
width: 22px;
height: 22px;
border-radius: 11px;
background: root.track.selected ? Theme.accent : transparent;
border-width: root.track.selected ? 0px : 1.5px;
border-color: select-ta.has-hover ? Theme.text-primary : Theme.text-muted;
animate background { duration: 120ms; }
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
visible: root.track.selected;
}
select-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("track", root.track.id, "toggle-select");
}
}
}
}
// More / context-menu button — always visible on Popular Tracks.
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-menu.show();
}
}
}
}
}
track-menu := TrackContextMenu {
x: parent.width - self.width - 12px;
y: 44px;
track-id: root.track.id;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
component SectionTitle inherits Text {
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
}
export component ArtistPageView inherits Rectangle {
callback open-album(string);
callback media-action(string, string, string);
// Read-more toggle for the biography.
property <bool> bio-expanded: false;
// Popular Tracks shows the first 5; Load more expands to all.
property <bool> top-tracks-expanded: false;
property <int> top-tracks-preview: 5;
// Popular Tracks multi-select mode — toggled by the section header's
// Select button. When true, each row reveals a leading checkbox column
// (TrackRow's `multi-select-mode` prop). Mirrors Tauri's flow.
property <bool> multi-select-mode: false;
background: Theme.surface-main;
// Main content + optional network sidebar — same flow as Tauri's
// .artist-body. The Flickable shrinks when the sidebar opens; the
// sidebar itself is a fixed 300px column on the right.
HorizontalLayout {
spacing: 0px;
Flickable {
horizontal-stretch: 1;
viewport-width: self.width;
viewport-height: page.preferred-height;
// Artwork-tinted gradient behind the header; scrolls with content.
Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 340px;
background: @linear-gradient(180deg,
ArtistState.header-color 0%,
ArtistState.header-color 16%,
transparent 100%);
}
page := VerticalLayout {
width: parent.width;
// padding matches Discover so the [<] [>] pair sits at the same
// top/left margin across every view.
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; }
// --- Artist header -----------------------------------------------
HorizontalLayout {
spacing: 32px;
alignment: start;
Rectangle {
width: 200px;
height: 200px;
border-radius: 100px;
background: Theme.surface-elevated;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
drop-shadow-offset-y: 8px;
clip: true;
Image {
width: 100%;
height: 100%;
source: ArtistState.artwork;
image-fit: cover;
}
}
VerticalLayout {
alignment: start;
padding-top: 8px;
spacing: 0px;
horizontal-stretch: 1;
Text {
text: @tr("ARTIST");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
Rectangle { height: 6px; }
Text {
text: ArtistState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
overflow: elide;
}
// Biography — single inline block matching Tauri's
// structure: truncated body + Read more/less toggle +
// optional "Source: <name>" line. The full text is
// shown when `bio-expanded` flips.
if ArtistState.bio != "": Rectangle { height: 12px; }
if ArtistState.bio != "": Text {
text: root.bio-expanded ? ArtistState.bio : ArtistState.bio-short;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
if ArtistState.bio-truncated: Rectangle {
height: 24px;
width: read-more-text.preferred-width + 16px;
read-more-text := Text {
text: root.bio-expanded ? @tr("Read less") : @tr("Read more");
color: read-more-ta.has-hover
? Theme.text-primary
: Theme.accent;
font-size: 13px;
font-weight: Typography.medium;
vertical-alignment: center;
height: 100%;
}
read-more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.bio-expanded = !root.bio-expanded;
}
}
}
if ArtistState.bio-source != "": Rectangle { height: 4px; }
if ArtistState.bio-source != "": Text {
text: @tr("Source") + ": " + ArtistState.bio-source;
color: Theme.text-muted;
font-size: 11px;
}
Rectangle { height: 18px; }
// Action buttons — Heart, Build collection, Radio
// (with QBZ / Qobuz dropdown), Network. Mirrors
// Tauri's artist-actions: NO Play here (top tracks
// has its own play). Hide (blacklist) is still
// deferred until the blacklist module migrates out
// of src-tauri.
HorizontalLayout {
alignment: start;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
clicked => {
root.media-action("artist", "", "favorite");
}
}
CircleAction {
icon: @image-url("../assets/icons/library-big.svg");
clicked => {
root.media-action("artist", "", "build-collection");
}
}
// Radio with dropdown — QBZ radio (local engine)
// vs Qobuz radio (qobuz-curated). Both call
// distinct media-actions; main.rs wires them.
Rectangle {
width: 40px;
height: 40px;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
clicked => {
radio-popup.show();
}
}
radio-popup := PopupWindow {
x: 0px;
y: 50px;
width: 180px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/radio.svg");
label: "QBZ Radio";
clicked => {
root.media-action("artist", "", "radio-qbz");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/radio.svg");
label: "Qobuz Radio";
clicked => {
root.media-action("artist", "", "radio-qobuz");
}
}
}
}
}
// Network — toggles the artist network sidebar.
// Stays active (accent-tinted) while the sidebar
// is open, matching Tauri's `.network-btn.active`.
CircleAction {
icon: @image-url("../assets/icons/element-connect.svg");
active: NetworkSidebarState.open;
clicked => {
NetworkSidebarState.open = !NetworkSidebarState.open;
NetworkSidebarActions.toggle();
}
}
}
}
}
Rectangle { height: 36px; }
if ArtistState.loading && ArtistState.top-tracks.length == 0: Rectangle {
height: 280px;
VerticalLayout {
alignment: center;
spacing: 18px;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
Text {
text: @tr("Loading artist…");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
}
}
}
// --- Popular tracks ----------------------------------------------
// Section header: title on the left, action cluster on the right
// (Play all / Select-mode toggle / More menu). Mirrors Tauri's
// popular-tracks section-header.
if ArtistState.top-tracks.length > 0: HorizontalLayout {
alignment: space-between;
spacing: 12px;
VerticalLayout {
alignment: center;
SectionTitle {
text: @tr("Popular Tracks");
}
}
HorizontalLayout {
alignment: end;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 40px;
clicked => {
root.media-action("artist", "", "play-top");
}
}
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
clicked => {
root.multi-select-mode = !root.multi-select-mode;
}
}
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
root.media-action("artist", "", "top-tracks-menu");
}
}
}
}
if ArtistState.top-tracks.length > 0: Rectangle { height: 10px; }
for track[index] in ArtistState.top-tracks: TrackRow {
visible: root.top-tracks-expanded || index < root.top-tracks-preview;
height: self.visible ? 52px : 0px;
track: track;
index: index;
multi-select-mode: root.multi-select-mode;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Load more — visible when there are more than the preview cap.
if !root.top-tracks-expanded
&& ArtistState.top-tracks.length > root.top-tracks-preview: HorizontalLayout {
alignment: start;
Rectangle {
width: load-more-text.preferred-width + 32px;
height: 32px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: load-more-ta.has-hover
? Theme.surface-elevated
: Theme.surface-card;
load-more-text := Text {
text: @tr("Load more");
color: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
load-more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.top-tracks-expanded = true;
}
}
}
}
// --- Release sections (Albums, EPs & Singles, Live, ...) ---------
for section in ArtistState.release-sections: VerticalLayout {
spacing: 0px;
Rectangle { height: 32px; }
Carousel {
section: section;
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
}
}
// Network sidebar — a 300px column on the right, vertically
// inset so it does not occupy the full body height (matches
// Tauri's `height: calc(100vh - 180px)`, which leaves room for
// the jump-nav at the top and the now-playing bar at the
// bottom). Sections (Origin / Labels / Similar / Relationships /
// Discovery) are populated in the following phases; the shell
// only renders the header and an empty body.
if NetworkSidebarState.open: VerticalLayout {
width: 300px;
spacing: 0px;
Rectangle { height: 22px; }
sidebar-panel := Rectangle {
vertical-stretch: 1;
background: Theme.surface-card;
// Left border line — 1px Theme.border-subtle, matching the
// Tauri `.network-sidebar-column { border-left: 1px solid
// var(--bg-tertiary); }`.
Rectangle {
x: 0px;
y: 0px;
width: 1px;
height: parent.height;
background: Theme.border-subtle;
}
VerticalLayout {
spacing: 0px;
// Header — uppercased title on the left, close button
// on the right.
Rectangle {
height: 44px;
background: Theme.surface-main;
HorizontalLayout {
padding-left: 12px;
padding-right: 8px;
spacing: 8px;
Text {
text: @tr("ARTIST NETWORK");
color: Theme.text-muted;
font-size: 12px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
horizontal-stretch: 1;
vertical-alignment: center;
}
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
border-radius: 6px;
background: close-ta.has-hover
? Theme.surface-elevated
: transparent;
QbzIcon {
source: @image-url("../assets/icons/panel-right-close.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
}
close-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
NetworkSidebarState.open = false;
NetworkSidebarActions.toggle();
}
}
}
}
}
}
// Header bottom separator.
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// Body — sections: Origin (MB), Labels, Similar, MB
// Relationships, Discovery. Wrapped in a Flickable so a
// long sidebar (Labels + Relationships + Discovery) can
// scroll. Phase 5 adds Origin only; phases 6-8 fill the
// rest.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: sidebar-body.preferred-height;
sidebar-body := VerticalLayout {
spacing: 0px;
padding-top: 4px;
padding-bottom: 12px;
alignment: start;
// --- Origin (MB metadata) ----------------------
// Mirrors Tauri's .sidebar-section.sidebar-origin
// and the gate `mbAvailable && (loading || data)`.
if NetworkSidebarState.mb-available
&& (NetworkSidebarState.origin-loading
|| NetworkSidebarState.origin.begin-date != ""
|| NetworkSidebarState.origin.end-date != ""
|| NetworkSidebarState.origin.location-display != ""):
VerticalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 12px;
padding-bottom: 12px;
spacing: 6px;
if NetworkSidebarState.origin-loading: Text {
text: @tr("Loading origin…");
color: Theme.text-muted;
font-size: 12px;
}
// Born / Founded
if !NetworkSidebarState.origin-loading
&& NetworkSidebarState.origin.begin-date != "":
HorizontalLayout {
spacing: 8px;
alignment: stretch;
Text {
text: NetworkSidebarState.origin.is-person
? @tr("BORN")
: @tr("FOUNDED");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
horizontal-stretch: 0;
}
Text {
text: NetworkSidebarState.origin.begin-date;
color: Theme.text-primary;
font-size: 13px;
horizontal-alignment: right;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
// Born in / Founded in — optionally clickable
if !NetworkSidebarState.origin-loading
&& NetworkSidebarState.origin.location-display != "":
HorizontalLayout {
spacing: 8px;
alignment: stretch;
Text {
text: NetworkSidebarState.origin.is-person
? @tr("BORN IN")
: @tr("FOUNDED IN");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
horizontal-stretch: 0;
}
if NetworkSidebarState.origin.location-clickable: Rectangle {
horizontal-stretch: 1;
location-text := Text {
text: NetworkSidebarState.origin.location-display;
color: location-ta.has-hover
? Theme.accent
: Theme.text-primary;
font-size: 13px;
horizontal-alignment: right;
vertical-alignment: center;
width: 100%;
}
location-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
NetworkSidebarActions.location-clicked(
NetworkSidebarState.mb-mbid);
}
}
}
if !NetworkSidebarState.origin.location-clickable: Text {
text: NetworkSidebarState.origin.location-display;
color: Theme.text-primary;
font-size: 13px;
horizontal-alignment: right;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
// Died / Disbanded
if !NetworkSidebarState.origin-loading
&& NetworkSidebarState.origin.end-date != "":
HorizontalLayout {
spacing: 8px;
alignment: stretch;
Text {
text: NetworkSidebarState.origin.is-person
? @tr("DIED")
: @tr("DISBANDED");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
horizontal-stretch: 0;
}
Text {
text: NetworkSidebarState.origin.end-date;
color: Theme.text-primary;
font-size: 13px;
horizontal-alignment: right;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
}
}
}
}
}
Rectangle { height: 22px; }
}
}
}