// The single, shared track-list row. Every track surface (album, // artist Popular, search, favorites, mix, playlist) renders this with // flags rather than its own copy: // // show-artwork leading is a cover thumbnail + a position number // (search / mix / playlist); false = a number cell // that swaps to play on hover (album / artist). // show-album an album-title column (mix / playlist). // show-favorite a standalone heart button. // show-download a standalone offline-cache button (album). // multi-select-mode the leading slot becomes a selection checkbox // (artist Popular, playlist editor). // // Everything routes through one media-action(kind, id, action): // "play" | "play-next" | "queue" | "favorite" | "download" | // "toggle-select" and the context-menu actions. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { TrackItem, DragActions, NowPlayingState, NavState } from "../state.slint"; import { QbzIcon } from "QbzIcon.slint"; import { TrackPlayCell } from "TrackPlayCell.slint"; import { TrackContextMenu } from "TrackContextMenu.slint"; import { QualityBadgeFull } from "QualityBadgeFull.slint"; export component TrackRow inherits Rectangle { in property track; // Fade out when the row is being removed (favorites un-favorite). opacity: root.track.removing ? 0.0 : 1.0; animate opacity { duration: 250ms; } in property index; in property show-artwork: true; in property show-album: false; in property show-favorite: false; // On by default: every Qobuz track row carries the offline cloud/check // button. A non-Qobuz surface (future Local Library / Plex rows, which are // already local) should set this false. in property show-download: true; // Local Library: a subtle per-source indicator (local / qobuz / plex) // shown INSTEAD of the favorite + offline columns. in property show-source: false; in property multi-select-mode: false; // Trailing "..." context-menu column. Off for rows that have no menu // actions (e.g. ephemeral-folder rows — play-only, no persistence). in property show-menu: true; // Custom-order reorder controls (up/down chevrons) at the leading // edge — used by the playlist custom sort. in property show-reorder: false; // Whether the row can be dragged onto sidebar playlists. in property draggable: true; callback media-action(string, string, string); // Explicit track number, else the 1-based list position. property number-text: root.track.number != "" ? root.track.number : "\{root.index + 1}"; // Set from the (conditional) context-menu button so the row highlight // still reacts to hovering it without referencing a possibly-absent id. property menu-hovered: false; property row-hovered: ta.has-hover || play-cell.hovered || root.menu-hovered; height: 50px; border-radius: 8px; background: root.row-hovered ? #ffffff16 : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); // Background TouchArea — also the drag source. Pressing the row // body (not the play/fav/more controls, which sit on top) and // moving past a threshold begins a track drag onto the sidebar // playlists. Global coords = the row's absolute-position + the // pointer offset; the TouchArea grabs on press so moves keep // arriving even once the pointer leaves the row. ta := TouchArea { mouse-cursor: default; // Double-click the row body = play the track (or toggle play/pause // if it's already the current track) — mirrors the play-cell click, // applied to every TrackRow across the app. double-clicked => { if (NowPlayingState.has-track && NowPlayingState.track-id == root.track.id) { NowPlayingState.toggle-play(); } else { root.media-action("track", root.track.id, "play"); } } property down-x; property down-y; property dragging: false; pointer-event(e) => { // The mouse back/forward buttons drive history nav. A TouchArea // swallows these (they never reach the AppShell catcher), so each // interactive row forwards them itself. if (e.kind == PointerEventKind.down && e.button == PointerEventButton.back) { NavState.request-back(); } if (e.kind == PointerEventKind.down && e.button == PointerEventButton.forward) { NavState.request-forward(); } if (e.kind == PointerEventKind.down) { // absolute-position double-counts this row's own layout offset // when the row sits inside a Flickable (the track list), so the // global pointer drifted down by the row's offset. Subtract // root.{x,y} to recover the true window position (scroll-safe: // root.{x,y} is the fixed in-layout offset, not the scroll). ta.down-x = root.absolute-position.x - root.x + ta.mouse-x; ta.down-y = root.absolute-position.y - root.y + ta.mouse-y; ta.dragging = false; } if (e.kind == PointerEventKind.up && ta.dragging) { DragActions.end(); ta.dragging = false; } } // Safety net: the `up` above only arrives if this TouchArea still holds // the pointer grab. Inside a ListView (or a Flickable that didn't drop // out of `interactive`), the scroll gesture can steal the grab mid-drag, // so the `up` never reaches us and the ghost stays glued to the cursor. // `pressed` flips to false on BOTH a normal release and a grab cancel, // so ending the drag here guarantees it always clears. Both paths guard // on `ta.dragging`, so whichever fires first wins and end() runs once. changed pressed => { if (!self.pressed && ta.dragging) { DragActions.end(); ta.dragging = false; } } moved => { if (ta.pressed && root.draggable) { if (!ta.dragging && (Math.abs(root.absolute-position.x - root.x + ta.mouse-x - ta.down-x) > 6px || Math.abs(root.absolute-position.y - root.y + ta.mouse-y - ta.down-y) > 6px)) { ta.dragging = true; DragActions.start( root.track.id, root.track.title, root.track.album != "" ? root.track.artist + " · " + root.track.album : root.track.artist, root.absolute-position.x - root.x + ta.mouse-x, root.absolute-position.y - root.y + ta.mouse-y); } if (ta.dragging) { DragActions.move( root.absolute-position.x - root.x + ta.mouse-x, root.absolute-position.y - root.y + ta.mouse-y); } } } } HorizontalLayout { padding-left: 12px; padding-right: 12px; spacing: 14px; // Reorder chevrons (custom-order mode) — leading edge. if root.show-reorder: VerticalLayout { alignment: center; spacing: 0px; Rectangle { width: 22px; height: 18px; QbzIcon { source: @image-url("../assets/icons/chevron-up.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: up-ta.has-hover ? Theme.text-primary : Theme.text-muted; } up-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, "move-up"); } } } Rectangle { width: 22px; height: 18px; QbzIcon { source: @image-url("../assets/icons/chevron-down.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: down-ta.has-hover ? Theme.text-primary : Theme.text-muted; } down-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, "move-down"); } } } } // Position number — only for artwork rows (the cover doesn't // carry the number; non-artwork rows show it in the play cell). if root.show-artwork && !root.multi-select-mode: VerticalLayout { alignment: center; Text { text: root.number-text; // Wide enough for 4-digit positions (big playlists). width: 32px; color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; overflow: elide; } } // Leading cell — cover/number play affordance, swapped for a // selection checkbox in multi-select mode. The play-cell stays // in the tree (just invisible) so row-hovered can read it. VerticalLayout { alignment: center; Rectangle { width: root.show-artwork ? 36px : 32px; height: root.show-artwork ? 36px : 28px; play-cell := TrackPlayCell { track-id: root.track.id; number: root.number-text; artwork: root.track.artwork; show-artwork: root.show-artwork; art-size: 36px; number-width: 32px; row-hovered: root.row-hovered && !root.multi-select-mode; visible: !root.multi-select-mode; play => { root.media-action("track", root.track.id, "play"); } } if root.multi-select-mode: Rectangle { x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; width: 13px; height: 13px; border-radius: 6.5px; background: root.track.selected ? Theme.accent : transparent; border-width: root.track.selected ? 0px : 1.5px; border-color: sel-ta.has-hover ? Theme.text-primary : Theme.text-muted; animate background { duration: 120ms; } QbzIcon { source: @image-url("../assets/icons/check.svg"); width: 8px; height: 8px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: #ffffff; visible: root.track.selected; } sel-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, "toggle-select"); } } } // Offline-decrypt padlock — overlays the play cell while the // track's CMAF bundle is being decrypted for playback. Pulses // to read as "unlocking" (mirrors Tauri's unlock glyph). if root.track.unlocking: lock-overlay := Rectangle { property pulse; width: parent.width; height: parent.height; border-radius: 6px; background: #000000aa; QbzIcon { source: @image-url("../assets/icons/lock.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: Theme.accent; opacity: lock-overlay.pulse ? 1.0 : 0.4; animate opacity { duration: 450ms; } } Timer { interval: 450ms; running: true; triggered => { lock-overlay.pulse = !lock-overlay.pulse; } } } } } // Title + artist. VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 2px; HorizontalLayout { alignment: start; spacing: 6px; Text { text: root.track.title; color: Theme.text-primary; font-size: Typography.link; font-weight: Typography.medium; vertical-alignment: center; overflow: elide; } if root.track.explicit: VerticalLayout { alignment: center; Rectangle { width: 16px; height: 16px; border-radius: 3px; background: Theme.surface-elevated; Text { text: "E"; color: Theme.text-muted; font-size: 9px; font-weight: Typography.semibold; horizontal-alignment: center; vertical-alignment: center; } } } } // Artist — a clickable link when an artist-id is present // (opens the artist view), plain muted text otherwise. The // TouchArea is sized to the text via the wrapping layout so it // doesn't swallow clicks across the whole row width. if root.track.artist != "": HorizontalLayout { alignment: start; Rectangle { width: artist-link.preferred-width; artist-link := Text { text: root.track.artist; color: (root.track.artist-id != "" && artist-ta.has-hover) ? Theme.text-primary : Theme.text-muted; font-size: Typography.legal; overflow: elide; } artist-ta := TouchArea { // Only interactive when there is an artist to open. enabled: root.track.artist-id != ""; mouse-cursor: root.track.artist-id != "" ? pointer : default; clicked => { root.media-action("artist", root.track.artist-id, "open"); } } } } } // Album column — clickable link when an album-id is present // (opens the album view), plain muted text otherwise. if root.show-album: Rectangle { width: 220px; album-link := Text { x: 0; width: 100%; text: root.track.album; color: (root.track.album-id != "" && album-ta.has-hover) ? Theme.text-primary : Theme.text-muted; font-size: Typography.legal; vertical-alignment: center; overflow: elide; } album-ta := TouchArea { // Constrain to the rendered text so empty padding isn't clickable. x: 0; width: album-link.preferred-width < parent.width ? album-link.preferred-width : parent.width; enabled: root.track.album-id != ""; mouse-cursor: root.track.album-id != "" ? pointer : default; clicked => { root.media-action("album", root.track.album-id, "open"); } } } // Duration. Text { text: root.track.duration; width: 70px; color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; vertical-alignment: center; } // Quality — bare badge (tier label + exact bit-depth / sample-rate), // no chip background so it blends into the row. HorizontalLayout { width: 92px; alignment: center; VerticalLayout { alignment: center; QualityBadgeFull { tier: root.track.quality-tier; detail: root.track.quality-detail; show-icon: false; bare: true; } } } // Favorite — filled accent heart when favorited, outline heart // otherwise. Click toggles (handled in Rust, which flips // `is-favorite` optimistically so this updates immediately). if root.show-favorite: VerticalLayout { alignment: center; Rectangle { width: 28px; height: 28px; border-radius: Radius.sm; background: fav-ta.has-hover ? Theme.surface-elevated : transparent; QbzIcon { source: root.track.is-favorite ? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg"); width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: root.track.is-favorite ? Theme.accent : (fav-ta.has-hover ? Theme.text-primary : Theme.text-muted); } fav-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, "favorite"); } } } } // Offline cache — a stateful affordance reflecting cache-status: // 0 none cloud-download (click -> cache) // 1/2 q'd/dl'ing loader (accent) (click -> cache; re-queues) // 3 ready circle-check (click -> uncache) // 4 failed triangle-alert (click -> cache; retries) // Shown when the surface enables it OR the track has active state, so // a downloading/ready copy surfaces its status even where the button // isn't enabled by default. if root.show-download || root.track.cache-status > 0: VerticalLayout { alignment: center; Rectangle { width: 28px; height: 28px; border-radius: Radius.sm; background: dl-ta.has-hover ? Theme.surface-elevated : transparent; // Queued / downloading: a SPINNING loader (continuous rotation // via animation-tick, like LoadingSpinner) so it reads as // "working in the background". Isolated in its own `if` so the // per-frame tick only drives the row while it is downloading. if root.track.cache-status == 1 || root.track.cache-status == 2: Image { width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; source: @image-url("../assets/icons/loader-circle.svg"); colorize: Theme.accent; transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg; } // Idle / ready / failed: a static glyph. if root.track.cache-status != 1 && root.track.cache-status != 2: QbzIcon { source: root.track.cache-status == 3 ? @image-url("../assets/icons/circle-check-big.svg") : (root.track.cache-status == 4 ? @image-url("../assets/icons/triangle-alert.svg") : @image-url("../assets/icons/cloud-download.svg")); width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: root.track.cache-status == 3 ? Theme.accent : (root.track.cache-status == 4 ? #e0564f : (dl-ta.has-hover ? Theme.text-primary : Theme.text-muted)); } dl-ta := TouchArea { mouse-cursor: pointer; clicked => { root.media-action("track", root.track.id, root.track.cache-status == 3 ? "uncache" : "cache"); } } } } // Source indicator (Local Library) — a subtle per-source glyph shown // instead of the favorite/offline columns. local -> hard-drive // (tinted), plex / qobuz(offline) -> their mark; small + flat, not the // bold album-card badge. if root.show-source && root.track.source != "": VerticalLayout { alignment: center; Rectangle { width: 28px; height: 28px; 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" ? Theme.text-muted : transparent; width: root.track.source == "local" ? 15px : 16px; 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. if root.show-menu: VerticalLayout { alignment: center; Rectangle { width: 32px; height: 32px; border-radius: Radius.sm; background: more-ta.has-hover ? Theme.surface-elevated : transparent; QbzIcon { source: @image-url("../assets/icons/ellipsis.svg"); width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted; } more-ta := TouchArea { mouse-cursor: pointer; clicked => { track-menu.show(); } changed has-hover => { root.menu-hovered = self.has-hover; } } } } } track-menu := TrackContextMenu { x: parent.width - self.width - 12px; y: 48px; track-id: root.track.id; // Per-source menu logic: Qobuz-backed actions (favorite, radio, share, // mixtape, offline, go-to, track-info) only for Qobuz/offline rows; // Add-to-playlist for Qobuz/offline/local (local playlists), not Plex/ // ephemeral; transient play/queue always. qobuz-actions: root.track.source != "local" && root.track.source != "plex" && root.track.source != "ephemeral"; playlist-action: root.track.source != "plex" && root.track.source != "ephemeral"; media-action(k, i, a) => { root.media-action(k, i, a); } } }