perf(smart-playlist): suggest rule values for what was typed

The rule editor's value box built its suggestions from libraryStore's
whole-library arrays, which made it one more reason to load every
track at startup, and left it empty when they had not loaded.

SuggestSmartPlaylistValues answers with up to 50 distinct values of a
field that contain the typed text, from the same joined row the rules
match against, so a suggestion is always something a rule can match.
yj-combobox announces its filter text so the editor can ask, debounced
and cached per field and text.

Refs #279
This commit is contained in:
yonlu committed 2026-10-05 22:59:24 -04:00
1 parent 3f23bb4396
commit 3cddf70c4d
7 files changed
+356 -49

No files matched your search

@@ -7,6 +7,11 @@ import { designTokens } from '../../styles/tokens.css';
* keyboard navigation. Accepts a flat `options` string array, filters as
* the user types, and emits `combobox-change` when a value is selected.
*
* It also emits `combobox-input` with `{ text }` whenever the text it
* filters by changes (typing, and the reset to empty on focus), so a
* host whose options are too many to hand over at once can fetch the
* ones matching what was typed instead (#279).
*
* Key implementation detail: option `<li>` elements use `@mousedown` with
* `e.preventDefault()` so that the input's `blur` event does not close the
* dropdown before the click registers.
@@ -196,6 +201,7 @@ export class YjCombobox extends LitElement {
this.filterText = input.value;
this.open = true;
this.highlightedIndex = -1;
this.announceInput();
}
private handleFocus() {
@@ -203,6 +209,17 @@ export class YjCombobox extends LitElement {
this.filterText = '';
this.open = true;
this.highlightedIndex = -1;
this.announceInput();
}
private announceInput() {
this.dispatchEvent(
new CustomEvent('combobox-input', {
detail: { text: this.filterText },
bubbles: true,
composed: true,
}),
);
}
private handleBlur() {
@@ -1,8 +1,12 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import * as library from '@go/library/models.js';
import { PreviewSmartPlaylist } from '@go/playlist/service.js';
import { libraryStore } from '@store/library-store';
import {
PreviewSmartPlaylist,
SuggestSmartPlaylistValues,
} from '@go/playlist/service.js';
import { list } from '@utils/binding';
import { LRUMap } from '@utils/lru-map';
import { describeError } from '@utils/describe-error';
import { designTokens } from '../../styles/tokens.css';
import '@components/combobox/combobox.ts';
@@ -91,51 +95,23 @@ function formatOperatorLabel(op: string): string {
return op.replace(/_/g, ' ');
}
/** Returns autocomplete suggestions for a given field from libraryStore. */
function getAutocompleteOptions(field: string): string[] {
switch (field) {
case 'artist':
return libraryStore.getCachedArtists()?.map((a) => a.Name) ?? [];
case 'genre':
return libraryStore.getCachedGenres()?.map((g) => g.Name) ?? [];
case 'album':
return libraryStore.getCachedAlbums()?.map((a) => a.Name) ?? [];
case 'title': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.TrackName).filter(Boolean))];
}
case 'composer': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.Composer).filter(Boolean))];
}
case 'file_type': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.FileType).filter(Boolean))];
}
case 'year':
case 'release_year': {
// Both year fields draw suggestions from the set of years
// present in the library. The cached Track only carries the
// display (original) year, so it seeds both datalists — the
// list is just a hint, and the real filter runs server-side.
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [
...new Set(
tracks
.map((t) => t.Year)
.filter((y) => y > 0)
.map(String),
),
].sort();
}
default:
return [];
}
}
/**
* Fields whose value box suggests values present in the library. Must
* match `suggestFields` in backend/smartplaylist; any other field gets
* a plain box.
*
* Suggestions are asked for as the user types (#279): they used to be
* built from libraryStore's whole-library arrays, which made this
* editor one more reason to load every track at startup, and gave it
* empty lists whenever those arrays had not landed.
*/
const SUGGEST_FIELDS = new Set([
'title', 'artist', 'album', 'genre', 'composer', 'file_type',
'year', 'release_year',
]);
/** How long typing must pause before suggestions are asked for. */
const SUGGEST_DEBOUNCE_MS = 120;
/**
* Overrides for fields whose title-cased name would be ambiguous. The
@@ -206,6 +182,17 @@ export class SmartPlaylistEditor extends LitElement {
private previewTimer: ReturnType<typeof setTimeout> | null = null;
/** The value suggestions each rule row is showing, by row index. */
@state() private suggestions = new Map<number, readonly string[]>();
private suggestTimer: ReturnType<typeof setTimeout> | null = null;
/** Answers already fetched this session, by field and typed text. */
private suggestCache = new LRUMap<string, readonly string[]>(100);
/** The latest request per row, so a slow answer cannot overwrite a newer one. */
private suggestWanted = new Map<number, string>();
// ── Styles ──────────────────────────────────────────────────────
static override styles = [
@@ -599,6 +586,7 @@ export class SmartPlaylistEditor extends LitElement {
// Reset value when field changes to avoid stale autocomplete data
row.value = '';
row.value2 = '';
this.dropSuggestions();
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
@@ -628,6 +616,56 @@ export class SmartPlaylistEditor extends LitElement {
this.onRulesChanged();
}
/** Ask for the values of row `index`'s field that contain `text`. */
private requestSuggestions(index: number, text: string) {
const field = this.ruleRows[index]?.field ?? '';
if (!SUGGEST_FIELDS.has(field)) return;
const key = `${field}\u0000${text}`;
this.suggestWanted.set(index, key);
const cached = this.suggestCache.get(key);
if (cached) {
this.setSuggestions(index, cached);
return;
}
if (this.suggestTimer !== null) clearTimeout(this.suggestTimer);
this.suggestTimer = setTimeout(() => {
this.suggestTimer = null;
void list(SuggestSmartPlaylistValues(field, text))
.then((values) => {
this.suggestCache.set(key, values);
if (this.suggestWanted.get(index) === key) {
this.setSuggestions(index, values);
}
})
.catch((err: unknown) => {
// A suggestion is a hint; the box still takes any text.
console.error('smart playlist: suggestions failed', err);
});
}, SUGGEST_DEBOUNCE_MS);
}
private setSuggestions(index: number, values: readonly string[]) {
const next = new Map(this.suggestions);
next.set(index, values);
this.suggestions = next;
}
/** Row indexes and fields changed: what was suggested no longer applies. */
private dropSuggestions() {
this.suggestions = new Map();
this.suggestWanted.clear();
}
private updateValue2(index: number, newValue: string) {
const row = this.ruleRows[index];
if (!row) return;
@@ -644,6 +682,7 @@ export class SmartPlaylistEditor extends LitElement {
private removeRule(index: number) {
if (this.ruleRows.length <= 1) return;
this.ruleRows = this.ruleRows.filter((_, i) => i !== index);
this.dropSuggestions();
this.onRulesChanged();
}
@@ -892,7 +931,9 @@ export class SmartPlaylistEditor extends LitElement {
`
: html`
<yj-combobox
.options=${getAutocompleteOptions(row.field)}
.options=${this.suggestions.get(index) ?? []}
@combobox-input=${(e: CustomEvent<{ text: string }>) =>
this.requestSuggestions(index, e.detail.text)}
.value=${row.value}
placeholder=${isAnyOf
? 'Comma-separated values'