diff --git a/crates/qbz-slint/src/qconnect_service.rs b/crates/qbz-slint/src/qconnect_service.rs
index 1a8353c06..ad39a2a7e 100644
--- a/crates/qbz-slint/src/qconnect_service.rs
+++ b/crates/qbz-slint/src/qconnect_service.rs
@@ -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;
diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint
index 753b48e1a..1287a7eb3 100644
--- a/crates/qbz-slint/ui/app.slint
+++ b/crates/qbz-slint/ui/app.slint
@@ -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);
}
diff --git a/crates/qbz-slint/ui/assets/icons/circle-dot.svg b/crates/qbz-slint/ui/assets/icons/circle-dot.svg
new file mode 100644
index 000000000..3764631d9
--- /dev/null
+++ b/crates/qbz-slint/ui/assets/icons/circle-dot.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-slint/ui/assets/icons/circle.svg b/crates/qbz-slint/ui/assets/icons/circle.svg
new file mode 100644
index 000000000..1858e22df
--- /dev/null
+++ b/crates/qbz-slint/ui/assets/icons/circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-slint/ui/assets/icons/disc-album.svg b/crates/qbz-slint/ui/assets/icons/disc-album.svg
new file mode 100644
index 000000000..68fefa3c3
--- /dev/null
+++ b/crates/qbz-slint/ui/assets/icons/disc-album.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint
index 71fea4cb4..dbdfd7b7b 100644
--- a/crates/qbz-slint/ui/shell/AppShell.slint
+++ b/crates/qbz-slint/ui/shell/AppShell.slint
@@ -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 {
diff --git a/crates/qbz-slint/ui/shell/LyricsSidebar.slint b/crates/qbz-slint/ui/shell/LyricsSidebar.slint
index 430591902..106b0b8c2 100644
--- a/crates/qbz-slint/ui/shell/LyricsSidebar.slint
+++ b/crates/qbz-slint/ui/shell/LyricsSidebar.slint
@@ -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;
}
}
}
diff --git a/crates/qbz-slint/ui/shell/PlayerBar.slint b/crates/qbz-slint/ui/shell/PlayerBar.slint
index 2aa5dd281..38e3a3923 100644
--- a/crates/qbz-slint/ui/shell/PlayerBar.slint
+++ b/crates/qbz-slint/ui/shell/PlayerBar.slint
@@ -34,7 +34,7 @@ component ConnectButton inherits Rectangle {
property 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;
}
}
}
diff --git a/crates/qbz-slint/ui/shell/QueueSidebar.slint b/crates/qbz-slint/ui/shell/QueueSidebar.slint
index 994856416..a3c141548 100644
--- a/crates/qbz-slint/ui/shell/QueueSidebar.slint
+++ b/crates/qbz-slint/ui/shell/QueueSidebar.slint
@@ -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 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 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;
diff --git a/crates/qbz-slint/ui/shell/SongCard.slint b/crates/qbz-slint/ui/shell/SongCard.slint
index ddb7c38c0..08cbb3474 100644
--- a/crates/qbz-slint/ui/shell/SongCard.slint
+++ b/crates/qbz-slint/ui/shell/SongCard.slint
@@ -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 label;
in property active: false;
in property on-color: #22c55e;
in property 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 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 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 pad: root.glass ? 2px : 0px;
- property 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 stamp-right-margin: root.glass ? 2px : 0px;
+ property 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 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");
+ }
}
}
}
diff --git a/crates/qbz-slint/ui/shell/TransportControls.slint b/crates/qbz-slint/ui/shell/TransportControls.slint
index 3ca2ba71c..7f19ebf21 100644
--- a/crates/qbz-slint/ui/shell/TransportControls.slint
+++ b/crates/qbz-slint/ui/shell/TransportControls.slint
@@ -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;
diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint
index e7396bd0f..12a3f89f5 100644
--- a/crates/qbz-slint/ui/state.slint
+++ b/crates/qbz-slint/ui/state.slint
@@ -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 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 queue-open: false;
+ in-out property 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 nav-in-sidebar: true;