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, } from '@utils/icon-language'; 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 = 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-actions { display: flex; gap: 8px; } .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; } .new-playlist-button { background: none; border: 1px solid var(--yj-border-subtle, #555); border-radius: 4px; color: var(--yj-text-primary, #fff); padding: 6px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-family: inherit; } .new-playlist-button:hover, .new-playlist-button.drag-over { border-color: var(--yj-accent, #ffd43b); color: var(--yj-accent-text, #ffd43b); } .new-playlist-button.drag-over { background-color: var( --yj-accent-bg-strong, rgba(255, 212, 59, 0.15) ); } .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-button { background: none; border: 1px solid var(--yj-border-subtle, #555); border-radius: 4px; color: var(--yj-text-primary, #fff); padding: 6px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-family: inherit; } .import-button:hover { border-color: var(--yj-accent, #ffd43b); color: var(--yj-accent-text, #ffd43b); } .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( '.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; const btn = this.shadowRoot?.querySelector( '.new-playlist-button', ); 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( '.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 { 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( '.create-form input', ); input?.focus(); }); }; private handleNewSmartPlaylistClick = () => { this.creatingSmart = true; this.creating = false; this.newPlaylistName = ''; void this.updateComplete.then(() => { const input = this.shadowRoot?.querySelector( '.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(); }; override render() { return html` Import New Playlist New Smart Playlist ${this.importError ? html` ${this.importError} ` : nothing} ${this.creating || this.creatingSmart ? this.renderCreateForm() : nothing} ${this.loading && this.entries.length === 0 ? html` Loading playlists... ` : this.renderPlaylistList()} ${this.playlistContextMenuOpen ? html` ${this.selectedPlaylists.size <= 1 ? html` void this.onPlaylistContextAction( 'rename', )} > Rename ${this.entries[this.playlistContextMenuIndex]?.summary.IsSmart ? nothing : html` void this.onPlaylistContextAction( 'set-default', )} > Set as Default Playlist `} ` : nothing} void this.onPlaylistContextAction( 'delete', )} > ${this.selectedPlaylists.size > 1 ? `Delete ${this.selectedPlaylists.size} Playlists` : 'Delete Playlist'} ` : nothing} this.refreshPlaylists()} > `; } private renderCreateForm() { const canCreate = this.newPlaylistName.trim().length > 0; const placeholder = this.creatingSmart ? 'Smart playlist name' : 'Playlist name'; return html` Cancel Create `; } private renderPlaylistList() { if (this.entries.length === 0) { return html` No playlists yet Create a playlist or drop tracks here. `; } const visible = this.sortedEntries; if (visible.length === 0) { return html` No playlists match your search. `; } return html` ${visible.map((entry) => { const originalIndex = this.entries.indexOf(entry); return this.renderPlaylistItem( entry, originalIndex, ); })} `; } 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` this.onPlaylistDragOver(e, index)} @dragleave=${(e: DragEvent) => this.onPlaylistDragLeave(e, index)} @drop=${(e: DragEvent) => this.onPlaylistDrop(e, index)} > 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`` : entry.summary.IsSmart ? html`` : nothing} ${isRenaming ? html` e.stopPropagation()} /> ` : html` ${entry.summary .Name} `} ${countLabel} `; } } declare global { interface HTMLElementTagNameMap { 'playlist-view': PlaylistView; } }
No playlists yet
Create a playlist or drop tracks here.
No playlists match your search.