// 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, TrackItem, NavState, ContentView, NetworkSidebarState, NetworkSidebarActions, DragState, } 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 { ReleaseGrid } from "ReleaseGrid.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint"; import { CircleAction } from "../primitives/CircleAction.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 label; in property icon; in property 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 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 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 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 content-width: root.width; // Popular Tracks shows the first 5; Load more expands to all. property top-tracks-expanded: false; property 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 multi-select-mode: false; background: 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 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); } // 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; } // 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: Theme.text-primary; 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: 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(); } } } } } // 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; CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 40px; clicked => { root.media-action( "artist", ArtistState.id, "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; } // Check-all row — shown above the list whenever the // section is in multi-select mode. Matches the QBZ // pattern: leading checkbox on the left of the row, just // like the per-row selectors below. if root.multi-select-mode && ArtistState.top-tracks.length > 0: HorizontalLayout { height: 36px; padding-left: 12px; padding-right: 12px; spacing: 16px; VerticalLayout { alignment: center; Rectangle { width: 28px; height: 28px; check-all-box := Rectangle { x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; width: 22px; height: 22px; border-radius: 11px; background: transparent; border-width: 1.5px; border-color: check-all-ta.has-hover ? Theme.text-primary : Theme.text-muted; check-all-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action( "artist", "", "select-all-top-tracks"); } } } } } VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: @tr("Select all"); color: Theme.text-secondary; font-size: Typography.body; font-weight: Typography.medium; } } } 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: number cell (no cover), with // the multi-select checkbox. show-artwork: false; multi-select-mode: root.multi-select-mode; 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; } } } } // --- Release sections (Albums, EPs & Singles, Live, ...) --------- for section in ArtistState.release-sections: VerticalLayout { spacing: 0px; Rectangle { height: 32px; } ReleaseGrid { section: section; album-clicked(id) => { root.open-album(id); } media-action(kind, id, action) => { root.media-action(kind, id, action); } } } } // 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 // Shared scrollbar in the right gutter (homologated cross-view). // Hidden while the network sidebar is open — that panel pins to the // same right edge and carries its own scroll, so the page scrollbar // there would overlap it. 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; } // --- 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 { // 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 natural-top: body-row.absolute-position.y - page-flickable.absolute-position.y; property 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); } } // 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 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 sticky-top: 44px; x: root.width - self.width; y: max(self.natural-top, self.sticky-top); width: 300px; height: root.height - self.y; 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: 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. 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: 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); } } } } } } } } } } // --- 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; } } } } } } }