LocalLibrary track rows (Tracks tab + local album detail) no longer carry the favorite or offline-cache columns — they don't apply to local content. Instead they show a subtle per-source indicator (local -> hard-drive tinted; qobuz offline / plex -> their mark), small + flat rather than the bold album-card badge (mirrors Tauri's track-row local indicator). TrackItem gains a `source` field, set by every constructor (Qobuz surfaces -> "qobuz"; map_local_track -> local/qobuz/plex); TrackRow gains `show-source`. Also fixes Qobuz AlbumView: the track list had a Favorite column header but the rows never rendered the heart (show-favorite defaulted false). AlbumPageView now sets show-favorite + show-download for Qobuz albums (matching the header) and the source indicator for local albums, with the column header switched to match each. cc
517 lines
22 KiB
Plaintext
517 lines
22 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;
|
|
// 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) => {
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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 — 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);
|
|
}
|
|
}
|
|
}
|