- #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
245 lines
11 KiB
Plaintext
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();
|
|
}
|
|
}
|
|
}
|
|
}
|