Files
qbz/crates/qbz-slint/ui/shell/PlayerBar.slint
T
vicrodh 5ae303157a feat(slint): wire volume mute toggle in the player bar
The volume icon now toggles mute: it stashes the current level, drops
the player to zero, and swaps to the volume-x glyph. Unmute restores
the level, and any non-zero set-volume (slider drag or step) clears
the mute so the two controls stay consistent.

cc
2026-05-18 23:31:44 -06:00

303 lines
11 KiB
Plaintext

// Bottom now-playing / transport bar.
//
// 110px tall. A seek bar near the top, then a controls row: transport
// cluster (left), the song card (center, no background/border), and the
// secondary actions + volume cluster (right). Queue and Lyrics are
// intentionally absent — they live in the header.
//
// Playback state is not wired yet; the values are placeholders until the
// playback session lands.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.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 { SeekBar } from "SeekBar.slint";
import { NowPlayingState } from "../state.slint";
component IconButton inherits Rectangle {
in property <image> icon;
in property <length> icon-size: 18px;
in property <bool> active: false;
callback clicked();
width: 34px;
height: 34px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
// Primary, filled play/pause button.
component PlayButton inherits Rectangle {
in property <image> icon;
callback clicked();
width: 38px;
height: 38px;
border-radius: 19px;
background: ta.has-hover ? #ffffffd6 : #ffffff;
QbzIcon {
source: root.icon;
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #000000;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
// Clickable album/artist link text.
component MetaLink inherits TouchArea {
in property <string> label;
callback activated();
width: link-text.preferred-width;
height: link-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.activated();
}
link-text := Text {
text: root.label;
color: root.has-hover ? Theme.text-primary : Theme.text-muted;
font-size: 12px;
}
}
export component PlayerBar inherits Rectangle {
callback open-album(string);
callback open-artist(string);
height: Layout.player-bar-height;
background: Theme.surface-card;
VerticalLayout {
spacing: 0px;
// Gap from the top edge to the seek bar.
Rectangle { height: 14px; }
// --- Seek bar ----------------------------------------------------
HorizontalLayout {
padding-left: Spacing.md;
padding-right: Spacing.md;
SeekBar {
progress: NowPlayingState.progress;
cache: NowPlayingState.cache;
elapsed: NowPlayingState.elapsed;
remaining: NowPlayingState.remaining;
duration-secs: NowPlayingState.duration-secs;
seek(fraction) => {
NowPlayingState.seek(fraction);
}
}
}
// --- Controls row ------------------------------------------------
HorizontalLayout {
vertical-stretch: 1;
padding-left: Spacing.md;
padding-right: Spacing.md;
spacing: Spacing.md;
// Transport cluster.
VerticalLayout {
alignment: center;
HorizontalLayout {
spacing: 2px;
IconButton {
icon: @image-url("../assets/icons/shuffle.svg");
icon-size: 16px;
active: NowPlayingState.shuffle;
clicked => {
NowPlayingState.toggle-shuffle();
}
}
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
clicked => {
NowPlayingState.previous();
}
}
VerticalLayout {
alignment: center;
PlayButton {
icon: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
clicked => {
NowPlayingState.toggle-play();
}
}
}
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
clicked => {
NowPlayingState.next();
}
}
IconButton {
icon: NowPlayingState.repeat-mode == 2
? @image-url("../assets/icons/repeat-1.svg")
: @image-url("../assets/icons/repeat.svg");
icon-size: 16px;
active: NowPlayingState.repeat-mode != 0;
clicked => {
NowPlayingState.cycle-repeat();
}
}
IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/heart.svg"); icon-size: 16px; }
}
}
// Song card — no background, no border.
HorizontalLayout {
horizontal-stretch: 1;
alignment: center;
HorizontalLayout {
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 56px;
height: 56px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image {
source: NowPlayingState.artwork;
width: 100%;
height: 100%;
image-fit: cover;
}
}
}
VerticalLayout {
alignment: center;
spacing: 4px;
Text {
text: NowPlayingState.has-track
? NowPlayingState.title
: @tr("Nothing playing");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
HorizontalLayout {
spacing: 6px;
alignment: start;
if NowPlayingState.has-track: MetaLink {
label: NowPlayingState.artist;
activated => {
if (NowPlayingState.artist-id != "") {
root.open-artist(NowPlayingState.artist-id);
}
}
}
if NowPlayingState.has-track: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/layers.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
if NowPlayingState.has-track: MetaLink {
label: NowPlayingState.album;
activated => {
if (NowPlayingState.album-id != "") {
root.open-album(NowPlayingState.album-id);
}
}
}
}
}
}
}
// Secondary actions + volume cluster.
VerticalLayout {
alignment: center;
HorizontalLayout {
spacing: 2px;
IconButton { icon: @image-url("../assets/icons/cast.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/picture-in-picture-2.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/maximize-2.svg"); icon-size: 16px; }
IconButton { icon: @image-url("../assets/icons/audio-lines.svg"); icon-size: 16px; }
Rectangle { width: 6px; }
IconButton {
icon: NowPlayingState.muted
? @image-url("../assets/icons/volume-x.svg")
: @image-url("../assets/icons/volume-2.svg");
icon-size: 16px;
active: NowPlayingState.muted;
clicked => {
NowPlayingState.toggle-mute();
}
}
VerticalLayout {
alignment: center;
QbzSlider {
width: 90px;
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
changed(v) => {
NowPlayingState.set-volume(v / 100.0);
}
}
}
IconButton {
icon: @image-url("../assets/icons/minus.svg");
icon-size: 15px;
clicked => {
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
}
}
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 15px;
clicked => {
NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05));
}
}
}
}
}
}
}