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
395 lines
15 KiB
Plaintext
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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|