Files
qbz/crates/qbz-ui/ui/playlist/PlaylistView.slint
T
vicrodh a0f5cf2bf5 feat(shell): metaball ambient + translucent bars/panels/controls, drop carousel fades
Smoke rounds 3-4 (owner feedback). Checkpoint during iteration.

Ambient (the big one): rewrote ambient.wgsl from translating gaussian blobs to a
METABALL field (r²/d² summed + smoothstep iso-surface) with 2-octave domain warp
— lobes fuse/stretch/split like an amoeba / lava lamp (the Cider motion that was
missing), faster clock, more contrast.

Surfaces made background-aware (translucent when the dynamic background is on):
- Search results view (root + toolbar), artist Network panel + its tab-row
  header (transparent to avoid double-translucent compounding), JUMP TO bar
  (JumpNavBar bar-bg passed in — can't import ShellState without a cycle).
- Discover toolbars + tab pill + BrowseHeaderTools; SearchResultsView toolbar.
- CircleAction (shared: playlist/album/artist/etc. action buttons) on-surface
  secondary fill goes glass; playlist Sort dropdown; QbzSelect closed control.
- New app-background-bar-alpha knob (0.3, env QBZ_BG_BAR_ALPHA): thin bars go
  MORE transparent than panels (surface-alpha 0.5).
- Content well: frosted tint (0.22) + 1px hairline border → distinct panel.
- Album/Artist header gradient auto-suppressed under the dynamic background
  (they clashed); header text stays light on the dynamic backdrop.
- Discover carousel edge-fade "shadows" hidden when active (Carousel /
  PlaylistCarousel / ArtistCarousel / PinnedCarousel).

Base: 76ea5a82. Prior checkpoint: 4e405338.

cc
2026-07-17 08:08:33 -06:00

1513 lines
74 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, PinnedActions, DragState, NavState, OfflineState, BulkAction, SettingsState } from "../state.slint";
import { PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, TooltipState, ShellState, AppearanceState } 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) -------------------
// Reorder RIDES the normal row-body drag (TrackRow's `ta` +
// DragActions — the same gesture that drags a track onto a sidebar
// playlist; no separate grip/gesture). TrackRow reports the source
// index via body-drag-started right as the shared drag starts; from
// there this view reads everything off DragState — the Rust-fed
// WINDOW-coord pointer the app-wide DragGhost is positioned with (no
// per-row absolute-position math). Releasing inside the track list
// reorders; releasing on a sidebar playlist is the unchanged Rust
// add-to-playlist drop (DragActions.end).
// All indices are VISIBLE-list indices into PlaylistState.tracks.
property <int> reorder-from: -1; // source row; -1 = this drag is not a reorder
property <int> reorder-over: -1; // insertion slot 0..N; -1 = pointer outside the list
// Sidebar drop target captured WHILE the drag is active. Rust's
// DragActions.end clears over-playlist-id in the same turn it clears
// `active`, and `changed` callbacks run deferred — by the time the
// drag-active handler below fires, over-playlist-id always reads "".
// This guarded mirror keeps the value the drop actually saw.
property <string> reorder-drop-playlist: "";
// DragState mirrors — a `changed` callback needs a component-local
// property (same pattern as Sidebar's drop detection).
property <bool> drag-active: DragState.active;
property <length> drag-px: DragState.pointer-x;
property <length> drag-py: DragState.pointer-y;
property <string> drag-over-id: DragState.over-playlist-id;
// True while the drag pointer sits inside the track list's rect.
// track-list is a regular element (NOT a row inside the ListView), so
// its absolute-position is exact — no row double-count. Only called
// from event handlers (reading absolute-position in a declarative
// binding is a layout recursion).
function pointer-in-list() -> bool {
return DragState.pointer-x >= track-list.absolute-position.x
&& DragState.pointer-x <= track-list.absolute-position.x + track-list.width
&& DragState.pointer-y >= track-list.absolute-position.y
&& DragState.pointer-y <= track-list.absolute-position.y + track-list.height;
}
// DragState.pointer-y -> insertion slot [0..N]. Rows are a fixed 50px
// stacked with 0 spacing; round() lands boundaries on row centers
// (top half of row k -> slot k, bottom half -> slot k+1). viewport-y
// (<= 0) folds the scroll back in, so the math stays scroll-aware.
function slot-from-pointer() -> int {
return Math.max(0, Math.min(PlaylistState.tracks.length,
Math.round((DragState.pointer-y - track-list.absolute-position.y
- track-list.viewport-y) / 50px)));
}
function update-reorder-slot() {
if (root.reorder-from >= 0 && DragState.active) {
root.reorder-over = root.pointer-in-list() ? root.slot-from-pointer() : -1;
}
}
changed drag-px => { root.update-reorder-slot(); }
changed drag-py => { root.update-reorder-slot(); }
changed drag-over-id => {
// Only while active — see the reorder-drop-playlist doc above.
if (DragState.active) {
root.reorder-drop-playlist = root.drag-over-id;
}
}
changed drag-active => {
if (root.drag-active) {
return;
}
// Drag ended (the Rust DragActions.end already ran — it handled a
// sidebar-playlist drop, if any). A reorder drag released INSIDE
// the list and NOT on a sidebar playlist moves the track; the slot
// is recomputed here from the FINAL pointer (the deferred drag-py
// handler may not have run for the last move). `slot == from` and
// `slot == from + 1` are the two gaps around the source row (no-op).
if (root.reorder-from >= 0
&& root.reorder-drop-playlist == ""
&& root.pointer-in-list()
&& root.slot-from-pointer() != root.reorder-from
&& root.slot-from-pointer() != root.reorder-from + 1) {
PlaylistActions.reorder-track(root.reorder-from, root.slot-from-pointer());
}
root.reorder-from = -1;
root.reorder-over = -1;
root.reorder-drop-playlist = "";
}
background: ShellState.app-background-active ? transparent : 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;
// Playlists have no header gradient — these sit on
// the plain page background, so use the theme-aware
// variant (reads on light themes).
on-surface: true;
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.play-all();
}
}
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
on-surface: true;
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;
on-surface: true;
clicked => {
root.toggle-favorite();
}
}
}
// Pin to Home — hidden for LOCAL playlists (the
// pinned store keys Qobuz catalog ids).
if !PlaylistState.is-local: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: PlaylistState.pinned
? @image-url("../assets/icons/pin-filled.svg")
: @image-url("../assets/icons/pin.svg");
active: PlaylistState.pinned;
on-surface: true;
tooltip: PlaylistState.pinned ? @tr("Unpin") : @tr("Pin");
clicked => {
PinnedActions.toggle-pin("playlist", PlaylistState.id,
PlaylistState.name, PlaylistState.owner,
PlaylistState.cover-url);
}
}
}
// 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;
on-surface: true;
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");
on-surface: true;
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");
on-surface: true;
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");
on-surface: true;
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");
on-surface: true;
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;
on-surface: true;
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;
on-surface: true;
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
: (ShellState.app-background-active
? Theme.surface-elevated.with-alpha(AppearanceState.app-background-surface-alpha)
: 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): rides the normal
// row-body drag — the row only reports the
// source index as the shared drag starts; the
// view-level DragState handlers above do the rest.
// TODO: auto-scroll during drag when the pointer
// nears the list's top/bottom edge (the drag
// currently reorders within the visible viewport).
body-drag-started(idx) => {
root.reorder-from = idx;
root.reorder-over = -1;
root.reorder-drop-playlist = "";
}
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 while the pointer is outside the list (slot -1 —
// e.g. over the sidebar, where the drop ADDS instead) and
// 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-from >= 0
&& root.reorder-over >= 0
&& 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): the
// reorder IS the normal row-body drag, so the ghost is positioned by
// the proven Rust DragActions window coords — no component-local ghost,
// no row absolute-position math.
}