1683 lines
48 KiB
TypeScript
1683 lines
48 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import { customElement, 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 { queueStore } from '@store/queue-store';
|
|
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,
|
|
} from './album-dropdown.js';
|
|
|
|
/**
|
|
* 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' };
|
|
|
|
/**
|
|
* Union item for the virtualized grid.
|
|
* Album entries carry the original album and its index
|
|
* in this.albums. Phantom entries are invisible
|
|
* placeholders that reserve space for the dropdown overlay.
|
|
*/
|
|
type GridEntry =
|
|
| {
|
|
kind: 'album';
|
|
album: library.Album;
|
|
albumIndex: number;
|
|
}
|
|
| { kind: 'phantom'; phantomIndex: number };
|
|
|
|
/** Milliseconds to debounce visibility-changed saves. */
|
|
const SCROLL_DEBOUNCE_MS = 100;
|
|
|
|
@customElement('cover-grid')
|
|
export class CoverGrid extends LitElement {
|
|
private libraryCtrl = new LibraryController(this);
|
|
|
|
// Grid layout constants — must match the virtualizer
|
|
// grid config and the CSS card dimensions.
|
|
private static readonly GRID_ITEM_WIDTH = 176;
|
|
private static readonly GRID_ITEM_HEIGHT = 230;
|
|
private static readonly GRID_GAP = 16;
|
|
private static readonly GRID_PADDING = 16;
|
|
|
|
private lastSelectedAlbumIndex: number | null = null;
|
|
private lastSelectedTrackIndex: number | null = null;
|
|
private scrollDebounceTimer: ReturnType<
|
|
typeof setTimeout
|
|
> | null = null;
|
|
|
|
private closeHandler = () => this.closeContextMenu();
|
|
|
|
/**
|
|
* Tracks per phantom row. Each track row is ~28px,
|
|
* the base content height is 202px, each column fits
|
|
* ~7 tracks, and 3 columns = 21 tracks per row.
|
|
*/
|
|
private static readonly TRACKS_PER_PHANTOM_ROW = 21;
|
|
|
|
// Virtualizer grid layout instance.
|
|
private gridLayout = grid({
|
|
itemSize: {
|
|
width: `${CoverGrid.GRID_ITEM_WIDTH}px`,
|
|
height: `${CoverGrid.GRID_ITEM_HEIGHT}px`,
|
|
},
|
|
gap: `${CoverGrid.GRID_GAP}px`,
|
|
padding: `${CoverGrid.GRID_PADDING}px`,
|
|
justify: 'center',
|
|
});
|
|
|
|
// buildVirtualizerItems() memoization cache.
|
|
private itemsCacheAlbums: library.Album[] = [];
|
|
private itemsCacheExpandedId: number | null = null;
|
|
private itemsCacheColumns = 0;
|
|
private itemsCachePhantomRows = 0;
|
|
private itemsCache: GridEntry[] = [];
|
|
|
|
static override styles = css`
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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: 8px;
|
|
transition: background-color 0.2s ease;
|
|
box-sizing: border-box;
|
|
width: 176px;
|
|
}
|
|
|
|
.album-card:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.album-card.selected {
|
|
outline: 2px solid #ffd43b;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.album-card.expanded {
|
|
outline: 2px solid #ffd43b;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.album-card:focus-visible {
|
|
outline: 2px solid #ffd43b;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.cover-container {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
background-color: #282828;
|
|
}
|
|
|
|
.cover-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.placeholder-cover {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(
|
|
135deg,
|
|
#404040 0%,
|
|
#282828 100%
|
|
);
|
|
color: #b3b3b3;
|
|
font-size: 48px;
|
|
}
|
|
|
|
.album-info {
|
|
margin-top: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.album-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.artist-name {
|
|
font-size: 12px;
|
|
color: #b3b3b3;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* ========================================
|
|
* Phantom cards (dropdown spacer)
|
|
* ======================================== */
|
|
|
|
.phantom-card {
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ========================================
|
|
* Dropdown overlay
|
|
* ======================================== */
|
|
|
|
.dropdown-overlay {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 10;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
/* ========================================
|
|
* Shared states
|
|
* ======================================== */
|
|
|
|
.loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 32px;
|
|
color: #b3b3b3;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 48px;
|
|
color: #b3b3b3;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-state p {
|
|
margin: 8px 0;
|
|
}
|
|
|
|
/* ========================================
|
|
* Context menu
|
|
* ======================================== */
|
|
|
|
#context-menu {
|
|
z-index: 200;
|
|
}
|
|
|
|
.context-menu-panel {
|
|
background-color: #343a40;
|
|
border: 1px solid #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: #fff;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.context-menu-panel wa-dropdown-item:hover {
|
|
background-color: 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[] = [];
|
|
|
|
/** Whether we're currently loading tracks for the dropdown. */
|
|
@state()
|
|
private loadingTracks = false;
|
|
|
|
/** Set of file paths of selected tracks inside the dropdown. */
|
|
@state()
|
|
private selectedTracks: Set<string> = new Set();
|
|
|
|
/** Number of phantom rows reserved for the dropdown overlay. */
|
|
@state()
|
|
private phantomRowCount = 1;
|
|
|
|
/** Pixel offset of the dropdown overlay from the top of the scroll content. */
|
|
@state()
|
|
private dropdownTopPx = 0;
|
|
|
|
@query('#context-menu')
|
|
private contextMenuPopup!: HTMLElement;
|
|
|
|
@query('#playlist-submenu')
|
|
private playlistSubmenuPopup!: HTMLElement;
|
|
|
|
@query('lit-virtualizer')
|
|
private virtualizer!: 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: {
|
|
row: number;
|
|
viewportOffset: number;
|
|
} | null = null;
|
|
private currentColumnCount = 0;
|
|
|
|
/* ====================================================================
|
|
* Lifecycle
|
|
* ==================================================================== */
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
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();
|
|
document.removeEventListener(
|
|
'click',
|
|
this.closeHandler,
|
|
);
|
|
document.removeEventListener(
|
|
'contextmenu',
|
|
this.closeHandler,
|
|
);
|
|
this.removeEventListener(
|
|
'error',
|
|
this.onGridImageError,
|
|
true,
|
|
);
|
|
|
|
if (this.scrollDebounceTimer !== null) {
|
|
clearTimeout(this.scrollDebounceTimer);
|
|
}
|
|
|
|
if (this.resizeDebounceTimer !== null) {
|
|
clearTimeout(this.resizeDebounceTimer);
|
|
}
|
|
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = null;
|
|
}
|
|
|
|
override updated(
|
|
changed: Map<PropertyKey, unknown>,
|
|
) {
|
|
super.updated(changed);
|
|
|
|
// When the dropdown opens or tracks change,
|
|
// recompute the phantom row count and overlay
|
|
// position.
|
|
if (
|
|
changed.has('expandedAlbumId') ||
|
|
changed.has('expandedTracks')
|
|
) {
|
|
this.phantomRowCount =
|
|
this.expandedAlbumId !== null
|
|
? Math.max(
|
|
1,
|
|
Math.ceil(
|
|
this.expandedTracks
|
|
.length /
|
|
CoverGrid.TRACKS_PER_PHANTOM_ROW,
|
|
),
|
|
)
|
|
: 1;
|
|
this.updateDropdownPosition();
|
|
}
|
|
|
|
// When a new album is expanded, scroll its
|
|
// row to the top of the viewport after the
|
|
// DOM reflects the new phantom rows.
|
|
if (
|
|
changed.has('expandedAlbumId') &&
|
|
this.expandedAlbumId !== null
|
|
) {
|
|
void this.updateComplete.then(() => {
|
|
this.scrollToExpandedAlbum();
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ====================================================================
|
|
* 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.virtualizer) return;
|
|
|
|
const safeIndex = Math.min(
|
|
saved,
|
|
this.albums.length - 1,
|
|
);
|
|
|
|
if (safeIndex <= 0) return;
|
|
|
|
this.virtualizer.scrollToIndex(
|
|
safeIndex,
|
|
'start',
|
|
);
|
|
}
|
|
|
|
private onVisibilityChanged = (
|
|
e: VisibilityChangedEvent,
|
|
) => {
|
|
if (this.scrollDebounceTimer !== null) {
|
|
clearTimeout(this.scrollDebounceTimer);
|
|
}
|
|
|
|
this.scrollDebounceTimer = setTimeout(() => {
|
|
const items = this.buildVirtualizerItems();
|
|
const first = items[e.first];
|
|
|
|
if (first?.kind === 'album') {
|
|
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 compute a fractional album index at a focus
|
|
* point before the resize, then after the reflow we
|
|
* place that same index back at the same viewport
|
|
* offset.
|
|
*
|
|
* 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();
|
|
|
|
const {
|
|
GRID_ITEM_HEIGHT,
|
|
GRID_GAP,
|
|
GRID_PADDING,
|
|
} = CoverGrid;
|
|
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
|
|
|
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;
|
|
|
|
if (!pending) return;
|
|
|
|
const newColumns = this.getColumnCount();
|
|
|
|
this.currentColumnCount = newColumns;
|
|
|
|
// If a dropdown is open, snap the
|
|
// expanded album's row to the top.
|
|
if (this.expandedAlbumId !== null) {
|
|
this.updateDropdownPosition();
|
|
this.scrollToExpandedAlbum();
|
|
|
|
return;
|
|
}
|
|
|
|
// No dropdown — restore the
|
|
// center-of-viewport position.
|
|
const newY =
|
|
GRID_PADDING + pending.row * rowStep;
|
|
|
|
container.scrollTop =
|
|
newY - pending.viewportOffset;
|
|
};
|
|
|
|
this.resizeObserver = new ResizeObserver(
|
|
() => {
|
|
// Capture on the first event using
|
|
// the pre-resize column count.
|
|
if (this.pendingFocus === null) {
|
|
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.
|
|
*/
|
|
private captureFocusPoint(
|
|
container: HTMLElement,
|
|
rowStep: number,
|
|
) {
|
|
const { GRID_PADDING } = CoverGrid;
|
|
|
|
// 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 / this.currentColumnCount,
|
|
);
|
|
const albumY =
|
|
GRID_PADDING + albumRow * rowStep;
|
|
|
|
this.pendingFocus = {
|
|
row: albumRow,
|
|
viewportOffset:
|
|
albumY - container.scrollTop,
|
|
};
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fall back to the viewport center.
|
|
const halfViewport =
|
|
container.clientHeight / 2;
|
|
const centerY =
|
|
container.scrollTop + halfViewport;
|
|
const row =
|
|
(centerY - GRID_PADDING) / rowStep;
|
|
|
|
this.pendingFocus = {
|
|
row,
|
|
viewportOffset: halfViewport,
|
|
};
|
|
}
|
|
|
|
/* ====================================================================
|
|
* Column count helper
|
|
* ==================================================================== */
|
|
|
|
private getColumnCount(): number {
|
|
const el =
|
|
this.scrollContainer ?? this.virtualizer;
|
|
|
|
if (!el) return 1;
|
|
|
|
const { GRID_ITEM_WIDTH, GRID_GAP, GRID_PADDING } =
|
|
CoverGrid;
|
|
const availableWidth =
|
|
el.clientWidth - GRID_PADDING * 2;
|
|
|
|
return Math.max(
|
|
1,
|
|
Math.floor(
|
|
(availableWidth + GRID_GAP) /
|
|
(GRID_ITEM_WIDTH + GRID_GAP),
|
|
),
|
|
);
|
|
}
|
|
|
|
/* ====================================================================
|
|
* Virtualizer items (albums + phantom rows)
|
|
*
|
|
* When a dropdown is open we inject phantom entries
|
|
* after the expanded album's row. The dropdown is
|
|
* rendered as an absolutely-positioned overlay on top
|
|
* of the phantom cards.
|
|
* ==================================================================== */
|
|
|
|
private buildVirtualizerItems(): GridEntry[] {
|
|
const columns = this.getColumnCount();
|
|
const phantomRows =
|
|
this.expandedAlbumId !== null
|
|
? this.phantomRowCount
|
|
: 0;
|
|
|
|
// Return cached result when inputs unchanged.
|
|
if (
|
|
this.itemsCacheAlbums === this.albums &&
|
|
this.itemsCacheExpandedId ===
|
|
this.expandedAlbumId &&
|
|
this.itemsCacheColumns === columns &&
|
|
this.itemsCachePhantomRows === phantomRows
|
|
) {
|
|
return this.itemsCache;
|
|
}
|
|
|
|
const items: GridEntry[] = [];
|
|
|
|
if (this.expandedAlbumId === null) {
|
|
for (let i = 0; i < this.albums.length; i++) {
|
|
items.push({
|
|
kind: 'album',
|
|
album: this.albums[i]!,
|
|
albumIndex: i,
|
|
});
|
|
}
|
|
} else {
|
|
const expandedIndex = this.albums.findIndex(
|
|
(a) => a.ID === this.expandedAlbumId,
|
|
);
|
|
|
|
// Position after the expanded album's row.
|
|
const insertAfter =
|
|
expandedIndex >= 0
|
|
? Math.min(
|
|
(Math.floor(
|
|
expandedIndex / columns,
|
|
) +
|
|
1) *
|
|
columns,
|
|
this.albums.length,
|
|
)
|
|
: this.albums.length;
|
|
|
|
const phantomCount = columns * phantomRows;
|
|
|
|
for (let i = 0; i < insertAfter; i++) {
|
|
items.push({
|
|
kind: 'album',
|
|
album: this.albums[i]!,
|
|
albumIndex: i,
|
|
});
|
|
}
|
|
|
|
for (let i = 0; i < phantomCount; i++) {
|
|
items.push({
|
|
kind: 'phantom',
|
|
phantomIndex: i,
|
|
});
|
|
}
|
|
|
|
for (
|
|
let i = insertAfter;
|
|
i < this.albums.length;
|
|
i++
|
|
) {
|
|
items.push({
|
|
kind: 'album',
|
|
album: this.albums[i]!,
|
|
albumIndex: i,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update cache.
|
|
this.itemsCache = items;
|
|
this.itemsCacheAlbums = this.albums;
|
|
this.itemsCacheExpandedId = this.expandedAlbumId;
|
|
this.itemsCacheColumns = columns;
|
|
this.itemsCachePhantomRows = phantomRows;
|
|
|
|
return items;
|
|
}
|
|
|
|
private gridKeyFunction = (entry: GridEntry) => {
|
|
if (entry.kind === 'phantom') {
|
|
return `phantom-${entry.phantomIndex}`;
|
|
}
|
|
|
|
return `a-${entry.album.ID}`;
|
|
};
|
|
|
|
/* ====================================================================
|
|
* Dropdown overlay positioning
|
|
*
|
|
* The dropdown is absolutely positioned inside the
|
|
* scroll container, overlaying the phantom cards.
|
|
* We compute its top offset from the expanded album's
|
|
* row position in the grid.
|
|
* ==================================================================== */
|
|
|
|
/**
|
|
* Smooth-scroll the container so the expanded
|
|
* album's row is positioned at the top of the view.
|
|
*/
|
|
private scrollToExpandedAlbum() {
|
|
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 {
|
|
GRID_ITEM_HEIGHT,
|
|
GRID_GAP,
|
|
GRID_PADDING,
|
|
} = CoverGrid;
|
|
const columns = this.getColumnCount();
|
|
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
|
const albumRow = Math.floor(
|
|
expandedIndex / columns,
|
|
);
|
|
|
|
container.scrollTo({
|
|
top:
|
|
GRID_PADDING +
|
|
albumRow * rowStep -
|
|
GRID_GAP / 2,
|
|
behavior: 'instant',
|
|
});
|
|
}
|
|
|
|
private updateDropdownPosition() {
|
|
if (this.expandedAlbumId === null) return;
|
|
|
|
const columns = this.getColumnCount();
|
|
const expandedIndex = this.albums.findIndex(
|
|
(a) => a.ID === this.expandedAlbumId,
|
|
);
|
|
|
|
if (expandedIndex < 0) return;
|
|
|
|
const {
|
|
GRID_ITEM_HEIGHT,
|
|
GRID_GAP,
|
|
GRID_PADDING,
|
|
} = CoverGrid;
|
|
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
|
const phantomStartRow =
|
|
Math.floor(expandedIndex / columns) + 1;
|
|
|
|
this.dropdownTopPx =
|
|
GRID_PADDING + phantomStartRow * rowStep;
|
|
}
|
|
|
|
/* ====================================================================
|
|
* 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 [];
|
|
}
|
|
}
|
|
|
|
/* ====================================================================
|
|
* 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;
|
|
this.phantomRowCount = 1;
|
|
|
|
return;
|
|
}
|
|
|
|
// Open (or switch)
|
|
this.expandedAlbumId = album.ID;
|
|
this.expandedTracks = [];
|
|
this.selectedTracks = new Set();
|
|
this.lastSelectedTrackIndex = null;
|
|
this.phantomRowCount = 1;
|
|
this.loadingTracks = true;
|
|
|
|
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,
|
|
);
|
|
} finally {
|
|
this.loadingTracks = false;
|
|
}
|
|
}
|
|
|
|
/* ====================================================================
|
|
* 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;
|
|
} 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;
|
|
} else {
|
|
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,
|
|
]);
|
|
}
|
|
|
|
this.contextMenuTarget = { kind: 'album' };
|
|
this.openContextMenuAt(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.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);
|
|
};
|
|
|
|
/* ====================================================================
|
|
* 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();
|
|
}
|
|
|
|
/* ====================================================================
|
|
* Render: grid entry (virtualizer renderItem)
|
|
* ==================================================================== */
|
|
|
|
private renderGridEntry = (
|
|
entry: GridEntry,
|
|
) => {
|
|
if (entry.kind === 'phantom') {
|
|
return html`<div
|
|
class="phantom-card"
|
|
></div>`;
|
|
}
|
|
|
|
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(' ');
|
|
|
|
return html`
|
|
<div
|
|
class=${classes}
|
|
tabindex="0"
|
|
role="button"
|
|
data-index=${index}
|
|
aria-label="${album.Name} by ${album.ArtistName}"
|
|
>
|
|
<div class="cover-container">
|
|
${album.CoverArtPath
|
|
? html`<img
|
|
class="cover-image"
|
|
src="${album.CoverArtThumbnailPath || album.CoverArtPath}"
|
|
alt="${album.Name} cover"
|
|
width="160"
|
|
height="160"
|
|
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}
|
|
</div>
|
|
<div
|
|
class="artist-name"
|
|
title="${album.ArtistName}"
|
|
>
|
|
${album.ArtistName}${album.Year
|
|
? ` - ${album.Year}`
|
|
: ''}
|
|
</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>
|
|
`;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="grid-scroll-container"
|
|
@click=${this.onGridClick}
|
|
>
|
|
<lit-virtualizer
|
|
.items=${this.buildVirtualizerItems()}
|
|
.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>
|
|
|
|
${this.expandedAlbumId !== null
|
|
? html`
|
|
<album-dropdown
|
|
class="dropdown-overlay"
|
|
style="top:${this.dropdownTopPx}px"
|
|
.tracks=${this.expandedTracks}
|
|
?loading-tracks=${this.loadingTracks}
|
|
.selectedTracks=${this.selectedTracks}
|
|
.phantomRows=${this.phantomRowCount}
|
|
@track-click=${this.onTrackClick}
|
|
@track-dblclick=${this.onTrackDblClick}
|
|
@track-contextmenu=${this.onTrackContextMenu}
|
|
></album-dropdown>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
|
|
<wa-popup
|
|
id="context-menu"
|
|
placement="bottom-start"
|
|
.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"
|
|
>▶</span
|
|
>
|
|
</wa-dropdown-item>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</wa-popup>
|
|
|
|
<wa-popup
|
|
id="playlist-submenu"
|
|
placement="right-start"
|
|
.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;
|
|
}
|
|
}
|