Files
qbz/crates/qbz-slint/ui/shell/QueueSidebar.slint
T
vicrodh 0606826df4 feat(slint): match queue footer to Tauri and add the track menu
Footer now carries the five Tauri controls with Tauri's icons — clear
(trash-list), save (add-to-list), infinite, sleep timer (clock), and
search; the timer button is visual-only for now. Queue rows gain a
context menu 1:1 with the Tauri queue track menu (Remove from queue,
Stop after this, Remove all after, Add to playlist, Track info); only
Remove is wired, the rest are placeholders.

cc
2026-05-19 02:50:21 -06:00

703 lines
27 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;
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 <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();
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 <bool> 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 <int> 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;
}
}
}
}
}
}
}