Files
yellowjacket/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts
T
yonlu 3cddf70c4d 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
2026-10-05 22:59:24 -04:00

1080 lines
36 KiB
TypeScript

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,
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';
// ── Field / Operator constants ──────────────────────────────────────
/** All fields matching the backend `fieldMap` keys. */
const FIELDS: string[] = [
'title',
'artist',
'album',
'genre',
'year',
'release_year',
'composer',
'file_type',
'duration',
'sample_rate',
'bit_depth',
'channels',
'bitrate',
'file_size',
'library',
'track_number',
'disc_number',
'play_count',
'days_since_played',
];
const NUMERIC_FIELDS = new Set([
'year',
'release_year',
'duration',
'sample_rate',
'bit_depth',
'channels',
'bitrate',
'file_size',
'library',
'track_number',
'disc_number',
'play_count',
'days_since_played',
]);
const TEXT_OPERATORS = [
'is',
'is_not',
'contains',
'does_not_contain',
'starts_with',
'ends_with',
'is_any_of',
];
const NUMERIC_OPERATORS = [
'is',
'is_not',
'greater_than',
'less_than',
'between',
];
const SORT_FIELDS = [
'title',
'artist',
'album',
'year',
'release_year',
'duration',
'play_count',
'random',
];
// ── Helpers ─────────────────────────────────────────────────────────
function getOperatorsForField(field: string): string[] {
return NUMERIC_FIELDS.has(field) ? NUMERIC_OPERATORS : TEXT_OPERATORS;
}
/**
* Human-readable labels for operator values.
* `is_not` → "is not", `does_not_contain` → "does not contain", etc.
*/
function formatOperatorLabel(op: string): string {
return op.replace(/_/g, ' ');
}
/**
* 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
* two year fields in particular need to disambiguate the album's
* original release from the specific (possibly reissue) release owned.
*/
const FIELD_LABEL_OVERRIDES: Record<string, string> = {
year: 'Year (Original Release)',
release_year: 'Year (This Release)',
};
/** Format a field name for display: `file_type` → "File Type". */
function formatFieldLabel(field: string): string {
const override = FIELD_LABEL_OVERRIDES[field];
if (override) return override;
return field
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// ── Rule row type ───────────────────────────────────────────────────
interface RuleRow {
field: string;
operator: string;
value: string;
/** Second value for `between` operator (max). */
value2: string;
}
function emptyRule(): RuleRow {
return { field: '', operator: '', value: '', value2: '' };
}
// ── Component ───────────────────────────────────────────────────────
/**
* `<smart-playlist-editor>` — Row-based rule builder with live preview.
*
* Accepts an initial `rules` JSON attribute (matching the backend RuleSet
* schema) and emits `rules-changed` CustomEvent whenever the user edits
* any row, limit, or sort control. A live preview panel calls
* `PreviewSmartPlaylist` with 300ms debounce and displays matching tracks.
*/
@customElement('smart-playlist-editor')
export class SmartPlaylistEditor extends LitElement {
// ── Public property ─────────────────────────────────────────────
/** Initial rules JSON (attribute). Parsed in connectedCallback. */
@property({ type: String })
rules = '';
// ── Internal state ──────────────────────────────────────────────
@state() private ruleRows: RuleRow[] = [emptyRule()];
/** Whether every rule must hold or any one of them. Mirrors the
* backend's `match`; 'all' is the default and the only thing a
* playlist saved before this existed can have meant. */
@state() private matchType: 'all' | 'any' = 'all';
@state() private limit = 0;
@state() private sortField = 'random';
@state() private sortDir = '';
@state() private previewTracks: library.Track[] = [];
@state() private previewLoading = false;
@state() private previewError = '';
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 = [
designTokens,
css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
/* ── Rule rows ────────────────────────── */
.rule-rows {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0 8px;
flex-shrink: 0;
}
.match-row {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
flex-wrap: wrap;
}
.match-select {
min-width: 72px;
}
.rule-row {
display: grid;
grid-template-columns: 160px 140px 1fr 28px;
gap: 6px;
align-items: stretch;
}
.rule-row.between-row {
grid-template-columns: 160px 140px 1fr 1fr 28px;
}
/* ── Form controls ────────────────────── */
select,
input[type='number'] {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
border: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 4px;
padding: 4px 8px;
font-size: var(--yj-text-md);
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
select:focus,
input[type='number']:focus {
outline: none;
border-color: var(--yj-accent, #ffd43b);
}
input[type='number'] {
-moz-appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ── Remove button ────────────────────── */
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
align-self: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--yj-text-secondary, #b3b3b3);
cursor: pointer;
font-size: 14px;
padding: 0;
transition: color 0.15s ease, background-color 0.15s ease;
}
.remove-btn:hover {
color: var(--yj-error-text, #ff8787);
background: rgba(255, 107, 107, 0.1);
}
.remove-btn.hidden {
visibility: hidden;
}
/* ── Add rule button ──────────────────── */
.add-rule-btn {
background: none;
border: 1px dashed
var(--yj-border-subtle, rgba(255, 255, 255, 0.15));
border-radius: 4px;
color: var(--yj-text-secondary, #b3b3b3);
padding: 4px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
align-self: flex-start;
}
.add-rule-btn:hover {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
/* ── Options row (limit, sort) ────────── */
.options-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0 4px;
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
margin-top: 4px;
flex-wrap: wrap;
flex-shrink: 0;
}
.option-group {
display: flex;
align-items: center;
gap: 6px;
}
.option-label {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
}
.limit-input {
width: 64px;
}
.sort-select {
min-width: 90px;
}
.sort-dir-btn {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
border: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 3px 8px;
font-size: var(--yj-text-sm);
font-family: inherit;
cursor: pointer;
min-width: 40px;
text-align: center;
transition: border-color 0.15s ease;
}
.sort-dir-btn:hover {
border-color: var(--yj-accent, #ffd43b);
}
/* ── Preview section ──────────────────── */
.preview-section {
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
margin-top: 8px;
padding-top: 10px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.preview-title {
font-size: var(--yj-text-sm);
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-count {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.preview-loading {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
padding: 8px 0;
}
.preview-error {
font-size: var(--yj-text-sm);
color: var(--yj-error-text, #ff8787);
padding: 6px 0;
}
.preview-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.preview-track {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
padding: 3px 0;
font-size: var(--yj-text-sm);
color: var(--yj-text-primary, #fff);
border-bottom: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.03));
}
.preview-track:last-child {
border-bottom: none;
}
.preview-track span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-track .artist,
.preview-track .album {
color: var(--yj-text-secondary, #b3b3b3);
}
.preview-empty {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #666);
padding: 8px 0;
}
`,
];
// ── Lifecycle ───────────────────────────────────────────────────
override connectedCallback() {
super.connectedCallback();
this.parseInitialRules();
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.previewTimer !== null) {
clearTimeout(this.previewTimer);
this.previewTimer = null;
}
}
// ── Parse initial rules ─────────────────────────────────────────
private parseInitialRules() {
if (!this.rules) {
this.ruleRows = [emptyRule()];
return;
}
try {
const parsed = JSON.parse(this.rules);
const rows: RuleRow[] = (parsed.rules ?? []).map(
(r: { field?: string; operator?: string; value?: string }) => {
const field = r.field ?? '';
const operator = r.operator ?? '';
let value = r.value ?? '';
let value2 = '';
// Deserialize `is_any_of` JSON array back to comma string
if (operator === 'is_any_of' && value.startsWith('[')) {
try {
const arr = JSON.parse(value) as string[];
value = arr.join(', ');
} catch {
// keep raw value
}
}
// Deserialize `between` "min,max" into two fields
if (operator === 'between' && value.includes(',')) {
const parts = value.split(',');
value = parts[0]?.trim() ?? '';
value2 = parts[1]?.trim() ?? '';
}
return { field, operator, value, value2 };
},
);
this.ruleRows = rows.length > 0 ? rows : [emptyRule()];
// A playlist saved before this field existed has no match
// and means "all" — the backend reads an empty match the
// same way, so an upgrade cannot widen anyone's playlist.
this.matchType = parsed.match === 'any' ? 'any' : 'all';
this.limit = parsed.limit ?? 0;
this.sortField = parsed.sort_field || 'random';
this.sortDir = parsed.sort_dir ?? '';
} catch {
this.ruleRows = [emptyRule()];
}
// Trigger initial preview if rules are complete.
this.schedulePreview();
}
// ── Build JSON from current state ───────────────────────────────
private buildRulesJSON(): string {
const rules = this.ruleRows.map((row) => {
let value = row.value;
// Serialize is_any_of comma-separated values to JSON array
if (row.operator === 'is_any_of' && value) {
const parts = value
.split(',')
.map((v) => v.trim())
.filter(Boolean);
value = JSON.stringify(parts);
}
// Serialize between as "min,max"
if (row.operator === 'between') {
value = `${row.value},${row.value2}`;
}
return {
field: row.field,
operator: row.operator,
value,
};
});
return JSON.stringify({
rules,
match: this.matchType,
limit: this.limit || 0,
sort_field: this.sortField || '',
sort_dir: this.sortDir || '',
});
}
// ── Rule mutation methods ───────────────────────────────────────
private updateField(index: number, newField: string) {
const row = this.ruleRows[index];
if (!row) return;
const wasNumeric = NUMERIC_FIELDS.has(row.field);
const isNumeric = NUMERIC_FIELDS.has(newField);
row.field = newField;
// Reset operator when field type changes (text↔numeric)
if (wasNumeric !== isNumeric || !row.operator) {
const ops = getOperatorsForField(newField);
row.operator = ops[0] ?? '';
}
// Reset value when field changes to avoid stale autocomplete data
row.value = '';
row.value2 = '';
this.dropSuggestions();
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private updateOperator(index: number, newOp: string) {
const row = this.ruleRows[index];
if (!row) return;
row.operator = newOp;
// Clear value2 if no longer between
if (newOp !== 'between') {
row.value2 = '';
}
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private updateValue(index: number, newValue: string) {
const row = this.ruleRows[index];
if (!row) return;
row.value = newValue;
this.ruleRows = [...this.ruleRows];
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;
row.value2 = newValue;
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private addRule() {
this.ruleRows = [...this.ruleRows, emptyRule()];
}
private removeRule(index: number) {
if (this.ruleRows.length <= 1) return;
this.ruleRows = this.ruleRows.filter((_, i) => i !== index);
this.dropSuggestions();
this.onRulesChanged();
}
private updateLimit(value: string) {
this.limit = Math.max(0, parseInt(value, 10) || 0);
this.onRulesChanged();
}
private updateMatchType(value: string) {
this.matchType = value === 'any' ? 'any' : 'all';
this.onRulesChanged();
}
private updateSortField(value: string) {
this.sortField = value;
if (!value) this.sortDir = '';
this.onRulesChanged();
}
private toggleSortDir() {
if (!this.sortDir) {
this.sortDir = 'ASC';
} else if (this.sortDir === 'ASC') {
this.sortDir = 'DESC';
} else {
this.sortDir = '';
}
this.onRulesChanged();
}
// ── Change notification ─────────────────────────────────────────
private onRulesChanged() {
const json = this.buildRulesJSON();
this.dispatchEvent(
new CustomEvent('rules-changed', {
bubbles: true,
composed: true,
detail: { json },
}),
);
this.schedulePreview();
}
// ── Live preview ────────────────────────────────────────────────
private schedulePreview() {
if (this.previewTimer !== null) {
clearTimeout(this.previewTimer);
}
this.previewTimer = setTimeout(() => {
this.previewTimer = null;
void this.runPreview();
}, 300);
}
/**
* Monotonic request version. Debouncing only coalesces keystrokes
* *within* its window; a query slower than 300 ms overlaps the next
* one and whichever answers last used to win (errors.M8). This is
* `explore-view`'s guard, checked on all three paths.
*/
private previewVersion = 0;
private async runPreview() {
// Skip preview if any rule is incomplete
const incomplete = this.ruleRows.some(
(r) =>
!r.field ||
!r.value ||
(r.operator === 'between' && !r.value2),
);
if (incomplete) {
this.previewTracks = [];
this.previewError = '';
return;
}
const json = this.buildRulesJSON();
const version = ++this.previewVersion;
this.previewLoading = true;
this.previewError = '';
try {
const tracks = await PreviewSmartPlaylist(json);
if (version !== this.previewVersion) return;
this.previewTracks = tracks ?? [];
} catch (error) {
if (version !== this.previewVersion) return;
console.error('Smart playlist preview failed:', error);
this.previewError = describeError(
error,
'The preview could not be built for these rules.',
);
this.previewTracks = [];
} finally {
if (version === this.previewVersion) {
this.previewLoading = false;
}
}
}
// ── Render ──────────────────────────────────────────────────────
override render() {
return html`
<div class="rule-rows">
${this.renderMatchType()}
${this.ruleRows.map((row, index) =>
this.renderRuleRow(row, index),
)}
<button class="add-rule-btn" @click=${this.addRule}>
+ Add Rule
</button>
</div>
${this.renderSortOptions()} ${this.renderPreview()}
`;
}
/**
* Whether every rule has to hold, or any one of them.
*
* It is a sentence with a control in the middle rather than a
* labelled field, because the two readings differ by one word and
* that word is the whole of the setting — "Match **all** of the
* following rules" says what the list below it means in a way a
* select labelled "Match" beside a list does not.
*
* Hidden while there is one rule: with nothing to combine, all and
* any are the same query, and a control whose two settings cannot
* differ is a question the user has no way to answer wrongly and
* no reason to answer at all.
*/
private renderMatchType() {
if (this.ruleRows.length < 2) return nothing;
return html`
<div class="match-row">
<span>Match</span>
<select
class="match-select"
aria-label="Match all or any of the following rules"
@change=${(e: Event) =>
this.updateMatchType(
(e.target as HTMLSelectElement).value,
)}
>
<option value="all" ?selected=${this.matchType === 'all'}>
all
</option>
<option value="any" ?selected=${this.matchType === 'any'}>
any
</option>
</select>
<span>of the following rules</span>
</div>
`;
}
private renderRuleRow(row: RuleRow, index: number) {
const isBetween = row.operator === 'between';
const operators = row.field ? getOperatorsForField(row.field) : [];
const isNumeric = NUMERIC_FIELDS.has(row.field);
const isAnyOf = row.operator === 'is_any_of';
return html`
<div class="rule-row ${isBetween ? 'between-row' : ''}">
<!-- Field combobox -->
<yj-combobox
.options=${FIELDS}
.value=${row.field}
placeholder="Select field…"
@combobox-change=${(e: CustomEvent) =>
this.updateField(index, e.detail.value)}
></yj-combobox>
<!-- Operator select -->
<select
@change=${(e: Event) =>
this.updateOperator(
index,
(e.target as HTMLSelectElement).value,
)}
?disabled=${!row.field}
>
${!row.field
? html`<option value="">—</option>`
: nothing}
${operators.map(
(op) => html`
<option
value=${op}
?selected=${op === row.operator}
>
${formatOperatorLabel(op)}
</option>
`,
)}
</select>
<!-- Value input -->
${isNumeric && !isBetween
? html`
<input
type="number"
.value=${row.value}
placeholder="Value"
?disabled=${!row.field}
@input=${(e: Event) =>
this.updateValue(
index,
(e.target as HTMLInputElement).value,
)}
/>
`
: isBetween
? html`
<input
type="number"
.value=${row.value}
placeholder="Min"
@input=${(e: Event) =>
this.updateValue(
index,
(e.target as HTMLInputElement).value,
)}
/>
<input
type="number"
.value=${row.value2}
placeholder="Max"
@input=${(e: Event) =>
this.updateValue2(
index,
(e.target as HTMLInputElement).value,
)}
/>
`
: html`
<yj-combobox
.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'
: 'Value'}
?disabled=${!row.field}
@combobox-change=${(e: CustomEvent) =>
this.updateValue(
index,
e.detail.value,
)}
></yj-combobox>
`}
<!-- Remove button -->
<button
class="remove-btn ${this.ruleRows.length <= 1 ? 'hidden' : ''}"
@click=${() => this.removeRule(index)}
title="Remove rule"
aria-label="Remove rule"
>
✕
</button>
</div>
`;
}
private renderSortOptions() {
const sortDirLabel = !this.sortDir
? '—'
: this.sortDir === 'ASC'
? '↑'
: '↓';
return html`
<div class="options-row">
<div class="option-group">
<span class="option-label">Limit</span>
<input
type="number"
class="limit-input"
min="0"
.value=${String(this.limit || '')}
placeholder="∞"
@input=${(e: Event) =>
this.updateLimit(
(e.target as HTMLInputElement).value,
)}
/>
</div>
<div class="option-group">
<span class="option-label">Sort by</span>
<select
class="sort-select"
@change=${(e: Event) =>
this.updateSortField(
(e.target as HTMLSelectElement).value,
)}
>
${SORT_FIELDS.map(
(f) => html`
<option
value=${f}
?selected=${f === this.sortField}
>
${formatFieldLabel(f)}
</option>
`,
)}
</select>
${this.sortField && this.sortField !== 'random'
? html`
<button
class="sort-dir-btn"
@click=${this.toggleSortDir}
title=${this.sortDir === 'ASC'
? 'Ascending'
: this.sortDir === 'DESC'
? 'Descending'
: 'No direction'}
>
${sortDirLabel}
</button>
`
: nothing}
</div>
</div>
`;
}
private renderPreview() {
return html`
<div class="preview-section">
<div class="preview-header">
<span class="preview-title">Preview</span>
${this.previewTracks.length > 0 && !this.previewLoading
? html`<span class="preview-count"
>${this.previewTracks.length} tracks</span
>`
: nothing}
</div>
${this.previewLoading
? html`<div class="preview-loading">
Evaluating rules…
</div>`
: this.previewError
? html`<div class="preview-error">
${this.previewError}
</div>`
: this.previewTracks.length > 0
? html`
<div class="preview-list">
${this.previewTracks.map(
(t) => html`
<div class="preview-track">
<span class="title"
>${t.TrackName}</span
>
<span class="artist"
>${t.ArtistName}</span
>
<span class="album"
>${t.Album}</span
>
</div>
`,
)}
</div>
`
: html`<div class="preview-empty">
Complete all rule fields to see a
preview.
</div>`}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'smart-playlist-editor': SmartPlaylistEditor;
}
}