Files
yellowjacket/frontend/src/components/queue-panel/queue-panel.ts
T
logan f126dd7397 fix(queue): draw the scrim only where it can be tapped
Below 600px `.panel-content` is `width: 100%`, so the scrim sat
entirely underneath an opaque panel -- measured at 424x439, host,
panel and scrim all 424x318. It dimmed nothing and dismissed nothing
there while wearing `cursor: pointer`, so #24's tap-outside-to-close
did not exist on the device it was drawn for.

Of the issue's two directions this takes the second. A gutter is the
drawer pattern and buys the affordance by taking width off a
full-screen surface on a 424px viewport; #55 already made the queue a
*screen* at that width, whose ways out are back and a 44px close
button. So there is no scrim there rather than an unreachable one.

Existence is `matchMedia` rather than `display: none`, on `job-band`'s
rule: a hidden scrim is still an element carrying the handler. The
600-899 band, where the panel is a 320px column of a wider content
area and the scrim has real uncovered pixels, is untouched.

The e2e half asserts *absence* at 424x439 rather than clicking,
because a phone-width case that clicks the scrim's centre hits the
panel and passes on the broken build -- which the issue anticipates.

Closes #171
2026-08-21 16:24:04 +00:00

2285 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof flow>[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.
*
* `<lit-virtualizer>` 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 517ms 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<HTMLElement>(
'.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`
<div
class=${classMap({
'track-item': true,
active,
selected,
'drop-before': showBefore,
'drop-after': showAfter,
})}
data-index=${index}
data-testid="queue-row"
data-file-path=${track.filePath}
draggable="true"
role="option"
aria-selected=${selected}
aria-current=${active ? 'true' : 'false'}
tabindex=${index === this.focusedIndex ? 0 : -1}
>
<span class="track-position">
${index + 1}
</span>
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
<div class="track-details">
<span class="track-title" title=${title}>
${trackLink(title, track.album, track.releaseGroupMbid, track.recordingMbid, undefined, track.artist)}
</span>
<span class="track-artist" title=${artist}>
${creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}
</span>
</div>
<button
class="remove-button"
title="Remove from queue"
aria-label="Remove ${title} from queue"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="14" height="14">
${svg`<path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>`}
</svg>
</button>
</div>
`;
};
override render() {
const tracks = this.queue.tracks;
return html`
${this.overlay && !this.phone
? html`<div
class="scrim"
part="scrim"
data-testid="queue-scrim"
aria-hidden="true"
@click=${this.closeFromOverlay}
></div>`
: nothing}
<div class="panel-content">
<div
class="resize-handle ${this.isDragging
? 'dragging'
: ''}"
@mousedown=${this.handleMouseDown}
></div>
<div class="sr-only" role="status" aria-live="polite">
${this.moveAnnouncement}
</div>
<div class="header">
<div class="header-title">
<h3>Queue</h3>
${describeQueueSource(this.queue.source)
? html`
<span
class="queue-source ${isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
@click=${(e: MouseEvent) => {
if (!isQueueSourceNavigable(this.queue.source)) return;
navigateToQueueSource(
e.currentTarget as EventTarget,
this.queue.source,
);
}}
>${describeQueueSource(this.queue.source)}</span>
`
: nothing}
</div>
<div class="header-actions">
<button
class="header-action-button"
@click=${() => void this.handleClearQueue()}
?disabled=${tracks.length === 0}
title="Clear queue"
>
<wa-icon
name="trash"
></wa-icon>
</button>
<button
class="header-action-button add-to-playlist-button"
@click=${this.handleAddToPlaylist}
?disabled=${tracks.length === 0}
title="Add queue to playlist"
>
<wa-icon
name=${ICON_PLAYLIST}
></wa-icon>
</button>
${this.overlay
? html`<button
class="header-action-button"
data-testid="queue-close"
aria-label="Close queue"
@click=${this.closeFromOverlay}
>
<wa-icon name="xmark"></wa-icon>
</button>`
: nothing}
</div>
</div>
<menu-surface
id="add-to-playlist-popup"
label="Add to playlist"
placement="bottom-end"
@menu-dismiss=${this.closePlaylistPicker}
.active=${this.playlistPickerOpen}
>
${this.playlistPickerOpen
? html`
<playlist-picker
.filePaths=${tracks.map(
(t) => t.filePath,
)}
@playlist-action-complete=${this
.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
`
: nothing}
</menu-surface>
<div
class="list-area"
@dragenter=${this.onPanelDragEnter}
@dragover=${this.onPanelDragOver}
@dragleave=${this.onPanelDragLeave}
@drop=${this.onPanelDrop}
>
${!this.open
? nothing
: tracks.length === 0
? html`<div class="empty-state">
<div class="drop-zone-icon">
<wa-icon
name=${ICON_NEW}
></wa-icon>
</div>
<wa-icon
name=${ICON_QUEUE}
></wa-icon>
<p>Queue is empty</p>
<p style="font-size: 12px;">
Add tracks from your
library or drop them
here.
</p>
</div>`
: html`
<lit-virtualizer
scroller
role="listbox"
aria-label="Queue"
aria-multiselectable="true"
.items=${tracks}
.renderItem=${this.renderTrackItem}
.keyFunction=${(track: QueueTrack) => track.id}
.layout=${this.flowLayout}
></lit-virtualizer>
`}
</div>
</div>
<menu-surface
id="context-menu"
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel" role="menu" aria-label="Queue track actions">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Queue
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.ctxMenu.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
slot="icon"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuFavoriteToggle()}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name=${this.favCtrl.iconName}
></wa-icon>
${this.favCtrl.allFavorited(this.getSelectedFilePaths()) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="circle-info"
></wa-icon>
Track
Details
</wa-dropdown-item>
</div>
`
: nothing}
</menu-surface>
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen &&
this.selection.hasSelection
? html`
<div
@mouseenter=${() =>
this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this
.ctxMenu.scheduleSubmenuClose}
>
<playlist-picker
id="context-playlist-picker"
.filePaths=${this.ctxMenu.playlistFilePaths}
@playlist-action-complete=${this
.onContextPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
</div>
`
: nothing}
</menu-surface>
<track-details></track-details>
`;
}
}