Files
qbz/crates/qbz-slint/ui/shell/PlayerBar.slint
T
vicrodh e845c59fea feat(qbz-slint): per-device-type icons in the QConnect device picker
Mirror Qobuz Connect's device icons in the connect flyout: map each renderer's
device_type to an icon (6 -> smartphone, 5 -> monitor or globe when the name is a
web player/browser, else -> speaker), matching the Tauri QconnectBadge
resolveDeviceType. Adds lucide monitor/smartphone/speaker/globe svgs, an icon key
on the QconnectDevice model computed in the event sink, and a per-row icon in the
PlayerBar device list.

cc
2026-06-03 11:19:14 -06:00

838 lines
42 KiB
Plaintext

// 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 <bool> active: false;
in property <string> tooltip;
callback clicked();
property <color> gold: #e0b341;
width: 28px;
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 => {
if (self.has-hover && root.tooltip != "" && !root.active) {
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 <string> label;
in property <string> hint;
in property <bool> 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 <float> 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 <float> col-side: ShellState.npb-mode == 1 ? 0.324 : root.side-frac;
property <float> 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 <bool> vol-locked: (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 {
// Fill the controls-row height (bar - top6 - seek20
// - gap6 - bottom6 = bar - 38).
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;
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() => {
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;
// 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
// 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;
y: (parent.height - self.height) / 2;
art-size: 56px;
// 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() => {
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: 28px;
height: 32px;
ConnectButton {
active: NowPlayingState.is-remote;
tooltip: @tr("Qobuz Connect");
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: 28px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/cast.svg");
icon-size: 16px;
btn-width: 28px;
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: 28px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/settings-2.svg");
icon-size: 16px;
btn-width: 28px;
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: 28px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/layout-grid.svg");
icon-size: 16px;
btn-width: 28px;
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: 28px;
tooltip: @tr("Lyrics");
active: ShellState.right-panel == 2;
clicked => {
ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
}
}
}
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: 28px;
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: 28px;
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: 28px;
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: 28px;
tooltip: @tr("Queue");
active: ShellState.right-panel == 1;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
}
}
}
}
}
}
Rectangle { height: 6px; }
}
}