feat(slint): classic now-playing bar layout

Add the second of the four switchable now-playing bar layouts plus the
4-layout architecture behind it.

ShellState.npb-mode (0=New, 1=Classic) drives mode-conditional columns in
PlayerBar; the transport cluster is extracted into TransportControls.slint and
IconButton/PlayButton into primitives/BarControls (PlayButton gains a `circle`
prop) so both layouts share them without duplicating the popups. The choice
persists via UiPrefs.npb_mode ("new"/"classic") — restored on startup, written
on the "Now Playing view" menu select.

Classic = the Tauri arrangement: transport LEFT (plain play glyph, leading
track-info, inline add-to-playlist + favorite-with-state), glass song card
CENTRE, actions + volume RIGHT. SongCard.glass is a contained card capped at a
fixed 520px; the audio stamp is ABSOLUTELY pinned to the card's right edge
(cover 56 | song info that truncates with ellipsis | stamp 140) so it stays
flush right regardless of the text layout. Track info fires from both the
title click and the transport (i). Classic uses the full-size audio stamp; the
backend LED reads PIPEWIRE.

cc
This commit is contained in:
vicrodh committed 2026-06-02 11:32:01 -06:00
1 parent da7758b9d6
commit 1ad0d7c169
9 files changed
+577 -301

No files matched your search

