Files
qbz/crates/qbz-slint/ui/shell/SongCard.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

473 lines
21 KiB
Plaintext

// Now-playing song card — cover (or a sleeping cat when idle) + title /
// artist·album / optional remote-renderer badge, with the AUDIO STAMP (the
// quality badge stacked over the backend + mode leds) to the right — a fixed
// rectangle.
//
// Shared by the now-playing modes and later the miniplayer. Reads NowPlayingState
// + SettingsState directly. Hover tooltips report to the global TooltipState
// (rendered by TooltipOverlay), so this card may be clipped without losing them.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NowPlayingState, SettingsState, TooltipState } from "../state.slint";
// Clickable artist/album link. Elides and shares the row width through a
// stretch factor (the artist gets the larger share) so neither can be pushed
// to zero — the album just takes the ellipsis first when the card is tight.
component MetaLink inherits TouchArea {
in property <string> label;
// Row priority. The artist passes 2 and the album passes 1, so a long
// album elides before the artist does and both keep a readable slice.
in property <float> stretch: 1.0;
callback activated();
horizontal-stretch: root.stretch;
// Never wider than the text itself (so a short name doesn't over-stretch)
// and free to shrink to nothing so the audio stamp stays at the wall.
max-width: link-text.preferred-width;
min-width: 0px;
// Fill the meta-row height (driven by the context icon) and centre the text
// inside, so the artist·album line is vertically centred with the icon — a
// fixed preferred-height left it sitting ~1-2px above the icon/dot.
vertical-stretch: 1;
mouse-cursor: pointer;
clicked => {
root.activated();
}
link-text := Text {
x: 0;
width: parent.width;
height: 100%;
text: root.label;
color: root.has-hover ? Theme.text-primary : Theme.text-muted;
font-size: 12px;
overflow: elide;
vertical-alignment: center;
}
}
// One output indicator rendered as a dot LED + a constant-colour label (New
// mode). The dot is `circle-dot` (colored) when the mode is engaged, `circle`
// (muted) when off; the LABEL never changes colour — it stays the quality-badge
// text grey so only the dot signals state. Content-sized so a row of these
// left-packs; the parent stamp width is fixed so the row never reflows.
component DotLed inherits Rectangle {
in property <string> label;
in property <bool> active: false;
in property <color> on-color: #22c55e;
in property <string> tooltip;
HorizontalLayout {
spacing: 4px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.active
? @image-url("../assets/icons/circle-dot.svg")
: @image-url("../assets/icons/circle.svg");
width: 9px;
height: 9px;
tint: root.active ? root.on-color : Theme.text-muted;
}
}
Text {
// Quality-badge text grey — constant regardless of on/off.
text: root.label;
color: #b0b0b0;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.3px;
vertical-alignment: center;
}
}
TouchArea {
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
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;
}
}
}
}
export component SongCard inherits Rectangle {
in property <length> art-size: 60px;
in property <bool> show-art: true;
in property <bool> show-badges: true;
// Below the responsive break the stamp shrinks 10% (proportional) so it
// keeps clear of the controls column; above the break it is full size.
in property <bool> narrow: false;
// Classic layout: render as a contained card (elevated bg + subtle shadow,
// NO border — Tauri's card has none, and the border is what made the album
// art read as inset/framed instead of flush). New (glass=false) stays
// edge-to-edge transparent.
in property <bool> glass: false;
// Classic non-responsive view uses the FULL-size audio stamp (1:1 Tauri);
// New and the responsive (narrow) view keep the compact stamp.
in property <bool> compact-stamp: true;
// New mode renders the output indicators as dot LEDs (circle-dot on /
// circle off + a constant-colour label) instead of the bordered OutputBadge
// chips; classic keeps the chips. The stamp is widened so the longest labels
// (PIPEWIRE + DACPASS) fit while the stamp width stays constant regardless of
// which backend/mode is shown.
in property <bool> dot-leds: false;
callback open-album(string);
callback open-artist(string);
callback open-context(string /* kind */, string /* id */);
// Opens the track-info panel. In Tauri this fires from the title click; the
// bar surfaces it as an explicit small (i) affordance instead.
callback track-info();
property <string> ctx-kind: NowPlayingState.context-id != ""
? NowPlayingState.context-kind
: "album";
property <string> ctx-id: NowPlayingState.context-id != ""
? NowPlayingState.context-id
: NowPlayingState.album-id;
// Geometry helpers for the absolutely-pinned audio stamp.
property <length> pad: root.glass ? 2px : 0px;
// Classic (glass) gets an extra right inset so the stamp clears the visible
// card border by roughly the same margin it has top/bottom (auto-centred).
// Test value — bump if it still reads tight.
property <length> stamp-right-margin: root.glass ? 2px : 0px;
property <length> stamp-w: root.dot-leds
// Hug the content so there is no dead space to the right: the dot-LED row
// (circle + PIPEWIRE/DACPASS) is ~116px and the full quality badge ~125px.
// Width is constant per mode so the stamp never reflows as labels change.
? (root.compact-stamp ? (root.narrow ? 116px : 120px) : 128px)
: (root.compact-stamp ? (root.narrow ? 94px : 104px) : 140px);
property <bool> has-stamp: root.show-badges && NowPlayingState.has-track;
background: root.glass ? Theme.surface-elevated : transparent;
border-radius: root.glass ? 8px : 0px;
// No border — keeps the album art flush with the card (Tauri parity).
drop-shadow-blur: root.glass ? 8px : 0px;
drop-shadow-color: root.glass ? Theme.card-shadow : transparent;
HorizontalLayout {
// Cover + song info ONLY. This row stops BEFORE the audio stamp, which
// is pinned absolutely to the card's right edge (see the bottom of this
// component). Fixed deterministic widths: cover 56 | 11 | song info
// (fills the rest, truncates with ellipsis). The 4px gap to the stamp
// is reserved by the width formula.
x: root.pad;
y: 0;
height: root.height;
width: root.has-stamp
? root.width - 2 * root.pad - root.stamp-w - 4px
: root.width - 2 * root.pad;
spacing: 0px;
// --- Cover / idle cat ------------------------------------------------
if root.show-art: VerticalLayout {
alignment: center;
Rectangle {
width: root.art-size;
height: root.art-size;
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;
}
// Idle: a sleeping cat with drifting "z"s (RunCat at rest).
if !NowPlayingState.has-track: QbzIcon {
source: @image-url("../assets/icons/cat.svg");
width: root.art-size * 0.5;
height: root.art-size * 0.5;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px + root.art-size * 0.06;
tint: Theme.text-muted;
}
if !NowPlayingState.has-track: Text {
property <float> ph: Math.mod(animation-tick() / 1ms, 2200) / 2200;
text: "z";
x: root.art-size * 0.60 + self.ph * 5px;
y: root.art-size * 0.34 - self.ph * 18px;
color: Theme.text-muted;
opacity: 1.0 - self.ph;
font-size: 8px + self.ph * 4px;
font-italic: true;
}
if !NowPlayingState.has-track: Text {
property <float> ph: Math.mod(animation-tick() / 1ms + 1100, 2200) / 2200;
text: "z";
x: root.art-size * 0.66 + self.ph * 5px;
y: root.art-size * 0.30 - self.ph * 18px;
color: Theme.text-muted;
opacity: 1.0 - self.ph;
font-size: 8px + self.ph * 4px;
font-italic: true;
}
// Fetch overlay: while the requested track is resolving/downloading
// (no audio yet), dim the cover and show the spinner so the click is
// acknowledged. Cleared by the poll loop once audio advances.
if NowPlayingState.has-track && NowPlayingState.loading: Rectangle {
width: 100%;
height: 100%;
background: #000000aa;
LoadingSpinner {
size: root.art-size * 0.5;
tint: #ffffff;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
}
}
// Fixed gap: cover -> song info.
if root.show-art: Rectangle { width: 11px; }
// --- Title + context/artist/album + renderer badge -------------------
VerticalLayout {
horizontal-stretch: 1;
// Allow this column to shrink fully so it can never push the audio
// stamp past the song-card / controls wall.
min-width: 0px;
alignment: center;
spacing: 4px;
// Title — elides, and on hover shows the full text in a tooltip ONLY
// when it is actually truncated (the audio stamp ate into it).
// Title — elides. Clicking it opens track info (the transport's (i)
// is the second trigger, 1:1 with Tauri's title-click). Hovering a
// truncated title shows the full text in a tooltip.
Rectangle {
title-txt := Text {
width: 100%;
text: NowPlayingState.has-track
? NowPlayingState.title
: @tr("Nothing playing");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
ttta := TouchArea {
mouse-cursor: (NowPlayingState.has-track && !NowPlayingState.is-ephemeral)
? pointer : default;
clicked => {
// Ephemeral tracks have no track-info page.
if (NowPlayingState.has-track && !NowPlayingState.is-ephemeral) {
root.track-info();
}
}
changed has-hover => {
if (self.has-hover && NowPlayingState.has-track
&& title-txt.preferred-width > self.width) {
TooltipState.text = NowPlayingState.title;
TooltipState.anchor-x = ttta.absolute-position.x + ttta.width / 2;
TooltipState.anchor-y = ttta.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
}
if NowPlayingState.has-track: HorizontalLayout {
spacing: 7px;
// Context (layers) button — opens the page playback came from.
if SettingsState.show-context-icon: Rectangle {
width: 16px;
height: 18px;
border-radius: 3px;
background: ctxta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
// turntable (the "playing from" context), pinned flush-LEFT
// (x:0): centering the glyph in its box left a ~2px indent so
// the artist·album line read as misaligned vs the title.
// (disc-album now means "flat folders" in the Folders toggle.)
source: @image-url("../assets/icons/turntable.svg");
width: 13px;
height: 13px;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: ctxta.has-hover ? Theme.text-primary : Theme.text-muted;
}
ctxta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (root.ctx-id != "") {
root.open-context(root.ctx-kind, root.ctx-id);
}
}
changed has-hover => {
if (self.has-hover) {
TooltipState.text = @tr("Go to where this is playing from");
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
// Artist — keeps priority (stretch 2); only elides once even the
// album has already been squeezed to its minimum.
MetaLink {
label: NowPlayingState.artist;
stretch: 2.0;
activated => {
if (NowPlayingState.artist-id != "" && !NowPlayingState.is-ephemeral) {
root.open-artist(NowPlayingState.artist-id);
}
}
}
VerticalLayout {
alignment: center;
Rectangle {
width: 3px;
height: 3px;
border-radius: 1.5px;
background: Theme.text-muted;
}
}
// Album — elides FIRST (stretch 1) so a long album can never push
// the audio stamp under the controls column (the song-card /
// controls divider is a hard wall), yet it always keeps a readable
// slice with an ellipsis instead of vanishing.
MetaLink {
label: NowPlayingState.album;
stretch: 1.0;
activated => {
if (NowPlayingState.album-id != "" && !NowPlayingState.is-ephemeral) {
root.open-album(NowPlayingState.album-id);
}
}
}
}
// The remote-renderer badge used to live here (below artist·album). It
// now moves into the audio-stamp LED row (when a peer/cast device owns
// playback the LOCAL output LEDs are meaningless, so the stamp shows the
// render target there instead).
}
}
// --- Audio stamp: quality badge over two leds. ABSOLUTELY pinned to the
// card's right edge (x = card width - padding - stamp width), vertically
// centred, so it is ALWAYS flush right no matter how the song-info column
// lays out. Deterministic fix for the trailing gap — does NOT rely on the
// text column's stretch reaching the edge.
if root.has-stamp: VerticalLayout {
x: root.width - root.pad - root.stamp-right-margin - root.stamp-w;
// This column FILLS the card height (an absolutely-positioned
// VerticalLayout takes the parent's height), so `alignment: center` is
// what middle-aligns the badge+LEDs block — exactly like the text
// column. Earlier y-math could never centre it: self.height == root.height
// made `(root.height - self.height) / 2 == 0`, and a VerticalLayout
// top-packs its fixed-height children by default → the stamp sat at the
// top.
y: 0;
height: root.height;
alignment: center;
width: root.stamp-w;
// A bit more breathing room between the badge and the LEDs so the stamp
// fills the (taller, item-2) card and centres with only a small margin
// instead of floating with dead space below the LEDs.
spacing: 4px;
Rectangle {
height: root.compact-stamp ? (root.narrow ? 26px : 29px) : 38px;
QualityBadgeFull {
x: 0;
y: 0;
width: parent.width;
height: parent.height;
compact: root.compact-stamp;
show-icon: true;
scale-factor: root.compact-stamp && root.narrow ? 0.9 : 1.0;
tier: NowPlayingState.quality-tier;
detail: NowPlayingState.quality-detail;
}
qta := TouchArea {
changed has-hover => {
if (self.has-hover) {
TooltipState.text = NowPlayingState.quality-detail;
TooltipState.anchor-x = parent.absolute-position.x + parent.width / 2;
TooltipState.anchor-y = parent.absolute-position.y;
TooltipState.show = true;
} else {
TooltipState.show = false;
}
}
}
}
HorizontalLayout {
height: root.compact-stamp ? 13px : 15px;
spacing: 8px;
// Render-target row stretches so the device name elides; the local
// dot LEDs left-pack at their natural width.
alignment: (NowPlayingState.is-remote && NowPlayingState.cast-target != "")
? stretch
: start;
// A peer/cast device owns playback → show the render TARGET instead
// of the (now meaningless) local output LEDs.
if NowPlayingState.is-remote && NowPlayingState.cast-target != "": HorizontalLayout {
horizontal-stretch: 1;
spacing: 4px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: root.compact-stamp ? 11px : 13px;
height: root.compact-stamp ? 11px : 13px;
tint: #e0b341;
}
}
Text {
horizontal-stretch: 1;
text: NowPlayingState.cast-target;
color: #e0b341;
font-size: root.compact-stamp ? 8px : 9px;
font-weight: 600;
overflow: elide;
vertical-alignment: center;
}
}
// Local-network cast (Chromecast / DLNA), no QConnect peer → protocol.
if NowPlayingState.cast-active && !NowPlayingState.is-remote: DotLed {
label: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
active: true;
on-color: #a855f7;
tooltip: @tr("Casting");
}
// Local output → backend + mode dot LEDs.
if !NowPlayingState.is-remote && !NowPlayingState.cast-active: DotLed {
label: SettingsState.output-backend-label;
active: SettingsState.output-backend-active;
on-color: #5b8def;
tooltip: @tr("Audio backend");
}
if !NowPlayingState.is-remote && !NowPlayingState.cast-active: DotLed {
label: SettingsState.output-mode-label;
active: SettingsState.output-mode-active;
on-color: #22c55e;
tooltip: @tr("Output mode");
}
}
}
}