Files
qbz/crates/qbz-ui/ui/immersive/ImmersiveSuggestionsPanel.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

493 lines
18 KiB
Plaintext

// Immersive split Suggestions panel — mounted in the SPLIT-right slot
// (ImmersiveState.split-panel == 2). 1:1 with Tauri's SuggestionsPanel.svelte
// (recon data-panels.md §6): a cards row (curated artist-playlist book-collage
// cards + the seed Song-Radio diamond-collage card, each with play/queue/
// play-next hover overlays + a corner badge; the radio card shows a spinner
// while building) and below, a "Recommended tracks" list (play on click).
// Split-only (no focus variant).
//
// Data is fed by crate::suggestions::apply_suggestions into SuggestionsState;
// actions resolve through SuggestionsActions (play/queue/play-next playlist,
// start-radio, play-track). Rendered white-on-dark over the atmosphere backdrop
// like the other immersive split panels.
//
// Slint gotchas observed (reference_slint_*): viewport dims are passed as props
// (no `parent` in the root binding); no `opacity:` on the root; no `changed`
// watcher on layout geometry; ADR-009 declaration order; no pills (ADR-008);
// @tr() for visible strings; Theme tokens for accent.
import { SuggestionsState, SuggestionsActions, SuggestionCard } from "../state.slint";
import { TrackItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { Theme } from "../foundation/semantic-colors.slint";
// Circular overlay action button (no pill — a true circle; ADR-008 forbids
// pill-shaped chips, a circle is fine). `primary` = 40px accent disc, else a
// 32px translucent disc.
component OverlayButton inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <length> icon-size: 16px;
callback clicked();
width: root.primary ? 44px : 34px;
height: root.primary ? 44px : 34px;
border-radius: self.width / 2;
background: root.primary
? (ta.has-hover ? Theme.accent-hover : Theme.accent)
: (ta.has-hover ? #ffffff33 : #ffffff1f);
// Hairline rim so the secondary discs read on any backdrop.
border-width: root.primary ? 0px : 1px;
border-color: #ffffff4d;
animate background { duration: 120ms; }
// Subtle lift on the primary disc.
drop-shadow-blur: root.primary ? 12px : 0px;
drop-shadow-color: #00000066;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// One collage cover tile — absolutely placed + rotated by the card layout.
component CollageTile inherits Rectangle {
in property <image> img;
border-radius: 4px;
clip: true;
drop-shadow-blur: 8px;
drop-shadow-color: #00000066;
Image {
source: root.img;
image-fit: cover;
width: parent.width;
height: parent.height;
}
}
// One suggestion card (playlist book-collage OR radio diamond-collage). 168px
// wide, fixed height; collage on top, title/subtitle below; hover overlay with
// the play/queue/play-next (playlist) or single start (radio) actions.
component SuggestionCardView inherits Rectangle {
in property <SuggestionCard> card;
in property <int> index;
width: 168px;
height: 224px;
border-radius: 12px;
background: card-ta.has-hover ? #ffffff2e : #ffffff1f;
border-width: 1px;
border-color: card-ta.has-hover ? #ffffff40 : #ffffff26;
drop-shadow-blur: 16px;
drop-shadow-color: #00000059;
animate background, border-color { duration: 150ms; }
property <bool> is-radio: root.card.kind == "radio";
card-ta := TouchArea { mouse-cursor: default; }
VerticalLayout {
padding: 12px;
spacing: 10px;
// ---- Collage / image well ------------------------------------
well := Rectangle {
height: 144px;
border-radius: 8px;
background: #00000040;
clip: true;
// Book collage (playlist): 3 covers, left/center/right.
if !root.is-radio: Rectangle {
width: 100%;
height: 100%;
CollageTile {
img: root.card.cover0;
width: parent.width * 0.62;
height: parent.height * 0.82;
x: parent.width * 0.06;
y: (parent.height - self.height) / 2;
}
CollageTile {
img: root.card.cover2;
width: parent.width * 0.62;
height: parent.height * 0.82;
x: parent.width * 0.32;
y: (parent.height - self.height) / 2;
}
CollageTile {
img: root.card.cover1;
width: parent.width * 0.66;
height: parent.height * 0.88;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
// Diamond collage (radio): up to 4 covers.
if root.is-radio: Rectangle {
width: 100%;
height: 100%;
CollageTile {
img: root.card.cover1;
width: parent.width * 0.62;
height: parent.height * 0.62;
x: parent.width * 0.04;
y: (parent.height - self.height) / 2;
}
CollageTile {
img: root.card.cover2;
width: parent.width * 0.62;
height: parent.height * 0.62;
x: parent.width * 0.34;
y: (parent.height - self.height) / 2;
}
CollageTile {
img: root.card.cover0;
width: parent.width * 0.64;
height: parent.height * 0.64;
x: (parent.width - self.width) / 2;
y: parent.height * 0.06;
}
CollageTile {
img: root.card.cover3;
width: parent.width * 0.5;
height: parent.height * 0.5;
x: (parent.width - self.width) / 2;
y: parent.height * 0.42;
}
}
// Corner badge (above the collage): the full-colour Qobuz logo
// (playlist cards) or a white radio glyph (radio card). A small dark
// disc behind it keeps the glyph legible over any collage cover.
badge-disc := Rectangle {
x: parent.width - self.width - 6px;
y: 6px;
width: 30px;
height: 30px;
border-radius: self.width / 2;
background: #000000a6;
if root.card.badge == "qobuz": Image {
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
image-fit: contain;
source: @image-url("../assets/icons/qobuz-logo-filled.svg");
}
if root.card.badge != "qobuz": QbzIcon {
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
source: @image-url("../assets/icons/radio.svg");
tint: #ffffff;
}
}
// Radio building spinner — dim veil + the shared LoadingSpinner
// (animation-tick rotation; same as every other heavy-load spinner).
if root.is-radio && root.card.loading: Rectangle {
width: 100%;
height: 100%;
background: #00000099;
LoadingSpinner {
size: 28px;
tint: #ffffffcc;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
// Hover overlay with actions.
overlay := Rectangle {
width: 100%;
height: 100%;
background: #00000099;
opacity: ov-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
ov-ta := TouchArea { mouse-cursor: pointer; }
if !root.is-radio: HorizontalLayout {
alignment: center;
spacing: 10px;
padding: 8px;
VerticalLayout {
alignment: center;
OverlayButton {
icon: @image-url("../assets/icons/list-end.svg");
clicked => { SuggestionsActions.queue-playlist(root.card.playlist-id); }
}
}
VerticalLayout {
alignment: center;
OverlayButton {
primary: true;
icon: @image-url("../assets/icons/play-fill.svg");
icon-size: 18px;
clicked => { SuggestionsActions.play-playlist(root.card.playlist-id); }
}
}
VerticalLayout {
alignment: center;
OverlayButton {
icon: @image-url("../assets/icons/list-end.svg");
clicked => { SuggestionsActions.play-next-playlist(root.card.playlist-id); }
}
}
}
if root.is-radio: HorizontalLayout {
alignment: center;
padding: 8px;
VerticalLayout {
alignment: center;
OverlayButton {
primary: true;
icon: @image-url("../assets/icons/radio.svg");
icon-size: 18px;
clicked => {
if (!root.card.loading) {
SuggestionsActions.start-radio(
root.card.seed-track-id,
root.card.seed-track-name,
root.card.seed-artist-id);
}
}
}
}
}
}
}
// ---- Title + subtitle ----------------------------------------
Text {
text: root.card.title;
color: #ffffff;
font-size: 15px;
font-weight: 600;
overflow: elide;
}
Text {
text: root.card.subtitle;
color: #ffffffb3;
font-size: 13px;
overflow: elide;
}
}
}
// One recommended-track row: 40px cover + title/artist, play on click/hover.
component RecTrackRow inherits Rectangle {
in property <TrackItem> track;
callback clicked();
height: 56px;
border-radius: 8px;
background: row-ta.has-hover ? #ffffff21 : transparent;
animate background { duration: 150ms; }
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 10px;
// Artwork + hover play overlay.
VerticalLayout {
alignment: center;
art := Rectangle {
width: 40px;
height: 40px;
border-radius: 4px;
clip: true;
background: #ffffff14;
Image {
source: root.track.artwork;
image-fit: cover;
width: parent.width;
height: parent.height;
}
Rectangle {
width: 100%;
height: 100%;
background: #00000080;
opacity: row-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
}
}
}
// Title + artist.
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
Text {
text: root.track.title;
color: #ffffff;
font-size: 15px;
font-weight: 500;
overflow: elide;
}
Text {
text: root.track.artist;
color: #ffffffb3;
font-size: 13px;
overflow: elide;
}
}
// Duration.
VerticalLayout {
alignment: center;
Text {
text: root.track.duration;
color: #ffffff99;
font-size: 13px;
font-weight: 500;
}
}
}
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
export component ImmersiveSuggestionsPanel inherits Rectangle {
in property <length> viewport-width: 480px;
in property <length> viewport-height: 600px;
in property <bool> centered: true;
width: root.viewport-width;
height: root.viewport-height;
background: transparent;
VerticalLayout {
alignment: root.centered ? center : start;
Rectangle {
height: root.centered
? Math.min(root.viewport-height,
Math.clamp(340px, root.viewport-height * 0.62, 760px))
: root.viewport-height;
// ---- Empty (no track selected) ----------------------------
if SuggestionsState.artist-id == "" && !SuggestionsState.loading: VerticalLayout {
alignment: center;
spacing: 12px;
HorizontalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/radio.svg");
width: 32px;
height: 32px;
tint: #ffffffb3;
}
}
Text {
text: @tr("No track selected");
color: #ffffffb3;
font-size: 18px;
horizontal-alignment: center;
}
}
// ---- Loading (no content yet) -----------------------------
if SuggestionsState.loading && SuggestionsState.cards.length == 0
&& SuggestionsState.tracks.length == 0: Text {
text: @tr("Loading suggestions...");
color: #ffffffcc;
font-size: 18px;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
// ---- Error ------------------------------------------------
if SuggestionsState.error != "": Text {
text: @tr("Failed to load suggestions");
color: #ffffffe6;
font-size: 18px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
// ---- Loaded -----------------------------------------------
if SuggestionsState.error == "" && SuggestionsState.artist-id != ""
&& (SuggestionsState.cards.length > 0 || SuggestionsState.tracks.length > 0): Rectangle {
clip: true;
flick := Flickable {
viewport-width: self.width;
viewport-height: body.preferred-height;
body := VerticalLayout {
width: parent.width;
padding-right: 12px;
spacing: 24px;
// Cards row.
if SuggestionsState.cards.length > 0: HorizontalLayout {
spacing: 12px;
alignment: start;
for card[i] in SuggestionsState.cards: SuggestionCardView {
card: card;
index: i;
}
}
// Recommended tracks.
if SuggestionsState.tracks.length > 0: Text {
text: @tr("Recommended tracks");
color: #ffffffe6;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.4px;
}
if SuggestionsState.tracks.length > 0: VerticalLayout {
spacing: 4px;
for track in SuggestionsState.tracks: RecTrackRow {
track: track;
clicked => { SuggestionsActions.play-track(track.id); }
}
}
}
}
ListScrollbar {
x: parent.width - self.width;
width: 14px;
height: 100%;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
}
}
}
}