+97 -236
View File
@@ -15,93 +15,14 @@ import { QbzSlider } from "../primitives/QbzSlider.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { SeekBar } from "SeekBar.slint";
import { SongCard } from "SongCard.slint";
import { TransportControls } from "TransportControls.slint";
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint";
// Square, compact icon button with a hover tooltip.
component IconButton inherits Rectangle {
in property <image> icon;
in property <length> icon-size: 16px;
in property <bool> active: false;
in property <string> tooltip;
// Footprint width; the right-cluster buttons are narrower (28) than the
// transport (32) so they pack tighter.
in property <length> btn-width: 32px;
callback clicked();
width: root.btn-width;
height: 32px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
// Pixel-snap the centring so fine SVGs don't render blurry.
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
// Primary, filled play/pause button.
component PlayButton inherits Rectangle {
in property <image> icon;
in property <string> tooltip;
callback clicked();
width: 38px;
height: 38px;
border-radius: 19px;
background: ta.has-hover ? #ffffffd6 : #ffffff;
QbzIcon {
source: root.icon;
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #000000;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
// IconButton + PlayButton now live in primitives/BarControls.slint (shared
// with TransportControls). ConnectButton + ToggleRow stay local (bar-only).
// Qobuz Connect button — monitor-speaker glyph; golden over a soft golden tint
// when a remote renderer owns playback. The renderer name lives in the song
@@ -210,7 +131,12 @@ export component PlayerBar inherits Rectangle {
property <float> side-frac: root.width >= 1920px
? 0.25
: (root.width >= 1366px ? 0.30 : 0.39);
property <float> centre-frac: 1.0 - 2.0 * root.side-frac;
// Classic uses a fixed ~32.4/35.2/32.4 split (the centre song card is 12%
// narrower than a plain 40% so it isn't oversized in the open view); New
// uses the responsive side fraction above. BOTH modes keep the two side
// columns equal so the centre column stays dead centre.
property <float> col-side: ShellState.npb-mode == 1 ? 0.324 : root.side-frac;
property <float> col-centre: 1.0 - 2.0 * root.col-side;
VerticalLayout {
Rectangle { height: 6px; }
@@ -247,17 +173,19 @@ export component PlayerBar inherits Rectangle {
padding-right: 6px;
spacing: 0px;
// LEFT column (responsive 39/30/25%) — song card; audio stamp
// floats to its right wall.
// LEFT column. New = the song card (audio stamp floats to its
// right wall). Classic = the transport cluster hugging the left
// edge with a plain play glyph (the Tauri arrangement).
Rectangle {
width: (parent.width - 12px) * root.side-frac;
width: (parent.width - 12px) * root.col-side;
clip: true;
// SongCard fills the column (a layout fills its Rectangle
// parent), so its text column can stretch and push the audio
// New: SongCard (a Rectangle) fills the column by default (a
// Rectangle child takes 100% of its parent), so its inner
// HorizontalLayout's stretch text column can push the audio
// stamp to the right wall. Vertical centring is handled by the
// card's own sub-columns. Do NOT wrap it in an `alignment:
// start` layout — that forces preferred sizing and kills both.
SongCard {
if ShellState.npb-mode == 0: SongCard {
// Fill the controls-row height (bar - top6 - seek20
// - gap6 - bottom6 = bar - 38).
art-size: Layout.player-bar-height - 38px;
@@ -273,166 +201,91 @@ export component PlayerBar inherits Rectangle {
open-context(kind, id) => {
root.media-action(kind, id, "open");
}
track-info() => {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
// Classic: transport cluster, left-aligned, plain play glyph.
if ShellState.npb-mode == 1: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: start;
TransportControls {
play-circle: false;
classic-actions: true;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
}
// CENTRE column (responsive 22/40/50%) — symmetric transport,
// absolutely centred at every width.
// CENTRE column. New = the transport cluster (symmetric, filled
// disc, absolutely centred). Classic = the glass song card, centred.
Rectangle {
width: (parent.width - 12px) * root.centre-frac;
width: (parent.width - 12px) * root.col-centre;
clip: true;
VerticalLayout {
// New: centred transport, PLAY on the window centre.
if ShellState.npb-mode == 0: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
spacing: 2px;
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
clicked => {
if (NowPlayingState.has-track) {
root.media-action("track", NowPlayingState.track-id, "track-info");
TransportControls {
play-circle: true;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/shuffle.svg");
icon-size: 16px;
tooltip: @tr("Shuffle");
active: NowPlayingState.shuffle;
clicked => {
NowPlayingState.toggle-shuffle();
}
// Classic: a contained glass card, centred and content-sized
// (clamped 354-718 like Tauri) so the audio stamp stays close
// to the track info instead of drifting to a far wall.
// Classic: the glass song card, mounted DIRECTLY in the column
// (a Rectangle child fills its Rectangle parent — exactly like
// the New layout). That fill is what lets the text column stretch
// and push the audio stamp FLUSH to the card's right edge. A
// VerticalLayout wrapper here breaks the fill and floats the
// stamp with a trailing gap — do NOT reintroduce one.
//
// `max-width` is the cap: the card grows with the window only up
// to here, then stays put, so the audio stamp settles at a
// consistent right edge instead of drifting across a too-wide
// card. `height` + `y` float it (centred) in the 72px row.
if ShellState.npb-mode == 1: SongCard {
glass: true;
// Fixed cap. `max-width` is IGNORED on a hand-positioned
// Rectangle (only layouts honour it) — set the width
// directly: 520px cap on big screens, the full column when
// it is narrower. The text column then stretches inside this
// fixed card and pins the 140px audio stamp to its right edge.
width: Math.min(parent.width, 520px);
height: 60px;
y: (parent.height - self.height) / 2;
art-size: 56px;
// Full-size 1:1-Tauri stamp at every width.
compact-stamp: false;
narrow: root.width < 1366px;
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
open-context(kind, id) => {
root.media-action(kind, id, "open");
}
track-info() => {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
tooltip: @tr("Previous");
clicked => {
NowPlayingState.previous();
}
}
}
VerticalLayout {
alignment: center;
PlayButton {
icon: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play");
clicked => {
NowPlayingState.toggle-play();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
tooltip: @tr("Next");
clicked => {
NowPlayingState.next();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: NowPlayingState.repeat-mode == 2
? @image-url("../assets/icons/repeat-1.svg")
: @image-url("../assets/icons/repeat.svg");
icon-size: 16px;
tooltip: @tr("Repeat");
active: NowPlayingState.repeat-mode != 0;
clicked => {
NowPlayingState.cycle-repeat();
}
}
}
// "+" — grouped add menu.
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to…");
clicked => {
add-menu.show();
}
}
add-menu := PopupWindow {
x: 0px;
y: -222px;
width: 232px;
height: 214px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to library");
clicked => {
root.media-action("track", NowPlayingState.track-id, "favorite");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("track", NowPlayingState.track-id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", NowPlayingState.track-id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-mixtape");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Add album to collection");
enabled: NowPlayingState.album-id != "";
clicked => {
root.media-action("album", NowPlayingState.album-id, "favorite");
}
}
}
}
}
}
}
}
}
// RIGHT column (responsive 39/30/25%, symmetric with the song
// card) — secondary actions + volume, clustered at the right wall.
Rectangle {
width: (parent.width - 12px) * root.side-frac;
width: (parent.width - 12px) * root.col-side;
clip: true;
HorizontalLayout {
alignment: end;
@@ -709,17 +562,25 @@ export component PlayerBar inherits Rectangle {
}
layout-menu := PopupWindow {
x: 32px - 196px;
y: -210px;
y: -242px;
width: 196px;
height: 202px;
height: 234px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/panel-left.svg");
label: @tr("Default");
selected: true;
label: @tr("New");
selected: ShellState.npb-mode == 0;
clicked => {
root.media-action("npb-view", "", "default");
root.media-action("npb-view", "", "new");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/panel-right-close.svg");
label: @tr("Classic");
selected: ShellState.npb-mode == 1;
clicked => {
root.media-action("npb-view", "", "classic");
}
}
ContextMenuItem {
+106 -63
View File
@@ -52,7 +52,7 @@ component OutputBadge inherits Rectangle {
in property <color> on-color: #22c55e;
in property <string> tooltip;
height: 15px;
// Height comes from the leds row (set per stamp size); fill it.
border-radius: 3px;
background: root.active ? root.on-color.with-alpha(0.16) : Theme.surface-hover;
border-width: 1px;
@@ -82,17 +82,28 @@ component OutputBadge inherits Rectangle {
}
}
export component SongCard inherits HorizontalLayout {
export component SongCard inherits Rectangle {
in property <length> art-size: 60px;
in property <bool> show-art: true;
in property <bool> show-badges: true;
// Below the responsive break the stamp shrinks 10% (proportional) so it
// keeps clear of the controls column; above the break it is full size.
in property <bool> narrow: false;
// Classic layout: render as a contained card (elevated bg + subtle shadow,
// NO border — Tauri's card has none, and the border is what made the album
// art read as inset/framed instead of flush). New (glass=false) stays
// edge-to-edge transparent.
in property <bool> glass: false;
// Classic non-responsive view uses the FULL-size audio stamp (1:1 Tauri);
// New and the responsive (narrow) view keep the compact stamp.
in property <bool> compact-stamp: true;
callback open-album(string);
callback open-artist(string);
callback open-context(string /* kind */, string /* id */);
// Opens the track-info panel. In Tauri this fires from the title click; the
// bar surfaces it as an explicit small (i) affordance instead.
callback track-info();
property <string> ctx-kind: NowPlayingState.context-id != ""
? NowPlayingState.context-kind
@@ -101,11 +112,30 @@ export component SongCard inherits HorizontalLayout {
? NowPlayingState.context-id
: NowPlayingState.album-id;
spacing: 11px;
// Default (stretch) alignment: the text column (the only stretch child)
// absorbs the leftover and PUSHES the audio stamp to the right wall. Do NOT
// set `alignment: start` here — that forces preferred sizing, kills the
// stretch, and collapses the album to zero width.
// Geometry helpers for the absolutely-pinned audio stamp.
property <length> pad: root.glass ? 2px : 0px;
property <length> stamp-w: root.compact-stamp ? (root.narrow ? 94px : 104px) : 140px;
property <bool> has-stamp: root.show-badges && NowPlayingState.has-track;
background: root.glass ? Theme.surface-elevated : transparent;
border-radius: root.glass ? 8px : 0px;
// No border — keeps the album art flush with the card (Tauri parity).
drop-shadow-blur: root.glass ? 8px : 0px;
drop-shadow-color: root.glass ? Theme.card-shadow : transparent;
HorizontalLayout {
// Cover + song info ONLY. This row stops BEFORE the audio stamp, which
// is pinned absolutely to the card's right edge (see the bottom of this
// component). Fixed deterministic widths: cover 56 | 11 | song info
// (fills the rest, truncates with ellipsis). The 4px gap to the stamp
// is reserved by the width formula.
x: root.pad;
y: 0;
height: root.height;
width: root.has-stamp
? root.width - 2 * root.pad - root.stamp-w - 4px
: root.width - 2 * root.pad;
spacing: 0px;
// --- Cover / idle cat ------------------------------------------------
if root.show-art: VerticalLayout {
@@ -156,6 +186,9 @@ export component SongCard inherits HorizontalLayout {
}
}
// Fixed gap: cover -> song info.
if root.show-art: Rectangle { width: 11px; }
// --- Title + context/artist/album + renderer badge -------------------
VerticalLayout {
horizontal-stretch: 1;
@@ -166,6 +199,9 @@ export component SongCard inherits HorizontalLayout {
spacing: 4px;
// Title — elides, and on hover shows the full text in a tooltip ONLY
// when it is actually truncated (the audio stamp ate into it).
// Title — elides. Clicking it opens track info (the transport's (i)
// is the second trigger, 1:1 with Tauri's title-click). Hovering a
// truncated title shows the full text in a tooltip.
Rectangle {
title-txt := Text {
width: 100%;
@@ -178,6 +214,12 @@ export component SongCard inherits HorizontalLayout {
overflow: elide;
}
ttta := TouchArea {
mouse-cursor: NowPlayingState.has-track ? pointer : default;
clicked => {
if (NowPlayingState.has-track) {
root.track-info();
}
}
changed has-hover => {
if (self.has-hover && NowPlayingState.has-track
&& title-txt.preferred-width > self.width) {
@@ -287,68 +329,69 @@ export component SongCard inherits HorizontalLayout {
}
}
// --- Breathing room before the audio stamp (tightened to shift it left)
if root.show-badges && NowPlayingState.has-track: Rectangle { width: 2px; }
}
// --- Audio stamp: quality badge over two leds, a fixed rectangle -----
if root.show-badges && NowPlayingState.has-track: VerticalLayout {
alignment: center;
VerticalLayout {
width: root.narrow ? 94px : 104px;
spacing: 3px;
// --- Audio stamp: quality badge over two leds. ABSOLUTELY pinned to the
// card's right edge (x = card width - padding - stamp width), vertically
// centred, so it is ALWAYS flush right no matter how the song-info column
// lays out. Deterministic fix for the trailing gap — does NOT rely on the
// text column's stretch reaching the edge.
if root.has-stamp: VerticalLayout {
x: root.width - root.pad - root.stamp-w;
y: (root.height - self.height) / 2;
width: root.stamp-w;
spacing: root.compact-stamp ? 3px : 2px;
Rectangle {
height: root.narrow ? 26px : 29px;
QualityBadgeFull {
x: 0;
y: 0;
width: parent.width;
height: parent.height;
compact: true;
show-icon: true;
scale-factor: root.narrow ? 0.9 : 1.0;
tier: NowPlayingState.quality-tier;
detail: NowPlayingState.quality-detail;
}
qta := TouchArea {
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Stored audio quality");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
Rectangle {
height: root.compact-stamp ? (root.narrow ? 26px : 29px) : 36px;
QualityBadgeFull {
x: 0;
y: 0;
width: parent.width;
height: parent.height;
compact: root.compact-stamp;
show-icon: true;
scale-factor: root.compact-stamp && root.narrow ? 0.9 : 1.0;
tier: NowPlayingState.quality-tier;
detail: NowPlayingState.quality-detail;
}
qta := TouchArea {
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Stored audio quality");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
// Backend + mode leds side-by-side; while casting they fuse into
// one purple led.
HorizontalLayout {
spacing: 3px;
if !NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: SettingsState.output-backend-label;
active: SettingsState.output-backend-active;
on-color: #5b8def;
tooltip: @tr("Audio backend");
}
if !NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: SettingsState.output-mode-label;
active: SettingsState.output-mode-active;
on-color: #22c55e;
tooltip: @tr("Output mode");
}
if NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
active: true;
on-color: #a855f7;
tooltip: @tr("Casting");
}
HorizontalLayout {
height: root.compact-stamp ? 15px : 18px;
spacing: root.compact-stamp ? 3px : 2px;
if !NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: SettingsState.output-backend-label;
active: SettingsState.output-backend-active;
on-color: #5b8def;
tooltip: @tr("Audio backend");
}
if !NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: SettingsState.output-mode-label;
active: SettingsState.output-mode-active;
on-color: #22c55e;
tooltip: @tr("Output mode");
}
if NowPlayingState.cast-active: OutputBadge {
horizontal-stretch: 1;
label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
active: true;
on-color: #a855f7;
tooltip: @tr("Casting");
}
}
}
@@ -0,0 +1,228 @@
// Now-playing transport cluster: shuffle, previous, play/pause, next, repeat,
// and a trailing add affordance. Extracted from PlayerBar so the same cluster
// can sit in the CENTRE column (New layout) or the LEFT column (Classic layout)
// without duplicating popups.
//
// `play-circle` = New filled disc vs Classic plain glyph.
// `classic-actions` = New keeps the grouped "+" add menu; Classic swaps it for
// an inline one-click add-to-playlist + a favorite toggle
// (filled-heart state), 1:1 with the Tauri Classic transport.
// The track-info "(i)" lives in the song card, not here.
//
// Reads NowPlayingState + QueueState directly; bubbles add/menu actions through
// `media-action` to PlayerBar.
import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, QueueState, TooltipState } from "../state.slint";
export component TransportControls inherits HorizontalLayout {
// New layout = filled white disc; Classic = plain glyph.
in property <bool> play-circle: true;
// Classic = inline add-to-playlist + favorite; New = grouped "+" menu.
in property <bool> classic-actions: false;
callback media-action(string /* kind */, string /* id */, string /* action */);
spacing: 2px;
// Track info — the first control (left of shuffle), 1:1 with the other
// views. The song-card title is the second trigger. Keeping it here also
// restores PLAY's symmetric centring (3 controls each side) in New.
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
clicked => {
if (NowPlayingState.has-track) {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/shuffle.svg");
icon-size: 16px;
tooltip: @tr("Shuffle");
active: NowPlayingState.shuffle;
clicked => {
NowPlayingState.toggle-shuffle();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
tooltip: @tr("Previous");
clicked => {
NowPlayingState.previous();
}
}
}
VerticalLayout {
alignment: center;
PlayButton {
circle: root.play-circle;
icon: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play");
clicked => {
NowPlayingState.toggle-play();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
tooltip: @tr("Next");
clicked => {
NowPlayingState.next();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: NowPlayingState.repeat-mode == 2
? @image-url("../assets/icons/repeat-1.svg")
: @image-url("../assets/icons/repeat.svg");
icon-size: 16px;
tooltip: @tr("Repeat");
active: NowPlayingState.repeat-mode != 0;
clicked => {
NowPlayingState.cycle-repeat();
}
}
}
// New: "+" — grouped add menu.
if !root.classic-actions: VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to…");
clicked => {
add-menu.show();
}
}
add-menu := PopupWindow {
x: 0px;
y: -222px;
width: 232px;
height: 214px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to library");
clicked => {
root.media-action("track", NowPlayingState.track-id, "favorite");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("track", NowPlayingState.track-id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", NowPlayingState.track-id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-mixtape");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Add album to collection");
enabled: NowPlayingState.album-id != "";
clicked => {
root.media-action("album", NowPlayingState.album-id, "favorite");
}
}
}
}
}
}
// Classic: inline add-to-playlist (one click) — 1:1 with Tauri.
if root.classic-actions: VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
}
// Classic: inline favorite toggle with filled-heart state — 1:1 with Tauri.
if root.classic-actions: VerticalLayout {
alignment: center;
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: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: QueueState.now-playing-favorite
? Theme.favorite
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
fav-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
QueueState.toggle-now-playing-favorite();
}
changed has-hover => {
if (self.has-hover) {
TooltipState.text = QueueState.now-playing-favorite
? @tr("Remove from favorites")
: @tr("Add to favorites");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
}
}