- label.rs parse_top_track: the /label/page top_tracks carry the main artist in
a track-level `artists` array (roles=main-artist), not performer/artist (null
here), and the album title in `album.title`. Read both (with legacy fallbacks)
so label Popular Tracks show the artist name + album (discussion #631).
TopTrack gains an `album` field, threaded to the row.
- Artist + Label Popular Tracks: show-album: true so the album-title column
renders (owner: album required in all PopularTracks lists).
Note: "Add to Mixtape" is already present in the artist/label Popular-Tracks
multi-select bar (cassette icon, tooltip) and the per-track "..." menu
(mixtape-action = qobuz-row) — no code change needed there.
Base: 6c41f58f.
cc
1805 lines
88 KiB
Plaintext
1805 lines
88 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,
|
|
ArtistActions,
|
|
ArtworkActions,
|
|
PinnedActions,
|
|
StoryItem,
|
|
TrackItem,
|
|
NavState,
|
|
ContentView,
|
|
NetworkSidebarState,
|
|
NetworkSidebarActions,
|
|
DragState,
|
|
ShellState,
|
|
AppearanceState,
|
|
} from "../state.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { TrackRow } from "../primitives/TrackRow.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 { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
|
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
|
|
import { ReleaseGrid } from "ReleaseGrid.slint";
|
|
import { AlbumCard } from "../discover/AlbumCard.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint";
|
|
import { CircleAction } from "../primitives/CircleAction.slint";
|
|
import { ImmersiveAtmosphere } from "../immersive/ImmersiveAtmosphere.slint";
|
|
|
|
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;
|
|
// Whether the in-page search input is currently visible.
|
|
// Closed by default; the JUMP TO bar's search icon toggles it.
|
|
property <bool> search-visible: false;
|
|
// Active JUMP TO tab. Currently set by tab-clicked only — a
|
|
// future pass will compute it from scroll position so the bar
|
|
// self-tracks the visible section.
|
|
property <string> active-jump-tab: "popular-tracks";
|
|
// Width handed down to the bio paragraph. The artist header sits
|
|
// above the body row where the sidebar lives, so the bio never
|
|
// needs to shrink for the sidebar — it always uses the full
|
|
// window width minus the page padding + portrait + header gap.
|
|
property <length> content-width: root.width;
|
|
|
|
// Header text colors (same rule as AlbumPageView): the
|
|
// ImmersiveAtmosphere behind the header is always dark, so when the
|
|
// gradient setting is ON the header text must be light regardless of
|
|
// theme (light themes' Theme.text-* are dark and vanish on the
|
|
// atmosphere). With the gradient OFF the header sits on
|
|
// Theme.surface-main and keeps the normal theme colors.
|
|
// Header sits on a DARK backdrop when the gradient atmosphere is on OR the
|
|
// app-wide dynamic background is active → light text either way. The album's
|
|
// own atmosphere is suppressed under the dynamic background (they clash).
|
|
property <bool> header-light: AppearanceState.album-header-gradient || ShellState.app-background-active;
|
|
property <bool> header-atmo-on: AppearanceState.album-header-gradient && !ShellState.app-background-active;
|
|
property <color> hdr-strong: header-light
|
|
? #ffffff : Theme.text-primary;
|
|
property <color> hdr-body: header-light
|
|
? #ffffff.with-alpha(0.88) : Theme.text-secondary;
|
|
|
|
// With no dark header backdrop the CircleActions sit on the plain theme
|
|
// surface — switch them to the theme-aware on-surface variant.
|
|
property <bool> hdr-on-surface: !header-light;
|
|
|
|
// Atmosphere/gradient extent: end EXACTLY at the header/content
|
|
// boundary — `body-row.y` is where the body content starts within
|
|
// `page` (the header height varies with the bio length, so a fixed px
|
|
// either overshoots into the content or fades early). The backgrounds
|
|
// share the page's top origin inside the Flickable viewport and are
|
|
// absolutely positioned (not part of the page layout), so this cannot
|
|
// form a binding loop.
|
|
property <length> atmo-height: page.y + body-row.y;
|
|
// Popular Tracks shows the first 5; Load more expands to all.
|
|
property <bool> top-tracks-expanded: false;
|
|
property <int> top-tracks-preview: 5;
|
|
// "Appears On" section uses the same 5-row preview + show-all toggle.
|
|
property <bool> appears-on-expanded: false;
|
|
// "Other" (often AI/junk covers) renders LAST, collapsed by default.
|
|
property <bool> other-expanded: false;
|
|
// Popular Tracks multi-select now lives in ArtistState
|
|
// (top-tracks-multi-select / top-tracks-selected-count), so it resets on
|
|
// artist change and drives the shared MultiSelectBar.
|
|
|
|
// Responsiveness: the 300px Artist Network sidebar AUTO-collapses when the
|
|
// content area is space-constrained — a right panel (Queue / Lyrics) is
|
|
// open on a non-wide window — so the Popular Tracks list keeps priority
|
|
// (tracks win — it's a music app), and AUTO-opens when there's room again.
|
|
// The user can still toggle it manually (the button below); that override
|
|
// sticks until the next constrain-edge or artist change.
|
|
//
|
|
// Uses ShellState.content-constrained (the SAME signal AlbumView uses),
|
|
// NOT a raw `root.width < N`. The old `root.width < 1080px` trigger WAS
|
|
// the regression: at a normal window the content area is already < 1080px
|
|
// WITHOUT any panel, so `net-cramped` was permanently true — opening the
|
|
// Queue produced no transition, the sidebar never auto-closed, and (being
|
|
// a 300px overlay) it covered the Popular Tracks action cluster instead of
|
|
// yielding to it.
|
|
property <bool> net-cramped: ShellState.content-constrained;
|
|
property <string> net-nav-watch: ArtistState.id;
|
|
changed net-cramped => {
|
|
NetworkSidebarState.open = !root.net-cramped;
|
|
}
|
|
changed net-nav-watch => {
|
|
NetworkSidebarState.open = !root.net-cramped;
|
|
}
|
|
|
|
background: ShellState.app-background-active ? transparent : Theme.surface-main;
|
|
|
|
// Single page-wide Flickable — the gradient header + body row
|
|
// (main content + sidebar-slot placeholder) all live inside it so
|
|
// the artist header spans the full window width (gradient covers
|
|
// edge-to-edge, mirroring Tauri's `.artist-body` row analogy of a
|
|
// colspan=2 header followed by a 2-column body). The actual
|
|
// sidebar is rendered AFTER the Flickable as an overlay sibling so
|
|
// it can stay anchored on the right and fill the available height
|
|
// independently of the page scroll.
|
|
page-flickable := Flickable {
|
|
// Freeze scrolling while a track drag is in flight, so the
|
|
// dragged row (and thus the ghost position) doesn't drift.
|
|
interactive: !DragState.active;
|
|
viewport-width: self.width;
|
|
viewport-height: page.preferred-height;
|
|
// Scroll-position restore on back/forward (see nav::scope_for).
|
|
property <string> sr-armed: NavState.restore-scope;
|
|
function sr-restore() {
|
|
if (NavState.restore-scope == "artist" && self.viewport-height > self.height) {
|
|
self.viewport-y = NavState.scroll-restore;
|
|
NavState.restore-scope = "";
|
|
}
|
|
}
|
|
init => { self.sr-restore(); }
|
|
changed sr-armed => { self.sr-restore(); }
|
|
changed viewport-height => { self.sr-restore(); }
|
|
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
|
|
|
// Static Immersive-style blurred artwork field behind the header;
|
|
// scrolls with content when the appearance setting is enabled.
|
|
if root.header-atmo-on && ArtistState.header-atmosphere.width > 0: ImmersiveAtmosphere {
|
|
x: 0;
|
|
y: 0;
|
|
width: parent.width;
|
|
height: root.atmo-height;
|
|
source-image: ArtistState.header-atmosphere;
|
|
fallback-image: ArtistState.artwork;
|
|
animated: false;
|
|
dim: 0.24;
|
|
base-opacity: 0.92;
|
|
warp-opacity: 0.38;
|
|
}
|
|
if root.header-atmo-on && ArtistState.header-atmosphere.width <= 0: Rectangle {
|
|
x: 0;
|
|
y: 0;
|
|
width: parent.width;
|
|
height: root.atmo-height;
|
|
background: @linear-gradient(180deg,
|
|
ArtistState.header-color 0%,
|
|
ArtistState.header-color 16%,
|
|
transparent 100%);
|
|
}
|
|
if root.header-atmo-on: Rectangle {
|
|
x: 0;
|
|
y: 0;
|
|
width: parent.width;
|
|
height: root.atmo-height;
|
|
// Fade start sits proportionally near the bottom so the blend
|
|
// to surface-main always COMPLETES right at the header/content
|
|
// boundary, whatever height the header resolved to.
|
|
background: @linear-gradient(180deg,
|
|
transparent 0%,
|
|
transparent 82%,
|
|
Theme.surface-main 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;
|
|
}
|
|
// Right-click → image context menu (Add/Change/
|
|
// Remove custom image, Open in browser, Save as).
|
|
// Left clicks pass through (no action on the
|
|
// portrait itself).
|
|
artwork-ta := TouchArea {
|
|
mouse-cursor: default;
|
|
pointer-event(e) => {
|
|
if (e.button == PointerEventButton.right
|
|
&& e.kind == PointerEventKind.up) {
|
|
artwork-menu.show();
|
|
}
|
|
}
|
|
}
|
|
artwork-menu := PopupWindow {
|
|
x: artwork-ta.mouse-x;
|
|
y: artwork-ta.mouse-y;
|
|
width: 220px;
|
|
close-policy: close-on-click;
|
|
ContextMenu {
|
|
if !ArtistState.has-custom-image: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/image-plus.svg");
|
|
label: @tr("Add image");
|
|
clicked => {
|
|
ArtworkActions.add-custom(
|
|
"artist", ArtistState.name);
|
|
}
|
|
}
|
|
if ArtistState.has-custom-image: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/image-plus.svg");
|
|
label: @tr("Change image");
|
|
clicked => {
|
|
ArtworkActions.add-custom(
|
|
"artist", ArtistState.name);
|
|
}
|
|
}
|
|
if ArtistState.has-custom-image: ContextMenuItem {
|
|
icon: @image-url("../assets/icons/trash-2.svg");
|
|
label: @tr("Remove image");
|
|
clicked => {
|
|
ArtworkActions.remove-custom(
|
|
"artist", ArtistState.name);
|
|
}
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/external-link.svg");
|
|
label: @tr("Open in browser");
|
|
clicked => {
|
|
ArtworkActions.open-in-browser(
|
|
ArtistState.artwork-url);
|
|
}
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/cloud-download.svg");
|
|
label: @tr("Save as…");
|
|
clicked => {
|
|
ArtworkActions.save-as(
|
|
ArtistState.artwork-url,
|
|
ArtistState.name + ".jpg");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
VerticalLayout {
|
|
alignment: start;
|
|
padding-top: 8px;
|
|
spacing: 0px;
|
|
horizontal-stretch: 1;
|
|
|
|
Text {
|
|
text: ArtistState.name;
|
|
color: root.hdr-strong;
|
|
font-size: Typography.section;
|
|
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: root.hdr-body;
|
|
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, Radio (with QBZ / Qobuz
|
|
// dropdown), Network, and a ⋯ overflow menu. Mirrors
|
|
// Tauri's artist-actions: NO Play here (top tracks
|
|
// has its own play). The owner-directed overflow menu
|
|
// (⋯) consolidates Create Artist Collection, Artist
|
|
// Scene (MB-gated), and the Blacklist/Show toggle —
|
|
// see the menu block below.
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
spacing: 12px;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/heart.svg");
|
|
on-surface: root.hdr-on-surface;
|
|
active: ArtistState.is-following;
|
|
clicked => {
|
|
root.media-action("artist", ArtistState.id, "follow");
|
|
}
|
|
}
|
|
// Radio with dropdown — QBZ radio (local engine)
|
|
// vs Qobuz radio (qobuz-curated). Both call
|
|
// distinct media-actions; main.rs wires them.
|
|
Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/radio.svg");
|
|
on-surface: root.hdr-on-surface;
|
|
clicked => {
|
|
radio-popup.show();
|
|
}
|
|
}
|
|
radio-popup := PopupWindow {
|
|
x: 0px;
|
|
y: 38px;
|
|
width: 180px;
|
|
close-policy: close-on-click;
|
|
ContextMenu {
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/radio.svg");
|
|
label: @tr("QBZ Radio");
|
|
clicked => {
|
|
root.media-action("artist", ArtistState.id, "radio-qbz");
|
|
}
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/radio.svg");
|
|
label: @tr("Qobuz Radio");
|
|
clicked => {
|
|
root.media-action("artist", ArtistState.id, "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");
|
|
on-surface: root.hdr-on-surface;
|
|
active: NetworkSidebarState.open;
|
|
clicked => {
|
|
NetworkSidebarState.open = !NetworkSidebarState.open;
|
|
NetworkSidebarActions.toggle();
|
|
}
|
|
}
|
|
// ⋯ Overflow menu — owner-directed (2026-06-16,
|
|
// supersedes Tauri's hidden eye-icon). Three
|
|
// items: Create Artist Collection, Artist Scene
|
|
// (MB-gated), separator, Blacklist/Show artist.
|
|
Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/ellipsis.svg");
|
|
on-surface: root.hdr-on-surface;
|
|
clicked => {
|
|
artist-overflow-menu.show();
|
|
}
|
|
}
|
|
// Opens below the ⋯ with the left edge aligned
|
|
// to the button (the radio popup uses the same
|
|
// x:0 / y:38 anchor).
|
|
artist-overflow-menu := PopupWindow {
|
|
x: 0px;
|
|
y: 38px;
|
|
width: 224px;
|
|
height: 5 * 33px + 1px + 10px;
|
|
close-policy: close-on-click;
|
|
ContextMenu {
|
|
// Item 1 — Create Artist Collection.
|
|
// Reuses the existing build-collection
|
|
// seam (folded in from the old
|
|
// standalone CircleAction, per owner).
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/library-big.svg");
|
|
label: @tr("Create Artist Collection");
|
|
clicked => {
|
|
root.media-action(
|
|
"artist", ArtistState.id, "build-collection");
|
|
}
|
|
}
|
|
// Item 2 — Artist Scene. Same seam as
|
|
// the ArtistNetwork Origin link;
|
|
// enabled ONLY when MusicBrainz is
|
|
// available (same gate as the Origin
|
|
// link). Greyed + inert when MB is off
|
|
// or the artist is unmatched, keeping
|
|
// the menu height stable.
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/map-pin.svg");
|
|
label: @tr("Artist Scene");
|
|
enabled: NetworkSidebarState.mb-available;
|
|
clicked => {
|
|
NetworkSidebarActions.location-clicked(
|
|
NetworkSidebarState.mb-mbid);
|
|
}
|
|
}
|
|
// Item 3 — Share (copy the Qobuz artist URL).
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/link.svg");
|
|
label: @tr("Share");
|
|
clicked => {
|
|
root.media-action(
|
|
"artist", ArtistState.id, "share");
|
|
}
|
|
}
|
|
// Item 4 — Pin / Unpin to Home. Label and
|
|
// icon flip with ArtistState.pinned. The
|
|
// subtitle slot is empty (artists carry
|
|
// no secondary line on the Pinned card).
|
|
ContextMenuItem {
|
|
icon: ArtistState.pinned
|
|
? @image-url("../assets/icons/pin-filled.svg")
|
|
: @image-url("../assets/icons/pin.svg");
|
|
label: ArtistState.pinned
|
|
? @tr("Unpin")
|
|
: @tr("Pin");
|
|
clicked => {
|
|
PinnedActions.toggle-pin(
|
|
"artist", ArtistState.id,
|
|
ArtistState.name, "",
|
|
ArtistState.artwork-url);
|
|
}
|
|
}
|
|
// Visible separator.
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
// Item 5 — Blacklist / Show artist.
|
|
// Label flips with ArtistState.is-blacklisted.
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/blind-eye.svg");
|
|
label: ArtistState.is-blacklisted
|
|
? @tr("Show artist")
|
|
: @tr("Blacklist artist");
|
|
clicked => {
|
|
root.media-action(
|
|
"artist", ArtistState.id, "blacklist-toggle");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Hidden-artist banner ----------------------------------------
|
|
// Shown when the CURRENTLY displayed artist is blacklisted. The
|
|
// page stays fully navigable (non-obvious behavior #6 — direct
|
|
// fetch-by-id is never blocked); this is an unblock affordance,
|
|
// not an access lock. Styled like WarningBanner (warning tone).
|
|
if ArtistState.is-blacklisted: Rectangle { height: 16px; }
|
|
if ArtistState.is-blacklisted: Rectangle {
|
|
border-radius: 8px;
|
|
background: #eab3081a;
|
|
border-width: 1px;
|
|
border-color: #eab3084d;
|
|
height: banner-row.preferred-height;
|
|
|
|
banner-row := HorizontalLayout {
|
|
padding: 12px;
|
|
spacing: 10px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/blind-eye.svg");
|
|
width: 16px;
|
|
height: 16px;
|
|
tint: #eab308;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: @tr("This artist is hidden from discovery");
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
wrap: word-wrap;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 0;
|
|
Rectangle {
|
|
width: show-artist-text.preferred-width + 20px;
|
|
height: 28px;
|
|
border-radius: 6px;
|
|
background: show-artist-ta.has-hover
|
|
? Theme.surface-elevated
|
|
: transparent;
|
|
show-artist-text := Text {
|
|
text: @tr("Show artist");
|
|
color: show-artist-ta.has-hover
|
|
? Theme.accent-hover
|
|
: Theme.accent;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
show-artist-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.media-action(
|
|
"artist", ArtistState.id, "blacklist-toggle");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Body row — main content column (search, popular tracks,
|
|
// release grids) flanked by an empty 300px reservation when
|
|
// the network sidebar is open, so the actual sidebar
|
|
// overlay (rendered below) never covers the page content.
|
|
// jump-nav.natural-top binds to `body-row.absolute-position.y
|
|
// - 44px`, so the bar always sits flush in the gap above
|
|
// this row regardless of how tall the artist header above
|
|
// it grew (long bios push the body row down with no manual
|
|
// tuning).
|
|
// Breathing room between the artist portrait and the JUMP TO bar.
|
|
// The bar tracks body-row.absolute-position.y, so pushing the row
|
|
// down keeps the fix-on-scroll behaviour intact.
|
|
Rectangle { height: 20px; }
|
|
body-row := HorizontalLayout {
|
|
spacing: 0px;
|
|
main-col := VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
spacing: 0px;
|
|
|
|
// Reservation slot for the JUMP TO bar — the bar is an
|
|
// absolute-positioned overlay (sticky-positioned above
|
|
// this row), so the body column has to skip its 44px
|
|
// height plus a small visual gap for content to land
|
|
// below it rather than behind it.
|
|
Rectangle { height: 52px; }
|
|
|
|
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 {
|
|
spacing: 12px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
SectionTitle {
|
|
text: @tr("Popular Tracks");
|
|
}
|
|
}
|
|
HorizontalLayout {
|
|
horizontal-stretch: 0;
|
|
spacing: 12px;
|
|
// Wrapped in VerticalLayout (alignment:center) so the round
|
|
// buttons keep their 40px size — an unwrapped CircleAction
|
|
// in this row was stretching tall ("giant button").
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/play-fill.svg");
|
|
primary: true;
|
|
// Plain page background (below the header divider) —
|
|
// theme-aware variant so it reads on light themes.
|
|
on-surface: true;
|
|
clicked => {
|
|
root.media-action(
|
|
"artist", ArtistState.id, "play-top");
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
|
active: ArtistState.top-tracks-multi-select;
|
|
on-surface: true;
|
|
clicked => {
|
|
ArtistActions.toggle-top-tracks-select();
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/ellipsis.svg");
|
|
on-surface: true;
|
|
clicked => {
|
|
top-tracks-menu.show();
|
|
}
|
|
}
|
|
// Popular Tracks section menu (all-tracks actions),
|
|
// opens below the ⋯ with right edges aligned.
|
|
top-tracks-menu := PopupWindow {
|
|
x: 32px - self.width;
|
|
y: 38px;
|
|
width: 224px;
|
|
height: 4 * 33px + 10px;
|
|
close-policy: close-on-click;
|
|
ContextMenu {
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-plus.svg");
|
|
label: @tr("Play all next");
|
|
clicked => {
|
|
ArtistActions.top-tracks-menu-action("next-all");
|
|
}
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-end.svg");
|
|
label: @tr("Add all to queue");
|
|
clicked => {
|
|
ArtistActions.top-tracks-menu-action("queue-all");
|
|
}
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
label: @tr("Shuffle all");
|
|
clicked => {
|
|
ArtistActions.top-tracks-menu-action("shuffle-all");
|
|
}
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-music.svg");
|
|
label: @tr("Add all to playlist");
|
|
clicked => {
|
|
ArtistActions.top-tracks-menu-action("playlist-all");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ArtistState.top-tracks.length > 0: Rectangle { height: 10px; }
|
|
|
|
// Bulk action bar — homologated icon bar, shown in multi-select
|
|
// mode over the selected Popular Tracks.
|
|
if ArtistState.top-tracks-multi-select
|
|
&& ArtistState.top-tracks.length > 0: MultiSelectBar {
|
|
selected-count: ArtistState.top-tracks-selected-count;
|
|
actions: [
|
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-favorites", label: @tr("Add to Library"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
|
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
|
];
|
|
action(id) => {
|
|
ArtistActions.top-tracks-bulk-action(id);
|
|
}
|
|
}
|
|
if ArtistState.top-tracks-multi-select
|
|
&& 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 ? 50px : 0px;
|
|
track: track;
|
|
index: index;
|
|
// Artist Popular Tracks: show the album cover thumbnail + the
|
|
// album-title column so you can tell which album/EP a track is
|
|
// from (discussion #631). Both threaded from Rust (artist.rs).
|
|
show-artwork: true;
|
|
show-album: true;
|
|
multi-select-mode: ArtistState.top-tracks-multi-select;
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
// Load more / View less — centered text-only affordance
|
|
// sitting 4px below the last visible row. The label flips
|
|
// with the expanded state (Tauri only shows Load more;
|
|
// View less is a Slint-side improvement so users can
|
|
// collapse back to the 5-track preview without scrolling).
|
|
if ArtistState.top-tracks.length > root.top-tracks-preview: Rectangle { height: 4px; }
|
|
if ArtistState.top-tracks.length > root.top-tracks-preview: HorizontalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: load-more-text.preferred-width + 24px;
|
|
height: 28px;
|
|
load-more-text := Text {
|
|
text: root.top-tracks-expanded
|
|
? @tr("View less")
|
|
: @tr("Load more");
|
|
color: load-more-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-secondary;
|
|
font-size: 13px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
load-more-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.top-tracks-expanded = !root.top-tracks-expanded;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Novedad más reciente (latest-release highlight) -------------
|
|
// A single highlighted card between Popular Tracks and the
|
|
// discography buckets. Nullable on the API → hidden when absent.
|
|
if ArtistState.has-last-release: VerticalLayout {
|
|
spacing: 12px;
|
|
Rectangle { height: 32px; }
|
|
Text {
|
|
text: @tr("Latest release");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.heading;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
AlbumCard {
|
|
width: 200px;
|
|
height: 246px;
|
|
album: ArtistState.last-release;
|
|
clicked(id) => {
|
|
root.open-album(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Release sections (Albums, EPs & Singles, Live, ...) ---------
|
|
// "other" is excluded here and rendered LAST (collapsed), below
|
|
// Appears On — it is usually low-quality / AI cover junk.
|
|
for section in ArtistState.release-sections: VerticalLayout {
|
|
spacing: 0px;
|
|
if section.release-type != "other": Rectangle { height: 32px; }
|
|
if section.release-type != "other": ReleaseGrid {
|
|
section: section;
|
|
album-clicked(id) => {
|
|
root.open-album(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
set-section-sort(rt, sort) => {
|
|
ArtistActions.set-section-sort(rt, sort);
|
|
}
|
|
load-more-section(rt) => {
|
|
ArtistActions.load-more-section(rt);
|
|
}
|
|
open-releases(rt) => {
|
|
ArtistActions.open-releases(rt);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Appears On (tracks_appears_on) — guest-appearance tracks ----
|
|
// A flat cross-album track section (NOT albums). 5-row preview +
|
|
// show-all toggle, mirroring Popular Tracks. Hidden when empty.
|
|
if ArtistState.appears-on.length > 0: VerticalLayout {
|
|
spacing: 0px;
|
|
Rectangle { height: 32px; }
|
|
Text {
|
|
text: @tr("Appears On");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.heading;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
Rectangle { height: 10px; }
|
|
for track[index] in ArtistState.appears-on: TrackRow {
|
|
visible: root.appears-on-expanded || index < root.top-tracks-preview;
|
|
height: self.visible ? 50px : 0px;
|
|
track: track;
|
|
index: index;
|
|
show-artwork: false;
|
|
multi-select-mode: false;
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
}
|
|
if ArtistState.appears-on.length > root.top-tracks-preview: Rectangle { height: 4px; }
|
|
if ArtistState.appears-on.length > root.top-tracks-preview: HorizontalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: appears-more-text.preferred-width + 24px;
|
|
height: 28px;
|
|
appears-more-text := Text {
|
|
text: root.appears-on-expanded
|
|
? @tr("View less")
|
|
: @tr("Load more");
|
|
color: appears-more-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-secondary;
|
|
font-size: 13px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
appears-more-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.appears-on-expanded = !root.appears-on-expanded;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Other (rendered LAST, collapsed by default) -----------------
|
|
// Usually low-quality / AI cover junk, so it sits below Appears On
|
|
// and stays hidden behind a Show/Hide toggle.
|
|
for section in ArtistState.release-sections: VerticalLayout {
|
|
spacing: 0px;
|
|
if section.release-type == "other": Rectangle { height: 32px; }
|
|
if section.release-type == "other": Rectangle {
|
|
height: other-header.preferred-height;
|
|
other-header := HorizontalLayout {
|
|
spacing: 8px;
|
|
Text {
|
|
text: section.title;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.heading;
|
|
font-weight: Typography.semibold;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
}
|
|
Text {
|
|
text: root.other-expanded ? @tr("Hide") : @tr("Show");
|
|
color: other-toggle-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-secondary;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
other-toggle-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.other-expanded = !root.other-expanded;
|
|
}
|
|
}
|
|
}
|
|
if section.release-type == "other" && root.other-expanded: Rectangle {
|
|
height: 12px;
|
|
}
|
|
if section.release-type == "other" && root.other-expanded: ReleaseGrid {
|
|
section: section;
|
|
hide-header: true;
|
|
album-clicked(id) => {
|
|
root.open-album(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
root.media-action(kind, id, action);
|
|
}
|
|
set-section-sort(rt, sort) => {
|
|
ArtistActions.set-section-sort(rt, sort);
|
|
}
|
|
load-more-section(rt) => {
|
|
ArtistActions.load-more-section(rt);
|
|
}
|
|
open-releases(rt) => {
|
|
ArtistActions.open-releases(rt);
|
|
}
|
|
}
|
|
}
|
|
} // close main-col VerticalLayout
|
|
|
|
// Sidebar slot — empty placeholder that reserves the
|
|
// right 300px so the main column does not stretch
|
|
// under the overlay sidebar.
|
|
if NetworkSidebarState.open: Rectangle {
|
|
width: 300px;
|
|
}
|
|
} // close body HorizontalLayout
|
|
} // close page VerticalLayout
|
|
} // close Flickable
|
|
|
|
// --- Sticky JUMP TO bar -------------------------------------------------
|
|
// Overlay anchored to the top of ArtistPageView with the same
|
|
// sticky math as the sidebar: tracks the page scroll until the
|
|
// user scrolls past the header, then pins to y = 0. The natural
|
|
// y is the row just below the artist header in the page layout
|
|
// (NavButtons + spacer + artist header + small gap, ~290px).
|
|
jump-nav := JumpNavBar {
|
|
bar-bg: ShellState.app-background-active
|
|
? Theme.surface-main.with-alpha(AppearanceState.app-background-bar-alpha)
|
|
: Theme.surface-main;
|
|
// body-row.absolute-position.y already moves up with scroll
|
|
// (it's a window-coord lookup), so `natural-top` here is
|
|
// the LIVE viewport-relative y the bar wants to occupy at
|
|
// any given scroll position — not a static scroll=0 value.
|
|
// The sticky clamp just keeps it from going above the top
|
|
// of the page (sticky-top = 0); adding viewport-y to the
|
|
// formula would double-count the scroll and lift the bar
|
|
// ahead of the body row during the transition (visible gap
|
|
// + bar-over-header artifact during mid-scroll).
|
|
property <length> natural-top:
|
|
body-row.absolute-position.y
|
|
- page-flickable.absolute-position.y;
|
|
property <length> sticky-top: 0px;
|
|
|
|
x: 0;
|
|
y: max(self.natural-top, self.sticky-top);
|
|
width: root.width;
|
|
tabs: ArtistState.jump-tabs;
|
|
active-tab-id: root.active-jump-tab;
|
|
search-open: root.search-visible;
|
|
tab-clicked(id, anchor-y) => {
|
|
root.active-jump-tab = id;
|
|
// Scroll the page so the target section lands right
|
|
// below the sticky JUMP TO bar. anchor-y is the
|
|
// target's page-local y (content-coords); subtract
|
|
// self.height to put the section just below the bar.
|
|
page-flickable.viewport-y = -(anchor-y - self.height);
|
|
}
|
|
search-clicked => {
|
|
root.search-visible = !root.search-visible;
|
|
if (!root.search-visible) {
|
|
self.search-text = "";
|
|
ArtistActions.search("");
|
|
}
|
|
}
|
|
search-text-edited(query) => {
|
|
ArtistActions.search(query);
|
|
}
|
|
}
|
|
|
|
// Shared scrollbar in the right gutter (homologated cross-view).
|
|
// Declared AFTER the sticky JUMP TO bar so it paints ON TOP of it —
|
|
// the full-width jump bar was overlapping the scrollbar. Still BELOW
|
|
// the modals/dialogs below (those must stay on top). Hidden while the
|
|
// network sidebar is open — that panel pins to the same right edge and
|
|
// carries its own scroll.
|
|
if !NetworkSidebarState.open: ListScrollbar {
|
|
x: parent.width - self.width - 4px;
|
|
width: 14px;
|
|
height: 100%;
|
|
viewport-height: page-flickable.viewport-height;
|
|
visible-height: page-flickable.height;
|
|
viewport-y <=> page-flickable.viewport-y;
|
|
auto-hide: true;
|
|
hover-active: ShellState.pointer-in-window
|
|
&& ShellState.pointer-x >= page-flickable.absolute-position.x
|
|
&& ShellState.pointer-x <= page-flickable.absolute-position.x + page-flickable.width
|
|
&& ShellState.pointer-y >= page-flickable.absolute-position.y
|
|
&& ShellState.pointer-y <= page-flickable.absolute-position.y + page-flickable.height;
|
|
}
|
|
|
|
// Network sidebar — overlay anchored to the right edge of the
|
|
// ArtistPageView, with sticky-style y positioning so it tracks
|
|
// the page scroll while the header is visible and then pins
|
|
// to `sticky-top` once the user scrolls past the header. The
|
|
// bottom edge is always flush with the now-playing bar
|
|
// (height = root.height - y), so no gap appears when sticky.
|
|
if NetworkSidebarState.open: sidebar-panel := Rectangle {
|
|
// natural-top is the LIVE viewport-relative y of the
|
|
// body-row top + 44 (= bottom edge of the JUMP TO bar,
|
|
// which itself starts AT body row top). Same reasoning
|
|
// as the bar: body-row.absolute-position.y already
|
|
// accounts for scroll, so the formula is just a
|
|
// max() clamp — no extra `+ viewport-y` here either.
|
|
property <length> natural-top:
|
|
body-row.absolute-position.y - page-flickable.absolute-position.y
|
|
+ jump-nav.height;
|
|
// 44px parks the sidebar just below the sticky JUMP TO
|
|
// bar (jump-nav's height) once the header scrolls away.
|
|
property <length> sticky-top: 44px;
|
|
|
|
x: root.width - self.width;
|
|
y: max(self.natural-top, self.sticky-top);
|
|
width: 300px;
|
|
height: root.height - self.y;
|
|
background: ShellState.app-background-active
|
|
? Theme.surface-card.with-alpha(AppearanceState.app-background-surface-alpha)
|
|
: 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;
|
|
// Transparent under the dynamic background: it sits ON the
|
|
// already-translucent panel body, so a second translucent
|
|
// layer would compound to near-opaque. Let the body show.
|
|
background: ShellState.app-background-active
|
|
? transparent
|
|
: Theme.surface-main;
|
|
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 8px;
|
|
spacing: 14px;
|
|
|
|
// Tab: Artist Network (primary).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: net-tab.preferred-width;
|
|
height: 28px;
|
|
net-tab := Text {
|
|
text: @tr("Network");
|
|
color: NetworkSidebarState.active-tab == "network"
|
|
? Theme.text-primary
|
|
: Theme.text-muted;
|
|
font-size: 12px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.8px;
|
|
x: 0;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
Rectangle {
|
|
visible: NetworkSidebarState.active-tab == "network";
|
|
width: parent.width;
|
|
height: 2px;
|
|
y: parent.height - 2px;
|
|
background: Theme.accent;
|
|
border-radius: 1px;
|
|
}
|
|
net-tab-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
NetworkSidebarState.active-tab = "network";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Tab: Magazine (Stories).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: mag-tab.preferred-width;
|
|
height: 28px;
|
|
mag-tab := Text {
|
|
text: @tr("Magazine");
|
|
color: NetworkSidebarState.active-tab == "magazine"
|
|
? Theme.text-primary
|
|
: Theme.text-muted;
|
|
font-size: 12px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.8px;
|
|
x: 0;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
Rectangle {
|
|
visible: NetworkSidebarState.active-tab == "magazine";
|
|
width: parent.width;
|
|
height: 2px;
|
|
y: parent.height - 2px;
|
|
background: Theme.accent;
|
|
border-radius: 1px;
|
|
}
|
|
mag-tab-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
NetworkSidebarState.active-tab = "magazine";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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.
|
|
if NetworkSidebarState.active-tab == "network": 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) ---
|
|
// Gate the WHOLE block on having data (or loading) so
|
|
// an empty official-similar list no longer renders a
|
|
// "No similar artists found" header above a populated
|
|
// "You may also like" (the incoherence the owner
|
|
// flagged on Vicky Psarakis). When empty, the block is
|
|
// simply absent and only the discovery rail shows.
|
|
if ArtistState.similar-artists.length > 0
|
|
|| ArtistState.loading: 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("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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Magazine tab — Qobuz editorial stories --------------
|
|
if NetworkSidebarState.active-tab == "magazine": Flickable {
|
|
vertical-stretch: 1;
|
|
viewport-width: self.width;
|
|
viewport-height: magazine-body.preferred-height;
|
|
|
|
magazine-body := VerticalLayout {
|
|
spacing: 10px;
|
|
padding: 12px;
|
|
alignment: start;
|
|
|
|
if ArtistState.stories-loading
|
|
&& ArtistState.stories.length == 0: Text {
|
|
text: @tr("Loading…");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
if !ArtistState.stories-loading
|
|
&& ArtistState.stories.length == 0: Text {
|
|
text: @tr("No stories for this artist");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
|
|
for story in ArtistState.stories: Rectangle {
|
|
border-radius: 8px;
|
|
height: story-col.preferred-height;
|
|
background: story-ta.has-hover ? Theme.surface-hover : transparent;
|
|
story-col := VerticalLayout {
|
|
padding: 6px;
|
|
spacing: 6px;
|
|
if story.image-url != "": Rectangle {
|
|
// 1:1 square thumbnail (height tracks the
|
|
// layout-stretched width).
|
|
height: self.width;
|
|
border-radius: 6px;
|
|
clip: true;
|
|
Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
source: story.image;
|
|
image-fit: cover;
|
|
}
|
|
}
|
|
Text {
|
|
text: story.title;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
font-weight: Typography.semibold;
|
|
wrap: word-wrap;
|
|
}
|
|
if story.author != "": Text {
|
|
text: story.author;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
story-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
ArtworkActions.open-in-browser(story.url);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|