Files
qbz/crates/qbz-ui/ui/primitives/TrackRow.slint
T
vicrodh 6e58682aa6 fix(playlist): reuse the shared drag ghost for custom-order reorder
The bespoke reorder ghost read a row's absolute-position inside a ListView, which double-counts the row's own offset, so it only sat at the cursor for row 0 and flew off for the rest. Drive the app-wide DragState directly from the reorder gesture so the shared DragGhost (positioned in AppShell at the proven window coords) renders the reorder ghost; a new DragState.reorder flag keeps the sidebar from claiming it as an add-to-playlist drop and keeps the Rust DragActions path out of it. Removed the buggy local ghost. Reorder logic + persistence unchanged. (state.slint also carries the quality-badge fields for the next commit.)

cc
2026-07-13 01:29:09 -06:00

878 lines
44 KiB
Plaintext

// 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, TooltipState, ShellState } 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 <TrackItem> track;
// Blacklisted rows render dimmed + INERT in place (never removed):
// opacity 0.4, no play/drag/menu-play/favorite/download, nav-only menu,
// a blacklist glyph in the number/play cell. Mirrors Tauri's
// `TrackRow.svelte` `.blacklisted` (opacity:0.4; cursor:not-allowed;
// not draggable; click/contextmenu/keydown short-circuit). This is the
// single shared interaction gate every blacklist surface reuses.
property <bool> row-inert: root.track.is-blacklisted;
// Fade out when the row is being removed (favorites un-favorite); a
// blacklisted row that isn't fading dims to 0.4, and so does the row
// currently being drag-reordered (queue-drag parity).
opacity: root.track.removing
? 0.0
: ((root.row-inert || root.reorder-source) ? 0.4 : 1.0);
animate opacity { duration: 250ms; }
in property <int> index;
in property <bool> show-artwork: true;
in property <bool> show-album: false;
in property <bool> show-favorite: false;
// Keep the favorite column's 28px slot when the heart is hidden, so a
// surface mixing heart rows WITH heart-less rows (the playlist detail:
// Qobuz rows have it, local/plex sidecar rows don't) keeps one
// duration/quality grid — Tauri renders a placeholder for the same
// reason (TrackRow.svelte favorite-placeholder).
in property <bool> reserve-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 <bool> show-download: true;
// Local Library: a subtle per-source indicator (local / qobuz / plex)
// shown INSTEAD of the favorite + offline columns.
in property <bool> show-source: false;
in property <bool> 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 <bool> show-menu: true;
// Library surfaces (LocalLibrary Tracks tab / local album detail /
// folder detail) force the LOCAL menu even for rows whose source
// renders as "qobuz" (qobuz_download offline copies): Tauri shows the
// same minimal local menu there, and the Qobuz block's arms are dead
// on library row ids (spec §3.4). The SourceGlyph keeps reading the
// unchanged source field.
in property <bool> force-local-menu: false;
// Playlist-detail per-row "Remove from playlist" — the surface gates
// it (PlaylistView: ownership, spec §3.1). Default off everywhere else.
in property <bool> remove-from-playlist-action: false;
// Track Info menu entry visibility — a surface whose Track Info is
// dead hides it (spec §3.3); default on keeps other surfaces unchanged.
in property <bool> track-info-action: true;
// Go to Album / Go to Artist on LOCAL rows (owner improvement over
// Tauri, which omits both there): defaults ON for every persistent
// row of a force-local-menu surface (LocalLibrary Tracks/folder
// detail, local album detail — their Rust arms route by row source);
// mixed surfaces (playlist detail) opt rows in explicitly, because
// only RESOLVED local/plex sidecar rows have navigable keys (the
// "file:"/"broken:"/unresolved fallbacks must keep the entries
// hidden — no dead clicks).
in property <bool> local-goto-actions: root.force-local-menu
&& root.track.source != "ephemeral";
// Custom-order reorder controls (up/down chevrons) at the leading
// edge — used by the playlist custom sort.
in property <bool> show-reorder: false;
// Drag-reorder (custom-order lists, issue #589). Mirrors QueueRow's
// drag contract: the grip handle in the reorder gutter reports the
// gesture in WINDOW coords; the parent view owns the list-level state.
// NOT the row-body drag (ta + DragActions = drag onto sidebar
// playlists) — the handle has its OWN TouchArea.
in property <int> reorder-index;
in property <bool> reorder-source: false; // this row is being dragged
callback reorder-begin(int /* visible index */, length /* row window-top */,
length /* pointer window-x */, length /* pointer window-y */);
callback reorder-update(length /* pointer window-x */, length /* pointer window-y */);
callback reorder-end();
// Whether the row can be dragged onto sidebar playlists.
in property <bool> draggable: true;
callback media-action(string, string, string);
// Explicit track number, else the 1-based list position.
property <string> 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 <bool> menu-hovered: false;
// Right-click context mode (spec §3.5): the popup opens at the pointer
// instead of the "..." anchor. ta fills the row, so ta.mouse-x/y are
// already the popup's parent-relative coordinates.
property <bool> menu-at-pointer: false;
property <length> menu-click-x;
property <length> menu-click-y;
property <bool> 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: root.multi-select-mode ? pointer : 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 => {
// Blacklisted rows are inert — no play (Tauri click short-circuit).
if (root.row-inert) {
return;
}
// In select mode a double-click just selects (via `clicked`); no play.
if (root.multi-select-mode) {
return;
}
if (NowPlayingState.has-track && NowPlayingState.track-id == root.track.id) {
NowPlayingState.toggle-play();
} else {
root.media-action("track", root.track.id, "play");
}
}
// In multi-select mode the WHOLE row body is a selection target (Tauri
// whole-row selectable). It routes the same media-action the checkbox
// does, so the central toggle arm reads the modifier — Shift+Click range
// and Ctrl+Click work on the body too. The "..." menu button + checkbox
// keep their own handlers; the other in-row controls (artist/album link,
// heart, offline) are disabled in select mode so their click passes
// through to here. Inert (blacklisted) rows never select.
clicked => {
if (root.multi-select-mode && !root.row-inert) {
root.media-action("track", root.track.id, "toggle-select");
}
}
property <length> down-x;
property <length> down-y;
property <bool> 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();
}
// Right-click opens the same context menu at the pointer (Tauri's
// contextmenu mode, spec §3.5). Suppressed in multi-select mode
// (Tauri parity: row right-click toggles nothing there) and on
// menu-less rows (ephemeral). A blacklisted (inert) row STILL
// opens it — reduced to nav-only (Go to album/artist, Track info)
// via track-menu.nav-only below, so the user keeps navigation.
// x clamps to the row so the menu never overflows past the "..."
// gutter; vertical overflow at the window bottom matches the
// existing "..." anchor behavior.
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right
&& root.show-menu && !root.multi-select-mode) {
root.menu-at-pointer = true;
root.menu-click-x = ta.mouse-x;
root.menu-click-y = ta.mouse-y;
track-menu.show();
}
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 => {
// Blacklisted rows are not draggable (Tauri `draggable=false`).
if (ta.pressed && root.draggable && !root.row-inert) {
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 gutter (custom-order mode) — leading edge: a drag GRIP
// handle next to the up/down chevrons. The grip's TouchArea is
// deliberately separate from the row-body `ta` (which drives the
// drag-onto-sidebar-playlists ghost via DragActions) — this one
// implements the queue's drag-reorder gesture (QueueRow) and
// reports in window coords via the reorder-* callbacks.
if root.show-reorder: HorizontalLayout {
spacing: 2px;
VerticalLayout {
alignment: center;
Rectangle {
width: 18px;
height: 36px;
border-radius: Radius.sm;
background: grip-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/grip-vertical.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: grip-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
grip-ta := TouchArea {
mouse-cursor: grab;
property <length> down-y;
property <bool> dragging: false;
pointer-event(e) => {
if (e.kind == PointerEventKind.down) {
// absolute-position double-counts the ROW's own
// layout offset inside the ListView; subtracting
// root.{y} recovers true window coords — and the
// correction propagates unchanged to this nested
// element (if/for offsets are exact). See
// reference_slint_absolute_position_flickable.
self.down-y = self.absolute-position.y - root.y + self.mouse-y;
self.dragging = false;
}
if (e.kind == PointerEventKind.up && self.dragging) {
root.reorder-end();
self.dragging = false;
}
}
// Safety net: the playlist ListView stays scrollable, so
// it can steal the grab mid-drag and `up` never arrives.
// `pressed` flips false on release AND grab-cancel.
changed pressed => {
if (!self.pressed && self.dragging) {
root.reorder-end();
self.dragging = false;
}
}
moved => {
if (self.pressed) {
if (!self.dragging
&& Math.abs(self.absolute-position.y - root.y + self.mouse-y - self.down-y) > 6px) {
self.dragging = true;
root.reorder-begin(root.reorder-index,
root.absolute-position.y - root.y,
self.absolute-position.x - root.x + self.mouse-x,
self.absolute-position.y - root.y + self.mouse-y);
}
if (self.dragging) {
root.reorder-update(
self.absolute-position.x - root.x + self.mouse-x,
self.absolute-position.y - root.y + self.mouse-y);
}
}
}
}
}
}
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;
// Secondary column — must read SMALLER than the track title
// (Typography.link, 14px), not bigger. Was Typography.body (15px).
font-size: Typography.legal;
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 && !root.row-inert;
// Blacklisted rows swap the play/number cell for the
// blacklist glyph below, so hide the play affordance.
visible: !root.multi-select-mode && !root.row-inert;
play => {
// Inert guard (the cell is hidden when inert, but keep
// the action gated regardless).
if (root.row-inert) {
return;
}
root.media-action("track", root.track.id, "play");
}
}
// Blacklist glyph — replaces the number/play affordance on a
// blacklisted (dimmed, inert) row. A bare hover TouchArea drives
// the shared tooltip ("Artist is blacklisted") without making
// the cell interactive (no play). Mirrors Tauri's number-cell
// Ban icon with title="Artist is blacklisted" (TrackRow.svelte).
if root.row-inert: Rectangle {
width: parent.width;
height: parent.height;
QbzIcon {
source: @image-url("../assets/icons/blind-eye.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: Theme.text-muted;
}
blacklist-ta := TouchArea {
mouse-cursor: not-allowed;
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Artist is blacklisted");
// Anchor on the glyph cell itself (centre-x,
// top-y), matching the CircleAction tooltip
// convention (window coords).
TooltipState.anchor-x = self.absolute-position.x + self.width / 2;
TooltipState.anchor-y = self.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
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 <bool> 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;
// Title line — femtovg measures some scripts' runs at ~0 (Korean
// at Typography.link; Japanese measures fine), so NOTHING about
// this line may be content-measured or the line collapses and the
// glyphs have nowhere to paint. The first failed fix assigned the
// WIDTH (stretch Rectangle) but missed that `alignment: center`
// on this column sizes each line to its preferred HEIGHT — the
// Korean line still collapsed to ~0 tall and its `clip: true`
// erased the glyphs. SongCard parity (the one surface where
// Korean renders): a plain Rectangle direct in the column, NO
// clip, Text width assigned — plus a FIXED height here, so both
// axes are layout-assigned and measurement is never trusted.
title-line := Rectangle {
height: 20px;
title-text := Text {
x: 0;
// Reserve the badge slot (16px + 6px gap) when explicit
// so the elide stops before the badge, never under it.
width: parent.width - (root.track.explicit ? 22px : 0px);
height: 100%;
text: root.track.title;
color: Theme.text-primary;
font-size: Typography.link;
font-weight: Typography.medium;
vertical-alignment: center;
overflow: elide;
}
// Explicit "E" badge — hugs the END of the title text (Tauri's
// .track-title-row: flex, gap 6px, badge flex-shrink:0), not
// the column's far right. preferred-width is only used to
// POSITION the badge (the title's own width stays assigned,
// so a bogus ~0 measurement can't blank it); when the run
// measures ~0 with a non-empty title (the femtovg Korean
// case), hugging is impossible, so the badge parks in the
// right-edge slot the title width already reserves.
if root.track.explicit: Rectangle {
x: (root.track.title != "" && title-text.preferred-width < 1px)
? title-text.width + 6px
: Math.min(title-text.preferred-width + 6px,
title-text.width + 6px);
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
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;
// disabled in select mode so the click selects the row.
enabled: root.track.artist-id != "" && !root.multi-select-mode;
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 != "" && !root.multi-select-mode;
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;
// Secondary metadata — must read SMALLER than the track title
// (Typography.link, 14px), not bigger. Was Typography.body (15px),
// which made the duration larger than the title — illogical.
font-size: Typography.legal;
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 && !root.row-inert: 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
// Dedicated favorite token (not the generic accent):
// a stable >=3:1-vs-row color in every theme, and the
// intended heart fill. The filled/outline SVG swap is
// the primary 1.4.1 shape cue; this keeps the fill from
// flattening against accent in High-Contrast.
? Theme.favorite
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-muted);
}
fav-ta := TouchArea {
// Disabled in select mode so the click selects the row.
enabled: !root.multi-select-mode;
mouse-cursor: pointer;
clicked => {
root.media-action("track", root.track.id, "favorite");
}
}
}
}
// Heart-slot placeholder (column-grid alignment, see the
// reserve-favorite doc above). Also reserved when a show-favorite
// surface hides the heart on a blacklisted row, so the dimmed row
// keeps the same duration/quality grid as its siblings.
if (!root.show-favorite && root.reserve-favorite)
|| (root.show-favorite && root.row-inert): Rectangle {
width: 28px;
}
// 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) && !root.row-inert: 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: ((ShellState.reduce-motion
? ShellState.coarse-tick-ms * 1.0
: 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 {
// Disabled in select mode so the click selects the row.
enabled: !root.multi-select-mode;
mouse-cursor: pointer;
clicked => {
root.media-action("track", root.track.id,
root.track.cache-status == 3 ? "uncache" : "cache");
}
}
}
}
// Offline-slot placeholder on a blacklisted row whose surface would
// otherwise show the offline button — keeps the dimmed row's grid
// aligned with its download-bearing siblings.
if root.row-inert && (root.show-download || root.track.cache-status > 0): Rectangle {
width: 28px;
}
// 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" || root.track.source == "qobuz_purchase"
? @image-url("../assets/icons/qobuz-logo-filled.svg")
: @image-url("../assets/icons/hard-drive.svg"));
// `qobuz_purchase` → GOLD-tinted Qobuz mark (§8.7 purchase badge).
colorize: root.track.source == "qobuz_purchase" ? #eab308
: (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 trigger. KEPT on a blacklisted (inert) row — the menu
// it opens is reduced to nav-only (Go to album/artist, Track info) via
// track-menu.nav-only, so the row still offers navigation while every
// play/queue/mutating entry is gone. Mirrors Tauri keeping a reduced
// nav-only menu on a blacklisted row (the T5 deviation that hid the
// whole menu is reverted here).
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 => {
root.menu-at-pointer = false;
track-menu.show();
}
changed has-hover => {
root.menu-hovered = self.has-hover;
}
}
}
}
}
// True for rows whose source is Qobuz-backed (Qobuz surfaces + offline
// copies); the menu gates below split on it.
property <bool> qobuz-row: root.track.source != "local"
&& root.track.source != "plex"
&& root.track.source != "ephemeral";
track-menu := TrackContextMenu {
// Anchored at the "..." gutter, or at the right-click pointer in
// context mode (spec §3.5).
x: root.menu-at-pointer
? Math.max(0px, Math.min(root.menu-click-x, root.width - self.width - 12px))
: parent.width - self.width - 12px;
y: root.menu-at-pointer ? root.menu-click-y : 48px;
track-id: root.track.id;
// Blacklisted (inert) rows get the nav-only reduction: only Go to
// album / Go to artist / Track info, no play/queue/mutating entries.
nav-only: root.row-inert;
// Per-source menu logic: the Qobuz block (radio, share, offline,
// go-to, track-info) only for Qobuz/offline rows, and never on
// library surfaces (force-local-menu — its arms are dead on library
// row ids, spec §3.4); transient play/queue always.
qobuz-actions: !root.force-local-menu && root.qobuz-row;
// Favorite: on library surfaces only qobuz_download rows (source
// renders "qobuz") get the entry, wired to the REAL Qobuz id in
// Rust. Plain local/Plex rows get NO favorite entry — deliberate
// deviation from Tauri, whose "Add to Library" there sends the
// LOCAL row id to the Qobuz favorites API (spec §1.4/§3.2 latent
// bug): we port the intent, not the bug.
favorite-action: root.force-local-menu
? root.track.source == "qobuz"
: root.qobuz-row;
// Add to Mixtape/Collection on library surfaces for every
// persistent row (local + Plex + qobuz_download; spec §3.1 —
// Tauri's Tracks-tab-only asymmetry is an artifact, not a design).
mixtape-action: root.force-local-menu
? root.track.source != "ephemeral"
: root.qobuz-row;
// Add-to-playlist for Qobuz/offline/local/Plex (local playlists
// store each source's own ref), not ephemeral (no persistent row
// to reference). Deliberately NOT hidden offline (deviation from
// Tauri's shouldHidePlaylistFeatures, spec §3.3): our local
// playlists accept tracks offline (D7/D8) and the picker already
// hides the Qobuz set offline.
playlist-action: root.track.source != "ephemeral";
remove-from-playlist-action: root.remove-from-playlist-action;
track-info-action: root.track-info-action;
// Go to album / artist: the Qobuz block's pair, ALSO enabled on
// local-surface rows (local-goto-actions doc above) — the Rust
// arms route by source (local album view / LocalLibrary artist
// for local+plex, the real Qobuz pages for qobuz_download).
go-album-action: self.qobuz-actions || root.local-goto-actions;
go-artist-action: self.qobuz-actions || root.local-goto-actions;
// Cached-state-aware offline entry (Refresh/Remove when cached) —
// straight from the row model.
cache-status: root.track.cache-status;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}