Files
qbz/crates/qbz-slint/ui/shell/TransportControls.slint
T
vicrodh ed7891c697 feat(slint): play-fetching spinner (now-playing bar + track row + album button)
A play can take seconds before audio starts (esp. Plex, whose resolve downloads
the whole file) with no feedback today. Add a source-agnostic loading indicator:

- A loading flag keyed to PENDING_PLAY_ID, raised at the top of play_audible
  (before the Plex/Qobuz/local resolve — single chokepoint, covers auto-advance)
  and cleared by the poll loop when the requested track's audio actually starts
  (position > 0). Cleared on every play-error early-return and force-cleared on
  queue-finished / peer-controller / ephemeral-stop so it never sticks.
- Watchdog: force-clear after 45s if the engine accepted a play that silently
  never advances (undecodable-but-valid file) — otherwise the spinner is eternal.
- Spinner on three surfaces driven by NowPlayingState.loading: now-playing bar
  (cover overlay + play/pause button), the active track row's play cell, and the
  album big play button (gated to the album actually being launched).

Audio backend / playback routing untouched. The spinner only communicates the
wait; the underlying Plex fetch latency is fixed separately (progressive streaming).

cc
2026-06-08 12:40:06 -06:00

230 lines
8.9 KiB
Plaintext

// Now-playing transport cluster: shuffle, previous, play/pause, next, repeat,
// and a trailing add affordance. Extracted from PlayerBar so the same cluster
// can sit in the CENTRE column (New layout) or the LEFT column (Classic layout)
// without duplicating popups.
//
// `play-circle` = New filled disc vs Classic plain glyph.
// `classic-actions` = New keeps the grouped "+" add menu; Classic swaps it for
// an inline one-click add-to-playlist + a favorite toggle
// (filled-heart state), 1:1 with the Tauri Classic transport.
// The track-info "(i)" lives in the song card, not here.
//
// Reads NowPlayingState + QueueState directly; bubbles add/menu actions through
// `media-action` to PlayerBar.
import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, QueueState, TooltipState } from "../state.slint";
export component TransportControls inherits HorizontalLayout {
// New layout = filled white disc; Classic = plain glyph.
in property <bool> play-circle: true;
// Classic = inline add-to-playlist + favorite; New = grouped "+" menu.
in property <bool> classic-actions: false;
callback media-action(string /* kind */, string /* id */, string /* action */);
spacing: 2px;
// Track info — the first control (left of shuffle), 1:1 with the other
// views. The song-card title is the second trigger. Keeping it here also
// restores PLAY's symmetric centring (3 controls each side) in New.
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/info.svg");
tooltip: @tr("Track info");
clicked => {
if (NowPlayingState.has-track) {
root.media-action("track", NowPlayingState.track-id, "track-info");
}
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/shuffle.svg");
icon-size: 16px;
tooltip: @tr("Shuffle");
active: NowPlayingState.shuffle;
clicked => {
NowPlayingState.toggle-shuffle();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-back.svg");
tooltip: @tr("Previous");
clicked => {
NowPlayingState.previous();
}
}
}
VerticalLayout {
alignment: center;
PlayButton {
circle: root.play-circle;
loading: NowPlayingState.loading;
icon: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
tooltip: NowPlayingState.playing ? @tr("Pause") : @tr("Play");
clicked => {
NowPlayingState.toggle-play();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/skip-forward.svg");
tooltip: @tr("Next");
clicked => {
NowPlayingState.next();
}
}
}
VerticalLayout {
alignment: center;
IconButton {
icon: NowPlayingState.repeat-mode == 2
? @image-url("../assets/icons/repeat-1.svg")
: @image-url("../assets/icons/repeat.svg");
icon-size: 16px;
tooltip: @tr("Repeat");
active: NowPlayingState.repeat-mode != 0;
clicked => {
NowPlayingState.cycle-repeat();
}
}
}
// New: "+" — grouped add menu.
if !root.classic-actions: VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to…");
clicked => {
add-menu.show();
}
}
add-menu := PopupWindow {
x: 0px;
y: -222px;
width: 232px;
height: 214px;
close-policy: close-on-click;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to library");
clicked => {
root.media-action("track", NowPlayingState.track-id, "favorite");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("track", NowPlayingState.track-id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("track", NowPlayingState.track-id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("Add to mixtape");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-mixtape");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Add album to collection");
enabled: NowPlayingState.album-id != "";
clicked => {
root.media-action("album", NowPlayingState.album-id, "favorite");
}
}
}
}
}
}
// Classic: inline add-to-playlist (one click) — 1:1 with Tauri.
if root.classic-actions: VerticalLayout {
alignment: center;
IconButton {
icon: @image-url("../assets/icons/plus.svg");
icon-size: 16px;
tooltip: @tr("Add to playlist");
clicked => {
root.media-action("track", NowPlayingState.track-id, "add-to-playlist");
}
}
}
// Classic: inline favorite toggle with filled-heart state — 1:1 with Tauri.
if root.classic-actions: VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: fav-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: QueueState.now-playing-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.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: QueueState.now-playing-favorite
? Theme.favorite
: (fav-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
fav-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
QueueState.toggle-now-playing-favorite();
}
changed has-hover => {
if (self.has-hover) {
TooltipState.text = QueueState.now-playing-favorite
? @tr("Remove from library")
: @tr("Add to library");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
}
}