import { LitElement, html, css, nothing, unsafeCSS } from 'lit'; import { customElement, property, 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 '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import { QueueController } from '@store/controllers/queue-controller'; import '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; import '@lit-labs/virtualizer'; import type { LitVirtualizer } from '@lit-labs/virtualizer'; import { flow } from '@lit-labs/virtualizer/layouts/flow.js'; import type { QueueTrack } from '@store/queue-store'; import { SelectionController } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller'; import { hasTrackPayload, getDragPayload, setDragPayload, emitDragActive, getActiveDragSource, } from '@utils/drag-controller'; import { createDragImage, removeDragImage, } from '@utils/drag-image'; const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 320; @customElement('queue-panel') export class QueuePanel extends LitElement implements SelectionHost { private queue = new QueueController(this); private selection = new SelectionController(this); @property({ type: Boolean, reflect: true }) open = false; @state() private isDragging = false; @state() private playlistPickerOpen = false; @state() private contextMenuOpen = false; @state() private playlistSubmenuOpen = false; private dragOver = false; private dropTargetIndex = -1; private dropTargetRafId = 0; private autoScrollRafId = 0; private autoScrollDelta = 0; private dragImageEl: HTMLElement | null = null; @query('#add-to-playlist-popup') private addToPlaylistPopup!: HTMLElement; @query('#context-menu') private contextMenuPopup!: HTMLElement; @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; @query('lit-virtualizer') private virtualizer!: LitVirtualizer; private closePickerHandler = (e: MouseEvent) => { const path = e.composedPath(); const popup = this.addToPlaylistPopup; const btn = this.shadowRoot?.querySelector( '.add-to-playlist-button', ); if ( popup && !path.includes(popup) && (!btn || !path.includes(btn)) ) { this.closePlaylistPicker(); } }; private closeContextMenuHandler = () => this.closeContextMenu(); private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); const isTrackClick = path.some( (el) => el instanceof HTMLElement && el.classList.contains('track-item') && this.shadowRoot?.contains(el), ); if (!isTrackClick) { this.selection.clear(); } }; private panelWidth = DEFAULT_WIDTH; private flowLayout = flow(); /** * Track the last currentIndex so we only auto-scroll * on actual track changes. */ private lastScrolledIndex = -1; /** * Tracks the last currentIndex for which the virtualizer was * told to re-render, so the active-track highlight stays in sync. */ private lastRenderedIndex = -1; // ================================================================= // SelectionHost interface // ================================================================= getItemKey(index: number): string | undefined { if (index < 0 || index >= this.queue.tracks.length) { return undefined; } return String(index); } getItemCount(): number { return this.queue.tracks.length; } onSelectionChanged(): void { this.virtualizer?.requestUpdate(); } static override styles = css` :host { flex-shrink: 0; width: 0; overflow: hidden; background-color: #212529; display: flex; flex-direction: row; } :host([open]) { width: var( --queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px ); border-left: 1px solid #333; } .resize-handle { position: absolute; top: 0; left: 0; width: 4px; height: 100%; cursor: col-resize; background-color: transparent; transition: background-color 0.15s ease; z-index: 10; } .resize-handle:hover, .resize-handle.dragging { background-color: #6c757d; } .panel-content { position: relative; display: flex; flex-direction: column; min-width: ${unsafeCSS(MIN_WIDTH)}px; flex: 1; } .header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #333; flex-shrink: 0; } .header h3 { margin: 0; font-size: 14px; font-weight: 600; } .add-to-playlist-button { background: none; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center; } .add-to-playlist-button:hover { color: #ffd43b; } .add-to-playlist-button:disabled { color: #555; cursor: not-allowed; } #add-to-playlist-popup { z-index: 210; } lit-virtualizer { flex: 1; overflow-y: auto; } .track-item { position: relative; display: flex; align-items: center; padding: 8px 16px; gap: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); cursor: default; user-select: none; width: 100%; box-sizing: border-box; } .track-item:hover { background-color: rgba(255, 255, 255, 0.05); } .track-item.selected { background-color: rgba(100, 160, 255, 0.15); } .track-item.active { background-color: rgba(255, 212, 59, 0.1); } .track-item.selected.active { background-color: rgba(100, 160, 255, 0.15); } .track-position { font-size: 12px; color: #888; min-width: 20px; text-align: right; } .track-item.active .track-position { color: #ffd43b; } .track-details { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .track-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-item.active .track-title { color: #ffd43b; } .track-artist { font-size: 11px; color: #b3b3b3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .remove-button { background: none; border: none; color: #888; cursor: pointer; padding: 4px; display: flex; align-items: center; opacity: 0; transition: opacity 0.15s; } .track-item:hover .remove-button { opacity: 1; } .remove-button:hover { color: #ff6b6b; } .panel-content.drag-over { outline: 2px dashed #ffd43b; outline-offset: -2px; } .drop-indicator { display: none; padding: 12px 16px; text-align: center; font-size: 12px; color: #ffd43b; border-bottom: 1px solid rgba(255, 212, 59, 0.2); } .panel-content.drag-over.empty-drag .drop-indicator { display: block; } .track-item.drop-before::before { content: ''; position: absolute; top: -1px; left: 8px; right: 8px; height: 2px; background: #ffd43b; border-radius: 1px; z-index: 5; pointer-events: none; } .track-item.drop-after::after { content: ''; position: absolute; bottom: -1px; left: 8px; right: 8px; height: 2px; background: #ffd43b; border-radius: 1px; z-index: 5; pointer-events: none; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: #b3b3b3; text-align: center; gap: 8px; } .empty-state wa-icon { font-size: 32px; } #context-menu { z-index: 200; } .context-menu-panel { background-color: #343a40; border: 1px solid #444; border-radius: 6px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); min-width: 160px; } .context-menu-panel wa-dropdown-item { cursor: pointer; --wa-color-text-normal: #fff; font-size: 13px; } .context-menu-panel wa-dropdown-item:hover { background-color: rgba(255, 255, 255, 0.1); } .submenu-item { position: relative; } .submenu-arrow { font-size: 10px; margin-left: auto; padding-left: 12px; } #playlist-submenu { z-index: 210; } `; override connectedCallback() { super.connectedCallback(); this.style.setProperty( '--queue-width', `${this.panelWidth}px`, ); document.addEventListener( 'mousemove', this.handleMouseMove, ); document.addEventListener( 'mouseup', this.handleMouseUp, ); document.addEventListener( 'click', this.closePickerHandler, ); document.addEventListener( 'click', this.closeContextMenuHandler, ); document.addEventListener( 'contextmenu', this.closeContextMenuHandler, ); document.addEventListener( 'click', this.clearSelectionHandler, ); document.addEventListener( 'dragend', this.onDocumentDragEnd, ); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener( 'mousemove', this.handleMouseMove, ); document.removeEventListener( 'mouseup', this.handleMouseUp, ); document.removeEventListener( 'click', this.closePickerHandler, ); document.removeEventListener( 'click', this.closeContextMenuHandler, ); document.removeEventListener( 'contextmenu', this.closeContextMenuHandler, ); document.removeEventListener( 'click', this.clearSelectionHandler, ); document.removeEventListener( 'dragend', this.onDocumentDragEnd, ); } override updated() { const currentIndex = this.queue.currentIndex; // Force virtualizer to re-render visible items when the // active track changes so the highlight stays in sync. if (currentIndex !== this.lastRenderedIndex) { this.lastRenderedIndex = currentIndex; this.virtualizer?.requestUpdate(); } // Auto-scroll to the active track when it changes. if ( currentIndex >= 0 && currentIndex !== this.lastScrolledIndex && this.virtualizer ) { this.lastScrolledIndex = currentIndex; requestAnimationFrame(() => { this.virtualizer?.scrollToIndex( currentIndex, 'center', ); }); } } private async handleAddToPlaylist() { if (this.queue.tracks.length === 0) return; this.playlistPickerOpen = !this.playlistPickerOpen; await this.updateComplete; const popup = this.addToPlaylistPopup; const btn = this.shadowRoot?.querySelector( '.add-to-playlist-button', ); if (popup && btn) { (popup as any).anchor = btn; (popup as any).active = this.playlistPickerOpen; } if (this.playlistPickerOpen) { const picker = this.shadowRoot?.querySelector( 'playlist-picker', ) as PlaylistPicker | null; picker?.reset(); } } private closePlaylistPicker() { if (!this.playlistPickerOpen) return; this.playlistPickerOpen = false; const popup = this.addToPlaylistPopup; if (popup) { (popup as any).active = false; } } private onPlaylistActionComplete = () => { this.closePlaylistPicker(); }; // ================================================================= // Selection & click handlers // ================================================================= private handleTrackClick( e: MouseEvent, _track: QueueTrack, index: number, ) { this.selection.handleItemClick( e, String(index), index, ); } private handleTrackDblClick(index: number) { this.selection.clear(); this.queue.playAtIndex(index); } private handleTrackContextMenu( e: MouseEvent, index: number, ) { e.preventDefault(); e.stopPropagation(); this.selection.handleContextMenu(String(index)); this.contextMenuOpen = true; // Position at mouse cursor using a virtual anchor. this.updateComplete.then(() => { const popup = this.contextMenuPopup; if (popup) { (popup as any).anchor = { getBoundingClientRect() { return { width: 0, height: 0, x: e.clientX, y: e.clientY, top: e.clientY, left: e.clientX, right: e.clientX, bottom: e.clientY, }; }, }; (popup as any).active = true; } }); } private onContextMenuAction(action: string) { const indices = this.selection.getSelectedIndices(); if (indices.length === 0) return; switch (action) { case 'play': this.queue.playAtIndex(indices[0]!); break; case 'remove': this.queue.removeTracksFromQueue(indices); break; } this.closeContextMenu(true); } private closeContextMenu(clearSelection = false) { if (!this.contextMenuOpen) return; this.closePlaylistSubmenu(); this.contextMenuOpen = false; if (clearSelection) { this.selection.clear(); } const popup = this.contextMenuPopup; if (popup) { (popup as any).active = false; } } private async showPlaylistSubmenu() { if (this.playlistSubmenuOpen) return; this.playlistSubmenuOpen = true; await this.updateComplete; const submenu = this.playlistSubmenuPopup; const trigger = this.shadowRoot?.querySelector('.submenu-item'); if (submenu && trigger) { (submenu as any).anchor = trigger; (submenu as any).active = true; } const picker = this.shadowRoot?.querySelector( '#context-playlist-picker', ) as PlaylistPicker | null; picker?.reset(); } private closePlaylistSubmenu() { if (!this.playlistSubmenuOpen) return; this.playlistSubmenuOpen = false; const submenu = this.playlistSubmenuPopup; if (submenu) { (submenu as any).active = false; } } /** * Derive file paths from selected indices for * operations that need file paths (e.g. Add to Playlist). */ private getSelectedFilePaths(): string[] { const tracks = this.queue.tracks; return this.selection .getSelectedIndices() .map((i) => tracks[i]!.filePath); } private onContextPlaylistActionComplete = () => { this.closeContextMenu(true); }; // ================================================================= // Drop target (tracks dropped into queue) // ================================================================= /** * Toggle the drag-over CSS classes directly on the * DOM element. This avoids Lit re-renders which * cause DOM mutations that break the browser's * drag event stream. */ private updateDragOverClass() { const panel = this.shadowRoot?.querySelector( '.panel-content', ); if (!panel) return; const isEmpty = this.queue.tracks.length === 0; panel.classList.toggle( 'drag-over', this.dragOver, ); panel.classList.toggle( 'empty-drag', this.dragOver && isEmpty, ); } private onPanelDragEnter = (e: DragEvent) => { if (!hasTrackPayload(e)) return; e.preventDefault(); if (e.dataTransfer) { const isInternal = getActiveDragSource() === 'queue'; e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy'; } if (!this.dragOver) { this.dragOver = true; this.updateDragOverClass(); this.startAutoScroll(); } }; private onPanelDragOver = (e: DragEvent) => { if (!hasTrackPayload(e)) return; e.preventDefault(); const isInternal = getActiveDragSource() === 'queue'; if (e.dataTransfer) { e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy'; } this.updateDropTargetIndex(e.clientY); this.updateAutoScrollDelta(e.clientY); }; private onPanelDragLeave = (_e: DragEvent) => { // No-op: cleanup is handled by dragend / drop. // Firing here would break due to child-boundary // and virtualizer re-render events. }; private onPanelDrop = (e: DragEvent) => { e.preventDefault(); const targetIndex = this.dropTargetIndex; this.cleanupDragState(); const payload = getDragPayload(e); if ( !payload || payload.filePaths.length === 0 ) { return; } if (payload.source === 'queue') { // Internal reorder. const fromIndices = this.selection .getSelectedIndices(); if (fromIndices.length > 0) { this.queue.moveTracksInQueue( fromIndices, targetIndex >= 0 ? targetIndex : this.queue.tracks.length, ); } } else { // External insert at position. const idx = targetIndex >= 0 ? targetIndex : this.queue.tracks.length; this.queue.insertTracksAtIndex( payload.filePaths, idx, ); } }; /** * Calculate the drop target index from cursor Y * position relative to the virtualizer's children. */ private updateDropTargetIndex(clientY: number) { const newIdx = this.computeDropTargetIndex(clientY); if (newIdx !== this.dropTargetIndex) { this.dropTargetIndex = newIdx; // Debounce via RAF to avoid layout thrashing // that interrupts the browser drag stream. if (!this.dropTargetRafId) { this.dropTargetRafId = requestAnimationFrame(() => { this.dropTargetRafId = 0; this.virtualizer?.requestUpdate(); }); } } } private computeDropTargetIndex( clientY: number, ): number { const tracks = this.queue.tracks; if (tracks.length === 0) return 0; const virt = this.virtualizer; if (!virt) return tracks.length; const items = virt.querySelectorAll('.track-item'); if (items.length === 0) return tracks.length; // Check each visible item to find the drop // position. for (const item of items) { const rect = item.getBoundingClientRect(); const midY = rect.top + rect.height / 2; if (clientY < midY) { const idx = Number( (item as HTMLElement).dataset.index, ); if (!Number.isNaN(idx)) return idx; } } // Cursor is below all visible items — append // at end. const lastItem = items[items.length - 1]; if (lastItem) { const idx = Number( (lastItem as HTMLElement).dataset .index, ); if (!Number.isNaN(idx)) return idx + 1; } return tracks.length; } // ================================================================= // Auto-scroll during drag // ================================================================= private static readonly SCROLL_ZONE = 60; private static readonly SCROLL_SPEED = 12; /** * Update the scroll delta based on cursor proximity * to the top/bottom edges. The RAF loop (started in * onPanelDragEnter) reads this value each frame. * Setting delta to 0 means no scrolling; the loop * stays running until the drag ends. */ private updateAutoScrollDelta(clientY: number) { const virt = this.virtualizer; if (!virt) return; const rect = virt.getBoundingClientRect(); const zone = QueuePanel.SCROLL_ZONE; const distTop = clientY - rect.top; const distBottom = rect.bottom - clientY; if (distTop < zone && distTop >= 0) { this.autoScrollDelta = -QueuePanel.SCROLL_SPEED * (1 - distTop / zone); } else if ( distBottom < zone && distBottom >= 0 ) { this.autoScrollDelta = QueuePanel.SCROLL_SPEED * (1 - distBottom / zone); } else { this.autoScrollDelta = 0; } } private startAutoScroll() { if (this.autoScrollRafId) return; const step = () => { const virt = this.virtualizer; if (!virt) { this.autoScrollRafId = 0; return; } if (this.autoScrollDelta !== 0) { virt.scrollTop += this.autoScrollDelta; } this.autoScrollRafId = requestAnimationFrame(step); }; this.autoScrollRafId = requestAnimationFrame(step); } private stopAutoScroll() { if (this.autoScrollRafId) { cancelAnimationFrame(this.autoScrollRafId); this.autoScrollRafId = 0; } this.autoScrollDelta = 0; } /** * Reset all drag-related state. Called from drop, * dragend, and the global dragend fallback. */ private cleanupDragState() { if (!this.dragOver) return; this.dragOver = false; this.dropTargetIndex = -1; if (this.dropTargetRafId) { cancelAnimationFrame(this.dropTargetRafId); this.dropTargetRafId = 0; } this.updateDragOverClass(); this.stopAutoScroll(); this.virtualizer?.requestUpdate(); } /** * Global dragend handler catches external drags * (from track-list / cover-grid) that end outside * the queue panel without a drop event. */ private onDocumentDragEnd = () => { this.cleanupDragState(); }; // ================================================================= // Drag source (queue tracks to playlist) // ================================================================= private onTrackDragStart = ( e: DragEvent, index: number, ) => { const tracks = this.queue.tracks; let filePaths: string[]; if (this.selection.isSelected(String(index))) { // Drag the entire multi-selection. filePaths = this.selection .getSelectedIndices() .map((i) => tracks[i]!.filePath); } else { // Dragging an unselected track — select // only it so internal reorder works. this.selection.handleContextMenu( String(index), ); const track = tracks[index]; if (!track) return; filePaths = [track.filePath]; } if (filePaths.length === 0) return; setDragPayload(e, { filePaths, source: 'queue', }); this.dragImageEl = createDragImage( filePaths.length, ); e.dataTransfer?.setDragImage( this.dragImageEl, 0, 0, ); emitDragActive(true); }; private onTrackDragEnd = () => { if (this.dragImageEl) { removeDragImage(this.dragImageEl); this.dragImageEl = null; } this.cleanupDragState(); emitDragActive(false); }; // ================================================================= // Other handlers // ================================================================= private handleRemoveTrack(e: Event, position: number) { e.stopPropagation(); this.queue.removeFromQueue(position); } private getDisplayTitle(track: { title: string; filePath: string; }): string { if (track.title) return track.title; // Fall back to filename without extension. const parts = track.filePath.split(/[\\/]/); const filename = parts[parts.length - 1] ?? track.filePath; return filename.replace(/\.[^.]+$/, ''); } private handleMouseDown = (e: MouseEvent) => { e.preventDefault(); this.isDragging = true; }; private handleMouseMove = (e: MouseEvent) => { if (!this.isDragging) return; const rect = this.getBoundingClientRect(); const newWidth = rect.right - e.clientX; const clampedWidth = Math.min( Math.max(newWidth, MIN_WIDTH), MAX_WIDTH, ); this.panelWidth = clampedWidth; this.style.setProperty( '--queue-width', `${clampedWidth}px`, ); }; private handleMouseUp = () => { if (!this.isDragging) return; this.isDragging = false; }; private renderTrackItem = ( track: QueueTrack, index: number, ) => { const currentIndex = this.queue.currentIndex; const active = index === currentIndex; const selected = this.selection.isSelected( String(index), ); const dropIdx = this.dropTargetIndex; const trackCount = this.queue.tracks.length; const showBefore = dropIdx === index; const showAfter = dropIdx === trackCount && index === trackCount - 1; const classes = [ 'track-item', active ? 'active' : '', selected ? 'selected' : '', showBefore ? 'drop-before' : '', showAfter ? 'drop-after' : '', ] .filter(Boolean) .join(' '); return html`
this.handleTrackClick(e, track, index)} @dblclick=${() => this.handleTrackDblClick(index)} @contextmenu=${(e: MouseEvent) => this.handleTrackContextMenu(e, index)} @dragstart=${(e: DragEvent) => this.onTrackDragStart(e, index)} @dragend=${this.onTrackDragEnd} > ${index + 1}
${this.getDisplayTitle(track)} ${track.artist || 'Unknown Artist'}
`; }; override render() { const tracks = this.queue.tracks; return html`

Queue

${this.playlistPickerOpen ? html` t.filePath, )} @playlist-action-complete=${this .onPlaylistActionComplete} @click=${(e: Event) => e.stopPropagation()} > ` : nothing}
Drop tracks here to add to queue
${tracks.length === 0 ? html`

Queue is empty

Click a track to start playing

` : html` `}
${this.contextMenuOpen ? html`
this.onContextMenuAction( 'play', )} > Play this.onContextMenuAction( 'remove', )} > Remove from Queue this.showPlaylistSubmenu()} @click=${(e: Event) => { e.stopPropagation(); void this.showPlaylistSubmenu(); }} > Add to Playlist
` : nothing}
${this.playlistSubmenuOpen && this.selection.hasSelection ? html` e.stopPropagation()} > ` : nothing} `; } }