// 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; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; 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(); height: 52px; background: ta.has-hover ? Theme.surface-hover : transparent; border-radius: Radius.sm; HorizontalLayout { padding-left: Spacing.sm; padding-right: Spacing.xs; padding-top: 6px; padding-bottom: 6px; spacing: 10px; // Leading: track number (UP NEXT) or thumbnail (History). if root.show-number: VerticalLayout { alignment: center; width: 26px; Text { text: (root.index + 1); color: Theme.text-muted; font-size: 13px; horizontal-alignment: center; } } if !root.show-number: Rectangle { width: 40px; height: 40px; 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: 13px; 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: 11px; overflow: elide; } } // Duration (hidden on hover when the row is removable, so the // remove control can take its place). VerticalLayout { alignment: center; Text { text: root.item.duration; color: Theme.text-muted; font-size: 11px; visible: !(root.removable && ta.has-hover); } } // Context-menu trigger — only on UP NEXT rows, shown on hover. if root.removable: VerticalLayout { alignment: center; width: 24px; visible: ta.has-hover; menu-btn := Rectangle { width: 24px; height: 24px; border-radius: Radius.sm; background: mta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: @image-url("../assets/icons/ellipsis.svg"); width: 15px; height: 15px; 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(); } } } } } ta := TouchArea { // The menu trigger sits above this area and consumes its own // clicks, so a row click never doubles as a menu open. mouse-cursor: pointer; clicked => { root.clicked(); } } // 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 { // Always present; the width animates 0 <-> 320 so the panel slides // in smoothly, matching the left sidebar's collapse animation. width: ShellState.right-panel == 1 ? 320px : 0px; clip: true; animate width { duration: 160ms; easing: ease-in-out; } 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; // Pull a fresh queue snapshot whenever the panel slides open — the // queue may have changed (or been restored) while it was closed. property panel-state: ShellState.right-panel; changed panel-state => { if (self.panel-state == 1) { 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.right-panel = 0; } } } } Rectangle { height: 1px; background: Theme.border-subtle; } // --- Body -------------------------------------------------------- Rectangle { vertical-stretch: 1; // ===== Queue tab ===== if QueueState.tab == 0: Flickable { viewport-width: self.width; viewport-height: queue-body.preferred-height; queue-body := VerticalLayout { padding: Spacing.md; spacing: Spacing.md; // 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; height: 68px; HorizontalLayout { padding: 10px; spacing: 12px; // Artwork. Rectangle { width: 48px; height: 48px; 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: 3px; HorizontalLayout { spacing: 5px; Text { text: QueueState.now-playing.title; color: Theme.text-primary; font-size: Typography.body; 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: 12px; overflow: elide; } } // Favorite (heart) toggle. VerticalLayout { alignment: center; fav := Rectangle { width: 32px; height: 32px; 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. VerticalLayout { spacing: 0px; for item[idx] in QueueState.upcoming-page: QueueRow { item: item; index: QueueState.page-start - 1 + idx; show-number: true; removable: true; clicked => { QueueState.play-upcoming(idx); } remove => { QueueState.remove-upcoming(idx); } } } // 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: Spacing.md; 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"); } } // Spacer. Rectangle { horizontal-stretch: 1; } // Action 4 — inline queue search field. Rectangle { width: 150px; 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"); color: Theme.text-muted; font-size: 12px; vertical-alignment: center; } } } } } } }