Files
qbz/crates/qbz-ui/ui/primitives/TrackPlayCell.slint
T
vicrodh 8c1750a78f fix(ux+audio): #661 pause-during-load, anti-alias ring, menu icons, bulk unfav
- #661: a pause issued during the load window fell into the toggle's
  resume branch and got swallowed — the track started anyway and the
  sleep inhibitor stayed held. stop() now cancels the in-flight play
  (PENDING_PLAY_ID), so nothing plays and no inhibitor is acquired.
- Accent ring on the playing row: 1px -> 1.5px border (femtovg 1px
  stroked circles alias; CircleAction's header rings use 1.5px).
- Menu position hints (#N) removed from the track context menu (owner).
- Icon mapping across menus: list-start = Play next, list-end = Add to
  queue, list-plus = Play later; new list-start.svg +
  list-indent-increase.svg (queue manual-block section marker).
- Local Library > Tracks multi-select gains bulk Remove from favorites
  (local-favorites store, live heart flip, toast).

km
2026-07-26 13:22:53 -06:00

245 lines
11 KiB
Plaintext

// Track-row leading cell — the play affordance shared by every track row.
//
// Two variants, chosen by `show-artwork`:
// - artwork: a cover thumbnail with a dark play/pause overlay;
// - number: a track-number cell that swaps to a play/pause glyph.
//
// States (ported 1:1 from the Tauri track rows):
// rest -> number / plain artwork
// row hovered (not current) -> play triangle
// current + playing -> animated equalizer bars
// current + playing + hover -> pause glyph
// current + paused -> play triangle
// Clicking a non-current cell plays the track; clicking the current cell
// toggles play/pause.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, ShellState, AppearanceState } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
// One equalizer bar — oscillates continuously while it is shown (a 1s
// triangle wave). ALWAYS driven by the shared ~8fps coarse clock, never
// animation-tick(): each step is a full-window repaint, and at display
// rate this tiny affordance alone held ~4.5% CPU with a playlist view
// open on a 165 Hz monitor (strong tier). The AppShell timer runs the
// clock whenever audio plays, so the bars keep moving on every tier.
component EqBar inherits Rectangle {
in property <length> base;
in property <float> delay; // 0..1 phase offset
width: 3px;
border-radius: 1.5px;
background: Theme.accent;
property <float> anim-ms: ShellState.coarse-tick-ms * 1.0;
property <float> phase: Math.mod(root.anim-ms / 1000.0 + root.delay, 1.0);
property <float> tri: 1.0 - Math.abs(root.phase - 0.5) * 2.0;
height: root.base * (0.5 + 0.5 * root.tri);
}
// Three-bar equalizer; bars grow from a fixed baseline so the block
// stays perfectly centered while the bar heights oscillate.
component EqualizerBars inherits Rectangle {
width: row.preferred-width;
height: 16px;
row := HorizontalLayout {
height: 16px;
spacing: 2px;
VerticalLayout {
height: 16px;
alignment: end;
EqBar { base: 10px; delay: 0.0; }
}
VerticalLayout {
height: 16px;
alignment: end;
EqBar { base: 14px; delay: 0.15; }
}
VerticalLayout {
height: 16px;
alignment: end;
EqBar { base: 8px; delay: 0.3; }
}
}
}
export component TrackPlayCell inherits Rectangle {
in property <string> track-id;
in property <string> number;
in property <image> artwork;
in property <bool> show-artwork: true;
in property <bool> row-hovered: false;
in property <length> art-size: 40px;
in property <length> number-width: 32px;
// True while the pointer is over the cell itself. The row OR's this
// with its other TouchAreas to drive `row-hovered` (Slint's has-hover
// does not propagate to ancestor TouchAreas).
out property <bool> hovered: ta.has-hover;
// Fired when a non-current track should start playing.
callback play();
// Offline-cache tracks publish the Qobuz catalog id in track-id but the
// local-library row id in local-track-id (playback.rs) — match either.
property <bool> is-active:
NowPlayingState.has-track && (NowPlayingState.track-id == root.track-id
|| (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == root.track-id));
property <bool> is-playing: root.is-active && NowPlayingState.playing;
// This row is the track being fetched/resolved (no audio yet). The global
// loading flag + active-row match means only the one loading row spins,
// and it clears automatically when the flag clears.
property <bool> is-loading: root.is-active && NowPlayingState.loading;
property <bool> show-overlay: root.row-hovered || root.is-active;
// The animated bars show only while playing, the row is at rest, the
// track isn't still fetching (the spinner wins during the fetch window),
// AND the user opted into the animation (Appearance settings). With the
// pref OFF the cell falls through to the static glyph below (the owner's
// 2026-07-24 spec: rest = play inside an accent ring, hover = pause).
property <bool> show-bars: root.is-playing && !root.row-hovered && !root.is-loading
&& AppearanceState.play-indicator-animation;
// The glyph shows PAUSE only when the playing row is hovered (the click
// pauses). At rest the playing row shows PLAY — an affordance, not a
// state badge: the accent ring + the row's edge mark carry the state.
property <bool> show-pause: root.is-playing && root.row-hovered;
// Static-mode ring on the playing row (pref OFF): transparent fill,
// accent circumference + accent glyph. Anything else keeps the original
// alpha backing + text-primary glyph (legibility on every theme, #638).
property <bool> accent-ring: !AppearanceState.play-indicator-animation && root.is-playing;
width: root.show-artwork ? root.art-size : root.number-width;
height: root.show-artwork ? root.art-size : 40px;
// --- Artwork variant ------------------------------------------------
if root.show-artwork: Rectangle {
width: root.art-size;
height: root.art-size;
border-radius: Radius.sm;
clip: true;
background: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/music.svg");
width: root.art-size * 0.45;
height: root.art-size * 0.45;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
Image {
width: 100%;
height: 100%;
source: root.artwork;
image-fit: cover;
}
Rectangle {
width: 100%;
height: 100%;
background: #000000;
opacity: root.show-overlay ? (root.row-hovered ? 0.72 : 0.6) : 0.0;
animate opacity { duration: 150ms; }
}
if root.show-bars: EqualizerBars {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
// Fetch spinner — wins over the play glyph while the active row is
// still resolving/downloading (same rotating loader as the offline
// download states).
if root.is-loading: Image {
width: 20px;
height: 20px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
source: @image-url("../assets/icons/loader-circle.svg");
colorize: #ffffff;
transform-rotation: ((ShellState.reduce-motion
? ShellState.coarse-tick-ms * 1.0
: animation-tick() / 1ms) * 0.36) * 1deg;
}
if root.show-overlay && !root.show-bars && !root.is-loading: QbzIcon {
source: root.show-pause
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
width: 20px;
height: 20px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
}
// --- Number variant -------------------------------------------------
if !root.show-artwork: Rectangle {
width: root.number-width;
height: 40px;
if !root.show-overlay: Text {
text: root.number;
color: Theme.text-muted;
font-size: Typography.body;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if root.show-bars: EqualizerBars {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
// Fetch spinner — wins over the play glyph / number while the active
// row is still resolving/downloading.
if root.is-loading: Image {
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
source: @image-url("../assets/icons/loader-circle.svg");
colorize: Theme.accent;
transform-rotation: ((ShellState.reduce-motion
? ShellState.coarse-tick-ms * 1.0
: animation-tick() / 1ms) * 0.36) * 1deg;
}
// Circular backing so the play/pause glyph is legible on EVERY theme
// (the old bare #ffffff glyph vanished on light themes — white-on-white,
// no scrim like the artwork variant has). Fill/border/shadow use the
// polarity-baked alpha ramp (black-alpha on light, white-alpha on dark)
// and the glyph uses text-primary = the theme's max-contrast color. #638.
// On the playing row with the animation pref OFF the circle switches to
// the static ring: transparent fill, accent circumference + glyph.
if root.show-overlay && !root.show-bars && !root.is-loading: Rectangle {
width: 24px;
height: 24px;
border-radius: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
background: root.accent-ring ? transparent : Theme.alpha-10;
// 1.5px like CircleAction's header rings — a 1px stroked circle
// aliases badly in femtovg (owner screenshot 2026-07-26).
border-width: 1.5px;
border-color: root.accent-ring ? Theme.accent : Theme.alpha-15;
drop-shadow-blur: 6px;
drop-shadow-color: Theme.card-shadow;
QbzIcon {
source: root.show-pause
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.accent-ring ? Theme.accent : Theme.text-primary;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (root.is-active) {
NowPlayingState.toggle-play();
} else {
root.play();
}
}
}
}