Files
qbz/crates/qbz-ui/ui/playlist/PlaylistView.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

1455 lines
71 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Playlist detail view. Same anatomy as AlbumView / MixView: a header
// (cover + "Playlist" label + name + description + "owner • N tracks •
// duration" info line + circular actions) over a column-headed track
// list of shared TrackRows (show-artwork + show-album).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { PlaylistState, PlaylistActions, DragState, NavState, OfflineState, BulkAction, SettingsState } from "../state.slint";
import { PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, TooltipState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { ListView } from "std-widgets.slint";
// One row in the Sort menu. The active field shows its direction arrow
// (asc/desc); re-selecting it toggles direction.
component SortOption inherits Rectangle {
in property <string> label;
in property <bool> active: false;
in property <bool> asc: true;
callback chosen();
height: 30px;
border-radius: 5px;
background: so-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 10px;
padding-right: 8px;
spacing: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
alignment: center;
if root.active: QbzIcon {
source: root.asc
? @image-url("../assets/icons/chevron-up.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
}
}
}
so-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.chosen();
}
}
}
// Small ghost icon button for the "Suggested Songs" rows (info / add /
// dismiss) and the section refresh. Surface-hover fill on hover (NOT a pill —
// a 4px-rounded square; ADR-008). Mirrors the Svelte `.action-btn`.
component SuggestionActionButton inherits Rectangle {
in property <image> icon;
in property <color> icon-tint: Theme.text-muted;
in property <color> hover-bg: Theme.surface-hover;
in property <color> hover-tint: Theme.text-primary;
in property <bool> enabled: true;
in property <length> diameter: 28px;
in property <length> glyph: 15px;
in property <string> tooltip;
// Exposed so the row can OR it into its `row-hovered` (a child TouchArea
// otherwise steals the row's hover, making the reveal flicker off).
out property <bool> hovered: sab-ta.has-hover;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: 4px;
opacity: root.enabled ? 1.0 : 0.45;
background: (sab-ta.has-hover && root.enabled) ? root.hover-bg : transparent;
animate background { duration: 150ms; }
QbzIcon {
source: root.icon;
width: root.glyph;
height: root.glyph;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: (sab-ta.has-hover && root.enabled) ? root.hover-tint : root.icon-tint;
}
sab-ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
// One "Suggested Songs" row — 40px cover (play on hover), title + clickable
// artist · album meta, duration, and the info / add / dismiss actions (shown
// on row hover). 1:1 with the Svelte `.suggestion-row`.
component PlaylistSuggestionRowView inherits Rectangle {
in property <PlaylistSuggestionRow> entry;
// OR the background detector with every interactive child's hover so the
// cover-play overlay + the trailing actions stay revealed (and clickable)
// when the pointer moves onto them (mirrors TrackRow.row-hovered).
property <bool> row-hovered:
psr-ta.has-hover || play-ta.has-hover
|| info-btn.hovered || add-btn.hovered || dismiss-btn.hovered;
height: 56px;
border-radius: 8px;
background: root.row-hovered ? Theme.surface-hover : transparent;
animate background { duration: 150ms; }
// Background hover detector (below the interactive children).
psr-ta := TouchArea {
mouse-cursor: default;
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
// Artwork + hover play overlay.
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
Rectangle {
width: 40px;
height: 40px;
border-radius: 4px;
clip: true;
background: Theme.surface-elevated;
Image {
source: root.entry.artwork;
image-fit: cover;
width: parent.width;
height: parent.height;
}
play-ov := Rectangle {
width: 100%;
height: 100%;
background: #00000080;
opacity: root.row-hovered ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
play-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
PlaylistSuggestionsActions.play-track(root.entry.track-id);
}
}
}
}
}
// Title + meta (artist · album, clickable links).
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
spacing: 2px;
Text {
text: root.entry.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
HorizontalLayout {
spacing: 4px;
alignment: start;
// Artist link (content width; clickable when artist-id set).
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
artist-ta := TouchArea {
width: artist-txt.preferred-width;
height: artist-txt.preferred-height;
mouse-cursor: root.entry.artist-id != "" ? pointer : default;
enabled: root.entry.artist-id != "";
clicked => {
PlaylistSuggestionsActions.go-artist(root.entry.artist-id);
}
artist-txt := Text {
text: root.entry.artist-name;
color: (artist-ta.has-hover && root.entry.artist-id != "")
? Theme.accent : Theme.text-muted;
font-size: Typography.legal;
}
}
}
if root.entry.album-title != "": Text {
text: "·";
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
// Album link (stretches + elides in the remaining space).
if root.entry.album-title != "": album-ta := TouchArea {
horizontal-stretch: 1;
mouse-cursor: root.entry.album-id != "" ? pointer : default;
enabled: root.entry.album-id != "";
clicked => {
PlaylistSuggestionsActions.go-album(root.entry.album-id);
}
album-txt := Text {
width: parent.width;
text: root.entry.album-title;
color: (album-ta.has-hover && root.entry.album-id != "")
? Theme.accent : Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
}
}
// Duration.
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
Text {
text: root.entry.duration-label;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: right;
width: 44px;
}
}
// Actions (info / add / dismiss) — fade in on row hover. The buttons'
// own hover feeds root.row-hovered so the cluster stays up while used.
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
HorizontalLayout {
spacing: 4px;
opacity: root.row-hovered ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
info-btn := SuggestionActionButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
clicked => {
PlaylistSuggestionsActions.show-info(root.entry.track-id);
}
}
add-btn := SuggestionActionButton {
icon: root.entry.added
? @image-url("../assets/icons/check.svg")
: @image-url("../assets/icons/plus.svg");
icon-tint: Theme.accent;
hover-bg: Theme.accent;
hover-tint: #ffffff;
enabled: !root.entry.adding && !root.entry.added;
tooltip: @tr("Add to playlist");
clicked => {
PlaylistSuggestionsActions.add-track(root.entry.track-id);
}
}
dismiss-btn := SuggestionActionButton {
icon: @image-url("../assets/icons/x.svg");
enabled: !root.entry.adding && !root.entry.added;
tooltip: @tr("Not interested");
clicked => {
PlaylistSuggestionsActions.dismiss-track(root.entry.track-id);
}
}
}
}
}
}
export component PlaylistView inherits Rectangle {
callback play-track(string);
callback play-all();
callback shuffle();
callback toggle-favorite();
callback edit();
callback media-action(string, string, string);
// Read-more description modal.
property <bool> show-description: false;
// "Upload to Qobuz" confirm modal (local playlists, D8).
property <bool> show-upload-confirm: false;
// Bulk-bar action sets (typed properties — a ternary over bare array
// literals can't coerce to [BulkAction]). Tauri parity (spec §1.5):
// Play next / Add to queue / Add to playlist over the selection,
// Remove owner-only — followed playlists keep the queueing actions.
property <[BulkAction]> bulk-actions-owner: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
];
property <[BulkAction]> bulk-actions-follower: [
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
{ id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
{ id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
];
// Label for the active sort field.
property <string> sort-label:
PlaylistState.sort-field == "title" ? @tr("Title")
: PlaylistState.sort-field == "artist" ? @tr("Artist")
: PlaylistState.sort-field == "album" ? @tr("Album")
: PlaylistState.sort-field == "duration" ? @tr("Duration")
: PlaylistState.sort-field == "added" ? @tr("Date added")
: PlaylistState.sort-field == "custom" ? @tr("Custom")
: @tr("Default");
// --- Custom-order drag-reorder state (issue #589) -------------------
// Slot bookkeeping is component-LOCAL; the floating ghost REUSES the
// app-wide DragGhost via the DragState global (with DragState.reorder
// set, so sidebar rows skip drop-target detection). DragActions is
// never called, so the Rust add-to-playlist drop path never runs.
// All indices are VISIBLE-list indices into PlaylistState.tracks.
property <bool> reorder-active: false;
property <int> reorder-from: -1; // visible index of the dragged row
property <int> reorder-over: -1; // insertion slot 0..N
// Window-Y of the track ListView's TOP EDGE, captured at drag-begin
// (row window-top - row*50px - viewport-y). Unlike the queue's fixed
// list-top, the live viewport-y is folded back in below so the slot
// math stays scroll-aware.
property <length> reorder-list-top;
// Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 50px
// stacked with 0 spacing; the +0.5 rounds boundaries to row centers
// (top half of row k -> slot k, bottom half -> slot k+1).
pure function slot-from-pointer(win-y: length) -> int {
return Math.max(0, Math.min(PlaylistState.tracks.length,
Math.floor((win-y - root.reorder-list-top - track-list.viewport-y) / 50px + 0.5)));
}
function commit-reorder() {
root.reorder-active = false;
// Release the shared ghost.
DragState.active = false;
DragState.reorder = false;
// Fire only a real move. `to == from` and `to == from+1` both drop
// back onto the same gap (no-op).
if (root.reorder-over != root.reorder-from
&& root.reorder-over != root.reorder-from + 1) {
PlaylistActions.reorder-track(root.reorder-from, root.reorder-over);
}
root.reorder-from = -1;
root.reorder-over = -1;
}
background: Theme.surface-main;
page := VerticalLayout {
// Fill PlaylistView so the ListView's vertical-stretch has
// real height (otherwise it collapses to a single row).
width: 100%;
height: 100%;
padding-left: 32px;
// Smaller right padding so the track list + scrollbar use
// the space instead of leaving a dead band on the right.
padding-right: 16px;
padding-top: 11px;
padding-bottom: 16px;
spacing: 0px;
HorizontalLayout {
vertical-stretch: 0;
alignment: start;
NavButtons { }
}
// Same nav -> header separation as AlbumView (homologated).
Rectangle { height: 22px; }
// --- Header ------------------------------------------------
HorizontalLayout {
vertical-stretch: 0;
spacing: 24px;
alignment: start;
// Cover — custom artwork if set, else a 4-up collage of
// the first tracks' covers (Tauri parity), else the
// server playlist image, else the first track, else a
// placeholder.
cover-box := Rectangle {
width: 150px;
height: 150px;
border-radius: Radius.md;
background: Theme.surface-elevated;
clip: true;
// Hover state — drives the artwork overlay (only
// shown on mouse-over). OR'd with the overlay button so
// moving onto it keeps the overlay visible.
property <bool> hovered:
cover-hover.has-hover || cover-btn-ta.has-hover;
// Custom artwork / single server image.
property <bool> show-single:
PlaylistState.has-custom
|| (PlaylistState.track-count < 4 && PlaylistState.cover-url != "");
property <bool> show-collage:
!PlaylistState.has-custom && PlaylistState.track-count >= 4;
if cover-box.show-single: Image {
width: 100%;
height: 100%;
source: PlaylistState.cover;
image-fit: cover;
}
// 2x2 collage from the first four track covers.
if cover-box.show-collage: VerticalLayout {
spacing: 2px;
HorizontalLayout {
spacing: 2px;
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[0].artwork;
image-fit: cover;
}
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[1].artwork;
image-fit: cover;
}
}
HorizontalLayout {
spacing: 2px;
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[2].artwork;
image-fit: cover;
}
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[3].artwork;
image-fit: cover;
}
}
}
// Single first-track cover when 1-3 tracks and no
// server image.
if !cover-box.show-single && !cover-box.show-collage
&& PlaylistState.track-count >= 1: Image {
width: 100%;
height: 100%;
source: PlaylistState.tracks[0].artwork;
image-fit: cover;
}
// Empty placeholder.
if PlaylistState.track-count == 0 && PlaylistState.cover-url == ""
&& !PlaylistState.has-custom: QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 56px;
height: 56px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
// Hover detector (below the overlay buttons so they
// stay clickable). Owner only.
cover-hover := TouchArea {
mouse-cursor: PlaylistState.is-owner ? pointer : default;
enabled: PlaylistState.is-owner;
}
// Hover overlay — a centered "change cover" affordance
// over the whole artwork, mirroring Tauri's
// `.artwork-overlay`: a dim scrim that fades in on
// hover with a single centered button. ImagePlus when
// no custom art is set (sets one); X when one is
// (clears it). Owner only. The button stays in the tree
// (its TouchArea name must be in scope for `hovered`),
// its glyph + action just switch on `has-custom`.
cover-overlay := Rectangle {
opacity: (PlaylistState.is-owner && cover-box.hovered) ? 1.0 : 0.0;
background: #00000066;
animate opacity { duration: 150ms; }
cover-btn := Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
background: cover-btn-ta.has-hover
? (PlaylistState.has-custom ? #ef4444 : Theme.accent)
: #000000aa;
QbzIcon {
source: PlaylistState.has-custom
? @image-url("../assets/icons/x.svg")
: @image-url("../assets/icons/image-plus.svg");
width: PlaylistState.has-custom ? 20px : 22px;
height: PlaylistState.has-custom ? 20px : 22px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
cover-btn-ta := TouchArea {
mouse-cursor: pointer;
// Only interactive while the overlay is up
// (and the user owns the playlist).
enabled: PlaylistState.is-owner && cover-box.hovered;
clicked => {
if (PlaylistState.has-custom) {
root.media-action("playlist", "", "clear-artwork");
} else {
root.media-action("playlist", "", "set-artwork");
}
}
}
}
}
}
// Metadata.
VerticalLayout {
alignment: start;
padding-top: 4px;
horizontal-stretch: 1;
Text {
text: @tr("Playlist");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
Rectangle { height: 4px; }
Text {
text: PlaylistState.name;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
wrap: word-wrap;
}
if PlaylistState.description != "": Rectangle { height: 6px; }
if PlaylistState.description != "": Text {
width: root.width - 250px;
text: PlaylistState.description-short;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if PlaylistState.description != PlaylistState.description-short: Rectangle { height: 3px; }
if PlaylistState.description != PlaylistState.description-short: read-more := TouchArea {
width: rm-row.preferred-width;
height: 18px;
mouse-cursor: pointer;
clicked => {
root.show-description = true;
}
rm-row := HorizontalLayout {
Text {
text: @tr("Read more");
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
Rectangle { height: 8px; }
Text {
text: PlaylistState.owner
+ " • " + PlaylistState.track-count + " " + @tr("tracks")
+ " • " + PlaylistState.total-duration;
color: Theme.text-secondary;
font-size: Typography.legal;
}
Rectangle { height: 14px; }
// Actions — buttons left, search + sort floating right.
HorizontalLayout {
vertical-stretch: 0;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.play-all();
}
}
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.shuffle();
}
}
}
// Favorite — a Qobuz concept; hidden for LOCAL
// playlists (no server entity to favorite).
if !PlaylistState.is-local: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
active: PlaylistState.is-favorite;
clicked => {
root.toggle-favorite();
}
}
}
// Follow on Qobuz (subscribe) — only a non-owned Qobuz
// playlist; syncs to the user's Qobuz account.
if !PlaylistState.is-local && !PlaylistState.is-owner: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
// The icon reflects the follow STATE so it reads at
// a glance and FLIPS on click (the action feedback):
// a check (accent-tinted via `active`) when you
// follow, the user-plus "add" glyph when you don't.
// The tooltip states the ACTION (mirrors Tauri).
icon: PlaylistState.is-following
? @image-url("../assets/icons/check.svg")
: @image-url("../assets/icons/user-plus.svg");
active: PlaylistState.is-following;
tooltip: PlaylistState.is-following ? @tr("Unfollow on Qobuz") : @tr("Follow on Qobuz");
clicked => {
root.media-action("playlist", PlaylistState.id, "follow");
}
}
}
// Copy to your library — clone a non-owned Qobuz playlist
// into your own playlists (hidden once copied).
if !PlaylistState.is-local && !PlaylistState.is-owner
&& !PlaylistState.is-copied: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/copy.svg");
tooltip: @tr("Copy to your library");
clicked => {
root.media-action("playlist", PlaylistState.id, "copy");
}
}
}
// Make the whole playlist available offline —
// resolves Qobuz membership, hidden for LOCAL ones.
if !PlaylistState.is-local: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cloud-download.svg");
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.media-action("playlist", PlaylistState.id, "cache");
}
}
}
// Upload to Qobuz (D8) — LOCAL playlists that are
// NOT offline-only, while online. Asks first; the
// local entity is deleted after a full upload.
if PlaylistState.is-local && !PlaylistState.offline-only
&& !OfflineState.offline: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/cloud-upload.svg");
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.show-upload-confirm = true;
}
}
}
// Edit (rename / delete) — only the user's own.
if PlaylistState.is-owner: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pen-line.svg");
clicked => {
root.edit();
}
}
}
// Select tracks (multi-select edit mode). NOT
// ownership-gated (Tauri parity, spec §1.5: followed
// playlists get select mode for bulk queueing too);
// only the bar's Remove action is owner-gated.
if PlaylistState.tracks.length > 0: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: PlaylistState.multi-select-mode;
clicked => {
root.media-action("playlist", "", "select-toggle");
}
}
}
// "Suggest songs" — generates the Suggested Songs
// section below the track list. Owner-only and hidden at
// >= 2000 tracks (the Qobuz playlist cap, can't add
// more). Manual activation mirrors the Svelte (it never
// auto-computes — large playlists shouldn't pay for it).
if PlaylistState.is-owner && !PlaylistState.is-local
&& PlaylistState.track-count > 0
&& PlaylistState.track-count < 2000
&& SettingsState.musicbrainz-enabled: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/wand-sparkles.svg");
active: PlaylistSuggestionsState.activated;
tooltip: @tr("Suggest songs for this playlist");
clicked => {
PlaylistSuggestionsActions.activate();
}
}
}
// Spacer floats the search + sort to the right.
Rectangle { horizontal-stretch: 1; }
// In-playlist search (shared ExpandableSearch).
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
ExpandableSearch {
placeholder: @tr("Search tracks");
max-open-width: 220px;
edited(q) => {
PlaylistActions.search(q);
}
}
}
// Sort dropdown — fixed width so it never grows
// when the active option changes.
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
sort-anchor := Rectangle {
height: 34px;
width: 132px;
border-radius: 6px;
background: sort-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
sort-row := HorizontalLayout {
padding-left: 11px;
padding-right: 8px;
spacing: 5px;
Text {
text: @tr("Sort") + ": " + root.sort-label;
color: Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/chevron-down.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
}
sort-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
sort-menu.show();
}
}
sort-menu := PopupWindow {
x: sort-anchor.width - 172px;
y: 38px;
width: 172px;
Rectangle {
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
border-radius: 8px;
drop-shadow-blur: 20px;
drop-shadow-color: #00000080;
VerticalLayout {
padding: 6px;
spacing: 1px;
SortOption {
label: @tr("Default");
active: PlaylistState.sort-field == "default";
chosen => { PlaylistActions.set-sort("default"); }
}
SortOption {
label: @tr("Title");
active: PlaylistState.sort-field == "title";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("title"); }
}
SortOption {
label: @tr("Artist");
active: PlaylistState.sort-field == "artist";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("artist"); }
}
SortOption {
label: @tr("Album");
active: PlaylistState.sort-field == "album";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("album"); }
}
SortOption {
label: @tr("Duration");
active: PlaylistState.sort-field == "duration";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("duration"); }
}
// Positional proxy for when each
// track entered the playlist (the
// API returns insertion order) —
// v1.x parity, starts newest-first.
SortOption {
label: @tr("Date added");
active: PlaylistState.sort-field == "added";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("added"); }
}
// Manual order — enables the per-row
// up/down reorder controls. Hidden on
// LOCAL playlists (B2): their Default
// order IS the editable repo order, so
// a separate custom sidecar is moot.
if !PlaylistState.is-local: SortOption {
label: @tr("Custom");
active: PlaylistState.sort-field == "custom";
chosen => { PlaylistActions.set-sort("custom"); }
}
}
}
}
}
}
}
}
}
Rectangle { height: 18px; }
if PlaylistState.loading: HorizontalLayout {
vertical-stretch: 0;
alignment: center;
LoadingSpinner { size: 36px; }
}
if !PlaylistState.loading && PlaylistState.tracks.length == 0: Text {
text: @tr("This playlist is empty.");
color: Theme.text-muted;
font-size: Typography.body;
}
// --- Track list column header ------------------------------
// Extra right padding (12 + 14px gutter) aligns the column
// labels with the rows, which are inset by the scrollbar.
if PlaylistState.tracks.length > 0: HorizontalLayout {
vertical-stretch: 0;
padding-left: 12px;
padding-right: 26px;
spacing: 14px;
Text {
text: "#";
width: 32px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
// Play/cover cell — matches the row's 36px artwork cell.
Rectangle { width: 36px; }
Text {
text: @tr("Title");
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-stretch: 1;
}
Text {
text: @tr("Album");
width: 220px;
color: Theme.text-muted;
font-size: Typography.legal;
}
Text {
text: @tr("Duration");
width: 70px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
Text {
text: @tr("Quality");
width: 92px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
// Reserve the row's three trailing columns: favorite (28),
// offline/download (28), context-menu (32).
Rectangle { width: 28px; }
Rectangle { width: 28px; }
Rectangle { width: 32px; }
}
// Breathing room between the column labels and the divider.
if PlaylistState.tracks.length > 0: Rectangle { height: 3px; }
if PlaylistState.tracks.length > 0: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 6px; }
// Bulk action bar — visible in edit mode (action sets defined
// as bulk-actions-owner / bulk-actions-follower above).
if PlaylistState.multi-select-mode: MultiSelectBar {
selected-count: PlaylistState.selected-count;
actions: PlaylistState.is-owner
? root.bulk-actions-owner
: root.bulk-actions-follower;
action(id) => {
root.media-action("playlist", "", id);
}
}
if PlaylistState.multi-select-mode: Rectangle { height: 8px; }
// Virtualized track list (built-in scrollbar hidden — a
// custom one OVERLAYS its own gutter to the right — homologated
// with every other view (the scrollbar sits in the right gutter
// without shrinking the list).
Rectangle {
vertical-stretch: 1;
// The list sits in a right-padded layout so the custom
// scrollbar lands in the gutter instead of overlapping the
// rows, and the rows' right edge lines up with the column
// header (mirrors LocalLibrary > Tracks).
VerticalLayout {
padding-right: 14px;
track-list := ListView {
vertical-stretch: 1;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
// Scroll-position restore on back/forward (see nav::scope_for).
property <string> sr-armed: NavState.restore-scope;
function sr-restore() {
if (NavState.restore-scope == "playlist" && self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
NavState.restore-scope = "";
}
}
init => { self.sr-restore(); }
changed sr-armed => { self.sr-restore(); }
changed viewport-height => { self.sr-restore(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
for track[i] in PlaylistState.tracks: TrackRow {
track: track;
index: i;
show-artwork: true;
show-album: true;
// Mixed "carrete" rows (Tauri parity): the heart
// + offline-cloud columns are Qobuz-only
// (hideFavorite/hideDownload — a local row's id
// is a library row id, not a catalog id; wiring
// those columns to it mis-types the call).
// Local/plex rows show the per-source glyph in
// the cloud column's place instead — incl. the
// REAL Plex glyph (Tauri quirk T4 fixed
// forward). The reserved heart slot keeps one
// duration/quality grid across row types.
show-favorite: track.source == "qobuz";
reserve-favorite: track.source != "qobuz";
show-download: track.source == "qobuz";
show-source: track.source != "qobuz" && track.source != "";
// Custom sort (Qobuz sidecar order) — or, on a LOCAL
// playlist, the Default sort: its natural order IS the
// editable repo order (B2), written via repo::reorder.
show-reorder: PlaylistState.sort-field == "custom"
|| (PlaylistState.is-local && PlaylistState.sort-field == "default");
// Per-row "Remove from playlist" — ownership-gated
// BY DESIGN (spec §1.6.1: Tauri's available branch
// renders it un-gated on followed playlists and the
// owner-only API rejects; we port the intent, not
// the hole). Local details set is-owner true; the
// read-only offline subset sets it false.
remove-from-playlist-action: PlaylistState.is-owner;
// Track Info is DEAD on this surface (no
// ("track","track-info") arm yet — backlog
// project_track_info); hidden per spec §3.3 instead
// of rendering a no-op entry. Other surfaces keep
// the default until their own audit.
track-info-action: false;
// Go to Album / Go to Artist on the local/plex
// sidecar rows too (Qobuz rows already carry the
// pair via the Qobuz block) — owner improvement
// over Tauri. Only RESOLVED rows (numeric snapshot
// ids) qualify: the "file:"/"broken:"/unresolved-
// "plex:" fallbacks have no navigable album/artist
// keys, so their entries stay hidden (no dead
// clicks).
local-goto-actions: (track.source == "local" || track.source == "plex")
&& track.id.is-float();
multi-select-mode: PlaylistState.multi-select-mode;
// Drag-reorder (issue #589): the row's grip handle
// reports the gesture; this view owns the state.
reorder-index: i;
reorder-source: root.reorder-active && root.reorder-from == i;
reorder-begin(idx, row-top, gx, gy) => {
root.reorder-active = true;
root.reorder-from = idx;
root.reorder-over = idx;
root.reorder-list-top =
row-top - idx * 50px - track-list.viewport-y;
// Reuse the app-wide DragGhost (proven
// window-coord positioning) for the floating
// pill; reorder=true keeps sidebar rows from
// claiming a drop target. Pointer first, then
// active, so the ghost never flashes at a
// stale position.
DragState.count = 1;
DragState.ghost-title = track.title;
DragState.ghost-subtitle = track.album != ""
? track.artist + " · " + track.album
: track.artist;
DragState.pointer-x = gx;
DragState.pointer-y = gy;
DragState.reorder = true;
DragState.active = true;
}
reorder-update(gx, gy) => {
root.reorder-over = root.slot-from-pointer(gy);
DragState.pointer-x = gx;
DragState.pointer-y = gy;
// TODO: auto-scroll during drag when the pointer
// nears the list's top/bottom edge (the drag
// currently reorders within the visible viewport).
}
reorder-end => {
root.commit-reorder();
}
media-action(kind, id, action) => {
if (action == "play") {
root.play-track(id);
} else {
root.media-action(kind, id, action);
}
}
}
}
}
// Drop indicator: a 2px accent line at the insertion slot,
// hidden on the two no-op slots (from, from+1). The slot's
// content-space position folds back through viewport-y;
// clamped to the list rect so a slot scrolled out of view
// never paints outside it. Width stops before the 14px
// scrollbar gutter.
if root.reorder-active
&& root.reorder-over != root.reorder-from
&& root.reorder-over != root.reorder-from + 1: Rectangle {
x: 0;
y: Math.max(0px, Math.min(parent.height - 2px,
root.reorder-over * 50px + track-list.viewport-y - 1px));
width: parent.width - 14px;
height: 2px;
background: Theme.accent;
border-radius: 1px;
}
ListScrollbar {
// Anchor to the VIEW's right edge like LocalLibrary > Tracks:
// the +16px cancels `page`'s right padding so the scrollbar
// sits 4px from the edge (not 4px from the padded content).
x: parent.width - self.width - 4px + 16px;
width: 14px;
height: 100%;
viewport-height: track-list.viewport-height;
visible-height: track-list.visible-height;
viewport-y <=> track-list.viewport-y;
}
}
// --- Suggested Songs (T8) ----------------------------------
// 1:1 port of the Svelte PlaylistSuggestions component. Docked
// below the (virtualized) track list: PlaylistView's track list is
// a ListView that owns its own scroll, so — unlike the Svelte page
// flow — this section can't scroll INTO view; it docks at the
// bottom (vertical-stretch: 0) and the list takes the rest. Shown
// only after manual activation, and self-hides once the filtered
// pool is exhausted (is-empty) — matching the Svelte guard.
if PlaylistSuggestionsState.activated && !PlaylistSuggestionsState.is-empty
&& PlaylistState.is-owner && PlaylistState.track-count < 2000: VerticalLayout {
vertical-stretch: 0;
padding-top: 16px;
padding-right: 14px;
spacing: 0px;
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 14px; }
// Header: star + "Suggested songs" + refresh (trailing edge).
HorizontalLayout {
padding-left: 12px;
spacing: 8px;
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/wand-sparkles.svg");
width: 17px;
height: 17px;
tint: Theme.accent;
}
}
Text {
text: @tr("Suggested songs");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.semibold;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
SuggestionActionButton {
icon: @image-url("../assets/icons/refresh-cw.svg");
diameter: 32px;
glyph: 16px;
icon-tint: Theme.text-secondary;
enabled: !PlaylistSuggestionsState.loading
&& !PlaylistSuggestionsState.loading-more;
tooltip: @tr("Refresh suggestions");
clicked => {
PlaylistSuggestionsActions.refresh();
}
}
}
// Close (×) — hide the panel (re-open via the header wand).
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
SuggestionActionButton {
icon: @image-url("../assets/icons/x.svg");
diameter: 32px;
glyph: 16px;
icon-tint: Theme.text-secondary;
tooltip: @tr("Close");
clicked => {
PlaylistSuggestionsState.activated = false;
}
}
}
}
Rectangle { height: 12px; }
// Loading (first fetch, no rows yet).
if PlaylistSuggestionsState.loading
&& PlaylistSuggestionsState.rows.length == 0: VerticalLayout {
alignment: center;
spacing: 12px;
padding: 24px;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 26px; }
}
Text {
text: @tr("Finding similar artists...");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
// Error state with a Retry (re-activates).
if PlaylistSuggestionsState.error != "": VerticalLayout {
alignment: center;
spacing: 12px;
padding: 24px;
Text {
text: PlaylistSuggestionsState.error;
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
wrap: word-wrap;
}
HorizontalLayout {
alignment: center;
retry-btn := Rectangle {
width: retry-label.preferred-width + 32px;
height: 34px;
border-radius: Radius.sm;
background: retry-ta.has-hover ? Theme.accent-hover : Theme.accent;
retry-label := Text {
text: @tr("Retry");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
retry-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
PlaylistSuggestionsActions.activate();
}
}
}
}
}
// The visible page of suggestion rows.
if PlaylistSuggestionsState.error == ""
&& !(PlaylistSuggestionsState.loading
&& PlaylistSuggestionsState.rows.length == 0): VerticalLayout {
spacing: 2px;
for srow in PlaylistSuggestionsState.rows: PlaylistSuggestionRowView {
entry: srow;
}
if PlaylistSuggestionsState.loading-more: HorizontalLayout {
alignment: center;
padding: 12px;
LoadingSpinner { size: 16px; }
}
}
// Back-to-top FAB clearance: reserve the floating button's
// footprint (44px button + 24px bottom margin + a small gap) at
// the content's end so the last track rows — and the docked
// Suggested Songs — scroll clear of the FAB instead of sitting
// under it. Reserved as a CONSTANT (not gated on the FAB's own
// `viewport-y < -visible-height`) on purpose: a height switched
// on that condition would feed a layout binding loop (this
// height -> track-list height -> visible-height -> FAB condition
// -> this height) and would also jank the docked section on
// scroll. Invisible on short lists (their bottom is already
// empty); only shows where content actually reaches the FAB.
Rectangle { height: 80px; }
}
}
// Back to top — appears once the list is scrolled past one
// viewport. A shared rule for big lists (playlists, library, ...).
if track-list.viewport-y < -track-list.visible-height: Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
// Clear the right-gutter scrollbar (14px + 4px margin) so the FAB
// never sits over it.
x: parent.width - self.width - 40px;
y: parent.height - self.height - 24px;
background: btt-ta.has-hover ? Theme.accent : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 16px;
drop-shadow-color: #00000066;
QbzIcon {
source: @image-url("../assets/icons/chevron-up.svg");
width: 20px;
height: 20px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: btt-ta.has-hover ? #ffffff : Theme.text-secondary;
}
btt-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-list.viewport-y = 0;
}
}
}
// "Upload to Qobuz" confirm (D8) — destructive-ish conversion: the
// local entity is deleted once the upload completes.
if root.show-upload-confirm: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
root.show-upload-confirm = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 440px);
height: confirm-panel.preferred-height;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
TouchArea { }
confirm-panel := VerticalLayout {
padding: 20px;
spacing: 14px;
Text {
text: @tr("Upload to Qobuz?");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
}
Text {
text: @tr("This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy.");
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
HorizontalLayout {
alignment: end;
spacing: 10px;
Rectangle {
width: upload-cancel-label.preferred-width + 28px;
height: 36px;
border-radius: Radius.sm;
background: upload-cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
upload-cancel-label := Text {
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Typography.body;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
upload-cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.show-upload-confirm = false;
}
}
}
Rectangle {
width: upload-go-label.preferred-width + 36px;
height: 36px;
border-radius: Radius.sm;
background: upload-go-ta.has-hover ? #ffffffd6 : Theme.accent;
upload-go-label := Text {
text: @tr("Upload");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
upload-go-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.show-upload-confirm = false;
root.media-action("playlist", PlaylistState.id, "upload-to-qobuz");
}
}
}
}
}
}
}
// Read-more description modal (same treatment as Album / Artist).
if root.show-description: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
root.show-description = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
TouchArea { }
VerticalLayout {
padding: 24px;
spacing: 14px;
HorizontalLayout {
Text {
text: @tr("About this playlist");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
desc-close := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
root.show-description = false;
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: desc-close.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: desc-full.preferred-height;
desc-full := Text {
width: parent.width;
text: PlaylistState.description;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
}
}
}
}
// The floating reorder ghost is the app-wide DragGhost (AppShell),
// driven through DragState by the reorder-begin/update handlers above —
// no component-local ghost (its absolute-position math double-counted
// the row offset inside the ListView and drifted off the cursor).
}