Files
qbz/crates/qbz-slint/ui/album/AlbumPageView.slint
T
vicrodh ea517326b7 fix(myqbz): visual round 2 — local art, checkbox-filter, circular checkboxes, quality skeleton, album header
Per the user's 2nd visual review:
- Local/Plex album art now renders in the detail rows: resolve_items backfills
  empty-art rows from the first resolved track (source-aware dispatch ->
  spawn_local_or_plex_loads with live Plex creds); to_item re-hydrates from cache.
- Type/source FILTER homologated: a LABELED filter button (FavGenreButton idiom,
  accent-fill active) + a checkbox popup; the "Select" toggle is now ICON-ONLY
  (square-check-big, no text) matching Favorites/LocalLibrary.
- New primitives/SelectionCheckbox.slint (the canonical CIRCULAR 13px selection
  checkbox, accent fill, check / minus for indeterminate). Replaced all 5 square
  MyQBZ checkboxes (detail list+grid, filter popup, builder per-row + tri-state
  select-all). KindRadio dots stay round radios (single-select, correct).
- Quality column shows a LoadingDots skeleton while resolving (not empty).
- Album view header buttons: deleted AlbumPageView's private divergent CircleAction
  copy -> the shared primitive, so it matches every other view's thickness.

CollectionsView kind-filter stays QbzSelect (single-select group-by = the app's
group-by idiom). Backend crates untouched. cargo check -p qbz-slint green.

cc
2026-06-09 13:31:31 -06:00

715 lines
31 KiB
Plaintext

