// Bottom now-playing / transport bar — Default mode. // // Three clusters over a full-width seek bar: the SONG CARD (left), the INFO + // TRANSPORT cluster (absolutely centred so it never drifts with the song-card // width), and the SECONDARY actions + volume cluster (right). Grouped popovers // open upward: "+" add menu, the Qobuz Connect flyout, the audio-settings // (normalization + gapless) popover, and the layout-mode popover. import { Theme } from "../foundation/semantic-colors.slint"; import { Spacing } from "../foundation/spacing.slint"; import { Layout } from "../foundation/layout.slint"; import { Radius } from "../foundation/radius.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzSlider } from "../primitives/QbzSlider.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; import { IconButton, PlayButton } from "../primitives/BarControls.slint"; import { SeekBar } from "SeekBar.slint"; import { SongCard } from "SongCard.slint"; import { TransportControls } from "TransportControls.slint"; import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState, QconnectDevState, QconnectDevice } from "../state.slint"; // IconButton + PlayButton now live in primitives/BarControls.slint (shared // with TransportControls). ConnectButton + ToggleRow stay local (bar-only). // Qobuz Connect button — monitor-speaker glyph; golden over a soft golden tint // when a remote renderer owns playback. The renderer name lives in the song // card (the remote badge under artist·album), not here. component ConnectButton inherits Rectangle { in property active: false; in property tooltip; callback clicked(); property gold: #e0b341; width: 32px; height: 32px; border-radius: Radius.sm; background: root.active ? root.gold.with-alpha(0.16) : (ta.has-hover ? Theme.surface-hover : transparent); border-width: root.active ? 1px : 0px; border-color: root.gold.with-alpha(0.45); QbzIcon { source: @image-url("../assets/icons/monitor-speaker.svg"); width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: root.active ? root.gold : (ta.has-hover ? Theme.text-primary : Theme.text-secondary); } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } changed has-hover => { // 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; TooltipState.show = true; } else if (!self.has-hover) { TooltipState.show = false; } } } } // A labelled toggle row for the audio-settings popover. component ToggleRow inherits Rectangle { in property label; in property hint; in property checked; callback toggled(bool); height: 44px; HorizontalLayout { padding-left: 12px; padding-right: 12px; spacing: 12px; VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 1px; Text { text: root.label; color: Theme.text-primary; font-size: 13px; } if root.hint != "": Text { text: root.hint; color: Theme.text-muted; font-size: 10px; } } VerticalLayout { alignment: center; QbzToggle { checked: root.checked; toggled(v) => { root.toggled(v); } } } } } export component PlayerBar inherits Rectangle { callback open-album(string); callback open-artist(string); callback media-action(string, string, string); callback settings-bool(string, bool); height: Layout.player-bar-height; background: Theme.surface-card; // Responsive column zones. The song-card (left) and the buttons (right) // share the SAME fraction so they are symmetric — which makes the centre // transport column absolutely centred (PLAY lands on the window centre) at // every width. The side fraction shrinks as the window grows so the song // card / buttons never sprawl on wide screens. // width < 1366 -> 39% | 22% | 39% // 1366..<1920 -> 30% | 40% | 30% // width >= 1920 -> 25% | 50% | 25% property side-frac: root.width >= 1920px ? 0.25 : (root.width >= 1366px ? 0.30 : 0.39); // Classic uses a fixed ~32.4/35.2/32.4 split (the centre song card is 12% // narrower than a plain 40% so it isn't oversized in the open view); New // uses the responsive side fraction above. BOTH modes keep the two side // columns equal so the centre column stays dead centre. property col-side: ShellState.npb-mode == 1 ? 0.324 : root.side-frac; property col-centre: 1.0 - 2.0 * root.col-side; // Volume lock, mirroring Tauri's handleVolumeChange formula: // (isAlsaDirectHw && !peerActive) || (peerActive && !remoteVolumeAllowed) // The local ALSA-Direct lock (bit-perfect) is LIFTED when controlling a peer // (the `!is-remote` term) so the user CAN adjust the remote renderer; the // peer half is the sink-pushed NowPlayingState.volume-locked. property vol-locked: (SettingsState.backend-is-alsa && SettingsState.alsa-plugin-is-hw && !NowPlayingState.is-remote) || NowPlayingState.volume-locked; VerticalLayout { Rectangle { height: 6px; } // Full-width seek bar (6px side margins, times at the ends). HorizontalLayout { padding-left: 6px; padding-right: 6px; SeekBar { height: 20px; progress: NowPlayingState.progress; cache: NowPlayingState.cache; seekable-max: NowPlayingState.seekable-max; elapsed: NowPlayingState.elapsed; remaining: NowPlayingState.remaining; duration-secs: NowPlayingState.duration-secs; seek(fraction) => { NowPlayingState.seek(fraction); } } } Rectangle { height: 6px; } // --- Controls: responsive symmetric zones (39/22/39, 30/40/30, // 25/50/25 by window width). The side columns share one fraction so // the transport (centre) is symmetric and PLAY lands on the window // centre (aligned with the header search + title). The album art // fills the row height. Columns clip; tooltips are a global overlay // so clipping is safe. HorizontalLayout { vertical-stretch: 1; padding-left: 6px; padding-right: 6px; spacing: 0px; // LEFT column. New = the song card (audio stamp floats to its // right wall). Classic = the transport cluster hugging the left // edge with a plain play glyph (the Tauri arrangement). Rectangle { width: (parent.width - 12px) * root.col-side; clip: true; // New: SongCard (a Rectangle) fills the column by default (a // Rectangle child takes 100% of its parent), so its inner // HorizontalLayout's stretch text column can push the audio // stamp to the right wall. Vertical centring is handled by the // card's own sub-columns. Do NOT wrap it in an `alignment: // start` layout — that forces preferred sizing and kills both. if ShellState.npb-mode == 0: SongCard { // 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: Math.round((parent.height - self.height) / 2 / 1px) * 1px; art-size: Layout.player-bar-height - 38px; // 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); } open-artist(id) => { root.open-artist(id); } open-context(kind, id) => { root.media-action(kind, id, "open"); } track-info() => { // Ephemeral tracks have no DB row → no track-info page. if (!NowPlayingState.is-ephemeral) { root.media-action("track", NowPlayingState.track-id, "track-info"); } } } // Classic: transport cluster, left-aligned, plain play glyph. if ShellState.npb-mode == 1: VerticalLayout { alignment: center; HorizontalLayout { alignment: start; TransportControls { play-circle: false; classic-actions: true; media-action(k, i, a) => { root.media-action(k, i, a); } } } } } // CENTRE column. New = the transport cluster (symmetric, filled // disc, absolutely centred). Classic = the glass song card, centred. Rectangle { width: (parent.width - 12px) * root.col-centre; clip: true; // New: centred transport, PLAY on the window centre. if ShellState.npb-mode == 0: VerticalLayout { alignment: center; HorizontalLayout { alignment: center; TransportControls { play-circle: true; media-action(k, i, a) => { root.media-action(k, i, a); } } } } // Classic: a contained glass card, centred and content-sized // (clamped 354-718 like Tauri) so the audio stamp stays close // to the track info instead of drifting to a far wall. // Classic: the glass song card, mounted DIRECTLY in the column // (a Rectangle child fills its Rectangle parent — exactly like // the New layout). That fill is what lets the text column stretch // and push the audio stamp FLUSH to the card's right edge. A // VerticalLayout wrapper here breaks the fill and floats the // stamp with a trailing gap — do NOT reintroduce one. // // `max-width` is the cap: the card grows with the window only up // to here, then stays put, so the audio stamp settles at a // consistent right edge instead of drifting across a too-wide // card. `height` + `y` float it (centred) in the 72px row. if ShellState.npb-mode == 1: SongCard { glass: true; // 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: 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, 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: Math.round((parent.height - self.height) / 2 / 1px) * 1px; art-size: 60px; // Full-size 1:1-Tauri stamp at every width. compact-stamp: false; narrow: root.width < 1366px; open-album(id) => { root.open-album(id); } open-artist(id) => { root.open-artist(id); } open-context(kind, id) => { root.media-action(kind, id, "open"); } track-info() => { // Ephemeral tracks have no DB row → no track-info page. if (!NowPlayingState.is-ephemeral) { root.media-action("track", NowPlayingState.track-id, "track-info"); } } } } // RIGHT column (responsive 39/30/25%, symmetric with the song // card) — secondary actions + volume, clustered at the right wall. Rectangle { width: (parent.width - 12px) * root.col-side; clip: true; HorizontalLayout { alignment: end; spacing: 1px; // Qobuz Connect — golden when remote; opens the connect flyout. VerticalLayout { alignment: center; Rectangle { 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: NowPlayingState.qconnect-connected ? @tr("Qobuz Connect: On") : @tr("Qobuz Connect: Off"); clicked => { qconnect-menu.show(); } } qconnect-menu := PopupWindow { x: 32px - 280px; y: -252px; width: 280px; height: 244px; close-policy: close-on-click-outside; Rectangle { background: Theme.surface-main; border-radius: Radius.sm; border-width: 1px; border-color: Theme.border-muted; drop-shadow-blur: 22px; drop-shadow-color: Theme.card-shadow; drop-shadow-offset-y: 6px; VerticalLayout { padding: 14px; spacing: 12px; // Header. HorizontalLayout { spacing: 8px; QbzIcon { source: @image-url("../assets/icons/monitor-speaker.svg"); width: 16px; height: 16px; tint: Theme.text-primary; } Text { text: @tr("Qobuz Connect"); color: Theme.text-primary; font-size: 14px; font-weight: 600; vertical-alignment: center; horizontal-stretch: 1; } } // Status row. Rectangle { height: 1px; background: Theme.border-subtle; } HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; Rectangle { width: 8px; height: 8px; border-radius: 4px; background: NowPlayingState.qconnect-connected ? #22c55e : Theme.text-muted; } } Text { text: NowPlayingState.qconnect-connected ? @tr("Connected") : @tr("Disconnected"); color: Theme.text-secondary; font-size: 12px; vertical-alignment: center; horizontal-stretch: 1; } } // "Playing on" header — shown only when a peer // renderer owns playback (controller mode). if NowPlayingState.is-remote && NowPlayingState.cast-target != "": Text { text: @tr("Playing on: {}", NowPlayingState.cast-target); color: Theme.accent; font-size: 11px; overflow: elide; } // Device list / empty state. Rectangle { vertical-stretch: 1; background: Theme.surface-elevated; border-radius: Radius.sm; clip: true; // Empty state (no session renderers yet). if QconnectDevState.devices.length == 0: VerticalLayout { alignment: center; spacing: 4px; Text { text: @tr("No devices found"); color: Theme.text-secondary; font-size: 12px; horizontal-alignment: center; } Text { text: @tr("Make sure your renderer is on the same network"); color: Theme.text-muted; font-size: 10px; horizontal-alignment: center; wrap: word-wrap; } } // Device picker — plain rows (ADR-008: no // pills). Tap a row to make it the active // renderer; the local device row is // "Play here" (pulls playback back to QBZ). if QconnectDevState.devices.length > 0: dev-flick := Flickable { viewport-height: dev-rows.preferred-height; dev-rows := VerticalLayout { padding: 4px; spacing: 2px; for d in QconnectDevState.devices: Rectangle { height: 34px; border-radius: Radius.sm; background: row-ta.has-hover ? Theme.surface-hover : transparent; row-ta := TouchArea { clicked => { QconnectDevState.set-active(d.renderer-id); qconnect-menu.close(); } } HorizontalLayout { padding-left: 8px; padding-right: 8px; spacing: 8px; VerticalLayout { alignment: center; QbzIcon { // Device-type icon, mirroring Qobuz Connect (computer / // web / mobile / speaker). @image-url needs a literal, so // pick among the four by the device's icon key. source: d.icon == "mobile" ? @image-url("../assets/icons/smartphone.svg") : d.icon == "speaker" ? @image-url("../assets/icons/speaker.svg") : d.icon == "web" ? @image-url("../assets/icons/globe.svg") : @image-url("../assets/icons/monitor.svg"); width: 14px; height: 14px; tint: d.is-active ? Theme.accent : Theme.text-secondary; } } Text { text: d.is-local ? @tr("Play here") : d.name; color: d.is-active ? Theme.text-primary : Theme.text-secondary; font-size: 12px; font-weight: d.is-active ? 600 : 400; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; } // Active indicator dot (no pill). VerticalLayout { alignment: center; Rectangle { width: 8px; height: 8px; border-radius: 4px; background: d.is-active ? Theme.accent : transparent; } } } } } } } // Connect / Disconnect toggle. Rectangle { height: 34px; border-radius: Radius.sm; background: connect-ta.pressed ? Theme.accent-pressed : connect-ta.has-hover ? Theme.accent-hover : Theme.accent; connect-ta := TouchArea { clicked => { NowPlayingState.qconnect-toggle(); qconnect-menu.close(); } } Text { text: NowPlayingState.qconnect-connected ? @tr("Disconnect") : @tr("Connect"); color: Theme.accent-text; font-size: 13px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; } } } } } } } // Cast (Chromecast / DLNA) — opens the cast flyout. VerticalLayout { alignment: center; Rectangle { width: 32px; height: 32px; IconButton { icon: @image-url("../assets/icons/cast.svg"); icon-size: 16px; btn-width: 32px; tooltip: @tr("Cast"); active: NowPlayingState.cast-active; clicked => { cast-menu.show(); } } cast-menu := PopupWindow { x: 32px - 280px; y: -252px; width: 280px; height: 244px; close-policy: close-on-click-outside; Rectangle { background: Theme.surface-main; border-radius: Radius.sm; border-width: 1px; border-color: Theme.border-muted; drop-shadow-blur: 22px; drop-shadow-color: Theme.card-shadow; drop-shadow-offset-y: 6px; VerticalLayout { padding: 14px; spacing: 12px; HorizontalLayout { spacing: 8px; QbzIcon { source: @image-url("../assets/icons/cast.svg"); width: 16px; height: 16px; tint: Theme.text-primary; } Text { text: @tr("Cast"); color: Theme.text-primary; font-size: 14px; font-weight: 600; vertical-alignment: center; horizontal-stretch: 1; } } Rectangle { height: 1px; background: Theme.border-subtle; } HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; Rectangle { width: 8px; height: 8px; border-radius: 4px; background: NowPlayingState.cast-active ? #a855f7 : Theme.text-muted; } } Text { text: NowPlayingState.cast-active ? @tr("Casting") : @tr("Not casting"); color: Theme.text-secondary; font-size: 12px; vertical-alignment: center; horizontal-stretch: 1; } } Rectangle { vertical-stretch: 1; background: Theme.surface-elevated; border-radius: Radius.sm; VerticalLayout { alignment: center; spacing: 4px; Text { text: @tr("No cast devices found"); color: Theme.text-secondary; font-size: 12px; horizontal-alignment: center; } Text { text: @tr("Looking for Chromecast & DLNA on your network"); color: Theme.text-muted; font-size: 10px; horizontal-alignment: center; wrap: word-wrap; } } } } } } } } // Audio settings — normalization + gapless. VerticalLayout { alignment: center; Rectangle { width: 32px; height: 32px; IconButton { icon: @image-url("../assets/icons/settings-2.svg"); icon-size: 16px; btn-width: 32px; tooltip: @tr("Audio settings"); active: SettingsState.normalization; clicked => { audio-menu.show(); } } audio-menu := PopupWindow { x: 32px - 252px; y: -126px; width: 252px; height: 118px; close-policy: close-on-click-outside; ContextMenu { ToggleRow { label: @tr("Normalization"); hint: @tr("Level loudness across tracks"); checked: SettingsState.normalization; toggled(v) => { root.settings-bool("normalization", v); } } ToggleRow { label: @tr("Gapless"); hint: @tr("Seamless track transitions"); checked: SettingsState.gapless; toggled(v) => { root.settings-bool("gapless", v); } } } } } } // Now-Playing view — unified switch for the bar layouts // (Default / Small / Large) + the window modes (Miniplayer / // Immersive). All are "presentation modes" of now-playing, so // one menu. Small/Large/window modes are disabled until those // layouts/features land. VerticalLayout { alignment: center; Rectangle { width: 32px; height: 32px; IconButton { icon: @image-url("../assets/icons/layout-grid.svg"); icon-size: 16px; btn-width: 32px; tooltip: @tr("Now Playing view"); clicked => { layout-menu.show(); } } layout-menu := PopupWindow { x: 32px - 196px; y: -242px; width: 196px; height: 234px; close-policy: close-on-click; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/panel-left.svg"); label: @tr("New"); selected: ShellState.npb-mode == 0; clicked => { root.media-action("npb-view", "", "new"); } } ContextMenuItem { icon: @image-url("../assets/icons/panel-right-close.svg"); label: @tr("Classic"); selected: ShellState.npb-mode == 1; clicked => { root.media-action("npb-view", "", "classic"); } } ContextMenuItem { icon: @image-url("../assets/icons/rows-3.svg"); label: @tr("Small"); enabled: false; clicked => { root.media-action("npb-view", "", "small"); } } ContextMenuItem { icon: @image-url("../assets/icons/layout-grid.svg"); label: @tr("Large"); enabled: false; clicked => { root.media-action("npb-view", "", "large"); } } Rectangle { height: 7px; Rectangle { height: 1px; y: 3px; background: Theme.border-subtle; } } ContextMenuItem { icon: @image-url("../assets/icons/picture-in-picture-2.svg"); label: @tr("Miniplayer"); enabled: false; clicked => { root.media-action("npb-view", "", "miniplayer"); } } ContextMenuItem { icon: @image-url("../assets/icons/maximize-2.svg"); label: @tr("Immersive"); enabled: false; clicked => { root.media-action("npb-view", "", "immersive"); } } } } } } // Lyrics. VerticalLayout { alignment: center; IconButton { icon: @image-url("../assets/icons/mic-vocal.svg"); icon-size: 16px; btn-width: 32px; tooltip: @tr("Lyrics"); active: ShellState.lyrics-open; clicked => { ShellState.lyrics-open = !ShellState.lyrics-open; } } } Rectangle { width: 6px; } // Volume — disabled while a remote renderer locks it. VerticalLayout { alignment: center; IconButton { icon: NowPlayingState.muted ? @image-url("../assets/icons/volume-x.svg") : @image-url("../assets/icons/volume-2.svg"); icon-size: 16px; btn-width: 32px; tooltip: NowPlayingState.muted ? @tr("Unmute") : @tr("Mute"); active: NowPlayingState.muted; clicked => { if (!root.vol-locked) { NowPlayingState.toggle-mute(); } } } } VerticalLayout { alignment: center; opacity: root.vol-locked ? 0.4 : 1.0; QbzSlider { width: 90px; minimum: 0; maximum: 100; value: NowPlayingState.volume * 100; changed(v) => { if (!root.vol-locked) { NowPlayingState.set-volume(v / 100.0); } } } } if AppearanceState.show-volume-steppers: VerticalLayout { alignment: center; IconButton { icon: @image-url("../assets/icons/minus.svg"); icon-size: 15px; btn-width: 32px; tooltip: @tr("Volume down"); clicked => { if (!root.vol-locked) { NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05)); } } } } if AppearanceState.show-volume-steppers: VerticalLayout { alignment: center; IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 15px; btn-width: 32px; tooltip: @tr("Volume up"); clicked => { if (!root.vol-locked) { NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05)); } } } } Rectangle { width: 6px; } // Queue panel toggle. VerticalLayout { alignment: center; IconButton { icon: @image-url("../assets/icons/list-ordered.svg"); icon-size: 16px; btn-width: 32px; tooltip: @tr("Queue"); active: ShellState.queue-open; clicked => { ShellState.queue-open = !ShellState.queue-open; } } } } } } Rectangle { height: 6px; } } }