Files
qbz/crates/qbz-slint/ui/album/AlbumPageView.slint
T
vicrodh 999ded11ff feat(slint): post-lyrics UI polish sweep + correctness fixes
- TrackRow: Korean/CJK titles render via layout-assigned width+height
  (SongCard parity, never femtovg-measured); explicit "E" badge hugs the
  title text; duration + position number dropped to 13px so they read
  smaller than the 14px title.
- AlbumView: label/awards rail 214->160px with smaller card/icon/padding
  and centered headings in the compact rail; removed the dark right gutter
  (full-width Flickable, content clears the scrollbar via the 32px page
  padding); column headers homologated to Playlist casing (Title/Duration/
  Quality), same in LocalAlbumView.
- ArtistView: network sidebar auto-collapses on ShellState.content-
  constrained (the AlbumView signal) and no longer re-opens on artist->
  artist navigation (reset_network_sidebar respects the constraint).
- Hero titles homologated to Typography.section (18px) across MyQBZ detail,
  Musician, Label releases, Artists-by-location, Offline Manager.
- Lyrics: Qobuz || LRCLIB parallel fetch, next-track prefetch, source
  footer, asymmetric line fade.

cc
2026-06-11 22:03:46 -06:00

874 lines
42 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, ShellState, TooltipState } 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 { AlbumContextMenu } from "../primitives/AlbumContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
// One label/award entry in the album sidebar — a round gradient avatar
// and a name. In `compact` mode the name is dropped (the rail is an
// icon-only strip) and shown in a hover tooltip instead.
component SidebarCard inherits Rectangle {
in property <image> icon;
in property <string> name;
in property <brush> avatar-bg;
in property <bool> compact: false;
callback clicked();
height: 48px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding: 6px;
spacing: 10px;
alignment: root.compact ? center : start;
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
border-radius: 14px;
background: root.avatar-bg;
QbzIcon {
source: root.icon;
width: 13px;
height: 13px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
}
if !root.compact: VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.name;
color: Theme.text-secondary;
// -2pts vs the Typography.link (14px) base — the label/award
// NAME is what needed trimming, not the LABEL/AWARDS heading.
font-size: 12px;
font-weight: Typography.medium;
wrap: word-wrap;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
// Compact rail: the name lives in a hover tooltip.
changed has-hover => {
if (self.has-hover && root.compact && root.name != "") {
TooltipState.text = root.name;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
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;
// Header description picks the SHORTER cutoff when the content area is
// space-constrained (window <1366 AND a right panel open), so the tracks
// keep priority; back to the full short text when there's room.
property <string> desc-display:
ShellState.content-constrained
? AlbumState.description-shorter
: AlbumState.description-short;
background: Theme.surface-main;
flick := Flickable {
// Full-width: the gradient + page background bleed edge-to-edge so
// there is no dark right gutter. Content stays clear of the scrollbar
// via the page's 32px right padding — the scrollbar lives in the
// outer 18px (parent.width-18 .. parent.width-4), and every text /
// track / label-rail child ends at parent.width-32, a 14px gap, so
// nothing paints under it. The gradient is the only thing reaching
// the edge, which is exactly the desired full-bleed colour. (The
// earlier `parent.width - 20px` clipped the gradient too, leaving an
// ugly dark gutter strip on the right.)
width: parent.width;
// 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; }
// Meta line. With a navigable label, render it inline as a
// clickable link (Tauri label-link: meta color, accent on
// hover) between the pre (year) and post (genre • tracks •
// duration) segments. Otherwise the plain full line.
if AlbumState.label-id != "" && AlbumState.label != "": HorizontalLayout {
alignment: start;
spacing: 0px;
Text {
text: AlbumState.meta-pre != "" ? AlbumState.meta-pre + " • " : "";
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
label-area := TouchArea {
width: label-text.preferred-width;
height: label-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.media-action("label", AlbumState.label-id, "open");
}
label-text := Text {
text: AlbumState.label;
color: label-area.has-hover ? Theme.accent : Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
Text {
text: AlbumState.meta-post != "" ? " • " + AlbumState.meta-post : "";
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
if AlbumState.label-id == "" || AlbumState.label == "": 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: root.desc-display;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if AlbumState.description != root.desc-display: Rectangle { height: 4px; }
if AlbumState.description != root.desc-display: 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;
// 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 {
// Live library state: filled accent heart when
// favorited, outline otherwise; spinner while a
// toggle is in flight. "favorite-toggle" adds or
// removes (the grid hearts stay add-only).
icon: AlbumState.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
active: AlbumState.is-favorite;
loading: AlbumState.favorite-loading;
clicked => {
root.media-action("album", AlbumState.id, "favorite-toggle");
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/radio.svg");
clicked => {
root.media-action("album", AlbumState.id, "radio");
}
}
}
if AlbumState.has-booklet: 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");
}
}
}
// Album info (i) — icon only for now; the detail panel
// logic lands later.
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/info.svg"); }
}
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
album-menu.show();
}
}
// Opens below the ⋯, right edges aligned — the
// button sits at the right end of the action row
// so the menu drops leftward (negative x).
album-menu := AlbumContextMenu {
x: 32px - self.width;
y: 38px;
album-id: AlbumState.id;
fully-cached: AlbumState.album-fully-cached;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
}
}
}
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 214px 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 {
// 60% of the previous 280px so the toolbar gives
// room to the square select button beside it.
width: 168px;
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;
}
}
}
}
}
// Multi-select toggle — square (matches the search box,
// not the round CircleActions), slightly smaller. Active
// (accent) while select mode is on; toggles it.
VerticalLayout {
alignment: center;
Rectangle {
width: 30px;
height: 30px;
border-radius: 6px;
border-width: 1px;
border-color: AlbumState.multi-select
? Theme.accent
: Theme.border-subtle;
background: (AlbumState.multi-select || select-ta.has-hover)
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/square-check-big.svg");
width: 15px;
height: 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: AlbumState.multi-select
? Theme.accent
: (select-ta.has-hover
? Theme.text-primary
: Theme.text-secondary);
}
select-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
AlbumActions.toggle-multi-select();
}
}
}
}
}
// Bulk action bar — visible in multi-select mode, over the
// selected catalog rows.
if AlbumState.multi-select: MultiSelectBar {
selected-count: AlbumState.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-favorites", label: @tr("Add to favorites"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.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) => {
AlbumActions.bulk-action(id);
}
}
if AlbumState.multi-select: Rectangle { height: 8px; }
// 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 {
// Capital-case to match PlaylistView's column headers.
text: @tr("Title");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
// Capital-case to match PlaylistView's column headers.
text: @tr("Duration");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
// Capital-case to match PlaylistView's column headers.
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;
multi-select-mode: AlbumState.multi-select;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
// Label / awards sidebar. Collapses to a slim ICON RAIL (56px)
// when the content area is space-constrained (window <1366 AND
// a right panel open), so the tracks — what matters in a music
// app — keep priority. In the rail, only the LABEL/AWARDS
// headings + the avatars show; names move to hover tooltips.
if AlbumState.label != "" || AlbumState.awards.length > 0: VerticalLayout {
// 160px (was 214px): the LABEL/AWARDS column was too wide
// and the track TITLE — the most important column — paid
// for it. Narrower here returns that width to the list.
width: ShellState.content-constrained ? 56px : 160px;
spacing: 24px;
alignment: start;
if AlbumState.label != "": VerticalLayout {
spacing: 8px;
SidebarHeading {
text: @tr("LABEL");
// Centered over the avatar when collapsed to the
// icon rail; left-aligned in the full column.
horizontal-alignment: ShellState.content-constrained ? center : left;
}
SidebarCard {
icon: @image-url("../assets/icons/disc.svg");
name: AlbumState.label;
compact: ShellState.content-constrained;
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");
horizontal-alignment: ShellState.content-constrained ? center : left;
}
for award in AlbumState.awards: SidebarCard {
icon: @image-url("../assets/laurels.svg");
name: award;
compact: ShellState.content-constrained;
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;
}
}
}
}
}
}