Files
qbz/crates/qbz-slint/ui/shell/PlayerBar.slint
T
vicrodh 682719cab0 feat(slint): library/sidebar UX overhaul + theme switching + fixes
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
  full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.

Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.

Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
  grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.

cc
2026-05-30 15:40:35 -06:00

322 lines
12 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, ShellState, AppearanceState } 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; }
// Lyrics + queue panel toggles (moved here from the header).
IconButton {
icon: @image-url("../assets/icons/captions.svg");
icon-size: 16px;
active: ShellState.right-panel == 2;
clicked => {
ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
}
}
IconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
icon-size: 16px;
active: ShellState.right-panel == 1;
clicked => {
ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
}
}
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);
}
}
}
// Volume +/- steppers — opt-in (Settings > Appearance).
if AppearanceState.show-volume-steppers: IconButton {
icon: @image-url("../assets/icons/minus.svg");
icon-size: 15px;
clicked => {
NowPlayingState.set-volume(Math.max(0.0, NowPlayingState.volume - 0.05));
}
}
if AppearanceState.show-volume-steppers: IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 15px;
clicked => {
NowPlayingState.set-volume(Math.min(1.0, NowPlayingState.volume + 0.05));
}
}
}
}
}
}
}