Files
qbz/crates/qbz-slint/ui/myqbz/MixtapeDetailView.slint
T
vicrodh 10b13be6e3 fix(myqbz): detail row ⋯ menu was unclickable — open-item TouchArea sat on top
The DetailRow had a full-row `row-ta` TouchArea (clicked -> open the item/album)
declared AFTER the cell layout. In Slint a later-declared element paints ON TOP,
so row-ta was frontmost over the whole row and captured every click -- including
the ⋯ button -- so the menu never opened and the row just opened the album. Moved
row-ta to be declared BEFORE the cell HorizontalLayout, so it sits behind the
per-cell TouchAreas (checkbox / play / title / subtitle / ⋯) and only catches the
empty gaps. The ⋯ menu (and the play overlay / title) now receive their clicks.

cc
2026-06-09 15:45:24 -06:00

1259 lines
62 KiB
Plaintext

// My QBZ — Mixtape / Collection DETAIL view (read-only slice).
//
// Spec 12 (MixtapeCollectionDetailView). A hero header (CollectionMosaic /
// custom cover + kind eyebrow + title + description + meta + the
// play/shuffle/dj-mix/edit/delete/sync action row), a sticky toolbar
// (search + sort + type-filter + source-filter + reset + select + the
// list/grid/expanded view-mode segmented group), then the 8-col item list
// (or the auto-fill grid). Data comes from MyQbzDetailState; the toolbar +
// open-item / open-artist callbacks are live, but every hero CTA + per-row
// context action is a logging STUB (read-only boundary).
//
// DEFERRED (later slice): the "expanded" view-mode's per-item inline track
// expansion (the toggle + shell render here, but no inline TrackRows — needs
// the enqueue resolver), the rename/description/delete/cover/DJ-mix modals,
// the BulkActionBar, live source/quality resolveItems, and the per-row
// release-type submenu. Quality badges + non-qobuz source kinds are
// placeholders until resolveItems lands.
//
// Geometry is verbatim from spec 12: row height 56px; 8-col grid
// 40 / 1fr / 140 / 80 / 160 / 72 / 60 / 40 (gap 12, padding 8 12); grid cards
// auto-fill minmax(150,1fr) gap 20; root padding 8 8 100 18.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
import { SelectionCheckbox } from "../primitives/SelectionCheckbox.slint";
import { LoadingDots } from "../primitives/LoadingDots.slint";
import { CollectionMosaic } from "CollectionMosaic.slint";
// --- One type / source filter CHECKBOX row ------------------------------
// A leading circular SelectionCheckbox (the app-wide multi-select idiom) + a
// label. Used for both the single-select Type section (exclusive — picking one
// unchecks the others, handled controller-side) and the multi-select Source
// section. Replaces the old text+trailing-check dropdown row.
component MenuOption inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
callback chosen();
height: 30px;
border-radius: 4px;
background: mo-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
VerticalLayout {
alignment: center;
SelectionCheckbox {
checked: root.selected;
toggled => { root.chosen(); }
}
}
Text {
text: root.label;
color: root.selected ? Theme.accent : Theme.text-primary;
font-size: Typography.legal;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
mo-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.chosen(); }
}
}
// --- A homologated toolbar trigger button (filter / select) -------------
// Matches LocalLibrary's bordered filter button: 30px tall, 6px radius, a
// surface-elevated resting fill; active borders accent (LocalLibrary's
// count>0 quality-filter idiom) and an optional count badge. Used only for the
// two controls QbzSelect can't express (the multi-section filter popup + the
// select-mode toggle). NEVER an accent fill.
component ToolBtn inherits Rectangle {
in property <image> icon;
in property <string> label; // "" -> icon-only
in property <int> badge: 0; // filter count badge (0 = hidden)
in property <bool> active: false;
// Labeled-active idiom (the Favorites FavGenreButton): active = accent FILL +
// white icon/text. When false (icon-only ToolBtns: select/reset), active is
// the LocalLibrary accent-BORDER idiom instead. Defaults to whichever fits
// the label state so callers don't have to set it.
in property <bool> fill-active: root.label != "";
callback clicked();
height: 30px;
border-radius: 6px;
// Width = the layout's natural size (its own symmetric padding included), so
// the content is centred by the padding rather than an absolute x offset that
// fought the width and pushed the glyph off-centre.
width: row.preferred-width;
background: (root.active && root.fill-active)
? Theme.accent
: (cb-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
border-width: 1px;
border-color: (root.active && !root.fill-active) ? Theme.accent : Theme.border-subtle;
row := HorizontalLayout {
padding-left: root.label == "" ? 8px : 10px;
padding-right: root.label == "" ? 8px : 10px;
height: 100%;
spacing: 6px;
alignment: center;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
tint: (root.active && root.fill-active)
? #ffffff
: (root.active ? Theme.text-primary : Theme.text-muted);
}
}
if root.label != "": Text {
text: root.label;
color: (root.active && root.fill-active)
? #ffffff
: (root.active ? Theme.text-primary : Theme.text-secondary);
font-size: 12px;
vertical-alignment: center;
}
if root.badge > 0: Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
background: (root.active && root.fill-active) ? #ffffff : Theme.accent;
Text {
text: root.badge;
color: (root.active && root.fill-active) ? Theme.accent : Theme.accent-text;
font-size: 10px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
cb-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- The per-row ⋯ context menu (read-only: items are stubs) -------------
component RowMenuItem inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> destructive: false;
callback chosen();
height: 32px;
border-radius: 6px;
background: rmi-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 13px;
height: 13px;
tint: root.destructive ? Theme.favorite : Theme.text-secondary;
}
}
Text {
text: root.label;
color: root.destructive ? Theme.favorite : Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
}
}
rmi-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.chosen(); }
}
}
// --- One item ROW (8-col grid; spec 12 §6.3) ----------------------------
component DetailRow inherits Rectangle {
in property <MixtapeDetailItem> item;
in property <int> index; // 1-based display ordinal among visible
in property <bool> select-mode: false;
height: 56px;
background: (row-ta.has-hover || root.item.selected) ? Theme.surface-hover : transparent;
// Subtle bottom border (rgba(255,255,255,0.04)).
Rectangle {
y: parent.height - 1px;
height: 1px;
background: #ffffff0a;
}
// Row click (open the item) — declared BEFORE the cell layout so it sits
// BEHIND the per-cell TouchAreas (checkbox / play / title / subtitle / ⋯).
// In Slint a later-declared element paints ON TOP, so the cell TouchAreas
// below capture their own clicks and row-ta only catches the gaps. (It used
// to be declared LAST — frontmost — which ate every click incl. the ⋯ button,
// so the menu never opened and the whole row just opened the album.)
row-ta := TouchArea {
clicked => {
if (root.select-mode) {
MyQbzDetailActions.toggle-item-select(root.item.position);
} else {
MyQbzDetailActions.open-item(root.item.source, root.item.item-type, root.item.source-item-id);
}
}
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
// Col 1 — # / checkbox (40px).
Rectangle {
width: 40px;
if root.select-mode: SelectionCheckbox {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
checked: root.item.selected;
toggled => { MyQbzDetailActions.toggle-item-select(root.item.position); }
}
if !root.select-mode: Text {
text: root.index;
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col 2 — artwork + title/subtitle (1fr).
HorizontalLayout {
horizontal-stretch: 1;
spacing: 12px;
// Artwork (36px) with a hover play overlay.
art := Rectangle {
width: 36px;
height: 36px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 4px;
background: Theme.surface-elevated;
clip: true;
if root.item.artwork-url != "": Image {
width: 100%;
height: 100%;
source: root.item.artwork;
image-fit: cover;
}
if root.item.artwork-url == "": QbzIcon {
source: root.item.item-type == "track"
? @image-url("../assets/icons/music.svg")
: (root.item.item-type == "playlist"
? @image-url("../assets/icons/list-music.svg")
: @image-url("../assets/icons/disc.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: Theme.text-muted;
}
// Hover play overlay.
Rectangle {
background: #00000099;
opacity: play-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 16px;
height: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
play-ta := TouchArea {
mouse-cursor: pointer;
clicked => { MyQbzDetailActions.play-item(root.item.source-item-id); }
}
}
}
// Title + subtitle.
VerticalLayout {
alignment: center;
spacing: 2px;
horizontal-stretch: 1;
title-ta := TouchArea {
height: 18px;
mouse-cursor: pointer;
clicked => {
MyQbzDetailActions.open-item(root.item.source, root.item.item-type, root.item.source-item-id);
}
Text {
width: 100%;
text: root.item.title;
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
overflow: elide;
vertical-alignment: center;
}
}
if root.item.subtitle != "": sub-ta := TouchArea {
height: 16px;
mouse-cursor: root.item.subtitle-is-link ? pointer : default;
clicked => {
if (root.item.subtitle-is-link) {
MyQbzDetailActions.open-artist(root.item.source, root.item.subtitle);
}
}
Text {
width: 100%;
text: root.item.subtitle;
color: (root.item.subtitle-is-link && sub-ta.has-hover)
? Theme.accent : Theme.text-muted;
font-size: 12px;
overflow: elide;
vertical-alignment: center;
}
}
}
}
// Col 3 — type (140px).
Rectangle {
width: 140px;
HorizontalLayout {
spacing: 6px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.item.item-type == "track"
? @image-url("../assets/icons/music.svg")
: (root.item.item-type == "playlist"
? @image-url("../assets/icons/list-music.svg")
: @image-url("../assets/icons/disc.svg"));
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
Text {
text: root.item.type-label;
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
vertical-alignment: center;
}
}
}
// Col 4 — source (80px). Plain-Image colorize (full-color Qobuz/Plex
// logos untinted; monochrome local hard-drive tinted muted) — the
// shared SourceGlyph, NOT an accent silhouette.
Rectangle {
width: 80px;
if root.item.source-kind != "": SourceGlyph {
kind: root.item.source-kind;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col 5 — quality (160px). Fed from the resolveItems pass (the row's
// album-level quality, derived from its first resolved track). While
// that async resolve is pending, a LoadingDots skeleton (the app's
// loading idiom) fills the cell instead of leaving it empty.
Rectangle {
width: 160px;
if root.item.quality-tier != "": QualityBadgeFull {
tier: root.item.quality-tier;
detail: root.item.quality-detail;
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if root.item.quality-tier == "" && root.item.quality-resolving: LoadingDots {
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col 6 — tracks (72px, right-aligned).
Text {
width: 72px;
text: root.item.tracks-text;
color: Theme.text-secondary;
font-size: Typography.legal;
horizontal-alignment: right;
vertical-alignment: center;
}
// Col 7 — year (60px, right-aligned).
Text {
width: 60px;
text: root.item.year-text;
color: Theme.text-secondary;
font-size: Typography.legal;
horizontal-alignment: right;
vertical-alignment: center;
}
// Col 8 — ⋯ context menu (40px).
Rectangle {
width: 40px;
menu-btn := Rectangle {
width: 24px;
height: 24px;
x: parent.width - self.width;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 4px;
background: menu-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: menu-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
menu-ta := TouchArea {
mouse-cursor: pointer;
clicked => { row-menu.show(); }
}
row-menu := PopupWindow {
x: menu-btn.width - 184px;
y: 28px;
width: 184px;
// Explicit height: without it the PopupWindow's box does not
// resolve and the in-flow rows below paint over it (the only
// PopupWindow in the codebase that omitted this). 4 items
// (32px) + 1 separator (1px) + padding (8px) + spacing (4px).
height: 145px;
// Act-and-dismiss menu — match the app idiom (close on any
// click; items also act before the implicit close).
close-policy: PopupClosePolicy.close-on-click;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
RowMenuItem {
icon: @image-url("../assets/icons/play-fill.svg");
label: @tr("Play");
chosen => { MyQbzDetailActions.play-item(root.item.source-item-id); }
}
RowMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Play next");
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-next"); }
}
RowMenuItem {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Add to queue");
chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "add-to-queue"); }
}
Rectangle { height: 1px; background: Theme.surface-elevated; }
RowMenuItem {
icon: @image-url("../assets/icons/trash-2.svg");
label: @tr("Remove from collection");
destructive: true;
chosen => { MyQbzDetailActions.remove-item(root.item.position); }
}
}
}
}
}
}
}
}
// --- One GRID card (spec 12 §7) -----------------------------------------
component DetailCard inherits Rectangle {
in property <MixtapeDetailItem> item;
in property <bool> select-mode: false;
in property <length> card-w: 150px;
width: root.card-w;
// artwork (card-w) + gap 8 + title ~18 + subtitle ~16 + padding 8*2.
height: root.card-w + 8px + 18px + 16px + 16px;
border-radius: 8px;
background: (card-ta.has-hover || root.item.selected) ? Theme.surface-hover : transparent;
border-width: root.item.selected ? 2px : 0px;
border-color: Theme.accent;
VerticalLayout {
padding: 8px;
spacing: 8px;
artw := Rectangle {
width: parent.width - 16px;
height: self.width;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
if root.item.artwork-url != "": Image {
width: 100%;
height: 100%;
source: root.item.artwork;
image-fit: cover;
}
if root.item.artwork-url == "": QbzIcon {
source: root.item.item-type == "track"
? @image-url("../assets/icons/music.svg")
: (root.item.item-type == "playlist"
? @image-url("../assets/icons/list-music.svg")
: @image-url("../assets/icons/disc.svg"));
width: 28px;
height: 28px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
// Select checkbox (top-left) OR hover play overlay. The circular
// checkbox sits on a dark disc so it stays legible over a bright
// cover (the row variant rides surface-hover and needs no backing).
if root.select-mode: Rectangle {
x: 6px;
y: 6px;
width: 22px;
height: 22px;
border-radius: 11px;
background: root.item.selected ? transparent : #0000008c;
SelectionCheckbox {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
checked: root.item.selected;
toggled => { MyQbzDetailActions.toggle-item-select(root.item.position); }
}
}
if !root.select-mode: Rectangle {
background: #00000073;
opacity: gp-ta.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
QbzIcon {
source: @image-url("../assets/icons/play-fill.svg");
width: 24px;
height: 24px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
gp-ta := TouchArea {
mouse-cursor: pointer;
clicked => { MyQbzDetailActions.play-item(root.item.source-item-id); }
}
}
}
Text {
text: root.item.title;
color: Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
overflow: elide;
width: 100%;
}
Text {
text: root.item.subtitle;
color: Theme.text-muted;
font-size: 12px;
overflow: elide;
width: 100%;
}
}
card-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (root.select-mode) {
MyQbzDetailActions.toggle-item-select(root.item.position);
} else {
MyQbzDetailActions.open-item(root.item.source, root.item.item-type, root.item.source-item-id);
}
}
}
}
export component MixtapeDetailView inherits Rectangle {
// Grid geometry (spec 12 §7/§15): auto-fill minmax(150,1fr), gap 20.
property <length> grid-min-w: 150px;
property <length> grid-gap: 20px;
background: Theme.surface-main;
// ===================== LOADING =====================================
if MyQbzDetailState.loading: HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
}
// ===================== NOT-FOUND ===================================
// No per-view back button — the shell owns the global < > nav.
if !MyQbzDetailState.loading && !MyQbzDetailState.found: VerticalLayout {
padding: 40px;
spacing: 16px;
alignment: center;
HorizontalLayout {
alignment: center;
Text {
text: @tr("Not found");
color: Theme.text-muted;
font-size: Typography.link;
}
}
}
// ===================== LOADED ======================================
// The Flickable is always in the tree (so the root-level ListScrollbar can
// bind to it); its content is gated on the loaded state.
flick := Flickable {
visible: !MyQbzDetailState.loading && MyQbzDetailState.found;
viewport-width: self.width;
viewport-height: page.preferred-height;
// Scroll-position restore on back/forward (see nav::scope_for).
property <string> sr-armed: NavState.restore-scope;
function sr-restore() {
if (NavState.restore-scope == "mixtape-detail" && self.viewport-height > self.height) {
self.viewport-y = NavState.scroll-restore;
NavState.restore-scope = "";
}
}
init => { self.sr-restore(); }
changed sr-armed => { self.sr-restore(); }
changed viewport-height => { self.sr-restore(); }
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
page := VerticalLayout {
width: parent.width;
// .detail-view padding: 8 8 100 18.
// Canonical content padding (Favorites / LocalLibrary): 32px L/R
// (the right pad is the scrollbar inset), 100px bottom clears the
// player bar. NO per-view back button — the shell owns the global
// < > nav.
padding-top: 16px;
padding-right: 32px;
padding-bottom: 100px;
padding-left: 32px;
spacing: 0px;
alignment: start;
// --- Hero header (.header-content) -------------------------
HorizontalLayout {
spacing: 32px;
alignment: start;
// Cover (186px mosaic / custom cover).
VerticalLayout {
alignment: end;
Rectangle {
width: 186px;
height: 186px;
// CollectionMosaic reads a MixtapeCardItem; the detail
// state carries the same mosaic fields, so wrap them.
CollectionMosaic {
size: 186px;
item: {
kind: MyQbzDetailState.kind,
item-count: MyQbzDetailState.item-count,
has-custom-cover: MyQbzDetailState.has-custom-cover,
custom-cover: MyQbzDetailState.custom-cover,
cover-count: MyQbzDetailState.cover-count,
url1: MyQbzDetailState.url1, url2: MyQbzDetailState.url2,
url3: MyQbzDetailState.url3, url4: MyQbzDetailState.url4,
url5: MyQbzDetailState.url5, url6: MyQbzDetailState.url6,
url7: MyQbzDetailState.url7, url8: MyQbzDetailState.url8,
url9: MyQbzDetailState.url9,
cover1: MyQbzDetailState.cover1, cover2: MyQbzDetailState.cover2,
cover3: MyQbzDetailState.cover3, cover4: MyQbzDetailState.cover4,
cover5: MyQbzDetailState.cover5, cover6: MyQbzDetailState.cover6,
cover7: MyQbzDetailState.cover7, cover8: MyQbzDetailState.cover8,
cover9: MyQbzDetailState.cover9,
};
}
}
}
// Info column.
VerticalLayout {
alignment: end;
horizontal-stretch: 1;
spacing: 8px;
// Eyebrow (.kind-tag).
Text {
text: MyQbzDetailState.kind-label;
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
}
// Title — canonical hero title size (Album/Playlist heroes).
Text {
text: MyQbzDetailState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
wrap: word-wrap;
}
// Description (optional).
if MyQbzDetailState.description != "": Text {
width: Math.min(root.width - 280px, 720px);
text: MyQbzDetailState.description;
color: Theme.text-secondary;
font-size: Typography.link;
wrap: word-wrap;
}
// Meta (item-count summary).
Text {
text: MyQbzDetailState.meta;
color: Theme.text-muted;
font-size: Typography.legal;
}
Rectangle { height: 4px; }
// Hero action row (spec 12 §3.6): EXACTLY four controls from
// the shared circular-icon-button family — Play (primary),
// Shuffle, DJ-mix, and the Overflow (⋯) menu trigger. Edit /
// delete / cover / convert / play-mode now live in the ⋯ menu
// (spec §10), NOT as flat circles.
HorizontalLayout {
spacing: 8px;
alignment: start;
// (1) Play — primary (filled white disc + black glyph;
// the shared CircleAction.primary, spec §3.6).
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.play-all(); }
}
// (2) Shuffle.
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
diameter: 40px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.shuffle(); }
}
}
// (3) DJ-mix (DjIcon) — opens the MyQbzMixModal (the
// "Random queue" sampler) via the dj-mix action.
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/turntable.svg");
diameter: 40px;
enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.dj-mix(); }
}
}
// (4) Overflow (⋯) — toggles the hero overflow menu.
VerticalLayout {
alignment: center;
overflow-anchor := Rectangle {
width: 40px;
height: 40px;
CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg");
diameter: 40px;
clicked => { overflow-menu.show(); }
}
overflow-menu := PopupWindow {
x: 0;
y: 44px;
width: 220px;
// Explicit height (same fix as row-menu) sized
// to the MAX item set: 7 items (32px) + 2
// separators (1px) + padding (8px) + spacing.
height: 250px;
close-policy: PopupClosePolicy.close-on-click;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
// 1 — Rename.
RowMenuItem {
icon: @image-url("../assets/icons/pen-line.svg");
label: @tr("Rename");
chosen => { MyQbzDetailActions.open-rename(); }
}
// 2 — Edit description.
RowMenuItem {
icon: @image-url("../assets/icons/pen-line.svg");
label: @tr("Edit description");
chosen => { MyQbzDetailActions.open-description(); }
}
// 3 — Set custom cover.
RowMenuItem {
icon: @image-url("../assets/icons/image.svg");
label: @tr("Upload cover");
chosen => { MyQbzDetailActions.upload-cover(); }
}
// 4 — Remove custom cover (only when set).
if MyQbzDetailState.has-custom-cover: RowMenuItem {
icon: @image-url("../assets/icons/x.svg");
label: @tr("Clear custom cover");
chosen => { MyQbzDetailActions.remove-cover(); }
}
Rectangle { height: 1px; background: Theme.surface-elevated; }
// 5 — Play-mode toggle (label = the OTHER mode).
RowMenuItem {
icon: @image-url("../assets/icons/shuffle.svg");
label: MyQbzDetailState.play-mode == "in_order"
? @tr("Album shuffle")
: @tr("In order");
chosen => { MyQbzDetailActions.toggle-play-mode(); }
}
// 6 — Convert kind (hidden for artist collections).
if MyQbzDetailState.kind != "artist_collection": RowMenuItem {
icon: @image-url("../assets/icons/cassette-tape.svg");
label: MyQbzDetailState.kind == "mixtape"
? @tr("Convert to Collection")
: @tr("Convert to Mixtape");
chosen => { MyQbzDetailActions.convert-kind(); }
}
Rectangle { height: 1px; background: Theme.surface-elevated; }
// 7 — Delete (destructive).
RowMenuItem {
icon: @image-url("../assets/icons/trash-2.svg");
label: @tr("Delete");
destructive: true;
chosen => { MyQbzDetailActions.open-delete(); }
}
}
}
}
}
}
}
}
}
Rectangle { height: 32px; }
// --- Empty list (collection with 0 items) ------------------
if MyQbzDetailState.item-count == 0: Rectangle {
height: 80px;
Text {
text: @tr("No items yet. Add albums, tracks, or playlists from their detail pages.");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// --- Toolbar + body (only when the collection has items) ---
if MyQbzDetailState.item-count > 0: VerticalLayout {
spacing: 0px;
alignment: start;
// --- Sticky toolbar (.list-controls) -------------------
HorizontalLayout {
height: 30px;
spacing: 8px;
// Spacer floats the toolbar flush right.
Rectangle { horizontal-stretch: 1; }
// Search box (app ExpandableSearch).
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search");
sm: true;
query <=> MyQbzDetailState.search;
edited(q) => { MyQbzDetailActions.search-changed(q); }
}
}
// Sort (app QbzSelect).
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 170px;
sm: true;
options: [
@tr("Position"),
@tr("Name"),
@tr("Year"),
@tr("Tracks"),
];
current-index: MyQbzDetailState.sort == "name" ? 1
: MyQbzDetailState.sort == "year" ? 2
: MyQbzDetailState.sort == "tracks" ? 3
: 0;
selected(i) => {
MyQbzDetailActions.set-sort(
i == 1 ? "name"
: i == 2 ? "year"
: i == 3 ? "tracks"
: "position");
}
}
}
// Type + source filter — LABELED filter button (the
// Favorites FavGenreButton idiom: icon + text, active =
// accent fill + "N filters") opening a CHECKBOX popup.
Rectangle {
width: filter-btn.width;
VerticalLayout {
alignment: center;
filter-btn := ToolBtn {
icon: @image-url("../assets/icons/list-filter.svg");
label: MyQbzDetailState.filter-count > 0
? (MyQbzDetailState.filter-count == 1
? @tr("1 filter")
: @tr("{} filters", MyQbzDetailState.filter-count))
: @tr("Filter");
active: MyQbzDetailState.filter-count > 0;
clicked => { filter-menu.show(); }
}
}
filter-menu := PopupWindow {
x: 0; y: 36px; width: 200px;
// Explicit height (same fix as row-menu): 2 section
// headers (20px) + 7 options (30px) + 1 separator
// (1px) + padding (8px) + spacing.
height: 280px;
// The Type/Source toggles act-and-keep-open is NOT
// wanted here — these are radio/checkbox options that
// should stay open while you toggle several, so use
// close-on-click-outside (matches the sidebar menu).
close-policy: PopupClosePolicy.close-on-click-outside;
Rectangle {
background: Theme.surface-main;
border-width: 1px;
border-color: Theme.surface-elevated;
border-radius: 8px;
drop-shadow-blur: 24px;
drop-shadow-color: #00000066;
VerticalLayout {
padding: 4px;
spacing: 1px;
// Type section.
Text {
text: @tr("Type");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 0.8px;
// padding 6 8 2.
height: 20px;
vertical-alignment: center;
horizontal-alignment: left;
}
MenuOption {
label: @tr("All types");
selected: MyQbzDetailState.type-filter == "all";
chosen => { MyQbzDetailActions.set-type-filter("all"); }
}
MenuOption {
label: @tr("Album");
selected: MyQbzDetailState.type-filter == "album";
chosen => { MyQbzDetailActions.set-type-filter("album"); }
}
MenuOption {
label: @tr("Track");
selected: MyQbzDetailState.type-filter == "track";
chosen => { MyQbzDetailActions.set-type-filter("track"); }
}
MenuOption {
label: @tr("Playlist");
selected: MyQbzDetailState.type-filter == "playlist";
chosen => { MyQbzDetailActions.set-type-filter("playlist"); }
}
Rectangle { height: 1px; background: Theme.surface-elevated; }
// Source section (multi-select).
Text {
text: @tr("Source");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 0.8px;
height: 20px;
vertical-alignment: center;
horizontal-alignment: left;
}
MenuOption {
label: @tr("Qobuz");
selected: MyQbzDetailState.src-qobuz;
chosen => { MyQbzDetailActions.toggle-source-filter("qobuz"); }
}
MenuOption {
label: @tr("Plex");
selected: MyQbzDetailState.src-plex;
chosen => { MyQbzDetailActions.toggle-source-filter("plex"); }
}
MenuOption {
label: @tr("Local");
selected: MyQbzDetailState.src-local;
chosen => { MyQbzDetailActions.toggle-source-filter("local"); }
}
}
}
}
}
// Reset (when any filter/sort differs from defaults) —
// icon-only ToolBtn (surface-elevated, never accent fill).
if MyQbzDetailState.has-any-filter: VerticalLayout {
alignment: center;
ToolBtn {
icon: @image-url("../assets/icons/rotate-ccw.svg");
clicked => { MyQbzDetailActions.reset-filters(); }
}
}
// Select / Cancel — ICON-ONLY (no text label), matching the
// app's Favorites/LocalLibrary multi-select toggle. `active`
// (select-mode on) is the only state cue.
VerticalLayout {
alignment: center;
ToolBtn {
icon: @image-url("../assets/icons/square-check-big.svg");
active: MyQbzDetailState.select-mode;
clicked => { MyQbzDetailActions.toggle-select-mode(); }
}
}
// View-mode toggle (list / grid / expanded) — the app's
// ToggleButton segments in a surface-elevated box (the
// 3-segment ViewToggle idiom; ADR-008, never accent-filled).
VerticalLayout {
alignment: center;
Rectangle {
height: 30px;
width: 90px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
HorizontalLayout {
ToggleButton {
icon: @image-url("../assets/icons/list.svg");
active: MyQbzDetailState.view-mode == "list";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("list"); }
}
ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg");
active: MyQbzDetailState.view-mode == "grid";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("grid"); }
}
ToggleButton {
icon: @image-url("../assets/icons/rows-3.svg");
active: MyQbzDetailState.view-mode == "expanded";
sm: true;
clicked => { MyQbzDetailActions.set-view-mode("expanded"); }
}
}
}
}
}
Rectangle { height: 10px; }
// ============ BODY: GRID ============
if MyQbzDetailState.view-mode == "grid": grid := Rectangle {
property <int> columns: Math.max(1,
Math.floor((self.width - 24px + root.grid-gap) / (root.grid-min-w + root.grid-gap)));
// card width = floor((W - gap*(cols-1)) / cols).
property <length> card-w:
(self.width - 24px - root.grid-gap * (self.columns - 1)) / self.columns;
property <length> card-h: self.card-w + 8px + 18px + 16px + 16px;
property <int> rows: Math.ceil(MyQbzDetailState.items.length / self.columns);
height: self.rows > 0
? self.rows * self.card-h + Math.max(0, self.rows - 1) * root.grid-gap
: 0px;
for item[i] in MyQbzDetailState.items: DetailCard {
x: 12px + Math.mod(i, grid.columns) * (grid.card-w + root.grid-gap);
y: Math.floor(i / grid.columns) * (grid.card-h + root.grid-gap);
item: item;
card-w: grid.card-w;
select-mode: MyQbzDetailState.select-mode;
}
}
// ============ BODY: LIST / EXPANDED ============
if MyQbzDetailState.view-mode != "grid": VerticalLayout {
spacing: 0px;
// Column header (.item-list-header) — HARDCODED labels.
Rectangle {
height: 1px;
background: Theme.surface-elevated;
}
HorizontalLayout {
height: 34px;
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
Text { width: 40px; text: "#"; color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; horizontal-alignment: center; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: @tr("Item"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; }
Text { width: 140px; text: @tr("Type"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; }
Text { width: 80px; text: @tr("Source"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; }
Text { width: 160px; text: @tr("Quality"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; vertical-alignment: center; }
Text { width: 72px; text: @tr("Tracks"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; horizontal-alignment: right; vertical-alignment: center; }
Text { width: 60px; text: @tr("Year"); color: Theme.text-muted; font-size: 10px; font-weight: Typography.semibold; letter-spacing: 1.2px; horizontal-alignment: right; vertical-alignment: center; }
Rectangle { width: 40px; }
}
Rectangle {
height: 1px;
background: Theme.surface-elevated;
}
// Rows. In LIST mode each item is just its row. In EXPANDED
// mode every album / playlist row is followed by its tracks
// rendered directly underneath (spec 12 §8 — no chevron,
// always visible, fetched once on entering expanded mode).
for item[i] in MyQbzDetailState.items: VerticalLayout {
spacing: 0px;
DetailRow {
item: item;
index: i + 1;
select-mode: MyQbzDetailState.select-mode;
}
// Inline tracks (.expanded-tracks): only in expanded
// mode, only for album / playlist items. Left-indented
// 52px to align under the artwork + gap.
if MyQbzDetailState.view-mode == "expanded" && item.can-expand: Rectangle {
// content-sized: the VerticalLayout below drives the
// height (loading row / empty caption / N TrackRows).
height: tracks-col.preferred-height;
tracks-col := VerticalLayout {
padding-left: 52px;
padding-right: 12px;
padding-top: 4px;
padding-bottom: 8px;
spacing: 0px;
// Loading: spinner while the fetch is in flight
// and nothing is cached yet.
if item.expand-loading && item.inline-tracks.length == 0: Rectangle {
height: 32px;
LoadingSpinner {
size: 16px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Loaded but empty: the "no results" caption.
if !item.expand-loading && item.tracks-loaded
&& item.inline-tracks.length == 0: Rectangle {
height: 32px;
Text {
text: @tr("No results found");
color: Theme.text-muted;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Tracks: one shared TrackRow per resolved track.
// hide-download + hide-favorite for inline rows
// (spec 12 §8 props); per-source menu logic is
// handled inside TrackRow by track.source.
for et[ti] in item.inline-tracks: TrackRow {
track: et;
index: ti;
show-artwork: false;
show-favorite: false;
show-download: false;
show-source: false;
draggable: false;
media-action(kind, id, action) => {
// The artist link emits ("artist", id, "open"); the
// context menu emits ("track", id, "go-to-artist").
// Both -> the parent item's artist navigation.
if ((kind == "artist" && action == "open") || action == "go-to-artist") {
MyQbzDetailActions.open-artist(item.source, et.artist);
} else {
// play / play-next / queue / go-to-album (album-link
// "open" too) all route through inline-track-action;
// the album link's id is ignored — go-to-album always
// opens the PARENT item (spec §8 menuActions).
MyQbzDetailActions.inline-track-action(
item.source-item-id,
et.id,
(kind == "album" && action == "open") ? "go-to-album" : action);
}
}
}
}
}
}
}
}
}
}
// Custom scrollbar (only meaningful in the loaded state).
ListScrollbar {
visible: !MyQbzDetailState.loading && MyQbzDetailState.found;
x: parent.width - self.width - 4px;
y: 0px;
width: 14px;
height: parent.height;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
// ===================== BULK ACTION BAR =============================
// Spec 12 §13: a bottom-docked bar that appears while items are selected
// (placement='bottom' default). Mirrors the local-library MultiSelectBar
// chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't
// expressible in MultiSelectBar, so its two actions are flattened to the
// adjacent "Add to queue" + "Play next" buttons, same idiom the Local
// Library tracks bar uses). The §13.1 action set, in order: Add to queue
// (play-later), Play next, Add to playlist, Add to Mixtape/Collection,
// Remove from this collection (danger), Clear (exit-select). The bar emits
// the id; the Rust side routes it.
if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle {
x: 18px;
// Sit just above the fixed player bar (the scroller reserves 100px at
// its bottom; the bar floats over that gutter).
y: parent.height - self.height - 12px;
width: parent.width - 18px - 22px;
height: 44px;
MultiSelectBar {
width: 100%;
selected-count: MyQbzDetailState.selected-count;
actions: [
{ id: "add-to-queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
];
action(id) => {
MyQbzDetailActions.bulk-action(id);
}
}
}
}