feat(M002): smart playlists — rule engine, editor UI, sidebar integration

Recovered from orphaned worktree commits (complete-milestone failed to merge).

Backend:
- Migration 9: is_smart + smart_rules columns on playlists table
- smartplaylist package: parameterized WHERE clause builder, field whitelist, genre subquery
- playlist.Service: Create/Update/Evaluate/Preview/GetRules smart playlist methods
- 65 tests (49 rule engine + 15 service + 1 migration)

Frontend:
- yj-combobox: reusable typeable dropdown with keyboard nav, ARIA, blur-race fix
- smart-playlist-editor: row-based rule builder with live preview
- smart-playlist-details: evaluate, refresh, play, shuffle, edit rules
- Sidebar: filter icon, Smart badge, create button, routing
- Queue snapshot on play/shuffle
This commit is contained in:
2026-03-21 12:53:00 -04:00
parent e974bd2a22
commit 477b7ff6a2
17 changed files with 5342 additions and 26 deletions
@@ -0,0 +1,303 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
/**
* `<yj-combobox>` — Typeable dropdown with autocomplete filtering and
* keyboard navigation. Accepts a flat `options` string array, filters as
* the user types, and emits `combobox-change` when a value is selected.
*
* 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.
*/
@customElement('yj-combobox')
export class YjCombobox extends LitElement {
// ── Public reactive properties ──────────────────────────────────
/** Full list of selectable options. */
@property({ type: Array })
options: string[] = [];
/** Currently selected value (reflects to attribute for CSS hooks). */
@property({ type: String, reflect: true })
value = '';
/** Placeholder text shown when the input is empty. */
@property({ type: String })
placeholder = '';
/** Disables input and dropdown interaction. */
@property({ type: Boolean })
disabled = false;
// ── Internal state ──────────────────────────────────────────────
/** Text currently in the input — drives filtering. */
@state()
private filterText = '';
/** Whether the dropdown is visible. */
@state()
private open = false;
/** Index into `filteredOptions` for keyboard highlight (-1 = none). */
@state()
private highlightedIndex = -1;
// ── Computed ────────────────────────────────────────────────────
/** Options that match the current filterText (case-insensitive substring). */
private get filteredOptions(): string[] {
const opts = this.options ?? [];
if (!this.filterText) return opts;
const needle = this.filterText.toLowerCase();
return opts.filter((o) => o.toLowerCase().includes(needle));
}
// ── Styles ──────────────────────────────────────────────────────
static override styles = [
designTokens,
css`
:host {
display: inline-block;
width: 100%;
}
.combobox-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
input {
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;
}
input:focus {
outline: none;
border-color: var(--yj-accent, #ffd43b);
}
input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
max-height: 200px;
overflow-y: auto;
background: var(--yj-bg-surface, #282828);
border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-top: none;
border-radius: 0 0 4px 4px;
margin: 0;
padding: 0;
list-style: none;
}
.dropdown li {
padding: 4px 8px;
cursor: pointer;
color: var(--yj-text-primary, #fff);
font-size: var(--yj-text-md);
}
.dropdown li:hover,
.dropdown li.highlighted {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
`,
];
// ── Lifecycle ───────────────────────────────────────────────────
override connectedCallback() {
super.connectedCallback();
// Initialise filterText from the external value so an existing
// selection is visible immediately.
this.filterText = this.value;
}
override updated(changed: Map<string, unknown>) {
super.updated(changed);
// Sync filterText when the parent sets `value` programmatically
// (e.g. when pre-populating the editor with saved rules).
if (changed.has('value') && !this.open) {
this.filterText = this.value;
}
// Scroll the highlighted option into view.
if (changed.has('highlightedIndex') && this.highlightedIndex >= 0) {
const items = this.shadowRoot?.querySelectorAll('.dropdown li');
items?.[this.highlightedIndex]?.scrollIntoView({
block: 'nearest',
});
}
}
// ── Event handlers ──────────────────────────────────────────────
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.filterText = input.value;
this.open = true;
this.highlightedIndex = -1;
}
private handleFocus() {
// Clear filter so the full option list is visible on focus.
this.filterText = '';
this.open = true;
this.highlightedIndex = -1;
}
private handleBlur() {
// Use rAF as a safety net — mousedown on an option calls
// preventDefault() which should keep focus, but some browsers are
// inconsistent. The tiny delay lets any pending mousedown handler
// fire first.
requestAnimationFrame(() => {
this.open = false;
// Restore display text to the confirmed value.
this.filterText = this.value;
});
}
private handleKeydown(e: KeyboardEvent) {
const opts = this.filteredOptions;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!this.open) {
this.open = true;
this.highlightedIndex = 0;
} else if (opts.length > 0) {
this.highlightedIndex =
(this.highlightedIndex + 1) % opts.length;
}
break;
case 'ArrowUp':
e.preventDefault();
if (opts.length > 0 && this.open) {
this.highlightedIndex =
(this.highlightedIndex - 1 + opts.length) %
opts.length;
}
break;
case 'Enter':
if (
this.open &&
this.highlightedIndex >= 0 &&
this.highlightedIndex < opts.length
) {
e.preventDefault();
this.selectOption(opts[this.highlightedIndex]!);
}
break;
case 'Escape':
e.preventDefault();
this.open = false;
this.filterText = this.value;
break;
case 'Tab':
// Close dropdown but let default Tab navigation proceed.
this.open = false;
this.filterText = this.value;
break;
default:
break;
}
}
// ── Selection ───────────────────────────────────────────────────
private selectOption(opt: string) {
this.value = opt;
this.filterText = opt;
this.open = false;
this.highlightedIndex = -1;
this.dispatchEvent(
new CustomEvent('combobox-change', {
bubbles: true,
composed: true,
detail: { value: opt },
}),
);
}
// ── Render ──────────────────────────────────────────────────────
override render() {
const opts = this.filteredOptions;
return html`
<div class="combobox-wrapper">
<input
.value=${this.filterText}
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
@keydown=${this.handleKeydown}
?disabled=${this.disabled}
placeholder=${this.placeholder}
autocomplete="off"
role="combobox"
aria-expanded=${this.open}
aria-autocomplete="list"
/>
${this.open && opts.length > 0
? html`
<ul class="dropdown" role="listbox">
${opts.map(
(opt, i) => html`
<li
role="option"
aria-selected=${i === this.highlightedIndex}
class=${i === this.highlightedIndex
? 'highlighted'
: ''}
@mousedown=${(e: Event) => {
e.preventDefault();
this.selectOption(opt);
}}
>
${opt}
</li>
`,
)}
</ul>
`
: nothing}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'yj-combobox': YjCombobox;
}
}
@@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import {
CreatePlaylist,
CreatePlaylistWithTracks,
CreateSmartPlaylist,
AddTracksToPlaylist,
DeletePlaylist,
RenamePlaylist,
@@ -86,6 +87,7 @@ export class PlaylistView extends LitElement {
@state() private loading = true;
@state() private refreshing = false;
@state() private creating = false;
@state() private creatingSmart = false;
@state() private newPlaylistName = '';
@state() private playlistContextMenuOpen = false;
@state() private playlistContextMenuIndex = -1;
@@ -1003,7 +1005,9 @@ export class PlaylistView extends LitElement {
bubbles: true,
composed: true,
detail: {
view: 'playlist-details',
view: entry.summary.IsSmart
? 'smart-playlist-details'
: 'playlist-details',
playlistId: entry.summary.ID,
playlistName: entry.summary.Name,
},
@@ -1021,10 +1025,14 @@ export class PlaylistView extends LitElement {
) => {
if (!hasTrackPayload(e)) return;
// Don't allow dropping tracks back onto
// the same playlist.
// Don't allow dropping tracks onto smart
// playlists — they have no playlist_tracks rows.
const entry = this.entries[index];
if (entry?.summary.IsSmart) return;
// Don't allow dropping tracks back onto
// the same playlist.
if (
entry &&
getActiveDragSource() === 'playlist' &&
@@ -1473,6 +1481,22 @@ export class PlaylistView extends LitElement {
private handleNewPlaylistClick = () => {
this.creating = true;
this.creatingSmart = false;
this.newPlaylistName = '';
void this.updateComplete.then(() => {
const input =
this.shadowRoot?.querySelector<HTMLInputElement>(
'.create-form input',
);
input?.focus();
});
};
private handleNewSmartPlaylistClick = () => {
this.creatingSmart = true;
this.creating = false;
this.newPlaylistName = '';
void this.updateComplete.then(() => {
@@ -1487,6 +1511,7 @@ export class PlaylistView extends LitElement {
private handleCancelCreate = () => {
this.creating = false;
this.creatingSmart = false;
this.newPlaylistName = '';
this.pendingDropPaths = [];
};
@@ -1495,6 +1520,36 @@ export class PlaylistView extends LitElement {
const name = this.newPlaylistName.trim();
if (!name) return;
if (this.creatingSmart) {
try {
const summary = await CreateSmartPlaylist(
name,
'{"rules":[],"limit":0,"sort_field":"","sort_dir":""}',
);
this.creatingSmart = false;
this.newPlaylistName = '';
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'smart-playlist-details',
playlistId: summary.ID,
playlistName: summary.Name,
autoEdit: true,
},
}),
);
} catch (err) {
console.error(
'Failed to create smart playlist:',
err,
);
}
return;
}
const paths = this.pendingDropPaths;
try {
@@ -1656,6 +1711,16 @@ export class PlaylistView extends LitElement {
></wa-icon>
New Playlist
</button>
<button
class="new-playlist-button"
@click=${this
.handleNewSmartPlaylistClick}
>
<wa-icon
name="filter"
></wa-icon>
New Smart Playlist
</button>
</div>
</div>
@@ -1667,7 +1732,7 @@ export class PlaylistView extends LitElement {
${this.renderSortToolbar()}
${this.creating
${this.creating || this.creatingSmart
? this.renderCreateForm()
: nothing}
${this.loading &&
@@ -1704,18 +1769,22 @@ export class PlaylistView extends LitElement {
></wa-icon>
Rename
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
void this.onPlaylistContextAction(
'set-default',
)}
>
<wa-icon
slot="icon"
name="star"
></wa-icon>
Set as Default Playlist
</wa-dropdown-item>
${this.entries[this.playlistContextMenuIndex]?.summary.IsSmart
? nothing
: html`
<wa-dropdown-item
@click=${() =>
void this.onPlaylistContextAction(
'set-default',
)}
>
<wa-icon
slot="icon"
name="star"
></wa-icon>
Set as Default Playlist
</wa-dropdown-item>
`}
`
: nothing}
<wa-dropdown-item
@@ -1747,12 +1816,15 @@ export class PlaylistView extends LitElement {
private renderCreateForm() {
const canCreate =
this.newPlaylistName.trim().length > 0;
const placeholder = this.creatingSmart
? 'Smart playlist name'
: 'Playlist name';
return html`
<div class="create-form">
<input
type="text"
placeholder="Playlist name"
placeholder=${placeholder}
.value=${this.newPlaylistName}
@input=${this.handleInputChange}
@keydown=${this.handleInputKeydown}
@@ -1857,7 +1929,9 @@ export class PlaylistView extends LitElement {
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const countLabel = entry.summary.IsSmart
? 'Smart'
: `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
@@ -1891,7 +1965,12 @@ export class PlaylistView extends LitElement {
class="playlist-icon"
name=${this.favCtrl.iconName}
></wa-icon>`
: nothing}
: entry.summary.IsSmart
? html`<wa-icon
class="playlist-icon"
name="filter"
></wa-icon>`
: nothing}
${isRenaming
? html`
<input
@@ -0,0 +1,564 @@
import { LitElement, html, css, nothing } from 'lit';
import {
customElement,
property,
state,
} from 'lit/decorators.js';
import { library } from '@go/models';
import {
EvaluateSmartPlaylist,
GetSmartPlaylistRules,
UpdateSmartPlaylistRules,
} from '@go/playlist/Service';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { queueStore } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
/**
* Format total milliseconds as a human-readable duration.
* e.g. 8_100_000 → "2h 15m", 180_000 → "3m 0s", 45_000 → "0m 45s"
*/
function formatTotalDuration(totalMs: number): string {
if (totalMs <= 0) return '0m';
const totalSeconds = Math.floor(totalMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
@customElement('smart-playlist-details')
export class SmartPlaylistDetails extends LitElement {
@property({ type: Number, attribute: 'playlist-id' })
playlistId = 0;
@property({ type: String, attribute: 'playlist-name' })
playlistName = '';
@property({ type: Boolean, attribute: 'auto-edit' })
autoEdit = false;
@state()
private tracks: library.Track[] = [];
@state()
private loading = true;
@state()
private editing = false;
@state()
private currentRulesJSON = '';
@state()
private pendingRulesJSON = '';
@state()
private saving = false;
private playlistDeletedCleanup: (() => void) | null = null;
private playlistRenamedCleanup: (() => void) | null = null;
// =================================================================
// Styles
// =================================================================
static override styles = [designTokens, css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
/* ====================================
* Header
* ==================================== */
.smart-playlist-header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
flex-shrink: 0;
border-bottom: 1px solid
var(
--yj-border-subtle,
rgba(255, 255, 255, 0.06)
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
.playlist-avatar {
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.playlist-avatar wa-icon {
font-size: 32px;
color: var(
--yj-text-secondary,
#b3b3b3
);
}
.playlist-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.playlist-title {
font-size: 24px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.2;
}
.track-count {
font-size: var(--yj-text-md);
color: var(
--yj-text-secondary,
#b3b3b3
);
}
/* ====================================
* Actions
* ==================================== */
.playlist-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px 8px;
flex-shrink: 0;
}
.action-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
font-family: inherit;
transition: border-color 0.15s ease, color 0.15s ease;
}
.action-button:hover {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent, #ffd43b);
}
.action-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-button:disabled:hover {
border-color: var(--yj-border-subtle, #555);
color: var(--yj-text-primary, #fff);
}
.action-button wa-icon {
font-size: 12px;
}
/* ====================================
* Content
* ==================================== */
.content {
flex: 1;
overflow: hidden;
}
track-list {
width: 100%;
height: 100%;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
color: var(--yj-text-secondary, #b3b3b3);
}
.empty-state {
padding: 32px 20px;
color: var(--yj-text-tertiary, #666);
font-size: 13px;
text-align: center;
}
.editor-container {
flex: 1;
overflow: auto;
padding: 0 20px 20px;
}
`];
// =================================================================
// Lifecycle
// =================================================================
override async connectedCallback() {
super.connectedCallback();
await this.loadTracks();
if (this.autoEdit) {
this.autoEdit = false;
this.handleEditRules();
}
this.playlistDeletedCleanup = EventsOn(
Events.PlaylistDeleted,
(deletedId: number) => {
if (deletedId === this.playlistId) {
this.navigateBack();
}
},
);
this.playlistRenamedCleanup = EventsOn(
Events.PlaylistRenamed,
(summary: { ID: number; Name: string }) => {
if (summary.ID === this.playlistId) {
this.playlistName = summary.Name;
}
},
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.playlistDeletedCleanup) {
this.playlistDeletedCleanup();
this.playlistDeletedCleanup = null;
}
if (this.playlistRenamedCleanup) {
this.playlistRenamedCleanup();
this.playlistRenamedCleanup = null;
}
}
// =================================================================
// Data loading
// =================================================================
private async loadTracks() {
if (!this.playlistId) return;
this.loading = true;
try {
const result = await EvaluateSmartPlaylist(this.playlistId);
this.tracks = result ?? [];
} catch (error) {
console.error(
'Failed to evaluate smart playlist:',
error,
);
this.tracks = [];
} finally {
this.loading = false;
}
}
// =================================================================
// Navigation
// =================================================================
private navigateBack() {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'playlists' },
}),
);
}
// =================================================================
// Actions
// =================================================================
private handlePlay() {
const filePaths = this.tracks
.filter((t) => t.FilePath)
.map((t) => t.FilePath);
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, false);
}
private handleShuffle() {
const filePaths = this.tracks
.filter((t) => t.FilePath)
.map((t) => t.FilePath);
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, true);
}
private handleRefresh() {
void this.loadTracks();
}
private async handleEditRules() {
try {
const result = await GetSmartPlaylistRules(this.playlistId);
this.currentRulesJSON = result;
this.pendingRulesJSON = result;
this.editing = true;
} catch (error) {
console.error('Failed to load smart playlist rules:', error);
}
}
private async handleSaveRules() {
this.saving = true;
try {
await UpdateSmartPlaylistRules(
this.playlistId,
this.pendingRulesJSON,
);
this.editing = false;
this.loadTracks();
} catch (error) {
console.error('Failed to save smart playlist rules:', error);
} finally {
this.saving = false;
}
}
private handleCancelEdit() {
this.editing = false;
this.pendingRulesJSON = '';
}
private handleRulesChanged(e: CustomEvent) {
this.pendingRulesJSON = e.detail.json;
}
// =================================================================
// Helpers
// =================================================================
private getTotalDuration(): string {
const totalMs = this.tracks.reduce(
(sum, t) => sum + Number(t.TrackLength || 0),
0,
);
return formatTotalDuration(totalMs);
}
// =================================================================
// Render
// =================================================================
override render() {
const trackCount = this.tracks.length;
const trackLabel = trackCount === 1 ? 'track' : 'tracks';
const hasPlayableTracks = this.tracks.some((t) => t.FilePath);
return html`
<div class="smart-playlist-header">
<button
class="back-button"
@click=${this.navigateBack}
title="Back to playlists"
aria-label="Back to playlists"
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<div class="playlist-avatar">
<wa-icon name="filter"></wa-icon>
</div>
<div class="playlist-info">
<h1
class="playlist-title"
title="${this.playlistName}"
>
${this.playlistName}
</h1>
${!this.loading
? html`
<span class="track-count">
${trackCount}
${trackLabel}
· ${this.getTotalDuration()}
</span>
`
: nothing}
</div>
</div>
${this.loading
? html`<div class="loading">
Evaluating smart playlist…
</div>`
: html`
<div class="playlist-actions">
${this.editing
? html`
<button
class="action-button"
@click=${this.handleSaveRules}
?disabled=${this.saving}
title="Save rules"
>
<wa-icon name="floppy-disk"></wa-icon>
${this.saving ? 'Saving…' : 'Save Rules'}
</button>
<button
class="action-button"
@click=${this.handleCancelEdit}
?disabled=${this.saving}
title="Cancel editing"
>
<wa-icon name="xmark"></wa-icon>
Cancel
</button>
`
: html`
<button
class="action-button"
@click=${this.handlePlay}
?disabled=${!hasPlayableTracks}
title="Play all tracks"
>
<wa-icon name="play"></wa-icon>
Play
</button>
<button
class="action-button"
@click=${this.handleShuffle}
?disabled=${!hasPlayableTracks}
title="Shuffle all tracks"
>
<wa-icon name="shuffle"></wa-icon>
Shuffle
</button>
<button
class="action-button"
@click=${this.handleRefresh}
title="Re-evaluate smart playlist rules"
>
<wa-icon name="arrow-rotate-right"></wa-icon>
Refresh
</button>
<button
class="action-button"
@click=${this.handleEditRules}
title="Edit smart playlist rules"
>
<wa-icon name="pen-to-square"></wa-icon>
Edit Rules
</button>
`}
</div>
${this.editing
? html`
<div class="editor-container">
<smart-playlist-editor
.rules=${this.currentRulesJSON}
@rules-changed=${this.handleRulesChanged}
></smart-playlist-editor>
</div>
`
: trackCount > 0
? html`
<div class="content">
<track-list
.externalTracks=${this.tracks}
></track-list>
</div>
`
: html`
<div class="empty-state">
No tracks match the current rules.
Configure rules and click Refresh.
</div>
`}
`}
`;
}
}
@@ -0,0 +1,909 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { library } from '@go/models';
import { PreviewSmartPlaylist } from '@go/playlist/Service';
import { libraryStore } from '@store/library-store';
import { designTokens } from '../../styles/tokens.css';
import '@components/combobox/combobox.ts';
// ── Field / Operator constants ──────────────────────────────────────
/** All 16 fields matching the backend `fieldMap` keys. */
const FIELDS: string[] = [
'title',
'artist',
'album',
'genre',
'year',
'composer',
'file_type',
'duration',
'sample_rate',
'bit_depth',
'channels',
'bitrate',
'file_size',
'library',
'track_number',
'disc_number',
];
const NUMERIC_FIELDS = new Set([
'year',
'duration',
'sample_rate',
'bit_depth',
'channels',
'bitrate',
'file_size',
'library',
'track_number',
'disc_number',
]);
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', 'duration', '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': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [
...new Set(
tracks
.map((t) => t.Year)
.filter((y) => y > 0)
.map(String),
),
].sort();
}
default:
return [];
}
}
/** Format a field name for display: `file_type` → "File Type". */
function formatFieldLabel(field: string): string {
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 = '';
@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: block;
}
/* ── Rule rows ────────────────────────── */
.rule-rows {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0 8px;
}
.rule-row {
display: grid;
grid-template-columns: 1fr 140px 1fr 28px;
gap: 6px;
align-items: start;
}
.rule-row.between-row {
grid-template-columns: 1fr 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;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--yj-text-secondary, #b3b3b3);
cursor: pointer;
font-size: 14px;
padding: 0;
margin-top: 2px;
transition: color 0.15s ease, background-color 0.15s ease;
}
.remove-btn:hover {
color: #ff6b6b;
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, #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;
}
.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;
}
.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: #ff6b6b;
padding: 6px 0;
}
.preview-list {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.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 ?? '';
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);
}
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();
this.previewLoading = true;
this.previewError = '';
try {
const tracks = await PreviewSmartPlaylist(json);
this.previewTracks = tracks ?? [];
} catch (error) {
console.error('Smart playlist preview failed:', error);
this.previewError =
error instanceof Error ? error.message : String(error);
this.previewTracks = [];
} finally {
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,
)}
>
<option value="" ?selected=${!this.sortField}>
None
</option>
${SORT_FIELDS.map(
(f) => html`
<option
value=${f}
?selected=${f === this.sortField}
>
${formatFieldLabel(f)}
</option>
`,
)}
</select>
${this.sortField
? 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;
}
}