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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user