Files
yellowjacket/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts
T
yonluandClaude Opus 5 162c68769f feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place —
a real TypeScript module tree nested by Go import path, generated by
wails3's static analyser rather than by building the app and running
it.  The @go alias absorbs the constant prefix, so a call site imports
'@go/library/library.js' and the codemod over all 93 sites was a
specifier rewrite plus splitting @go/models' namespaces into one
import per package.

The 12 SetContext bindings and the fake `context` model are gone, as
Phase 2's ServiceStartup port promised: 272 methods across 12
services, none of them plumbing.

@runtime/runtime is now a local shim (src/wails/runtime.ts) over
@wailsio/runtime, so the 22 EventsOn imports are untouched.  It
unwraps v3's WailsEvent into v2's callback shape, which is exact here:
nothing in backend/events passes more than one data argument, and v3
only packs arguments into a slice when there is more than one.

v3 tells the truth about two things v2 lied about, and that is most of
the diff.  A Go nil slice really does arrive as JSON null, and a Go
named string type really is an enum; v2 typed them as T[] and string.
utils/binding.ts states the app's actual contract — an absent list is
an empty list — once, at the boundary where it is true, and also drops
the CancellablePromise the app never cancels.  Four test fixtures
widen an enum field back to its value union.

Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts
still fakes window.go, which v3 does not have, so `make ui-test` is
broken and harness.test.ts fails to compile on EventsEmit.  That test
also asserts v2 ordering that no longer holds — v3's Events.Emit calls
the backend and does not notify in-page listeners at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 17:48:38 -04:00

971 lines
32 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 } from '@go/playlist/service.js';
import { libraryStore } from '@store/library-store';
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, ' ');
}
/** 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 [];
}
}
/**
* 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()];
@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;
// ── 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;
}
.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()];
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,
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.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();
}
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.onRulesChanged();
}
private updateLimit(value: string) {
this.limit = Math.max(0, parseInt(value, 10) || 0);
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.ruleRows.map((row, index) =>
this.renderRuleRow(row, index),
)}
<button class="add-rule-btn" @click=${this.addRule}>
+ Add Rule
</button>
</div>
${this.renderSortOptions()} ${this.renderPreview()}
`;
}
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=${getAutocompleteOptions(row.field)}
.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;
}
}