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 { 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'; const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 320; @customElement('queue-panel') export class QueuePanel extends LitElement { private queue = new QueueController(this); @property({ type: Boolean, reflect: true }) open = false; @state() private isDragging = false; @state() private playlistPickerOpen = false; @query('#add-to-playlist-popup') private addToPlaylistPopup!: 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 panelWidth = DEFAULT_WIDTH; private flowLayout = flow(); /** Track the last currentIndex so we only auto-scroll on actual track changes. */ private lastScrolledIndex = -1; 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 { display: flex; align-items: center; padding: 8px 16px; gap: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); cursor: pointer; width: 100%; box-sizing: border-box; } .track-item:hover { background-color: rgba(255, 255, 255, 0.05); } .track-item.active { background-color: rgba(255, 212, 59, 0.1); } .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; } .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; } `; 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); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); document.removeEventListener('click', this.closePickerHandler); } override updated() { const currentIndex = this.queue.currentIndex; // 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(); }; private handleRemoveTrack(e: Event, position: number) { e.stopPropagation(); this.queue.removeFromQueue(position); } private handleTrackClick(index: number) { this.queue.playAtIndex(index); } 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; return html`
this.handleTrackClick(index)} > ${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} ${tracks.length === 0 ? html`

Queue is empty

Click a track to start playing

` : html` `}
`; } }