// 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 label; in property 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 icon; in property icon-size: 17px; in property active: false; in property 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 item; in property index; // zero-based position in its list in property show-number: true; // true = UP NEXT, false = History in property 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 page-local-index; in property 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 down-y; property 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 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 reorder-active: false; property reorder-from: -1; // page-local index of the dragged row property 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 reorder-list-top; // Live pointer window-Y during the drag, for positioning the floating ghost. property 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; } } } } }