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
1259 lines
62 KiB
Plaintext
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);
|
|
}
|
|
}
|
|
}
|
|
}
|