Files
qbz/crates/qbz-slint/ui/primitives/TrackRow.slint
T
vicrodh de826bf560 feat(slint): playlist detail polish, sort (incl. custom) + WIP track drag
Polish + consistency:
  - Compact playlist header (cover 150px; nav->header spacing
    homologated with AlbumView); description truncated to ~2 lines +
    "Read more" modal; custom-artwork buttons only on cover hover.
  - New Typography.section (18px) token for hero/section titles,
    applied across Album / Artist / Mix / Playlist + Home carousels.
  - Shorter track rows app-wide (50px row, 36px artwork, -1 fonts);
    +3px column-header/divider gap; wider track-number column.
  - Reusable ExpandableSearch (the JumpNavBar page-search pattern:
    magnifier -> opens left -> X to close) in the playlist action row.
  - Custom ListScrollbar (thicker, min thumb height, own gutter) since
    Slint's ListView scrollbar can't be styled.

Sort:
  - Default / Title / Artist / Album / Duration with asc/desc per
    field, plus Custom (manual order persisted in library.db with
    up/down reorder). Client-side search + sort combined.

WIP: track drag-and-drop onto sidebar playlists
  - DragState/DragActions globals, drag source on the shared TrackRow,
    follow-cursor DragGhost, sidebar playlist rows self-detect the drop
    via absolute-position, multi-select group drag, add_tracks_to_playlist
    on drop, Flickables freeze scroll during a drag.
  - KNOWN ISSUE: the ghost is offset from the cursor (the row
    absolute-position + pointer mapping is off, esp. inside Flickables).
    Diagnostic logging is left in (TrackRow debug() + a start log) to
    pin down the wrong term before the generic fix.

cc
2026-05-22 18:26:55 -06:00

370 lines
14 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 } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { TrackPlayCell } from "TrackPlayCell.slint";
import { TrackContextMenu } from "TrackContextMenu.slint";
export component TrackRow inherits Rectangle {
in property <TrackItem> track;
in property <int> index;
in property <bool> show-artwork: true;
in property <bool> show-album: false;
in property <bool> show-favorite: false;
in property <bool> show-download: 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;
property <length> down-x;
property <length> down-y;
property <bool> dragging: false;
pointer-event(e) => {
if (e.kind == PointerEventKind.down) {
ta.down-x = root.absolute-position.x + ta.mouse-x;
ta.down-y = root.absolute-position.y + ta.mouse-y;
ta.dragging = false;
}
if (e.kind == PointerEventKind.up && ta.dragging) {
DragActions.end();
ta.dragging = false;
}
}
moved => {
if (ta.pressed && root.draggable) {
if (!ta.dragging
&& (Math.abs(root.absolute-position.x + ta.mouse-x - ta.down-x) > 6px
|| Math.abs(root.absolute-position.y + ta.mouse-y - ta.down-y) > 6px)) {
ta.dragging = true;
debug("DRAG absx=", root.absolute-position.x, " absy=", root.absolute-position.y,
" mx=", ta.mouse-x, " my=", ta.mouse-y);
DragActions.start(
root.track.id,
root.track.title,
root.track.album != ""
? root.track.artist + " · " + root.track.album
: root.track.artist,
root.absolute-position.x + ta.mouse-x,
root.absolute-position.y + ta.mouse-y);
}
if (ta.dragging) {
DragActions.move(
root.absolute-position.x + ta.mouse-x,
root.absolute-position.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: 22px;
height: 22px;
border-radius: 11px;
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: 14px;
height: 14px;
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");
}
}
}
}
}
// 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;
}
}
}
}
if root.track.artist != "": Text {
text: root.track.artist;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
// Album column.
if root.show-album: Text {
text: root.track.album;
width: 220px;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
overflow: elide;
}
// Duration.
Text {
text: root.track.duration;
width: 70px;
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
vertical-alignment: center;
}
// Quality.
Text {
text: root.track.quality-tier == "hires"
? "Hi-Res"
: (root.track.quality-tier == "cd" ? "CD" : "");
width: 70px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
}
// Favorite.
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: @image-url("../assets/icons/heart.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: 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.
if root.show-download: VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
border-radius: Radius.sm;
background: dl-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/cloud-download.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: dl-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
dl-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("track", root.track.id, "download");
}
}
}
}
// 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);
}
}
}