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
1784 lines
54 KiB
TypeScript
1784 lines
54 KiB
TypeScript
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;
|
||
}
|
||
}
|