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:
12 files changed
+300
-151
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user