The Discover toolbar's side layouts had no explicit height, so they filled the 56px bar and y resolved to 0 — the tab pill, genre filter and gear stuck to the top instead of centering. Both now carry height = preferred-height. The header sidebar toggle dropped from a 19px to a 14px icon: it now matches the nav-tab icon size and tint, and the 30px box centers it on whole pixels (was 5.5px → blurry). The queue row's click TouchArea was declared after the content, so it sat on top and ate the context-menu button's clicks — the [...] just skipped to the track. It is now declared before the content, so the menu button keeps its own clicks. cc
705 lines
27 KiB
Plaintext
705 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;
|
|
|
|
// 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;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
|
|
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.
|
|
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 {
|
|
// 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");
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|