// 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 icon; in property name; in property 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 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 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; } } } } } }