Files
qbz/crates/qbz-slint/ui/primitives/AlbumListRow.slint
T
vicrodh 66a9f9da22 fix(slint): favorite state on album cards + un-favorite in place
AlbumCardItem gains is-favorite (filled accent heart on the card +
"Remove from favorites" in the list-row menu) — favorites albums set it
true so the heart shows its state, which it never did before.

Un-favoriting from the favorites list (track heart or album card heart)
now removes the item: the row/card fades out (new `removing` field drives
a 250ms opacity animation on TrackRow / AlbumCard / AlbumListRow), the
favorite is removed on the server (+ fav_cache for tracks), and the row
is dropped from both the full and visible models after the fade. All
TrackItem / AlbumCardItem initializers carry the new fields.

cc
2026-05-27 06:56:26 -06:00

388 lines
14 KiB
Plaintext

// Reusable album list-row — the "list view" counterpart to the grid
// AlbumCard. Modeled on Tauri's My QBZ > Collections list (and the
// DiscographyBuilderView column grid): a thumbnail + title/artist ITEM
// column, then TYPE, SOURCE, QUALITY, TRACKS and YEAR columns plus a
// trailing overflow ("…") button.
//
// Intentionally column-for-column with the Tauri reference, not a
// reinterpretation. Reused by Discover "View all", Favorites,
// LocalLibrary and My QBZ — every column except ITEM is hideable via a
// bool prop so a context can show only the columns it cares about. In
// the DiscoverBrowse context the SOURCE column is hidden (single-source
// catalog).
//
// QUALITY reuses the shared QualityBadgeFull — the SAME badge AlbumView
// shows below the album art — so quality reads identically everywhere.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { AlbumCardItem } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { QualityBadgeFull } from "QualityBadgeFull.slint";
import { ContextMenu } from "ContextMenu.slint";
import { ContextMenuItem } from "ContextMenuItem.slint";
// Fixed column widths shared by the header and the rows so they align.
// (rank · art · type · source · quality · tracks · year · overflow;
// the ITEM title/artist stack takes the remaining stretch space.)
export global AlbumListCols {
out property <length> rank: 36px;
out property <length> art: 52px;
out property <length> type: 84px;
out property <length> source: 72px;
out property <length> quality: 150px;
out property <length> tracks: 64px;
out property <length> year: 64px;
out property <length> overflow: 36px;
out property <length> gap: 12px;
}
// Column header row — labels the columns. Mirrors the
// DiscographyBuilderView / MixtapeCollectionDetailView header convention.
export component AlbumListHeader inherits Rectangle {
in property <bool> show-rank: false;
in property <bool> show-source: true;
height: 32px;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: AlbumListCols.gap;
if root.show-rank: cell-rank := Text {
width: AlbumListCols.rank;
text: "#";
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
// Art column placeholder (no label).
Rectangle { width: AlbumListCols.art; }
// ITEM stretch column.
Text {
horizontal-stretch: 1;
text: @tr("ITEM");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
overflow: elide;
}
Text {
width: AlbumListCols.type;
text: @tr("TYPE");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
}
if root.show-source: Text {
width: AlbumListCols.source;
text: @tr("SOURCE");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
width: AlbumListCols.quality;
text: @tr("QUALITY");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
width: AlbumListCols.tracks;
text: @tr("TRACKS");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
width: AlbumListCols.year;
text: @tr("YEAR");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
// Overflow column placeholder (no label).
Rectangle { width: AlbumListCols.overflow; }
}
// Bottom hairline separator.
Rectangle {
y: parent.height - self.height;
height: 1px;
background: Theme.border-subtle;
}
}
export component AlbumListRow inherits Rectangle {
in property <AlbumCardItem> album;
// Fade out when the row is being removed (favorites un-favorite).
opacity: root.album.removing ? 0.0 : 1.0;
animate opacity { duration: 250ms; }
// Row index — drives the even/odd zebra striping (coherent with TrackRow).
in property <int> index;
// Optional leading rank ("#1" etc) for ranked lists (Most Streamed).
in property <bool> show-rank: false;
in property <string> rank;
// The SOURCE column is hidden in single-source contexts (Discover).
in property <bool> show-source: true;
callback clicked(string);
// kind ("album" | "artist"), id, action ("play" | "queue" |
// "play-next" | "favorite" | "open").
callback media-action(string, string, string);
// Hover spans the row body + the art / artist / overflow affordances so
// the row highlights as one (mirrors TrackRow's row-hovered).
property <bool> row-hovered:
ta.has-hover || art-ta.has-hover || artist-ta.has-hover || more-ta.has-hover;
height: 64px;
border-radius: Radius.sm;
// Even/odd zebra (odd rows faintly tinted), coherent with TrackRow.
background: root.row-hovered
? Theme.surface-hover
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.album.id);
}
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: AlbumListCols.gap;
// --- # ---------------------------------------------------------
if root.show-rank: Text {
width: AlbumListCols.rank;
text: root.rank;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
// --- Artwork thumb (play on hover) ----------------------------
VerticalLayout {
alignment: center;
Rectangle {
width: AlbumListCols.art;
height: AlbumListCols.art;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: root.album.artwork;
image-fit: cover;
}
// Hover scrim + centered play glyph over the cover.
Rectangle {
width: 100%;
height: 100%;
background: #000000;
opacity: root.row-hovered ? 0.45 : 0.0;
animate opacity { duration: 120ms; }
}
if root.row-hovered: QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 22px;
height: 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
art-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("album", root.album.id, "play");
}
}
}
}
// --- ITEM: title + artist -------------------------------------
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
spacing: 2px;
Text {
text: root.album.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
// Clickable artist name → artist page when an id is known.
artist-ta := TouchArea {
height: 18px;
mouse-cursor: root.album.artist-id != "" ? pointer : default;
clicked => {
if (root.album.artist-id != "") {
root.media-action("artist", root.album.artist-id, "open");
}
}
Text {
width: 100%;
text: root.album.artist;
color: root.album.artist-id != "" && artist-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
// --- TYPE ------------------------------------------------------
Text {
width: AlbumListCols.type;
text: root.album.release-type;
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
// --- SOURCE (hideable) ----------------------------------------
if root.show-source: Text {
width: AlbumListCols.source;
text: root.album.source == "qobuz"
? @tr("Qobuz")
: (root.album.source == "plex"
? @tr("Plex")
: (root.album.source == "local" ? @tr("Local") : ""));
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
// --- QUALITY (shared QualityBadgeFull) ------------------------
Rectangle {
width: AlbumListCols.quality;
VerticalLayout {
alignment: center;
QualityBadgeFull {
tier: root.album.quality-tier;
detail: root.album.quality-detail;
}
}
}
// --- TRACKS ----------------------------------------------------
Text {
width: AlbumListCols.tracks;
text: root.album.track-count;
color: Theme.text-secondary;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
// --- YEAR ------------------------------------------------------
Text {
width: AlbumListCols.year;
text: root.album.plain-year;
color: Theme.text-secondary;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
// --- Overflow "…" ---------------------------------------------
VerticalLayout {
alignment: center;
Rectangle {
width: AlbumListCols.overflow;
height: 32px;
border-radius: 6px;
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 18px;
height: 18px;
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 => {
row-menu.show();
}
}
row-menu := PopupWindow {
x: -170px;
y: 34px;
width: 196px;
height: 142px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Open album");
clicked => {
root.clicked(root.album.id);
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("album", root.album.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("album", root.album.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("album", root.album.id, "queue");
}
}
ContextMenuItem {
icon: root.album.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
label: root.album.is-favorite
? @tr("Remove from favorites")
: @tr("Add to favorites");
clicked => {
root.media-action("album", root.album.id, "favorite");
}
}
}
}
}
}
}
}