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
473 lines
21 KiB
Plaintext
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");
|
|
}
|
|
}
|
|
}
|
|
}
|