feat(qbz-slint): Queue+Lyrics stacking, NowPlayingBar polish, shell fill fix

NowPlayingBar / SongCard:
- Audio stamp homologated New<->Classic + vertically centred (alignment:center
  on the stamp column; New had a binding-loop card height, now explicit).
- Dot-LED output indicators (circle-dot/circle) in both modes; render-target
  badge moved into the LED row when a peer/cast device owns playback.
- Right-cluster buttons made square (32x32); context glyph -> disc-album,
  flush-left; classic stamp right margin; quality tooltip = the quality text;
  heart tooltip = Add to library; Qobuz Connect tooltip always shows On/Off.

Qobuz Connect:
- Clear the session topology on disconnect so a late event cannot resurrect
  the golden render badge (badge-persists-after-disconnect fix).

Queue / Lyrics right panel:
- Queue and Lyrics can both be open at once, stacked vertically in one column
  (independent queue-open/lyrics-open flags replace the exclusive right-panel
  int); each closable from its bar button and its own X.
- Panel width 280; compact rows (zebra), now-playing card homologated with a
  row, flexible footer search, Lyrics title sized like the Queue tabs.

Shell layout:
- Pin AppShell to the window and size the content area arithmetically
  (window - header - player-bar - padding). A Slint VerticalLayout ignores an
  explicit height and does not auto-fill its parent Rectangle, so the content
  row collapsed once the right panels became conditionally mounted; a Rectangle
  takes the explicit height and it cascades up. Bar height references the single
  Layout.player-bar-height source (note left for the future Small/Large bars).

cc
This commit is contained in:
vicrodh committed 2026-06-04 11:49:24 -06:00
1 parent 8e1bf18f2f
commit 14e3e3689c
12 files changed
+300 -151

No files matched your search

