Files
qbz/crates/qbz-slint/ui/primitives/TrackRow.slint
T
vicrodh 34eaebd8ef feat(slint): play-from-clicked-track, track-row quality badge, album link, playlist layout
Committed together because these changes share main.rs, playlist.rs and
PlaylistView.slint, and the new quality field is atomic across every
TrackItem builder, so they cannot compile as separate whole-file commits.

- Universal play-from-here: a single play_track_in_context dispatcher builds
  the queue from the current view's VISIBLE list and starts at the clicked
  track (playlist, album, artist, favorites, label, mix, search). Cached
  views order by the visible model; re-fetch views (album / artist top
  tracks) reorder to the in-page filter; the search "most popular" hero
  prepends to the results queue. Drops the playlist-track / mix-track arms
  that always started at track 1 (the clicked id was read from the wrong
  media-action slot).

- Album-name links in track rows open the album page (the ("album","open")
  media-action arm was missing and fell through to a no-op).

- Unified track-row quality badge: a new quality-detail field on TrackItem,
  populated from bit-depth / sample-rate in every builder through a shared
  crate::quality::detail, rendered with a bare (no chip background/border,
  centered text) QualityBadgeFull. Restores the exact bit-depth / sample-rate
  line that had regressed to a plain CD / Hi-Res tier.

- Playlist track list: reserve the favorite / download / context-menu columns
  in the column header, match the row's 36px cover cell, and anchor the
  scrollbar to the view edge (matches Local Library > Tracks).

cc
2026-05-31 21:50:51 -06:00

547 lines
24 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 } 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;
// 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 <int> index;
in property <bool> show-artwork: true;
in property <bool> show-album: false;
in property <bool> 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 <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;
// Custom-order reorder controls (up/down chevrons) at the leading
// edge — used by the playlist custom sort.
in property <bool> show-reorder: false;
// 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}";
property <bool> row-hovered:
ta.has-hover || play-cell.hovered || more-ta.has-hover;
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 <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();
}
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
image-fit: contain;
}
}
}
// Context 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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-menu.show();
}
}
}
}
}
track-menu := TrackContextMenu {
x: parent.width - self.width - 12px;
y: 48px;
track-id: root.track.id;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}