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:
9 files changed
+577
-301
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user