// Track card for grid surfaces (the Library "All" mixed feed). // // A reduced AlbumCard: a single track rendered as a 200px card instead of a // list row. Mirrors AlbumCard.slint 1:1 for design coherence — same 200x200 // clipped cover, hover scrim, centered play/favorite/more overlay row, then a // 6px spacer and a 40px meta row (title + "Track • Artist" subtitle beside an // icon-only quality badge). Deterministic 246px height. // // Unlike AlbumCard (whose body click OPENS the album), a track card's body // click PLAYS the track (like PlaylistCard's body-opens=false). Actions bubble // up as media-action("track", 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 { TrackItem, 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. File-local by design — every card // (AlbumCard/PlaylistCard/ArtistGridCard) carries its own copy. 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(); } 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 TrackCard inherits Rectangle { in property track; in property show-favorite: true; // Always-visible source badge on the artwork (Library All: local | plex | // qobuz). Off elsewhere (single-source surfaces). in property show-source-badge: false; // Fade out when the card is being removed (favorites un-favorite). opacity: root.track.removing ? 0.0 : 1.0; animate opacity { duration: 250ms; } callback clicked(string); // kind ("track"), track id, action ("play" | "queue" | "play-next" | // "favorite" | "open"). callback media-action(string, string, string); in property card-height: 246px; width: 200px; height: root.card-height; 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; Image { width: 100%; height: 100%; source: root.track.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; } } // Card body — click PLAYS the track. Declared before the action // buttons so those, when interactive, win the pointer. hover := TouchArea { width: 100%; height: 100%; mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, "play"); } } // 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.track.is-favorite ? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg"); active: root.track.is-favorite; clicked => { root.media-action("track", root.track.id, "favorite"); } } } play-btn := OverlayButton { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 44px; icon-size: 18px; clicked => { root.media-action("track", root.track.id, "play"); } } VerticalLayout { alignment: center; more-btn := OverlayButton { icon: @image-url("../assets/icons/ellipsis.svg"); clicked => { track-menu.show(); } } } } // Always-visible source badge — bottom-right of the art. Small // rounded square (ADR-008: not a pill). if root.show-source-badge && root.track.source != "": Rectangle { x: parent.width - self.width - 6px; y: parent.height - self.height - 6px; width: 24px; height: 24px; border-radius: 4px; background: #000000b3; Image { source: root.track.source == "plex" ? @image-url("../assets/icons/plex-logo.svg") : (root.track.source == "qobuz" ? @image-url("../assets/icons/qobuz-logo-filled.svg") : @image-url("../assets/icons/hard-drive.svg")); colorize: root.track.source == "local" ? #ffffff : transparent; width: root.track.source == "plex" ? 16px : (root.track.source == "qobuz" ? 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; } } // Context menu for the "more" button. track-menu := PopupWindow { x: 26px; y: 56px; width: 196px; // Play / Play-next / Queue / Go-to-artist / Go-to-album / favorite. height: 6 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/play.svg"); label: @tr("Play"); clicked => { root.media-action("track", root.track.id, "play"); } } ContextMenuItem { icon: @image-url("../assets/icons/list-plus.svg"); label: @tr("Play next"); clicked => { root.media-action("track", root.track.id, "play-next"); } } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); clicked => { root.media-action("track", root.track.id, "queue"); } } if root.track.artist-id != "": ContextMenuItem { icon: @image-url("../assets/icons/user.svg"); label: @tr("Go to artist"); clicked => { root.media-action("artist", root.track.artist-id, "open"); } } if root.track.album-id != "": ContextMenuItem { icon: @image-url("../assets/icons/disc.svg"); label: @tr("Go to album"); clicked => { root.media-action("album", root.track.album-id, "open"); } } if root.show-favorite: ContextMenuItem { icon: root.track.is-favorite ? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg"); label: root.track.is-favorite ? @tr("Remove from Library") : @tr("Add to Library"); clicked => { root.media-action("track", root.track.id, "favorite"); } } } } } Rectangle { height: 6px; } // --- Title / "Track • Artist" + quality badge -------------------- HorizontalLayout { height: 40px; spacing: Spacing.sm; VerticalLayout { alignment: center; spacing: 2px; horizontal-stretch: 1; // Track title — clickable, plays the track (same as the body). title-area := TouchArea { height: 20px; mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, "play"); } Text { width: 100%; text: root.track.title; color: title-area.has-hover ? Theme.accent : Theme.text-primary; font-size: Typography.body - 2px; font-weight: Typography.medium; vertical-alignment: center; overflow: elide; } } // "Track • Artist" subtitle — the artist portion links to the // artist page when an id is known. artist-area := TouchArea { height: 18px; mouse-cursor: root.track.artist-id != "" ? pointer : default; clicked => { if (root.track.artist-id != "") { root.media-action("artist", root.track.artist-id, "open"); } } Text { width: 100%; text: @tr("Track") + " • " + root.track.artist; color: root.track.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. TrackItem has no pre-composed label, so // the hover tooltip carries the bare detail line (e.g. "24-bit / // 96 kHz") rather than AlbumCard's full "Hi-Res: …" string. VerticalLayout { alignment: center; QualityBadge { tier: root.track.quality-tier; label: root.track.quality-detail; detail: root.track.quality-detail; } } } } }