Fourteen call sites, one tag name each and nothing else -- which is what menu-surface's shape buys: the host's panel is slotted into whichever presentation is up, so no item model, no keyboard model and no styling moved. The 48px rows come from contextMenuStyles, the one stylesheet every one of these hosts already includes, because the panel is the host's own light DOM and only the host's stylesheet can reach it. Two of the fourteen were found by the source sweep rather than by the conversion: queue-panel's add-to-playlist popup, which is a real menu. now-playing's cover preview is allowlisted instead -- it is a hover affordance in the bottom bar, so a touch device never opens it and nothing clips it. The playlist submenu had to come too, and that is the one place this change made something worse before it made it better. It is a placement="right-start" flyout anchored to its row, and making the menu full-width moved that anchor to x=0 -- so the flip put the picker at x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at all. Before the change the row started at x~245 and the same flip landed on screen. It is a sheet now and stacks over the first, which is also why menu-shown does not re-assert focus while it is open. The three hosts that do not use ContextMenuController -- page-header's overflow menu, playlist-view's hand-rolled menu, queue-panel's picker -- bind menu-dismiss themselves, or Escape would close the sheet and leave their own open flag set. page-header is included deliberately: the clipping does not bite there, since it opens downward from the top of a full-height view, but on a phone every action of an overflowing page lives in that menu at wa-dropdown-item defaults. One surface, so there is no second answer to what a menu looks like.
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 { MenuSurface } from '../menu-surface/menu-surface';
|
||
import '../menu-surface/menu-surface';
|
||
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!: MenuSurface;
|
||
|
||
@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()}
|
||
|
||
<menu-surface
|
||
id="playlist-context-menu"
|
||
.active=${this
|
||
.playlistContextMenuOpen}
|
||
@menu-dismiss=${this.closePlaylistContextMenu}
|
||
>
|
||
${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}
|
||
</menu-surface>
|
||
|
||
<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;
|
||
}
|
||
}
|