Files
yellowjacket/frontend/src/components/playlist-view/playlist-view.ts
T
logan f967916550
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m17s
fix(page-header): collapse the actions that do not fit into a menu
Playlists slotted three buttons totalling 390px into a header that gets
700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px
with the queue closed, and 158 of 162 at the 800x600 enforced minimum.
On a phone none of the three could be reached at all, which is what the
Android report said. Plan 018's size matrix promises the opposite: no
action is ever unreachable at any supported size.

The header could not fix that for slotted markup, and that is a fact
about the API rather than an effort estimate — a component cannot move
another component's light-DOM children into a dropdown and keep their
behaviour, and arbitrary markup offers nothing generic to render as a
menu item. So a host passes `PageAction[]` and the header chooses the
rendering; the slot survives for markup a data list cannot express, at
the stated cost that a slotted action does not collapse.

All three hosts that slot actions migrated, which also normalises the
plain-<button>/<wa-button> split between them onto one shape the header
styles — and lets it measure a button that has already upgraded, rather
than a wa-button whose shadow DOM arrives in its own first update.

Four things in it are load-bearing:

- Every measuring pass starts from all-visible, so the collapsed set is
  a pure function of the current width and an action comes back when
  the window grows. It flips `hidden` imperatively rather than
  re-rendering between steps, or the intermediate state paints and the
  fix flashes the overflow it exists to prevent.
- "Fits" means nothing is clipped, not that the header does not
  overflow. Once the title can ellipsis it absorbs the pressure and
  scrollWidth reports a perfect fit while the heading reads "Playlis…"
  — this bug moved from the button to the title, and invisible to the
  same measurement that missed it the first time.
- New Playlist has the highest priority because it is the drop target
  and a closed menu cannot be one. `PageAction.drop` therefore carries
  the host's own handlers; the affordance is absent from the overflow
  rather than approximated there.
- The overflow trigger is a named button with aria-expanded and an
  aria-controls naming a panel that is always in the DOM, and the
  keyboard model is the shared `MenuKeyboard`.

`layout-overflow.spec.ts` passes on the broken build — it asserts the
shell needs no sideways scrolling, and clipping inside a component is
invisible to it, which is why this defect survived a spec named for it.
The new spec measures each button against its own header at four
viewports and asserts buttons plus menu account for every declared
action, without which it would pass vacuously on a build rendering none.

Closes #69
2026-08-19 15:08:57 -04:00

