Files
qbz/crates/qbz-slint/ui/primitives/AlbumListRow.slint
T
vicrodh 82129cff35 feat(slint): LocalLibrary ephemeral folders + Folders tree 1:1 port
Ephemeral folders: open / browse / play a folder without indexing it —
album-grouped pane, CUE seek fast-path (no full re-decode per virtual track),
folder-path persistence + rehydrate, "already playing" add-vs-replace choice
dialog (ephemeral-only), and now-playing / queue / menu gating so an ephemeral
track exposes no persistence actions.

Folders tree brought 1:1 with Tauri:
- Rail header: select-mode, collapse-all, open-ephemeral, tree search.
- Resizable rail (default ~20% narrower; drag handle, min 50% default /
  max 50% of the content area).
- Subfolder cover grid + grid/list toggle, recursive track count, name search;
  compact icon bulk bar (folder-check / play-next / queue / playlist / clear).
- Tree is the default mode; circular checkboxes homologated with TrackRow;
  compact rows (smaller type + icon, no ambiguous trailing count).
- Flat/Tree icon toggle (disc-album / folder-tree), floated right.

Per-source TrackRow context menu (logic, not a blanket trim): Qobuz/offline =
full; local = play/next/queue + add-to-playlist; plex/ephemeral = play/next/
queue only.

App-wide standard: Math.round pixel-snap on icon centering across the UI to
kill sub-pixel blur (CircleAction, header back/forward/collapse, and the rest).
NowPlayingBar context icon -> turntable (frees disc-album for the Folders
toggle). New assets: folder-check, folder-tree, turntable.

cc
2026-06-07 10:12:59 -06:00

395 lines
15 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
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) — discreet per-source glyph, NOT text. ---
// Mirrors TrackRow's source indicator: local -> tinted hard-drive,
// qobuz/offline + plex -> their own mark; small + flat (the bold
// colored badge is reserved for the AlbumCard grid).
if root.show-source: Rectangle {
width: AlbumListCols.source;
if root.album.source != "": Image {
source: root.album.source == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: (root.album.source == "qobuz" || root.album.source == "qobuz_download"
? @image-url("../assets/icons/qobuz-logo-filled.svg")
: @image-url("../assets/icons/hard-drive.svg"));
colorize: root.album.source == "local" ? Theme.text-muted : transparent;
width: root.album.source == "local" ? 15px : 16px;
height: self.width;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
image-fit: contain;
}
}
// --- 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
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");
}
}
}
}
}
}
}
}