// Album card for the Discover sections. // // Mirrors the Svelte AlbumCardLite, scaled to a 200px column / 200px artwork // with a bottom-left award ribbon and a hover overlay (dark scrim, // genre/year meta, and play / favorite / more action buttons), then a // tight title / artist stack alongside an icon-only quality badge. // Deterministic 266px height. // // The "more" overlay button opens a context menu; its actions bubble up // as media-action(kind, id, action). 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 { AlbumCardItem, NavState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QualityBadge } from "../primitives/QualityBadge.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; // Circular hover-overlay action button. Solid (semi-transparent) fill so // the whole circle is a stable hit/hover surface — an outline-only button // makes the pointer flicker between the ring and the transparent center. component OverlayButton inherits Rectangle { in property icon; in property primary: false; in property diameter: 36px; in property icon-size: 16px; in property active: false; out property hovered: ta.has-hover; callback clicked(); width: root.diameter; height: root.diameter; border-radius: root.diameter / 2; background: root.primary ? (ta.has-hover ? #ffffffd6 : #ffffff) : (ta.has-hover ? #ffffff3d : #ffffff24); border-width: root.primary ? 0px : 1.5px; border-color: #ffffffcc; QbzIcon { source: root.icon; width: root.icon-size; height: root.icon-size; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: root.active ? Theme.accent : (root.primary ? #000000 : #ffffff); } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } // Forward the mouse back/forward buttons to history nav (a TouchArea // swallows them, so they never reach the AppShell catcher). pointer-event(e) => { if (e.kind == PointerEventKind.down && e.button == PointerEventButton.back) { NavState.request-back(); } if (e.kind == PointerEventKind.down && e.button == PointerEventButton.forward) { NavState.request-forward(); } } } } export component AlbumCard inherits Rectangle { in property album; // Hide the favorite heart + its menu item in non-favoritable contexts // (e.g. Local Library albums). Mirrors Tauri's AlbumCardLibraryLite. in property show-favorite: true; // Always-visible source badge on the artwork (Local Library: local | // qobuz-offline | plex). Off elsewhere (single-source catalogs). in property show-source-badge: false; // Fade out when the card is being removed (favorites un-favorite). opacity: root.album.removing ? 0.0 : 1.0; animate opacity { duration: 250ms; } callback clicked(string); // kind ("album") , album id, action ("play" | "queue" | "favorite"). callback media-action(string, string, string); // Fully deterministic height: artwork 200 + 6 spacer + 40 meta. width: 200px; height: 246px; // The overlay stays up while the pointer is anywhere over the artwork // OR over one of its buttons. A child button steals `has-hover` from // the artwork detector, so the states are OR-combined. property overlay-on: hover.has-hover || fav-btn.hovered || play-btn.hovered || more-btn.hovered; VerticalLayout { alignment: start; spacing: 0px; // --- Artwork + hover overlay ------------------------------------- Rectangle { width: 200px; height: 200px; border-radius: Radius.sm; background: Theme.surface-elevated; clip: true; // Cover art. Empty until the artwork pipeline resolves it, // leaving the elevated placeholder visible underneath. Image { width: 100%; height: 100%; source: root.album.artwork; image-fit: cover; } // Hover scrim. Rectangle { width: 100%; height: 100%; background: #000000; opacity: root.overlay-on ? 0.6 : 0.0; animate opacity { duration: 150ms; } } // Hover meta — genre + year, top-left. The year sits one // single line below the genre. VerticalLayout { x: 12px; y: 12px; spacing: 2px; opacity: root.overlay-on ? 1.0 : 0.0; animate opacity { duration: 150ms; } if root.album.genre != "": Text { text: root.album.genre; height: 20px; color: #ffffffeb; font-size: 13px; font-weight: Typography.bold; vertical-alignment: center; } if root.album.year != "": Text { text: root.album.year; height: 17px; color: #ffffffcc; font-size: 12px; vertical-alignment: center; } } // Card-open + hover detector. Declared before the action // buttons so those, when interactive, win the pointer. hover := TouchArea { width: 100%; height: 100%; mouse-cursor: pointer; clicked => { root.clicked(root.album.id); } } // Hover action buttons — favorite / play / more. HorizontalLayout { x: 0px; y: 120px; width: 200px; height: 44px; alignment: center; spacing: 12px; opacity: root.overlay-on ? 1.0 : 0.0; animate opacity { duration: 150ms; } VerticalLayout { alignment: center; fav-btn := OverlayButton { visible: root.show-favorite; icon: root.album.is-favorite ? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg"); active: root.album.is-favorite; clicked => { root.media-action("album", root.album.id, "favorite"); } } } play-btn := OverlayButton { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 44px; icon-size: 18px; clicked => { root.media-action("album", root.album.id, "play"); } } VerticalLayout { alignment: center; more-btn := OverlayButton { icon: @image-url("../assets/icons/ellipsis.svg"); clicked => { album-menu.show(); } } } } // Award ribbon — content-width only (it is a ribbon, not a // full-width band), capped so long labels still elide. if root.album.ribbon != "": Rectangle { x: 0px; y: parent.height - self.height - 8px; height: 20px; // Hug the label; cap below the card width so a runaway // label elides instead of spanning the whole card. width: Math.min(ribbon-row.preferred-width, 200px); background: root.album.ribbon-kind == "press" ? #d49511 : #000000e0; border-top-right-radius: 3px; border-bottom-right-radius: 3px; clip: true; // Left accent stripe (not on the gold press ribbon). Rectangle { x: 0px; width: root.album.ribbon-kind == "press" ? 0px : 3px; background: root.album.ribbon-kind == "qobuzissime" ? #8b5cf6 : #eab308; } ribbon-row := HorizontalLayout { padding-left: 10px; padding-right: 10px; Text { text: root.album.ribbon; color: root.album.ribbon-kind == "press" ? #1f1407 : #ffffff; font-size: 9px; font-weight: Typography.semibold; vertical-alignment: center; overflow: elide; } } } // Always-visible source badge (Local Library) — bottom-right of // the art, on top of the scrim/buttons. Small rounded square // (ADR-008: not a pill). Logos render in their own colors; the // hard-drive glyph is tinted white. if root.show-source-badge && root.album.source != "": Rectangle { x: parent.width - self.width - 6px; y: parent.height - self.height - 6px; width: 24px; height: 24px; border-radius: 4px; background: root.album.source == "qobuz_download" ? transparent : #000000b3; Image { source: root.album.source == "plex" ? @image-url("../assets/icons/plex-logo.svg") : (root.album.source == "qobuz_download" ? @image-url("../assets/icons/qobuz-logo-filled.svg") : @image-url("../assets/icons/hard-drive.svg")); colorize: root.album.source == "local" ? #ffffff : transparent; width: root.album.source == "plex" ? 16px : (root.album.source == "qobuz_download" ? 22px : 14px); height: self.width; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; image-fit: contain; } } // Context menu for the "more" button. album-menu := PopupWindow { x: 26px; y: 56px; width: 196px; height: 142px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/library-big.svg"); label: @tr("Open album"); clicked => { root.clicked(root.album.id); } } ContextMenuItem { icon: @image-url("../assets/icons/play.svg"); label: @tr("Play"); clicked => { root.media-action("album", root.album.id, "play"); } } ContextMenuItem { icon: @image-url("../assets/icons/list-plus.svg"); label: @tr("Play next"); clicked => { root.media-action("album", root.album.id, "play-next"); } } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); clicked => { root.media-action("album", root.album.id, "queue"); } } if root.show-favorite: ContextMenuItem { icon: @image-url("../assets/icons/heart.svg"); label: @tr("Add to favorites"); clicked => { root.media-action("album", root.album.id, "favorite"); } } } } } Rectangle { height: 6px; } // --- Title / artist + quality badge ------------------------------ HorizontalLayout { height: 40px; spacing: Spacing.sm; VerticalLayout { alignment: center; spacing: 2px; horizontal-stretch: 1; Text { text: root.album.title; height: 20px; color: Theme.text-primary; font-size: Typography.body - 2px; font-weight: Typography.medium; vertical-alignment: center; overflow: elide; } // Artist name — clickable when an artist id is known, // navigating to the artist page (1:1 with Tauri). artist-area := TouchArea { height: 18px; mouse-cursor: root.album.artist-id != "" ? pointer : default; clicked => { if (root.album.artist-id != "") { root.media-action("artist", root.album.artist-id, "open"); } } artist-text := Text { // Fill the title column and elide. Binding the TouchArea // width to this text's preferred-width clamped the whole // title column to the (often short) artist name, which // truncated titles to a few letters and shoved the quality // badge inward instead of letting it float right. width: 100%; text: root.album.artist; color: root.album.artist-id != "" && artist-area.has-hover ? Theme.text-primary : Theme.text-muted; font-size: Typography.link - 1px; vertical-alignment: center; overflow: elide; } } } // Icon-only quality badge with an exact-quality hover tooltip. VerticalLayout { alignment: center; QualityBadge { tier: root.album.quality-tier; label: root.album.quality-label; detail: root.album.quality-detail; } } } } }