import { LitElement, html, svg, css, nothing, unsafeCSS } from 'lit'; import { designTokens } from '../../styles/tokens.css'; import { srOnly } from '../../styles/sr-only.css'; 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 type { MenuSurface } from '../menu-surface/menu-surface'; import '../menu-surface/menu-surface'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import { QueueController } from '@store/controllers/queue-controller'; import { PHONE_QUERY } from '@utils/breakpoints'; import { creditStore } from '@store/credit-store'; import { describeQueueSource, isQueueSourceNavigable, navigateToQueueSource, } from '@utils/queue-source-link'; import { confirmAction } from '@components/confirm-dialog/confirm-dialog'; 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 { virtualizerRef } from '@lit-labs/virtualizer/virtualize.js'; import { flow } from '@lit-labs/virtualizer/layouts/flow.js'; import { classMap } from 'lit/directives/class-map.js'; import type { QueueTrack } from '@store/queue-store'; import { SelectionController } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller'; import { ContextMenuController, contextMenuStyles, isContextMenuKey, } from '@utils/context-menu-controller.js'; import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js'; import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows'; import { FavoritesController } from '@store/controllers/favorites-controller'; import { hasTrackPayload, getDragPayload, setDragPayload, emitDragActive, getActiveDragSource, } from '@utils/drag-controller'; import { createDragImage, createTrackCardDragImage, removeDragImage, } from '@utils/drag-image'; import { libraryStore } from '@store/library-store'; import type * as library from '@go/library/models.js'; import { loadTrackDetails } from '@utils/lazy-track-details.js'; import { tracksByFilePath } from '@utils/track-index.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js'; import { creditLink, trackLink, exploreLinkStyles, } from '@utils/explore-link'; import { ICON_NEW, ICON_PLAYLIST, ICON_QUEUE, } from '@utils/icon-language'; /** Above this many tracks, clearing the queue asks first. */ const CLEAR_CONFIRM_THRESHOLD = 20; const MIN_WIDTH = 200; const MAX_WIDTH = 500; const DEFAULT_WIDTH = 320; /** * The narrowest main panel the queue is allowed to leave behind before * it stops being a column and becomes an overlay (plan 018, issue #24). * * There is no cliff to derive this from, and pretending otherwise would * be the more dishonest answer: the track list rescales its columns * continuously (213px down to 124px between main widths of 900 and 544, * with no row overflow at any of them) and the album grid steps 3 * columns to 2 without breaking. So this is a judgement, anchored at * both ends — it keeps the *default* 1100px window inline, because an * inline queue is a desktop affordance people choose and demoting the * common case to an overlay would be a regression in feel; and it puts * every case measured as broken on the overlay side, which is 900x600 * (main = 379px, where all three of the Playlists header's actions are * clipped) and every phone width (main = 69px at 390, 0px at 320). */ const MAIN_PANEL_FLOOR = 480; @customElement('queue-panel') export class QueuePanel extends LitElement implements SelectionHost, ContextMenuHost { private queue = new QueueController(this); private selection = new SelectionController(this); private ctxMenu = new ContextMenuController(this); private favCtrl = new FavoritesController(this); @property({ type: Boolean, reflect: true }) open = false; /** * Whether the panel is covering the content instead of sitting * beside it. **Computed, never set by a caller** — it is reflected * so the stylesheet and a spec can both read it. * * It is deliberately *not* a media query, which is the whole reason * this is a property and not a `@media` block. The panel's width is * user state: drag-resizable between MIN_WIDTH and MAX_WIDTH and * persisted. A breakpoint at a fixed viewport width silently * assumes the default 320, so it is wrong by up to 180px for a user * who has widened the panel — in the direction that hurts, since a * wider queue is exactly when the content can least afford it. */ @property({ type: Boolean, reflect: true }) overlay = false; /** * Phone width, from `matchMedia` rather than from a media query, * because it decides whether the scrim *exists* (#171) — * `job-band`'s rule, and a stylesheet cannot express it: a * `display: none` scrim is still an element with a click handler. * * Below 600px the panel spans the whole content area, so the scrim * has no uncovered pixels: measured at 424x439, host, panel and * scrim are all 424x318 with the scrim entirely underneath. It dims * nothing and dismisses nothing there, and the queue is a *screen* * at that width anyway (#55) — back and a 44px close button are its * ways out. Between 600 and 899 the panel is a 320px column of a * wider content area, the scrim is reachable, and #24's * tap-outside-to-close is real; that band is untouched. */ @state() private phone = false; private phoneQuery?: MediaQueryList; @state() private isDragging = false; @state() private playlistPickerOpen = false; private dragOver = false; private dragEnterCount = 0; private dropTargetIndex = -1; private dropTargetRafId = 0; private autoScrollRafId = 0; private autoScrollDelta = 0; private dragImageEl: HTMLElement | null = null; /** Whether delegated event handlers have been attached to the virtualizer. */ private delegationAttached = false; @query('#add-to-playlist-popup') private addToPlaylistPopup!: MenuSurface; @query('#context-menu') private contextMenuPopup!: MenuSurface; @query('#playlist-submenu') private playlistSubmenuPopup!: MenuSurface; /** Unsubscribes the credit-arrival repaint. */ private creditsUnsub?: () => void; @query('lit-virtualizer') private virtualizer!: LitVirtualizer; @query('track-details') private trackDetailsDialog!: TrackDetails; private handleSelectAll = (): void => { this.selection.selectAll(); }; // Detect native scrollbar thumb drag to suppress lit-virtualizer's // scroll error corrections that fight the browser's drag gesture. private onVirtualizerMouseDown = (e: MouseEvent) => { const el = this.virtualizer; if (!el) return; const rect = el.getBoundingClientRect(); // Click is in the scrollbar gutter if it's beyond the content area. // clientWidth excludes scrollbar; offsetWidth includes it. if (e.clientX > rect.left + el.clientWidth) { this.scrollbarDragging = true; } }; private onScrollbarDragEnd = () => { this.scrollbarDragging = false; }; 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 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(); } }; /** The row holding the roving tab stop. */ @state() private focusedIndex = 0; /** * What the live region says about the last keyboard reorder. * * Empty until there has been one — the region itself renders * unconditionally, because a screen reader announces a *change* to a * region it is already watching and ignores one that appears with * its text already in it. */ @state() private moveAnnouncement = ''; private panelWidth = DEFAULT_WIDTH; private scrollbarDragging = false; /** Watches `.content-area`, which is the viewport minus the sidebar. */ private spaceObserver?: ResizeObserver; /** * What had focus when the overlay opened, so Escape and the scrim * can give it back. Focus is only taken back if the panel had it — * the same rule `MenuKeyboard` follows, for the same reason: the * queue can also be closed by the button in the bottom bar, and * yanking focus away from wherever the user actually is would be * worse than leaving it. */ private overlayOpener: HTMLElement | null = null; // _itemSize is an internal property applied via Object.assign in BaseLayout's // config setter. Setting it to match the actual fixed .track-item height (49px) // prevents lit-virtualizer's scroll error correction from fighting the native // scrollbar during drag on large lists (20k+ items). Without this, the default // estimate of 100px causes massive scroll height recalculation as items get // measured, which calls scrollTo() and desynchronizes the scrollbar thumb. private flowLayout = flow({ _itemSize: { width: 100, height: 49 }, } as Parameters[0]); /** * 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; } /** * Repaint the rows when the selection changes. * * `` renders through the `virtualize` directive, * which reacts to its *own* properties and not to the host having * re-rendered, so host state like a selection reaches the rows only * if it is pushed. `track-list` has always done this and both * playlist views had to be taught it. * * **There is a second, accidental mechanism here and it must not be * mistaken for this one**: `.keyFunction` below is a per-render * arrow, so it is a changed property on every host update and * repaints the rows by itself. Removing *either* alone changes * nothing observable, which is why #43 could not be settled by * reading the code. With both gone the highlight still arrives — * on whatever unrelated render happens next, measured at 134ms, * 3,866ms and 5,816ms against 5–17ms healthy, which a user cannot * tell from broken. `queue-selection.spec.ts` asserts the * *promptness* rather than the eventual state for that reason. */ onSelectionChanged(): void { this.virtualizer?.requestUpdate(); } // ================================================================= // ContextMenuHost interface // ================================================================= getContextMenuPopup(): MenuTarget | undefined { return this.contextMenuPopup; } getPlaylistSubmenuPopup(): MenuTarget | undefined { return this.playlistSubmenuPopup; } static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css` :host { flex-shrink: 0; width: 0; overflow: hidden; background-color: var(--yj-bg-surface, #212529); display: flex; flex-direction: row; contain: layout style paint; } :host([open]) { width: var( --queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px ); border-left: 1px solid var(--yj-border-subtle, #333); } /* --------------------------------------------------------- Overlay mode (plan 018, #24). In flow the panel takes its width *from the main panel*, which is the reported bug: at 900x600 that left 379px and clipped every action in the Playlists header, and at 320px it left 0px — the content was not degraded but gone. Here the host spans the whole content area instead and stops being a layout participant, so the main panel keeps its full width and the queue sits over it. The host itself is transparent and click-through; the scrim and the panel are what take pointer events. The containment drops paint, which would otherwise clip the panel's own shadow. --------------------------------------------------------- */ :host([overlay]) { position: absolute; inset: 0; width: auto; background-color: transparent; overflow: visible; pointer-events: none; contain: layout style; z-index: 20; } /* Closed, an overlay is not there at all. In flow the panel is width: 0, which is its own way of saying this; absolutely positioned there is no width to collapse. */ :host([overlay]:not([open])) { display: none; } :host([overlay][open]) { border-left: none; } :host([overlay]) .panel-content { position: absolute; top: 0; right: 0; bottom: 0; width: var(--queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px); max-width: 100%; box-sizing: border-box; background-color: var(--yj-bg-surface, #212529); border-left: 1px solid var(--yj-border-subtle, #333); box-shadow: -8px 0 24px rgb(0 0 0 / 45%); pointer-events: auto; } /* Dragging the edge of something that is already covering the content answers a question nobody asked, and it is a mouse-only affordance either way. */ :host([overlay]) .resize-handle { display: none; } /* Overlay only, and above 600px only -- see the phone field, which is where that half is decided (#171). */ .scrim { position: absolute; inset: 0; background-color: rgb(0 0 0 / 45%); pointer-events: auto; border: none; padding: 0; margin: 0; cursor: pointer; } /* The phone gets the whole width: below 600 there is no "beside" left to be, and this is the shape #55 turns into a real screen. A media query inside a shadow root is answered by the viewport, so the component states this itself rather than the shell reaching in. */ @media (max-width: 599px) { :host([overlay]) .panel-content { width: 100%; } /* A screen's way out has to be hittable with a thumb. Measured at 424x439 before #55: these were **25x21px**, and with the panel spanning the whole width there is no scrim here at all (#171) -- so this is the only pointer route out of a full-screen surface. Back answers it now as well, which is the other half. Sized only in overlay mode: inline these sit in a 320px column beside the content, where a mouse is what reaches them and 44px of header is 44px the queue does not get. */ :host([overlay]) .header-action-button { min-width: 44px; min-height: 44px; justify-content: center; } } .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: var(--yj-text-tertiary, #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 var(--yj-border-subtle, #333); flex-shrink: 0; } .header-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .header h3 { margin: 0; font-size: var(--yj-text-lg); font-weight: 600; } .queue-source { font-size: var(--yj-text-xs, 0.75rem); color: var(--yj-text-tertiary, #666); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .queue-source.navigable { cursor: pointer; } .queue-source.navigable:hover { text-decoration: underline; } .header-actions { display: flex; align-items: center; gap: 4px; } .header-action-button { background: none; border: none; color: inherit; cursor: pointer; padding: 4px; display: flex; align-items: center; } .header-action-button:hover { color: var(--yj-accent-text, #ffd43b); } .header-action-button:disabled { color: var(--yj-border-subtle, #555); cursor: not-allowed; } #add-to-playlist-popup { z-index: 210; } .list-area { flex: 1; display: flex; flex-direction: column; min-height: 0; } lit-virtualizer { flex: 1; overflow-y: auto; contain: paint; overflow-anchor: none; } .track-item { position: relative; display: flex; align-items: center; padding: 8px 16px; gap: 12px; border-bottom: 1px solid var(--yj-hover-overlay, rgba(255, 255, 255, 0.05)); cursor: default; user-select: none; width: 100%; box-sizing: border-box; height: 49px; overflow: hidden; contain: strict; } .track-item:hover { background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05)); } .track-item.selected { background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15)); } .track-item.active { background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1)); } .track-item.selected.active { background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15)); } .track-position { font-size: var(--yj-text-sm); color: var(--yj-text-tertiary, #888); min-width: 20px; text-align: right; } .track-item.active .track-position { color: var(--yj-accent-text, #ffd43b); } /* a11y.22, the same rule as track-list one panel over: the playing row was a tint and a text colour and nothing else. The triangle lives in the row's own 16px left padding, so it is a shape that is present or absent and the flex row does not move. */ .track-item.active::before { content: ''; position: absolute; left: 5px; top: 50%; transform: translateY(-50%); border-left: 5px solid var(--yj-accent-text, #ffd43b); border-top: 4px solid transparent; border-bottom: 4px solid transparent; } .track-art { width: 32px; height: 32px; border-radius: 4px; overflow: hidden; flex-shrink: 0; background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); } .track-art img { width: 100%; height: 100%; object-fit: cover; display: block; } .track-details { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } .track-title { font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-item.active .track-title { color: var(--yj-accent-text, #ffd43b); } .track-artist { font-size: var(--yj-text-xs); color: var(--yj-text-secondary, #b3b3b3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* * The per-row remove is a hover affordance, and on a device * without hover it is redundant rather than missing: the row's * context menu is a bottom sheet since #60 and carries "Remove * from Queue", so the action is one long-press away. An * always-visible X would instead spend part of a 424px row on * something already reachable. #68's treatment, for #68's reason. * * display:none outside the query rather than visibility:hidden: * a hidden button still occupies its hit area and is still in * the accessibility tree, so a phone would keep a target for a * control it can never see. */ .remove-button { display: none; } @media (hover: hover) and (pointer: fine) { .remove-button { background: none; border: none; color: var(--yj-text-tertiary, #888); cursor: pointer; padding: 4px; display: flex; align-items: center; visibility: hidden; } .track-item:hover .remove-button { visibility: visible; } .remove-button:hover { color: var(--yj-error-text, #ff8787); } } .list-area.drag-over { outline: 2px dashed var(--yj-accent, #ffd43b); outline-offset: -2px; } .track-item.drop-before::before { content: ''; position: absolute; top: -1px; left: 8px; right: 8px; height: 2px; background: var(--yj-accent, #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: var(--yj-accent, #ffd43b); border-radius: 1px; z-index: 5; pointer-events: none; } .empty-state { flex: 1; 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; /* intentionally large decorative icon */ } .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; } .list-area.drag-over.empty-drag .empty-state { background-color: var( --yj-accent-bg-strong, rgba(255, 212, 59, 0.15) ); } .list-area.drag-over.empty-drag .drop-zone-icon { display: flex; } .list-area.drag-over.empty-drag .empty-state > :not(.drop-zone-icon) { display: none; } `]; override firstUpdated() { this.attachVirtualizerHooks(); } /** * Attach scroll-error monkey-patch, mousedown listener, * and delegated event handlers to the virtualizer. * The virtualizer may not exist on first render (queue * empty), so this is called from both firstUpdated() * and updated() — guarded by a flag. */ private attachVirtualizerHooks() { if (this.delegationAttached) return; const virtEl = this.virtualizer; if (!virtEl) return; // Monkey-patch lit-virtualizer's scroll error correction to suppress it // during native scrollbar drag. Without this, the virtualizer calls // scrollTo() to "correct" sub-pixel estimation errors, which fights the // browser's native scrollbar drag gesture and causes the thumb to // desync from the mouse on large lists (20k+ items). // // NOTE: CSS `overflow-anchor: none` (set on lit-virtualizer above) // disables the *browser's* native scroll anchoring, but does NOT // affect lit-virtualizer's own _correctScrollError() method which // calls scrollTo() internally. This monkey-patch is still needed // to suppress that internal correction during scrollbar drag. // eslint-disable-next-line @typescript-eslint/no-explicit-any const virt = (virtEl as any)?.[virtualizerRef]; if (virt) { const origCorrect = virt._correctScrollError.bind(virt); virt._correctScrollError = () => { if (this.scrollbarDragging) { // Discard the error instead of applying it via scrollTo(). // This prevents stale corrections from accumulating and // being applied in a burst when the drag ends. virt._scrollError = null; return; } origCorrect(); }; } virtEl.addEventListener( 'mousedown', this.onVirtualizerMouseDown, ); // Event delegation: attach stable handlers to the virtualizer // so renderTrackItem creates zero per-item closures. virtEl.addEventListener('click', this.onDelegatedClick); virtEl.addEventListener('dblclick', this.onDelegatedDblClick); virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu); virtEl.addEventListener('dragstart', this.onDelegatedDragStart); virtEl.addEventListener('dragend', this.onTrackDragEnd); virtEl.addEventListener('keydown', this.onDelegatedKeydown); this.delegationAttached = true; } override connectedCallback() { super.connectedCallback(); // Credits arrive after the rows that asked for them, and a // virtualizer repaints from its *own* properties — a host // update alone leaves the rows exactly as they were. this.creditsUnsub = creditStore.subscribe(() => { this.requestUpdate(); this.virtualizer?.requestUpdate(); }); this.style.setProperty( '--queue-width', `${this.panelWidth}px`, ); // The mode is a measurement, so it is observed rather than // computed once: the parent is `.content-area`, whose width is // the viewport minus the sidebar — including the sidebar's own // collapse at 900px, which is what makes 900 the *worst* // desktop width rather than the minimum. this.updateOverlayMode(); // Read here rather than in a field initialiser, so a test can // install its own matchMedia before the element is created. this.phoneQuery = window.matchMedia?.(PHONE_QUERY); this.phone = this.phoneQuery?.matches ?? false; this.phoneQuery?.addEventListener('change', this.onPhoneMedia); if (this.parentElement) { this.spaceObserver = new ResizeObserver(() => this.updateOverlayMode(), ); this.spaceObserver.observe(this.parentElement); } document.addEventListener( 'mousemove', this.handleMouseMove, ); document.addEventListener( 'mouseup', this.handleMouseUp, ); document.addEventListener( 'click', this.closePickerHandler, ); document.addEventListener( 'click', this.clearSelectionHandler, ); document.addEventListener( 'dragend', this.onDocumentDragEnd, ); document.addEventListener( 'shortcut:select-all', this.handleSelectAll, ); document.addEventListener( 'mouseup', this.onScrollbarDragEnd, ); } override disconnectedCallback() { super.disconnectedCallback(); this.creditsUnsub?.(); this.creditsUnsub = undefined; this.spaceObserver?.disconnect(); this.spaceObserver = undefined; this.phoneQuery?.removeEventListener('change', this.onPhoneMedia); this.phoneQuery = undefined; document.removeEventListener('keydown', this.onOverlayKeydown); document.removeEventListener( 'mousemove', this.handleMouseMove, ); document.removeEventListener( 'mouseup', this.handleMouseUp, ); document.removeEventListener( 'click', this.closePickerHandler, ); document.removeEventListener( 'click', this.clearSelectionHandler, ); document.removeEventListener( 'dragend', this.onDocumentDragEnd, ); document.removeEventListener( 'shortcut:select-all', this.handleSelectAll, ); document.removeEventListener( 'mouseup', this.onScrollbarDragEnd, ); this.virtualizer?.removeEventListener( 'mousedown', this.onVirtualizerMouseDown, ); // Remove delegated event handlers from virtualizer. const virtEl = this.virtualizer; if (virtEl) { virtEl.removeEventListener('click', this.onDelegatedClick); virtEl.removeEventListener('dblclick', this.onDelegatedDblClick); virtEl.removeEventListener('contextmenu', this.onDelegatedContextMenu); virtEl.removeEventListener('dragstart', this.onDelegatedDragStart); virtEl.removeEventListener('dragend', this.onTrackDragEnd); virtEl.removeEventListener('keydown', this.onDelegatedKeydown); } this.delegationAttached = false; } /** * Decide whether the queue can afford to be a column. * * The parent is `.content-area`, so its width is the viewport minus * the sidebar and the sum already accounts for the sidebar's own * collapse. It is stable across the panel's own open/closed state * in both modes — in flow the panel is a child of that box, and as * an overlay it is out of flow — so this cannot oscillate. */ private updateOverlayMode = () => { const available = this.parentElement?.clientWidth ?? 0; // Before layout there is nothing to measure, and answering 0 by // flipping to overlay would show the scrim for a frame. if (available === 0) return; this.overlay = available - this.panelWidth < MAIN_PANEL_FLOOR; }; private onPhoneMedia = (e: MediaQueryListEvent): void => { this.phone = e.matches; }; /** * Escape closes a scrimmed overlay, which is the one keyboard rule * every dialog in this app already follows. * * It is a document listener rather than a panel-scoped binding * (`services/shortcut-scope.ts`) because it is not a *shortcut*: it * is the dismissal of something covering the page, and it has to * work while focus is still behind the scrim. It is attached only * while the overlay is actually up and removed on close, so it is * scoped to a state rather than being a permanent global. Nothing * else binds Escape — the shortcut service only uses it to blur a * text input. */ private onOverlayKeydown = (e: KeyboardEvent) => { if (e.key !== 'Escape' || !this.open || !this.overlay) return; e.stopPropagation(); this.closeFromOverlay(); }; private closeFromOverlay = () => { const hadFocus = this.contains( document.activeElement as Node | null, ); this.open = false; if (hadFocus) { const back = this.overlayOpener ?? document.getElementById('queue-button'); back?.focus(); } this.overlayOpener = null; }; override updated() { // The overlay owns Escape only while it is up. if (this.open && this.overlay) { document.addEventListener('keydown', this.onOverlayKeydown); this.overlayOpener ??= document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null; } else { document.removeEventListener('keydown', this.onOverlayKeydown); if (!this.open) this.overlayOpener = null; } // Closed, the panel is `width: 0` — which hides it from the eye // and from nobody else: its Clear and Add buttons still took tab // stops at x=1440 and were still read out (H-5). `inert` is the // one attribute that means "not there" to both. this.inert = !this.open; // ...and a panel that is not there does not render a list // either (perf.m7). `width: 0` and `contain: layout style // paint` bound the damage but do not stop the work: the // virtualizer inside still had a real height and a // `min-width: 300px`, so it measured its visible window on // every queue change, and `scrollToIndex` below called // `scrollIntoView()` on a laid-out but invisible element every // time the current track changed. The list body renders // `nothing` while closed; these indices reset so opening the // panel re-syncs the highlight and the scroll position rather // than inheriting stale ones. if (!this.open) { this.lastRenderedIndex = -1; this.lastScrolledIndex = -1; return; } // The virtualizer may not exist on first render // (queue empty). Retry hooks here when it appears. this.attachVirtualizerHooks(); 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', ); }); } } /** * Clearing stops playback and discards the list, and it is the one * mutation in this panel with no way back (errors.m3). A queue you * could rebuild in a second is not worth a prompt; one you spent * the evening on is. */ private handleClearQueue = async () => { const count = this.queue.tracks.length; if (count > CLEAR_CONFIRM_THRESHOLD) { const ok = await confirmAction({ title: 'Clear the queue?', message: `${count} tracks will be removed and playback will stop.`, impact: 'The queue cannot be brought back.', confirmLabel: 'Clear queue', danger: true, }); if (!ok) return; } this.queue.clearQueue(); }; 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.anchor = btn; popup.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.active = false; } }; private onPlaylistActionComplete = () => { this.closePlaylistPicker(); }; // ================================================================= // Delegated event handlers (stable references, zero per-item closures) // ================================================================= /** * Walk up from the event target to find the nearest * `.track-item` and extract the index via `data-index`. */ private resolveTrackIndexFromEvent( e: Event, ): number | null { const row = (e.target as HTMLElement).closest( '.track-item', ) as HTMLElement | null; if (!row) return null; const idx = Number(row.dataset.index); if (Number.isNaN(idx)) return null; return idx; } private onDelegatedClick = (e: MouseEvent) => { const idx = this.resolveTrackIndexFromEvent(e); if (idx === null) return; // Check if click was on the remove button const removeBtn = (e.target as HTMLElement).closest( '.remove-button', ); if (removeBtn) { e.stopPropagation(); this.queue.removeFromQueue(idx); return; } const track = this.queue.tracks[idx]; if (track) this.handleTrackClick(e, track, idx); }; private onDelegatedDblClick = (e: MouseEvent) => { const idx = this.resolveTrackIndexFromEvent(e); if (idx !== null) this.handleTrackDblClick(idx); }; private onDelegatedContextMenu = (e: MouseEvent) => { const idx = this.resolveTrackIndexFromEvent(e); if (idx !== null) this.handleTrackContextMenu(e, idx); }; private onDelegatedDragStart = (e: DragEvent) => { const idx = this.resolveTrackIndexFromEvent(e); if (idx !== null) this.onTrackDragStart(e, idx); }; // ================================================================= // 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.ctxMenu.openAt(e.clientX, e.clientY); } /** * The queue's rows had no keyboard path at all: not focusable, so * neither Enter nor Shift+F10 had anywhere to fire from, and the * menu — which is the only way to reach most of what a queue row can * do — was right-click only (a11y.3). */ private onDelegatedKeydown = (e: KeyboardEvent): void => { const count = this.queue.tracks.length; if (count === 0) return; const row = (e.target as HTMLElement | null)?.closest( '.track-item', ); // `focusedIndex` is the roving tab stop, and until now only the // arrow keys moved it — so a row focused by a click or by Tab // left it saying 0, and every key below acted on the wrong row. // Enter played the first track in the queue from any focused // row, which is a pre-existing bug that Alt+Arrow made visible // by moving something. The key event knows which row it came // from; use that. const rowIndex = Number(row?.dataset.index ?? NaN); if (Number.isInteger(rowIndex) && rowIndex !== this.focusedIndex) { this.focusedIndex = rowIndex; } if (isContextMenuKey(e) && row) { e.preventDefault(); e.stopPropagation(); this.selection.handleContextMenu(String(this.focusedIndex)); this.ctxMenu.openFrom(row); return; } if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.selection.clear(); this.queue.playAtIndex(this.focusedIndex); return; } // a11y.11: reordering the queue was drag-only, so its order // could not be changed without a mouse at all. // // This has to come before `nextRovingIndex`, which switches on // `e.key` and does not look at the modifiers — so Alt+ArrowUp // already moved the roving focus, and would have gone on doing // that *as well* as moving the row. if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); e.stopPropagation(); this.moveFocusedRow(e.key === 'ArrowUp' ? -1 : 1, count); return; } const next = nextRovingIndex(e.key, this.focusedIndex, count); if (next === null) return; e.preventDefault(); e.stopPropagation(); this.focusedIndex = next; this.selection.handleContextMenu(String(next)); void focusRovingRow( this, this.virtualizer, next, (i) => `.track-item[data-index="${i}"]`, ); }; /** * Move the focused row one position, and say where it went. * * It moves the *focused* row rather than the selection, which the * drag path uses: the keyboard model already keeps those in step * (every roving move re-selects the row it lands on), and "Alt+Down * moved four rows you cannot see" is not a thing to do without an * undo. * * The asymmetry in the target index is `MoveQueueTracks`'s, not * ours. `toIndex` is an index into the array *before* the move, so * moving down by one has to ask for `i + 2`: `i + 1` is where the * row already is once you account for its own removal, and the * backend's contiguous-block guard correctly treats it as a no-op. */ private moveFocusedRow(delta: -1 | 1, count: number): void { const from = this.focusedIndex; const to = from + delta; if (to < 0 || to >= count) { this.moveAnnouncement = delta < 0 ? 'Already first in the queue' : 'Already last in the queue'; return; } this.queue.moveTracksInQueue([from], delta < 0 ? to : from + 2); this.focusedIndex = to; this.selection.handleContextMenu(String(to)); this.moveAnnouncement = `Moved to position ${to + 1} of ${count}`; void focusRovingRow( this, this.virtualizer, to, (i) => `.track-item[data-index="${i}"]`, ); } 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; case 'track-details': if (indices.length === 1) { void this.openTrackDetails(indices[0]!); } else { void this.openBatchTrackDetails(indices); } break; } this.selection.clear(); this.ctxMenu.close(); } private onContextMenuFavoriteToggle() { const filePaths = this.getSelectedFilePaths(); if (filePaths.length === 0) return; if (this.favCtrl.allFavorited(filePaths)) { void this.favCtrl.removeFromFavorites( filePaths, ); } else { void this.favCtrl.addToFavorites( filePaths, ); } this.selection.clear(); this.ctxMenu.close(); } private async openTrackDetails(index: number) { const queueTrack = this.queue.tracks[index]; if (!queueTrack) return; const tracks = libraryStore.getCachedTracks(); const track = tracks ? tracksByFilePath(tracks).get( queueTrack.filePath, ) : undefined; if (!track) return; const ready = await loadTrackDetails( () => void this.openTrackDetails(index), ); if (!ready) return; const coverArt = track.CoverArtPath ? { coverArtPath: track.CoverArtPath, coverArtSmall: track.CoverArtSmall, coverArtMedium: track.CoverArtMedium, coverArtLarge: track.CoverArtLarge, } : undefined; this.trackDetailsDialog?.show( track, coverArt, ); } private async openBatchTrackDetails( indices: number[], ) { const queueTracks = this.queue.tracks; const cachedTracks = libraryStore.getCachedTracks(); if (!cachedTracks) return; const byPath = tracksByFilePath(cachedTracks); const tracks = indices .map((i) => queueTracks[i]) .filter((qt) => qt != null) .map((qt) => byPath.get(qt.filePath)) .filter( (t): t is library.Track => t != null, ); if (tracks.length === 0) return; const ready = await loadTrackDetails( () => void this.openBatchTrackDetails(indices), ); if (!ready) return; const first = tracks[0]!; const albumNames = new Set(tracks.map((t) => t.Album)); let coverArt: CoverArtUrls | null = null; let coverArtMixed = false; if (albumNames.size === 1 && first.CoverArtPath) { coverArt = { coverArtPath: first.CoverArtPath, coverArtSmall: first.CoverArtSmall, coverArtMedium: first.CoverArtMedium, coverArtLarge: first.CoverArtLarge, }; } else if (albumNames.size > 1) { coverArtMixed = true; } this.trackDetailsDialog?.showBatch( tracks, coverArt, coverArtMixed, ); } private onContextPlaylistActionComplete = () => { this.selection.clear(); this.ctxMenu.close(); }; /** * 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); } // ================================================================= // 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( '.list-area', ); 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(); this.dragEnterCount++; 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) => { this.dragEnterCount--; // Each child-boundary crossing fires a // paired dragenter/dragleave. The counter // only reaches 0 when the cursor truly // leaves the panel. if (this.dragEnterCount <= 0) { this.dragEnterCount = 0; this.cleanupDragState(); } }; 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.dragEnterCount = 0; 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', }); if (filePaths.length === 1) { const t = tracks[index]!; this.dragImageEl = createTrackCardDragImage( t.title, t.artist, t.filePath, ); } else { 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 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`, ); // Widening the panel is one of the two ways the content can run // out of room, and it is the way a viewport-width media query // cannot see at all. this.updateOverlayMode(); }; 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 artUrl = track.coverArtPath || ''; // The panel's width is user-resizable down to MIN_WIDTH, so // both of these are routinely clipped (a11y.24) — and the // remove button is one of every row, named identically // (a11y.32). const title = this.getDisplayTitle(track); const artist = track.artist || 'Unknown Artist'; // No inline closures — all events delegated via data-index // on the virtualizer element (see firstUpdated). return html`
${index + 1} ${artUrl ? html`
` : nothing}
${trackLink(title, track.album, track.releaseGroupMbid, track.recordingMbid, undefined, track.artist)} ${creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}
`; }; override render() { const tracks = this.queue.tracks; return html` ${this.overlay && !this.phone ? html`` : nothing}
${this.moveAnnouncement}

Queue

${describeQueueSource(this.queue.source) ? html` { if (!isQueueSourceNavigable(this.queue.source)) return; navigateToQueueSource( e.currentTarget as EventTarget, this.queue.source, ); }} >${describeQueueSource(this.queue.source)} ` : nothing}
${this.overlay ? html`` : nothing}
${this.playlistPickerOpen ? html` t.filePath, )} @playlist-action-complete=${this .onPlaylistActionComplete} @click=${(e: Event) => e.stopPropagation()} > ` : nothing}
${!this.open ? nothing : tracks.length === 0 ? html`

Queue is empty

Add tracks from your library or drop them here.

` : html` track.id} .layout=${this.flowLayout} > `}
${this.ctxMenu.contextMenuOpen ? html` ` : nothing} ${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection ? html`
this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this .ctxMenu.scheduleSubmenuClose} > e.stopPropagation()} >
` : nothing}
`; } }