Files
qbz/crates/qbz-slint/ui/primitives/PlaylistPickerModal.slint
T
vicrodh 3689a5fda8 feat(slint): add-to-playlist duplicate modal + playlist importer port
Add-to-playlist (Slices 0-3):
- qbz-core: check_playlist_duplicates + pure compute_playlist_duplicates
  (reuses qbz_models::PlaylistDuplicateResult) + unit tests.
- PlaylistDuplicateConfirmModal (Add all / Add new only / Cancel); the
  duplicate check runs on the Qobuz->Qobuz picker branch only (local/Plex
  add directly, mirroring Tauri); the track-id Vec lives Rust-side in a
  stash, never in Slint.
- Inline create-and-add in the picker (online create_playlist, offline ->
  local playlist per D8) + client-side filter (Slint 1.16 has no string
  contains, routed through a Rust callback) + success toasts.

Playlist importer (1:1 Tauri port):
- New headless crate qbz-playlist-import (Spotify/Apple/Tidal/Deezer
  scrapers, Qobuz matcher, importer, progress sink, shared http client;
  copy-not-move from src-tauri, zero tauri deps; 40 unit tests green).
- PlaylistImportModal (two-step fetch-preview -> import with live progress
  bar / log / summary) + controller + sidebar PLAYLISTS menu entry point.

Reco logging staged behind TODO(reco-v1) markers (golden rule: playlist
recommendations ship in Slint v1 once the reco engine is extracted to a
shared crate).

cc
2026-06-11 22:03:46 -06:00

626 lines
31 KiB
Plaintext

