Files
qbz/crates/qbz-slint/ui/artist/ArtistPageView.slint
T
vicrodh 8b526049bb fix(slint): drop redundant ARTIST label above the artist name
The small uppercased "ARTIST" eyebrow above the artist name in the
header is redundant — the context (artist page) and the prominent
name below already say it. Removed the Text node and the 6px spacer
that followed.

cc
2026-05-20 11:02:22 -06:00

1167 lines
50 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;
}
// One row in the network sidebar — small leading icon + name. Used by
// Labels (Disc icon), Similar Artists / Members / Discovery (User
// icon), and Member-Of groups (Music icon). Mirrors Tauri's
// `.sidebar-artist-link` rendering.
component SidebarLink inherits Rectangle {
in property <string> label;
in property <image> icon;
in property <string> tooltip;
callback clicked();
height: 28px;
border-radius: 4px;
background: link-ta.has-hover ? Theme.surface-elevated : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
alignment: stretch;
VerticalLayout {
alignment: center;
horizontal-stretch: 0;
QbzIcon {
source: root.icon;
width: 12px;
height: 12px;
tint: link-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
Text {
text: root.label;
color: link-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
overflow: elide;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
link-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component ArtistPageView inherits Rectangle {
callback open-album(string);
callback media-action(string, string, string);
// Whether the full-biography modal is open. Mirrors the album
// description Read-more pattern in AlbumPageView.
property <bool> show-bio: false;
// Width of the main content column — shrinks when the network
// sidebar opens, so the bio text wraps inside the visible band
// instead of being clipped by the sidebar.
property <length> content-width:
NetworkSidebarState.open ? root.width - 300px : root.width;
// 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: ArtistState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
overflow: elide;
}
// Biography — truncated body fills the horizontal
// band to the right of the artwork (subtracts page
// padding 32+32 + artwork 200 + header gap 32 =
// 296). Read more opens the full-bio modal, same
// pattern as the album description in
// AlbumPageView.
if ArtistState.bio != "": Rectangle { height: 12px; }
if ArtistState.bio != "": Text {
width: root.content-width - 296px;
text: ArtistState.bio-short;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if ArtistState.bio-truncated: Rectangle { height: 4px; }
if ArtistState.bio-truncated: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
clicked => {
root.show-bio = 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; }
// 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;
// Top inset clears the entire artist header — NavButtons
// (11 padding + ~32 row + 22 spacer = 65) + the artist
// header itself (~214px: ARTIST label + name + truncated
// bio + Read more + action buttons) + the 36px spacer
// before Popular Tracks. Total ~315px so the sidebar's
// top edge sits inline with where Popular Tracks starts,
// matching Tauri's `.network-sidebar-column` natural flow
// position (inside the body, alongside the sections —
// never overlapping the artist header).
Rectangle { height: 320px; }
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;
}
}
}
// --- Labels (Qobuz, deduped from own albums) ---
VerticalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 12px;
padding-bottom: 6px;
spacing: 4px;
Text {
text: @tr("LABELS");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
}
if ArtistState.labels.length == 0: Text {
text: @tr("No label info");
color: Theme.text-muted;
font-size: 12px;
}
for label in ArtistState.labels: SidebarLink {
label: label.name;
icon: @image-url("../assets/icons/disc.svg");
tooltip: label.name;
clicked => {
NetworkSidebarActions.label-clicked(
label.id, label.name);
}
}
}
// --- Similar Artists (Qobuz) ---
VerticalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 12px;
padding-bottom: 6px;
spacing: 4px;
Text {
text: @tr("SIMILAR ARTISTS");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
}
if ArtistState.similar-artists.length == 0
&& !ArtistState.loading: Text {
text: @tr("No similar artists found");
color: Theme.text-muted;
font-size: 12px;
}
if ArtistState.similar-artists.length == 0
&& ArtistState.loading: Text {
text: @tr("Loading…");
color: Theme.text-muted;
font-size: 12px;
}
for similar in ArtistState.similar-artists: SidebarLink {
label: similar.name;
icon: @image-url("../assets/icons/user.svg");
tooltip: similar.name;
clicked => {
NetworkSidebarActions.artist-clicked(similar.id);
}
}
}
// --- Relationships (MB) --------------------------
// Section is gated by mb-available so a
// disabled MB or unmatched artist hides it.
if NetworkSidebarState.mb-available
&& (NetworkSidebarState.relationships-loading
|| NetworkSidebarState.relationships.has-data):
VerticalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 12px;
padding-bottom: 6px;
spacing: 6px;
Text {
text: @tr("RELATIONSHIPS");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
}
if NetworkSidebarState.relationships-loading: Text {
text: @tr("Loading…");
color: Theme.text-muted;
font-size: 12px;
}
if !NetworkSidebarState.relationships-loading
&& NetworkSidebarState.relationships.members.length > 0:
VerticalLayout {
spacing: 2px;
padding-top: 2px;
Text {
text: @tr("Members & Former");
color: Theme.text-muted;
font-size: 11px;
}
for member in NetworkSidebarState.relationships.members:
SidebarLink {
label: member.name;
icon: @image-url("../assets/icons/user.svg");
tooltip: member.tooltip;
clicked => {
NetworkSidebarActions.musician-clicked(
member.name, member.role);
}
}
}
if !NetworkSidebarState.relationships-loading
&& NetworkSidebarState.relationships.groups.length > 0:
VerticalLayout {
spacing: 2px;
padding-top: 2px;
Text {
text: @tr("Member Of");
color: Theme.text-muted;
font-size: 11px;
}
for group in NetworkSidebarState.relationships.groups:
SidebarLink {
label: group.name;
icon: @image-url("../assets/icons/music.svg");
tooltip: group.tooltip;
clicked => {
NetworkSidebarActions.musician-clicked(
group.name, group.role);
}
}
}
if !NetworkSidebarState.relationships-loading
&& NetworkSidebarState.relationships.collaborators.length > 0:
VerticalLayout {
spacing: 2px;
padding-top: 2px;
Text {
text: @tr("Collaborators");
color: Theme.text-muted;
font-size: 11px;
}
for collab in NetworkSidebarState.relationships.collaborators:
SidebarLink {
label: collab.name;
icon: @image-url("../assets/icons/user.svg");
tooltip: collab.tooltip;
clicked => {
NetworkSidebarActions.musician-clicked(
collab.name, collab.role);
}
}
}
}
// --- Discovery: "You may also like" --------------
if NetworkSidebarState.mb-available
&& (NetworkSidebarState.discovery-loading
|| NetworkSidebarState.discovery-artists.length > 0):
VerticalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 12px;
padding-bottom: 12px;
spacing: 4px;
Text {
text: @tr("YOU MAY ALSO LIKE");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
}
if NetworkSidebarState.discovery-loading
&& NetworkSidebarState.discovery-artists.length == 0: Text {
text: @tr("Loading…");
color: Theme.text-muted;
font-size: 12px;
}
// Each row: SidebarLink filling the row plus a
// small thumbs-down button anchored to the
// right. The link click navigates to the
// Qobuz artist; the thumbs-down emits the
// dismiss callback so the row drops out and
// never reappears for this tag.
for disc in NetworkSidebarState.discovery-artists:
HorizontalLayout {
spacing: 2px;
SidebarLink {
horizontal-stretch: 1;
label: disc.name;
icon: @image-url("../assets/icons/user.svg");
tooltip: disc.name;
clicked => {
if (disc.qobuz-id != "") {
NetworkSidebarActions.artist-clicked(
disc.qobuz-id);
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 0;
Rectangle {
width: 24px;
height: 24px;
border-radius: 4px;
background: dismiss-ta.has-hover
? Theme.surface-elevated
: transparent;
QbzIcon {
source: @image-url(
"../assets/icons/thumbs-down.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: dismiss-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
}
dismiss-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
NetworkSidebarActions
.discovery-dismissed(
disc.mbid, disc.name);
}
}
}
}
}
}
}
}
}
}
// 32px gap above the now-playing bar — mirrors Tauri's
// height = calc(100vh - titlebar - jump-nav - nowplaying)
// which leaves a small margin above the bar.
Rectangle { height: 32px; }
}
}
// --- Full-biography modal ----------------------------------------------
// Same pattern as AlbumPageView's description modal: scrim that
// dismisses on click, centered panel with a Flickable so a long
// bio actually scrolls. Bio source (TiVo etc) appears as a small
// attribution line below the body.
if root.show-bio: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
root.show-bio = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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 this artist");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-bio := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
root.show-bio = false;
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-bio.has-hover
? Theme.text-primary
: Theme.text-muted;
}
}
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: bio-content.preferred-height;
bio-content := VerticalLayout {
width: parent.width;
spacing: 12px;
Text {
text: ArtistState.bio;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
if ArtistState.bio-source != "": Text {
text: @tr("Source") + ": " + ArtistState.bio-source;
color: Theme.text-muted;
font-size: 11px;
}
}
}
}
}
}
}