1784 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import {
CreatePlaylist,
CreatePlaylistWithTracks,
CreateSmartPlaylist,
AddTracksToPlaylist,
DeletePlaylist,
RenamePlaylist,
ImportPlaylists,
FindDuplicateTracksInPlaylist,
} from '@go/playlist/service.js';
import { PlaylistFilePicker } from '@go/frontendutil/frontendutil.js';
import type * as playlist from '@go/playlist/models.js';
import { PlaylistController } from '@store/controllers/playlist-controller';
import { SearchController } from '@store/controllers/search-controller';
import {
hasTrackPayload,
getDragPayload,
getActiveDragSource,
getActiveDragPlaylistId,
} from '@utils/drag-controller';
import {
MenuKeyboard,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { FavoritesController } from '@store/controllers/favorites-controller';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import {
ICON_NEW,
ICON_PLAYLIST,
ICON_SMART_PLAYLIST,
} from '@utils/icon-language';
import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
const SCROLL_DEBOUNCE_MS = 100;
type PlaylistSortField = 'name' | 'created' | 'modified' | 'tracks';
type SortDirection = 'asc' | 'desc';
const PLAYLIST_SORT_KEY = 'playlist-view-sort-field';
const PLAYLIST_SORT_DIR_KEY = 'playlist-view-sort-direction';
const SORT_OPTIONS: { id: PlaylistSortField; label: string }[] = [
{ id: 'modified', label: 'Recent' },
{ id: 'name', label: 'Name' },
{ id: 'created', label: 'Date Created' },
{ id: 'tracks', label: 'Track Count' },
];
interface PlaylistEntry {
summary: playlist.Summary;
tracks: playlist.Track[];
}
@customElement('playlist-view')
export class PlaylistView extends ViewLifecycleMixin(LitElement) {
private playlistCtrl = new PlaylistController(this);
private searchCtrl = new SearchController(this);
private favCtrl = new FavoritesController(this);
/** Tracks the store's cached array reference to detect refreshes. */
private lastPlaylistsRef:
| playlist.WithTracks[]
| null = null;
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
private lastSearchTerm = '';
// =================================================================
// Filtered entries (search — playlist name only)
// =================================================================
private get filteredEntries(): PlaylistEntry[] {
const term =
this.searchCtrl.term.toLowerCase();
if (!term) return this.entries;
return this.entries.filter((e) =>
e.summary.Name.toLowerCase().includes(
term,
),
);
}
@state() private entries: PlaylistEntry[] = [];
@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;
@state() private renamingPlaylistIndex = -1;
@state() private renameValue = '';
/** Indices of playlists selected via Ctrl/Shift+Click. */
@state() private selectedPlaylists: Set<number> = new Set();
/** Anchor index for Shift+Click range selection on playlists. */
private lastSelectedPlaylistIndex: number | null = null;
/** Index of the playlist currently hovered during a drag. */
@state() private dragOverPlaylistIndex = -1;
/** True when dragging over empty space in the playlist list. */
@state() private dragOverEmptyZone = false;
/** True when dragging over the "New Playlist" button. */
@state() private dragOverNewButton = false;
/** Error message from the last failed import, auto-clears. */
@state() private importError = '';
/** Active sort field for playlists. */
@state() private sortField: PlaylistSortField = 'modified';
/** Sort direction. */
@state() private sortDirection: SortDirection = 'desc';
/**
* File paths from a drop that landed outside any playlist.
* When non-empty the create form is in "create-and-add" mode.
*/
private pendingDropPaths: string[] = [];
@query('#playlist-context-menu')
private playlistContextMenuPopup!: WaPopup;
@query('duplicate-tracks-dialog')
private duplicateDialog!: DuplicateTracksDialog;
/** This view renders its own context menu rather than using
* `ContextMenuController`, so it borrows just the keyboard model —
* which is the part that must not exist twice. */
private menuKeyboard = new MenuKeyboard(() =>
this.closePlaylistContextMenu(),
);
private closePlaylistCtxMenuHandler =
() => this.closePlaylistContextMenu();
private playlistCtxMenuMousedownHandler =
(e: MouseEvent) => {
const plPopup =
this.playlistContextMenuPopup;
if (
plPopup &&
e.composedPath().includes(plPopup)
) {
return;
}
this.closePlaylistContextMenu();
};
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isPlaylistHeaderClick = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('playlist-header') &&
this.shadowRoot?.contains(el),
);
if (!isPlaylistHeaderClick) {
this.selectedPlaylists = new Set();
this.lastSelectedPlaylistIndex = null;
}
};
static override styles = [
contextMenuStyles,
css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
position: relative;
contain: layout style;
}
.header-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--yj-border-subtle, #555);
border-top-color: var(--yj-text-primary, #fff);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.create-form {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--yj-border-subtle, #333);
flex-shrink: 0;
}
.create-form input {
flex: 1;
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 10px;
font-size: 13px;
outline: none;
font-family: inherit;
}
.create-form input:focus {
border-color: var(--yj-accent, #ffd43b);
}
.create-form input::placeholder {
color: var(--yj-text-tertiary, #888);
}
.create-form button {
background: var(--yj-bg-overlay, #495057);
border: none;
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.create-form button:hover {
background: var(--yj-bg-overlay, #5a6268);
}
.create-form button.primary {
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
}
.create-form button.primary:hover {
background: var(--yj-accent-hover, #ffe066);
}
.create-form button.primary:disabled {
background: var(--yj-accent-muted, #665a1e);
color: var(--yj-text-tertiary, #888);
cursor: not-allowed;
}
.playlist-list {
flex: 1;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
contain: paint;
}
.playlist-item {
border-bottom: 1px solid
var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.playlist-header {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 10px;
cursor: pointer;
user-select: none;
}
.playlist-header:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.playlist-header.selected {
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.playlist-item.drag-over > .playlist-header {
background-color: var(--yj-accent-bg-strong, rgba(255, 212, 59, 0.15));
outline: 1px dashed var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
.playlist-icon {
font-size: 18px;
color: var(--yj-text-tertiary, #888);
flex-shrink: 0;
}
.playlist-name {
font-size: 14px;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.track-count {
font-size: 11px;
color: var(--yj-text-tertiary, #666);
flex-shrink: 0;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
color: var(--yj-text-secondary, #b3b3b3);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
gap: 8px;
}
.empty-state wa-icon {
font-size: 32px;
}
.empty-state p {
margin: 4px 0;
}
.drop-zone-icon {
display: none;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 12px;
background: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.18)
);
color: var(--yj-accent-text, #ffd43b);
font-size: 28px;
pointer-events: none;
}
.empty-state.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
outline: 2px dashed
var(--yj-accent, #ffd43b);
outline-offset: -4px;
}
.empty-state.drag-over .drop-zone-icon {
display: flex;
}
.empty-state.drag-over > :not(.drop-zone-icon) {
display: none;
}
.drop-zone {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 80px;
}
.drop-zone.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
outline: 2px dashed
var(--yj-accent, #ffd43b);
outline-offset: -4px;
}
.drop-zone.drag-over .drop-zone-icon {
display: flex;
}
#playlist-context-menu {
z-index: 200;
}
.rename-input {
flex: 1;
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-accent, #ffd43b);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 4px 8px;
font-size: 14px;
outline: none;
font-family: inherit;
min-width: 0;
}
.import-error {
padding: 0.5em 0.75em;
margin: 0.5em 16px 0;
font-size: 0.8em;
color: var(--yj-error-text, #ff8787);
background: color-mix(
in srgb,
var(--yj-error, #e03131) 10%,
var(--yj-bg-elevated, #343a40)
);
border-radius: 4px;
border-left: 3px solid
var(--yj-error, #e03131);
}
#sort-dropdown {
z-index: 200;
}
`];
// =================================================================
// Sort controls
// =================================================================
private restoreSortPreferences() {
try {
const field =
localStorage.getItem(PLAYLIST_SORT_KEY);
if (
field &&
SORT_OPTIONS.some(
(o) => o.id === field,
)
) {
this.sortField =
field as PlaylistSortField;
}
const dir = localStorage.getItem(
PLAYLIST_SORT_DIR_KEY,
);
if (dir === 'asc' || dir === 'desc') {
this.sortDirection = dir;
}
} catch {
/* localStorage unavailable */
}
}
private saveSortPreferences() {
try {
localStorage.setItem(
PLAYLIST_SORT_KEY,
this.sortField,
);
localStorage.setItem(
PLAYLIST_SORT_DIR_KEY,
this.sortDirection,
);
} catch {
/* localStorage unavailable */
}
}
private get sortedEntries(): PlaylistEntry[] {
const entries = this.filteredEntries;
const dir =
this.sortDirection === 'asc' ? 1 : -1;
return [...entries].sort((a, b) => {
// Pin default playlist to top when enabled.
if (this.favCtrl.pinDefault) {
const aIsDefault =
a.summary.ID ===
this.favCtrl.playlistId;
const bIsDefault =
b.summary.ID ===
this.favCtrl.playlistId;
if (aIsDefault && !bIsDefault)
return -1;
if (!aIsDefault && bIsDefault)
return 1;
}
let cmp = 0;
switch (this.sortField) {
case 'name':
cmp = a.summary.Name.localeCompare(
b.summary.Name,
);
break;
case 'created':
cmp = (
a.summary.CreatedAt || ''
).localeCompare(
b.summary.CreatedAt || '',
);
break;
case 'modified':
cmp = (
a.summary.UpdatedAt || ''
).localeCompare(
b.summary.UpdatedAt || '',
);
break;
case 'tracks':
cmp =
a.tracks.length -
b.tracks.length;
break;
}
return cmp * dir;
});
}
override connectedCallback() {
super.connectedCallback();
this.restoreSortPreferences();
this.loadPlaylists();
}
protected override onViewActivate(): void {
this.listenWhileActive(
document,
'click',
this.closePlaylistCtxMenuHandler,
);
this.listenWhileActive(
document,
'contextmenu',
this.closePlaylistCtxMenuHandler,
);
this.listenWhileActive(
document,
'mousedown',
this.playlistCtxMenuMousedownHandler,
);
this.listenWhileActive(
document,
'click',
this.clearSelectionHandler,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
}
override updated() {
const currentTerm = this.searchCtrl.term;
if (currentTerm !== this.lastSearchTerm) {
this.lastSearchTerm = currentTerm;
this.selectedPlaylists = new Set();
this.lastSelectedPlaylistIndex = null;
}
// Re-fetch when the store delivers fresh
// data after eager refetch on invalidation.
const cached =
this.playlistCtrl.cachedPlaylists;
if (
cached !== null &&
cached !== this.lastPlaylistsRef
) {
this.lastPlaylistsRef = cached;
this.loadPlaylists();
}
}
private get scrollContainer(): HTMLElement | null {
return (
this.shadowRoot?.querySelector(
'.playlist-list',
) ?? null
);
}
private restoreScrollPosition() {
const saved =
this.playlistCtrl.getScrollPosition();
if (saved > 0 && this.scrollContainer) {
this.scrollContainer.scrollTop = saved;
}
}
private onScroll = () => {
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
this.scrollDebounceTimer = setTimeout(() => {
if (this.scrollContainer) {
this.playlistCtrl.setScrollPosition(
this.scrollContainer.scrollTop,
);
}
}, SCROLL_DEBOUNCE_MS);
};
private async loadPlaylists() {
try {
this.loading = true;
const playlists =
await this.playlistCtrl.getPlaylists();
this.entries = playlists.map((p) => ({
summary: p.Summary,
tracks: p.Tracks ?? [],
}));
} catch (err) {
console.error(
'Failed to load playlists:',
err,
);
this.entries = [];
} finally {
this.loading = false;
}
await this.updateComplete;
this.restoreScrollPosition();
}
/**
* Re-fetches playlists without clearing the current view.
* Shows a spinner in the header while the fetch is in-flight.
*/
private async refreshPlaylists() {
this.refreshing = true;
try {
const playlists =
await this.playlistCtrl.refetch();
this.entries = playlists.map((p) => ({
summary: p.Summary,
tracks: p.Tracks ?? [],
}));
} catch (err) {
console.error(
'Failed to refresh playlists:',
err,
);
} finally {
this.refreshing = false;
}
}
private handlePlaylistHeaderClick = (
e: MouseEvent,
index: number,
) => {
const entry = this.entries[index];
if (!entry) return;
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isCtrl) {
// Ctrl/Cmd+Click: toggle playlist in selection
const next = new Set(this.selectedPlaylists);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
this.selectedPlaylists = next;
this.lastSelectedPlaylistIndex = index;
return;
}
if (isShift && this.lastSelectedPlaylistIndex !== null) {
// Shift+Click: range-select playlists
const start = Math.min(this.lastSelectedPlaylistIndex, index);
const end = Math.max(this.lastSelectedPlaylistIndex, index);
const next = new Set(this.selectedPlaylists);
for (let i = start; i <= end; i++) {
next.add(i);
}
this.selectedPlaylists = next;
return;
}
// Plain click: navigate to playlist details
this.selectedPlaylists = new Set();
this.lastSelectedPlaylistIndex = null;
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: entry.summary.IsSmart
? 'smart-playlist-details'
: 'playlist-details',
playlistId: entry.summary.ID,
playlistName: entry.summary.Name,
},
}),
);
};
// =================================================================
// Drop target (tracks dropped onto a specific playlist)
// =================================================================
private onPlaylistDragOver = (
e: DragEvent,
index: number,
) => {
if (!hasTrackPayload(e)) return;
// 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' &&
getActiveDragPlaylistId() ===
entry.summary.ID
) {
return;
}
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (this.dragOverPlaylistIndex !== index) {
this.dragOverPlaylistIndex = index;
}
// A specific playlist is targeted — hide the
// "new playlist" drop zone highlights.
if (this.dragOverEmptyZone) {
this.dragOverEmptyZone = false;
}
if (this.dragOverNewButton) {
this.dragOverNewButton = false;
}
};
private onPlaylistDragLeave = (
e: DragEvent,
index: number,
) => {
// Only clear if we're actually leaving this
// playlist item (not entering a child).
const related = e.relatedTarget as Node | null;
const items =
this.shadowRoot?.querySelectorAll(
'.playlist-item',
);
const item = items?.[index];
if (item && !item.contains(related)) {
if (this.dragOverPlaylistIndex === index) {
this.dragOverPlaylistIndex = -1;
}
}
};
private onPlaylistDrop = async (
e: DragEvent,
index: number,
) => {
e.preventDefault();
e.stopPropagation();
this.dragOverPlaylistIndex = -1;
const payload = getDragPayload(e);
if (
!payload ||
payload.filePaths.length === 0
) {
return;
}
const entry = this.entries[index];
if (!entry) return;
// Don't allow dropping tracks back onto
// the same playlist.
if (
payload.source === 'playlist' &&
payload.sourcePlaylistId ===
entry.summary.ID
) {
return;
}
try {
const result = await FindDuplicateTracksInPlaylist(
entry.summary.ID,
payload.filePaths,
);
const duplicates = result.Duplicates ?? [];
const unique = result.Unique ?? [];
if (duplicates.length > 0) {
await this.updateComplete;
this.duplicateDialog.show(
entry.summary.ID,
duplicates,
unique,
);
return;
}
await AddTracksToPlaylist(
entry.summary.ID,
payload.filePaths,
);
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to add tracks to playlist:',
err,
);
}
};
// =================================================================
// Drop target (empty space → create new playlist)
// =================================================================
private onEmptyZoneDragOver = (e: DragEvent) => {
if (!hasTrackPayload(e)) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
// Only show the "new playlist" drop zone when
// not hovering a specific playlist item.
if (
this.dragOverPlaylistIndex === -1 &&
!this.dragOverEmptyZone
) {
this.dragOverEmptyZone = true;
}
if (this.dragOverNewButton) {
this.dragOverNewButton = false;
}
};
private onEmptyZoneDragLeave = (e: DragEvent) => {
const related =
e.relatedTarget as Node | null;
if (!related || !this.contains(related)) {
this.dragOverEmptyZone = false;
}
};
private onEmptyZoneDrop = (e: DragEvent) => {
e.preventDefault();
this.dragOverEmptyZone = false;
const payload = getDragPayload(e);
if (
!payload ||
payload.filePaths.length === 0
) {
return;
}
this.pendingDropPaths = payload.filePaths;
this.creating = true;
this.newPlaylistName = '';
void this.updateComplete.then(() => {
const input =
this.shadowRoot?.querySelector<HTMLInputElement>(
'.create-form input',
);
input?.focus();
});
};
// =================================================================
// Drop target ("New Playlist" button)
// =================================================================
private onNewButtonDragOver = (e: DragEvent) => {
if (!hasTrackPayload(e)) return;
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (!this.dragOverNewButton) {
this.dragOverNewButton = true;
}
// Hide the empty-zone highlight while
// hovering the button.
if (this.dragOverEmptyZone) {
this.dragOverEmptyZone = false;
}
};
private onNewButtonDragLeave = (
e: DragEvent,
) => {
const related =
e.relatedTarget as Node | null;
// The button the event was bound to, rather than a selector for
// it: `page-header` renders it now, so it is not in this shadow
// root at all and the old `.new-playlist-button` lookup would
// find nothing and leave the highlight stuck on.
const btn = e.currentTarget as Element | null;
if (btn && !btn.contains(related)) {
this.dragOverNewButton = false;
}
};
private onNewButtonDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
this.dragOverNewButton = false;
this.onEmptyZoneDrop(e);
};
// =================================================================
// Playlist-level context menu (rename, delete)
// =================================================================
private handlePlaylistContextMenu = (
e: MouseEvent,
index: number,
opener?: HTMLElement,
) => {
e.preventDefault();
e.stopPropagation();
// If the right-clicked playlist is NOT in the current
// multi-selection, replace the selection with just that one.
if (!this.selectedPlaylists.has(index)) {
this.selectedPlaylists = new Set([index]);
this.lastSelectedPlaylistIndex = index;
}
this.playlistContextMenuIndex = index;
this.playlistContextMenuOpen = true;
this.updateComplete.then(() => {
const popup =
this.playlistContextMenuPopup;
if (popup) {
popup.anchor = {
getBoundingClientRect() {
return new DOMRect(
e.clientX,
e.clientY,
0,
0,
);
},
};
popup.active = true;
this.menuKeyboard.open(
popup.querySelector('.context-menu-panel'),
opener,
);
}
});
};
/** Shift+F10 / ContextMenu on a focused playlist header. */
private handlePlaylistMenuKey(
e: KeyboardEvent,
index: number,
): void {
const header = e.currentTarget as HTMLElement | null;
if (!header) return;
const rect = header.getBoundingClientRect();
this.handlePlaylistContextMenu(
new MouseEvent('contextmenu', {
clientX: rect.left + 16,
clientY: rect.top + rect.height / 2,
}),
index,
header,
);
}
private closePlaylistContextMenu() {
if (!this.playlistContextMenuOpen) return;
this.menuKeyboard.close();
this.playlistContextMenuOpen = false;
this.playlistContextMenuIndex = -1;
const popup =
this.playlistContextMenuPopup;
if (popup) {
popup.active = false;
}
}
private async onPlaylistContextAction(
action: string,
) {
const index =
this.playlistContextMenuIndex;
const entry = this.entries[index];
if (!entry) return;
switch (action) {
case 'rename':
this.renamingPlaylistIndex = index;
this.renameValue =
entry.summary.Name;
void this.updateComplete.then(
() => {
const input =
this.shadowRoot?.querySelector<HTMLInputElement>(
'.rename-input',
);
input?.focus();
input?.select();
},
);
break;
case 'set-default':
void this.favCtrl
.setDefaultPlaylist(entry.summary.ID)
.catch((err: unknown) => {
console.error(
'Failed to set default playlist:',
err,
);
});
break;
case 'delete': {
if (this.selectedPlaylists.size > 1) {
const entries = [...this.selectedPlaylists]
.map(i => this.entries[i])
.filter((e): e is PlaylistEntry => e !== undefined);
const tracks = entries.reduce(
(sum, e) => sum + e.tracks.length,
0,
);
// The loop used to delete every selected playlist
// with no prompt at all (errors.M6).
const ok = await confirmAction({
title: `Delete ${entries.length} playlists?`,
message: entries
.map((e) => e.summary.Name)
.join(', '),
impact: `${tracks.toLocaleString()} track entries will be removed. The audio files are not touched.`,
confirmLabel: 'Delete playlists',
danger: true,
});
if (!ok) break;
for (const e of entries) {
await this.deletePlaylist(
e.summary.ID,
e.summary.Name,
);
}
this.selectedPlaylists = new Set();
this.lastSelectedPlaylistIndex = null;
await this.refreshPlaylists();
} else {
await this.handleDeletePlaylist(
entry.summary.ID,
entry.summary.Name,
entry.tracks.length,
);
}
break;
}
}
this.selectedPlaylists = new Set();
this.lastSelectedPlaylistIndex = null;
this.closePlaylistContextMenu();
}
private async handleDeletePlaylist(
playlistID: number,
name = 'this playlist',
trackCount = 0,
) {
const ok = await confirmAction({
title: `Delete “${name}”?`,
message: 'The playlist is deleted; the audio files are not.',
impact:
trackCount > 0
? `${trackCount.toLocaleString()} track entries will be removed.`
: undefined,
confirmLabel: 'Delete playlist',
danger: true,
});
if (!ok) return;
await this.deletePlaylist(playlistID, name);
await this.refreshPlaylists();
}
/**
* The delete itself, already confirmed. A partial failure used to
* look exactly like a success until the refresh put the playlist
* back (errors.M6), so it is Persistent: the thing the user asked
* for did not happen and retrying means something.
*/
private async deletePlaylist(
playlistID: number,
name: string,
): Promise<void> {
try {
await DeletePlaylist(playlistID);
} catch (err) {
console.error('Failed to delete playlist:', err);
notificationStore.persistent({
key: 'playlist-delete',
text: `Could not delete “${name}”. ${describeError(err)}`,
detail: String(err),
coalescedText: (count) =>
`Could not delete ${count} playlists.`,
action: {
label: 'Try again',
run: () =>
void this.deletePlaylist(playlistID, name).then(() =>
this.refreshPlaylists(),
),
},
});
}
}
private handleRenameKeydown = async (
e: KeyboardEvent,
) => {
if (e.key === 'Enter') {
await this.submitRename();
} else if (e.key === 'Escape') {
this.renamingPlaylistIndex = -1;
this.renameValue = '';
}
};
private handleRenameBlur = async () => {
await this.submitRename();
};
private handleRenameInput = (e: Event) => {
const input = e.target as HTMLInputElement;
this.renameValue = input.value;
};
private async submitRename() {
const index = this.renamingPlaylistIndex;
if (index < 0) return;
const entry = this.entries[index];
if (!entry) return;
const trimmed = this.renameValue.trim();
this.renamingPlaylistIndex = -1;
this.renameValue = '';
if (
!trimmed ||
trimmed === entry.summary.Name
) {
return;
}
try {
await RenamePlaylist(
entry.summary.ID,
trimmed,
);
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to rename playlist:',
err,
);
}
}
// =================================================================
// Import playlist
// =================================================================
private handleImportPlaylist = async () => {
try {
const filePaths =
await PlaylistFilePicker();
if (!filePaths || filePaths.length === 0)
return;
this.importError = '';
await ImportPlaylists(filePaths);
} catch (err) {
console.error(
'Failed to import playlist:',
err,
);
this.importError = describeError(
err,
'That playlist file could not be imported.',
);
setTimeout(() => {
this.importError = '';
}, 6000);
}
};
// =================================================================
// Create playlist
// =================================================================
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(() => {
const input =
this.shadowRoot?.querySelector<HTMLInputElement>(
'.create-form input',
);
input?.focus();
});
};
private handleCancelCreate = () => {
this.creating = false;
this.creatingSmart = false;
this.newPlaylistName = '';
this.pendingDropPaths = [];
};
private handleCreatePlaylist = async () => {
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 {
if (paths.length > 0) {
await CreatePlaylistWithTracks(
name,
paths,
);
} else {
await CreatePlaylist(name);
}
this.creating = false;
this.newPlaylistName = '';
this.pendingDropPaths = [];
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to create playlist:',
err,
);
}
};
private handleInputChange = (e: Event) => {
const input = e.target as HTMLInputElement;
this.newPlaylistName = input.value;
};
private handleInputKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
void this.handleCreatePlaylist();
} else if (e.key === 'Escape') {
this.handleCancelCreate();
}
};
// =================================================================
// Render
// =================================================================
private onPageHeaderSort = (
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
) => {
this.sortField = e.detail.field as PlaylistSortField;
this.sortDirection = e.detail.direction;
this.saveSortPreferences();
};
/**
* The three things this page can do, as data.
*
* The priority order is what #69's Direction asks for and it is
* only interesting for one of them: **New Playlist is highest
* because it is the drop target**. You cannot drag a track onto a
* closed menu, so collapsing it is the one collapse here that
* removes a capability rather than relocating it. Import is lowest
* because it is the rarest, and at 900×600 it is the only one that
* has to go.
*/
private headerActions(): PageAction[] {
return [
{
id: 'import',
label: 'Import',
icon: 'file-import',
priority: 0,
onSelect: () => void this.handleImportPlaylist(),
},
{
id: 'new-playlist',
label: 'New Playlist',
icon: ICON_NEW,
priority: 2,
onSelect: () => this.handleNewPlaylistClick(),
drop: {
active: this.dragOverNewButton,
onDragOver: this.onNewButtonDragOver,
onDragLeave: this.onNewButtonDragLeave,
onDrop: this.onNewButtonDrop,
},
},
{
id: 'new-smart-playlist',
label: 'New Smart Playlist',
icon: ICON_SMART_PLAYLIST,
priority: 1,
onSelect: () => this.handleNewSmartPlaylistClick(),
},
];
}
override render() {
return html`
<page-header
heading="Playlists"
.count=${this.loading && this.entries.length === 0
? null
: this.sortedEntries.length}
count-noun="playlist"
.sortOptions=${SORT_OPTIONS}
sort-field=${this.sortField}
sort-direction=${this.sortDirection}
search-term=${this.searchCtrl.term}
?busy=${this.refreshing}
@sort-change=${this.onPageHeaderSort}
.actions=${this.headerActions()}
>
</page-header>
${this.importError
? html`<div class="import-error">
${this.importError}
</div>`
: nothing}
${this.creating || this.creatingSmart
? this.renderCreateForm()
: nothing}
${this.loading &&
this.entries.length === 0
? html`<div class="loading">
Loading playlists...
</div>`
: this.renderPlaylistList()}
<wa-popup
id="playlist-context-menu"
placement="bottom-start"
flip
shift
.active=${this
.playlistContextMenuOpen}
>
${this.playlistContextMenuOpen
? html`
<div
class="context-menu-panel"
role="menu"
aria-label="Playlist actions"
>
${this.selectedPlaylists.size <= 1
? html`
<wa-dropdown-item
@click=${() =>
void this.onPlaylistContextAction(
'rename',
)}
>
<wa-icon
slot="icon"
name="pen"
></wa-icon>
Rename
</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
@click=${() =>
void this.onPlaylistContextAction(
'delete',
)}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
${this.selectedPlaylists.size > 1
? `Delete ${this.selectedPlaylists.size} Playlists`
: 'Delete Playlist'}
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
<duplicate-tracks-dialog
@playlist-action-complete=${() =>
this.refreshPlaylists()}
></duplicate-tracks-dialog>
`;
}
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=${placeholder}
.value=${this.newPlaylistName}
@input=${this.handleInputChange}
@keydown=${this.handleInputKeydown}
/>
<button
@click=${this.handleCancelCreate}
>
Cancel
</button>
<button
class="primary"
?disabled=${!canCreate}
@click=${this.handleCreatePlaylist}
>
Create
</button>
</div>
`;
}
private renderPlaylistList() {
if (this.entries.length === 0) {
return html`
<div
class="empty-state ${this.dragOverEmptyZone ? 'drag-over' : ''}"
@dragover=${this.onEmptyZoneDragOver}
@dragleave=${this.onEmptyZoneDragLeave}
@drop=${this.onEmptyZoneDrop}
>
<div class="drop-zone-icon">
<wa-icon
name=${ICON_NEW}
></wa-icon>
</div>
<wa-icon name=${ICON_PLAYLIST}></wa-icon>
<p>No playlists yet</p>
<p style="font-size: 12px;">
Create a playlist or drop
tracks here.
</p>
</div>
`;
}
const visible = this.sortedEntries;
if (visible.length === 0) {
return html`
<div
class="empty-state ${this.dragOverEmptyZone ? 'drag-over' : ''}"
@dragover=${this.onEmptyZoneDragOver}
@dragleave=${this.onEmptyZoneDragLeave}
@drop=${this.onEmptyZoneDrop}
>
<div class="drop-zone-icon">
<wa-icon
name=${ICON_NEW}
></wa-icon>
</div>
<p>
No playlists match your
search.
</p>
</div>
`;
}
return html`
<ul
class="playlist-list"
@scroll=${this.onScroll}
>
${visible.map((entry) => {
const originalIndex =
this.entries.indexOf(entry);
return this.renderPlaylistItem(
entry,
originalIndex,
);
})}
<li
class="drop-zone ${this.dragOverEmptyZone ? 'drag-over' : ''}"
@dragover=${this
.onEmptyZoneDragOver}
@dragleave=${this
.onEmptyZoneDragLeave}
@drop=${this.onEmptyZoneDrop}
>
<div class="drop-zone-icon">
<wa-icon
name=${ICON_NEW}
></wa-icon>
</div>
</li>
</ul>
`;
}
private renderPlaylistItem(
entry: PlaylistEntry,
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
const isRenaming =
this.renamingPlaylistIndex === index;
return html`
<li
class="playlist-item ${isDragOver
? 'drag-over'
: ''}"
@dragover=${(e: DragEvent) =>
this.onPlaylistDragOver(e, index)}
@dragleave=${(e: DragEvent) =>
this.onPlaylistDragLeave(e, index)}
@drop=${(e: DragEvent) =>
this.onPlaylistDrop(e, index)}
>
<div
class="playlist-header ${this.selectedPlaylists.has(index) ? 'selected' : ''}"
role="button"
tabindex="0"
aria-label=${`Playlist ${entry.summary.Name}`}
@click=${(e: MouseEvent) =>
this.handlePlaylistHeaderClick(e, index)}
@keydown=${(e: KeyboardEvent) => {
if (isContextMenuKey(e)) {
e.preventDefault();
e.stopPropagation();
this.handlePlaylistMenuKey(e, index);
}
}}
@contextmenu=${(e: MouseEvent) =>
this.handlePlaylistContextMenu(
e,
index,
)}
>
${entry.summary.ID === this.favCtrl.playlistId
? html`<wa-icon
class="playlist-icon"
name=${this.favCtrl.iconFor(true)}
></wa-icon>`
: entry.summary.IsSmart
? html`<wa-icon
class="playlist-icon"
name=${ICON_SMART_PLAYLIST}
></wa-icon>`
: nothing}
${isRenaming
? html`
<input
class="rename-input"
type="text"
.value=${this
.renameValue}
@input=${this
.handleRenameInput}
@keydown=${this
.handleRenameKeydown}
@blur=${this
.handleRenameBlur}
@click=${(
e: Event,
) =>
e.stopPropagation()}
/>
`
: html`
<span
class="playlist-name"
title=${entry.summary.Name}
>
${entry.summary
.Name}
</span>
`}
<span class="track-count">
${countLabel}
</span>
</div>
</li>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'playlist-view': PlaylistView;
}
}