Extends Play later (end of the manual block) from track rows to every surface that already offered Play next / Add to queue: - playback.rs: enqueue_album_later, enqueue_playlist_later (mixed sidecar interleave), enqueue_artist_top_later, enqueue_tracks_later, enqueue_local_tracks_later — all NATURAL order (each add_track_later appends, no reverse), QConnect controller routing via play_later_batch_on_peer_if_active, and post-add queue sync. - local_playlist::enqueue_later_by_id (source-aware rows, offline-only stamp preserved), myqbz_play RowMode::PlayLater + BulkEnqueueMode. - 19 dispatch arms in main.rs (album/playlist/artist/label/mix/ favorites/local-library/album-page/disc/myqbz dialects). - 12 context menus + 10 bulk bars in .slint: list-plus icon between Play next and Add to queue, popup heights bumped. The immersive tap-action dropdown is deliberately untouched (it is a configurable single-tap behavior setting, not a context menu). km
1531 lines
76 KiB
Plaintext
1531 lines
76 KiB
Plaintext
// 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) + #442:
|
||
// Play next / Play later / 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-start.svg"), danger: false, needs-selection: true },
|
||
{ id: "play-later-selected", label: @tr("Play later"), 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-start.svg"), danger: false, needs-selection: true },
|
||
{ id: "play-later-selected", label: @tr("Play later"), 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;
|
||
tooltip: @tr("Play");
|
||
// 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;
|
||
tooltip: @tr("Shuffle");
|
||
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;
|
||
tooltip: PlaylistState.is-favorite
|
||
? @tr("Remove from favorites")
|
||
: @tr("Add to favorites");
|
||
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;
|
||
tooltip: @tr("Make available offline");
|
||
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;
|
||
tooltip: @tr("Upload to Qobuz");
|
||
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;
|
||
tooltip: @tr("Edit playlist");
|
||
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;
|
||
tooltip: @tr("Select tracks");
|
||
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;
|
||
auto-hide: true;
|
||
hover-active: ShellState.pointer-in-window
|
||
&& ShellState.pointer-x >= track-list.absolute-position.x
|
||
&& ShellState.pointer-x <= track-list.absolute-position.x + track-list.width
|
||
&& ShellState.pointer-y >= track-list.absolute-position.y
|
||
&& ShellState.pointer-y <= track-list.absolute-position.y + track-list.height;
|
||
}
|
||
}
|
||
|
||
// --- 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.
|
||
}
|