+8
View File
@@ -448,6 +448,14 @@ impl SlintQconnectService {
{
let mut state = runtime.sync_state.lock().await;
state.watchdog_generation = state.watchdog_generation.wrapping_add(1);
// Clear the session topology + per-renderer cache so that any
// event still in flight when the loop aborts cannot recompute
// `is_peer_renderer_active() == true` and resurrect the golden
// render badge AFTER we clear is-remote/cast-target below
// (tokio `abort()` is not instantaneous; the sink can run one
// more `refresh_now_playing_remote_state` from a stale session).
state.session = QconnectSessionState::default();
state.session_renderer_states.clear();
}
if let Err(err) = runtime.app.disconnect().await {
let mut guard = self.inner.lock().await;
+8
View File
@@ -75,6 +75,14 @@ export component AppWindow inherits Window {
}
if root.screen == AppScreen.shell: AppShell {
// Fill the window. A conditionally-mounted child sizes to its PREFERRED
// size by default, so without this the shell sized to its content — fine
// while the right panels were always mounted (tall content), but once
// they became conditional, with both closed the shell collapsed to the
// top with dead space below. Bind to the real window size (`root` here is
// the AppWindow); `100%` did not pin it.
width: root.width;
height: root.height;
open-album(id) => {
root.open-album(id);
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="2.5" fill="#ffffff" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 286 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 220 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="12" cy="12" r="5" />
<path d="M12 12h.01" />
</svg>

After

Width:  |  Height:  |  Size: 298 B

+43 -5
View File
@@ -6,6 +6,7 @@
// content well); no hairline borders.
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, ShellState } from "../state.slint";
import { Sidebar } from "Sidebar.slint";
@@ -117,6 +118,23 @@ export component AppShell inherits Rectangle {
padding-bottom: 8px;
Rectangle {
// EXPLICIT height = window - header - player bar - this
// VL's bottom padding. A Slint `VerticalLayout` ignores an
// explicit `height` (it sizes to content), and a layout
// does not auto-fill its parent Rectangle — so the content
// row could not stretch on its own and collapsed once the
// right panels (which used to drive the height) became
// conditionally mounted. A Rectangle DOES take an explicit
// height, so we size the content area arithmetically; the
// height then cascades UP (this -> VL -> frame -> row ->
// shell). `root.height` is the AppShell height (pinned to
// the window in app.slint).
//
// NOTE: `Layout.player-bar-height` is the single source of
// truth for the bar height (New + Classic both use it).
// When the Small / Large bar layouts land with a DIFFERENT
// height, make that value mode-aware and this stays correct.
height: root.height - Layout.header-height - Layout.player-bar-height - 8px;
vertical-stretch: 1;
background: Theme.surface-main;
border-radius: Radius.md;
@@ -277,11 +295,31 @@ export component AppShell inherits Rectangle {
}
}
// Right-side panels — layout siblings of the content area, so
// they shrink the content rather than overlaying it. Always
// present; each animates its own width open/closed.
QueueSidebar { }
LyricsSidebar { }
// Right-side panel column — Queue and/or Lyrics stacked vertically,
// sharing the height (Feishin-style). A layout sibling of the content
// (shrinks it, no overlay). The column width animates 0<->240 (= the
// open left-sidebar width); it collapses only when BOTH are closed.
// When both are open each section vertical-stretches to half; when
// only one is open it fills.
Rectangle {
width: (ShellState.queue-open || ShellState.lyrics-open) ? 280px : 0px;
clip: true;
animate width { duration: 160ms; easing: ease-in-out; }
background: Theme.surface-card;
VerticalLayout {
spacing: 0px;
if ShellState.queue-open: QueueSidebar {
vertical-stretch: 1;
}
if ShellState.queue-open && ShellState.lyrics-open: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
if ShellState.lyrics-open: LyricsSidebar {
vertical-stretch: 1;
}
}
}
}
PlayerBar {
@@ -37,11 +37,9 @@ component CloseButton inherits Rectangle {
}
export component LyricsSidebar 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 == 2 ? 320px : 0px;
// Width is owned by the right-panel column in AppShell; this fills it and
// vertical-stretches to its share of the height (stacked under the queue).
clip: true;
animate width { duration: 160ms; easing: ease-in-out; }
background: Theme.surface-card;
VerticalLayout {
@@ -57,7 +55,8 @@ export component LyricsSidebar inherits Rectangle {
Text {
text: @tr("Lyrics");
color: Theme.text-primary;
font-size: Typography.heading;
// Homologated with the Queue / History tab size.
font-size: Typography.body;
font-weight: Typography.semibold;
vertical-alignment: center;
horizontal-stretch: 1;
@@ -66,7 +65,7 @@ export component LyricsSidebar inherits Rectangle {
alignment: center;
CloseButton {
clicked => {
ShellState.right-panel = 0;
ShellState.lyrics-open = false;
}
}
}
+45 -28
View File
@@ -34,7 +34,7 @@ component ConnectButton inherits Rectangle {
property <color> gold: #e0b341;
width: 28px;
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: root.active
@@ -60,7 +60,9 @@ component ConnectButton inherits Rectangle {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "" && !root.active) {
// Always show the tooltip — even when active/connected (the old
// `!root.active` gate hid "Qobuz Connect: On").
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;
@@ -194,12 +196,20 @@ export component PlayerBar inherits Rectangle {
// card's own sub-columns. Do NOT wrap it in an `alignment:
// start` layout — that forces preferred sizing and kills both.
if ShellState.npb-mode == 0: SongCard {
// Fill the controls-row height (bar - top6 - seek20
// - gap6 - bottom6 = bar - 38).
// EXPLICIT height (= controls-row height). Without it the
// inner `height: root.height` formed a binding loop that sized
// the card to the TEXT content (~50px) instead of the cover
// (72px), so the audio stamp centred against the wrong height
// and floated above the album art. Mirror classic: fixed
// height + y-centre in the column.
height: Layout.player-bar-height - 38px;
y: (parent.height - self.height) / 2;
art-size: Layout.player-bar-height - 38px;
// Below 1366px shrink the stamp 10% so it stays
// clear of the controls column; full size above.
narrow: root.width < 1366px;
// Homologate the stamp with classic: full-size badge + LEDs,
// not the compact variant.
compact-stamp: false;
// New mode: output indicators as dot LEDs, not chips.
dot-leds: true;
open-album(id) => {
root.open-album(id);
}
@@ -263,15 +273,20 @@ export component PlayerBar inherits Rectangle {
// card. `height` + `y` float it (centred) in the 72px row.
if ShellState.npb-mode == 1: SongCard {
glass: true;
// Dot LEDs in classic too (user preference); the full-size
// quality badge is kept (compact-stamp stays false).
dot-leds: 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
// directly: 560px 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;
width: Math.min(parent.width, 560px);
// +4px taller (2px top/bottom) so the title + audio stamp are
// not glued to the card edge; art + stamp grow in step.
height: 64px;
y: (parent.height - self.height) / 2;
art-size: 56px;
art-size: 60px;
// Full-size 1:1-Tauri stamp at every width.
compact-stamp: false;
narrow: root.width < 1366px;
@@ -303,13 +318,15 @@ export component PlayerBar inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
width: 32px;
height: 32px;
ConnectButton {
// Golden whenever a Qobuz Connect session is on — whether
// qbz is the renderer or controlling a peer (not just is-remote).
active: NowPlayingState.qconnect-connected;
tooltip: @tr("Qobuz Connect");
tooltip: NowPlayingState.qconnect-connected
? @tr("Qobuz Connect: On")
: @tr("Qobuz Connect: Off");
clicked => {
qconnect-menu.show();
}
@@ -506,12 +523,12 @@ export component PlayerBar inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/cast.svg");
icon-size: 16px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Cast");
active: NowPlayingState.cast-active;
clicked => {
@@ -609,12 +626,12 @@ export component PlayerBar inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/settings-2.svg");
icon-size: 16px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Audio settings");
active: SettingsState.normalization;
clicked => {
@@ -657,12 +674,12 @@ export component PlayerBar inherits Rectangle {
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/layout-grid.svg");
icon-size: 16px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Now Playing view");
clicked => {
layout-menu.show();
@@ -742,11 +759,11 @@ export component PlayerBar inherits Rectangle {
IconButton {
icon: @image-url("../assets/icons/mic-vocal.svg");
icon-size: 16px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Lyrics");
active: ShellState.right-panel == 2;
active: ShellState.lyrics-open;
clicked => {
ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
ShellState.lyrics-open = !ShellState.lyrics-open;
}
}
}
@@ -761,7 +778,7 @@ export component PlayerBar inherits Rectangle {
? @image-url("../assets/icons/volume-x.svg")
: @image-url("../assets/icons/volume-2.svg");
icon-size: 16px;
btn-width: 28px;
btn-width: 32px;
tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute");
active: NowPlayingState.muted;
clicked => {
@@ -791,7 +808,7 @@ export component PlayerBar inherits Rectangle {
IconButton {
icon: @image-url("../assets/icons/minus.svg");
icon-size: 15px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Volume down");
clicked => {
if (!root.vol-locked) {
@@ -805,7 +822,7 @@ export component PlayerBar inherits Rectangle {
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 15px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Volume up");
clicked => {
if (!root.vol-locked) {
@@ -823,11 +840,11 @@ export component PlayerBar inherits Rectangle {
IconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
icon-size: 16px;
btn-width: 28px;
btn-width: 32px;
tooltip: @tr("Queue");
active: ShellState.right-panel == 1;
active: ShellState.queue-open;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
ShellState.queue-open = !ShellState.queue-open;
}
}
}
+48 -41
View File
@@ -125,8 +125,14 @@ component QueueRow inherits Rectangle {
callback clicked();
callback remove();
height: 52px;
background: ta.has-hover ? Theme.surface-hover : transparent;
// Compact rows with a subtle zebra so they read as distinct without the
// tall, airy spacing they had before.
height: 44px;
background: ta.has-hover
? Theme.surface-hover
: (Math.mod(root.index, 2) == 1
? Theme.surface-elevated.with-alpha(0.35)
: transparent);
border-radius: Radius.sm;
// Declared before the content so the context-menu button (which has
@@ -143,24 +149,24 @@ component QueueRow inherits Rectangle {
HorizontalLayout {
padding-left: Spacing.sm;
padding-right: Spacing.xs;
padding-top: 6px;
padding-bottom: 6px;
spacing: 10px;
padding-top: 4px;
padding-bottom: 4px;
spacing: 9px;
// Leading: track number (UP NEXT) or thumbnail (History).
if root.show-number: VerticalLayout {
alignment: center;
width: 26px;
width: 22px;
Text {
text: (root.index + 1);
color: Theme.text-muted;
font-size: 13px;
font-size: 12px;
horizontal-alignment: center;
}
}
if !root.show-number: Rectangle {
width: 40px;
height: 40px;
width: 34px;
height: 34px;
border-radius: 4px;
background: Theme.surface-elevated;
clip: true;
@@ -183,7 +189,7 @@ component QueueRow inherits Rectangle {
Text {
text: root.item.title;
color: Theme.text-primary;
font-size: 13px;
font-size: 12px;
font-weight: Typography.medium;
overflow: elide;
}
@@ -195,7 +201,7 @@ component QueueRow inherits Rectangle {
Text {
text: root.item.artist;
color: Theme.text-muted;
font-size: 11px;
font-size: 10px;
overflow: elide;
}
}
@@ -274,24 +280,20 @@ component QueueRow inherits Rectangle {
}
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;
// Width is owned by the right-panel column in AppShell (which animates it);
// this fills that column and vertical-stretches to its share of the height.
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();
}
// The panel is conditionally mounted (`if queue-open` at the AppShell mount
// site, ADR-010), so it is CREATED each time it opens — pull a fresh queue
// snapshot on `init` (the queue may have changed/been restored while closed).
init => {
QueueState.panel-opened();
}
VerticalLayout {
@@ -341,7 +343,7 @@ export component QueueSidebar inherits Rectangle {
alignment: center;
CloseButton {
clicked => {
ShellState.right-panel = 0;
ShellState.queue-open = false;
}
}
}
@@ -361,8 +363,8 @@ export component QueueSidebar inherits Rectangle {
viewport-height: queue-body.preferred-height;
queue-body := VerticalLayout {
padding: Spacing.md;
spacing: Spacing.md;
padding: 10px;
spacing: 12px;
// NOW PLAYING section.
if QueueState.has-current: VerticalLayout {
@@ -378,14 +380,19 @@ export component QueueSidebar inherits Rectangle {
Rectangle {
background: Theme.surface-elevated;
border-radius: Radius.sm;
height: 68px;
// Same height as a queue row — the bg + section
// separator are enough to distinguish it.
height: 44px;
HorizontalLayout {
padding: 10px;
spacing: 12px;
padding-left: 6px;
padding-right: 6px;
padding-top: 4px;
padding-bottom: 4px;
spacing: 9px;
// Artwork.
Rectangle {
width: 48px;
height: 48px;
width: 34px;
height: 34px;
border-radius: 4px;
background: Theme.surface-card;
clip: true;
@@ -400,13 +407,13 @@ export component QueueSidebar inherits Rectangle {
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
spacing: 3px;
spacing: 2px;
HorizontalLayout {
spacing: 5px;
Text {
text: QueueState.now-playing.title;
color: Theme.text-primary;
font-size: Typography.body;
font-size: 12px;
font-weight: Typography.medium;
overflow: elide;
}
@@ -418,7 +425,7 @@ export component QueueSidebar inherits Rectangle {
Text {
text: QueueState.now-playing.artist;
color: Theme.text-muted;
font-size: 12px;
font-size: 10px;
overflow: elide;
}
}
@@ -426,8 +433,8 @@ export component QueueSidebar inherits Rectangle {
VerticalLayout {
alignment: center;
fav := Rectangle {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border-radius: Radius.sm;
background: fav-ta.has-hover
? Theme.surface-hover
@@ -564,7 +571,7 @@ export component QueueSidebar inherits Rectangle {
viewport-height: history-body.preferred-height;
history-body := VerticalLayout {
padding: Spacing.md;
padding: 10px;
spacing: 8px;
Text {
@@ -651,12 +658,12 @@ export component QueueSidebar inherits Rectangle {
}
}
// Spacer.
Rectangle { horizontal-stretch: 1; }
// Action 4 — inline queue search field.
// Inline queue search field — flexes to fill the space left of the
// four actions so it never overflows / overlaps at the narrower
// (240px) panel width.
Rectangle {
width: 150px;
horizontal-stretch: 1;
min-width: 90px;
height: 30px;
border-radius: Radius.sm;
background: Theme.surface-elevated;
+122 -67
View File
@@ -28,7 +28,10 @@ component MetaLink inherits TouchArea {
// and free to shrink to nothing so the audio stamp stays at the wall.
max-width: link-text.preferred-width;
min-width: 0px;
height: link-text.preferred-height;
// Fill the meta-row height (driven by the context icon) and centre the text
// inside, so the artist·album line is vertically centred with the icon — a
// fixed preferred-height left it sitting ~1-2px above the icon/dot.
vertical-stretch: 1;
mouse-cursor: pointer;
clicked => {
root.activated();
@@ -36,6 +39,7 @@ component MetaLink inherits TouchArea {
link-text := Text {
x: 0;
width: parent.width;
height: 100%;
text: root.label;
color: root.has-hover ? Theme.text-primary : Theme.text-muted;
font-size: 12px;
@@ -44,31 +48,42 @@ component MetaLink inherits TouchArea {
}
}
// One output led (backend / mode). Fills its allotted slot so a row of leds is
// a fixed width; coloured when a deliberate mode is engaged.
component OutputBadge inherits Rectangle {
// One output indicator rendered as a dot LED + a constant-colour label (New
// mode). The dot is `circle-dot` (colored) when the mode is engaged, `circle`
// (muted) when off; the LABEL never changes colour — it stays the quality-badge
// text grey so only the dot signals state. Content-sized so a row of these
// left-packs; the parent stamp width is fixed so the row never reflows.
component DotLed inherits Rectangle {
in property <string> label;
in property <bool> active: false;
in property <color> on-color: #22c55e;
in property <string> tooltip;
// 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;
border-color: root.active ? root.on-color.with-alpha(0.5) : Theme.border-subtle;
Text {
text: root.label;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
color: root.active ? root.on-color : Theme.text-muted;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.3px;
HorizontalLayout {
spacing: 4px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.active
? @image-url("../assets/icons/circle-dot.svg")
: @image-url("../assets/icons/circle.svg");
width: 9px;
height: 9px;
tint: root.active ? root.on-color : Theme.text-muted;
}
}
Text {
// Quality-badge text grey — constant regardless of on/off.
text: root.label;
color: #b0b0b0;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.3px;
vertical-alignment: center;
}
}
ta := TouchArea {
TouchArea {
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
@@ -97,6 +112,12 @@ export component SongCard inherits Rectangle {
// 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;
// New mode renders the output indicators as dot LEDs (circle-dot on /
// circle off + a constant-colour label) instead of the bordered OutputBadge
// chips; classic keeps the chips. The stamp is widened so the longest labels
// (PIPEWIRE + DACPASS) fit while the stamp width stays constant regardless of
// which backend/mode is shown.
in property <bool> dot-leds: false;
callback open-album(string);
callback open-artist(string);
@@ -114,7 +135,16 @@ export component SongCard inherits Rectangle {
// 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;
// Classic (glass) gets an extra right inset so the stamp clears the visible
// card border by roughly the same margin it has top/bottom (auto-centred).
// Test value — bump if it still reads tight.
property <length> stamp-right-margin: root.glass ? 2px : 0px;
property <length> stamp-w: root.dot-leds
// Hug the content so there is no dead space to the right: the dot-LED row
// (circle + PIPEWIRE/DACPASS) is ~116px and the full quality badge ~125px.
// Width is constant per mode so the stamp never reflows as labels change.
? (root.compact-stamp ? (root.narrow ? 116px : 120px) : 128px)
: (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;
@@ -238,15 +268,18 @@ export component SongCard inherits Rectangle {
// Context (layers) button — opens the page playback came from.
if SettingsState.show-context-icon: Rectangle {
width: 18px;
width: 16px;
height: 18px;
border-radius: 3px;
background: ctxta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/layers.svg");
// disc-album, pinned flush-LEFT (x:0): centering the glyph in
// its box left a ~2px indent so the artist·album line started
// further right than the title above it and read as misaligned.
source: @image-url("../assets/icons/disc-album.svg");
width: 13px;
height: 13px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ctxta.has-hover ? Theme.text-primary : Theme.text-muted;
}
@@ -304,29 +337,10 @@ export component SongCard inherits Rectangle {
}
}
}
// Remote-renderer badge — only when a peer renderer owns playback.
if NowPlayingState.has-track
&& NowPlayingState.is-remote
&& NowPlayingState.cast-target != "": HorizontalLayout {
spacing: 4px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: 11px;
height: 11px;
tint: #e0b341;
}
}
Text {
text: NowPlayingState.cast-target;
color: #e0b341;
font-size: 10px;
vertical-alignment: center;
}
}
// The remote-renderer badge used to live here (below artist·album). It
// now moves into the audio-stamp LED row (when a peer/cast device owns
// playback the LOCAL output LEDs are meaningless, so the stamp shows the
// render target there instead).
}
}
@@ -337,13 +351,25 @@ export component SongCard inherits Rectangle {
// 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;
x: root.width - root.pad - root.stamp-right-margin - root.stamp-w;
// This column FILLS the card height (an absolutely-positioned
// VerticalLayout takes the parent's height), so `alignment: center` is
// what middle-aligns the badge+LEDs block — exactly like the text
// column. Earlier y-math could never centre it: self.height == root.height
// made `(root.height - self.height) / 2 == 0`, and a VerticalLayout
// top-packs its fixed-height children by default → the stamp sat at the
// top.
y: 0;
height: root.height;
alignment: center;
width: root.stamp-w;
spacing: root.compact-stamp ? 3px : 2px;
// A bit more breathing room between the badge and the LEDs so the stamp
// fills the (taller, item-2) card and centres with only a small margin
// instead of floating with dead space below the LEDs.
spacing: 4px;
Rectangle {
height: root.compact-stamp ? (root.narrow ? 26px : 29px) : 36px;
height: root.compact-stamp ? (root.narrow ? 26px : 29px) : 38px;
QualityBadgeFull {
x: 0;
y: 0;
@@ -358,7 +384,7 @@ export component SongCard inherits Rectangle {
qta := TouchArea {
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Stored audio quality");
TooltipState.text = NowPlayingState.quality-detail;
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
@@ -370,29 +396,58 @@ export component SongCard inherits Rectangle {
}
HorizontalLayout {
height: root.compact-stamp ? 15px : 18px;
spacing: root.compact-stamp ? 3px : 2px;
if !NowPlayingState.cast-active: OutputBadge {
height: root.compact-stamp ? 13px : 15px;
spacing: 8px;
// Render-target row stretches so the device name elides; the local
// dot LEDs left-pack at their natural width.
alignment: (NowPlayingState.is-remote && NowPlayingState.cast-target != "")
? stretch
: start;
// A peer/cast device owns playback → show the render TARGET instead
// of the (now meaningless) local output LEDs.
if NowPlayingState.is-remote && NowPlayingState.cast-target != "": HorizontalLayout {
horizontal-stretch: 1;
label: SettingsState.output-backend-label;
active: SettingsState.output-backend-active;
on-color: #5b8def;
tooltip: @tr("Audio backend");
spacing: 4px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: root.compact-stamp ? 11px : 13px;
height: root.compact-stamp ? 11px : 13px;
tint: #e0b341;
}
}
Text {
horizontal-stretch: 1;
text: NowPlayingState.cast-target;
color: #e0b341;
font-size: root.compact-stamp ? 8px : 9px;
font-weight: 600;
overflow: elide;
vertical-alignment: center;
}
}
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;
// Local-network cast (Chromecast / DLNA), no QConnect peer → protocol.
if NowPlayingState.cast-active && !NowPlayingState.is-remote: DotLed {
label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
active: true;
on-color: #a855f7;
tooltip: @tr("Casting");
}
// Local output → backend + mode dot LEDs.
if !NowPlayingState.is-remote && !NowPlayingState.cast-active: DotLed {
label: SettingsState.output-backend-label;
active: SettingsState.output-backend-active;
on-color: #5b8def;
tooltip: @tr("Audio backend");
}
if !NowPlayingState.is-remote && !NowPlayingState.cast-active: DotLed {
label: SettingsState.output-mode-label;
active: SettingsState.output-mode-active;
on-color: #22c55e;
tooltip: @tr("Output mode");
}
}
}
}
@@ -213,8 +213,8 @@ export component TransportControls inherits HorizontalLayout {
changed has-hover => {
if (self.has-hover) {
TooltipState.text = QueueState.now-playing-favorite
? @tr("Remove from favorites")
: @tr("Add to favorites");
? @tr("Remove from library")
: @tr("Add to library");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
+7 -2
View File
@@ -1877,8 +1877,13 @@ export global ShellState {
public function cycle-sidebar() {
root.sidebar-state = Math.mod(root.sidebar-state + 1, 3);
}
// 0 = none, 1 = queue, 2 = lyrics
in-out property <int> right-panel: 0;
// Right-side panels — independent now: Queue and Lyrics can BOTH be open
// at once, stacked vertically in a shared right column (Feishin-style). Each
// is toggled from its bar button and closed from its own X. The column is
// visible when either is open. (Replaces the old exclusive `right-panel` int
// where lyrics closed queue and vice-versa.)
in-out property <bool> queue-open: false;
in-out property <bool> lyrics-open: false;
// Section navigation placement: the sections (Discover/Library/Local
// Library/My QBZ) now live in the sidebar and open right-flyout menus.
in-out property <bool> nav-in-sidebar: true;