Files
yellowjacket/frontend/src/components/cover-grid/cover-grid.ts
T
yonlu a4eac394ce perf: inline SVGs, memoize grid slices, batch store notifications
- Replace wa-icon shadow DOM with inline SVG in queue-panel (xmark)
  and album-dropdown (fav icons) to eliminate per-item shadow roots
- Memoize getBeforeEntries/getAfterEntries in cover-grid to prevent
  .slice() creating new array refs that trigger virtualizer relayout
- Remove transition: scale and border-radius from album cards to
  avoid per-frame repaints and anti-aliased path clipping
- Add queueMicrotask batching to player-store and favorites-store
  notify() to coalesce rapid-fire updates into single renders
2026-03-15 09:29:40 -04:00

2107 lines
64 KiB
TypeScript

import { LitElement, html, nothing } from 'lit';
import {
customElement,
property,
state,
query,
} from 'lit/decorators.js';
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { GetAlbumTracks } from '@go/library/Library';
import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
import '@components/track-details/track-details.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import { AlbumSelectionManager } from './album-selection.js';
import { ScrollManager } from './scroll-manager.js';
import type { ScrollManagerHost } from './scroll-manager.js';
import './album-dropdown.js';
import type {
TrackClickDetail,
TrackDblClickDetail,
TrackContextMenuDetail,
TrackDragStartDetail,
} from './album-dropdown.js';
import {
DRAG_MIME,
setDragPayload,
emitDragActive,
} from '@utils/drag-controller';
import type { DragPayload } from '@utils/drag-controller';
import { ContextMenuController } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
createAlbumArtDragImage,
createDragImage,
createTrackCardDragImage,
removeDragImage,
} from '@utils/drag-image';
import { coverGridStyles } from './cover-grid-styles.js';
import {
ALBUM_SORT_OPTIONS,
SORT_DIR_KEY,
SORT_FIELD_KEY,
ZOOM_STEP,
} from './cover-grid-types.js';
import type {
AlbumSortField,
ContextMenuTarget,
GridEntry,
SortDirection,
} from './cover-grid-types.js';
@customElement('cover-grid')
export class CoverGrid
extends LitElement
implements ContextMenuHost, ScrollManagerHost
{
/**
* When set, the grid displays these albums instead of
* fetching all albums from the library store. The
* parent is responsible for reloading when data changes.
*/
@property({ type: Array, attribute: false })
externalAlbums?: library.Album[];
libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private lastSearchTerm = '';
/** Tracks the store's cached array reference to detect refreshes. */
private lastAlbumsRef: library.Album[] | null =
null;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private ctxMenu = new ContextMenuController(this);
private favCtrl = new FavoritesController(this);
private selMgr = new AlbumSelectionManager();
private scrollMgr = new ScrollManager(this, {
GRID_GAP: CoverGrid.GRID_GAP,
GRID_PADDING: CoverGrid.GRID_PADDING,
});
private lastSelectedAlbumIndex: number | null = null;
private lastSelectedTrackIndex: number | null = null;
/**
* When true, the next split→single transition
* skips the expensive overlay capture and scroll
* restore.
*/
private skipOverlay = false;
/** Current card width — driven by the store. */
get cardWidth(): number {
return this.libraryCtrl.coverSize;
}
/**
* Height of the text area below the cover image.
* Two lines: album name (+ year) and artist.
*/
private get cardTextHeight(): number {
const w = this.cardWidth;
if (w < 160) return 36;
return w > 250 ? 46 : 40;
}
/** Derived card height from card width. */
get cardHeight(): number {
return this.cardWidth + this.cardTextHeight;
}
/** Image size inside the card (card minus padding). */
private get imageSize(): number {
return this.cardWidth - CoverGrid.CARD_PADDING * 2;
}
// Virtualizer grid layout instance — recreated when
// the card size changes.
private gridLayout = this.createGridLayout();
private gridLayoutWidth = 0;
/**
* Secondary layout for the "after" virtualizer in
* split mode. Uses zero top padding so there is no
* extra gap between the dropdown and the first row.
*/
private gridLayoutAfter = this.createGridLayout(
true,
);
private createGridLayout(noTopPad = false) {
const w = this.libraryCtrl?.coverSize ?? 176;
if (!noTopPad) {
this.gridLayoutWidth = w;
}
const h = w + this.cardTextHeight;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
return grid({
itemSize: {
width: `${w}px`,
height: `${h}px`,
},
gap: `${gap}px`,
padding: noTopPad
? `0 ${pad}px ${pad}px`
: `${pad}px`,
justify: 'center',
});
}
private dragImageEl: HTMLElement | null = null;
// -- Memoisation caches for filtered albums --
cachedFilteredAlbums: library.Album[] = [];
private prevFilterAlbums: library.Album[] = [];
private prevFilterTerm = '';
private prevSortField: AlbumSortField = 'name';
private prevSortDir: SortDirection = 'asc';
// =================================================================
// Filtered albums (memoised)
// =================================================================
/**
* Recompute the filtered-albums cache when its
* inputs have changed. Called from willUpdate()
* so the cache is ready before render().
*/
private recomputeAlbumCache() {
const term = this.searchCtrl.term;
if (
this.albums !== this.prevFilterAlbums ||
term !== this.prevFilterTerm ||
this.sortField !== this.prevSortField ||
this.sortDirection !== this.prevSortDir
) {
this.prevFilterAlbums = this.albums;
this.prevFilterTerm = term;
this.prevSortField = this.sortField;
this.prevSortDir = this.sortDirection;
this.cachedFilteredAlbums =
this.computeFilteredAlbums();
}
}
private computeFilteredAlbums(): library.Album[] {
const term =
this.searchCtrl.term.toLowerCase();
let albums: library.Album[];
if (!term) {
albums = this.albums;
} else {
albums = this.albums.filter(
(a) =>
a.Name.toLowerCase().includes(
term,
) ||
a.ArtistName.toLowerCase().includes(
term,
),
);
}
// Apply sort.
const opt = ALBUM_SORT_OPTIONS.find(
(o) => o.id === this.sortField,
);
if (!opt) return albums;
const dir =
this.sortDirection === 'asc' ? 1 : -1;
return [...albums].sort(
(a, b) => dir * opt.comparator(a, b),
);
}
/** Wheel event handler ref for manual add/remove. */
private wheelHandler = (e: WheelEvent) => {
this.onWheel(e);
};
private wheelListenerAttached = false;
// buildGridEntries() memoization cache.
private gridEntriesCache: GridEntry[] = [];
private gridEntriesCacheKey: library.Album[] = [];
// getBeforeEntries/getAfterEntries memoization — prevents .slice()
// from creating new array refs that trigger virtualizer relayout.
private beforeEntriesCache: GridEntry[] = [];
private afterEntriesCache: GridEntry[] = [];
private splitEntriesCacheKey: GridEntry[] | null = null;
private splitEntriesCacheIndex = -1;
static override styles = coverGridStyles;
/* ====================================================================
* Reactive state
* ==================================================================== */
@state()
private albums: library.Album[] = [];
@state()
private loading = true;
private contextMenuTarget: ContextMenuTarget = {
kind: 'album',
};
/**
* Album ID that was right-clicked to open the
* context menu. Used as fallback when the
* right-clicked album is not part of the current
* visual selection.
*/
private contextMenuAlbumId: number | null = null;
@state()
private selectedAlbums: Set<number> = new Set();
/** Current album sort field. */
@state()
private sortField: AlbumSortField = 'name';
/** Current sort direction. */
@state()
private sortDirection: SortDirection = 'asc';
/** Whether the sort dropdown popup is open. */
@state()
private sortDropdownOpen = false;
@query('#sort-dropdown')
private sortDropdownPopup!: WaPopup;
/** ID of the album whose dropdown is currently open, or null. */
@state()
expandedAlbumId: number | null = null;
/** Tracks loaded for the expanded album dropdown. */
@state()
expandedTracks: library.Track[] = [];
/** Set of file paths of selected tracks inside the dropdown. */
@state()
private selectedTracks: Set<string> = new Set();
/**
* True when using the dual-virtualizer layout
* (dropdown sandwiched between two grids).
*/
@state()
splitMode = false;
/**
* Index into this.albums where the split occurs.
* Albums [0, splitIndex) go into the "before"
* virtualizer; [splitIndex, length) go into "after".
* Not `@state()` — always set before `splitMode`
* changes, which triggers the render.
*/
splitIndex = 0;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
// ContextMenuHost interface.
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
return this.playlistSubmenuPopup;
}
onContextMenuClose(): void {
this.contextMenuAlbumId = null;
}
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@query('#grid-single')
private virtualizerSingle!: LitVirtualizer;
@query('.grid-scroll-container')
private scrollContainer!: HTMLElement;
/* ====================================================================
* Sort controls
* ==================================================================== */
/** Restore sort preferences from localStorage. */
private restoreSortPreferences() {
try {
const field = localStorage.getItem(
SORT_FIELD_KEY,
);
const dir =
localStorage.getItem(SORT_DIR_KEY);
if (
field &&
ALBUM_SORT_OPTIONS.some(
(o) => o.id === field,
)
) {
this.sortField =
field as AlbumSortField;
}
if (dir === 'asc' || dir === 'desc') {
this.sortDirection = dir;
}
} catch {
// Ignore storage errors.
}
}
/** Persist sort preferences to localStorage. */
private saveSortPreferences() {
try {
localStorage.setItem(
SORT_FIELD_KEY,
this.sortField,
);
localStorage.setItem(
SORT_DIR_KEY,
this.sortDirection,
);
} catch {
// Ignore storage errors.
}
}
/** Set the sort field from the dropdown. */
private onSortDropdownSelect(
field: AlbumSortField,
) {
this.sortField = field;
this.saveSortPreferences();
this.closeSortDropdown();
}
/** Toggle sort direction. */
private toggleSortDirection() {
this.sortDirection =
this.sortDirection === 'asc'
? 'desc'
: 'asc';
this.saveSortPreferences();
}
private toggleSortDropdown() {
if (this.sortDropdownOpen) {
this.closeSortDropdown();
} else {
this.openSortDropdown();
}
}
private async openSortDropdown() {
this.sortDropdownOpen = true;
await this.updateComplete;
const popup = this.sortDropdownPopup;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (popup && anchor) {
popup.anchor = anchor;
popup.active = true;
}
}
private closeSortDropdown() {
if (!this.sortDropdownOpen) return;
this.sortDropdownOpen = false;
const popup = this.sortDropdownPopup;
if (popup) {
popup.active = false;
}
}
private sortDropdownCloseHandler = (
e: MouseEvent,
) => {
if (!this.sortDropdownOpen) return;
const path = e.composedPath();
const popup = this.sortDropdownPopup;
if (popup && path.includes(popup)) return;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (anchor && path.includes(anchor)) return;
this.closeSortDropdown();
};
/* ====================================================================
* Lifecycle
* ==================================================================== */
override connectedCallback() {
super.connectedCallback();
this.restoreSortPreferences();
this.loadAlbums();
document.addEventListener(
'mousedown',
this.sortDropdownCloseHandler,
);
// error events do not bubble — use capture
// phase to catch <img> load failures.
this.addEventListener(
'error',
this.onGridImageError,
true,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
'mousedown',
this.sortDropdownCloseHandler,
);
this.removeEventListener(
'error',
this.onGridImageError,
true,
);
this.scrollContainer?.removeEventListener(
'wheel',
this.wheelHandler,
);
this.wheelListenerAttached = false;
this.scrollMgr.teardown();
this.scrollMgr.revealContainer(
this.scrollContainer,
);
}
override willUpdate(
changed: Map<PropertyKey, unknown>,
) {
super.willUpdate(changed);
this.recomputeAlbumCache();
// When the parent provides a new external album
// list, update local albums and reset selection.
if (changed.has('externalAlbums') && this.externalAlbums) {
this.albums = this.externalAlbums;
this.selMgr.setAlbums(this.externalAlbums);
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
this.loading = false;
}
// When switching albums, expandedTracks
// briefly goes to []. Exit split mode so
// the single virtualizer takes over while
// loading.
if (
changed.has('expandedTracks') &&
this.expandedTracks.length === 0 &&
this.splitMode
) {
const sm = this.scrollMgr;
if (this.skipOverlay) {
this.skipOverlay = false;
sm.savedScrollTop =
sm.computeAdjustedScrollTop(
this.scrollContainer,
this.shadowRoot,
);
sm.savedAlbumViewportOffset = null;
this.splitMode = false;
sm.needsScrollRestore = true;
sm.showDropdownAfterRestore = false;
} else {
sm.savedScrollTop =
sm.computeAdjustedScrollTop(
this.scrollContainer,
this.shadowRoot,
);
sm.captureAnchorOffset(
this.scrollContainer,
this.shadowRoot,
);
sm.captureOverlay(
this.scrollContainer,
this.shadowRoot,
);
this.splitMode = false;
sm.needsScrollRestore = true;
sm.showDropdownAfterRestore = false;
}
}
// Enter split mode when tracks have loaded.
if (
changed.has('expandedTracks') &&
this.expandedAlbumId !== null &&
this.expandedTracks.length > 0
) {
const sm = this.scrollMgr;
if (!sm.restoreInFlight) {
sm.savedScrollTop =
this.scrollContainer
?.scrollTop ?? 0;
}
sm.captureOverlay(
this.scrollContainer,
this.shadowRoot,
);
this.splitIndex =
sm.computeSplitIndex(
this.scrollContainer,
);
this.splitMode = true;
sm.needsScrollRestore = true;
sm.showDropdownAfterRestore = true;
}
// Exit split mode when the dropdown closes.
if (
changed.has('expandedAlbumId') &&
this.expandedAlbumId === null &&
this.splitMode
) {
const sm = this.scrollMgr;
sm.savedScrollTop =
sm.computeAdjustedScrollTop(
this.scrollContainer,
this.shadowRoot,
);
sm.savedAlbumViewportOffset = null;
sm.captureOverlay(
this.scrollContainer,
this.shadowRoot,
);
this.splitMode = false;
sm.needsScrollRestore = true;
sm.showDropdownAfterRestore = false;
}
}
override updated(
changed: Map<PropertyKey, unknown>,
) {
super.updated(changed);
// Ctrl+Scroll zoom — lazily attach to the
// scroll container once it exists in the DOM.
if (
!this.wheelListenerAttached &&
this.scrollContainer
) {
this.scrollContainer.addEventListener(
'wheel',
this.wheelHandler,
{ passive: false },
);
this.wheelListenerAttached = true;
}
// Recreate the virtualizer grid layout when
// the card size changes.
const cardSizeChanged =
this.gridLayoutWidth !== this.cardWidth;
if (cardSizeChanged) {
this.gridLayout = this.createGridLayout();
this.gridLayoutAfter =
this.createGridLayout(true);
}
// Apply CSS custom properties for dynamic sizing.
this.updateSizeProperties();
// Restore scroll after a single/split mode
// transition (set in willUpdate).
if (this.scrollMgr.needsScrollRestore) {
this.scrollMgr.runScrollRestore(
this.scrollContainer,
this.shadowRoot,
this.expandedAlbumId,
this.updateComplete,
);
}
// Close dropdown and clear selection when
// the search term changes.
const currentTerm = this.searchCtrl.term;
if (currentTerm !== this.lastSearchTerm) {
this.lastSearchTerm = currentTerm;
this.closeDropdown();
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
}
// On zoom while dropdown is open, recompute
// the split (column count may change) and
// re-scroll after layout settles.
if (
cardSizeChanged &&
this.splitMode &&
this.expandedTracks.length > 0
) {
this.splitIndex =
this.scrollMgr.computeSplitIndex(
this.scrollContainer,
);
const sm = this.scrollMgr;
void (async () => {
await this.updateComplete;
await sm.awaitBeforeLayout(
this.shadowRoot,
);
await sm.scrollToShowDropdown(
this.scrollContainer,
this.shadowRoot,
);
})();
}
// Re-fetch when the store delivers fresh
// data after eager refetch on invalidation.
if (!this.externalAlbums) {
const cached =
this.libraryCtrl.cachedAlbums;
if (
cached !== null &&
cached !== this.lastAlbumsRef
) {
this.lastAlbumsRef = cached;
this.loadAlbums();
}
}
}
/* ====================================================================
* Dynamic size properties
* ==================================================================== */
private updateSizeProperties() {
const w = this.cardWidth;
this.style.setProperty(
'--card-width',
`${w}px`,
);
// Scale placeholder initial font.
const placeholderFont =
Math.max(16, Math.round(w * 0.3));
this.style.setProperty(
'--placeholder-font',
`${placeholderFont}px`,
);
// Text sizing tiers mapped to design tokens.
if (w < 160) {
this.classList.add('size-small');
this.style.setProperty(
'--album-name-font',
'var(--yj-text-xs)',
);
this.style.setProperty(
'--artist-name-font',
'10px',
);
} else if (w > 250) {
this.classList.remove('size-small');
this.style.setProperty(
'--album-name-font',
'var(--yj-text-lg)',
);
this.style.setProperty(
'--artist-name-font',
'var(--yj-text-md)',
);
} else {
this.classList.remove('size-small');
this.style.setProperty(
'--album-name-font',
'var(--yj-text-lg)',
);
this.style.setProperty(
'--artist-name-font',
'var(--yj-text-sm)',
);
}
}
/* ====================================================================
* Ctrl+Scroll zoom
* ==================================================================== */
private onWheel(e: WheelEvent) {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
this.libraryCtrl.coverSize =
this.cardWidth + delta;
}
/* ====================================================================
* Data loading
* ==================================================================== */
private async loadAlbums() {
try {
this.loading = true;
// When driven by an external album list,
// skip the backend fetch entirely.
const albums = this.externalAlbums
?? (await this.libraryCtrl.getAlbums())
?? [];
this.albums = albums;
this.selMgr.setAlbums(albums);
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
} catch (error) {
console.error(
'Error loading albums:',
error,
);
this.albums = [];
this.selMgr.setAlbums([]);
} finally {
this.loading = false;
}
await this.updateComplete;
this.scrollMgr.restoreScrollPosition(
this.virtualizerSingle,
);
this.scrollMgr.setupResizeObserver(
this.scrollContainer,
async () => {
this.splitIndex =
this.scrollMgr.computeSplitIndex(
this.scrollContainer,
);
this.requestUpdate();
await this.updateComplete;
await this.scrollMgr.awaitBeforeLayout(
this.shadowRoot,
);
await this.scrollMgr.scrollToShowDropdown(
this.scrollContainer,
this.shadowRoot,
);
},
);
}
/* ====================================================================
* Scroll event handler
* ==================================================================== */
private onVisibilityChanged = (
e: VisibilityChangedEvent,
) => {
const sm = this.scrollMgr;
const isSplit = this.splitMode;
sm.onVisibilityChanged(e.first, () =>
isSplit
? this.getBeforeEntries()
: this.buildGridEntries(),
);
};
/* ====================================================================
* Virtualizer items
* ==================================================================== */
/**
* Build a flat GridEntry array for the filtered
* albums. Memoized on the cachedFilteredAlbums
* reference — only allocates a new array when the
* underlying album list changes.
*/
private buildGridEntries(): GridEntry[] {
const filtered = this.cachedFilteredAlbums;
if (filtered === this.gridEntriesCacheKey) {
return this.gridEntriesCache;
}
const entries: GridEntry[] = [];
for (let i = 0; i < filtered.length; i++) {
entries.push({
album: filtered[i]!,
albumIndex: i,
});
}
this.gridEntriesCacheKey = filtered;
this.gridEntriesCache = entries;
return entries;
}
/** Rebuild before/after caches if the entries or splitIndex changed. */
private ensureSplitCache(): void {
const entries = this.buildGridEntries();
if (
entries === this.splitEntriesCacheKey &&
this.splitIndex === this.splitEntriesCacheIndex
) {
return;
}
this.splitEntriesCacheKey = entries;
this.splitEntriesCacheIndex = this.splitIndex;
this.beforeEntriesCache = entries.slice(0, this.splitIndex);
this.afterEntriesCache = entries.slice(this.splitIndex);
}
/** Entries for the "before" virtualizer (memoized). */
private getBeforeEntries(): GridEntry[] {
this.ensureSplitCache();
return this.beforeEntriesCache;
}
/** Entries for the "after" virtualizer (memoized). */
private getAfterEntries(): GridEntry[] {
this.ensureSplitCache();
return this.afterEntriesCache;
}
/* ====================================================================
* Dropdown (expand/collapse)
* ==================================================================== */
/** Close the dropdown if one is open. */
private closeDropdown() {
if (this.expandedAlbumId === null) return;
this.expandedAlbumId = null;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
}
/**
* Open (or switch to) the given album's
* dropdown. If the dropdown is already open
* for the same album this is a no-op.
*/
private async openDropdown(
album: library.Album,
) {
if (this.expandedAlbumId === album.ID) return;
this.expandedAlbumId = album.ID;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
try {
const tracks = await GetAlbumTracks(
album.ID,
);
if (this.expandedAlbumId === album.ID) {
this.expandedTracks = tracks;
}
} catch (error) {
console.error(
'Error loading album tracks:',
error,
);
}
}
/**
* Synchronise the dropdown to the current
* selection: open the sole selected album's
* dropdown, or close it when zero or many
* albums are selected.
*/
private syncDropdownToSelection() {
if (this.selectedAlbums.size === 1) {
const [albumId] = this.selectedAlbums;
const album = this.cachedFilteredAlbums.find(
(a) => a.ID === albumId,
);
if (album) {
void this.openDropdown(album);
}
} else {
this.closeDropdown();
}
}
/* ====================================================================
* Event delegation helpers
* ==================================================================== */
/**
* Walk up from the event target to find the nearest
* `.album-card` and read its `data-index` attribute.
* Returns `null` if the click was not on a card.
*/
private resolveAlbumFromEvent(
e: Event,
): { album: library.Album; index: number } | null {
const path = e.composedPath();
const filtered = this.cachedFilteredAlbums;
for (const el of path) {
if (
el instanceof HTMLElement &&
el.classList.contains('album-card')
) {
const raw = el.dataset['index'];
if (raw === undefined) return null;
const index = parseInt(raw, 10);
const album = filtered[index];
if (!album) return null;
return { album, index };
}
}
return null;
}
/* ====================================================================
* Delegated album event handlers
* ==================================================================== */
private onGridAlbumClick = (e: MouseEvent) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
const { album, index } = hit;
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (
isShift &&
this.lastSelectedAlbumIndex !== null
) {
const range = this.selMgr.selectAlbumRange(
this.lastSelectedAlbumIndex,
index,
this.cachedFilteredAlbums,
);
const next = new Set(this.selectedAlbums);
for (const id of range) {
next.add(id);
}
this.selectedAlbums = next;
this.syncDropdownToSelection();
void this.selMgr.warmCache(
this.selectedAlbums,
);
} else if (isCtrl) {
const next = new Set(this.selectedAlbums);
if (next.has(album.ID)) {
next.delete(album.ID);
} else {
next.add(album.ID);
}
this.selectedAlbums = next;
this.lastSelectedAlbumIndex = index;
this.syncDropdownToSelection();
void this.selMgr.warmCache(
this.selectedAlbums,
);
} else {
// Plain click: if this album is the
// sole selection, deselect + close.
// Otherwise select only this album
// and open its dropdown.
if (
this.selectedAlbums.size === 1 &&
this.selectedAlbums.has(album.ID)
) {
this.selectedAlbums = new Set();
this.closeDropdown();
} else {
this.selectedAlbums = new Set([
album.ID,
]);
void this.openDropdown(album);
}
this.lastSelectedAlbumIndex = index;
void this.selMgr.warmCache(
this.selectedAlbums,
);
}
};
private onGridAlbumDblClick = async (
e: MouseEvent,
) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
const filePaths =
await this.selMgr.getAlbumFilePaths(
hit.album,
);
if (filePaths.length === 0) return;
this.selectedAlbums = new Set();
this.closeDropdown();
queueStore.setQueue(filePaths, 0, true);
};
private onGridAlbumKeydown = (
e: KeyboardEvent,
) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
e.preventDefault();
const { album, index } = hit;
// Mirror plain-click behaviour: toggle
// sole selection, or select and open.
if (
this.selectedAlbums.size === 1 &&
this.selectedAlbums.has(album.ID)
) {
this.selectedAlbums = new Set();
this.closeDropdown();
} else {
this.selectedAlbums = new Set([album.ID]);
void this.openDropdown(album);
}
this.lastSelectedAlbumIndex = index;
void this.selMgr.warmCache(
this.selectedAlbums,
);
};
private onGridAlbumContextMenu = (
e: MouseEvent,
) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
e.preventDefault();
e.stopPropagation();
this.contextMenuAlbumId = hit.album.ID;
this.contextMenuTarget = { kind: 'album' };
this.ctxMenu.openAt(e.clientX, e.clientY);
};
/**
* Delegated image error handler — falls back from
* thumbnail to full-size cover art.
*/
private onGridImageError = (e: Event) => {
const img = e.target;
if (!(img instanceof HTMLImageElement)) return;
if (!img.classList.contains('cover-image')) {
return;
}
const card = img.closest('.album-card');
if (!card) return;
const raw = (card as HTMLElement).dataset[
'index'
];
if (raw === undefined) return;
const index = parseInt(raw, 10);
const album = this.cachedFilteredAlbums[index];
if (album && img.src !== album.CoverArtPath) {
img.src = album.CoverArtPath;
}
};
/* ====================================================================
* Track event handlers (from album-dropdown)
* ==================================================================== */
private onTrackClick = (
e: CustomEvent<TrackClickDetail>,
) => {
const {
track,
index,
ctrlKey,
shiftKey,
metaKey,
} = e.detail;
const isCtrl = ctrlKey || metaKey;
if (
shiftKey &&
this.lastSelectedTrackIndex !== null
) {
const range = this.selMgr.selectTrackRange(
this.lastSelectedTrackIndex,
index,
this.expandedTracks,
);
const next = new Set(this.selectedTracks);
for (const path of range) {
next.add(path);
}
this.selectedTracks = next;
} else if (isCtrl) {
const next = new Set(this.selectedTracks);
if (next.has(track.FilePath)) {
next.delete(track.FilePath);
} else {
next.add(track.FilePath);
}
this.selectedTracks = next;
this.lastSelectedTrackIndex = index;
} else {
this.selectedTracks = new Set([
track.FilePath,
]);
this.lastSelectedTrackIndex = index;
}
};
private onTrackDblClick = (
e: CustomEvent<TrackDblClickDetail>,
) => {
const { index } = e.detail;
// Play the full album starting from this track
const filePaths = this.expandedTracks.map(
(t) => t.FilePath,
);
if (filePaths.length === 0) return;
this.selectedTracks = new Set();
queueStore.setQueue(filePaths, index);
};
private onTrackContextMenu = (
e: CustomEvent<TrackContextMenuDetail>,
) => {
const { track, clientX, clientY } = e.detail;
if (!this.selectedTracks.has(track.FilePath)) {
this.selectedTracks = new Set([
track.FilePath,
]);
}
this.contextMenuTarget = { kind: 'track' };
this.ctxMenu.openAt(clientX, clientY);
};
/* ====================================================================
* Drag source (dropdown tracks)
* ==================================================================== */
private onTrackDragStart = (
e: CustomEvent<TrackDragStartDetail>,
) => {
const { track, dataTransfer } = e.detail;
let filePaths: string[];
if (this.selectedTracks.has(track.FilePath)) {
filePaths =
this.selMgr.getSelectedTrackFilePaths(
this.selectedTracks,
this.expandedTracks,
);
} else {
filePaths = [track.FilePath];
}
if (filePaths.length === 0) return;
if (dataTransfer) {
const payload: DragPayload = {
filePaths,
source: 'cover-grid',
};
dataTransfer.effectAllowed = 'copy';
dataTransfer.setData(
DRAG_MIME,
JSON.stringify(payload),
);
this.dragImageEl =
filePaths.length === 1
? createTrackCardDragImage(
track.TrackName,
track.ArtistName,
track.FilePath,
)
: createDragImage(
filePaths.length,
);
dataTransfer.setDragImage(
this.dragImageEl,
0,
0,
);
}
emitDragActive(true);
};
private onTrackDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
/* ====================================================================
* Drag source (album cards)
* ==================================================================== */
/**
* Pre-warm the file-path cache for the album under
* the pointer so that a subsequent dragstart (which
* is synchronous) can read the paths immediately.
*/
private onAlbumPointerDown = (e: PointerEvent) => {
// Only act on primary button (left-click).
if (e.button !== 0) return;
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
// Fire-and-forget: warm the cache entry.
void this.selMgr.warmSingleAlbum(hit.album);
};
private onAlbumDragStart = (e: DragEvent) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
// Read file paths synchronously from the
// pre-warmed cache. The cache is populated
// via pointerdown or selection-change warming.
let filePaths: string[];
let isSingleAlbum: boolean;
if (this.selectedAlbums.has(hit.album.ID)) {
// Dragged album is part of the selection —
// drag all selected albums' tracks.
filePaths =
this.selMgr.getCachedSelectedPaths(
this.selectedAlbums,
);
isSingleAlbum =
this.selectedAlbums.size === 1;
} else {
// Dragged album is not selected — clear
// selection and drag only this album.
this.selectedAlbums = new Set();
filePaths =
this.selMgr.getCachedAlbumPaths(
hit.album.ID,
) ?? [];
isSingleAlbum = true;
}
if (filePaths.length === 0) {
// Cache miss — cancel the drag.
e.preventDefault();
return;
}
setDragPayload(e, {
filePaths,
source: 'cover-grid',
});
// Single album: show cover art thumbnail.
// Multiple albums: show track-count badge.
if (isSingleAlbum && hit.album.CoverArtPath) {
this.dragImageEl =
createAlbumArtDragImage(
this.getCoverUrl(hit.album),
);
} else {
this.dragImageEl = createDragImage(
filePaths.length,
);
}
e.dataTransfer?.setDragImage(
this.dragImageEl,
0,
0,
);
emitDragActive(true);
};
private onAlbumDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
/* ====================================================================
* Grid click (empty area)
* ==================================================================== */
private onGridClick = (e: MouseEvent) => {
for (const el of e.composedPath()) {
if (!(el instanceof HTMLElement)) continue;
if (
el.classList.contains('album-card') ||
el.classList.contains('album-dropdown')
) {
return;
}
}
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
this.expandedAlbumId = null;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
};
/* ====================================================================
* Context menu actions
* ==================================================================== */
private async onContextMenuAction(action: string) {
let filePaths: string[];
if (this.contextMenuTarget.kind === 'track') {
filePaths =
this.selMgr.getSelectedTrackFilePaths(
this.selectedTracks,
this.expandedTracks,
);
} else {
filePaths =
await this.selMgr.getContextMenuAlbumFilePaths(
this.contextMenuAlbumId,
this.selectedAlbums,
);
}
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
queueStore.playTracksNext(filePaths);
break;
case 'track-details':
this.openTrackDetails(filePaths[0]!);
break;
}
this.clearContextMenuSelection();
this.ctxMenu.close();
}
private async onContextMenuFavoriteToggle() {
const filePaths =
await this.getPlaylistSubmenuFilePaths();
if (filePaths.length === 0) return;
if (this.favCtrl.allFavorited(filePaths)) {
void this.favCtrl.removeFromFavorites(
filePaths,
);
} else {
void this.favCtrl.addToFavorites(
filePaths,
);
}
this.clearContextMenuSelection();
this.ctxMenu.close();
}
/** Clear the selection that was active for the context menu. */
private clearContextMenuSelection() {
if (this.contextMenuTarget.kind === 'track') {
this.selectedTracks = new Set();
} else {
this.selectedAlbums = new Set();
}
}
private openTrackDetails(filePath: string) {
const track = this.expandedTracks.find(
(t) => t.FilePath === filePath,
);
if (!track) return;
const coverArt =
this.selMgr.resolveTrackCoverArt(
track.Album,
this.expandedAlbumId,
);
this.trackDetailsDialog?.show(
track,
coverArt ?? undefined,
);
}
/** Resolve file paths for the playlist submenu. */
private async getPlaylistSubmenuFilePaths(): Promise<
string[]
> {
if (this.contextMenuTarget.kind === 'track') {
return this.selMgr.getSelectedTrackFilePaths(
this.selectedTracks,
this.expandedTracks,
);
}
return this.selMgr.getContextMenuAlbumFilePaths(
this.contextMenuAlbumId,
this.selectedAlbums,
);
}
/* ====================================================================
* Render: sort toolbar
* ==================================================================== */
/** Render the sort toolbar above the grid. */
private renderSortToolbar() {
const activeOpt = ALBUM_SORT_OPTIONS.find(
(o) => o.id === this.sortField,
);
const label = activeOpt
? activeOpt.label
: 'Name';
const dirIcon =
this.sortDirection === 'asc'
? 'arrow-up-short-wide'
: 'arrow-down-wide-short';
return html`
<div class="sort-toolbar">
<span>Sort:</span>
<button
class="sort-anchor"
@click=${() =>
this.toggleSortDropdown()}
>
<span class="sort-label">
${label}
</span>
<wa-icon
name="chevron-down"
></wa-icon>
</button>
<button
class="sort-dir-btn"
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
@click=${() =>
this.toggleSortDirection()}
>
<wa-icon
name=${dirIcon}
></wa-icon>
</button>
${this.searchCtrl.term
? html`<div class="search-indicator">
Showing results for
&ldquo;${this.searchCtrl.term}&rdquo;
</div>`
: nothing}
</div>
${this.renderSortDropdownPopup()}
`;
}
/** Render the sort dropdown popup. */
private renderSortDropdownPopup() {
return html`
<wa-popup
id="sort-dropdown"
placement="bottom-start"
flip
shift
.active=${this.sortDropdownOpen}
>
${this.sortDropdownOpen
? html`
<div
class="sort-dropdown-panel"
>
${ALBUM_SORT_OPTIONS.map(
(opt) => html`
<wa-dropdown-item
class=${this.sortField === opt.id ? 'active-sort' : ''}
@click=${() =>
this.onSortDropdownSelect(
opt.id,
)}
>
${opt.label}
</wa-dropdown-item>
`,
)}
</div>
`
: nothing}
</wa-popup>
`;
}
/* ====================================================================
* Rendering helpers
* ==================================================================== */
private getAlbumInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
/**
* Pick the appropriate cover art URL based on the
* current card size and device pixel ratio.
*/
private getCoverUrl(album: library.Album): string {
const needed =
this.imageSize * window.devicePixelRatio;
if (needed <= 100) {
return (
album.CoverArtSmall ||
album.CoverArtMedium ||
album.CoverArtPath
);
}
if (needed <= 200) {
return (
album.CoverArtMedium ||
album.CoverArtLarge ||
album.CoverArtPath
);
}
if (needed <= 400) {
return (
album.CoverArtLarge ||
album.CoverArtPath
);
}
return album.CoverArtPath;
}
/* ====================================================================
* Render: grid entry (virtualizer renderItem)
* ==================================================================== */
private renderGridEntry = (
entry: GridEntry,
) => {
return this.renderAlbumCard(
entry.album,
entry.albumIndex,
);
};
/* ====================================================================
* Render: album card
*
* No per-card event listeners — events are delegated
* via data-index on the virtualizer.
* ==================================================================== */
private renderAlbumCard(
album: library.Album,
index: number,
) {
const selected = this.selectedAlbums.has(
album.ID,
);
const expanded =
this.expandedAlbumId === album.ID;
const classes = [
'album-card',
selected ? 'selected' : '',
expanded ? 'expanded' : '',
]
.filter(Boolean)
.join(' ');
const imgSize = this.imageSize;
return html`
<div
class=${classes}
tabindex="0"
role="button"
data-index=${index}
aria-label="${album.Name} by ${album.ArtistName}"
draggable="true"
@pointerdown=${this.onAlbumPointerDown}
@dragstart=${this.onAlbumDragStart}
@dragend=${this.onAlbumDragEnd}
>
<div class="cover-container">
${album.CoverArtPath
? html`<img
class="cover-image"
src="${this.getCoverUrl(album)}"
alt="${album.Name} cover"
width="${imgSize}"
height="${imgSize}"
loading="lazy"
decoding="async"
/>`
: html`<div
class="placeholder-cover"
>
${this.getAlbumInitial(album.Name)}
</div>`}
</div>
<div class="album-info">
<div
class="album-name"
title="${album.Name}"
>
${album.Name}${album.Year
? html`
<span class="album-year">
(${album.Year})</span
>`
: nothing}
</div>
<div
class="artist-name"
title="${album.ArtistName}"
>
${album.ArtistName}
</div>
</div>
</div>
`;
}
/* ====================================================================
* Render: main
* ==================================================================== */
override render() {
if (this.loading) {
return html`<div class="loading">
Loading albums...
</div>`;
}
if (this.albums.length === 0) {
return html`
<div class="empty-state">
<p>No albums found</p>
<p>
Add music to your library to see
album covers here.
</p>
</div>
`;
}
if (this.cachedFilteredAlbums.length === 0) {
return html`
${this.renderSortToolbar()}
<div class="empty-state">
<p>No albums match your search.</p>
</div>
`;
}
const gridContent = this.splitMode
? this.renderSplitGrid()
: this.renderSingleGrid();
return html`
${this.renderSortToolbar()}
<div
class="grid-scroll-container"
@click=${this.onGridClick}
>
${gridContent}
</div>
${this.renderContextMenu()}
`;
}
/** Single virtualizer — no dropdown open. */
private renderSingleGrid() {
return html`
<lit-virtualizer
id="grid-single"
.items=${this.buildGridEntries()}
.renderItem=${this.renderGridEntry}
.keyFunction=${(entry: GridEntry) => entry.album.ID}
.layout=${this.gridLayout}
@click=${this.onGridAlbumClick}
@dblclick=${this.onGridAlbumDblClick}
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer>
`;
}
/**
* Dual virtualizer — dropdown sandwiched between
* "before" and "after" grids.
*/
private renderSplitGrid() {
const sm = this.scrollMgr;
const ctr = this.scrollContainer;
const containerW = sm.getContainerWidth(ctr);
const rowW = sm.getGridRowWidth(ctr);
const afterEntries = this.getAfterEntries();
return html`
<lit-virtualizer
id="grid-before"
.items=${this.getBeforeEntries()}
.renderItem=${this.renderGridEntry}
.keyFunction=${(entry: GridEntry) => entry.album.ID}
.layout=${this.gridLayout}
@click=${this.onGridAlbumClick}
@dblclick=${this.onGridAlbumDblClick}
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer>
<album-dropdown
.tracks=${this.expandedTracks}
.selectedTracks=${this.selectedTracks}
.containerWidth=${containerW}
.gridRowWidth=${rowW}
.caratOffset=${sm.getCaratOffset(ctr)}
style="margin-left:${(containerW - rowW) / 2}px"
@track-click=${this.onTrackClick}
@track-dblclick=${this.onTrackDblClick}
@track-contextmenu=${this.onTrackContextMenu}
@track-dragstart=${this.onTrackDragStart}
@track-dragend=${this.onTrackDragEnd}
></album-dropdown>
${afterEntries.length > 0
? html`
<lit-virtualizer
id="grid-after"
.items=${afterEntries}
.renderItem=${this.renderGridEntry}
.keyFunction=${(entry: GridEntry) => entry.album.ID}
.layout=${this.gridLayoutAfter}
@click=${this.onGridAlbumClick}
@dblclick=${this.onGridAlbumDblClick}
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
></lit-virtualizer>
`
: nothing}
`;
}
/** Context menu + playlist submenu popups. */
/** Trigger playlist submenu with resolved file paths. */
private async handleShowPlaylistSubmenu() {
const filePaths =
await this.getPlaylistSubmenuFilePaths();
void this.ctxMenu.showPlaylistSubmenu(
filePaths,
);
}
private renderContextMenu() {
const { ctxMenu } = this;
return html`
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${ctxMenu.contextMenuOpen}
>
${ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
@mouseenter=${() =>
ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
@mouseenter=${() =>
ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
@mouseenter=${() =>
ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
ctxMenu.clearSubmenuCloseTimer();
void this.handleShowPlaylistSubmenu();
}}
@mouseleave=${ctxMenu
.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.handleShowPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>&#9654;</span
>
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuFavoriteToggle()}
@mouseenter=${() =>
ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name=${this.favCtrl.iconName}
></wa-icon>
${this.favCtrl.allFavorited(this.ctxMenu.playlistFilePaths) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
</wa-dropdown-item>
${this.contextMenuTarget
.kind ===
'track' &&
this.selectedTracks
.size === 1
? html`
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="circle-info"
></wa-icon>
Track
Details
</wa-dropdown-item>
`
: nothing}
</div>
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${ctxMenu.playlistSubmenuOpen}
>
${ctxMenu.playlistSubmenuOpen
? html`
<div
@mouseenter=${() =>
ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${ctxMenu
.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${ctxMenu
.playlistFilePaths}
@playlist-action-complete=${ctxMenu
.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
</div>
`
: nothing}
</wa-popup>
<track-details></track-details>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'cover-grid': CoverGrid;
}
}