// "Add to Playlist" picker — a global modal overlay, 1:1 with Tauri's
// PlaylistModal.svelte (addTrack mode). A focus-opened dropdown lists
// the user's playlists; clicking a row only SELECTS it (the selection
// becomes the input's placeholder); the footer Add button fires the
// actual pick. "+ Create new playlist" (first dropdown row) reveals an
// inline name field confirmed by the same footer button ("Create & Add").
// Opened from the track context menu (PlaylistPickerState.open).
//
// Filter matching runs Rust-side (Slint 1.16 has no string `.contains`):
// every keystroke recomputes each row's `filter-rank` (match ordinal,
// -1 = filtered out) plus `filter-matches`; ranks >= 8 hide behind the
// "{} more playlist(s)..." overflow row.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { PlaylistPickerState, PlaylistPickerActions, PlaylistPickItem } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { LoadingSpinner } from "LoadingSpinner.slint";
import { QbzPrimaryButton } from "QbzPrimaryButton.slint";
import { SecondaryButton } from "SecondaryButton.slint";
// One flat dropdown row (Tauri parity: single 41px line, name + count,
// hover fill only — no icon tile, no zebra). Rows filtered out
// (filter-rank == -1) or ranked past the 8-row window collapse to zero
// height; the list layout's spacing is 0 so nothing leaks. The height
// binding reads only model data — never layout-managed geometry.
component DropRow inherits Rectangle {
in property <PlaylistPickItem> item;
// Keyboard highlight (arrow nav) — painted like hover.
in property <bool> highlighted;
// Enter-to-select dispatch: when `select-seq` bumps, the row whose
// filter-rank equals `select-rank` fires `select()`.
in property <int> select-seq;
in property <int> select-rank: -1;
callback select();
visible: root.item.filter-rank >= 0 && root.item.filter-rank < 8;
height: (root.item.filter-rank >= 0 && root.item.filter-rank < 8) ? 41px : 0px;
background: (ta.has-hover || root.highlighted) ? Theme.surface-hover : transparent;
changed select-seq => {
if (root.item.filter-rank >= 0 && root.item.filter-rank == root.select-rank) {
root.select();
}
}
// Row-level TouchArea declared FIRST, cell content after.
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.select();
}
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
padding-top: 10px;
padding-bottom: 10px;
spacing: 8px;
// LOCAL playlists (library.db) keep a small hard-drive glyph —
// deliberate Slint extra; locals don't exist in Tauri's picker.
if root.item.is-local: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/hard-drive.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
Text {
text: root.item.tracks-line;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
export component PlaylistPickerModal inherits Rectangle {
visible: PlaylistPickerState.open;
if PlaylistPickerState.open: Rectangle {
background: #000000bf;
// Scrim — click outside to dismiss.
TouchArea {
mouse-cursor: default;
clicked => {
PlaylistPickerActions.close();
}
}
// Card-sized FocusScope: keyboard nav + the picker's local state.
// The locals live on the `if open:` subtree so every conditional
// remount resets them — no state.slint props, no Rust resets.
keys := FocusScope {
// Dropdown visibility + the select-then-Add selection.
property <bool> dropdown-open: false;
property <string> selected-id: ""; // "" = nothing selected
property <string> selected-label: ""; // "{name} ({tracks-line})" placeholder
// Keyboard highlight over shown + 1 virtual rows; -1 = none,
// 0 = the create-new row, 1.. = visible playlist rows.
property <int> highlight: -1;
// Bumped by Enter; the rank-matching DropRow selects itself.
property <int> select-seq: 0;
property <int> nav-count: Math.min(PlaylistPickerState.filter-matches, 8) + 1;
property <int> adding-count: Math.max(1, PlaylistPickerState.track-ids.length);
width: Math.min(root.width - 80px, 490px);
// Tauri: min-height 400px / max-height 90vh — the body
// stretches so the footer pins to the bottom and blank space
// sits under the picker group.
height: Math.min(Math.max(400px, panel.preferred-height), root.height - 80px);
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
forward-focus: search-input;
// Enter semantics: dropdown open → select the highlighted row
// (select only, never submit); dropdown closed → act as the
// footer Add button.
function on-enter() {
if (self.dropdown-open) {
if (self.highlight >= 0) {
self.commit-highlight();
}
} else if (!PlaylistPickerState.creating) {
if (PlaylistPickerState.creating-open) {
if (PlaylistPickerState.create-name != "") {
PlaylistPickerActions.create-and-add();
}
} else if (self.selected-id != "") {
PlaylistPickerActions.pick(self.selected-id);
}
}
}
function commit-highlight() {
if (self.highlight == 0) {
// Virtual row 0 = "+ Create new playlist".
self.selected-id = "";
self.selected-label = "";
PlaylistPickerState.creating-open = true;
PlaylistPickerState.filter = "";
PlaylistPickerActions.filter-changed("");
self.dropdown-open = false;
self.highlight = -1;
} else if (self.highlight > 0) {
self.select-seq += 1;
}
}
key-pressed(event) => {
if (event.text == Key.DownArrow) {
if (!self.dropdown-open) {
self.dropdown-open = true;
self.highlight = 0;
} else {
self.highlight = Math.mod(self.highlight + 1, self.nav-count);
}
return accept;
}
if (event.text == Key.UpArrow) {
if (!self.dropdown-open) {
self.dropdown-open = true;
self.highlight = 0;
} else if (self.highlight <= 0) {
self.highlight = self.nav-count - 1;
} else {
self.highlight -= 1;
}
return accept;
}
if (event.text == Key.Escape) {
// First Escape closes the dropdown, second the modal.
if (self.dropdown-open) {
self.dropdown-open = false;
self.highlight = -1;
} else {
PlaylistPickerActions.close();
}
return accept;
}
if (event.text == Key.Return) {
self.on-enter();
return accept;
}
return reject;
}
// Deferred mount focus — synchronous focus during the remount
// races layout (see ExpandableSearch); a one-shot timer grabs
// it a tick later. forward-focus routes it into the input.
focus-timer := Timer {
interval: 30ms;
running: true;
triggered => {
self.running = false;
keys.focus();
}
}
card := Rectangle {
border-radius: Radius.lg;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
// NO clip — the dropdown may overflow the card bottom,
// like Tauri's overflow: visible.
// Swallow clicks so they don't reach the scrim; doubles as
// "click inside the card closes the list" (Tauri parity).
TouchArea {
clicked => {
keys.dropdown-open = false;
keys.highlight = -1;
}
}
panel := VerticalLayout {
padding: 0px;
spacing: 0px;
// Header: title + close-x.
header := HorizontalLayout {
padding-left: 24px;
padding-right: 24px;
padding-top: 20px;
padding-bottom: 20px;
Text {
text: @tr("Add to Playlist");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
PlaylistPickerActions.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;
}
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
body := VerticalLayout {
padding: 24px;
spacing: 16px;
vertical-stretch: 1;
alignment: start;
if PlaylistPickerState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 30px; }
}
picker-group := VerticalLayout {
spacing: 8px;
label-row := HorizontalLayout {
height: 20px;
Text {
text: @tr("Choose playlist");
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.medium;
horizontal-stretch: 1;
vertical-alignment: center;
}
counter := Text {
// max(1, …): single-track opens carry
// track-id with track-ids empty.
text: PlaylistPickerState.local-mode
? @tr("Adding {} local track(s)", keys.adding-count)
: @tr("Adding {} track(s)", keys.adding-count);
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
}
}
// The search/selection input — explicit 43px
// (Tauri metric; also keeps the card layout
// from dumping surplus height into it).
input-box := Rectangle {
height: 43px;
border-radius: Radius.sm;
background: Theme.surface-card;
border-width: 1px;
border-color: search-input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 12px;
padding-right: 36px; // room for the clear chip
Rectangle {
horizontal-stretch: 1;
clip: true;
search-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
single-line: true;
enabled: !PlaylistPickerState.loading;
text <=> PlaylistPickerState.filter;
edited => {
keys.dropdown-open = true; // typing re-opens
keys.highlight = -1;
PlaylistPickerActions.filter-changed(self.text);
}
accepted => {
keys.on-enter();
}
changed has-focus => {
// Tauri: list opens on focus.
if (self.has-focus) {
keys.dropdown-open = true;
}
}
}
Text {
// Placeholder — Slint TextInput has no
// placeholder property; a sibling Text
// gated on empty text. Doubles as the
// selection label (the filter is cleared
// on select, so text is empty then).
visible: search-input.text == "";
width: 100%;
height: 100%;
text: PlaylistPickerState.creating-open
? @tr("+ Create new playlist")
: (keys.selected-label != ""
? keys.selected-label
: @tr("Search playlists..."));
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
// Clear-selection chip — only when a selection
// (or the inline-create state) exists and the
// list is closed.
if (keys.selected-id != "" || PlaylistPickerState.creating-open)
&& !keys.dropdown-open: clear-chip := Rectangle {
width: 20px;
height: 20px;
x: parent.width - self.width - 10px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.pill;
background: chip-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: chip-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
chip-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
keys.selected-id = "";
keys.selected-label = "";
PlaylistPickerState.creating-open = false;
PlaylistPickerState.create-name = "";
}
}
}
}
}
// Inline create — a second form group ADDED below the
// picker (Tauri §1.4.6); the footer button confirms.
if PlaylistPickerState.creating-open: create-group := VerticalLayout {
spacing: 8px;
Text {
text: @tr("Playlist Name");
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.medium;
}
name-box := Rectangle {
height: 43px;
border-radius: Radius.sm;
background: Theme.surface-card;
border-width: 1px;
border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
Rectangle {
horizontal-stretch: 1;
clip: true;
name-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: Typography.link;
vertical-alignment: center;
single-line: true;
enabled: !PlaylistPickerState.creating;
text <=> PlaylistPickerState.create-name;
accepted => {
if (PlaylistPickerState.create-name != ""
&& !PlaylistPickerState.creating) {
PlaylistPickerActions.create-and-add();
}
}
}
Text {
visible: name-input.text == "";
width: 100%;
height: 100%;
text: @tr("My Playlist");
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
footer := HorizontalLayout {
padding-left: 24px;
padding-right: 24px;
padding-top: 16px;
padding-bottom: 16px;
alignment: end;
spacing: 12px;
SecondaryButton {
label: @tr("Cancel");
clicked => {
PlaylistPickerActions.close();
}
}
// The single accent confirm (ADR-008). Disabled until
// valid — replaces Tauri's in-body error box.
QbzPrimaryButton {
height: 34px;
label: PlaylistPickerState.creating
? @tr("Saving...")
: (PlaylistPickerState.creating-open ? @tr("Create & Add") : @tr("Add"));
enabled: !PlaylistPickerState.creating
&& (PlaylistPickerState.creating-open
? PlaylistPickerState.create-name != ""
: keys.selected-id != "");
clicked => {
if (PlaylistPickerState.creating-open) {
PlaylistPickerActions.create-and-add();
} else {
PlaylistPickerActions.pick(keys.selected-id);
}
}
}
}
}
// Dropdown overlay — declared LAST inside the card so it
// paints above the body/footer (ADR-009 declaration-order z)
// and legitimately overflows the unclipped card bottom.
if keys.dropdown-open: dropdown := Rectangle {
property <int> shown: Math.min(PlaylistPickerState.filter-matches, 8);
// create-new row 41 + 1 divider, 41px per shown row,
// 41px no-results row, 1 + 34 overflow row.
property <length> drop-content: 42px
+ self.shown * 41px
+ (PlaylistPickerState.filter-matches == 0
&& PlaylistPickerState.filter != "" ? 41px : 0px)
+ (PlaylistPickerState.filter-matches > 8 ? 35px : 0px);
x: 24px;
// Anchor below the input — static arithmetic over fixed
// metrics (never an absolute-position binding): header 68
// (20 pad + 28 close-x + 20 pad) + 1 divider + 24 body pad
// + 20 label row + 8 spacing + 43 input + 4 gap.
y: 68px + 1px + 24px + 20px + 8px + 43px + 4px;
width: parent.width - 48px;
height: Math.min(self.drop-content + 2px, 322px); // Tauri max-height 320 + border
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
border-radius: Radius.sm;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
drop-shadow-color: Theme.card-shadow;
clip: true;
// Keep clicks on the list chrome from reaching the card
// swallower (which would close the list).
TouchArea { }
Flickable {
viewport-width: self.width;
viewport-height: inner.preferred-height;
inner := VerticalLayout {
spacing: 0px;
// "+ Create new playlist" — ALWAYS first, never
// filtered out (Tauri parity).
create-row := Rectangle {
height: 41px;
background: (create-ta.has-hover || keys.highlight == 0)
? Theme.surface-hover : transparent;
create-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
keys.selected-id = "";
keys.selected-label = "";
PlaylistPickerState.creating-open = true;
PlaylistPickerState.filter = "";
PlaylistPickerActions.filter-changed("");
keys.dropdown-open = false;
keys.highlight = -1;
}
}
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
padding-top: 10px;
padding-bottom: 10px;
Text {
text: @tr("+ Create new playlist");
color: Theme.accent;
font-size: Typography.link;
font-weight: Typography.medium;
vertical-alignment: center;
overflow: elide;
}
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
for pl[i] in PlaylistPickerState.playlists: DropRow {
item: pl;
highlighted: pl.filter-rank >= 0
&& pl.filter-rank + 1 == keys.highlight;
select-seq: keys.select-seq;
select-rank: keys.highlight - 1;
select => {
keys.selected-id = self.item.id;
keys.selected-label = self.item.tracks-line == ""
? self.item.name
: self.item.name + " (" + self.item.tracks-line + ")";
PlaylistPickerState.creating-open = false;
// Tauri clears the query on selection (§2.4).
PlaylistPickerState.filter = "";
PlaylistPickerActions.filter-changed("");
keys.dropdown-open = false;
keys.highlight = -1;
}
}
if PlaylistPickerState.filter != ""
&& PlaylistPickerState.filter-matches == 0: Rectangle {
height: 41px;
Text {
width: 100%;
height: 100%;
text: @tr("No playlists found");
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// Overflow row — ranks past the 8-row window.
if PlaylistPickerState.filter-matches > 8: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
if PlaylistPickerState.filter-matches > 8: Rectangle {
height: 34px;
Text {
width: 100%;
height: 100%;
text: @tr("{} more playlist(s)...",
PlaylistPickerState.filter-matches - 8);
color: Theme.text-muted;
font-size: Typography.legal;
font-italic: true;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
}
}
}