// Album detail page.
//
// Mirrors the Svelte AlbumDetailView: back button, an album header
// (artwork + title / artist / info line / action buttons), a divider,
// and the track list with a column header. Data comes from the
// AlbumState global; the 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 { Spacing } from "../foundation/spacing.slint";
import { AlbumState, AlbumActions, ArtworkActions, NavState, ContentView, DragState, NowPlayingState } from "../state.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.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 { CircleAction } from "../primitives/CircleAction.slint";
import { NavButtons } from "../shell/NavButtons.slint";
// One label/award entry in the album sidebar — a round gradient avatar
// and a name.
component SidebarCard inherits Rectangle {
in property <image> icon;
in property <string> name;
in property <brush> avatar-bg;
callback clicked();
height: 56px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding: 8px;
spacing: 10px;
VerticalLayout {
alignment: center;
Rectangle {
width: 36px;
height: 36px;
border-radius: 18px;
background: root.avatar-bg;
QbzIcon {
source: root.icon;
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: #ffffff;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.name;
color: Theme.text-secondary;
font-size: Typography.link;
font-weight: Typography.medium;
wrap: word-wrap;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
component SidebarHeading inherits Text {
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1px;
}
export component AlbumPageView inherits Rectangle {
callback open-artist(string);
callback media-action(string, string, string);
// Whether the full-description modal is open.
property <bool> show-description: false;
background: Theme.surface-main;
flick := Flickable {
// Freeze scroll during a track drag so the ghost 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 == "album" && 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,
AlbumState.header-color 0%,
AlbumState.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; }
// --- Album header ------------------------------------------------
HorizontalLayout {
spacing: 32px;
Rectangle {
width: 224px;
height: 224px;
border-radius: 12px;
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: AlbumState.artwork;
image-fit: cover;
}
// Right-click → cover context menu (same pattern
// as the artist portrait): Add/Change/Remove
// custom cover, Open in browser, Save as.
cover-ta := TouchArea {
mouse-cursor: default;
pointer-event(e) => {
if (e.button == PointerEventButton.right
&& e.kind == PointerEventKind.up) {
cover-menu.show();
}
}
}
cover-menu := PopupWindow {
x: cover-ta.mouse-x;
y: cover-ta.mouse-y;
width: 220px;
close-policy: close-on-click;
ContextMenu {
if !AlbumState.has-custom-cover: ContextMenuItem {
icon: @image-url("../assets/icons/image-plus.svg");
label: @tr("Add cover");
clicked => {
ArtworkActions.add-custom(
"album", AlbumState.id);
}
}
if AlbumState.has-custom-cover: ContextMenuItem {
icon: @image-url("../assets/icons/image-plus.svg");
label: @tr("Change cover");
clicked => {
ArtworkActions.add-custom(
"album", AlbumState.id);
}
}
if AlbumState.has-custom-cover: ContextMenuItem {
icon: @image-url("../assets/icons/trash-2.svg");
label: @tr("Remove cover");
clicked => {
ArtworkActions.remove-custom(
"album", AlbumState.id);
}
}
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(
AlbumState.artwork-url);
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cloud-download.svg");
label: @tr("Save as…");
clicked => {
ArtworkActions.save-as(
AlbumState.artwork-url,
AlbumState.title + " - Cover.jpg");
}
}
}
}
}
VerticalLayout {
alignment: start;
padding-top: 4px;
spacing: 0px;
horizontal-stretch: 1;
Text {
text: AlbumState.title;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
overflow: elide;
}
Rectangle { height: 4px; }
HorizontalLayout {
alignment: start;
artist-area := TouchArea {
width: artist-text.preferred-width;
height: artist-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.open-artist(AlbumState.artist-id);
}
artist-text := Text {
text: AlbumState.artist;
// Same color as the surrounding metadata,
// bold to read as a link.
color: artist-area.has-hover
? Theme.text-primary
: Theme.text-secondary;
font-size: Typography.heading;
font-weight: Typography.bold;
}
}
}
Rectangle { height: 10px; }
Text {
text: AlbumState.info-line;
color: Theme.text-secondary;
font-size: Typography.body;
overflow: elide;
}
// Editorial description — lives with the metadata and
// fills the width to the right of the artwork; truncated,
// with a Read more link to the full modal.
if AlbumState.description != "": Rectangle { height: 12px; }
if AlbumState.description != "": Text {
// Explicit width so the text fills the whole band
// to the right of the artwork instead of wrapping
// narrow and leaving dead space. Bound to root.width
// (not the parent layout) to avoid a binding loop:
// page padding 32+32, artwork 224, header gap 32.
width: root.width - 320px;
text: AlbumState.description-short;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
clicked => {
root.show-description = 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: 20px; }
HorizontalLayout {
alignment: start;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
// Spin only while THIS album's first track is fetching.
loading: NowPlayingState.loading
&& NowPlayingState.album-id == AlbumState.id;
clicked => {
root.media-action("album", AlbumState.id, "play");
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
root.media-action("album", AlbumState.id, "shuffle");
}
}
}
// Edit metadata (tag editor) — local albums only; hidden
// for Plex (tag write isn't supported there).
if AlbumState.is-local && AlbumState.source != "plex": VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pencil.svg");
clicked => {
root.media-action("album", AlbumState.id, "edit");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
clicked => {
root.media-action("album", AlbumState.id, "favorite");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cloud-download.svg");
clicked => {
root.media-action("album", AlbumState.id, "cache");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
clicked => {
root.media-action("album", AlbumState.id, "radio");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/user.svg");
clicked => {
root.open-artist(AlbumState.artist-id);
}
}
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/book-open.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cassette-tape.svg");
clicked => {
root.media-action("album", AlbumState.id, "add-to-mixtape");
}
}
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/square-check-big.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); }
}
}
}
}
Rectangle { height: 20px; }
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 8px; }
// --- Track list + label/awards sidebar --------------------------
// No `alignment: start` here: it would defeat the track list's
// horizontal-stretch and leave the sidebar floating mid-row
// with dead space to its right. The track list stretches and
// the 254px sidebar is pushed flush to the right edge; when a
// main right sidebar opens, the whole content region narrows
// and the sidebar rides along with it.
HorizontalLayout {
spacing: 32px;
VerticalLayout {
horizontal-stretch: 1;
spacing: 0px;
alignment: start;
if AlbumState.loading && AlbumState.tracks.length == 0: Rectangle {
height: 280px;
VerticalLayout {
alignment: center;
spacing: 18px;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
Text {
text: @tr("Loading album…");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
}
}
}
// Toolbar — album quality badge and track search.
HorizontalLayout {
height: 52px;
spacing: 16px;
VerticalLayout {
alignment: center;
QualityBadgeFull {
tier: AlbumState.quality-tier;
detail: AlbumState.quality-detail;
}
}
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
alignment: center;
Rectangle {
width: 280px;
height: 34px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 7px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
// Functional client-side filter.
Rectangle {
horizontal-stretch: 1;
track-search := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
edited => {
AlbumActions.search(self.text);
}
}
Text {
visible: track-search.text == "";
text: @tr("Search tracks...");
// Span the box so the placeholder
// centers like the TextInput text.
width: 100%;
height: 100%;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
}
}
}
}
}
// Column header.
HorizontalLayout {
height: 40px;
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
Text {
text: "#";
width: 32px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("TITLE");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
text: @tr("QUALITY");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
// Favorite / offline-cache column headers (Qobuz). For
// a local album the rows show a source indicator
// instead, so one unlabeled column keeps alignment.
if !AlbumState.is-local: Rectangle {
width: 28px;
QbzIcon {
source: @image-url("../assets/icons/heart.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
if !AlbumState.is-local: Rectangle {
width: 28px;
QbzIcon {
source: @image-url("../assets/icons/cloud-download.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
}
if AlbumState.is-local: Rectangle { width: 28px; }
// Aligns with the per-row more-button slot.
Rectangle { width: 32px; }
}
for track[index] in AlbumState.tracks: TrackRow {
track: track;
index: index;
// Number cell (no cover). Qobuz albums carry the
// favorite heart (matching the column header) + the
// offline-cache button; local albums show the source
// indicator instead.
show-artwork: false;
show-favorite: !AlbumState.is-local;
show-download: !AlbumState.is-local;
show-source: AlbumState.is-local;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
// Label / awards sidebar.
if AlbumState.label != "" || AlbumState.awards.length > 0: VerticalLayout {
width: 254px;
spacing: 24px;
alignment: start;
if AlbumState.label != "": VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("LABEL"); }
SidebarCard {
icon: @image-url("../assets/icons/disc.svg");
name: AlbumState.label;
avatar-bg: @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
clicked => {
if (AlbumState.label-id != "") {
root.media-action("label", AlbumState.label-id, "open");
}
}
}
}
if AlbumState.awards.length > 0: VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("AWARDS"); }
for award in AlbumState.awards: SidebarCard {
icon: @image-url("../assets/laurels.svg");
name: award;
avatar-bg: @linear-gradient(135deg, #b45309 0%, #eab308 100%);
}
}
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
// --- Full-description modal --------------------------------------------
if root.show-description: Rectangle {
background: #000000bf;
// Scrim — a click anywhere outside the panel dismisses.
TouchArea {
mouse-cursor: default;
clicked => {
root.show-description = 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: Theme.card-shadow;
// Swallow clicks so they do not reach the scrim.
TouchArea { }
VerticalLayout {
padding: 24px;
spacing: 14px;
HorizontalLayout {
Text {
text: @tr("About this album");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
root.show-description = 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-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
// vertical-stretch bounds the Flickable to the leftover
// panel height so a long description actually scrolls.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: desc-text.preferred-height;
desc-text := Text {
width: parent.width;
text: AlbumState.description;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
}
}
}
}
}