Files
yellowjacket/frontend/src/components/cover-grid/cover-grid.ts
T

3074 lines
91 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
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 { queueStore } from '@store/queue-store';
import { Events } from '../../events';
import '@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 type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.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 {
createAlbumArtDragImage,
createDragImage,
removeDragImage,
} from '@utils/drag-image';
/**
* Discriminated context menu target so we know whether the
* context-menu is operating on albums or on tracks inside the
* dropdown.
*/
type ContextMenuTarget =
| { kind: 'album' }
| { kind: 'track' };
/**
* Item for the virtualized grid.
* Carries the original album and its index in this.albums.
*/
interface GridEntry {
album: library.Album;
albumIndex: number;
}
/** Milliseconds to debounce visibility-changed saves. */
const SCROLL_DEBOUNCE_MS = 100;
/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;
@customElement('cover-grid')
export class CoverGrid extends LitElement {
private libraryCtrl = new LibraryController(this);
private cancelScanComplete?: () => void;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private lastSelectedAlbumIndex: number | null = null;
private lastSelectedTrackIndex: number | null = null;
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
private closeHandler = () => this.closeContextMenu();
/**
* When true, the next split→single transition
* skips the expensive overlay capture and scroll
* restore. Used by the select-toggle checkbox
* so closing the dropdown is instant.
*/
private skipOverlay = false;
/** Current card width — driven by the store. */
private 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. */
private 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;
/**
* Pre-resolved file paths for selected albums, keyed by album ID.
* Populated asynchronously when albums are selected so that
* dragstart can read them synchronously.
*/
private albumFilePathCache = new Map<
number,
string[]
>();
/** Wheel event handler ref for manual add/remove. */
private wheelHandler = (e: WheelEvent) => {
this.onWheel(e);
};
private wheelListenerAttached = false;
// buildGridEntries() memoization cache.
static override styles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.grid-scroll-container {
flex: 1;
position: relative;
overflow-y: auto;
}
/* ========================================
* Album card
* ======================================== */
.album-card {
display: flex;
flex-direction: column;
cursor: pointer;
border-radius: 8px;
padding: 5px;
transition: background-color 0.2s ease;
box-sizing: border-box;
width: var(--card-width, 176px);
}
.album-card:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.1));
}
.album-card.selected {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.album-card.expanded {
background-color: var(--yj-bg-elevated, #343a40);
border-radius: 8px 8px 0 0;
}
.album-card:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.cover-container {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background-color: var(--yj-bg-surface, #282828);
transition: scale 0.15s ease;
}
.album-card.selected .cover-container {
scale: 0.95;
}
/* ========================================
* Selection checkbox overlay
* ======================================== */
.select-toggle {
position: absolute;
top: 6px;
left: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.8);
background-color: rgba(0, 0, 0, 0.4);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.album-card:hover .select-toggle {
opacity: 1;
}
.select-toggle.checked {
opacity: 1;
background-color: var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
}
.check-icon {
color: #000;
font-size: 14px;
font-weight: bold;
line-height: 1;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
}
.placeholder-cover {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--placeholder-font, 48px);
}
.album-info {
margin-top: 4px;
min-width: 0;
text-align: center;
transition: scale 0.15s ease;
}
.album-card.selected .album-info {
scale: 0.95;
}
.album-name {
font-size: var(--album-name-font, 14px);
font-weight: 400;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-name {
font-size: var(--artist-name-font, 12px);
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.album-year {
color: var(--yj-text-tertiary, #888);
}
/* ========================================
* Shared states
* ======================================== */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
color: var(--yj-text-secondary, #b3b3b3);
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
}
.empty-state p {
margin: 8px 0;
}
/* ========================================
* Context menu
* ======================================== */
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
}
.context-menu-panel wa-dropdown-item {
--wa-color-text-normal: var(--yj-text-primary, #fff);
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.submenu-item {
position: relative;
}
.submenu-arrow {
font-size: 10px;
margin-left: auto;
padding-left: 12px;
}
#playlist-submenu {
z-index: 210;
}
`;
/* ====================================================================
* Reactive state
* ==================================================================== */
@state()
private albums: library.Album[] = [];
@state()
private loading = true;
@state()
private contextMenuOpen = false;
@state()
private contextMenuTarget: ContextMenuTarget = {
kind: 'album',
};
@state()
private selectedAlbums: Set<number> = new Set();
@state()
private playlistSubmenuOpen = false;
@state()
private playlistFilePaths: string[] = [];
/** ID of the album whose dropdown is currently open, or null. */
@state()
private expandedAlbumId: number | null = null;
/** Tracks loaded for the expanded album dropdown. */
@state()
private 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()
private splitMode = false;
/**
* Index into this.albums where the split occurs.
* Albums [0, splitIndex) go into the "before"
* virtualizer; [splitIndex, length) go into "after".
*/
@state()
private splitIndex = 0;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
@query('#grid-single')
private virtualizerSingle!: LitVirtualizer;
@query('.grid-scroll-container')
private scrollContainer!: HTMLElement;
// Resize-aware scroll preservation.
private resizeObserver: ResizeObserver | null = null;
private resizeDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
private pendingFocus: {
albumIndex: number;
viewportOffset: number;
} | null = null;
private currentColumnCount = 0;
private isResizing = false;
// Scroll restoration across single/split mode
// transitions.
private savedScrollTop = 0;
private needsScrollRestore = false;
private showDropdownAfterRestore = false;
/**
* Monotonically increasing counter used to
* cancel stale scroll-restore async blocks.
* Each new restore bumps the generation; the
* async block bails out when it detects it is
* no longer current.
*/
private scrollRestoreGeneration = 0;
/**
* Set to the generation value when an async
* scroll-restore block finishes or is cancelled.
* When scrollRestoreGeneration >
* scrollRestoreResolved, an async restore is
* still in flight and the DOM scrollTop may be
* unreliable.
*/
private scrollRestoreResolved = 0;
/**
* When switching albums, the pixel distance from
* the newly-expanded album's top edge to the
* viewport top — computed in single-mode
* coordinates during exit-split. Used by the
* enter-split restore to place the album at the
* same visual position before scrollToShowDropdown
* makes any further adjustments.
*/
private savedAlbumViewportOffset: number | null =
null;
/** Overlay element showing the old grid state
* while a mode transition is in flight. */
private transitionOverlay: HTMLDivElement | null =
null;
/* ====================================================================
* Lifecycle
* ==================================================================== */
override connectedCallback() {
super.connectedCallback();
this.loadAlbums();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadAlbums(),
);
document.addEventListener(
'click',
this.closeHandler,
);
document.addEventListener(
'contextmenu',
this.closeHandler,
);
// error events do not bubble — use capture
// phase to catch <img> load failures.
this.addEventListener(
'error',
this.onGridImageError,
true,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cancelScanComplete?.();
document.removeEventListener(
'click',
this.closeHandler,
);
document.removeEventListener(
'contextmenu',
this.closeHandler,
);
this.removeEventListener(
'error',
this.onGridImageError,
true,
);
this.scrollContainer?.removeEventListener(
'wheel',
this.wheelHandler,
);
this.wheelListenerAttached = false;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
if (this.resizeDebounceTimer !== null) {
clearTimeout(this.resizeDebounceTimer);
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.removeOverlay();
}
override willUpdate(
changed: Map<PropertyKey, unknown>,
) {
super.willUpdate(changed);
// 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
) {
if (this.skipOverlay) {
// Lightweight exit: skip the
// expensive overlay capture but
// still restore scroll position
// since the DOM restructure
// (split → single virtualizer)
// resets scrollTop.
this.skipOverlay = false;
this.savedScrollTop =
this.computeAdjustedScrollTop();
this.savedAlbumViewportOffset = null;
this.splitMode = false;
this.needsScrollRestore = true;
this.showDropdownAfterRestore = false;
} else {
// Capture the raw split-mode scrollTop
// before converting to single-mode coords.
const rawScrollTop =
this.scrollContainer?.scrollTop ??
0;
// Convert to dropdown-free coordinates
// before exiting split mode.
this.savedScrollTop =
this.computeAdjustedScrollTop();
// If switching to a new album (not
// closing), record the viewport offset
// of the newly-expanded album in the
// OLD split layout so the enter-split
// restore can place it at the same
// visual position.
if (this.expandedAlbumId !== null) {
const idx = this.albums.findIndex(
(a) =>
a.ID ===
this.expandedAlbumId,
);
if (idx >= 0) {
const gap =
CoverGrid.GRID_GAP;
const pad =
CoverGrid.GRID_PADDING;
const cols =
this.getColumnCount();
const rowStep =
this.cardHeight + gap;
const row = Math.floor(
idx / cols,
);
// Album's Y in single-mode
// (no dropdown) coordinates.
const albumY =
pad + row * rowStep;
// In the old split layout
// the dropdown shifts
// everything below it.
const oldBeforeRows =
Math.ceil(
this.splitIndex /
cols,
);
const oldDropdownTop =
pad +
oldBeforeRows * rowStep;
const dropdown =
this.shadowRoot?.querySelector(
'album-dropdown',
);
const oldDropdownHeight =
(
dropdown as HTMLElement
)?.offsetHeight ?? 0;
const albumYOldSplit =
albumY >= oldDropdownTop
? albumY +
oldDropdownHeight
: albumY;
// Viewport offset in
// old-split coordinates.
this.savedAlbumViewportOffset =
albumYOldSplit -
rawScrollTop;
console.log(
'[willUpdate] anchor capture',
{
albumY,
oldDropdownTop,
oldDropdownHeight,
albumYOldSplit,
rawScrollTop,
offset: this
.savedAlbumViewportOffset,
},
);
}
} else {
this.savedAlbumViewportOffset =
null;
}
console.log(
'[willUpdate] exit split (tracks empty)',
{
savedScrollTop:
this.savedScrollTop,
savedAlbumViewportOffset:
this
.savedAlbumViewportOffset,
expandedAlbumId:
this.expandedAlbumId,
},
);
this.captureOverlay();
this.splitMode = false;
this.needsScrollRestore = true;
this.showDropdownAfterRestore = false;
}
}
// Enter split mode when tracks have loaded.
if (
changed.has('expandedTracks') &&
this.expandedAlbumId !== null &&
this.expandedTracks.length > 0
) {
// If a restore is already in flight
// (switching albums), keep the saved
// value — the DOM scrollTop may still
// be clamped to 0 because the previous
// restore hasn't finished.
const restoreInFlight =
this.scrollRestoreGeneration >
this.scrollRestoreResolved;
if (!restoreInFlight) {
this.savedScrollTop =
this.scrollContainer
?.scrollTop ?? 0;
}
console.log(
'[willUpdate] enter split',
{
savedScrollTop:
this.savedScrollTop,
restoreInFlight,
expandedAlbumId:
this.expandedAlbumId,
splitIndex: this.splitIndex,
scrollHeight:
this.scrollContainer
?.scrollHeight,
},
);
this.captureOverlay();
this.computeSplitIndex();
this.splitMode = true;
this.needsScrollRestore = true;
this.showDropdownAfterRestore = true;
}
// Exit split mode when the dropdown closes.
if (
changed.has('expandedAlbumId') &&
this.expandedAlbumId === null &&
this.splitMode
) {
// Convert to dropdown-free coordinates
// before exiting split mode.
this.savedScrollTop =
this.computeAdjustedScrollTop();
this.savedAlbumViewportOffset = null;
console.log(
'[willUpdate] exit split (close)',
{
savedScrollTop:
this.savedScrollTop,
},
);
this.captureOverlay();
this.splitMode = false;
this.needsScrollRestore = true;
this.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). Uses a
// retry loop (restoreScrollTop) so the scroll
// position is applied reliably even if the
// virtualizer hasn't expanded its host height
// yet.
if (this.needsScrollRestore) {
this.needsScrollRestore = false;
const saved = this.savedScrollTop;
const showDropdown =
this.showDropdownAfterRestore;
// Capture whether an album is still
// selected — if so and !showDropdown, we
// are in the brief split→single gap while
// new tracks load (album switch). Keep
// the overlay visible until the new split
// view is ready.
const switching =
!showDropdown &&
this.expandedAlbumId !== null;
// Bump the generation so any in-flight
// async restore from a previous cycle
// will bail out.
const gen =
++this.scrollRestoreGeneration;
console.log(
'[updated] scroll restore start',
{
saved,
showDropdown,
switching,
gen,
},
);
void (async () => {
await this.updateComplete;
if (
gen !==
this.scrollRestoreGeneration
) {
console.log(
`[updated] gen ${gen} stale, aborting`,
);
this.scrollRestoreResolved = gen;
return;
}
console.log(
'[updated] after updateComplete',
{
scrollTop:
this.scrollContainer
?.scrollTop,
scrollHeight:
this.scrollContainer
?.scrollHeight,
gen,
},
);
await this.restoreScrollTop(saved);
if (
gen !==
this.scrollRestoreGeneration
) {
this.scrollRestoreResolved = gen;
return;
}
if (showDropdown) {
// When switching albums, anchor
// the scroll so the newly-expanded
// album stays at the same viewport
// position it occupied before the
// old dropdown was removed.
if (
this.savedAlbumViewportOffset !==
null &&
this.expandedAlbumId !== null
) {
const idx =
this.albums.findIndex(
(a) =>
a.ID ===
this
.expandedAlbumId,
);
if (idx >= 0) {
const gap =
CoverGrid.GRID_GAP;
const pad =
CoverGrid.GRID_PADDING;
const cols =
this.getColumnCount();
const rowStep =
this.cardHeight + gap;
const row = Math.floor(
idx / cols,
);
const albumY =
pad + row * rowStep;
const anchor =
albumY -
this
.savedAlbumViewportOffset;
console.log(
'[updated] anchor restore',
{
albumY,
offset: this
.savedAlbumViewportOffset,
anchor,
},
);
await this.restoreScrollTop(
anchor,
);
}
this.savedAlbumViewportOffset =
null;
}
if (
gen !==
this.scrollRestoreGeneration
) {
this.scrollRestoreResolved =
gen;
return;
}
await this.scrollToShowDropdown();
}
if (
gen !==
this.scrollRestoreGeneration
) {
this.scrollRestoreResolved = gen;
return;
}
if (!switching) {
console.log(
'[updated] removing overlay',
);
this.removeOverlay();
} else {
console.log(
'[updated] keeping overlay (switching)',
);
}
this.scrollRestoreResolved = gen;
})();
}
// 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.computeSplitIndex();
void (async () => {
await this.updateComplete;
await this.awaitBeforeLayout();
await this.scrollToShowDropdown();
})();
}
}
/* ====================================================================
* 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.
if (w < 160) {
this.classList.add('size-small');
this.style.setProperty(
'--album-name-font',
'11px',
);
this.style.setProperty(
'--artist-name-font',
'10px',
);
} else if (w > 250) {
this.classList.remove('size-small');
this.style.setProperty(
'--album-name-font',
'16px',
);
this.style.setProperty(
'--artist-name-font',
'13px',
);
} else {
this.classList.remove('size-small');
this.style.setProperty(
'--album-name-font',
'14px',
);
this.style.setProperty(
'--artist-name-font',
'12px',
);
}
}
/* ====================================================================
* 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;
const albums =
await this.libraryCtrl.getAlbums();
this.albums = albums ?? [];
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
} catch (error) {
console.error(
'Error loading albums:',
error,
);
this.albums = [];
} finally {
this.loading = false;
}
await this.updateComplete;
this.restoreScrollPosition();
this.setupResizeObserver();
}
/* ====================================================================
* Scroll position (index-based)
* ==================================================================== */
private restoreScrollPosition() {
const saved =
this.libraryCtrl.getScrollPosition('albums');
if (saved <= 0 || !this.virtualizerSingle) {
return;
}
const safeIndex = Math.min(
saved,
this.albums.length - 1,
);
if (safeIndex <= 0) return;
this.virtualizerSingle.scrollToIndex(
safeIndex,
'start',
);
}
/**
* Save scroll position from the first visible
* album. In split mode we compute the index from
* scrollTop; in single mode we use the virtualizer
* visibilityChanged event data.
*/
private onVisibilityChanged = (
e: VisibilityChangedEvent,
) => {
// Skip saves while a resize reflow is in
// progress — the virtualizer reports
// intermediate positions that would overwrite
// the real scroll position in the store.
if (this.isResizing) return;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
this.scrollDebounceTimer = setTimeout(() => {
if (this.splitMode) {
// In split mode the event indices are
// relative to the before-virtualizer.
// Save the album index directly.
const entries =
this.getBeforeEntries();
const first = entries[e.first];
if (first) {
this.libraryCtrl.setScrollPosition(
'albums',
first.albumIndex,
);
}
} else {
const entries =
this.buildGridEntries();
const first = entries[e.first];
if (first) {
this.libraryCtrl.setScrollPosition(
'albums',
first.albumIndex,
);
}
}
}, SCROLL_DEBOUNCE_MS);
};
/* ====================================================================
* Resize-aware scroll preservation
*
* When the container width changes (queue panel
* open/close, window resize) the grid reflows and
* the pixel scroll position becomes stale.
*
* We identify the album at the viewport center
* before the resize, then after the reflow we
* place that same album back at the same viewport
* offset. Integer album indices ensure zero
* scroll creep across repeated open/close cycles.
*
* If a dropdown is open the expanded album is the
* focus; otherwise the album at the viewport center
* is used.
* ==================================================================== */
private setupResizeObserver() {
const container = this.scrollContainer;
if (!container) return;
// Guard against stacked observers from
// repeated calls (e.g. library re-scan).
this.resizeObserver?.disconnect();
this.currentColumnCount =
this.getColumnCount();
/** Restore scroll so the focus album stays
* at the same viewport offset after reflow. */
const restoreScroll = () => {
const pending = this.pendingFocus;
this.pendingFocus = null;
this.isResizing = false;
if (!pending) return;
const newColumns = this.getColumnCount();
this.currentColumnCount = newColumns;
// If a dropdown is open, recompute the
// split and re-evaluate scroll.
if (
this.splitMode &&
this.expandedAlbumId !== null
) {
this.computeSplitIndex();
this.requestUpdate();
void (async () => {
await this.updateComplete;
await this.awaitBeforeLayout();
await this.scrollToShowDropdown();
})();
return;
}
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const rowStep =
this.cardHeight + gap;
// Derive the album's row under the new
// column count. Both albumIndex and
// newColumns are integers, so newRow is
// also an integer — no fractional drift.
const newRow = Math.floor(
pending.albumIndex / newColumns,
);
const newY =
pad + newRow * rowStep;
container.scrollTop =
newY - pending.viewportOffset;
};
this.resizeObserver = new ResizeObserver(
() => {
const rowStep =
this.cardHeight + CoverGrid.GRID_GAP;
// Capture on the first event using
// the pre-resize column count.
if (this.pendingFocus === null) {
this.isResizing = true;
this.captureFocusPoint(
container,
rowStep,
);
}
const newColumns =
this.getColumnCount();
if (
newColumns !==
this.currentColumnCount
) {
// Column count changed — correct
// scroll immediately.
if (
this.resizeDebounceTimer !==
null
) {
clearTimeout(
this.resizeDebounceTimer,
);
this.resizeDebounceTimer =
null;
}
restoreScroll();
return;
}
// Same column count — debounce for a
// final adjustment once resizing settles.
if (
this.resizeDebounceTimer !== null
) {
clearTimeout(
this.resizeDebounceTimer,
);
}
this.resizeDebounceTimer = setTimeout(
restoreScroll,
100,
);
},
);
this.resizeObserver.observe(container);
}
/**
* Determine the focus point for scroll restoration.
* If a dropdown is open, the expanded album is the
* focus and its current viewport offset is preserved.
* Otherwise the album at the viewport center is used.
*
* Stores an integer album index and the pixel offset
* from that album's top edge to the viewport top.
* Integer indices ensure zero drift across repeated
* open/close cycles (no fractional accumulation).
*/
private captureFocusPoint(
container: HTMLElement,
rowStep: number,
) {
const pad = CoverGrid.GRID_PADDING;
const cols = this.currentColumnCount;
// Prefer the expanded album as focus.
if (this.expandedAlbumId !== null) {
const idx = this.albums.findIndex(
(a) => a.ID === this.expandedAlbumId,
);
if (idx >= 0) {
const albumRow = Math.floor(
idx / cols,
);
const albumY =
pad + albumRow * rowStep;
this.pendingFocus = {
albumIndex: idx,
viewportOffset:
albumY - container.scrollTop,
};
return;
}
}
// Fall back to the album whose row contains
// the viewport center.
const centerY =
container.scrollTop +
container.clientHeight / 2;
const centerRow = Math.floor(
Math.max(0, centerY - pad) /
rowStep,
);
const albumIndex = Math.min(
centerRow * cols,
Math.max(0, this.albums.length - 1),
);
// Pixel offset from that album's top edge
// to the viewport top — used exactly once in
// restoreScroll, never fed back.
const albumY =
pad + centerRow * rowStep;
this.pendingFocus = {
albumIndex,
viewportOffset:
albumY - container.scrollTop,
};
}
/* ====================================================================
* Column count helper
* ==================================================================== */
private getColumnCount(): number {
const el =
this.scrollContainer ??
this.virtualizerSingle;
if (!el) return 1;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const availableWidth =
el.clientWidth - pad * 2;
return Math.max(
1,
Math.floor(
(availableWidth + gap) /
(this.cardWidth + gap),
),
);
}
/** Container width in pixels for the dropdown. */
private getContainerWidth(): number {
const el =
this.scrollContainer ??
this.virtualizerSingle;
return el?.clientWidth ?? 800;
}
/**
* Width of the album row: from the left edge of
* the leftmost card to the right edge of the
* rightmost card, including card padding but not
* the outer grid padding.
*/
private getGridRowWidth(): number {
const cols = this.getColumnCount();
const gap = CoverGrid.GRID_GAP;
return (
cols * this.cardWidth +
(cols - 1) * gap
);
}
/**
* Horizontal offset of the carat (in pixels from
* the left edge of the dropdown) so that it points
* at the center of the expanded album card.
*/
private getCaratOffset(): number {
if (this.expandedAlbumId === null) return 0;
const idx = this.albums.findIndex(
(a) => a.ID === this.expandedAlbumId,
);
if (idx < 0) return 0;
const cols = this.getColumnCount();
const colIndex = idx % cols;
const gap = CoverGrid.GRID_GAP;
return (
colIndex * (this.cardWidth + gap) +
this.cardWidth / 2
);
}
/* ====================================================================
* Split-mode helpers
*
* When the dropdown is open the album grid is split
* into two virtualizers with the dropdown in between.
* This avoids phantom rows and lets the dropdown size
* itself to its content exactly.
* ==================================================================== */
/**
* Compute the split point: all albums up to and
* including the expanded album's row go into the
* "before" virtualizer; the rest go into "after".
*/
private computeSplitIndex() {
if (this.expandedAlbumId === null) {
this.splitIndex = this.albums.length;
return;
}
const columns = this.getColumnCount();
const expandedIndex = this.albums.findIndex(
(a) => a.ID === this.expandedAlbumId,
);
if (expandedIndex < 0) {
this.splitIndex = this.albums.length;
return;
}
this.splitIndex = Math.min(
(Math.floor(expandedIndex / columns) +
1) *
columns,
this.albums.length,
);
}
/* ====================================================================
* Virtualizer items
* ==================================================================== */
/**
* Build a flat GridEntry array for a given album
* slice. Always returns a new array so the
* virtualizer re-renders visible items when
* component state (e.g. selectedAlbums) changes.
*/
private buildGridEntries(): GridEntry[] {
const entries: GridEntry[] = [];
for (let i = 0; i < this.albums.length; i++) {
entries.push({
album: this.albums[i]!,
albumIndex: i,
});
}
return entries;
}
/** Entries for the "before" virtualizer. */
private getBeforeEntries(): GridEntry[] {
return this.buildGridEntries().slice(
0,
this.splitIndex,
);
}
/** Entries for the "after" virtualizer. */
private getAfterEntries(): GridEntry[] {
return this.buildGridEntries().slice(
this.splitIndex,
);
}
private gridKeyFunction = (
entry: GridEntry,
) => {
return `a-${entry.album.ID}`;
};
/* ====================================================================
* Transition overlay
*
* Before a single/split mode switch we clone the
* scroll container into an absolutely-positioned
* overlay so the old visual state stays on-screen
* while the new layout computes underneath
* (hidden). Once the new layout is ready and
* scroll is restored we remove the overlay and
* reveal the real container in one paint frame.
* ==================================================================== */
/**
* Capture the current scroll container as a
* static overlay so the user keeps seeing the old
* state while the DOM switches underneath.
*/
private captureOverlay() {
const container = this.scrollContainer;
if (!container || this.transitionOverlay) {
return;
}
const scrollY = container.scrollTop;
const overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute;inset:0;z-index:10;' +
'overflow:hidden;pointer-events:none;';
// Clone each child into a wrapper that
// reproduces the scroll viewport.
const inner = document.createElement('div');
inner.style.cssText =
'position:relative;height:100%;' +
'pointer-events:none;';
for (const child of Array.from(
container.childNodes,
)) {
inner.appendChild(child.cloneNode(true));
}
// Shift content up to match the current
// scroll offset.
inner.style.transform =
`translateY(-${scrollY}px)`;
overlay.appendChild(inner);
// Append to :host (shadow root), not inside
// the scroll container, so Lit's diffing does
// not touch it.
this.shadowRoot?.appendChild(overlay);
this.transitionOverlay = overlay;
// Hide the real container while the new
// layout settles.
container.style.visibility = 'hidden';
}
/**
* Remove the snapshot overlay and reveal the real
* scroll container. Both happen synchronously so
* they land in the same paint frame.
*/
private removeOverlay() {
if (this.transitionOverlay) {
this.transitionOverlay.remove();
this.transitionOverlay = null;
}
if (this.scrollContainer) {
this.scrollContainer.style.visibility = '';
}
}
/* ====================================================================
* Dropdown scroll positioning
*
* In split mode the dropdown is a normal-flow DOM
* element between two virtualizers. We query its
* position from the DOM.
* ==================================================================== */
/**
* Wait for the "before" virtualizer to finish its
* layout pass so that its host element height
* reflects the total content size. Without this,
* setting scrollTop can be silently clamped to 0
* because the scroll container hasn't grown yet.
*/
private async awaitBeforeLayout(): Promise<void> {
const virt =
this.shadowRoot?.querySelector(
'#grid-before',
) as LitVirtualizer | null;
await virt?.layoutComplete;
}
/**
* Return the current scrollTop converted to
* single-mode (dropdown-free) coordinates.
*
* In split mode the open dropdown shifts all
* content below it downward. When we save a
* scroll position for later restoration in a
* different layout we need to remove that shift
* so the saved value is layout-agnostic.
*/
private computeAdjustedScrollTop(): number {
const container = this.scrollContainer;
if (!container) return 0;
const raw = container.scrollTop;
if (!this.splitMode) return raw;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const columns = this.getColumnCount();
const rowStep = this.cardHeight + gap;
const beforeRows = Math.ceil(
this.splitIndex / columns,
);
// Position where the dropdown starts in
// the split layout (scroll-content coords).
const dropdownTop =
pad + beforeRows * rowStep;
if (raw <= dropdownTop) {
console.log(
'[adjustScroll] raw <= dropdownTop, no adjust',
{ raw, dropdownTop },
);
return raw;
}
const dropdown =
this.shadowRoot?.querySelector(
'album-dropdown',
);
const dropdownHeight =
(dropdown as HTMLElement)?.offsetHeight ??
0;
const adjusted = raw - dropdownHeight;
console.log(
'[adjustScroll]',
{
raw,
dropdownTop,
dropdownHeight,
adjusted,
},
);
return adjusted;
}
/**
* Set scrollTop on the scroll container and verify
* the browser didn't silently clamp it. If the
* virtualizer hasn't expanded its host height yet,
* scrollTop will be clamped to a smaller value.
* In that case, wait one animation frame (giving
* the virtualizer time to size itself) and retry.
*/
private async restoreScrollTop(
target: number,
): Promise<void> {
const container = this.scrollContainer;
if (!container) return;
const maxAttempts = 10;
for (let i = 0; i < maxAttempts; i++) {
container.scrollTop = target;
console.log(
`[restoreScrollTop] attempt ${i}`,
{
target,
actual: container.scrollTop,
scrollHeight:
container.scrollHeight,
clientHeight:
container.clientHeight,
},
);
// Success if the browser accepted the
// value, or the target is at/below zero.
if (
container.scrollTop >= target ||
target <= 0
) {
return;
}
// Content hasn't expanded enough yet —
// wait one frame and retry.
await new Promise<void>((r) =>
requestAnimationFrame(() => r()),
);
}
console.warn(
'[restoreScrollTop] gave up after max attempts',
{
target,
actual: container.scrollTop,
scrollHeight: container.scrollHeight,
},
);
}
/**
* Scroll the container so the expanded album card
* and its dropdown are visible, using minimal
* movement:
*
* 1. If both fit in the viewport already, don't
* scroll.
* 2. If the dropdown bottom overflows below the
* viewport, align it with the viewport bottom.
* 3. If that would push the album card above the
* viewport, pin the album card top to the
* viewport top instead.
*
* Positions are computed from grid math rather
* than DOM queries so that the method works
* immediately after a single/split mode switch
* (before the virtualizer has laid out).
*/
private async scrollToShowDropdown() {
const container = this.scrollContainer;
if (
!container ||
this.expandedAlbumId === null
) {
return;
}
const expandedIndex = this.albums.findIndex(
(a) => a.ID === this.expandedAlbumId,
);
if (expandedIndex < 0) return;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const columns = this.getColumnCount();
const rowStep = this.cardHeight + gap;
const albumRow = Math.floor(
expandedIndex / columns,
);
// Top of the album card (at the midpoint of
// the gap above the row) in scroll-content
// coordinates.
const albumTop =
pad + albumRow * rowStep - gap / 2;
// Compute the dropdown bottom from grid math.
// The dropdown sits right after the "before"
// rows: ceil(splitIndex / columns) full rows.
const dropdown =
this.shadowRoot?.querySelector(
'album-dropdown',
);
if (!dropdown) return;
// Wait for the dropdown to finish rendering
// its tracks so that offsetHeight is accurate.
await (dropdown as LitElement).updateComplete;
const beforeRows = Math.ceil(
this.splitIndex / columns,
);
const dropdownTop = pad + beforeRows * rowStep;
const dropdownBottom =
dropdownTop +
(dropdown as HTMLElement).offsetHeight;
const viewTop = container.scrollTop;
const viewHeight = container.clientHeight;
// The valid scroll range where both the album
// top and dropdown bottom are in view:
// scrollTop <= albumTop (card visible)
// scrollTop >= dropdownBottom - viewHeight
const minScroll =
dropdownBottom - viewHeight;
const maxScroll = albumTop;
let newScrollTop: number;
if (minScroll <= maxScroll) {
// Both can fit — clamp to the valid
// range, only scrolling if needed.
newScrollTop = Math.max(
minScroll,
Math.min(viewTop, maxScroll),
);
} else {
// Combined height exceeds the viewport.
// Pin the album card top to the viewport
// top so it stays visible.
newScrollTop = albumTop;
}
console.log(
'[scrollToShowDropdown]',
{
expandedIndex,
albumRow,
albumTop,
beforeRows,
dropdownTop,
dropdownOffsetHeight:
(dropdown as HTMLElement)
.offsetHeight,
dropdownBottom,
viewTop,
viewHeight,
minScroll,
maxScroll,
newScrollTop,
scrollHeight:
container.scrollHeight,
willScroll:
newScrollTop !== viewTop,
},
);
if (newScrollTop !== viewTop) {
await this.restoreScrollTop(newScrollTop);
}
}
/* ====================================================================
* Album selection helpers
* ==================================================================== */
private selectAlbumRange(
from: number,
to: number,
): Set<number> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const ids = new Set<number>();
for (let i = start; i <= end; i++) {
const album = this.albums[i];
if (album) {
ids.add(album.ID);
}
}
return ids;
}
private async getSelectedAlbumFilePaths(): Promise<
string[]
> {
const selected = this.albums.filter((a) =>
this.selectedAlbums.has(a.ID),
);
const allPaths: string[] = [];
for (const album of selected) {
const paths =
await this.getAlbumFilePaths(album);
allPaths.push(...paths);
}
return allPaths;
}
private async getAlbumFilePaths(
album: library.Album,
): Promise<string[]> {
try {
const tracks = await GetAlbumTracks(album.ID);
return tracks.map((t) => t.FilePath);
} catch (error) {
console.error(
'Error loading album tracks:',
error,
);
return [];
}
}
/**
* Pre-resolve file paths for all selected albums so
* that dragstart can read them synchronously. Called
* fire-and-forget whenever the album selection changes.
*/
private async warmAlbumFilePathCache(): Promise<void> {
const selected = this.albums.filter((a) =>
this.selectedAlbums.has(a.ID),
);
// Fetch missing entries.
for (const album of selected) {
if (this.albumFilePathCache.has(album.ID)) {
continue;
}
try {
const tracks = await GetAlbumTracks(
album.ID,
);
// Only store if still selected.
if (this.selectedAlbums.has(album.ID)) {
this.albumFilePathCache.set(
album.ID,
tracks.map((t) => t.FilePath),
);
}
} catch {
// Silently skip — drag will just not
// include this album's paths.
}
}
}
/**
* Read cached file paths for the current album
* selection. Returns an empty array if any albums
* haven't been cached yet.
*/
private getCachedSelectedAlbumFilePaths(): string[] {
const result: string[] = [];
for (const album of this.albums) {
if (!this.selectedAlbums.has(album.ID)) {
continue;
}
const paths =
this.albumFilePathCache.get(album.ID);
if (paths) {
result.push(...paths);
}
}
return result;
}
/* ====================================================================
* Track selection helpers
* ==================================================================== */
private selectTrackRange(
from: number,
to: number,
): Set<string> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const paths = new Set<string>();
for (let i = start; i <= end; i++) {
const track = this.expandedTracks[i];
if (track) {
paths.add(track.FilePath);
}
}
return paths;
}
private getSelectedTrackFilePaths(): string[] {
// Preserve the original track order
return this.expandedTracks
.filter((t) =>
this.selectedTracks.has(t.FilePath),
)
.map((t) => t.FilePath);
}
/* ====================================================================
* Dropdown (expand/collapse)
* ==================================================================== */
private async toggleDropdown(
album: library.Album,
) {
if (this.expandedAlbumId === album.ID) {
// Close
this.expandedAlbumId = null;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
return;
}
// Open (or switch)
this.expandedAlbumId = album.ID;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
try {
const tracks = await GetAlbumTracks(album.ID);
// Only apply if still the same album
if (this.expandedAlbumId === album.ID) {
this.expandedTracks = tracks;
}
} catch (error) {
console.error(
'Error loading album tracks:',
error,
);
}
}
/** 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;
}
/* ====================================================================
* 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();
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 = this.albums[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.selectAlbumRange(
this.lastSelectedAlbumIndex,
index,
);
const next = new Set(this.selectedAlbums);
for (const id of range) {
next.add(id);
}
this.selectedAlbums = next;
this.closeDropdown();
void this.warmAlbumFilePathCache();
} 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.closeDropdown();
void this.warmAlbumFilePathCache();
} else {
this.selectedAlbums = new Set();
void this.toggleDropdown(album);
this.lastSelectedAlbumIndex = index;
}
};
private onGridAlbumDblClick = async (
e: MouseEvent,
) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
const filePaths = await this.getAlbumFilePaths(
hit.album,
);
if (filePaths.length === 0) return;
this.selectedAlbums = new Set();
queueStore.setQueue(filePaths, 0);
};
private onGridAlbumKeydown = (
e: KeyboardEvent,
) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
e.preventDefault();
void this.toggleDropdown(hit.album);
this.lastSelectedAlbumIndex = hit.index;
};
private onGridAlbumContextMenu = (
e: MouseEvent,
) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
e.preventDefault();
e.stopPropagation();
if (!this.selectedAlbums.has(hit.album.ID)) {
this.selectedAlbums = new Set([
hit.album.ID,
]);
void this.warmAlbumFilePathCache();
}
this.contextMenuTarget = { kind: 'album' };
this.openContextMenuAt(e.clientX, e.clientY);
};
/**
* Selection checkbox toggle on album cards.
* Stops propagation so that the card click
* handler (which toggles the dropdown) does
* not fire.
*/
private onSelectToggleClick = (
e: MouseEvent,
) => {
e.stopPropagation();
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
const { album, index } = hit;
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;
// Lightweight dropdown close: set the
// skipOverlay flag so willUpdate skips
// the expensive overlay capture and
// scroll-restore machinery.
if (this.expandedAlbumId !== null) {
this.skipOverlay = true;
this.expandedAlbumId = null;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
}
void this.warmAlbumFilePathCache();
};
/**
* 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.albums[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.selectTrackRange(
this.lastSelectedTrackIndex,
index,
);
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.openContextMenuAt(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.getSelectedTrackFilePaths();
} 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 = 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;
if (this.albumFilePathCache.has(hit.album.ID)) {
return;
}
// Fire-and-forget: warm the cache entry.
void this.getAlbumFilePaths(hit.album).then(
(paths) => {
if (paths.length > 0) {
this.albumFilePathCache.set(
hit.album.ID,
paths,
);
}
},
);
};
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.getCachedSelectedAlbumFilePaths();
isSingleAlbum =
this.selectedAlbums.size === 1;
} else {
// Dragged album is not selected — clear
// selection and drag only this album.
this.selectedAlbums = new Set();
filePaths =
this.albumFilePathCache.get(
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) => {
const path = e.composedPath();
const clickedCard = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('album-card'),
);
const clickedDropdown = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('album-dropdown'),
);
if (!clickedCard && !clickedDropdown) {
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
this.expandedAlbumId = null;
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
}
};
/* ====================================================================
* Context menu (shared between albums and tracks)
* ==================================================================== */
private openContextMenuAt(
clientX: number,
clientY: number,
) {
this.contextMenuOpen = true;
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: clientX,
y: clientY,
top: clientY,
left: clientX,
right: clientX,
bottom: clientY,
};
},
};
(popup as any).active = true;
}
});
}
private async onContextMenuAction(action: string) {
const filePaths =
this.contextMenuTarget.kind === 'track'
? this.getSelectedTrackFilePaths()
: await this.getSelectedAlbumFilePaths();
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
queueStore.playTracksNext(filePaths);
break;
}
this.closeContextMenu(true);
}
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.playlistFilePaths = [];
if (clearSelection) {
if (
this.contextMenuTarget.kind === 'track'
) {
this.selectedTracks = new Set();
} else {
this.selectedAlbums = new Set();
}
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private async showPlaylistSubmenu() {
if (this.playlistSubmenuOpen) return;
if (this.contextMenuTarget.kind === 'track') {
this.playlistFilePaths =
this.getSelectedTrackFilePaths();
} else if (this.selectedAlbums.size > 0) {
this.playlistFilePaths =
await this.getSelectedAlbumFilePaths();
}
this.playlistSubmenuOpen = true;
await this.updateComplete;
const submenu = this.playlistSubmenuPopup;
const trigger =
this.shadowRoot?.querySelector(
'.submenu-item',
);
if (submenu && trigger) {
(submenu as any).anchor = trigger;
(submenu as any).active = true;
}
const picker =
this.shadowRoot?.querySelector(
'playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
private closePlaylistSubmenu() {
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu();
};
/* ====================================================================
* 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
class="select-toggle ${selected ? 'checked' : ''}"
@click=${this.onSelectToggleClick}
>
${selected
? html`<span
class="check-icon"
>
&#10003;
</span>`
: nothing}
</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>
`;
}
const gridContent = this.splitMode
? this.renderSplitGrid()
: this.renderSingleGrid();
return html`
<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=${this.gridKeyFunction}
.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() {
return html`
<lit-virtualizer
id="grid-before"
.items=${this.getBeforeEntries()}
.renderItem=${this.renderGridEntry}
.keyFunction=${this.gridKeyFunction}
.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=${this.getContainerWidth()}
.gridRowWidth=${this.getGridRowWidth()}
.caratOffset=${this.getCaratOffset()}
style="margin-left:${(this.getContainerWidth() - this.getGridRowWidth()) / 2}px"
@track-click=${this.onTrackClick}
@track-dblclick=${this.onTrackDblClick}
@track-contextmenu=${this.onTrackContextMenu}
@track-dragstart=${this.onTrackDragStart}
@track-dragend=${this.onTrackDragEnd}
></album-dropdown>
${this.getAfterEntries().length > 0
? html`
<lit-virtualizer
id="grid-after"
.items=${this.getAfterEntries()}
.renderItem=${this.renderGridEntry}
.keyFunction=${this.gridKeyFunction}
.layout=${this.gridLayoutAfter}
@click=${this.onGridAlbumClick}
@dblclick=${this.onGridAlbumDblClick}
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
></lit-virtualizer>
`
: nothing}
`;
}
/** Context menu + playlist submenu popups. */
private renderContextMenu() {
return html`
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() =>
this.showPlaylistSubmenu()}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>&#9654;</span
>
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen
? html`
<playlist-picker
.filePaths=${this.playlistFilePaths}
@playlist-action-complete=${this.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
`
: nothing}
</wa-popup>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'cover-grid': CoverGrid;
}
}