Files
qbz/crates/qbz-slint/ui/album/AlbumPageView.slint
T
vicrodh e2c0d812fe fix(slint): search refinements — artist card, history, spinner, hover
- ArtistCard rebuilt 1:1 with Tauri SearchView.artist-card: 160x220
  inside a surface-card container, 12px corners, 120px circular avatar
  with a gradient placeholder, hover swaps to surface-elevated. The
  Follow chip (rectangular per ADR-008) sits below the name when the
  user does not follow the artist.
- Search loading state uses a rotating spinner (Lucide LoaderCircle on
  Slint transform-rotation) matching the Tauri spinner — 1s linear
  infinite. The search header (title + tabs + filter radios) collapses
  while the initial load runs so the chrome does not show through.
- Search history: live debounce + submit now use
  nav::push_or_replace_search, so the latest query is the single Search
  entry in history. Back from an opened album lands on that search.
- Track row backgrounds use the OR-d row-hovered (cell / fav / dl /
  more), so hovering the more button no longer drops the row tint.
- replace_category artists arm re-applies the top-result dedupe, so
  filtering does not bring the duplicate back.

cc
2026-05-19 20:37:52 -06:00

784 lines
31 KiB
Plaintext

// Album detail page.
//
// Mirrors the Svelte AlbumDetailView: back button, an album header
// (artwork + title / artist / info line / action buttons), a divider,
// and the track list with a column header. Data comes from the
// AlbumState global; the action buttons are visual for now.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { AlbumState, AlbumTrackItem, AlbumActions, NavState, ContentView } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { NavButtons } from "../shell/NavButtons.slint";
component CircleAction inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <length> diameter: 40px;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
// Ghost circle — translucent fill with a faint ring, never a solid
// accent fill (mirrors the Tauri transport controls).
background: ta.has-hover ? Theme.surface-hover : transparent;
border-width: 1px;
border-color: ta.has-hover ? Theme.text-disabled : Theme.border-subtle;
QbzIcon {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
component TrackRow inherits Rectangle {
in property <AlbumTrackItem> track;
in property <int> index;
// kind ("track"), track id, action ("play" | "queue" | "favorite").
callback media-action(string, string, string);
// Any-hover detector — Slint does not propagate has-hover to ancestor
// TouchAreas, so the row OR's its own TouchArea with every nested one
// (the play cell + the side buttons) to drive the play overlay.
property <bool> row-hovered:
ta.has-hover
|| play-cell.hovered
|| fav-ta.has-hover
|| dl-ta.has-hover
|| more-ta.has-hover;
height: 56px;
border-radius: 8px;
// The even-row tint is clearly fainter than the hover tint so the two
// never read as the same state. Use row-hovered so hovering nested
// controls (cell / fav / dl / more) keeps the row tint.
background: root.row-hovered
? #ffffff16
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
// Row hover/background detector — declared before the content so the
// more-button keeps its own clicks.
ta := TouchArea {
mouse-cursor: default;
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
VerticalLayout {
alignment: center;
play-cell := TrackPlayCell {
track-id: root.track.id;
number: root.track.number;
show-artwork: false;
number-width: 32px;
row-hovered: root.row-hovered;
play => {
root.media-action("track", root.track.id, "play");
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
// alignment: start packs the title and the explicit badge at
// their natural sizes, so the badge sits right after the name
// instead of being stretched out to a column position.
HorizontalLayout {
alignment: start;
spacing: 6px;
Text {
text: root.track.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
vertical-alignment: center;
overflow: elide;
}
if root.track.explicit: VerticalLayout {
alignment: center;
Rectangle {
width: 16px;
height: 16px;
border-radius: 3px;
background: Theme.surface-elevated;
Text {
text: "E";
color: Theme.text-muted;
font-size: 9px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
Text {
text: root.track.artist;
visible: root.track.artist != "";
color: Theme.text-muted;
font-size: Typography.link;
overflow: elide;
}
}
Text {
text: root.track.duration;
width: 80px;
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
text: root.track.quality-tier == "hires"
? "Hi-Res"
: (root.track.quality-tier == "cd" ? "CD" : "");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
}
// Favorite button.
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
border-radius: Radius.sm;
background: fav-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/heart.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: fav-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
fav-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("track", root.track.id, "favorite");
}
}
}
}
// Offline-cache button.
VerticalLayout {
alignment: center;
Rectangle {
width: 28px;
height: 28px;
border-radius: Radius.sm;
background: dl-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/cloud-download.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: dl-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
dl-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("track", root.track.id, "download");
}
}
}
}
// Context-menu button — always visible in the muted column color,
// a 32px square hit area that brightens on hover.
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
border-radius: Radius.sm;
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-menu.show();
}
}
}
}
}
track-menu := TrackContextMenu {
x: parent.width - self.width - 12px;
y: 48px;
track-id: root.track.id;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
// One label/award entry in the album sidebar — a round gradient avatar
// and a name.
component SidebarCard inherits Rectangle {
in property <image> icon;
in property <string> name;
in property <brush> avatar-bg;
callback clicked();
height: 56px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding: 8px;
spacing: 10px;
VerticalLayout {
alignment: center;
Rectangle {
width: 36px;
height: 36px;
border-radius: 18px;
background: root.avatar-bg;
QbzIcon {
source: root.icon;
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.name;
color: Theme.text-secondary;
font-size: Typography.link;
font-weight: Typography.medium;
wrap: word-wrap;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
component SidebarHeading inherits Text {
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1px;
}
export component AlbumPageView inherits Rectangle {
callback open-artist(string);
callback media-action(string, string, string);
// Whether the full-description modal is open.
property <bool> show-description: false;
background: Theme.surface-main;
Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
// Artwork-tinted gradient behind the header; scrolls with content.
Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 340px;
background: @linear-gradient(180deg,
AlbumState.header-color 0%,
AlbumState.header-color 16%,
transparent 100%);
}
page := VerticalLayout {
width: parent.width;
// padding matches Discover so the [<] [>] pair sits at the same
// top/left margin across every view.
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
// --- History navigation ------------------------------------------
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
// --- Album header ------------------------------------------------
HorizontalLayout {
spacing: 32px;
Rectangle {
width: 224px;
height: 224px;
border-radius: 12px;
background: Theme.surface-elevated;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
drop-shadow-offset-y: 8px;
clip: true;
Image {
width: 100%;
height: 100%;
source: AlbumState.artwork;
image-fit: cover;
}
}
VerticalLayout {
alignment: start;
padding-top: 4px;
spacing: 0px;
horizontal-stretch: 1;
Text {
text: AlbumState.title;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
overflow: elide;
}
Rectangle { height: 4px; }
HorizontalLayout {
alignment: start;
artist-area := TouchArea {
width: artist-text.preferred-width;
height: artist-text.preferred-height;
mouse-cursor: pointer;
clicked => {
root.open-artist(AlbumState.artist-id);
}
artist-text := Text {
text: AlbumState.artist;
// Same color as the surrounding metadata,
// bold to read as a link.
color: artist-area.has-hover
? Theme.text-primary
: Theme.text-secondary;
font-size: Typography.heading;
font-weight: Typography.bold;
}
}
}
Rectangle { height: 10px; }
Text {
text: AlbumState.info-line;
color: Theme.text-secondary;
font-size: Typography.body;
overflow: elide;
}
// Editorial description — lives with the metadata and
// fills the width to the right of the artwork; truncated,
// with a Read more link to the full modal.
if AlbumState.description != "": Rectangle { height: 12px; }
if AlbumState.description != "": Text {
// Explicit width so the text fills the whole band
// to the right of the artwork instead of wrapping
// narrow and leaving dead space. Bound to root.width
// (not the parent layout) to avoid a binding loop:
// page padding 32+32, artwork 224, header gap 32.
width: root.width - 320px;
text: AlbumState.description-short;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
width: read-more-row.preferred-width;
height: 20px;
mouse-cursor: pointer;
clicked => {
root.show-description = true;
}
read-more-row := HorizontalLayout {
Text {
text: @tr("Read more");
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
Rectangle { height: 20px; }
HorizontalLayout {
alignment: start;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
clicked => {
root.media-action("album", AlbumState.id, "play");
}
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/shuffle.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
clicked => {
root.media-action("album", AlbumState.id, "favorite");
}
}
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/radio.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/user.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/book-open.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/cassette-tape.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/square-check-big.svg"); }
}
VerticalLayout {
alignment: center;
CircleAction { icon: @image-url("../assets/icons/ellipsis.svg"); }
}
}
}
}
Rectangle { height: 20px; }
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 8px; }
// --- Track list + label/awards sidebar --------------------------
// No `alignment: start` here: it would defeat the track list's
// horizontal-stretch and leave the sidebar floating mid-row
// with dead space to its right. The track list stretches and
// the 254px sidebar is pushed flush to the right edge; when a
// main right sidebar opens, the whole content region narrows
// and the sidebar rides along with it.
HorizontalLayout {
spacing: 32px;
VerticalLayout {
horizontal-stretch: 1;
spacing: 0px;
alignment: start;
if AlbumState.loading && AlbumState.tracks.length == 0: Text {
text: @tr("Loading album...");
color: Theme.text-muted;
font-size: Typography.body;
}
// Toolbar — album quality badge and track search.
HorizontalLayout {
height: 52px;
spacing: 16px;
VerticalLayout {
alignment: center;
QualityBadgeFull {
tier: AlbumState.quality-tier;
detail: AlbumState.quality-detail;
}
}
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
alignment: center;
Rectangle {
width: 280px;
height: 34px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 7px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
// Functional client-side filter.
Rectangle {
horizontal-stretch: 1;
track-search := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
edited => {
AlbumActions.search(self.text);
}
}
Text {
visible: track-search.text == "";
text: @tr("Search tracks...");
// Span the box so the placeholder
// centers like the TextInput text.
width: 100%;
height: 100%;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
}
}
}
}
}
// Column header.
HorizontalLayout {
height: 40px;
padding-left: 12px;
padding-right: 12px;
spacing: 16px;
Text {
text: "#";
width: 32px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("TITLE");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
text: @tr("QUALITY");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
// Favorite / offline-cache column headers.
Rectangle {
width: 28px;
QbzIcon {
source: @image-url("../assets/icons/heart.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
}
Rectangle {
width: 28px;
QbzIcon {
source: @image-url("../assets/icons/cloud-download.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
}
// Aligns with the per-row more-button slot.
Rectangle { width: 32px; }
}
for track[index] in AlbumState.tracks: TrackRow {
track: track;
index: index;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
// Label / awards sidebar.
if AlbumState.label != "" || AlbumState.awards.length > 0: VerticalLayout {
width: 254px;
spacing: 24px;
alignment: start;
if AlbumState.label != "": VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("LABEL"); }
SidebarCard {
icon: @image-url("../assets/icons/disc.svg");
name: AlbumState.label;
avatar-bg: @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
}
if AlbumState.awards.length > 0: VerticalLayout {
spacing: 8px;
SidebarHeading { text: @tr("AWARDS"); }
for award in AlbumState.awards: SidebarCard {
icon: @image-url("../assets/laurels.svg");
name: award;
avatar-bg: @linear-gradient(135deg, #b45309 0%, #eab308 100%);
}
}
}
}
}
}
// --- Full-description modal --------------------------------------------
if root.show-description: Rectangle {
background: #000000bf;
// Scrim — a click anywhere outside the panel dismisses.
TouchArea {
mouse-cursor: default;
clicked => {
root.show-description = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: Theme.card-shadow;
// Swallow clicks so they do not reach the scrim.
TouchArea { }
VerticalLayout {
padding: 24px;
spacing: 14px;
HorizontalLayout {
Text {
text: @tr("About this album");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
root.show-description = false;
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
// vertical-stretch bounds the Flickable to the leftover
// panel height so a long description actually scrolls.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: desc-text.preferred-height;
desc-text := Text {
width: parent.width;
text: AlbumState.description;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
}
}
}
}
}