From 14e3e3689c693b98cef80ed5c8d089d9861b8708 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:49:24 -0600 Subject: [PATCH] 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 --- crates/qbz-slint/src/qconnect_service.rs | 8 + crates/qbz-slint/ui/app.slint | 8 + .../qbz-slint/ui/assets/icons/circle-dot.svg | 4 + crates/qbz-slint/ui/assets/icons/circle.svg | 3 + .../qbz-slint/ui/assets/icons/disc-album.svg | 5 + crates/qbz-slint/ui/shell/AppShell.slint | 48 ++++- crates/qbz-slint/ui/shell/LyricsSidebar.slint | 11 +- crates/qbz-slint/ui/shell/PlayerBar.slint | 73 ++++--- crates/qbz-slint/ui/shell/QueueSidebar.slint | 89 +++++---- crates/qbz-slint/ui/shell/SongCard.slint | 189 +++++++++++------- .../ui/shell/TransportControls.slint | 4 +- crates/qbz-slint/ui/state.slint | 9 +- 12 files changed, 300 insertions(+), 151 deletions(-) create mode 100644 crates/qbz-slint/ui/assets/icons/circle-dot.svg create mode 100644 crates/qbz-slint/ui/assets/icons/circle.svg create mode 100644 crates/qbz-slint/ui/assets/icons/disc-album.svg 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;