Add drag-and-drop reordering to the Queue sidebar: a per-row drag handle, a floating drag ghost, and a drop indicator. When a QConnect transport is connected the reorder is routed to the cloud (CtrlSrvrQueueReorderTracks) so order stays WS-authoritative; otherwise it moves the local queue track via core().move_track. The Flickable is non-interactive so a stale pressed_time cannot strand the gesture and leave phantom scroll after release. cc
870 lines
36 KiB
Plaintext
870 lines
36 KiB
Plaintext
// Right-side Queue panel.
|
|
//
|
|
// Rendered as a layout sibling of the content area (see AppShell), so it
|
|
// shrinks the content rather than overlaying it. Closing sets
|
|
// ShellState.right-panel = 0.
|
|
//
|
|
// Ported from the Tauri QueuePanel.svelte. Two tabs (Queue / History), a
|
|
// NOW PLAYING card with a favorite toggle, a paginated UP NEXT list
|
|
// (40 tracks per page), and a footer with four actions plus an inline
|
|
// queue search field.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Spacing } from "../foundation/spacing.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
|
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
|
|
import { ShellState, QueueState, QueueItem } from "../state.slint";
|
|
|
|
// Close button — mirrors the X control used by LyricsSidebar.
|
|
component CloseButton inherits Rectangle {
|
|
callback clicked();
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: Radius.sm;
|
|
background: ta.has-hover ? Theme.surface-hover : transparent;
|
|
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/x.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
// One tab — plain text, no pill background (QBZ design rule). The active
|
|
// tab is the brightest; inactive tabs dim and brighten on hover.
|
|
component QueueTab inherits TouchArea {
|
|
in property <string> label;
|
|
in property <bool> active: false;
|
|
mouse-cursor: pointer;
|
|
horizontal-stretch: 0;
|
|
|
|
Text {
|
|
text: root.label;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
color: root.active
|
|
? Theme.text-primary
|
|
: (root.has-hover ? Theme.text-secondary : Theme.text-muted);
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
|
|
// A small footer / control icon button — plain icon, no pill background.
|
|
component IconButton inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <length> icon-size: 17px;
|
|
in property <bool> active: false;
|
|
in property <bool> enabled: true;
|
|
callback clicked();
|
|
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: Radius.sm;
|
|
background: (ta.has-hover && root.enabled) ? Theme.surface-hover : transparent;
|
|
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: root.icon-size;
|
|
height: root.icon-size;
|
|
// Round to whole pixels — a fractional offset renders blurry.
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: !root.enabled
|
|
? Theme.text-disabled
|
|
: (root.active
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary));
|
|
}
|
|
|
|
ta := TouchArea {
|
|
mouse-cursor: root.enabled ? pointer : default;
|
|
clicked => {
|
|
if (root.enabled) {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Explicit "E" badge — mirrors the album-track-list badge.
|
|
component ExplicitBadge inherits Rectangle {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 3px;
|
|
background: Theme.surface-elevated;
|
|
Text {
|
|
text: "E";
|
|
color: Theme.text-muted;
|
|
font-size: 9px;
|
|
font-weight: Typography.semibold;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
|
|
// One UP NEXT / History row — index or artwork, title, artist, duration,
|
|
// and (upcoming only) a remove control on hover.
|
|
component QueueRow inherits Rectangle {
|
|
in property <QueueItem> item;
|
|
in property <int> index; // zero-based position in its list
|
|
in property <bool> show-number: true; // true = UP NEXT, false = History
|
|
in property <bool> removable: false;
|
|
callback clicked();
|
|
callback remove();
|
|
|
|
// Drag-reorder (UP NEXT only). `page-local-index` is the 0-based row within
|
|
// the visible page (NOT the page-global display `index`). The parent owns the
|
|
// list-level drag state; the row reports gestures in WINDOW coords.
|
|
in property <int> page-local-index;
|
|
in property <bool> drag-source: false; // this row is the one being dragged
|
|
callback drag-begin(int /* page-local index */, length /* row window-top */);
|
|
callback drag-update(length /* pointer window-y */);
|
|
callback drag-end();
|
|
|
|
// Compact rows with a subtle zebra so they read as distinct without the
|
|
// tall, airy spacing they had before.
|
|
height: 44px;
|
|
// Dim the row that is being dragged.
|
|
opacity: root.drag-source ? 0.4 : 1.0;
|
|
background: ta.has-hover
|
|
? Theme.surface-hover
|
|
: (Math.mod(root.index, 2) == 1
|
|
? Theme.surface-elevated.with-alpha(0.35)
|
|
: transparent);
|
|
border-radius: Radius.sm;
|
|
|
|
// Declared before the content so the context-menu button (which has
|
|
// its own TouchArea) renders on top and keeps its own clicks. A row
|
|
// TouchArea declared after the content would sit on top and eat the
|
|
// menu-button clicks — making the [...] just skip to the track.
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
property <length> down-y;
|
|
property <bool> dragging: false;
|
|
clicked => {
|
|
// Suppress the release-click that ends a reorder (must not also play).
|
|
if (!ta.dragging) {
|
|
root.clicked();
|
|
}
|
|
}
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down) {
|
|
// absolute-position double-counts this row's own layout offset
|
|
// inside the Flickable; subtract root.{x,y} to recover the true
|
|
// window Y (scroll-safe). See reference_slint_absolute_position_flickable.
|
|
ta.down-y = root.absolute-position.y - root.y + ta.mouse-y;
|
|
ta.dragging = false;
|
|
}
|
|
if (e.kind == PointerEventKind.up && ta.dragging) {
|
|
root.drag-end();
|
|
ta.dragging = false;
|
|
}
|
|
}
|
|
// Safety net: a Flickable/ListView can steal the grab mid-drag, so `up`
|
|
// may never arrive. `pressed` flips false on release AND grab-cancel.
|
|
changed pressed => {
|
|
if (!self.pressed && ta.dragging) {
|
|
root.drag-end();
|
|
ta.dragging = false;
|
|
}
|
|
}
|
|
moved => {
|
|
if (ta.pressed) {
|
|
if (!ta.dragging
|
|
&& Math.abs(root.absolute-position.y - root.y + ta.mouse-y - ta.down-y) > 6px) {
|
|
ta.dragging = true;
|
|
root.drag-begin(root.page-local-index, root.absolute-position.y - root.y);
|
|
}
|
|
if (ta.dragging) {
|
|
root.drag-update(root.absolute-position.y - root.y + ta.mouse-y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
HorizontalLayout {
|
|
padding-left: Spacing.sm;
|
|
padding-right: Spacing.xs;
|
|
padding-top: 4px;
|
|
padding-bottom: 4px;
|
|
spacing: 9px;
|
|
|
|
// Leading: track number (UP NEXT) or thumbnail (History).
|
|
if root.show-number: VerticalLayout {
|
|
alignment: center;
|
|
width: 22px;
|
|
Text {
|
|
text: (root.index + 1);
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
if !root.show-number: Rectangle {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 4px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
y: (parent.height - self.height) / 2;
|
|
if root.item.artwork.width > 0: Image {
|
|
source: root.item.artwork;
|
|
width: 100%;
|
|
height: 100%;
|
|
image-fit: cover;
|
|
}
|
|
}
|
|
|
|
// Title + artist.
|
|
VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
alignment: center;
|
|
spacing: 2px;
|
|
HorizontalLayout {
|
|
spacing: 5px;
|
|
Text {
|
|
text: root.item.title;
|
|
color: Theme.text-primary;
|
|
font-size: 12px;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
}
|
|
if root.item.explicit: VerticalLayout {
|
|
alignment: center;
|
|
ExplicitBadge {}
|
|
}
|
|
}
|
|
Text {
|
|
text: root.item.artist;
|
|
color: Theme.text-muted;
|
|
font-size: 10px;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
|
|
// Duration.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.item.duration;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
|
|
// Context-menu trigger — UP NEXT rows. Always visible, with a
|
|
// proper 32px hit area (matching the album track rows) so a
|
|
// near-miss never falls through to the row and skips the queue.
|
|
if root.removable: VerticalLayout {
|
|
alignment: center;
|
|
menu-btn := Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: Radius.sm;
|
|
background: mta.has-hover ? Theme.surface-elevated : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/ellipsis.svg");
|
|
width: 16px;
|
|
height: 16px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: mta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
mta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
track-menu.show();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Per-track context menu — 1:1 with the Tauri queue track menu.
|
|
// Only "Remove from queue" is wired; the rest are placeholders.
|
|
track-menu := PopupWindow {
|
|
x: root.width - 200px;
|
|
y: 44px;
|
|
width: 196px;
|
|
height: 175px;
|
|
ContextMenu {
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/trash-2.svg");
|
|
label: @tr("Remove from queue");
|
|
clicked => {
|
|
root.remove();
|
|
}
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/circle-stop.svg");
|
|
label: @tr("Stop after this");
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-x.svg");
|
|
label: @tr("Remove all after");
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-plus.svg");
|
|
label: @tr("Add to playlist");
|
|
}
|
|
ContextMenuItem {
|
|
icon: @image-url("../assets/icons/info.svg");
|
|
label: @tr("Track info");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component QueueSidebar inherits Rectangle {
|
|
// Width is owned by the right-panel column in AppShell (which animates it);
|
|
// this fills that column and vertical-stretches to its share of the height.
|
|
clip: true;
|
|
background: Theme.surface-card;
|
|
|
|
// True when the Queue tab has nothing to show at all.
|
|
property <bool> queue-empty:
|
|
!QueueState.has-current && QueueState.upcoming-total == 0;
|
|
|
|
// --- UP NEXT drag-reorder state (component-local; NOT the DragState global,
|
|
// which drives the track->playlist ghost drag). All indices are PAGE-LOCAL.
|
|
property <bool> reorder-active: false;
|
|
property <int> reorder-from: -1; // page-local index of the dragged row
|
|
property <int> reorder-over: -1; // insertion slot 0..N
|
|
// Window-Y of the upcoming list's first-row top, captured exactly at
|
|
// drag-begin (row top - row*44px); pointer-Y is converted to a slot against it.
|
|
property <length> reorder-list-top;
|
|
// Live pointer window-Y during the drag, for positioning the floating ghost.
|
|
property <length> reorder-pointer-y;
|
|
|
|
// Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 44px stacked
|
|
// with 0 spacing; the +0.5 row rounds the boundary to row centers (top half
|
|
// of row k -> slot k, bottom half -> slot k+1).
|
|
pure function slot-from-pointer(win-y: length) -> int {
|
|
return Math.max(0, Math.min(QueueState.upcoming-page.length,
|
|
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)));
|
|
}
|
|
function commit-reorder() {
|
|
root.reorder-active = false;
|
|
// Fire only a real move. `to == from` and `to == from+1` both drop back
|
|
// onto the same gap (no-op), matching move_track's from==to early return.
|
|
if (root.reorder-over != root.reorder-from
|
|
&& root.reorder-over != root.reorder-from + 1) {
|
|
QueueState.reorder(root.reorder-from, root.reorder-over);
|
|
}
|
|
root.reorder-from = -1;
|
|
root.reorder-over = -1;
|
|
}
|
|
|
|
// The panel is conditionally mounted (`if queue-open` at the AppShell mount
|
|
// site, ADR-010), so it is CREATED each time it opens — pull a fresh queue
|
|
// snapshot on `init` (the queue may have changed/been restored while closed).
|
|
init => {
|
|
QueueState.panel-opened();
|
|
}
|
|
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// --- Header: tabs + close button --------------------------------
|
|
HorizontalLayout {
|
|
padding-left: Spacing.md;
|
|
padding-right: Spacing.sm;
|
|
padding-top: Spacing.sm;
|
|
padding-bottom: Spacing.sm;
|
|
height: 44px;
|
|
spacing: 14px;
|
|
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QueueTab {
|
|
label: @tr("Queue");
|
|
active: QueueState.tab == 0;
|
|
clicked => {
|
|
QueueState.set-tab(0);
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: "|";
|
|
color: Theme.text-disabled;
|
|
font-size: Typography.body;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QueueTab {
|
|
label: @tr("History");
|
|
active: QueueState.tab == 1;
|
|
clicked => {
|
|
QueueState.set-tab(1);
|
|
}
|
|
}
|
|
}
|
|
// Spacer pushes the close button to the right edge.
|
|
Rectangle { horizontal-stretch: 1; }
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CloseButton {
|
|
clicked => {
|
|
ShellState.queue-open = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// --- Body --------------------------------------------------------
|
|
Rectangle {
|
|
vertical-stretch: 1;
|
|
|
|
// ===== Queue tab =====
|
|
if QueueState.tab == 0: Flickable {
|
|
// Drag-scroll is DISABLED on purpose: a row drag-reorder lives
|
|
// inside this Flickable, and Slint 1.16 cannot freeze the scroll
|
|
// for the drag without stranding the Flickable's internal
|
|
// `pressed_time` (it swallows the `Released` while !interactive),
|
|
// which then phantom-scrolls on the next slide. Keeping it always
|
|
// non-interactive lets the row TouchArea own the grab cleanly and
|
|
// never enters that stuck state. Wheel scroll still works (the
|
|
// Flickable only short-circuits non-wheel events); pagination
|
|
// covers long lists.
|
|
interactive: false;
|
|
viewport-width: self.width;
|
|
viewport-height: queue-body.preferred-height;
|
|
|
|
queue-body := VerticalLayout {
|
|
padding: 10px;
|
|
spacing: 12px;
|
|
|
|
// NOW PLAYING section.
|
|
if QueueState.has-current: VerticalLayout {
|
|
spacing: 10px;
|
|
Text {
|
|
text: @tr("NOW PLAYING");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
// Highlighted current-track card.
|
|
Rectangle {
|
|
background: Theme.surface-elevated;
|
|
border-radius: Radius.sm;
|
|
// Same height as a queue row — the bg + section
|
|
// separator are enough to distinguish it.
|
|
height: 44px;
|
|
HorizontalLayout {
|
|
padding-left: 6px;
|
|
padding-right: 6px;
|
|
padding-top: 4px;
|
|
padding-bottom: 4px;
|
|
spacing: 9px;
|
|
// Artwork.
|
|
Rectangle {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 4px;
|
|
background: Theme.surface-card;
|
|
clip: true;
|
|
if QueueState.now-playing.artwork.width > 0: Image {
|
|
source: QueueState.now-playing.artwork;
|
|
width: 100%;
|
|
height: 100%;
|
|
image-fit: cover;
|
|
}
|
|
}
|
|
// Title + artist.
|
|
VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
alignment: center;
|
|
spacing: 2px;
|
|
HorizontalLayout {
|
|
spacing: 5px;
|
|
Text {
|
|
text: QueueState.now-playing.title;
|
|
color: Theme.text-primary;
|
|
font-size: 12px;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
}
|
|
if QueueState.now-playing.explicit: VerticalLayout {
|
|
alignment: center;
|
|
ExplicitBadge {}
|
|
}
|
|
}
|
|
Text {
|
|
text: QueueState.now-playing.artist;
|
|
color: Theme.text-muted;
|
|
font-size: 10px;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
// Favorite (heart) toggle.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
fav := Rectangle {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: Radius.sm;
|
|
background: fav-ta.has-hover
|
|
? Theme.surface-hover
|
|
: transparent;
|
|
QbzIcon {
|
|
source: QueueState.now-playing-favorite
|
|
? @image-url("../assets/icons/heart-filled.svg")
|
|
: @image-url("../assets/icons/heart.svg");
|
|
width: 17px;
|
|
height: 17px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: QueueState.now-playing-favorite
|
|
? Theme.favorite
|
|
: (fav-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-muted);
|
|
}
|
|
fav-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
QueueState.toggle-now-playing-favorite();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// UP NEXT section.
|
|
if QueueState.upcoming-total > 0: VerticalLayout {
|
|
spacing: 8px;
|
|
// Counter — "UP NEXT (20 of 40, 38 left)".
|
|
Text {
|
|
text: @tr("UP NEXT ({} of {}, {} left)",
|
|
QueueState.page-end,
|
|
QueueState.upcoming-total,
|
|
QueueState.upcoming-remaining);
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
// Page of upcoming rows + drop-indicator overlay. Explicit
|
|
// height (rows are a fixed 44px, spacing 0) so the wrapping
|
|
// Rectangle does not collapse — a VerticalLayout does not
|
|
// auto-size its parent Rectangle.
|
|
Rectangle {
|
|
height: QueueState.upcoming-page.length * 44px;
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
for item[idx] in QueueState.upcoming-page: QueueRow {
|
|
item: item;
|
|
index: QueueState.page-start - 1 + idx;
|
|
page-local-index: idx;
|
|
show-number: true;
|
|
removable: true;
|
|
drag-source: root.reorder-active && root.reorder-from == idx;
|
|
clicked => {
|
|
QueueState.play-upcoming(idx);
|
|
}
|
|
remove => {
|
|
QueueState.remove-upcoming(idx);
|
|
}
|
|
drag-begin(local-idx, row-top) => {
|
|
root.reorder-active = true;
|
|
root.reorder-from = local-idx;
|
|
root.reorder-over = local-idx;
|
|
root.reorder-list-top = row-top - local-idx * 44px;
|
|
}
|
|
drag-update(pointer-y) => {
|
|
root.reorder-over = root.slot-from-pointer(pointer-y);
|
|
root.reorder-pointer-y = pointer-y;
|
|
}
|
|
drag-end => {
|
|
root.commit-reorder();
|
|
}
|
|
}
|
|
}
|
|
// Drop indicator: a 2px accent line at the insertion
|
|
// slot, hidden for the two no-op slots (from, from+1).
|
|
if root.reorder-active
|
|
&& root.reorder-over != root.reorder-from
|
|
&& root.reorder-over != root.reorder-from + 1: Rectangle {
|
|
x: 0;
|
|
y: root.reorder-over * 44px - 1px;
|
|
width: parent.width;
|
|
height: 2px;
|
|
background: Theme.accent;
|
|
border-radius: 1px;
|
|
}
|
|
}
|
|
// Paginator — plain text controls, no pills. Only
|
|
// shown when the upcoming list spans >1 page.
|
|
if QueueState.page-count > 1: HorizontalLayout {
|
|
padding-top: 4px;
|
|
alignment: center;
|
|
spacing: Spacing.md;
|
|
IconButton {
|
|
icon: @image-url("../assets/icons/chevron-left.svg");
|
|
icon-size: 15px;
|
|
enabled: QueueState.page > 0;
|
|
clicked => {
|
|
QueueState.prev-page();
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Page {} of {}",
|
|
QueueState.page + 1,
|
|
QueueState.page-count);
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
IconButton {
|
|
icon: @image-url("../assets/icons/chevron-right.svg");
|
|
icon-size: 15px;
|
|
enabled: QueueState.page < QueueState.page-count - 1;
|
|
clicked => {
|
|
QueueState.next-page();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Empty state — no current track and no upcoming.
|
|
if root.queue-empty: VerticalLayout {
|
|
padding-top: 48px;
|
|
spacing: 6px;
|
|
Text {
|
|
text: @tr("Your queue is empty");
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.medium;
|
|
horizontal-alignment: center;
|
|
}
|
|
Text {
|
|
text: @tr("Play an album or track to get started");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
horizontal-alignment: center;
|
|
wrap: word-wrap;
|
|
}
|
|
}
|
|
|
|
// No-results state — a search is active but nothing matched.
|
|
if !root.queue-empty
|
|
&& QueueState.search-query != ""
|
|
&& QueueState.upcoming-total == 0: VerticalLayout {
|
|
padding-top: 32px;
|
|
Text {
|
|
text: @tr("No tracks match your search");
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== History tab =====
|
|
if QueueState.tab == 1: Flickable {
|
|
viewport-width: self.width;
|
|
viewport-height: history-body.preferred-height;
|
|
|
|
history-body := VerticalLayout {
|
|
padding: 10px;
|
|
spacing: 8px;
|
|
|
|
Text {
|
|
text: @tr("RECENTLY PLAYED");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
if QueueState.history.length > 0: VerticalLayout {
|
|
spacing: 0px;
|
|
for item[idx] in QueueState.history: QueueRow {
|
|
item: item;
|
|
index: idx;
|
|
show-number: false;
|
|
removable: false;
|
|
clicked => {
|
|
QueueState.play-history(idx);
|
|
}
|
|
}
|
|
}
|
|
if QueueState.history.length == 0: VerticalLayout {
|
|
padding-top: 48px;
|
|
Text {
|
|
text: @tr("Nothing played yet");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Footer: four actions + inline search (Queue tab only) ------
|
|
if QueueState.tab == 0 && !root.queue-empty: Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
if QueueState.tab == 0 && !root.queue-empty: HorizontalLayout {
|
|
padding-left: Spacing.md;
|
|
padding-right: Spacing.md;
|
|
padding-top: 10px;
|
|
padding-bottom: 10px;
|
|
spacing: Spacing.xs;
|
|
|
|
// Action 1 — Clear queue.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
IconButton {
|
|
icon: @image-url("../assets/icons/trash-list.svg");
|
|
clicked => {
|
|
QueueState.clear-queue();
|
|
}
|
|
}
|
|
}
|
|
// Action 2 — Save the queue as a playlist.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
IconButton {
|
|
icon: @image-url("../assets/icons/add-to-list.svg");
|
|
clicked => {
|
|
QueueState.save-as-playlist();
|
|
}
|
|
}
|
|
}
|
|
// Action 3 — Toggle infinite play.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
IconButton {
|
|
icon: @image-url("../assets/icons/infinity.svg");
|
|
active: QueueState.infinite-play;
|
|
clicked => {
|
|
QueueState.toggle-infinite-play();
|
|
}
|
|
}
|
|
}
|
|
// Action 4 — Sleep timer. Visual parity with Tauri; the
|
|
// timer popover / functionality is not wired yet.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
IconButton {
|
|
icon: @image-url("../assets/icons/clock.svg");
|
|
}
|
|
}
|
|
|
|
// Inline queue search field — flexes to fill the space left of the
|
|
// four actions so it never overflows / overlaps at the narrower
|
|
// (240px) panel width.
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
min-width: 90px;
|
|
height: 30px;
|
|
border-radius: Radius.sm;
|
|
background: Theme.surface-elevated;
|
|
HorizontalLayout {
|
|
padding-left: 9px;
|
|
padding-right: 7px;
|
|
spacing: 6px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
search-input := TextInput {
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-primary;
|
|
font-size: 12px;
|
|
vertical-alignment: center;
|
|
single-line: true;
|
|
text: QueueState.search-query;
|
|
edited => {
|
|
QueueState.search-query = self.text;
|
|
QueueState.search-changed();
|
|
}
|
|
}
|
|
Text {
|
|
visible: search-input.text == "";
|
|
text: @tr("Search queue");
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// Floating drag ghost — a pill following the pointer with the dragged track's
|
|
// title, so it's clear something is being moved. A free-positioned child of
|
|
// the panel ROOT (NOT a layout child — a VerticalLayout would ignore its x/y
|
|
// and stack it), declared last so it renders above the list. Converts the live
|
|
// pointer window-Y into this panel's local space.
|
|
if root.reorder-active
|
|
&& root.reorder-from >= 0
|
|
&& root.reorder-from < QueueState.upcoming-page.length: Rectangle {
|
|
x: Spacing.md;
|
|
y: root.reorder-pointer-y - root.absolute-position.y - self.height / 2;
|
|
width: parent.width - 2 * Spacing.md;
|
|
height: 34px;
|
|
background: Theme.surface-elevated;
|
|
border-radius: Radius.sm;
|
|
border-width: 1px;
|
|
border-color: Theme.accent;
|
|
opacity: 0.95;
|
|
HorizontalLayout {
|
|
padding-left: Spacing.sm;
|
|
padding-right: Spacing.sm;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: QueueState.upcoming-page[root.reorder-from].title;
|
|
color: Theme.text-primary;
|
|
font-size: 12px;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|