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:
9 files changed
+1337
-5
No files matched your search
@@ -420,7 +420,12 @@ export component AlbumPageView inherits Rectangle {
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction { icon: @image-url("../assets/icons/cassette-tape.svg"); }
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
clicked => {
|
||||
root.media-action("album", AlbumState.id, "add-to-mixtape");
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
|
||||
@@ -304,6 +304,15 @@ export component LocalAlbumView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
clicked => {
|
||||
LocalAlbumActions.add-to-mixtape();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version picker (only with multiple physical copies).
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
|
||||
@@ -1330,6 +1330,7 @@ export component LocalLibraryView inherits Rectangle {
|
||||
{ id: "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: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
||||
];
|
||||
action(id) => {
|
||||
@@ -1739,6 +1740,14 @@ export component LocalLibraryView inherits Rectangle {
|
||||
clicked => { LocalLibraryActions.folders-bulk-action("add-to-playlist"); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
RailBulkBtn {
|
||||
icon: @image-url("../assets/icons/cassette-tape.svg");
|
||||
tooltip: @tr("Add to Mixtape/Collection");
|
||||
clicked => { LocalLibraryActions.folders-bulk-action("add-to-mixtape"); }
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
RailBulkBtn {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
|
||||
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
||||
import { CreateMyQbzModal } from "../myqbz/CreateMyQbzModal.slint";
|
||||
import { MyQbzEditModal } from "../myqbz/MyQbzEditModal.slint";
|
||||
import { AddToMixtapeModal } from "../myqbz/AddToMixtapeModal.slint";
|
||||
import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal.slint";
|
||||
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
||||
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
|
||||
@@ -389,6 +390,10 @@ export component AppShell inherits Rectangle {
|
||||
// Global My QBZ detail edit modals (rename / description / delete-confirm).
|
||||
MyQbzEditModal { }
|
||||
|
||||
// Global "Add to Mixtape/Collection" picker, opened from any track /
|
||||
// album / playlist / bulk-selection "Add to Mixtape/Collection" trigger.
|
||||
AddToMixtapeModal { }
|
||||
|
||||
// Ephemeral "already playing" add-vs-replace choice.
|
||||
EphemeralPlayChoiceModal { }
|
||||
|
||||
|
||||
@@ -697,6 +697,7 @@ export global LocalAlbumActions {
|
||||
callback shuffle();
|
||||
callback edit-tags();
|
||||
callback add-to-playlist();
|
||||
callback add-to-mixtape();
|
||||
callback play-track(string /* track id */);
|
||||
callback select-version(int);
|
||||
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
||||
@@ -1125,6 +1126,76 @@ export global MyQbzCreateActions {
|
||||
callback close();
|
||||
}
|
||||
|
||||
// === "Add to Mixtape/Collection" picker modal (global singleton) =========
|
||||
// Spec 21/50. A global overlay (mounted ONCE at the window root) 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. Every app-wide "Add to Mixtape/Collection" trigger calls
|
||||
// MyQbzAddActions.open(...) with a payload it built; the Rust controller
|
||||
// (crate::myqbz_add) holds the pending items and drives the picker.
|
||||
//
|
||||
// One picker row = one collection (mixtape | collection | artist_collection),
|
||||
// with its kind label, "N albums" meta, and an `already-has` flag computed
|
||||
// from `item_exists` so the row can show an "already added" hint.
|
||||
export struct MyQbzAddRow {
|
||||
id: string,
|
||||
name: string,
|
||||
// "mixtape" | "collection" | "artist_collection".
|
||||
kind: string,
|
||||
// Row icon by kind ("mixtape" -> cassette, "artist_collection" -> user,
|
||||
// else library-big). Resolved in Rust to a key the row maps to a glyph.
|
||||
icon: string,
|
||||
// Right-aligned kind tag, uppercase ("MIXTAPE" | "COLLECTION" | "ARTIST").
|
||||
kind-label: string,
|
||||
// "N albums" / "1 album" — ICU plural rendered in Rust (always "album(s)").
|
||||
meta: string,
|
||||
// True when this collection already contains ALL of the pending items
|
||||
// (per item_exists). Drives the per-row "already added" hint.
|
||||
already-has: bool,
|
||||
}
|
||||
|
||||
export global MyQbzAddState {
|
||||
in-out property <bool> open: false;
|
||||
// Whether the picker is loading the collection list.
|
||||
in property <bool> loading: false;
|
||||
// The collections offered as targets (already kind-restricted +
|
||||
// recency-sorted + item_exists-resolved by the Rust controller).
|
||||
in property <[MyQbzAddRow]> rows: [];
|
||||
// Header title + subtitle (precomputed: single-item title or bulk
|
||||
// "{count} items" / "{first} + N more").
|
||||
in property <string> header-title;
|
||||
in property <string> header-subtitle;
|
||||
// True when >1 pending item (bulk add).
|
||||
in property <bool> bulk-mode: false;
|
||||
// True when ANY pending item is a track/playlist -> targets restricted to
|
||||
// Mixtapes; the "Create Collection" button is hidden.
|
||||
in property <bool> restrict-to-mixtape: false;
|
||||
// Free-text filter over collection names.
|
||||
in-out property <string> search;
|
||||
// Id of the collection currently being written to (disables rows/buttons).
|
||||
in property <string> busy-id;
|
||||
// --- Create-new sub-panel ---
|
||||
in-out property <bool> creating: false;
|
||||
in-out property <string> create-name;
|
||||
// "mixtape" | "collection".
|
||||
in-out property <string> create-kind: "mixtape";
|
||||
in property <bool> create-busy: false;
|
||||
}
|
||||
|
||||
export global MyQbzAddActions {
|
||||
// Click a collection row -> add the pending items to it.
|
||||
callback pick(string /* collection id */);
|
||||
// Filter the list by name.
|
||||
callback search-changed(string /* query */);
|
||||
// Open the create-new sub-panel preset to a kind ("mixtape"|"collection").
|
||||
callback show-create(string /* kind */);
|
||||
// Back out of the create sub-panel to the picker.
|
||||
callback create-back();
|
||||
// Create a new collection (create-kind/create-name) then add the items.
|
||||
callback create-and-add();
|
||||
callback close();
|
||||
}
|
||||
|
||||
// Ephemeral "play with active queue" choice — shown when the user triggers an
|
||||
// ephemeral play action while a queue is already playing. Two outcomes:
|
||||
// add to the queue, or clear the queue and start playing.
|
||||
|
||||
Reference in new issue
Block a user