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
626 lines
31 KiB
Plaintext
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|