The multi-select checkbox circle was 22px and read as oversized. Drop it to 13px (border-radius 6.5px) with an 8px check; the existing (parent - self)/2 centering keeps it centered in the play/number cell. cc
425 lines
17 KiB
Plaintext
425 lines
17 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 } 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;
|
|
// 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) => {
|
|
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: 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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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 — 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.
|
|
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);
|
|
}
|
|
}
|
|
}
|