// 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, TooltipState, BlacklistActions } 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; // Multi-select mode (Local Library albums): a checkbox overlay replaces the // hover action buttons, and the card click toggles selection instead of // opening. Default false → every other AlbumCard consumer is untouched. in property select-mode: 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); // Deterministic height: artwork 200 + 6 spacer + 40 meta = 246. Overridable // per-context (Discover Recommendations gives the row extra breathing room // so the meta line never clips); the VerticalLayout is `alignment: start`, // so any extra height is quiet air below the card, not stretched content. in property card-height: 246px; width: 200px; height: root.card-height; // 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 => { if (root.select-mode) { root.media-action("album", root.album.id, "toggle-select"); } else { root.clicked(root.album.id); } } } // Multi-select checkbox — top-right of the art, shown only in // select-mode (the card click toggles; this is the indicator). if root.select-mode: Rectangle { x: parent.width - self.width - 8px; y: 8px; width: 24px; height: 24px; border-radius: 12px; background: root.album.selected ? Theme.accent : #00000099; border-width: 1.5px; border-color: root.album.selected ? Theme.accent : #ffffffcc; animate background { duration: 120ms; } QbzIcon { source: @image-url("../assets/icons/check.svg"); visible: root.album.selected; 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: #ffffff; } } // Hover action buttons — favorite / play / more. Hidden in // select-mode (the click toggles selection instead). HorizontalLayout { visible: !root.select-mode; 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; // `qobuz_purchase` → §8.7 gold chip: dark-amber translucent fill + // gold 1px border (the visual that distinguishes a purchased album // in the local library; the glyph is GOLD-tinted below). background: root.album.source == "qobuz_purchase" ? #1e1400d9 : (root.album.source == "qobuz_download" ? transparent : #000000b3); border-width: root.album.source == "qobuz_purchase" ? 1px : 0px; border-color: #eab30880; Image { source: root.album.source == "plex" ? @image-url("../assets/icons/plex-logo.svg") : (root.album.source == "qobuz_download" || root.album.source == "qobuz_purchase" ? @image-url("../assets/icons/qobuz-logo-filled.svg") : @image-url("../assets/icons/hard-drive.svg")); colorize: root.album.source == "qobuz_purchase" ? #eab308 : (root.album.source == "local" ? #ffffff : transparent); width: root.album.source == "plex" ? 16px : (root.album.source == "qobuz_download" ? 22px : (root.album.source == "qobuz_purchase" ? 18px : 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; } // §8.7: the gold purchase badge carries a hover tooltip // `library.qobuzPurchaseIndicator` ("Purchased from Qobuz"), // mirroring Tauri's SourceBadge `title=` on the qobuz_purchase // chip. Bare hover TouchArea drives the shared TooltipState; // wired ONLY for qobuz_purchase (the download/plex/local badges // have no source tooltip — matching the Tauri baseline). if root.album.source == "qobuz_purchase": TouchArea { changed has-hover => { if (self.has-hover) { TooltipState.text = @tr("Purchased from Qobuz"); TooltipState.anchor-x = self.absolute-position.x + self.width / 2; TooltipState.anchor-y = self.absolute-position.y; TooltipState.show = true; } else { TooltipState.show = false; } } } } // Context menu for the "more" button. album-menu := PopupWindow { x: 26px; y: 56px; width: 196px; // 6 items max (Open/Play/Play-next/Queue + favorite + block). height: 6 * 33px + 10px; 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: root.album.is-favorite ? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg"); label: root.album.is-favorite ? @tr("Remove from Library") : @tr("Add to Library"); clicked => { root.media-action("album", root.album.id, "favorite"); } } // Block this album (album blacklist) — Qobuz cards only. // One-way: a blocked album drops from the grid on reload. if root.album.source != "local" && root.album.source != "plex": ContextMenuItem { icon: @image-url("../assets/icons/blind-eye.svg"); label: @tr("Block this album"); clicked => { BlacklistActions.block-album(root.album.id, root.album.title, root.album.artist, root.album.artwork-url); } } } } } 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; } } } } }