feat(myqbz): AddToMixtapeModal + app-wide add-to-collection entry points

Phase-2 Slice 8. Adds "Add to Mixtape/Collection" everywhere "Add to playlist"
lives, so collections can be populated:

- ui/myqbz/AddToMixtapeModal.slint: the picker (search + collection list with
  kind icon/name/count + "Already added" via item_exists + kind tag) and the
  create-new-inline panel (name + kind), per spec 21. Mounted once at root.
- src/myqbz_add.rs: list_collections (kind-restricted: track/playlist->mixtapes,
  album->mixtapes+collections, never artist_collection; recency-sorted); add via
  add_item_with(allow_duplicate=false) counting added/skipped-duplicates + toast;
  create_and_add reuses create_collection. All via with_db -> with_connection.
- Entry points wired: per-track (universal via TrackContextMenu/TrackRow -> search,
  favorites, album rows, local tracks, now-playing), Qobuz album, local/plex album,
  playlist (PM), bulk local tracks. Replaced the PlaylistManager add-to-mixtape no-op.
  Qobuz bulk omitted by design (spec 50 negative-space).

Note: adversarial reviewers were blocked by a session limit; verified manually
(cargo green, confined to qbz-slint, backend untouched, ADR-005/006, coverage per
spec 50).

cc
This commit is contained in:
vicrodh committed 2026-06-09 06:19:35 -06:00
1 parent 2a72878a1d
commit 5da3b8bfd5
9 files changed
+1337 -5

No files matched your search

