Playback (minimal, album-focused): - playback.rs controller: play an album (set queue + start audio), play a single track, enqueue, play a queue index, transport (play/pause, next, previous, seek, volume) - the audible step uses Player::play_track (fetch -> download -> play); the protected audio backend is untouched - a 450ms poll loop pushes position/progress/times/playing onto NowPlayingState and auto-advances at end of track - NowPlayingState / QueueState globals; NowPlayingBar bound to real state (song card, seek bar, transport, volume); QueueSidebar lists the queue and rows play on click - a played track is recorded into the recently-played store Detail views: - album description and artist biography stay with the metadata, filling the width to the right of the artwork (not below the header) - header nav tabs gained a dropdown caret and 2px side padding Settings > Playback defaults are now opt-out: show context icon, restore session, resume position and stream-uncached default on; streaming-only stays opt-in; initial buffer defaults to 2s. Known gaps: queue/now-playing artwork not yet threaded (Image is not Send); autoplay-mode preference not consulted by auto-advance; buffer progress line stays at 0. cc
266 lines
9.8 KiB
Plaintext
266 lines
9.8 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; }
|
|
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: @image-url("../assets/icons/repeat.svg"); icon-size: 16px; }
|
|
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: @image-url("../assets/icons/volume-2.svg"); icon-size: 16px; }
|
|
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; }
|
|
IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 15px; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|