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
2285 lines
76 KiB
TypeScript
2285 lines
76 KiB
TypeScript
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 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<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"
|
||
>
|
||
▶
|
||
</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>
|
||
`;
|
||
}
|
||
}
|