@@ -0,0 +1,489 @@
// "Add to Mixtape/Collection" picker modal — the global singleton that adds
// one or more items (album / track / playlist, from qobuz or local) into an
// existing Mixtape/Collection, or creates a brand-new one inline and adds into
// it. 1:1 with Tauri's `AddToMixtapeModal.svelte` (spec 21 §A) chrome:
//
// - own backdrop (z9998) + 480px card (z9999), NOT the shared modal chrome.
// - header eyebrow ("ADD TO MIXTAPE/COLLECTION") + title/subtitle (single
// item -> title+subtitle; bulk -> "{count} items" + "{first} + N more").
// - TWO mutually-exclusive body panels: the PICKER (search row + collection
// list + create buttons) and the CREATE-NEW panel (name + kind + footer).
// - per-row kind icon, "N albums" meta, kind tag, and an "already added"
// hint when the collection already contains every pending item.
//
// Mounted ONCE at the window root (AppShell). The Rust controller
// (crate::myqbz_add) holds the pending items + drives every action; the
// "added N / skipped M duplicates" outcome is surfaced via toast.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { MyQbzAddState, MyQbzAddActions, MyQbzAddRow } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { LineEdit } from "std-widgets.slint";
// A single labelled radio option in the create-panel Kind toggle.
component KindRadio inherits TouchArea {
in property <string> label;
in property <bool> selected;
mouse-cursor: pointer;
horizontal-stretch: 0;
HorizontalLayout {
spacing: 6px;
Rectangle {
width: 18px;
height: 18px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 9px;
border-width: root.selected ? 0px : 1.5px;
border-color: Theme.text-muted;
background: root.selected ? Theme.accent : transparent;
Rectangle {
width: 7px;
height: 7px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 3.5px;
background: #ffffff;
visible: root.selected;
}
}
Text {
text: root.label;
color: Theme.text-primary;
font-size: Typography.body;
vertical-alignment: center;
}
}
}
// One collection row in the picker list.
component AddPickRow inherits Rectangle {
in property <MyQbzAddRow> data;
in property <int> index;
in property <bool> busy;
callback clicked(string);
height: 52px;
border-radius: 8px;
opacity: root.busy ? 0.5 : 1.0;
background: ta.has-hover && !root.busy
? #ffffff16
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
// Kind icon.
VerticalLayout {
alignment: center;
Rectangle {
width: 32px;
height: 32px;
QbzIcon {
source: root.data.icon == "user"
? @image-url("../assets/icons/user.svg")
: (root.data.icon == "library-big"
? @image-url("../assets/icons/library-big.svg")
: @image-url("../assets/icons/cassette-tape.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;
}
}
}
// Name + meta.
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.data.name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
overflow: elide;
}
HorizontalLayout {
spacing: 6px;
Text {
text: root.data.meta;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
// "Already added" hint (item_exists for all pending items).
if root.data.already-has: HorizontalLayout {
spacing: 3px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/circle-check-big.svg");
width: 12px;
height: 12px;
tint: Theme.accent;
}
}
Text {
text: @tr("Already added");
color: Theme.accent;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
}
// Right-aligned kind tag.
VerticalLayout {
alignment: center;
Text {
text: root.data.kind-label;
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 1.2px;
}
}
}
ta := TouchArea {
mouse-cursor: root.busy ? default : pointer;
clicked => {
if (!root.busy) {
root.clicked(root.data.id);
}
}
}
}
// A small footer button (Cancel / Back / create-buttons).
component FooterButton inherits Rectangle {
in property <string> label;
in property <bool> primary: false;
in property <bool> enabled-look: true;
in property <image> icon;
in property <bool> has-icon: false;
callback clicked();
height: 38px;
border-radius: Radius.sm;
opacity: root.enabled-look ? 1.0 : 0.5;
background: root.primary
? (ta.has-hover ? #ffffffd6 : Theme.accent)
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
border-width: root.primary ? 0px : 1px;
border-color: Theme.border-subtle;
HorizontalLayout {
padding-left: root.primary ? 20px : 16px;
padding-right: root.primary ? 20px : 16px;
spacing: 6px;
alignment: center;
if root.has-icon: VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
tint: root.primary ? #ffffff : Theme.text-primary;
}
}
Text {
text: root.label;
color: root.primary ? #ffffff : Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: root.enabled-look ? pointer : default;
clicked => {
if (root.enabled-look) {
root.clicked();
}
}
}
}
export component AddToMixtapeModal inherits Rectangle {
visible: MyQbzAddState.open;
property <bool> create-mixtape: MyQbzAddState.create-kind == "mixtape";
property <bool> can-create: MyQbzAddState.create-name != "" && !MyQbzAddState.create-busy;
if MyQbzAddState.open: Rectangle {
background: #00000099; // rgba(0,0,0,0.6)
// Scrim — click outside to dismiss.
TouchArea {
mouse-cursor: default;
clicked => {
MyQbzAddActions.close();
}
}
Rectangle {
width: Math.min(root.width - 80px, 480px);
height: Math.min(root.height - 120px, 560px);
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
// Swallow clicks so they don't reach the scrim.
TouchArea { }
VerticalLayout {
// === Header (always rendered) ===
Rectangle {
height: header-row.preferred-height + 36px;
HorizontalLayout {
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
padding-bottom: 16px;
header-row := HorizontalLayout {
VerticalLayout {
horizontal-stretch: 1;
spacing: 2px;
Text {
text: @tr("ADD TO MIXTAPE/COLLECTION");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
Text {
text: MyQbzAddState.header-title;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
overflow: elide;
}
if MyQbzAddState.header-subtitle != "": Text {
text: MyQbzAddState.header-subtitle;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
MyQbzAddActions.close();
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
}
// Header bottom border.
Rectangle {
height: 1px;
y: parent.height - 1px;
background: Theme.border-subtle;
}
}
// === Body: PICKER panel (default) ===
if !MyQbzAddState.creating: VerticalLayout {
// Search row.
Rectangle {
height: 46px;
HorizontalLayout {
padding-left: 20px;
padding-right: 20px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
search-input := LineEdit {
placeholder-text: @tr("Search…");
text <=> MyQbzAddState.search;
edited(t) => {
MyQbzAddActions.search-changed(t);
}
}
}
Rectangle {
height: 1px;
y: parent.height - 1px;
background: Theme.border-subtle;
}
}
// Loading spinner.
if MyQbzAddState.loading: HorizontalLayout {
alignment: center;
padding: 24px;
LoadingSpinner { size: 30px; }
}
// Empty state.
if !MyQbzAddState.loading && MyQbzAddState.rows.length == 0: Rectangle {
height: 80px;
Text {
text: MyQbzAddState.search != ""
? @tr("No matches.")
: @tr("No mixtapes or collections yet.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// Collection list.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: list.preferred-height;
list := VerticalLayout {
padding: 8px;
spacing: 2px;
for row[i] in MyQbzAddState.rows: AddPickRow {
data: row;
index: i;
busy: MyQbzAddState.busy-id != "";
clicked(id) => {
MyQbzAddActions.pick(id);
}
}
}
}
// Footer: create buttons.
Rectangle {
height: 60px;
background: Theme.surface-elevated;
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
HorizontalLayout {
padding: 12px;
spacing: 8px;
FooterButton {
horizontal-stretch: 1;
label: @tr("Mixtapes");
has-icon: true;
icon: @image-url("../assets/icons/plus.svg");
clicked => {
MyQbzAddActions.show-create("mixtape");
}
}
// Hidden for track/playlist payloads (collections hold
// whole albums only).
if !MyQbzAddState.restrict-to-mixtape: FooterButton {
horizontal-stretch: 1;
label: @tr("Collections");
has-icon: true;
icon: @image-url("../assets/icons/plus.svg");
clicked => {
MyQbzAddActions.show-create("collection");
}
}
}
}
}
// === Body: CREATE-NEW panel ===
if MyQbzAddState.creating: VerticalLayout {
padding: 20px;
spacing: 16px;
// Name.
VerticalLayout {
spacing: 8px;
Text {
text: @tr("NAME");
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
create-name-input := LineEdit {
placeholder-text: root.create-mixtape ? "90s Cassettes" : "Jazz Library";
text <=> MyQbzAddState.create-name;
enabled: !MyQbzAddState.create-busy;
accepted => {
if (root.can-create) {
MyQbzAddActions.create-and-add();
}
}
}
}
// Kind toggle.
VerticalLayout {
spacing: 8px;
Text {
text: @tr("KIND");
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
HorizontalLayout {
spacing: 16px;
alignment: start;
KindRadio {
label: @tr("Mixtapes");
selected: root.create-mixtape;
clicked => {
if (!MyQbzAddState.create-busy) {
MyQbzAddState.create-kind = "mixtape";
}
}
}
KindRadio {
label: @tr("Collections");
selected: !root.create-mixtape;
clicked => {
if (!MyQbzAddState.create-busy) {
MyQbzAddState.create-kind = "collection";
}
}
}
}
}
// Spacer pushes the footer down.
Rectangle { vertical-stretch: 1; }
// Footer: Back + Create & Add.
HorizontalLayout {
alignment: end;
spacing: 8px;
FooterButton {
label: @tr("Back");
enabled-look: !MyQbzAddState.create-busy;
clicked => {
MyQbzAddActions.create-back();
}
}
FooterButton {
label: @tr("Create & Add");
primary: true;
enabled-look: root.can-create;
clicked => {
if (root.can-create) {
MyQbzAddActions.create-and-add();
}
}
}
}
}
}
}
}
}