cover grid refactor
-split component into several files
This commit is contained in:
@@ -16,8 +16,6 @@ Prioritized list of architectural improvements identified during a full codebase
|
||||
|
||||
### 3. ~~Split `queue.go` (2254 lines)~~ — solved
|
||||
|
||||
Split into 5 files: `queue.go` (core types, operations, constructor), `handlers.go` (event handlers + `toStringSlice`/`toIntSlice` helpers), `persistence.go` (DB I/O), `navigation.go` (shuffle/navigation), `emit.go` (event emission). Also applied in-place improvements: `trackMeta.toTrack()` method, `commitMutation()` helper, `slices.Insert` for slice operations, fixed `InsertNext` empty-queue bug, fixed `AddTracks` persist ordering, unified `AddTrack` persistence.
|
||||
|
||||
---
|
||||
|
||||
### 4. Split `cover-grid.ts` (3740 lines)
|
||||
@@ -27,6 +25,7 @@ Split into 5 files: `queue.go` (core types, operations, constructor), `handlers.
|
||||
**Why it matters:** Difficult to understand, modify, or review. Changes to context menu logic risk breaking grid rendering and vice versa.
|
||||
|
||||
**Approach:** Extract logical sections into separate files/components:
|
||||
|
||||
- Context menu logic into a shared utility or sub-component
|
||||
- Selection logic already uses a `SelectionController` — verify it's fully extracted
|
||||
- Drag-and-drop setup into the existing `DragController` if not already
|
||||
@@ -73,10 +72,12 @@ Split into 5 files: `queue.go` (core types, operations, constructor), `handlers.
|
||||
**Why it matters:** Type safety is completely bypassed for a core interaction pattern. Typos in property names (`actve` instead of `active`) would silently fail.
|
||||
|
||||
**Approach:** Create a type declaration for the WebAwesome popup element (or find one in their package). Alternatively, write a small typed utility:
|
||||
|
||||
```typescript
|
||||
function openPopup(popup: Element, anchor: Element | VirtualAnchor): void
|
||||
function closePopup(popup: Element): void
|
||||
```
|
||||
|
||||
Replace all 49 `as any` casts with calls to these utilities.
|
||||
|
||||
---
|
||||
@@ -118,6 +119,7 @@ Replace all 49 `as any` casts with calls to these utilities.
|
||||
**Why it matters:** Inconsistency makes the codebase harder to learn. The queue's event-only approach requires substantial boilerplate that the playlist avoids. New features on the queue require touching 4 files (Go event constant, TS event constant, Go handler, TS store method) vs 1-2 files for the playlist.
|
||||
|
||||
**Approach:** This is a larger refactor. Two options:
|
||||
|
||||
1. **Move queue to bindings** (recommended): Add the queue to `FEBindings`, expose typed methods, call them directly from the frontend store. Remove the event handlers and the `Request*` events. Keep the backend-to-frontend events (`QueueChanged`, etc.) for state push.
|
||||
2. **Accept the inconsistency**: Document the rationale (queue existed before playlists, events were the original pattern, bindings were adopted later). Add a comment in AGENTS.md.
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# Plan: Split `cover-grid.ts` + Extract Shared Context Menu
|
||||
|
||||
Addresses refactoring catalog #4 (split `cover-grid.ts`, 3774 lines) and partially addresses #8 (49× `as any` casts on popups).
|
||||
|
||||
## Current State
|
||||
|
||||
`frontend/src/components/cover-grid/cover-grid.ts` is the largest frontend file at 3774 lines. It contains a single `CoverGrid` LitElement that handles:
|
||||
|
||||
- Virtualized album grid rendering (single + split mode with inline dropdown)
|
||||
- Album/track selection (custom inline logic with Ctrl/Shift/range)
|
||||
- Context menus (album + track, with playlist submenu) — **duplicated across 6 components**
|
||||
- Drag-and-drop source (albums + tracks)
|
||||
- Sort controls (toolbar + dropdown)
|
||||
- Ctrl+scroll zoom
|
||||
- Scroll position save/restore (index-based + pixel-based resize-aware)
|
||||
- Transition overlays (DOM snapshots during layout transitions)
|
||||
- Album filtering/sorting (memoized)
|
||||
- 319 lines of CSS
|
||||
|
||||
The context menu logic is copy-pasted into 6 components: `cover-grid.ts`, `track-list.ts`, `playlist-view.ts`, `queue-panel.ts`, `genres-view.ts`, `artists-view.ts`. Each duplicates ~200 lines of state, open/close methods, submenu timers, document event listeners, and render templates.
|
||||
|
||||
---
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **Extract logic modules, not sub-components.** The grid is one visual component. Splitting it into multiple custom elements would create artificial boundaries and state-forwarding complexity. Instead, extract plain TS files (classes/functions) that the component imports.
|
||||
|
||||
2. **Follow existing patterns.** The codebase has `SelectionController` in `utils/`, `drag-controller.ts`, `drag-image.ts`. New extractions follow these conventions.
|
||||
|
||||
3. **Shared context menu is the highest-value extraction.** Duplicated across 6 components, it benefits the whole codebase.
|
||||
|
||||
4. **Don't over-split.** Lifecycle methods, render methods, and data loading are inherently tied to component state and stay in the main file. Some code density is fine for orchestration.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Types and Constants → `cover-grid-types.ts`
|
||||
|
||||
**New file:** `frontend/src/components/cover-grid/cover-grid-types.ts` (~85 lines)
|
||||
|
||||
**Move from `cover-grid.ts` lines 49-132:**
|
||||
- `ContextMenuTarget` discriminated union type
|
||||
- `GridEntry` interface
|
||||
- `SCROLL_DEBOUNCE_MS`, `ZOOM_STEP` constants
|
||||
- `SORT_FIELD_KEY`, `SORT_DIR_KEY` localStorage key constants
|
||||
- `AlbumSortField` type, `SortDirection` type
|
||||
- `AlbumSortOption` interface
|
||||
- `ALBUM_SORT_OPTIONS` array (3 sort options with comparator functions)
|
||||
|
||||
**Rationale:** Pure data definitions with zero component dependency. Multiple files in the directory will import these (scroll-manager needs `SCROLL_DEBOUNCE_MS`, main file needs sort options, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Part 2: CSS Styles → `cover-grid-styles.ts`
|
||||
|
||||
**New file:** `frontend/src/components/cover-grid/cover-grid-styles.ts` (~270 lines)
|
||||
|
||||
**Move from `cover-grid.ts` lines 343-661**, minus the context-menu styles (~47 lines at 615-661) which move to the shared context menu utility in Part 4.
|
||||
|
||||
Export as a tagged template:
|
||||
```typescript
|
||||
import { css } from 'lit';
|
||||
export const coverGridStyles = css`...`;
|
||||
```
|
||||
|
||||
Main file uses:
|
||||
```typescript
|
||||
import { coverGridStyles } from './cover-grid-styles.js';
|
||||
import { contextMenuStyles } from '@utils/context-menu-controller.js';
|
||||
// ...
|
||||
static override styles = [coverGridStyles, contextMenuStyles];
|
||||
```
|
||||
|
||||
**Rationale:** Standard Lit pattern for large style blocks. Reduces visual noise. The style array composition pattern is idiomatic Lit.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Scroll Manager → `scroll-manager.ts`
|
||||
|
||||
**New file:** `frontend/src/components/cover-grid/scroll-manager.ts` (~450 lines)
|
||||
|
||||
**Move from `cover-grid.ts`:**
|
||||
- Scroll position persistence: `restoreScrollPosition()` (line 1580), `onVisibilityChanged` (line 1607)
|
||||
- Resize-aware scroll preservation: `setupResizeObserver()` (line 1668), `captureFocusPoint()` (line 1803)
|
||||
- Layout helpers: `getColumnCount()` (line 1865), `getContainerWidth()` (line 1887), `getGridRowWidth()` (line 1901), `getCaratOffset()` (line 1916), `computeSplitIndex()` (line 1949)
|
||||
- Transition overlay: `captureOverlay()` (line 2039), `removeOverlay()` (line 2090)
|
||||
- Scroll positioning: `awaitBeforeLayout()` (line 2116), `computeAdjustedScrollTop()` (line 2135), `restoreScrollTop()` (line 2197), `scrollToShowDropdown()` (line 2265)
|
||||
- Associated fields: `resizeObserver`, `resizeDebounceTimer`, `pendingFocus`, `currentColumnCount`, `isResizing`, `savedScrollTop`, `needsScrollRestore`, `showDropdownAfterRestore`, `scrollRestoreGeneration`, `scrollRestoreResolved`, `savedAlbumViewportOffset`, `transitionOverlay`, `scrollDebounceTimer`
|
||||
|
||||
**Shape:** Plain class with a host interface (not a ReactiveController — scroll management is imperative/async, not reactive).
|
||||
|
||||
```typescript
|
||||
export interface ScrollManagerHost {
|
||||
readonly libraryCtrl: LibraryController;
|
||||
readonly cachedFilteredAlbums: library.Album[];
|
||||
readonly expandedAlbumId: number | null;
|
||||
readonly expandedTracks: library.Track[];
|
||||
readonly splitMode: boolean;
|
||||
readonly splitIndex: number;
|
||||
readonly cardWidth: number;
|
||||
readonly cardHeight: number;
|
||||
readonly cardTextHeight: number;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
updateComplete: Promise<boolean>;
|
||||
requestUpdate(): void;
|
||||
}
|
||||
|
||||
export class ScrollManager {
|
||||
constructor(host: ScrollManagerHost, gridConstants: GridConstants);
|
||||
|
||||
// Called from component lifecycle
|
||||
setup(): void; // from connectedCallback
|
||||
teardown(): void; // from disconnectedCallback
|
||||
|
||||
// Scroll save/restore
|
||||
onVisibilityChanged(e: VisibilityChangedEvent): void;
|
||||
restoreScrollPosition(): void;
|
||||
|
||||
// Resize handling
|
||||
setupResizeObserver(): void;
|
||||
|
||||
// Split/single mode transitions
|
||||
captureOverlay(): void;
|
||||
removeOverlay(): void;
|
||||
computeAdjustedScrollTop(): number;
|
||||
async restoreScrollTop(target: number): Promise<void>;
|
||||
async scrollToShowDropdown(): Promise<void>;
|
||||
awaitBeforeLayout(): Promise<void>;
|
||||
|
||||
// Layout geometry
|
||||
getColumnCount(): number;
|
||||
getContainerWidth(): number;
|
||||
getGridRowWidth(): number;
|
||||
getCaratOffset(): number;
|
||||
computeSplitIndex(): number;
|
||||
|
||||
// State exposed to component
|
||||
needsScrollRestore: boolean;
|
||||
showDropdownAfterRestore: boolean;
|
||||
savedScrollTop: number;
|
||||
savedAlbumViewportOffset: number | null;
|
||||
isResizing: boolean;
|
||||
splitIndex: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:** Scroll management is the largest concern (~800 raw lines, consolidated to ~450 without the grid constants that stay on the component). It's completely self-contained — reads component state but doesn't modify selection, context menus, or rendering. The host interface decouples it from the concrete class. A plain class (not ReactiveController) is honest about the imperative nature of scroll management.
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Shared Context Menu Controller → `utils/context-menu-controller.ts`
|
||||
|
||||
**New file:** `frontend/src/utils/context-menu-controller.ts` (~200 lines)
|
||||
|
||||
This is the highest cross-cutting value extraction. The same context menu pattern is duplicated in 6 components.
|
||||
|
||||
**Extract the common pattern from all 6 components:**
|
||||
|
||||
```typescript
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
export interface ContextMenuHost extends ReactiveControllerHost {
|
||||
// Query accessors — each component provides its own popup element refs
|
||||
getContextMenuPopup(): HTMLElement | undefined;
|
||||
getPlaylistSubmenuPopup(): HTMLElement | undefined;
|
||||
updateComplete: Promise<boolean>;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
}
|
||||
|
||||
export class ContextMenuController implements ReactiveController {
|
||||
// Reactive state (component reads these for rendering)
|
||||
contextMenuOpen = false;
|
||||
playlistSubmenuOpen = false;
|
||||
playlistFilePaths: string[] = [];
|
||||
|
||||
constructor(host: ContextMenuHost);
|
||||
|
||||
// Lifecycle — registers/removes document-level listeners
|
||||
hostConnected(): void;
|
||||
hostDisconnected(): void;
|
||||
|
||||
// Actions
|
||||
openAt(clientX: number, clientY: number): void;
|
||||
close(): void;
|
||||
showPlaylistSubmenu(filePaths: string[]): Promise<void>;
|
||||
closePlaylistSubmenu(): void;
|
||||
onPlaylistActionComplete(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Also extract** shared context menu CSS styles as:
|
||||
```typescript
|
||||
export const contextMenuStyles = css`
|
||||
#context-menu { ... }
|
||||
.context-menu-panel { ... }
|
||||
wa-dropdown-item { ... }
|
||||
.submenu-item { ... }
|
||||
.submenu-arrow { ... }
|
||||
#playlist-submenu { ... }
|
||||
`;
|
||||
```
|
||||
|
||||
**What stays in each component:**
|
||||
- The `renderContextMenu()` method — menu items differ per component (cover-grid has conditional "Track Details", queue-panel has "Remove" instead of "Add to Queue", etc.)
|
||||
- The `onContextMenuAction(action)` handler — file path resolution differs per component
|
||||
- The `@query` decorators for popup elements (passed to controller via host interface)
|
||||
|
||||
**Components to update (6):**
|
||||
1. `cover-grid.ts` — Remove ~200 lines of inline context menu code
|
||||
2. `track-list.ts` — Remove ~200 lines
|
||||
3. `playlist-view.ts` — Remove ~200 lines (keep the second playlist-level context menu as-is or also migrate)
|
||||
4. `queue-panel.ts` — Remove ~200 lines
|
||||
5. `genres-view.ts` — Remove ~200 lines
|
||||
6. `artists-view.ts` — Remove ~200 lines
|
||||
|
||||
**Bonus:** All 49× `(popup as any).anchor = ...` and `(popup as any).active = ...` casts are now centralized in one file. This partially addresses catalog item #8 — adding proper typing to the controller's internals eliminates the `as any` from all 6 components.
|
||||
|
||||
**Rationale:** ReactiveController is the right shape here (unlike ScrollManager) because it manages document-level event listeners tied to the component lifecycle via `hostConnected`/`hostDisconnected`. This matches the existing `SelectionController` pattern in `utils/`.
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Album Selection Manager → `album-selection.ts`
|
||||
|
||||
**New file:** `frontend/src/components/cover-grid/album-selection.ts` (~250 lines)
|
||||
|
||||
**Move from `cover-grid.ts`:**
|
||||
- Album selection: `selectAlbumRange()` (line 2378), `getSelectedAlbumFilePaths()` (line 2398), `getContextMenuAlbumFilePaths()` (line 2422), `getAlbumFilePaths()` (line 2449)
|
||||
- Drag cache: `warmAlbumFilePathCache()` (line 2471), `getCachedSelectedAlbumFilePaths()` (line 2505), `albumFilePathCache` Map
|
||||
- Track selection: `selectTrackRange()` (line 2528), `getSelectedTrackFilePaths()` (line 2547)
|
||||
- Dropdown coupling: `closeDropdown()` (line 2561), `openDropdown()` (line 2575), `syncDropdownToSelection()` (line 2607)
|
||||
|
||||
**Shape:**
|
||||
```typescript
|
||||
export class AlbumSelectionManager {
|
||||
selectedAlbums = new Set<number>();
|
||||
selectedTracks = new Set<string>();
|
||||
expandedAlbumId: number | null = null;
|
||||
expandedTracks: library.Track[] = [];
|
||||
lastSelectedAlbumIndex: number | null = null;
|
||||
lastSelectedTrackIndex: number | null = null;
|
||||
|
||||
private albumFilePathCache = new Map<number, string[]>();
|
||||
|
||||
// Album selection
|
||||
selectAlbumRange(from: number, to: number, filteredAlbums: library.Album[]): Set<number>;
|
||||
async getSelectedAlbumFilePaths(albums: library.Album[]): Promise<string[]>;
|
||||
async getContextMenuAlbumFilePaths(contextMenuAlbumId: number | null, albums: library.Album[]): Promise<string[]>;
|
||||
|
||||
// Drag cache
|
||||
async warmCache(albums: library.Album[]): Promise<void>;
|
||||
getCachedSelectedPaths(albums: library.Album[]): string[];
|
||||
|
||||
// Track selection
|
||||
selectTrackRange(from: number, to: number): Set<string>;
|
||||
getSelectedTrackFilePaths(): string[];
|
||||
|
||||
// Dropdown
|
||||
async openDropdown(album: library.Album): Promise<void>;
|
||||
closeDropdown(): void;
|
||||
syncDropdownToSelection(filteredAlbums: library.Album[]): void;
|
||||
|
||||
// Reset
|
||||
clear(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Why not use the existing `SelectionController`?** The existing controller:
|
||||
- Uses string keys only; album selection uses numeric IDs
|
||||
- Manages a single selection set; cover-grid has separate album and track selections
|
||||
- Has no concept of dropdown coupling (selecting 1 album → opens dropdown)
|
||||
- Has no file path caching for drag
|
||||
|
||||
Retrofitting `SelectionController` to handle all of this would make it overly complex for its other consumers (`track-list.ts`, `playlist-view.ts`, `queue-panel.ts`). A dedicated manager for cover-grid's dual album/track model is cleaner.
|
||||
|
||||
**Rationale:** Selection state + file path resolution is a coherent concern (~250 lines) that doesn't need access to the DOM, making it easy to extract. The main component's event handlers become thin wrappers that call into this manager.
|
||||
|
||||
---
|
||||
|
||||
## What Stays in `cover-grid.ts`
|
||||
|
||||
After all extractions and improvements, the main file will be approximately **~1700 lines** (down from 3774):
|
||||
|
||||
| Section | ~Lines | Why it stays |
|
||||
|---------|--------|-------------|
|
||||
| Imports and class declaration | 60 | Structural |
|
||||
| Properties, state, queries, controllers | 100 | Component-specific reactive state (fewer `@state` props) |
|
||||
| Grid layout creation + memoization | 80 | Tightly coupled to virtualizer |
|
||||
| Lifecycle (connectedCallback, disconnectedCallback, willUpdate, updated) | 350 | Orchestration — wires managers together (debug logs removed) |
|
||||
| Dynamic size properties + zoom | 70 | Simple, component-specific |
|
||||
| Data loading | 30 | Simple async fetch |
|
||||
| Virtualizer item builders | 40 | Depends on component state (memoized) |
|
||||
| Event handlers (album + track + drag) | 340 | Thin delegation to managers |
|
||||
| Render methods | 430 | Templates reference component state |
|
||||
| Sort toolbar logic | 120 | Small, self-contained |
|
||||
|
||||
~1700 lines is still substantial, but the *complexity* is dramatically reduced because the three hardest subsystems (scroll management, context menus, selection/file-path resolution) are encapsulated in dedicated modules. The remaining code is pure orchestration and rendering.
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Do
|
||||
|
||||
- **Does not split into multiple custom elements** — Artificial component boundaries would add event-forwarding complexity for no UX benefit.
|
||||
- **Does not refactor the split/single virtualizer architecture** — That's the core rendering strategy; changing it is a separate effort.
|
||||
- **Does not retrofit `SelectionController` for albums** — The existing controller serves different consumers with simpler needs. See Part 5 rationale.
|
||||
- **Does not touch `album-dropdown.ts`** — Already a well-scoped 410-line sub-component.
|
||||
- **Does not extract drag handlers** — ~165 lines of glue code that delegates to existing `drag-controller.ts`/`drag-image.ts`. Diminishing returns.
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Code Quality and Performance Improvements
|
||||
|
||||
These improvements are applied during the extraction steps that touch the relevant code. They don't change behavior — they make the same behavior more efficient and clean.
|
||||
|
||||
### 6a. Remove 13 `console.log` debug statements
|
||||
|
||||
**Lines:** 1133, 1151, 1192, 1226, 1303, 1320, 1328, 1390, 1432, 1437, 2158, 2176, 2345
|
||||
|
||||
The scroll restoration and transition overlay code contains 13 `console.log` calls that are clearly development debugging artifacts (e.g., `[willUpdate] exit split (tracks empty)`, `[updated] scroll restore start`, `[adjustScroll]`, `[restoreScrollTop] attempt ${i}`).
|
||||
|
||||
**Action:** Remove all 13 `console.log` calls. Keep the 3 `console.error` (actual failures) and 1 `console.warn` (retry exhaustion).
|
||||
|
||||
**Applied during:** Part 3 (scroll-manager extraction) and Part 5 lifecycle cleanup.
|
||||
|
||||
### 6b. Memoize `buildGridEntries()` — eliminates 3-5 redundant array allocations per render
|
||||
|
||||
**Problem:** `buildGridEntries()` allocates a new `GridEntry[]` array on every call. In split-mode rendering, it's called up to 5 times per render cycle:
|
||||
- `getBeforeEntries()` → `buildGridEntries().slice(0, splitIndex)` (line 2003)
|
||||
- `getAfterEntries()` called **twice** in `renderSplitGrid()` — once for `.length > 0` check (line 3612), once for `.items` (line 3616) — each rebuilding the full array
|
||||
- `onVisibilityChanged` scroll handler also rebuilds it (line 1637)
|
||||
|
||||
There's even a placeholder comment on line 340: `// buildGridEntries() memoization cache.` — but no cache was ever implemented.
|
||||
|
||||
**Action:**
|
||||
1. Cache the `GridEntry[]` result, keyed on `cachedFilteredAlbums` reference identity. Invalidate in `recomputeAlbumCache()`.
|
||||
2. In `renderSplitGrid()`, compute `const afterEntries = this.getAfterEntries()` once and reuse for both the length check and the `.items` binding.
|
||||
|
||||
**Applied during:** Part 1 (types — `GridEntry` moves) and main file cleanup.
|
||||
|
||||
### 6c. Cache expanded album index — eliminates 6 redundant O(n) scans
|
||||
|
||||
**Problem:** `cachedFilteredAlbums.findIndex((a) => a.ID === this.expandedAlbumId)` appears at 6 call sites (lines 1077, 1364, 1813, 1919, 1959, 2276). Each is a linear scan of the album array for the same ID.
|
||||
|
||||
**Action:** Compute `expandedAlbumIndex` in `recomputeAlbumCache()` (or in `willUpdate` when `expandedAlbumId` changes). All 6 call sites become a direct property read. Invalidate when either `expandedAlbumId` or `cachedFilteredAlbums` changes.
|
||||
|
||||
**Applied during:** Part 3 (scroll-manager extraction — 4 of the 6 sites are in scroll code) and main file cleanup.
|
||||
|
||||
### 6d. Build `albumById` Map for O(1) selection lookups
|
||||
|
||||
**Problem:** `getSelectedAlbumFilePaths()` (line 2401) and `warmAlbumFilePathCache()` (line 2472) both call `this.albums.filter(a => selectedAlbums.has(a.ID))` to find selected albums — an O(n) scan of the full album list. `resolveTrackCoverArt()` (line 3181) does `this.albums.find(a => a.Name === albumName)` — an O(n) name-based scan that could also match the wrong album if names collide.
|
||||
|
||||
**Action:** Build a `Map<number, library.Album>` (keyed by album ID) when `albums` changes. Selection lookups iterate `selectedAlbums` and do O(1) map lookups. `resolveTrackCoverArt()` uses the map with `expandedAlbumId` instead of name-based search.
|
||||
|
||||
**Applied during:** Part 5 (album-selection extraction).
|
||||
|
||||
### 6e. Remove unnecessary `@state()` from 2 properties
|
||||
|
||||
**Problem:** 15 properties have `@state()`. Two don't need it:
|
||||
- `playlistFilePaths` (line 695) — only rendered inside the playlist submenu, which is conditionally shown when `playlistSubmenuOpen` is true. Since `showPlaylistSubmenu()` sets `playlistFilePaths` before setting `playlistSubmenuOpen`, the reactive update from `playlistSubmenuOpen` will render with the correct paths. `playlistFilePaths` itself doesn't need to trigger a re-render.
|
||||
- `splitIndex` (line 737) — only used to compute `getBeforeEntries()`/`getAfterEntries()`. It's always set before `splitMode` changes (which triggers the render), so it doesn't need independent reactivity.
|
||||
|
||||
**Action:** Remove `@state()` decorator from both. Make them plain private fields.
|
||||
|
||||
**Applied during:** Main file cleanup after extractions.
|
||||
|
||||
### 6f. Single-pass `onGridClick` path traversal
|
||||
|
||||
**Problem:** `onGridClick` (line 3071) calls `composedPath()` once, then iterates it twice with `.some()` — once for `.album-card` and once for `.album-dropdown`.
|
||||
|
||||
**Action:** Single loop checking both classes:
|
||||
```typescript
|
||||
for (const el of e.composedPath()) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
if (el.classList.contains('album-card') ||
|
||||
el.classList.contains('album-dropdown')) return;
|
||||
}
|
||||
```
|
||||
|
||||
**Applied during:** Main file cleanup.
|
||||
|
||||
### 6g. Use expanded album directly for cover art resolution
|
||||
|
||||
**Problem:** `resolveTrackCoverArt(albumName)` (line 3176) does an O(n) `.find()` on `this.albums` by `Name` to get cover art URLs. But we already know which album is expanded (`expandedAlbumId`), and all tracks in the dropdown belong to that album. Name-based lookup has a theoretical collision risk if two albums share the same name.
|
||||
|
||||
**Action:** Replace the name-based search with a direct lookup using `expandedAlbumId` and the `albumById` map from improvement 6d. Falls back gracefully if the album isn't found.
|
||||
|
||||
**Applied during:** Part 5 (album-selection extraction) or main file cleanup.
|
||||
|
||||
### 6h. Prune `albumFilePathCache` to prevent unbounded growth
|
||||
|
||||
**Problem:** The `albumFilePathCache` (Map<number, string[]>) is warmed when albums are selected and read during dragstart, but entries are never removed. Over a session, it grows without bound.
|
||||
|
||||
**Action:**
|
||||
1. Clear the entire cache when `albums` changes (library rescan).
|
||||
2. After `warmAlbumFilePathCache()` completes, remove entries whose album ID is no longer in `selectedAlbums`.
|
||||
|
||||
**Applied during:** Part 5 (album-selection extraction — the cache moves to `AlbumSelectionManager`).
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
| Step | File(s) | Risk | Notes |
|
||||
|------|---------|------|-------|
|
||||
| 1 | `cover-grid-types.ts` | Minimal | Pure move, no logic changes |
|
||||
| 2 | `cover-grid-styles.ts` | Minimal | Pure move, verify `static styles` array works |
|
||||
| 3 | `utils/context-menu-controller.ts` | Medium | Widest blast radius — update 6 components |
|
||||
| 4 | `album-selection.ts` + improvements 6d, 6g, 6h | Low | Contained to cover-grid |
|
||||
| 5 | `scroll-manager.ts` + improvements 6a, 6c | Medium | Largest extraction, deep state interaction |
|
||||
| 6 | Main file cleanup: improvements 6b, 6e, 6f | Low | After extractions, clean up remaining code |
|
||||
| 7 | Verify: `pnpm build` + `pnpm exec tsc --noEmit` | — | Ensure no type errors or build failures |
|
||||
|
||||
Steps 1-2 are safe warmups. Step 3 has the highest cross-cutting value. Steps 4-5 are the structural wins for cover-grid itself. Step 6 is polish. Each step should be independently verifiable with `tsc --noEmit`.
|
||||
@@ -19,12 +19,16 @@ import { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import {
|
||||
ContextMenuController,
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
/** Pixels to change card width per scroll tick. */
|
||||
const ZOOM_STEP = 16;
|
||||
@@ -49,9 +53,13 @@ interface ArtistEntry {
|
||||
}
|
||||
|
||||
@customElement('artists-view')
|
||||
export class ArtistsView extends LitElement {
|
||||
export class ArtistsView
|
||||
extends LitElement
|
||||
implements ContextMenuHost
|
||||
{
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private wheelListenerAttached = false;
|
||||
private lastSearchTerm = '';
|
||||
@@ -81,9 +89,6 @@ export class ArtistsView extends LitElement {
|
||||
|
||||
// ----- Context menu state -----
|
||||
|
||||
@state()
|
||||
private contextMenuOpen = false;
|
||||
|
||||
/**
|
||||
* Artist ID that was right-clicked to open the
|
||||
* context menu. Used as fallback when the
|
||||
@@ -92,39 +97,25 @@ export class ArtistsView extends LitElement {
|
||||
*/
|
||||
private contextMenuArtistId: number | null = null;
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
|
||||
@state()
|
||||
private playlistFilePaths: string[] = [];
|
||||
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: HTMLElement;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup!: HTMLElement;
|
||||
|
||||
private submenuCloseTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
getContextMenuPopup(): HTMLElement | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
// ----- Close handlers -----
|
||||
getPlaylistSubmenuPopup():
|
||||
| HTMLElement
|
||||
| undefined {
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
|
||||
private closeHandler = () =>
|
||||
this.closeContextMenu();
|
||||
|
||||
private mousedownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
const path = e.composedPath();
|
||||
const popup = this.contextMenuPopup;
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
if (submenu && path.includes(submenu)) return;
|
||||
|
||||
this.closeContextMenu();
|
||||
};
|
||||
onContextMenuClose(): void {
|
||||
this.contextMenuArtistId = null;
|
||||
}
|
||||
|
||||
// ----- Grid spacing constants -----
|
||||
|
||||
@@ -219,198 +210,156 @@ export class ArtistsView extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
static override styles = [
|
||||
contextMenuStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-scroll-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.grid-scroll-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
lit-virtualizer {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
lit-virtualizer {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.artist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
background-color: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
}
|
||||
.artist-card:hover {
|
||||
background-color: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
}
|
||||
|
||||
.artist-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.artist-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.artist-card.selected {
|
||||
outline: 2px solid
|
||||
var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.artist-card.selected {
|
||||
outline: 2px solid
|
||||
var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.artist-card.selected .avatar-container {
|
||||
scale: 0.95;
|
||||
}
|
||||
.artist-card.selected
|
||||
.avatar-container {
|
||||
scale: 0.95;
|
||||
}
|
||||
|
||||
.artist-card.selected .artist-name {
|
||||
scale: 0.95;
|
||||
}
|
||||
.artist-card.selected .artist-name {
|
||||
scale: 0.95;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--yj-bg-overlay, #404040) 0%,
|
||||
var(--yj-bg-surface, #282828) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-container {
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--yj-bg-overlay, #404040) 0%,
|
||||
var(--yj-bg-surface, #282828)
|
||||
100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: var(--placeholder-font, 48px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: var(
|
||||
--placeholder-font,
|
||||
48px
|
||||
);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: var(
|
||||
--artist-name-font,
|
||||
14px
|
||||
);
|
||||
font-weight: 500;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--artist-name-pad, 6px) 2px
|
||||
0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.artist-name {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: var(
|
||||
--artist-name-font,
|
||||
14px
|
||||
);
|
||||
font-weight: 500;
|
||||
color: var(
|
||||
--yj-text-primary,
|
||||
#fff
|
||||
);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--artist-name-pad, 6px)
|
||||
2px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.search-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
#495057
|
||||
);
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid
|
||||
var(--yj-border-subtle, #555);
|
||||
white-space: nowrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.search-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
#495057
|
||||
);
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid
|
||||
var(--yj-border-subtle, #555);
|
||||
white-space: nowrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loading-message,
|
||||
.empty-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ====================================
|
||||
* 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;
|
||||
--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;
|
||||
}
|
||||
`;
|
||||
.loading-message,
|
||||
.empty-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
* Lifecycle
|
||||
@@ -431,18 +380,6 @@ export class ArtistsView extends LitElement {
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadArtists(),
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
@@ -453,19 +390,6 @@ export class ArtistsView extends LitElement {
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
}
|
||||
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'contextmenu',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
override updated() {
|
||||
@@ -905,56 +829,12 @@ export class ArtistsView extends LitElement {
|
||||
|
||||
this.contextMenuArtistId = artist.ID;
|
||||
|
||||
this.openContextMenuAt(
|
||||
this.ctxMenu.openAt(
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
};
|
||||
|
||||
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 closeContextMenu() {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
this.playlistFilePaths = [];
|
||||
this.contextMenuArtistId = null;
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async onContextMenuAction(
|
||||
action: string,
|
||||
) {
|
||||
@@ -965,7 +845,11 @@ export class ArtistsView extends LitElement {
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
queueStore.setQueue(filePaths, 0, true);
|
||||
queueStore.setQueue(
|
||||
filePaths,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
break;
|
||||
case 'add-to-queue':
|
||||
queueStore.addTracksToQueue(
|
||||
@@ -979,81 +863,20 @@ export class ArtistsView extends LitElement {
|
||||
break;
|
||||
}
|
||||
|
||||
this.closeContextMenu();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Playlist submenu
|
||||
* ================================================================ */
|
||||
|
||||
private clearSubmenuCloseTimer() {
|
||||
if (this.submenuCloseTimer !== null) {
|
||||
clearTimeout(this.submenuCloseTimer);
|
||||
this.submenuCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSubmenuClose = () => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.submenuCloseTimer = setTimeout(() => {
|
||||
this.submenuCloseTimer = null;
|
||||
this.closePlaylistSubmenu();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
private async showPlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistFilePaths =
|
||||
/**
|
||||
* Resolve artist file paths and show the
|
||||
* playlist submenu.
|
||||
*/
|
||||
private async handleShowPlaylistSubmenu() {
|
||||
const paths =
|
||||
await this.getContextMenuArtistFilePaths();
|
||||
|
||||
if (this.playlistFilePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
void this.ctxMenu.showPlaylistSubmenu(paths);
|
||||
}
|
||||
|
||||
private closePlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (!this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistSubmenuOpen = false;
|
||||
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (submenu) {
|
||||
(submenu as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaylistActionComplete = () => {
|
||||
this.closeContextMenu();
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
* File path resolution
|
||||
* ================================================================ */
|
||||
@@ -1191,9 +1014,10 @@ export class ArtistsView extends LitElement {
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.contextMenuOpen}
|
||||
.active=${this.ctxMenu
|
||||
.contextMenuOpen}
|
||||
>
|
||||
${this.contextMenuOpen
|
||||
${this.ctxMenu.contextMenuOpen
|
||||
? html`
|
||||
<div
|
||||
class="context-menu-panel"
|
||||
@@ -1204,7 +1028,7 @@ export class ArtistsView extends LitElement {
|
||||
'play',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1218,7 +1042,7 @@ export class ArtistsView extends LitElement {
|
||||
'add-to-queue',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1232,7 +1056,7 @@ export class ArtistsView extends LitElement {
|
||||
'play-next',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1243,16 +1067,17 @@ export class ArtistsView extends LitElement {
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
void this.showPlaylistSubmenu();
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
void this.handleShowPlaylistSubmenu();
|
||||
}}
|
||||
@mouseleave=${this
|
||||
.ctxMenu
|
||||
.scheduleSubmenuClose}
|
||||
@click=${(
|
||||
e: Event,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
void this.showPlaylistSubmenu();
|
||||
void this.handleShowPlaylistSubmenu();
|
||||
}}
|
||||
>
|
||||
<wa-icon
|
||||
@@ -1275,21 +1100,24 @@ export class ArtistsView extends LitElement {
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this
|
||||
.active=${this.ctxMenu
|
||||
.playlistSubmenuOpen}
|
||||
>
|
||||
${this.playlistSubmenuOpen
|
||||
${this.ctxMenu.playlistSubmenuOpen
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() =>
|
||||
this.clearSubmenuCloseTimer()}
|
||||
this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this
|
||||
.ctxMenu
|
||||
.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
.filePaths=${this
|
||||
.ctxMenu
|
||||
.playlistFilePaths}
|
||||
@playlist-action-complete=${this
|
||||
.ctxMenu
|
||||
.onPlaylistActionComplete}
|
||||
@click=${(
|
||||
e: Event,
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { GetAlbumTracks } from '@go/library/Library';
|
||||
import type { library } from '@go/models';
|
||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||
|
||||
/**
|
||||
* Manages album and track selection, file-path resolution,
|
||||
* and the drag-cache for the cover grid.
|
||||
*
|
||||
* This is a plain helper class (not a ReactiveController)
|
||||
* because selection state is owned by the component's
|
||||
* `@state()` properties — the manager only computes
|
||||
* derived data (file paths, ranges, cache entries).
|
||||
*/
|
||||
export class AlbumSelectionManager {
|
||||
/**
|
||||
* Map from album ID to Album for O(1) lookups.
|
||||
* Rebuilt via `setAlbums()` when the album list changes.
|
||||
*/
|
||||
private albumById = new Map<number, library.Album>();
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
>();
|
||||
|
||||
/**
|
||||
* Update the album-by-ID index. Call this whenever
|
||||
* the full album list changes (initial load, library
|
||||
* rescan, external album prop change).
|
||||
*
|
||||
* Also clears the file-path cache since album IDs may
|
||||
* have shifted after a rescan.
|
||||
*/
|
||||
setAlbums(albums: library.Album[]): void {
|
||||
this.albumById = new Map(
|
||||
albums.map((a) => [a.ID, a]),
|
||||
);
|
||||
this.albumFilePathCache.clear();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Album selection helpers
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Return the set of album IDs in the range
|
||||
* [from, to] (inclusive, order-independent)
|
||||
* within the filtered album list.
|
||||
*/
|
||||
selectAlbumRange(
|
||||
from: number,
|
||||
to: number,
|
||||
filteredAlbums: library.Album[],
|
||||
): 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 = filteredAlbums[i];
|
||||
|
||||
if (album) {
|
||||
ids.add(album.ID);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file paths for all albums in the given
|
||||
* selection set. Uses the albumById index for
|
||||
* O(1) lookups instead of filtering the full list.
|
||||
*/
|
||||
async getSelectedAlbumFilePaths(
|
||||
selectedAlbums: Set<number>,
|
||||
): Promise<string[]> {
|
||||
const allPaths: string[] = [];
|
||||
|
||||
for (const id of selectedAlbums) {
|
||||
const album = this.albumById.get(id);
|
||||
|
||||
if (!album) continue;
|
||||
|
||||
const paths =
|
||||
await this.getAlbumFilePaths(album);
|
||||
allPaths.push(...paths);
|
||||
}
|
||||
|
||||
return allPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return file paths for the context menu target.
|
||||
* If the right-clicked album is part of the current
|
||||
* selection, return paths for all selected albums.
|
||||
* Otherwise return paths for the right-clicked
|
||||
* album only.
|
||||
*/
|
||||
async getContextMenuAlbumFilePaths(
|
||||
contextMenuAlbumId: number | null,
|
||||
selectedAlbums: Set<number>,
|
||||
): Promise<string[]> {
|
||||
if (
|
||||
contextMenuAlbumId !== null &&
|
||||
!selectedAlbums.has(contextMenuAlbumId)
|
||||
) {
|
||||
const album = this.albumById.get(
|
||||
contextMenuAlbumId,
|
||||
);
|
||||
|
||||
if (album) {
|
||||
return this.getAlbumFilePaths(album);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.getSelectedAlbumFilePaths(
|
||||
selectedAlbums,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file paths for a single album by loading
|
||||
* its tracks from the backend.
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Drag file-path cache
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Pre-resolve file paths for all selected albums so
|
||||
* that dragstart can read them synchronously. Called
|
||||
* fire-and-forget whenever the album selection changes.
|
||||
*
|
||||
* After warming, prunes entries whose album ID is no
|
||||
* longer in the selection to prevent unbounded growth.
|
||||
*/
|
||||
async warmCache(
|
||||
selectedAlbums: Set<number>,
|
||||
): Promise<void> {
|
||||
for (const id of selectedAlbums) {
|
||||
if (this.albumFilePathCache.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const album = this.albumById.get(id);
|
||||
|
||||
if (!album) continue;
|
||||
|
||||
try {
|
||||
const tracks = await GetAlbumTracks(
|
||||
album.ID,
|
||||
);
|
||||
|
||||
// Only store if still selected.
|
||||
if (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.
|
||||
}
|
||||
}
|
||||
|
||||
// Prune stale entries (6h).
|
||||
for (const id of this.albumFilePathCache.keys()) {
|
||||
if (!selectedAlbums.has(id)) {
|
||||
this.albumFilePathCache.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cached file paths for the current album
|
||||
* selection. Returns concatenated paths (may be
|
||||
* incomplete if some albums haven't been cached yet).
|
||||
*/
|
||||
getCachedSelectedPaths(
|
||||
selectedAlbums: Set<number>,
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
for (const id of selectedAlbums) {
|
||||
const paths =
|
||||
this.albumFilePathCache.get(id);
|
||||
|
||||
if (paths) {
|
||||
result.push(...paths);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a single album's paths are in the
|
||||
* cache, and return them if so.
|
||||
*/
|
||||
getCachedAlbumPaths(
|
||||
albumId: number,
|
||||
): string[] | undefined {
|
||||
return this.albumFilePathCache.get(albumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm a single album's cache entry (used by
|
||||
* pointerdown before a potential dragstart).
|
||||
*/
|
||||
async warmSingleAlbum(
|
||||
album: library.Album,
|
||||
): Promise<void> {
|
||||
if (this.albumFilePathCache.has(album.ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = await this.getAlbumFilePaths(
|
||||
album,
|
||||
);
|
||||
|
||||
if (paths.length > 0) {
|
||||
this.albumFilePathCache.set(
|
||||
album.ID,
|
||||
paths,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Track selection helpers
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Return the set of track file paths in the range
|
||||
* [from, to] (inclusive, order-independent).
|
||||
*/
|
||||
selectTrackRange(
|
||||
from: number,
|
||||
to: number,
|
||||
expandedTracks: library.Track[],
|
||||
): 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 = expandedTracks[i];
|
||||
|
||||
if (track) {
|
||||
paths.add(track.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return selected track file paths in their
|
||||
* original track order.
|
||||
*/
|
||||
getSelectedTrackFilePaths(
|
||||
selectedTracks: Set<string>,
|
||||
expandedTracks: library.Track[],
|
||||
): string[] {
|
||||
return expandedTracks
|
||||
.filter((t) =>
|
||||
selectedTracks.has(t.FilePath),
|
||||
)
|
||||
.map((t) => t.FilePath);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Cover art resolution
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Resolve cover art URLs for a track's album.
|
||||
* Uses the albumById index with the expanded album ID
|
||||
* for an O(1) lookup instead of a name-based O(n) scan.
|
||||
*
|
||||
* Falls back to name-based search if the expanded album
|
||||
* doesn't match (defensive).
|
||||
*/
|
||||
resolveTrackCoverArt(
|
||||
albumName: string,
|
||||
expandedAlbumId: number | null,
|
||||
): CoverArtUrls | null {
|
||||
if (!albumName) return null;
|
||||
|
||||
// Prefer the expanded album (we know the track
|
||||
// belongs to it) for an O(1) lookup.
|
||||
if (expandedAlbumId !== null) {
|
||||
const album = this.albumById.get(
|
||||
expandedAlbumId,
|
||||
);
|
||||
|
||||
if (album?.CoverArtPath) {
|
||||
return {
|
||||
coverArtPath: album.CoverArtPath,
|
||||
coverArtSmall: album.CoverArtSmall,
|
||||
coverArtMedium:
|
||||
album.CoverArtMedium,
|
||||
coverArtLarge: album.CoverArtLarge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: name-based search across all albums.
|
||||
for (const album of this.albumById.values()) {
|
||||
if (
|
||||
album.Name === albumName &&
|
||||
album.CoverArtPath
|
||||
) {
|
||||
return {
|
||||
coverArtPath: album.CoverArtPath,
|
||||
coverArtSmall: album.CoverArtSmall,
|
||||
coverArtMedium:
|
||||
album.CoverArtMedium,
|
||||
coverArtLarge: album.CoverArtLarge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { css } from 'lit';
|
||||
import { contextMenuStyles } from '@utils/context-menu-controller.js';
|
||||
|
||||
/** Component-specific styles for the cover grid. */
|
||||
const gridStyles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Sort toolbar
|
||||
* ======================================== */
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
var(--yj-border-subtle, #333);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sort-anchor:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-anchor .sort-label {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sort-dir-btn:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dropdown-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: 140px;
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: var(
|
||||
--yj-text-primary,
|
||||
#fff
|
||||
);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sort-dropdown-panel
|
||||
wa-dropdown-item:hover {
|
||||
background-color: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel
|
||||
wa-dropdown-item.active-sort {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
--wa-color-text-normal: var(
|
||||
--yj-accent,
|
||||
#ffd43b
|
||||
);
|
||||
}
|
||||
|
||||
#sort-dropdown {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.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,
|
||||
transform 0.15s 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: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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.search-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid
|
||||
var(--yj-border-subtle, #555);
|
||||
white-space: nowrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Combined styles for the cover grid component. */
|
||||
export const coverGridStyles = [
|
||||
gridStyles,
|
||||
contextMenuStyles,
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { library } from '@go/models';
|
||||
|
||||
/**
|
||||
* Discriminated context menu target so we know whether the
|
||||
* context-menu is operating on albums or on tracks inside the
|
||||
* dropdown.
|
||||
*/
|
||||
export type ContextMenuTarget =
|
||||
| { kind: 'album' }
|
||||
| { kind: 'track' };
|
||||
|
||||
/**
|
||||
* Item for the virtualized grid.
|
||||
* Carries the original album and its index in the filtered
|
||||
* album list.
|
||||
*/
|
||||
export interface GridEntry {
|
||||
album: library.Album;
|
||||
albumIndex: number;
|
||||
}
|
||||
|
||||
/** Milliseconds to debounce visibility-changed saves. */
|
||||
export const SCROLL_DEBOUNCE_MS = 100;
|
||||
|
||||
/** Pixels to change card width per scroll tick. */
|
||||
export const ZOOM_STEP = 16;
|
||||
|
||||
/** localStorage keys for sort preferences. */
|
||||
export const SORT_FIELD_KEY = 'cover-grid-sort-field';
|
||||
export const SORT_DIR_KEY = 'cover-grid-sort-direction';
|
||||
|
||||
/** Available sort fields for the album grid. */
|
||||
export type AlbumSortField = 'name' | 'artist' | 'year';
|
||||
|
||||
/** Sort option definition for the dropdown. */
|
||||
export interface AlbumSortOption {
|
||||
id: AlbumSortField;
|
||||
label: string;
|
||||
comparator: (
|
||||
a: library.Album,
|
||||
b: library.Album,
|
||||
) => number;
|
||||
}
|
||||
|
||||
/** All available sort options for albums. */
|
||||
export const ALBUM_SORT_OPTIONS: AlbumSortOption[] = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Name',
|
||||
comparator: (a, b) =>
|
||||
a.Name.localeCompare(b.Name),
|
||||
},
|
||||
{
|
||||
id: 'artist',
|
||||
label: 'Artist',
|
||||
comparator: (a, b) => {
|
||||
const cmp = a.ArtistName.localeCompare(
|
||||
b.ArtistName,
|
||||
);
|
||||
|
||||
if (cmp !== 0) return cmp;
|
||||
|
||||
return a.Name.localeCompare(b.Name);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'year',
|
||||
label: 'Year',
|
||||
comparator: (a, b) => {
|
||||
// Albums without a year sort last.
|
||||
if (!a.Year && !b.Year) {
|
||||
return a.Name.localeCompare(b.Name);
|
||||
}
|
||||
|
||||
if (!a.Year) return 1;
|
||||
if (!b.Year) return -1;
|
||||
|
||||
const cmp = a.Year - b.Year;
|
||||
|
||||
if (cmp !== 0) return cmp;
|
||||
|
||||
return a.Name.localeCompare(b.Name);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Sort direction for the album grid. */
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,907 @@
|
||||
import type { LitElement } from 'lit';
|
||||
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||
import type { library } from '@go/models';
|
||||
import type { LibraryController } from '@store/controllers/library-controller';
|
||||
|
||||
import {
|
||||
SCROLL_DEBOUNCE_MS,
|
||||
} from './cover-grid-types.js';
|
||||
import type { GridEntry } from './cover-grid-types.js';
|
||||
|
||||
/**
|
||||
* Grid spacing constants shared between the scroll
|
||||
* manager and the host component.
|
||||
*/
|
||||
export interface GridConstants {
|
||||
readonly GRID_GAP: number;
|
||||
readonly GRID_PADDING: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only interface into the cover-grid component
|
||||
* that the scroll manager needs.
|
||||
*/
|
||||
export interface ScrollManagerHost extends LitElement {
|
||||
readonly libraryCtrl: LibraryController;
|
||||
readonly cachedFilteredAlbums: library.Album[];
|
||||
readonly expandedAlbumId: number | null;
|
||||
readonly expandedTracks: library.Track[];
|
||||
readonly splitMode: boolean;
|
||||
readonly splitIndex: number;
|
||||
readonly cardWidth: number;
|
||||
readonly cardHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages scroll position persistence, resize-aware
|
||||
* scroll preservation, transition overlays, and
|
||||
* split/single mode geometry for the cover grid.
|
||||
*
|
||||
* This is a plain class (not a ReactiveController)
|
||||
* because scroll management is imperative and async,
|
||||
* not reactive.
|
||||
*/
|
||||
export class ScrollManager {
|
||||
private host: ScrollManagerHost;
|
||||
private gc: GridConstants;
|
||||
|
||||
// Scroll position debounce.
|
||||
private scrollDebounceTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
|
||||
// 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;
|
||||
|
||||
/** True while a resize reflow is in progress. */
|
||||
isResizing = false;
|
||||
|
||||
// Scroll restoration across single/split mode
|
||||
// transitions.
|
||||
savedScrollTop = 0;
|
||||
needsScrollRestore = false;
|
||||
showDropdownAfterRestore = false;
|
||||
|
||||
/**
|
||||
* Monotonically increasing counter used to cancel
|
||||
* stale scroll-restore async blocks.
|
||||
*/
|
||||
private scrollRestoreGeneration = 0;
|
||||
|
||||
/**
|
||||
* Set to the generation value when an async
|
||||
* scroll-restore block finishes or is cancelled.
|
||||
*/
|
||||
private scrollRestoreResolved = 0;
|
||||
|
||||
/**
|
||||
* When switching albums, the pixel distance from
|
||||
* the newly-expanded album's top edge to the
|
||||
* viewport top.
|
||||
*/
|
||||
savedAlbumViewportOffset: number | null = null;
|
||||
|
||||
/** Overlay element showing the old grid state
|
||||
* while a mode transition is in flight. */
|
||||
private transitionOverlay: HTMLDivElement | null =
|
||||
null;
|
||||
|
||||
/** Cached index of the expanded album in the
|
||||
* filtered list. -1 when no album is expanded
|
||||
* or the album isn't in the filtered list. */
|
||||
private expandedAlbumIndex = -1;
|
||||
|
||||
/** The expanded album ID that corresponds to the
|
||||
* cached index. Used to detect invalidation. */
|
||||
private expandedAlbumIndexId: number | null = null;
|
||||
|
||||
/** The filtered-albums reference used to compute
|
||||
* the cached index. Used to detect invalidation. */
|
||||
private expandedAlbumIndexAlbums:
|
||||
library.Album[] = [];
|
||||
|
||||
constructor(
|
||||
host: ScrollManagerHost,
|
||||
gc: GridConstants,
|
||||
) {
|
||||
this.host = host;
|
||||
this.gc = gc;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Expanded album index cache (improvement 6c)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Return the index of the expanded album in the
|
||||
* filtered list. Cached and invalidated when
|
||||
* `expandedAlbumId` or `cachedFilteredAlbums`
|
||||
* changes.
|
||||
*/
|
||||
getExpandedAlbumIndex(): number {
|
||||
const id = this.host.expandedAlbumId;
|
||||
const albums = this.host.cachedFilteredAlbums;
|
||||
|
||||
if (
|
||||
id === this.expandedAlbumIndexId &&
|
||||
albums === this.expandedAlbumIndexAlbums
|
||||
) {
|
||||
return this.expandedAlbumIndex;
|
||||
}
|
||||
|
||||
this.expandedAlbumIndexId = id;
|
||||
this.expandedAlbumIndexAlbums = albums;
|
||||
|
||||
if (id === null) {
|
||||
this.expandedAlbumIndex = -1;
|
||||
} else {
|
||||
this.expandedAlbumIndex = albums.findIndex(
|
||||
(a) => a.ID === id,
|
||||
);
|
||||
}
|
||||
|
||||
return this.expandedAlbumIndex;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Lifecycle
|
||||
// ================================================================
|
||||
|
||||
/** Clean up timers and observers. */
|
||||
teardown(): void {
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
}
|
||||
|
||||
if (this.resizeDebounceTimer !== null) {
|
||||
clearTimeout(this.resizeDebounceTimer);
|
||||
}
|
||||
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.removeOverlay();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Scroll position (index-based)
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Restore scroll position from the library store
|
||||
* after initial album load.
|
||||
*/
|
||||
restoreScrollPosition(
|
||||
virtualizer: LitVirtualizer | undefined,
|
||||
): void {
|
||||
const saved =
|
||||
this.host.libraryCtrl.getScrollPosition(
|
||||
'albums',
|
||||
);
|
||||
|
||||
if (saved <= 0 || !virtualizer) return;
|
||||
|
||||
const safeIndex = Math.min(
|
||||
saved,
|
||||
this.host.cachedFilteredAlbums.length - 1,
|
||||
);
|
||||
|
||||
if (safeIndex <= 0) return;
|
||||
|
||||
virtualizer.scrollToIndex(safeIndex, 'start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save scroll position from the first visible album.
|
||||
* In split mode we use the before-entries; in single
|
||||
* mode we use the full grid entries.
|
||||
*/
|
||||
onVisibilityChanged(
|
||||
first: number,
|
||||
getEntries: () => GridEntry[],
|
||||
): void {
|
||||
if (this.isResizing) return;
|
||||
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
}
|
||||
|
||||
this.scrollDebounceTimer = setTimeout(() => {
|
||||
const entries = getEntries();
|
||||
const entry = entries[first];
|
||||
|
||||
if (entry) {
|
||||
this.host.libraryCtrl.setScrollPosition(
|
||||
'albums',
|
||||
entry.albumIndex,
|
||||
);
|
||||
}
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Resize-aware scroll preservation
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Set up a ResizeObserver on the scroll container
|
||||
* to preserve scroll position across width changes.
|
||||
*/
|
||||
setupResizeObserver(
|
||||
container: HTMLElement,
|
||||
onSplitResize: () => Promise<void>,
|
||||
): void {
|
||||
// Guard against stacked observers.
|
||||
this.resizeObserver?.disconnect();
|
||||
this.currentColumnCount =
|
||||
this.getColumnCount(container);
|
||||
|
||||
const restoreScroll = () => {
|
||||
const pending = this.pendingFocus;
|
||||
|
||||
this.pendingFocus = null;
|
||||
this.isResizing = false;
|
||||
|
||||
if (!pending) return;
|
||||
|
||||
const newColumns =
|
||||
this.getColumnCount(container);
|
||||
this.currentColumnCount = newColumns;
|
||||
|
||||
// If a dropdown is open, delegate to the
|
||||
// host for split recomputation.
|
||||
if (
|
||||
this.host.splitMode &&
|
||||
this.host.expandedAlbumId !== null
|
||||
) {
|
||||
void onSplitResize();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const rowStep =
|
||||
this.host.cardHeight + gap;
|
||||
|
||||
const newRow = Math.floor(
|
||||
pending.albumIndex / newColumns,
|
||||
);
|
||||
const newY = pad + newRow * rowStep;
|
||||
|
||||
container.scrollTop =
|
||||
newY - pending.viewportOffset;
|
||||
};
|
||||
|
||||
this.resizeObserver = new ResizeObserver(
|
||||
() => {
|
||||
const rowStep =
|
||||
this.host.cardHeight +
|
||||
this.gc.GRID_GAP;
|
||||
|
||||
if (this.pendingFocus === null) {
|
||||
this.isResizing = true;
|
||||
this.captureFocusPoint(
|
||||
container,
|
||||
rowStep,
|
||||
);
|
||||
}
|
||||
|
||||
const newColumns =
|
||||
this.getColumnCount(container);
|
||||
|
||||
if (
|
||||
newColumns !==
|
||||
this.currentColumnCount
|
||||
) {
|
||||
if (
|
||||
this.resizeDebounceTimer !==
|
||||
null
|
||||
) {
|
||||
clearTimeout(
|
||||
this.resizeDebounceTimer,
|
||||
);
|
||||
this.resizeDebounceTimer =
|
||||
null;
|
||||
}
|
||||
|
||||
restoreScroll();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.resizeDebounceTimer !== null
|
||||
) {
|
||||
clearTimeout(
|
||||
this.resizeDebounceTimer,
|
||||
);
|
||||
}
|
||||
|
||||
this.resizeDebounceTimer = setTimeout(
|
||||
restoreScroll,
|
||||
100,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this.resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the focus point for scroll restoration.
|
||||
*/
|
||||
private captureFocusPoint(
|
||||
container: HTMLElement,
|
||||
rowStep: number,
|
||||
): void {
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const cols = this.currentColumnCount;
|
||||
const filtered =
|
||||
this.host.cachedFilteredAlbums;
|
||||
|
||||
// Prefer the expanded album as focus.
|
||||
if (this.host.expandedAlbumId !== null) {
|
||||
const idx = this.getExpandedAlbumIndex();
|
||||
|
||||
if (idx >= 0) {
|
||||
const albumRow = Math.floor(
|
||||
idx / cols,
|
||||
);
|
||||
const albumY =
|
||||
pad + albumRow * rowStep;
|
||||
|
||||
this.pendingFocus = {
|
||||
albumIndex: idx,
|
||||
viewportOffset:
|
||||
albumY - container.scrollTop,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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, filtered.length - 1),
|
||||
);
|
||||
|
||||
const albumY = pad + centerRow * rowStep;
|
||||
|
||||
this.pendingFocus = {
|
||||
albumIndex,
|
||||
viewportOffset:
|
||||
albumY - container.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Column count / geometry helpers
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Compute the number of columns that fit in the
|
||||
* given container.
|
||||
*/
|
||||
getColumnCount(
|
||||
container?: HTMLElement,
|
||||
): number {
|
||||
if (!container) return 1;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const availableWidth =
|
||||
container.clientWidth - pad * 2;
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(availableWidth + gap) /
|
||||
(this.host.cardWidth + gap),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Container width in pixels. */
|
||||
getContainerWidth(
|
||||
container?: HTMLElement,
|
||||
): number {
|
||||
return container?.clientWidth ?? 800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of the album row (left of leftmost card to
|
||||
* right of rightmost card).
|
||||
*/
|
||||
getGridRowWidth(
|
||||
container?: HTMLElement,
|
||||
): number {
|
||||
const cols = this.getColumnCount(container);
|
||||
const gap = this.gc.GRID_GAP;
|
||||
|
||||
return (
|
||||
cols * this.host.cardWidth +
|
||||
(cols - 1) * gap
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal offset of the carat so it points at
|
||||
* the center of the expanded album card.
|
||||
*/
|
||||
getCaratOffset(
|
||||
container?: HTMLElement,
|
||||
): number {
|
||||
const idx = this.getExpandedAlbumIndex();
|
||||
|
||||
if (idx < 0) return 0;
|
||||
|
||||
const cols = this.getColumnCount(container);
|
||||
const colIndex = idx % cols;
|
||||
const gap = this.gc.GRID_GAP;
|
||||
|
||||
return (
|
||||
colIndex *
|
||||
(this.host.cardWidth + gap) +
|
||||
this.host.cardWidth / 2
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Split-mode helpers
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Compute the split point and return it. The
|
||||
* component assigns this to its `splitIndex` state.
|
||||
*/
|
||||
computeSplitIndex(
|
||||
container?: HTMLElement,
|
||||
): number {
|
||||
const filtered =
|
||||
this.host.cachedFilteredAlbums;
|
||||
|
||||
const idx = this.getExpandedAlbumIndex();
|
||||
|
||||
if (idx < 0) return filtered.length;
|
||||
|
||||
const columns =
|
||||
this.getColumnCount(container);
|
||||
|
||||
return Math.min(
|
||||
(Math.floor(idx / columns) + 1) * columns,
|
||||
filtered.length,
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Transition overlay
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Capture the current scroll container as a static
|
||||
* overlay.
|
||||
*/
|
||||
captureOverlay(
|
||||
container: HTMLElement | undefined,
|
||||
shadowRoot: ShadowRoot | null,
|
||||
): void {
|
||||
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;';
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
inner.style.transform =
|
||||
`translateY(-${scrollY}px)`;
|
||||
|
||||
overlay.appendChild(inner);
|
||||
shadowRoot?.appendChild(overlay);
|
||||
this.transitionOverlay = overlay;
|
||||
|
||||
container.style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the snapshot overlay and reveal the real
|
||||
* scroll container.
|
||||
*/
|
||||
removeOverlay(): void {
|
||||
if (this.transitionOverlay) {
|
||||
this.transitionOverlay.remove();
|
||||
this.transitionOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the real scroll container (call separately
|
||||
* when the overlay has already been removed or was
|
||||
* never created).
|
||||
*/
|
||||
revealContainer(
|
||||
container: HTMLElement | undefined,
|
||||
): void {
|
||||
if (container) {
|
||||
container.style.visibility = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Dropdown scroll positioning
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Wait for the "before" virtualizer to finish its
|
||||
* layout pass.
|
||||
*/
|
||||
async awaitBeforeLayout(
|
||||
shadowRoot: ShadowRoot | null,
|
||||
): Promise<void> {
|
||||
const virt = shadowRoot?.querySelector(
|
||||
'#grid-before',
|
||||
) as LitVirtualizer | null;
|
||||
|
||||
await virt?.layoutComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current scrollTop converted to
|
||||
* single-mode (dropdown-free) coordinates.
|
||||
*/
|
||||
computeAdjustedScrollTop(
|
||||
container: HTMLElement | undefined,
|
||||
shadowRoot: ShadowRoot | null,
|
||||
): number {
|
||||
if (!container) return 0;
|
||||
|
||||
const raw = container.scrollTop;
|
||||
|
||||
if (!this.host.splitMode) return raw;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const columns =
|
||||
this.getColumnCount(container);
|
||||
const rowStep = this.host.cardHeight + gap;
|
||||
const beforeRows = Math.ceil(
|
||||
this.host.splitIndex / columns,
|
||||
);
|
||||
|
||||
const dropdownTop =
|
||||
pad + beforeRows * rowStep;
|
||||
|
||||
if (raw <= dropdownTop) return raw;
|
||||
|
||||
const dropdown = shadowRoot?.querySelector(
|
||||
'album-dropdown',
|
||||
);
|
||||
const dropdownHeight =
|
||||
(dropdown as HTMLElement)?.offsetHeight ??
|
||||
0;
|
||||
|
||||
return raw - dropdownHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set scrollTop on the scroll container with
|
||||
* retry logic for virtualizer expansion.
|
||||
*/
|
||||
async restoreScrollTop(
|
||||
container: HTMLElement | undefined,
|
||||
target: number,
|
||||
): Promise<void> {
|
||||
if (!container) return;
|
||||
|
||||
const maxAttempts = 10;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
container.scrollTop = target;
|
||||
|
||||
if (
|
||||
container.scrollTop >= target ||
|
||||
target <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 with minimal movement.
|
||||
*/
|
||||
async scrollToShowDropdown(
|
||||
container: HTMLElement | undefined,
|
||||
shadowRoot: ShadowRoot | null,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!container ||
|
||||
this.host.expandedAlbumId === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedIndex =
|
||||
this.getExpandedAlbumIndex();
|
||||
|
||||
if (expandedIndex < 0) return;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const columns =
|
||||
this.getColumnCount(container);
|
||||
const rowStep = this.host.cardHeight + gap;
|
||||
const albumRow = Math.floor(
|
||||
expandedIndex / columns,
|
||||
);
|
||||
|
||||
const albumTop =
|
||||
pad + albumRow * rowStep - gap / 2;
|
||||
|
||||
const dropdown = shadowRoot?.querySelector(
|
||||
'album-dropdown',
|
||||
);
|
||||
|
||||
if (!dropdown) return;
|
||||
|
||||
await (dropdown as LitElement).updateComplete;
|
||||
|
||||
const beforeRows = Math.ceil(
|
||||
this.host.splitIndex / columns,
|
||||
);
|
||||
const dropdownTop =
|
||||
pad + beforeRows * rowStep;
|
||||
const dropdownBottom =
|
||||
dropdownTop +
|
||||
(dropdown as HTMLElement).offsetHeight;
|
||||
|
||||
const viewTop = container.scrollTop;
|
||||
const viewHeight = container.clientHeight;
|
||||
|
||||
const minScroll = dropdownBottom - viewHeight;
|
||||
const maxScroll = albumTop;
|
||||
|
||||
let newScrollTop: number;
|
||||
|
||||
if (minScroll <= maxScroll) {
|
||||
newScrollTop = Math.max(
|
||||
minScroll,
|
||||
Math.min(viewTop, maxScroll),
|
||||
);
|
||||
} else {
|
||||
newScrollTop = albumTop;
|
||||
}
|
||||
|
||||
if (newScrollTop !== viewTop) {
|
||||
await this.restoreScrollTop(
|
||||
container,
|
||||
newScrollTop,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// willUpdate / updated helpers
|
||||
//
|
||||
// Called from the component's lifecycle methods to
|
||||
// compute scroll-related state transitions.
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Check whether a scroll-restore async block is
|
||||
* currently in flight.
|
||||
*/
|
||||
get restoreInFlight(): boolean {
|
||||
return (
|
||||
this.scrollRestoreGeneration >
|
||||
this.scrollRestoreResolved
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the anchor capture for an exit-split
|
||||
* transition when switching albums (not closing).
|
||||
* Records the viewport offset of the newly-expanded
|
||||
* album in the old split layout.
|
||||
*/
|
||||
captureAnchorOffset(
|
||||
container: HTMLElement | undefined,
|
||||
shadowRoot: ShadowRoot | null,
|
||||
): void {
|
||||
if (this.host.expandedAlbumId === null) {
|
||||
this.savedAlbumViewportOffset = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const rawScrollTop =
|
||||
container?.scrollTop ?? 0;
|
||||
const idx = this.getExpandedAlbumIndex();
|
||||
|
||||
if (idx < 0) return;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const cols =
|
||||
this.getColumnCount(container);
|
||||
const rowStep = this.host.cardHeight + gap;
|
||||
const row = Math.floor(idx / cols);
|
||||
|
||||
const albumY = pad + row * rowStep;
|
||||
|
||||
const oldBeforeRows = Math.ceil(
|
||||
this.host.splitIndex / cols,
|
||||
);
|
||||
const oldDropdownTop =
|
||||
pad + oldBeforeRows * rowStep;
|
||||
const dropdown = shadowRoot?.querySelector(
|
||||
'album-dropdown',
|
||||
);
|
||||
const oldDropdownHeight =
|
||||
(dropdown as HTMLElement)?.offsetHeight ??
|
||||
0;
|
||||
|
||||
const albumYOldSplit =
|
||||
albumY >= oldDropdownTop
|
||||
? albumY + oldDropdownHeight
|
||||
: albumY;
|
||||
|
||||
this.savedAlbumViewportOffset =
|
||||
albumYOldSplit - rawScrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the async scroll-restore sequence from the
|
||||
* component's `updated()` callback.
|
||||
*/
|
||||
runScrollRestore(
|
||||
container: HTMLElement | undefined,
|
||||
shadowRoot: ShadowRoot | null,
|
||||
expandedAlbumId: number | null,
|
||||
updateComplete: Promise<boolean>,
|
||||
): void {
|
||||
this.needsScrollRestore = false;
|
||||
|
||||
const saved = this.savedScrollTop;
|
||||
const showDropdown =
|
||||
this.showDropdownAfterRestore;
|
||||
|
||||
const switching =
|
||||
!showDropdown &&
|
||||
expandedAlbumId !== null;
|
||||
|
||||
const gen = ++this.scrollRestoreGeneration;
|
||||
|
||||
void (async () => {
|
||||
await updateComplete;
|
||||
|
||||
if (gen !== this.scrollRestoreGeneration) {
|
||||
this.scrollRestoreResolved = gen;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.restoreScrollTop(
|
||||
container,
|
||||
saved,
|
||||
);
|
||||
|
||||
if (gen !== this.scrollRestoreGeneration) {
|
||||
this.scrollRestoreResolved = gen;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (showDropdown) {
|
||||
if (
|
||||
this.savedAlbumViewportOffset !==
|
||||
null &&
|
||||
expandedAlbumId !== null
|
||||
) {
|
||||
const idx =
|
||||
this.getExpandedAlbumIndex();
|
||||
|
||||
if (idx >= 0) {
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad =
|
||||
this.gc.GRID_PADDING;
|
||||
const cols =
|
||||
this.getColumnCount(
|
||||
container,
|
||||
);
|
||||
const rowStep =
|
||||
this.host.cardHeight +
|
||||
gap;
|
||||
const row = Math.floor(
|
||||
idx / cols,
|
||||
);
|
||||
const albumY =
|
||||
pad + row * rowStep;
|
||||
const anchor =
|
||||
albumY -
|
||||
this
|
||||
.savedAlbumViewportOffset!;
|
||||
|
||||
await this.restoreScrollTop(
|
||||
container,
|
||||
anchor,
|
||||
);
|
||||
}
|
||||
|
||||
this.savedAlbumViewportOffset =
|
||||
null;
|
||||
}
|
||||
|
||||
if (
|
||||
gen !==
|
||||
this.scrollRestoreGeneration
|
||||
) {
|
||||
this.scrollRestoreResolved = gen;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.scrollToShowDropdown(
|
||||
container,
|
||||
shadowRoot,
|
||||
);
|
||||
}
|
||||
|
||||
if (gen !== this.scrollRestoreGeneration) {
|
||||
this.scrollRestoreResolved = gen;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!switching) {
|
||||
this.removeOverlay();
|
||||
this.revealContainer(container);
|
||||
}
|
||||
|
||||
this.scrollRestoreResolved = gen;
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,15 @@ import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { Events } from '../../events';
|
||||
import {
|
||||
ContextMenuController,
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
/** Pixels to change card width per scroll tick. */
|
||||
const ZOOM_STEP = 16;
|
||||
@@ -49,9 +53,13 @@ interface GenreEntry {
|
||||
}
|
||||
|
||||
@customElement('genres-view')
|
||||
export class GenresView extends LitElement {
|
||||
export class GenresView
|
||||
extends LitElement
|
||||
implements ContextMenuHost
|
||||
{
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private wheelListenerAttached = false;
|
||||
private lastSearchTerm = '';
|
||||
@@ -84,9 +92,6 @@ export class GenresView extends LitElement {
|
||||
|
||||
// ----- Context menu state -----
|
||||
|
||||
@state()
|
||||
private contextMenuOpen = false;
|
||||
|
||||
/**
|
||||
* Genre name that was right-clicked to open the
|
||||
* context menu. Used as fallback when the
|
||||
@@ -95,39 +100,27 @@ export class GenresView extends LitElement {
|
||||
*/
|
||||
private contextMenuGenreName: string | null = null;
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
|
||||
@state()
|
||||
private playlistFilePaths: string[] = [];
|
||||
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: HTMLElement;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup!: HTMLElement;
|
||||
|
||||
private submenuCloseTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
// ----- ContextMenuHost interface -----
|
||||
|
||||
// ----- Close handlers -----
|
||||
getContextMenuPopup(): HTMLElement | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
private closeHandler = () =>
|
||||
this.closeContextMenu();
|
||||
getPlaylistSubmenuPopup():
|
||||
| HTMLElement
|
||||
| undefined {
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
|
||||
private mousedownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
const path = e.composedPath();
|
||||
const popup = this.contextMenuPopup;
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
if (submenu && path.includes(submenu)) return;
|
||||
|
||||
this.closeContextMenu();
|
||||
};
|
||||
onContextMenuClose(): void {
|
||||
this.contextMenuGenreName = null;
|
||||
}
|
||||
|
||||
// ----- Grid spacing constants -----
|
||||
|
||||
@@ -221,7 +214,9 @@ export class GenresView extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
static override styles = [
|
||||
contextMenuStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -363,59 +358,8 @@ export class GenresView extends LitElement {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ====================================
|
||||
* 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;
|
||||
--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;
|
||||
}
|
||||
`;
|
||||
`,
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
* Lifecycle
|
||||
@@ -436,18 +380,6 @@ export class GenresView extends LitElement {
|
||||
Events.LibraryScanComplete,
|
||||
() => this.loadGenres(),
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
@@ -458,19 +390,6 @@ export class GenresView extends LitElement {
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
}
|
||||
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'contextmenu',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
override updated() {
|
||||
@@ -954,56 +873,12 @@ export class GenresView extends LitElement {
|
||||
|
||||
this.contextMenuGenreName = genre.name;
|
||||
|
||||
this.openContextMenuAt(
|
||||
this.ctxMenu.openAt(
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
};
|
||||
|
||||
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 closeContextMenu() {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
this.playlistFilePaths = [];
|
||||
this.contextMenuGenreName = null;
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onContextMenuAction(action: string) {
|
||||
const filePaths =
|
||||
this.getContextMenuGenreFilePaths();
|
||||
@@ -1026,87 +901,9 @@ export class GenresView extends LitElement {
|
||||
break;
|
||||
}
|
||||
|
||||
this.closeContextMenu();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Playlist submenu
|
||||
* ================================================================ */
|
||||
|
||||
private clearSubmenuCloseTimer() {
|
||||
if (this.submenuCloseTimer !== null) {
|
||||
clearTimeout(this.submenuCloseTimer);
|
||||
this.submenuCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSubmenuClose = () => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.submenuCloseTimer = setTimeout(() => {
|
||||
this.submenuCloseTimer = null;
|
||||
this.closePlaylistSubmenu();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
private showPlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistFilePaths =
|
||||
this.getContextMenuGenreFilePaths();
|
||||
|
||||
if (this.playlistFilePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistSubmenuOpen = true;
|
||||
|
||||
void this.updateComplete.then(() => {
|
||||
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() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (!this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistSubmenuOpen = false;
|
||||
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (submenu) {
|
||||
(submenu as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaylistActionComplete = () => {
|
||||
this.closeContextMenu();
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
* File path resolution
|
||||
* ================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
* Helpers
|
||||
* ================================================================ */
|
||||
@@ -1202,9 +999,10 @@ export class GenresView extends LitElement {
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.contextMenuOpen}
|
||||
.active=${this.ctxMenu
|
||||
.contextMenuOpen}
|
||||
>
|
||||
${this.contextMenuOpen
|
||||
${this.ctxMenu.contextMenuOpen
|
||||
? html`
|
||||
<div
|
||||
class="context-menu-panel"
|
||||
@@ -1215,7 +1013,7 @@ export class GenresView extends LitElement {
|
||||
'play',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1229,7 +1027,7 @@ export class GenresView extends LitElement {
|
||||
'add-to-queue',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1243,7 +1041,7 @@ export class GenresView extends LitElement {
|
||||
'play-next',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1254,16 +1052,21 @@ export class GenresView extends LitElement {
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.showPlaylistSubmenu();
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
void this.ctxMenu.showPlaylistSubmenu(
|
||||
this.getContextMenuGenreFilePaths(),
|
||||
);
|
||||
}}
|
||||
@mouseleave=${this
|
||||
.ctxMenu
|
||||
.scheduleSubmenuClose}
|
||||
@click=${(
|
||||
e: Event,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
this.showPlaylistSubmenu();
|
||||
void this.ctxMenu.showPlaylistSubmenu(
|
||||
this.getContextMenuGenreFilePaths(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<wa-icon
|
||||
@@ -1286,21 +1089,24 @@ export class GenresView extends LitElement {
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this
|
||||
.active=${this.ctxMenu
|
||||
.playlistSubmenuOpen}
|
||||
>
|
||||
${this.playlistSubmenuOpen
|
||||
${this.ctxMenu.playlistSubmenuOpen
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() =>
|
||||
this.clearSubmenuCloseTimer()}
|
||||
this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this
|
||||
.ctxMenu
|
||||
.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
.filePaths=${this
|
||||
.ctxMenu
|
||||
.playlistFilePaths}
|
||||
@playlist-action-complete=${this
|
||||
.ctxMenu
|
||||
.onPlaylistActionComplete}
|
||||
@click=${(
|
||||
e: Event,
|
||||
|
||||
@@ -24,7 +24,6 @@ import { PlaylistController } from '@store/controllers/playlist-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '@components/track-info/track-info';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import {
|
||||
@@ -41,6 +40,9 @@ import {
|
||||
removeDragImage,
|
||||
} from '@utils/drag-image';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { ContextMenuController } from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { contextMenuStyles } from '@utils/context-menu-controller.js';
|
||||
import '@components/track-details/track-details.js';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||
@@ -56,12 +58,23 @@ interface PlaylistEntry {
|
||||
@customElement('playlist-view')
|
||||
export class PlaylistView
|
||||
extends LitElement
|
||||
implements SelectionHost
|
||||
implements SelectionHost, ContextMenuHost
|
||||
{
|
||||
private player = new PlayerController(this);
|
||||
private playlistCtrl = new PlaylistController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
|
||||
getContextMenuPopup(): HTMLElement | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
getPlaylistSubmenuPopup():
|
||||
| HTMLElement
|
||||
| undefined {
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
private cancelScanComplete?: () => void;
|
||||
private scrollDebounceTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
@@ -162,8 +175,6 @@ export class PlaylistView
|
||||
@state() private refreshing = false;
|
||||
@state() private creating = false;
|
||||
@state() private newPlaylistName = '';
|
||||
@state() private contextMenuOpen = false;
|
||||
@state() private playlistSubmenuOpen = false;
|
||||
@state() private playlistContextMenuOpen = false;
|
||||
@state() private playlistContextMenuIndex = -1;
|
||||
@state() private renamingPlaylistIndex = -1;
|
||||
@@ -198,31 +209,23 @@ export class PlaylistView
|
||||
@query('track-details')
|
||||
private trackDetailsDialog!: TrackDetails;
|
||||
|
||||
private submenuCloseTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
private closePlaylistCtxMenuHandler =
|
||||
() => this.closePlaylistContextMenu();
|
||||
|
||||
private closeContextMenuHandler = () => {
|
||||
this.closeContextMenu();
|
||||
this.closePlaylistContextMenu();
|
||||
};
|
||||
private playlistCtxMenuMousedownHandler =
|
||||
(e: MouseEvent) => {
|
||||
const plPopup =
|
||||
this.playlistContextMenuPopup;
|
||||
|
||||
private mousedownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
const path = e.composedPath();
|
||||
const popup = this.contextMenuPopup;
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
const plPopup =
|
||||
this.playlistContextMenuPopup;
|
||||
if (
|
||||
plPopup &&
|
||||
e.composedPath().includes(plPopup)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
if (submenu && path.includes(submenu)) return;
|
||||
if (plPopup && path.includes(plPopup)) return;
|
||||
|
||||
this.closeContextMenu();
|
||||
this.closePlaylistContextMenu();
|
||||
};
|
||||
this.closePlaylistContextMenu();
|
||||
};
|
||||
|
||||
private clearSelectionHandler = (e: MouseEvent) => {
|
||||
const path = e.composedPath();
|
||||
@@ -321,7 +324,9 @@ export class PlaylistView
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
static override styles = [
|
||||
contextMenuStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -705,43 +710,6 @@ export class PlaylistView
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#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;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #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;
|
||||
}
|
||||
|
||||
#playlist-context-menu {
|
||||
z-index: 200;
|
||||
}
|
||||
@@ -777,7 +745,7 @@ export class PlaylistView
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
`;
|
||||
`];
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -788,15 +756,15 @@ export class PlaylistView
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.closeContextMenuHandler,
|
||||
this.closePlaylistCtxMenuHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
this.closeContextMenuHandler,
|
||||
this.closePlaylistCtxMenuHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
this.playlistCtxMenuMousedownHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
@@ -815,15 +783,15 @@ export class PlaylistView
|
||||
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.closeContextMenuHandler,
|
||||
this.closePlaylistCtxMenuHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'contextmenu',
|
||||
this.closeContextMenuHandler,
|
||||
this.closePlaylistCtxMenuHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
this.playlistCtxMenuMousedownHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
@@ -1040,30 +1008,7 @@ export class PlaylistView
|
||||
this.selection.handleContextMenu(
|
||||
String(trackIndex),
|
||||
);
|
||||
this.contextMenuOpen = true;
|
||||
|
||||
// Position at mouse cursor using a virtual anchor.
|
||||
this.updateComplete.then(() => {
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).anchor = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
right: e.clientX,
|
||||
bottom: e.clientY,
|
||||
};
|
||||
},
|
||||
};
|
||||
(popup as any).active = true;
|
||||
}
|
||||
});
|
||||
this.ctxMenu.openAt(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
private onContextMenuAction(action: string) {
|
||||
@@ -1092,7 +1037,8 @@ export class PlaylistView
|
||||
break;
|
||||
}
|
||||
|
||||
this.closeContextMenu(true);
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private openTrackDetails(filePath: string) {
|
||||
@@ -1451,82 +1397,7 @@ export class PlaylistView
|
||||
this.onEmptyZoneDrop(e);
|
||||
};
|
||||
|
||||
private closeContextMenu(clearSelection = false) {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
|
||||
if (clearSelection) {
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSubmenuCloseTimer() {
|
||||
if (this.submenuCloseTimer !== null) {
|
||||
clearTimeout(this.submenuCloseTimer);
|
||||
this.submenuCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSubmenuClose = () => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.submenuCloseTimer = setTimeout(() => {
|
||||
this.submenuCloseTimer = null;
|
||||
this.closePlaylistSubmenu();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
private async showPlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
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() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (!this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistSubmenuOpen = false;
|
||||
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (submenu) {
|
||||
(submenu as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaylistActionComplete = () => {
|
||||
this.closeContextMenu(true);
|
||||
};
|
||||
|
||||
private isActiveTrack(
|
||||
track: playlist.Track,
|
||||
@@ -1549,7 +1420,7 @@ export class PlaylistView
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.closeContextMenu();
|
||||
this.ctxMenu.close();
|
||||
this.playlistContextMenuIndex = index;
|
||||
this.playlistContextMenuOpen = true;
|
||||
|
||||
@@ -1854,9 +1725,10 @@ export class PlaylistView
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.contextMenuOpen}
|
||||
.active=${this.ctxMenu
|
||||
.contextMenuOpen}
|
||||
>
|
||||
${this.contextMenuOpen
|
||||
${this.ctxMenu.contextMenuOpen
|
||||
? html`
|
||||
<div class="context-menu-panel">
|
||||
<wa-dropdown-item
|
||||
@@ -1865,7 +1737,7 @@ export class PlaylistView
|
||||
'play',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1879,7 +1751,7 @@ export class PlaylistView
|
||||
'add-to-queue',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1893,7 +1765,7 @@ export class PlaylistView
|
||||
'play-next',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1907,7 +1779,7 @@ export class PlaylistView
|
||||
'remove',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1918,14 +1790,15 @@ export class PlaylistView
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
void this.showPlaylistSubmenu();
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
||||
}}
|
||||
@mouseleave=${this
|
||||
.ctxMenu
|
||||
.scheduleSubmenuClose}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
void this.showPlaylistSubmenu();
|
||||
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
||||
}}
|
||||
>
|
||||
<wa-icon
|
||||
@@ -1948,7 +1821,7 @@ export class PlaylistView
|
||||
'track-details',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1969,20 +1842,23 @@ export class PlaylistView
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.playlistSubmenuOpen}
|
||||
.active=${this.ctxMenu
|
||||
.playlistSubmenuOpen}
|
||||
>
|
||||
${this.playlistSubmenuOpen &&
|
||||
${this.ctxMenu.playlistSubmenuOpen &&
|
||||
this.selection.hasSelection
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() =>
|
||||
this.clearSubmenuCloseTimer()}
|
||||
this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this
|
||||
.ctxMenu
|
||||
.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
.filePaths=${this.getSelectedFilePaths()}
|
||||
@playlist-action-complete=${this
|
||||
.ctxMenu
|
||||
.onPlaylistActionComplete}
|
||||
@click=${(e: Event) =>
|
||||
e.stopPropagation()}
|
||||
|
||||
@@ -17,6 +17,11 @@ import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||
import type { QueueTrack } from '@store/queue-store';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import {
|
||||
ContextMenuController,
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import {
|
||||
hasTrackPayload,
|
||||
getDragPayload,
|
||||
@@ -41,10 +46,11 @@ const DEFAULT_WIDTH = 320;
|
||||
@customElement('queue-panel')
|
||||
export class QueuePanel
|
||||
extends LitElement
|
||||
implements SelectionHost
|
||||
implements SelectionHost, ContextMenuHost
|
||||
{
|
||||
private queue = new QueueController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
@@ -55,12 +61,6 @@ export class QueuePanel
|
||||
@state()
|
||||
private playlistPickerOpen = false;
|
||||
|
||||
@state()
|
||||
private contextMenuOpen = false;
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
|
||||
private dragOver = false;
|
||||
private dragEnterCount = 0;
|
||||
|
||||
@@ -103,24 +103,6 @@ export class QueuePanel
|
||||
}
|
||||
};
|
||||
|
||||
private submenuCloseTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
|
||||
private closeContextMenuHandler = () =>
|
||||
this.closeContextMenu();
|
||||
|
||||
private mousedownCloseHandler = (e: MouseEvent) => {
|
||||
const path = e.composedPath();
|
||||
const popup = this.contextMenuPopup;
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
if (submenu && path.includes(submenu)) return;
|
||||
|
||||
this.closeContextMenu();
|
||||
};
|
||||
|
||||
private clearSelectionHandler = (e: MouseEvent) => {
|
||||
const path = e.composedPath();
|
||||
const isTrackClick = path.some(
|
||||
@@ -172,7 +154,19 @@ export class QueuePanel
|
||||
this.virtualizer?.requestUpdate();
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
// =================================================================
|
||||
// ContextMenuHost interface
|
||||
// =================================================================
|
||||
|
||||
getContextMenuPopup(): HTMLElement | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
getPlaylistSubmenuPopup(): HTMLElement | undefined {
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
|
||||
static override styles = [contextMenuStyles, css`
|
||||
:host {
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
@@ -445,43 +439,7 @@ export class QueuePanel
|
||||
display: none;
|
||||
}
|
||||
|
||||
#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;
|
||||
--wa-color-text-normal: var(--yj-text-primary, #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;
|
||||
}
|
||||
`;
|
||||
`];
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -501,18 +459,6 @@ export class QueuePanel
|
||||
'click',
|
||||
this.closePickerHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.closeContextMenuHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
this.closeContextMenuHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.clearSelectionHandler,
|
||||
@@ -537,18 +483,6 @@ export class QueuePanel
|
||||
'click',
|
||||
this.closePickerHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.closeContextMenuHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'contextmenu',
|
||||
this.closeContextMenuHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.clearSelectionHandler,
|
||||
@@ -660,30 +594,7 @@ export class QueuePanel
|
||||
e.stopPropagation();
|
||||
|
||||
this.selection.handleContextMenu(String(index));
|
||||
this.contextMenuOpen = true;
|
||||
|
||||
// Position at mouse cursor using a virtual anchor.
|
||||
this.updateComplete.then(() => {
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).anchor = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
right: e.clientX,
|
||||
bottom: e.clientY,
|
||||
};
|
||||
},
|
||||
};
|
||||
(popup as any).active = true;
|
||||
}
|
||||
});
|
||||
this.ctxMenu.openAt(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
private onContextMenuAction(action: string) {
|
||||
@@ -706,7 +617,8 @@ export class QueuePanel
|
||||
break;
|
||||
}
|
||||
|
||||
this.closeContextMenu(true);
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private openTrackDetails(index: number) {
|
||||
@@ -759,77 +671,11 @@ export class QueuePanel
|
||||
};
|
||||
}
|
||||
|
||||
private closeContextMenu(clearSelection = false) {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
|
||||
if (clearSelection) {
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSubmenuCloseTimer() {
|
||||
if (this.submenuCloseTimer !== null) {
|
||||
clearTimeout(this.submenuCloseTimer);
|
||||
this.submenuCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSubmenuClose = () => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.submenuCloseTimer = setTimeout(() => {
|
||||
this.submenuCloseTimer = null;
|
||||
this.closePlaylistSubmenu();
|
||||
}, 150);
|
||||
private onContextPlaylistActionComplete = () => {
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
};
|
||||
|
||||
private async showPlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
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(
|
||||
'#context-playlist-picker',
|
||||
) as PlaylistPicker | null;
|
||||
|
||||
picker?.reset();
|
||||
}
|
||||
|
||||
private closePlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (!this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistSubmenuOpen = false;
|
||||
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (submenu) {
|
||||
(submenu as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive file paths from selected indices for
|
||||
* operations that need file paths (e.g. Add to Playlist).
|
||||
@@ -842,10 +688,6 @@ export class QueuePanel
|
||||
.map((i) => tracks[i]!.filePath);
|
||||
}
|
||||
|
||||
private onContextPlaylistActionComplete = () => {
|
||||
this.closeContextMenu(true);
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Drop target (tracks dropped into queue)
|
||||
// =================================================================
|
||||
@@ -1430,9 +1272,9 @@ export class QueuePanel
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.contextMenuOpen}
|
||||
.active=${this.ctxMenu.contextMenuOpen}
|
||||
>
|
||||
${this.contextMenuOpen
|
||||
${this.ctxMenu.contextMenuOpen
|
||||
? html`
|
||||
<div class="context-menu-panel">
|
||||
<wa-dropdown-item
|
||||
@@ -1441,7 +1283,7 @@ export class QueuePanel
|
||||
'play',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1455,7 +1297,7 @@ export class QueuePanel
|
||||
'remove',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1466,14 +1308,14 @@ export class QueuePanel
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
void this.showPlaylistSubmenu();
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
||||
}}
|
||||
@mouseleave=${this
|
||||
.scheduleSubmenuClose}
|
||||
.ctxMenu.scheduleSubmenuClose}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
void this.showPlaylistSubmenu();
|
||||
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
||||
}}
|
||||
>
|
||||
<wa-icon
|
||||
@@ -1496,7 +1338,7 @@ export class QueuePanel
|
||||
'track-details',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1517,20 +1359,20 @@ export class QueuePanel
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.playlistSubmenuOpen}
|
||||
.active=${this.ctxMenu.playlistSubmenuOpen}
|
||||
>
|
||||
${this.playlistSubmenuOpen &&
|
||||
${this.ctxMenu.playlistSubmenuOpen &&
|
||||
this.selection.hasSelection
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() =>
|
||||
this.clearSubmenuCloseTimer()}
|
||||
this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this
|
||||
.scheduleSubmenuClose}
|
||||
.ctxMenu.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
id="context-playlist-picker"
|
||||
.filePaths=${this.getSelectedFilePaths()}
|
||||
.filePaths=${this.ctxMenu.playlistFilePaths}
|
||||
@playlist-action-complete=${this
|
||||
.onContextPlaylistActionComplete}
|
||||
@click=${(e: Event) =>
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import {
|
||||
ContextMenuController,
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
@@ -39,7 +44,6 @@ 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 '@components/track-details/track-details.js';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||
@@ -53,7 +57,7 @@ const DEFAULT_FIXED_WIDTH = 80;
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
@customElement('track-list')
|
||||
export class TrackList extends LitElement implements SelectionHost {
|
||||
export class TrackList extends LitElement implements SelectionHost, ContextMenuHost {
|
||||
/**
|
||||
* When set, the list displays these tracks instead of
|
||||
* fetching all tracks from the library store. The
|
||||
@@ -68,6 +72,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
private searchCtrl = new SearchController(this);
|
||||
private trackListCtrl = new TrackListController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private cancelScanComplete?: () => void;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
@@ -98,18 +103,22 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
@state()
|
||||
private tracks: library.Track[] = [];
|
||||
|
||||
@state()
|
||||
private contextMenuOpen = false;
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: HTMLElement;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup!: HTMLElement;
|
||||
|
||||
// -- ContextMenuHost interface --
|
||||
|
||||
getContextMenuPopup(): HTMLElement | undefined {
|
||||
return this.contextMenuPopup;
|
||||
}
|
||||
|
||||
getPlaylistSubmenuPopup(): HTMLElement | undefined {
|
||||
return this.playlistSubmenuPopup;
|
||||
}
|
||||
|
||||
@query('track-details')
|
||||
private trackDetailsDialog!: TrackDetails;
|
||||
|
||||
@@ -118,10 +127,6 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
|
||||
private lastActiveTrackPath: string | null = null;
|
||||
|
||||
private submenuCloseTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
|
||||
// -- Memoisation caches for filtered / sorted tracks --
|
||||
private cachedFilteredTracks: library.Track[] = [];
|
||||
private cachedSortedTracks: library.Track[] = [];
|
||||
@@ -132,21 +137,6 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
private prevSortField: string | null = null;
|
||||
private prevSortDir: SortDirection = 'asc';
|
||||
|
||||
private closeHandler = () => this.closeContextMenu();
|
||||
|
||||
private mousedownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
const path = e.composedPath();
|
||||
const popup = this.contextMenuPopup;
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
if (submenu && path.includes(submenu)) return;
|
||||
|
||||
this.closeContextMenu();
|
||||
};
|
||||
|
||||
private clearSelectionHandler = (e: MouseEvent) => {
|
||||
const path = e.composedPath();
|
||||
const isTrackClick = path.some(
|
||||
@@ -671,7 +661,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
static override styles = css`
|
||||
static override styles = [contextMenuStyles, css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -942,46 +932,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
`;
|
||||
`];
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -996,9 +947,6 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
() => this.loadTracks(),
|
||||
);
|
||||
}
|
||||
document.addEventListener('click', this.closeHandler);
|
||||
document.addEventListener('contextmenu', this.closeHandler);
|
||||
document.addEventListener('mousedown', this.mousedownCloseHandler);
|
||||
document.addEventListener('mousedown', this.sortDropdownCloseHandler);
|
||||
document.addEventListener('click', this.clearSelectionHandler);
|
||||
document.addEventListener('mousemove', this.onColResizeMove);
|
||||
@@ -1021,9 +969,6 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
this.hasRestoredScroll = false;
|
||||
super.disconnectedCallback();
|
||||
this.cancelScanComplete?.();
|
||||
document.removeEventListener('click', this.closeHandler);
|
||||
document.removeEventListener('contextmenu', this.closeHandler);
|
||||
document.removeEventListener('mousedown', this.mousedownCloseHandler);
|
||||
document.removeEventListener('mousedown', this.sortDropdownCloseHandler);
|
||||
document.removeEventListener('click', this.clearSelectionHandler);
|
||||
document.removeEventListener('mousemove', this.onColResizeMove);
|
||||
@@ -1178,30 +1123,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
e.stopPropagation();
|
||||
|
||||
this.selection.handleContextMenu(track.FilePath);
|
||||
this.contextMenuOpen = true;
|
||||
|
||||
// Position the popup at the mouse cursor using a virtual anchor.
|
||||
this.updateComplete.then(() => {
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).anchor = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
top: e.clientY,
|
||||
left: e.clientX,
|
||||
right: e.clientX,
|
||||
bottom: e.clientY,
|
||||
};
|
||||
},
|
||||
};
|
||||
(popup as any).active = true;
|
||||
}
|
||||
});
|
||||
this.ctxMenu.openAt(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
@@ -1278,7 +1200,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
break;
|
||||
}
|
||||
|
||||
this.closeContextMenu(true);
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private openTrackDetails(filePath: string) {
|
||||
@@ -1320,80 +1243,6 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
};
|
||||
}
|
||||
|
||||
private closeContextMenu(clearSelection = false) {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
|
||||
if (clearSelection) {
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSubmenuCloseTimer() {
|
||||
if (this.submenuCloseTimer !== null) {
|
||||
clearTimeout(this.submenuCloseTimer);
|
||||
this.submenuCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSubmenuClose = () => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.submenuCloseTimer = setTimeout(() => {
|
||||
this.submenuCloseTimer = null;
|
||||
this.closePlaylistSubmenu();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
private async showPlaylistSubmenu() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
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() {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (!this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistSubmenuOpen = false;
|
||||
|
||||
const submenu = this.playlistSubmenuPopup;
|
||||
|
||||
if (submenu) {
|
||||
(submenu as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaylistActionComplete = () => {
|
||||
this.closeContextMenu(true);
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Sort controls
|
||||
// =================================================================
|
||||
@@ -1781,28 +1630,28 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.contextMenuOpen}
|
||||
.active=${this.ctxMenu.contextMenuOpen}
|
||||
>
|
||||
${this.contextMenuOpen
|
||||
${this.ctxMenu.contextMenuOpen
|
||||
? html`
|
||||
<div class="context-menu-panel">
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('play')}
|
||||
@mouseenter=${() => this.closePlaylistSubmenu()}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="play"></wa-icon>
|
||||
Play
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('add-to-queue')}
|
||||
@mouseenter=${() => this.closePlaylistSubmenu()}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
Add to Queue
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() => this.onContextMenuAction('play-next')}
|
||||
@mouseenter=${() => this.closePlaylistSubmenu()}
|
||||
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||
Play Next
|
||||
@@ -1810,13 +1659,13 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
<wa-dropdown-item
|
||||
class="submenu-item"
|
||||
@mouseenter=${() => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
void this.showPlaylistSubmenu();
|
||||
this.ctxMenu.clearSubmenuCloseTimer();
|
||||
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
|
||||
}}
|
||||
@mouseleave=${this.scheduleSubmenuClose}
|
||||
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
void this.showPlaylistSubmenu();
|
||||
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
|
||||
}}
|
||||
>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
@@ -1830,8 +1679,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
this.onContextMenuAction(
|
||||
'track-details',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.closePlaylistSubmenu()}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
@@ -1851,18 +1700,18 @@ export class TrackList extends LitElement implements SelectionHost {
|
||||
placement="right-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.playlistSubmenuOpen}
|
||||
.active=${this.ctxMenu.playlistSubmenuOpen}
|
||||
>
|
||||
${this.playlistSubmenuOpen && this.selection.hasSelection
|
||||
${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection
|
||||
? html`
|
||||
<div
|
||||
@mouseenter=${() =>
|
||||
this.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this.scheduleSubmenuClose}
|
||||
this.ctxMenu.clearSubmenuCloseTimer()}
|
||||
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
||||
>
|
||||
<playlist-picker
|
||||
.filePaths=${this.selection.getSelectedKeysOrdered()}
|
||||
@playlist-action-complete=${this.onPlaylistActionComplete}
|
||||
.filePaths=${this.ctxMenu.playlistFilePaths}
|
||||
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
></playlist-picker>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { css } from 'lit';
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from 'lit';
|
||||
|
||||
/**
|
||||
* Host interface for components using the ContextMenuController.
|
||||
* The host must provide access to the popup elements (typically
|
||||
* via @query decorators) and optionally a callback for cleanup
|
||||
* when the context menu closes.
|
||||
*/
|
||||
export interface ContextMenuHost
|
||||
extends ReactiveControllerHost {
|
||||
updateComplete: Promise<boolean>;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
/** Return the main context-menu popup element. */
|
||||
getContextMenuPopup(): HTMLElement | undefined;
|
||||
/** Return the playlist submenu popup element. */
|
||||
getPlaylistSubmenuPopup(): HTMLElement | undefined;
|
||||
/**
|
||||
* Called when the context menu is closed by an
|
||||
* outside click/contextmenu/mousedown. Components
|
||||
* use this to clear domain-specific state (e.g.
|
||||
* contextMenuAlbumId, contextMenuGenreName).
|
||||
*/
|
||||
onContextMenuClose?(): void;
|
||||
}
|
||||
|
||||
/** Submenu close delay in milliseconds. */
|
||||
const SUBMENU_CLOSE_DELAY = 150;
|
||||
|
||||
/**
|
||||
* Reusable context menu controller that manages the open/close
|
||||
* state of a wa-popup context menu with an optional playlist
|
||||
* submenu.
|
||||
*
|
||||
* Handles:
|
||||
* - Opening the context menu at a given screen position
|
||||
* - Closing on outside click / contextmenu / mousedown
|
||||
* - Playlist submenu open/close with hover delay
|
||||
* - Document-level event listener lifecycle
|
||||
*
|
||||
* Does NOT handle:
|
||||
* - Rendering the context menu template (component-specific)
|
||||
* - Dispatching menu actions (component-specific)
|
||||
* - File path resolution for the playlist picker
|
||||
*/
|
||||
export class ContextMenuController
|
||||
implements ReactiveController
|
||||
{
|
||||
private host: ContextMenuHost;
|
||||
|
||||
/** Whether the main context menu popup is open. */
|
||||
contextMenuOpen = false;
|
||||
|
||||
/** Whether the playlist submenu popup is open. */
|
||||
playlistSubmenuOpen = false;
|
||||
|
||||
/** File paths to pass to the playlist picker. */
|
||||
playlistFilePaths: string[] = [];
|
||||
|
||||
private submenuCloseTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
|
||||
/** Bound close handler for document events. */
|
||||
private closeHandler = () => this.close();
|
||||
|
||||
/** Bound mousedown handler for outside-click detection. */
|
||||
private mousedownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
const path = e.composedPath();
|
||||
const popup =
|
||||
this.host.getContextMenuPopup();
|
||||
const submenu =
|
||||
this.host.getPlaylistSubmenuPopup();
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
|
||||
if (submenu && path.includes(submenu)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.close();
|
||||
};
|
||||
|
||||
constructor(host: ContextMenuHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// LIFECYCLE
|
||||
// =================================================================
|
||||
|
||||
hostConnected(): void {
|
||||
document.addEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'contextmenu',
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
this.clearSubmenuCloseTimer();
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// MAIN CONTEXT MENU
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Open the context menu at the given screen
|
||||
* coordinates using a virtual anchor.
|
||||
*/
|
||||
openAt(clientX: number, clientY: number): void {
|
||||
this.contextMenuOpen = true;
|
||||
this.host.requestUpdate();
|
||||
|
||||
void this.host.updateComplete.then(() => {
|
||||
const popup =
|
||||
this.host.getContextMenuPopup();
|
||||
|
||||
if (!popup) return;
|
||||
|
||||
(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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the context menu and playlist submenu.
|
||||
* Notifies the host via `onContextMenuClose()` so
|
||||
* it can clear domain-specific state.
|
||||
*/
|
||||
close(): void {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
this.playlistFilePaths = [];
|
||||
|
||||
const popup =
|
||||
this.host.getContextMenuPopup();
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
|
||||
this.host.onContextMenuClose?.();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// PLAYLIST SUBMENU
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Open the playlist submenu, positioning it
|
||||
* relative to the `.submenu-item` trigger element.
|
||||
*
|
||||
* @param filePaths - File paths to pass to the
|
||||
* playlist picker. The caller resolves these
|
||||
* before calling (sync or async).
|
||||
*/
|
||||
async showPlaylistSubmenu(
|
||||
filePaths: string[],
|
||||
): Promise<void> {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
this.playlistFilePaths = filePaths;
|
||||
this.playlistSubmenuOpen = true;
|
||||
this.host.requestUpdate();
|
||||
|
||||
await this.host.updateComplete;
|
||||
|
||||
const submenu =
|
||||
this.host.getPlaylistSubmenuPopup();
|
||||
const trigger =
|
||||
this.host.shadowRoot?.querySelector(
|
||||
'.submenu-item',
|
||||
);
|
||||
|
||||
if (submenu && trigger) {
|
||||
(submenu as any).anchor = trigger;
|
||||
(submenu as any).active = true;
|
||||
}
|
||||
|
||||
const picker =
|
||||
this.host.shadowRoot?.querySelector(
|
||||
'playlist-picker',
|
||||
) as
|
||||
| (HTMLElement & { reset(): void })
|
||||
| null;
|
||||
|
||||
picker?.reset();
|
||||
}
|
||||
|
||||
/** Close the playlist submenu. */
|
||||
closePlaylistSubmenu(): void {
|
||||
this.clearSubmenuCloseTimer();
|
||||
|
||||
if (!this.playlistSubmenuOpen) return;
|
||||
|
||||
this.playlistSubmenuOpen = false;
|
||||
|
||||
const submenu =
|
||||
this.host.getPlaylistSubmenuPopup();
|
||||
|
||||
if (submenu) {
|
||||
(submenu as any).active = false;
|
||||
}
|
||||
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
/** Clear any pending submenu close timer. */
|
||||
clearSubmenuCloseTimer(): void {
|
||||
if (this.submenuCloseTimer !== null) {
|
||||
clearTimeout(this.submenuCloseTimer);
|
||||
this.submenuCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the submenu to close after a short
|
||||
* delay. Used on mouseleave to allow the user to
|
||||
* move between the trigger and the submenu popup.
|
||||
*/
|
||||
scheduleSubmenuClose = (): void => {
|
||||
this.clearSubmenuCloseTimer();
|
||||
this.submenuCloseTimer = setTimeout(() => {
|
||||
this.submenuCloseTimer = null;
|
||||
this.closePlaylistSubmenu();
|
||||
}, SUBMENU_CLOSE_DELAY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience callback for the playlist-picker's
|
||||
* `playlist-action-complete` event. Closes the
|
||||
* entire context menu.
|
||||
*/
|
||||
onPlaylistActionComplete = (): void => {
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared CSS styles for context menu and playlist submenu
|
||||
* popups. Components include these via the static styles
|
||||
* array: `static override styles = [myStyles, contextMenuStyles]`.
|
||||
*/
|
||||
export const contextMenuStyles = css`
|
||||
#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;
|
||||
--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;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user