From 1221a403cf8ee32d172a35540307ba885c325916 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Tue, 24 Feb 2026 09:49:34 -0500 Subject: [PATCH] cover grid refactor -split component into several files --- .opencode/plans/refactoring-catalog.md | 6 +- .opencode/plans/split-cover-grid.md | 411 +++ .../components/artists-view/artists-view.ts | 548 ++-- .../components/cover-grid/album-selection.ts | 354 +++ .../cover-grid/cover-grid-styles.ts | 282 ++ .../components/cover-grid/cover-grid-types.ts | 88 + .../src/components/cover-grid/cover-grid.ts | 2308 +++-------------- .../components/cover-grid/scroll-manager.ts | 907 +++++++ .../src/components/genres-view/genres-view.ts | 292 +-- .../components/playlist-view/playlist-view.ts | 246 +- .../src/components/queue-panel/queue-panel.ts | 240 +- .../src/components/track-list/track-list.ts | 229 +- frontend/src/utils/context-menu-controller.ts | 337 +++ 13 files changed, 3055 insertions(+), 3193 deletions(-) create mode 100644 .opencode/plans/split-cover-grid.md create mode 100644 frontend/src/components/cover-grid/album-selection.ts create mode 100644 frontend/src/components/cover-grid/cover-grid-styles.ts create mode 100644 frontend/src/components/cover-grid/cover-grid-types.ts create mode 100644 frontend/src/components/cover-grid/scroll-manager.ts create mode 100644 frontend/src/utils/context-menu-controller.ts diff --git a/.opencode/plans/refactoring-catalog.md b/.opencode/plans/refactoring-catalog.md index 2aaebf2..ee191ec 100644 --- a/.opencode/plans/refactoring-catalog.md +++ b/.opencode/plans/refactoring-catalog.md @@ -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. diff --git a/.opencode/plans/split-cover-grid.md b/.opencode/plans/split-cover-grid.md new file mode 100644 index 0000000..6336a4c --- /dev/null +++ b/.opencode/plans/split-cover-grid.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; + 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; + async scrollToShowDropdown(): Promise; + awaitBeforeLayout(): Promise; + + // 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; + 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; + 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(); + selectedTracks = new Set(); + expandedAlbumId: number | null = null; + expandedTracks: library.Track[] = []; + lastSelectedAlbumIndex: number | null = null; + lastSelectedTrackIndex: number | null = null; + + private albumFilePathCache = new Map(); + + // Album selection + selectAlbumRange(from: number, to: number, filteredAlbums: library.Album[]): Set; + async getSelectedAlbumFilePaths(albums: library.Album[]): Promise; + async getContextMenuAlbumFilePaths(contextMenuAlbumId: number | null, albums: library.Album[]): Promise; + + // Drag cache + async warmCache(albums: library.Album[]): Promise; + getCachedSelectedPaths(albums: library.Album[]): string[]; + + // Track selection + selectTrackRange(from: number, to: number): Set; + getSelectedTrackFilePaths(): string[]; + + // Dropdown + async openDropdown(album: library.Album): Promise; + 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` (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) 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`. diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index 2676a50..816f93e 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -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`
- this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > { - 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(); }} > - ${this.playlistSubmenuOpen + ${this.ctxMenu.playlistSubmenuOpen ? html`
- this.clearSubmenuCloseTimer()} + this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this + .ctxMenu .scheduleSubmenuClose} > (); + + /** + * 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 { + const start = Math.min(from, to); + const end = Math.max(from, to); + const ids = new Set(); + + 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, + ): Promise { + 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, + ): Promise { + 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 { + 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, + ): Promise { + 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, + ): 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 { + 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 { + const start = Math.min(from, to); + const end = Math.max(from, to); + const paths = new Set(); + + 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, + 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; + } +} diff --git a/frontend/src/components/cover-grid/cover-grid-styles.ts b/frontend/src/components/cover-grid/cover-grid-styles.ts new file mode 100644 index 0000000..4d11314 --- /dev/null +++ b/frontend/src/components/cover-grid/cover-grid-styles.ts @@ -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, +]; diff --git a/frontend/src/components/cover-grid/cover-grid-types.ts b/frontend/src/components/cover-grid/cover-grid-types.ts new file mode 100644 index 0000000..068caa9 --- /dev/null +++ b/frontend/src/components/cover-grid/cover-grid-types.ts @@ -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'; diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 68e80fe..87dfb21 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -1,4 +1,4 @@ -import { LitElement, html, css, nothing } from 'lit'; +import { LitElement, html, nothing } from 'lit'; import { customElement, property, @@ -22,10 +22,11 @@ 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'; +import { AlbumSelectionManager } from './album-selection.js'; +import { ScrollManager } from './scroll-manager.js'; +import type { ScrollManagerHost } from './scroll-manager.js'; import './album-dropdown.js'; import type { TrackClickDetail, @@ -39,100 +40,33 @@ import { emitDragActive, } from '@utils/drag-controller'; import type { DragPayload } from '@utils/drag-controller'; +import { ContextMenuController } from '@utils/context-menu-controller.js'; +import type { ContextMenuHost } from '@utils/context-menu-controller.js'; import { createAlbumArtDragImage, createDragImage, createTrackCardDragImage, removeDragImage, } from '@utils/drag-image'; - -/** - * Discriminated context menu target so we know whether the - * context-menu is operating on albums or on tracks inside the - * dropdown. - */ -type ContextMenuTarget = - | { kind: 'album' } - | { kind: 'track' }; - -/** - * Item for the virtualized grid. - * Carries the original album and its index in this.albums. - */ -interface GridEntry { - album: library.Album; - albumIndex: number; -} - -/** Milliseconds to debounce visibility-changed saves. */ -const SCROLL_DEBOUNCE_MS = 100; - -/** Pixels to change card width per scroll tick. */ -const ZOOM_STEP = 16; - -/** localStorage keys for sort preferences. */ -const SORT_FIELD_KEY = 'cover-grid-sort-field'; -const SORT_DIR_KEY = 'cover-grid-sort-direction'; - -/** Available sort fields for the album grid. */ -type AlbumSortField = 'name' | 'artist' | 'year'; - -/** Sort option definition for the dropdown. */ -interface AlbumSortOption { - id: AlbumSortField; - label: string; - comparator: ( - a: library.Album, - b: library.Album, - ) => number; -} - -/** All available sort options for albums. */ -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); - }, - }, -]; - -type SortDirection = 'asc' | 'desc'; +import { coverGridStyles } from './cover-grid-styles.js'; +import { + ALBUM_SORT_OPTIONS, + SORT_DIR_KEY, + SORT_FIELD_KEY, + ZOOM_STEP, +} from './cover-grid-types.js'; +import type { + AlbumSortField, + ContextMenuTarget, + GridEntry, + SortDirection, +} from './cover-grid-types.js'; @customElement('cover-grid') -export class CoverGrid extends LitElement { +export class CoverGrid + extends LitElement + implements ContextMenuHost, ScrollManagerHost +{ /** * When set, the grid displays these albums instead of * fetching all albums from the library store. The @@ -142,7 +76,7 @@ export class CoverGrid extends LitElement { @property({ type: Array, attribute: false }) externalAlbums?: library.Album[]; - private libraryCtrl = new LibraryController(this); + libraryCtrl = new LibraryController(this); private searchCtrl = new SearchController(this); private cancelScanComplete?: () => void; private lastSearchTerm = ''; @@ -152,30 +86,15 @@ export class CoverGrid extends LitElement { private static readonly GRID_PADDING = 8; private static readonly CARD_PADDING = 5; + private ctxMenu = new ContextMenuController(this); + private selMgr = new AlbumSelectionManager(); + private scrollMgr = new ScrollManager(this, { + GRID_GAP: CoverGrid.GRID_GAP, + GRID_PADDING: CoverGrid.GRID_PADDING, + }); + private lastSelectedAlbumIndex: number | null = null; private lastSelectedTrackIndex: number | null = null; - private scrollDebounceTimer: ReturnType< - typeof setTimeout - > | null = null; - - private submenuCloseTimer: ReturnType< - typeof setTimeout - > | null = null; - - 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(); - }; /** * When true, the next split→single transition @@ -185,7 +104,7 @@ export class CoverGrid extends LitElement { private skipOverlay = false; /** Current card width — driven by the store. */ - private get cardWidth(): number { + get cardWidth(): number { return this.libraryCtrl.coverSize; } @@ -202,7 +121,7 @@ export class CoverGrid extends LitElement { } /** Derived card height from card width. */ - private get cardHeight(): number { + get cardHeight(): number { return this.cardWidth + this.cardTextHeight; } @@ -251,18 +170,8 @@ export class CoverGrid extends LitElement { private dragImageEl: HTMLElement | null = null; - /** - * Pre-resolved file paths for selected albums, keyed by album ID. - * Populated asynchronously when albums are selected so that - * dragstart can read them synchronously. - */ - private albumFilePathCache = new Map< - number, - string[] - >(); - // -- Memoisation caches for filtered albums -- - private cachedFilteredAlbums: library.Album[] = []; + cachedFilteredAlbums: library.Album[] = []; private prevFilterAlbums: library.Album[] = []; private prevFilterTerm = ''; private prevSortField: AlbumSortField = 'name'; @@ -338,327 +247,10 @@ export class CoverGrid extends LitElement { private wheelListenerAttached = false; // buildGridEntries() memoization cache. + private gridEntriesCache: GridEntry[] = []; + private gridEntriesCacheKey: library.Album[] = []; - - static override styles = 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; - } - - /* ======================================== - * Context menu - * ======================================== */ - - #context-menu { - z-index: 200; - } - - .context-menu-panel { - background-color: var(--yj-bg-elevated, #343a40); - border: 1px solid var(--yj-border, #444); - border-radius: 6px; - padding: 4px 0; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - min-width: 160px; - } - - .context-menu-panel wa-dropdown-item { - cursor: pointer; - } - - .context-menu-panel wa-dropdown-item { - --wa-color-text-normal: var(--yj-text-primary, #fff); - font-size: 13px; - } - - .context-menu-panel wa-dropdown-item:hover { - background-color: var( - --yj-hover-overlay, - rgba(255, 255, 255, 0.1) - ); - } - - .submenu-item { - position: relative; - } - - .submenu-arrow { - font-size: 10px; - margin-left: auto; - padding-left: 12px; - } - - #playlist-submenu { - z-index: 210; - } - `; + static override styles = coverGridStyles; /* ==================================================================== * Reactive state @@ -670,10 +262,6 @@ export class CoverGrid extends LitElement { @state() private loading = true; - @state() - private contextMenuOpen = false; - - @state() private contextMenuTarget: ContextMenuTarget = { kind: 'album', }; @@ -689,12 +277,6 @@ export class CoverGrid extends LitElement { @state() private selectedAlbums: Set = new Set(); - @state() - private playlistSubmenuOpen = false; - - @state() - private playlistFilePaths: string[] = []; - /** Current album sort field. */ @state() private sortField: AlbumSortField = 'name'; @@ -712,11 +294,11 @@ export class CoverGrid extends LitElement { /** ID of the album whose dropdown is currently open, or null. */ @state() - private expandedAlbumId: number | null = null; + expandedAlbumId: number | null = null; /** Tracks loaded for the expanded album dropdown. */ @state() - private expandedTracks: library.Track[] = []; + expandedTracks: library.Track[] = []; /** Set of file paths of selected tracks inside the dropdown. */ @state() @@ -727,15 +309,16 @@ export class CoverGrid extends LitElement { * (dropdown sandwiched between two grids). */ @state() - private splitMode = false; + splitMode = false; /** * Index into this.albums where the split occurs. * Albums [0, splitIndex) go into the "before" * virtualizer; [splitIndex, length) go into "after". + * Not `@state()` — always set before `splitMode` + * changes, which triggers the render. */ - @state() - private splitIndex = 0; + splitIndex = 0; @query('#context-menu') private contextMenuPopup!: HTMLElement; @@ -743,6 +326,19 @@ export class CoverGrid extends LitElement { @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; + // ContextMenuHost interface. + getContextMenuPopup(): HTMLElement | undefined { + return this.contextMenuPopup; + } + + getPlaylistSubmenuPopup(): HTMLElement | undefined { + return this.playlistSubmenuPopup; + } + + onContextMenuClose(): void { + this.contextMenuAlbumId = null; + } + @query('track-details') private trackDetailsDialog!: TrackDetails; @@ -752,59 +348,7 @@ export class CoverGrid extends LitElement { @query('.grid-scroll-container') private scrollContainer!: HTMLElement; - // Resize-aware scroll preservation. - private resizeObserver: ResizeObserver | null = null; - private resizeDebounceTimer: ReturnType< - typeof setTimeout - > | null = null; - private pendingFocus: { - albumIndex: number; - viewportOffset: number; - } | null = null; - private currentColumnCount = 0; - private isResizing = false; - // Scroll restoration across single/split mode - // transitions. - private savedScrollTop = 0; - private needsScrollRestore = false; - private showDropdownAfterRestore = false; - - /** - * Monotonically increasing counter used to - * cancel stale scroll-restore async blocks. - * Each new restore bumps the generation; the - * async block bails out when it detects it is - * no longer current. - */ - private scrollRestoreGeneration = 0; - - /** - * Set to the generation value when an async - * scroll-restore block finishes or is cancelled. - * When scrollRestoreGeneration > - * scrollRestoreResolved, an async restore is - * still in flight and the DOM scrollTop may be - * unreliable. - */ - private scrollRestoreResolved = 0; - - /** - * When switching albums, the pixel distance from - * the newly-expanded album's top edge to the - * viewport top — computed in single-mode - * coordinates during exit-split. Used by the - * enter-split restore to place the album at the - * same visual position before scrollToShowDropdown - * makes any further adjustments. - */ - private savedAlbumViewportOffset: number | null = - null; - - /** Overlay element showing the old grid state - * while a mode transition is in flight. */ - private transitionOverlay: HTMLDivElement | null = - null; /* ==================================================================== * Sort controls @@ -946,18 +490,7 @@ export class CoverGrid extends LitElement { () => this.loadAlbums(), ); } - document.addEventListener( - 'click', - this.closeHandler, - ); - document.addEventListener( - 'contextmenu', - this.closeHandler, - ); - document.addEventListener( - 'mousedown', - this.mousedownCloseHandler, - ); + document.addEventListener( 'mousedown', this.sortDropdownCloseHandler, @@ -975,18 +508,7 @@ export class CoverGrid extends LitElement { override disconnectedCallback() { super.disconnectedCallback(); this.cancelScanComplete?.(); - document.removeEventListener( - 'click', - this.closeHandler, - ); - document.removeEventListener( - 'contextmenu', - this.closeHandler, - ); - document.removeEventListener( - 'mousedown', - this.mousedownCloseHandler, - ); + document.removeEventListener( 'mousedown', this.sortDropdownCloseHandler, @@ -1002,17 +524,10 @@ export class CoverGrid extends LitElement { ); this.wheelListenerAttached = false; - if (this.scrollDebounceTimer !== null) { - clearTimeout(this.scrollDebounceTimer); - } - - if (this.resizeDebounceTimer !== null) { - clearTimeout(this.resizeDebounceTimer); - } - - this.resizeObserver?.disconnect(); - this.resizeObserver = null; - this.removeOverlay(); + this.scrollMgr.teardown(); + this.scrollMgr.revealContainer( + this.scrollContainer, + ); } override willUpdate( @@ -1025,6 +540,7 @@ export class CoverGrid extends LitElement { // list, update local albums and reset selection. if (changed.has('externalAlbums') && this.externalAlbums) { this.albums = this.externalAlbums; + this.selMgr.setAlbums(this.externalAlbums); this.selectedAlbums = new Set(); this.lastSelectedAlbumIndex = null; this.loading = false; @@ -1039,132 +555,38 @@ export class CoverGrid extends LitElement { this.expandedTracks.length === 0 && this.splitMode ) { + const sm = this.scrollMgr; + if (this.skipOverlay) { - // Lightweight exit: skip the - // expensive overlay capture but - // still restore scroll position - // since the DOM restructure - // (split → single virtualizer) - // resets scrollTop. this.skipOverlay = false; - this.savedScrollTop = - this.computeAdjustedScrollTop(); - this.savedAlbumViewportOffset = null; + sm.savedScrollTop = + sm.computeAdjustedScrollTop( + this.scrollContainer, + this.shadowRoot, + ); + sm.savedAlbumViewportOffset = null; this.splitMode = false; - this.needsScrollRestore = true; - this.showDropdownAfterRestore = false; + sm.needsScrollRestore = true; + sm.showDropdownAfterRestore = false; } else { - // Capture the raw split-mode scrollTop - // before converting to single-mode coords. - const rawScrollTop = - this.scrollContainer?.scrollTop ?? - 0; - - // Convert to dropdown-free coordinates - // before exiting split mode. - this.savedScrollTop = - this.computeAdjustedScrollTop(); - - // If switching to a new album (not - // closing), record the viewport offset - // of the newly-expanded album in the - // OLD split layout so the enter-split - // restore can place it at the same - // visual position. - if (this.expandedAlbumId !== null) { - const filtered = - this.cachedFilteredAlbums; - const idx = filtered.findIndex( - (a) => - a.ID === - this.expandedAlbumId, + sm.savedScrollTop = + sm.computeAdjustedScrollTop( + this.scrollContainer, + this.shadowRoot, ); - if (idx >= 0) { - const gap = - CoverGrid.GRID_GAP; - const pad = - CoverGrid.GRID_PADDING; - const cols = - this.getColumnCount(); - const rowStep = - this.cardHeight + gap; - const row = Math.floor( - idx / cols, - ); - - // Album's Y in single-mode - // (no dropdown) coordinates. - const albumY = - pad + row * rowStep; - - // In the old split layout - // the dropdown shifts - // everything below it. - const oldBeforeRows = - Math.ceil( - this.splitIndex / - cols, - ); - const oldDropdownTop = - pad + - oldBeforeRows * rowStep; - const dropdown = - this.shadowRoot?.querySelector( - 'album-dropdown', - ); - const oldDropdownHeight = - ( - dropdown as HTMLElement - )?.offsetHeight ?? 0; - - const albumYOldSplit = - albumY >= oldDropdownTop - ? albumY + - oldDropdownHeight - : albumY; - - // Viewport offset in - // old-split coordinates. - this.savedAlbumViewportOffset = - albumYOldSplit - - rawScrollTop; - - console.log( - '[willUpdate] anchor capture', - { - albumY, - oldDropdownTop, - oldDropdownHeight, - albumYOldSplit, - rawScrollTop, - offset: this - .savedAlbumViewportOffset, - }, - ); - } - } else { - this.savedAlbumViewportOffset = - null; - } - - console.log( - '[willUpdate] exit split (tracks empty)', - { - savedScrollTop: - this.savedScrollTop, - savedAlbumViewportOffset: - this - .savedAlbumViewportOffset, - expandedAlbumId: - this.expandedAlbumId, - }, + sm.captureAnchorOffset( + this.scrollContainer, + this.shadowRoot, ); - this.captureOverlay(); + sm.captureOverlay( + this.scrollContainer, + this.shadowRoot, + ); this.splitMode = false; - this.needsScrollRestore = true; - this.showDropdownAfterRestore = false; + sm.needsScrollRestore = true; + sm.showDropdownAfterRestore = false; } } @@ -1174,41 +596,25 @@ export class CoverGrid extends LitElement { this.expandedAlbumId !== null && this.expandedTracks.length > 0 ) { - // If a restore is already in flight - // (switching albums), keep the saved - // value — the DOM scrollTop may still - // be clamped to 0 because the previous - // restore hasn't finished. - const restoreInFlight = - this.scrollRestoreGeneration > - this.scrollRestoreResolved; + const sm = this.scrollMgr; - if (!restoreInFlight) { - this.savedScrollTop = + if (!sm.restoreInFlight) { + sm.savedScrollTop = this.scrollContainer ?.scrollTop ?? 0; } - console.log( - '[willUpdate] enter split', - { - savedScrollTop: - this.savedScrollTop, - restoreInFlight, - expandedAlbumId: - this.expandedAlbumId, - splitIndex: this.splitIndex, - scrollHeight: - this.scrollContainer - ?.scrollHeight, - }, + sm.captureOverlay( + this.scrollContainer, + this.shadowRoot, ); - - this.captureOverlay(); - this.computeSplitIndex(); + this.splitIndex = + sm.computeSplitIndex( + this.scrollContainer, + ); this.splitMode = true; - this.needsScrollRestore = true; - this.showDropdownAfterRestore = true; + sm.needsScrollRestore = true; + sm.showDropdownAfterRestore = true; } // Exit split mode when the dropdown closes. @@ -1217,24 +623,22 @@ export class CoverGrid extends LitElement { this.expandedAlbumId === null && this.splitMode ) { - // Convert to dropdown-free coordinates - // before exiting split mode. - this.savedScrollTop = - this.computeAdjustedScrollTop(); - this.savedAlbumViewportOffset = null; + const sm = this.scrollMgr; - console.log( - '[willUpdate] exit split (close)', - { - savedScrollTop: - this.savedScrollTop, - }, + sm.savedScrollTop = + sm.computeAdjustedScrollTop( + this.scrollContainer, + this.shadowRoot, + ); + sm.savedAlbumViewportOffset = null; + + sm.captureOverlay( + this.scrollContainer, + this.shadowRoot, ); - - this.captureOverlay(); this.splitMode = false; - this.needsScrollRestore = true; - this.showDropdownAfterRestore = false; + sm.needsScrollRestore = true; + sm.showDropdownAfterRestore = false; } } @@ -1272,175 +676,14 @@ export class CoverGrid extends LitElement { this.updateSizeProperties(); // Restore scroll after a single/split mode - // transition (set in willUpdate). Uses a - // retry loop (restoreScrollTop) so the scroll - // position is applied reliably even if the - // virtualizer hasn't expanded its host height - // yet. - if (this.needsScrollRestore) { - this.needsScrollRestore = false; - - const saved = this.savedScrollTop; - const showDropdown = - this.showDropdownAfterRestore; - - // Capture whether an album is still - // selected — if so and !showDropdown, we - // are in the brief split→single gap while - // new tracks load (album switch). Keep - // the overlay visible until the new split - // view is ready. - const switching = - !showDropdown && - this.expandedAlbumId !== null; - - // Bump the generation so any in-flight - // async restore from a previous cycle - // will bail out. - const gen = - ++this.scrollRestoreGeneration; - - console.log( - '[updated] scroll restore start', - { - saved, - showDropdown, - switching, - gen, - }, + // transition (set in willUpdate). + if (this.scrollMgr.needsScrollRestore) { + this.scrollMgr.runScrollRestore( + this.scrollContainer, + this.shadowRoot, + this.expandedAlbumId, + this.updateComplete, ); - - void (async () => { - await this.updateComplete; - - if ( - gen !== - this.scrollRestoreGeneration - ) { - console.log( - `[updated] gen ${gen} stale, aborting`, - ); - this.scrollRestoreResolved = gen; - - return; - } - - console.log( - '[updated] after updateComplete', - { - scrollTop: - this.scrollContainer - ?.scrollTop, - scrollHeight: - this.scrollContainer - ?.scrollHeight, - gen, - }, - ); - - await this.restoreScrollTop(saved); - - if ( - gen !== - this.scrollRestoreGeneration - ) { - this.scrollRestoreResolved = gen; - - return; - } - - if (showDropdown) { - // When switching albums, anchor - // the scroll so the newly-expanded - // album stays at the same viewport - // position it occupied before the - // old dropdown was removed. - if ( - this.savedAlbumViewportOffset !== - null && - this.expandedAlbumId !== null - ) { - const idx = - this.cachedFilteredAlbums.findIndex( - (a) => - a.ID === - this - .expandedAlbumId, - ); - - if (idx >= 0) { - const gap = - CoverGrid.GRID_GAP; - const pad = - CoverGrid.GRID_PADDING; - const cols = - this.getColumnCount(); - const rowStep = - this.cardHeight + gap; - const row = Math.floor( - idx / cols, - ); - const albumY = - pad + row * rowStep; - const anchor = - albumY - - this - .savedAlbumViewportOffset; - - console.log( - '[updated] anchor restore', - { - albumY, - offset: this - .savedAlbumViewportOffset, - anchor, - }, - ); - - await this.restoreScrollTop( - anchor, - ); - } - - this.savedAlbumViewportOffset = - null; - } - - if ( - gen !== - this.scrollRestoreGeneration - ) { - this.scrollRestoreResolved = - gen; - - return; - } - - await this.scrollToShowDropdown(); - } - - if ( - gen !== - this.scrollRestoreGeneration - ) { - this.scrollRestoreResolved = gen; - - return; - } - - if (!switching) { - console.log( - '[updated] removing overlay', - ); - this.removeOverlay(); - } else { - console.log( - '[updated] keeping overlay (switching)', - ); - } - - this.scrollRestoreResolved = gen; - })(); } // Close dropdown and clear selection when @@ -1462,12 +705,24 @@ export class CoverGrid extends LitElement { this.splitMode && this.expandedTracks.length > 0 ) { - this.computeSplitIndex(); + this.splitIndex = + this.scrollMgr.computeSplitIndex( + this.scrollContainer, + ); + + const sm = this.scrollMgr; void (async () => { await this.updateComplete; - await this.awaitBeforeLayout(); - await this.scrollToShowDropdown(); + + await sm.awaitBeforeLayout( + this.shadowRoot, + ); + + await sm.scrollToShowDropdown( + this.scrollContainer, + this.shadowRoot, + ); })(); } } @@ -1556,6 +811,7 @@ export class CoverGrid extends LitElement { ?? []; this.albums = albums; + this.selMgr.setAlbums(albums); this.selectedAlbums = new Set(); this.lastSelectedAlbumIndex = null; } catch (error) { @@ -1564,428 +820,73 @@ export class CoverGrid extends LitElement { error, ); this.albums = []; + this.selMgr.setAlbums([]); } finally { this.loading = false; } await this.updateComplete; - this.restoreScrollPosition(); - this.setupResizeObserver(); - } - /* ==================================================================== - * Scroll position (index-based) - * ==================================================================== */ - - private restoreScrollPosition() { - const saved = - this.libraryCtrl.getScrollPosition('albums'); - - if (saved <= 0 || !this.virtualizerSingle) { - return; - } - - const safeIndex = Math.min( - saved, - this.cachedFilteredAlbums.length - 1, + this.scrollMgr.restoreScrollPosition( + this.virtualizerSingle, ); - - if (safeIndex <= 0) return; - - this.virtualizerSingle.scrollToIndex( - safeIndex, - 'start', - ); - } - - /** - * Save scroll position from the first visible - * album. In split mode we compute the index from - * scrollTop; in single mode we use the virtualizer - * visibilityChanged event data. - */ - private onVisibilityChanged = ( - e: VisibilityChangedEvent, - ) => { - // Skip saves while a resize reflow is in - // progress — the virtualizer reports - // intermediate positions that would overwrite - // the real scroll position in the store. - if (this.isResizing) return; - - if (this.scrollDebounceTimer !== null) { - clearTimeout(this.scrollDebounceTimer); - } - - this.scrollDebounceTimer = setTimeout(() => { - if (this.splitMode) { - // In split mode the event indices are - // relative to the before-virtualizer. - // Save the album index directly. - const entries = - this.getBeforeEntries(); - const first = entries[e.first]; - - if (first) { - this.libraryCtrl.setScrollPosition( - 'albums', - first.albumIndex, + this.scrollMgr.setupResizeObserver( + this.scrollContainer, + async () => { + this.splitIndex = + this.scrollMgr.computeSplitIndex( + this.scrollContainer, ); - } - } else { - const entries = - this.buildGridEntries(); - const first = entries[e.first]; - - if (first) { - this.libraryCtrl.setScrollPosition( - 'albums', - first.albumIndex, - ); - } - } - }, SCROLL_DEBOUNCE_MS); - }; - - /* ==================================================================== - * Resize-aware scroll preservation - * - * When the container width changes (queue panel - * open/close, window resize) the grid reflows and - * the pixel scroll position becomes stale. - * - * We identify the album at the viewport center - * before the resize, then after the reflow we - * place that same album back at the same viewport - * offset. Integer album indices ensure zero - * scroll creep across repeated open/close cycles. - * - * If a dropdown is open the expanded album is the - * focus; otherwise the album at the viewport center - * is used. - * ==================================================================== */ - - private setupResizeObserver() { - const container = this.scrollContainer; - - if (!container) return; - - // Guard against stacked observers from - // repeated calls (e.g. library re-scan). - this.resizeObserver?.disconnect(); - - this.currentColumnCount = - this.getColumnCount(); - - /** Restore scroll so the focus album stays - * at the same viewport offset after reflow. */ - const restoreScroll = () => { - const pending = this.pendingFocus; - - this.pendingFocus = null; - this.isResizing = false; - - if (!pending) return; - - const newColumns = this.getColumnCount(); - - this.currentColumnCount = newColumns; - - // If a dropdown is open, recompute the - // split and re-evaluate scroll. - if ( - this.splitMode && - this.expandedAlbumId !== null - ) { - this.computeSplitIndex(); this.requestUpdate(); - void (async () => { - await this.updateComplete; - await this.awaitBeforeLayout(); - await this.scrollToShowDropdown(); - })(); + await this.updateComplete; - return; - } + await this.scrollMgr.awaitBeforeLayout( + this.shadowRoot, + ); - const gap = CoverGrid.GRID_GAP; - const pad = CoverGrid.GRID_PADDING; - const rowStep = - this.cardHeight + gap; - - // Derive the album's row under the new - // column count. Both albumIndex and - // newColumns are integers, so newRow is - // also an integer — no fractional drift. - const newRow = Math.floor( - pending.albumIndex / newColumns, - ); - const newY = - pad + newRow * rowStep; - - container.scrollTop = - newY - pending.viewportOffset; - }; - - this.resizeObserver = new ResizeObserver( - () => { - const rowStep = - this.cardHeight + CoverGrid.GRID_GAP; - - // Capture on the first event using - // the pre-resize column count. - if (this.pendingFocus === null) { - this.isResizing = true; - - this.captureFocusPoint( - container, - rowStep, - ); - } - - const newColumns = - this.getColumnCount(); - - if ( - newColumns !== - this.currentColumnCount - ) { - // Column count changed — correct - // scroll immediately. - if ( - this.resizeDebounceTimer !== - null - ) { - clearTimeout( - this.resizeDebounceTimer, - ); - this.resizeDebounceTimer = - null; - } - - restoreScroll(); - - return; - } - - // Same column count — debounce for a - // final adjustment once resizing settles. - if ( - this.resizeDebounceTimer !== null - ) { - clearTimeout( - this.resizeDebounceTimer, - ); - } - - this.resizeDebounceTimer = setTimeout( - restoreScroll, - 100, + await this.scrollMgr.scrollToShowDropdown( + this.scrollContainer, + this.shadowRoot, ); }, ); - - this.resizeObserver.observe(container); - } - - /** - * Determine the focus point for scroll restoration. - * If a dropdown is open, the expanded album is the - * focus and its current viewport offset is preserved. - * Otherwise the album at the viewport center is used. - * - * Stores an integer album index and the pixel offset - * from that album's top edge to the viewport top. - * Integer indices ensure zero drift across repeated - * open/close cycles (no fractional accumulation). - */ - private captureFocusPoint( - container: HTMLElement, - rowStep: number, - ) { - const pad = CoverGrid.GRID_PADDING; - const cols = this.currentColumnCount; - const filtered = this.cachedFilteredAlbums; - - // Prefer the expanded album as focus. - if (this.expandedAlbumId !== null) { - const idx = filtered.findIndex( - (a) => a.ID === this.expandedAlbumId, - ); - - if (idx >= 0) { - const albumRow = Math.floor( - idx / cols, - ); - const albumY = - pad + albumRow * rowStep; - - this.pendingFocus = { - albumIndex: idx, - viewportOffset: - albumY - container.scrollTop, - }; - - return; - } - } - - // Fall back to the album whose row contains - // the viewport center. - const centerY = - container.scrollTop + - container.clientHeight / 2; - const centerRow = Math.floor( - Math.max(0, centerY - pad) / - rowStep, - ); - const albumIndex = Math.min( - centerRow * cols, - Math.max(0, filtered.length - 1), - ); - - // Pixel offset from that album's top edge - // to the viewport top — used exactly once in - // restoreScroll, never fed back. - const albumY = - pad + centerRow * rowStep; - - this.pendingFocus = { - albumIndex, - viewportOffset: - albumY - container.scrollTop, - }; } /* ==================================================================== - * Column count helper + * Scroll event handler * ==================================================================== */ - private getColumnCount(): number { - const el = - this.scrollContainer ?? - this.virtualizerSingle; + private onVisibilityChanged = ( + e: VisibilityChangedEvent, + ) => { + const sm = this.scrollMgr; + const isSplit = this.splitMode; - if (!el) return 1; - - const gap = CoverGrid.GRID_GAP; - const pad = CoverGrid.GRID_PADDING; - const availableWidth = - el.clientWidth - pad * 2; - - return Math.max( - 1, - Math.floor( - (availableWidth + gap) / - (this.cardWidth + gap), - ), + sm.onVisibilityChanged(e.first, () => + isSplit + ? this.getBeforeEntries() + : this.buildGridEntries(), ); - } - - /** Container width in pixels for the dropdown. */ - private getContainerWidth(): number { - const el = - this.scrollContainer ?? - this.virtualizerSingle; - - return el?.clientWidth ?? 800; - } - - /** - * Width of the album row: from the left edge of - * the leftmost card to the right edge of the - * rightmost card, including card padding but not - * the outer grid padding. - */ - private getGridRowWidth(): number { - const cols = this.getColumnCount(); - const gap = CoverGrid.GRID_GAP; - - return ( - cols * this.cardWidth + - (cols - 1) * gap - ); - } - - /** - * Horizontal offset of the carat (in pixels from - * the left edge of the dropdown) so that it points - * at the center of the expanded album card. - */ - private getCaratOffset(): number { - if (this.expandedAlbumId === null) return 0; - - const idx = this.cachedFilteredAlbums.findIndex( - (a) => a.ID === this.expandedAlbumId, - ); - - if (idx < 0) return 0; - - const cols = this.getColumnCount(); - const colIndex = idx % cols; - const gap = CoverGrid.GRID_GAP; - - return ( - colIndex * (this.cardWidth + gap) + - this.cardWidth / 2 - ); - } - - /* ==================================================================== - * Split-mode helpers - * - * When the dropdown is open the album grid is split - * into two virtualizers with the dropdown in between. - * This avoids phantom rows and lets the dropdown size - * itself to its content exactly. - * ==================================================================== */ - - /** - * Compute the split point: all albums up to and - * including the expanded album's row go into the - * "before" virtualizer; the rest go into "after". - */ - private computeSplitIndex() { - const filtered = this.cachedFilteredAlbums; - - if (this.expandedAlbumId === null) { - this.splitIndex = filtered.length; - - return; - } - - const columns = this.getColumnCount(); - const expandedIndex = filtered.findIndex( - (a) => a.ID === this.expandedAlbumId, - ); - - if (expandedIndex < 0) { - this.splitIndex = filtered.length; - - return; - } - - this.splitIndex = Math.min( - (Math.floor(expandedIndex / columns) + - 1) * - columns, - filtered.length, - ); - } + }; /* ==================================================================== * Virtualizer items * ==================================================================== */ /** - * Build a flat GridEntry array for a given album - * slice. Always returns a new array so the - * virtualizer re-renders visible items when - * component state (e.g. selectedAlbums) changes. + * Build a flat GridEntry array for the filtered + * albums. Memoized on the cachedFilteredAlbums + * reference — only allocates a new array when the + * underlying album list changes. */ private buildGridEntries(): GridEntry[] { const filtered = this.cachedFilteredAlbums; + + if (filtered === this.gridEntriesCacheKey) { + return this.gridEntriesCache; + } + const entries: GridEntry[] = []; for (let i = 0; i < filtered.length; i++) { @@ -1995,6 +896,9 @@ export class CoverGrid extends LitElement { }); } + this.gridEntriesCacheKey = filtered; + this.gridEntriesCache = entries; + return entries; } @@ -2019,540 +923,6 @@ export class CoverGrid extends LitElement { return `a-${entry.album.ID}`; }; - /* ==================================================================== - * Transition overlay - * - * Before a single/split mode switch we clone the - * scroll container into an absolutely-positioned - * overlay so the old visual state stays on-screen - * while the new layout computes underneath - * (hidden). Once the new layout is ready and - * scroll is restored we remove the overlay and - * reveal the real container in one paint frame. - * ==================================================================== */ - - /** - * Capture the current scroll container as a - * static overlay so the user keeps seeing the old - * state while the DOM switches underneath. - */ - private captureOverlay() { - const container = this.scrollContainer; - - if (!container || this.transitionOverlay) { - return; - } - - const scrollY = container.scrollTop; - const overlay = document.createElement('div'); - - overlay.style.cssText = - 'position:absolute;inset:0;z-index:10;' + - 'overflow:hidden;pointer-events:none;'; - - // Clone each child into a wrapper that - // reproduces the scroll viewport. - const inner = document.createElement('div'); - - inner.style.cssText = - 'position:relative;height:100%;' + - 'pointer-events:none;'; - - for (const child of Array.from( - container.childNodes, - )) { - inner.appendChild(child.cloneNode(true)); - } - - // Shift content up to match the current - // scroll offset. - inner.style.transform = - `translateY(-${scrollY}px)`; - - overlay.appendChild(inner); - - // Append to :host (shadow root), not inside - // the scroll container, so Lit's diffing does - // not touch it. - this.shadowRoot?.appendChild(overlay); - this.transitionOverlay = overlay; - - // Hide the real container while the new - // layout settles. - container.style.visibility = 'hidden'; - } - - /** - * Remove the snapshot overlay and reveal the real - * scroll container. Both happen synchronously so - * they land in the same paint frame. - */ - private removeOverlay() { - if (this.transitionOverlay) { - this.transitionOverlay.remove(); - this.transitionOverlay = null; - } - - if (this.scrollContainer) { - this.scrollContainer.style.visibility = ''; - } - } - - /* ==================================================================== - * Dropdown scroll positioning - * - * In split mode the dropdown is a normal-flow DOM - * element between two virtualizers. We query its - * position from the DOM. - * ==================================================================== */ - - /** - * Wait for the "before" virtualizer to finish its - * layout pass so that its host element height - * reflects the total content size. Without this, - * setting scrollTop can be silently clamped to 0 - * because the scroll container hasn't grown yet. - */ - private async awaitBeforeLayout(): Promise { - const virt = - this.shadowRoot?.querySelector( - '#grid-before', - ) as LitVirtualizer | null; - - await virt?.layoutComplete; - } - - /** - * Return the current scrollTop converted to - * single-mode (dropdown-free) coordinates. - * - * In split mode the open dropdown shifts all - * content below it downward. When we save a - * scroll position for later restoration in a - * different layout we need to remove that shift - * so the saved value is layout-agnostic. - */ - private computeAdjustedScrollTop(): number { - const container = this.scrollContainer; - - if (!container) return 0; - - const raw = container.scrollTop; - - if (!this.splitMode) return raw; - - const gap = CoverGrid.GRID_GAP; - const pad = CoverGrid.GRID_PADDING; - const columns = this.getColumnCount(); - const rowStep = this.cardHeight + gap; - const beforeRows = Math.ceil( - this.splitIndex / columns, - ); - - // Position where the dropdown starts in - // the split layout (scroll-content coords). - const dropdownTop = - pad + beforeRows * rowStep; - - if (raw <= dropdownTop) { - console.log( - '[adjustScroll] raw <= dropdownTop, no adjust', - { raw, dropdownTop }, - ); - - return raw; - } - - const dropdown = - this.shadowRoot?.querySelector( - 'album-dropdown', - ); - const dropdownHeight = - (dropdown as HTMLElement)?.offsetHeight ?? - 0; - - const adjusted = raw - dropdownHeight; - - console.log( - '[adjustScroll]', - { - raw, - dropdownTop, - dropdownHeight, - adjusted, - }, - ); - - return adjusted; - } - - /** - * Set scrollTop on the scroll container and verify - * the browser didn't silently clamp it. If the - * virtualizer hasn't expanded its host height yet, - * scrollTop will be clamped to a smaller value. - * In that case, wait one animation frame (giving - * the virtualizer time to size itself) and retry. - */ - private async restoreScrollTop( - target: number, - ): Promise { - const container = this.scrollContainer; - - if (!container) return; - - const maxAttempts = 10; - - for (let i = 0; i < maxAttempts; i++) { - container.scrollTop = target; - - console.log( - `[restoreScrollTop] attempt ${i}`, - { - target, - actual: container.scrollTop, - scrollHeight: - container.scrollHeight, - clientHeight: - container.clientHeight, - }, - ); - - // Success if the browser accepted the - // value, or the target is at/below zero. - if ( - container.scrollTop >= target || - target <= 0 - ) { - return; - } - - // Content hasn't expanded enough yet — - // wait one frame and retry. - await new Promise((r) => - requestAnimationFrame(() => r()), - ); - } - - console.warn( - '[restoreScrollTop] gave up after max attempts', - { - target, - actual: container.scrollTop, - scrollHeight: container.scrollHeight, - }, - ); - } - - /** - * Scroll the container so the expanded album card - * and its dropdown are visible, using minimal - * movement: - * - * 1. If both fit in the viewport already, don't - * scroll. - * 2. If the dropdown bottom overflows below the - * viewport, align it with the viewport bottom. - * 3. If that would push the album card above the - * viewport, pin the album card top to the - * viewport top instead. - * - * Positions are computed from grid math rather - * than DOM queries so that the method works - * immediately after a single/split mode switch - * (before the virtualizer has laid out). - */ - private async scrollToShowDropdown() { - const container = this.scrollContainer; - - if ( - !container || - this.expandedAlbumId === null - ) { - return; - } - - const filtered = this.cachedFilteredAlbums; - const expandedIndex = filtered.findIndex( - (a) => a.ID === this.expandedAlbumId, - ); - - if (expandedIndex < 0) return; - - const gap = CoverGrid.GRID_GAP; - const pad = CoverGrid.GRID_PADDING; - const columns = this.getColumnCount(); - const rowStep = this.cardHeight + gap; - const albumRow = Math.floor( - expandedIndex / columns, - ); - - // Top of the album card (at the midpoint of - // the gap above the row) in scroll-content - // coordinates. - const albumTop = - pad + albumRow * rowStep - gap / 2; - - // Compute the dropdown bottom from grid math. - // The dropdown sits right after the "before" - // rows: ceil(splitIndex / columns) full rows. - const dropdown = - this.shadowRoot?.querySelector( - 'album-dropdown', - ); - - if (!dropdown) return; - - // Wait for the dropdown to finish rendering - // its tracks so that offsetHeight is accurate. - await (dropdown as LitElement).updateComplete; - - const beforeRows = Math.ceil( - this.splitIndex / columns, - ); - const dropdownTop = pad + beforeRows * rowStep; - const dropdownBottom = - dropdownTop + - (dropdown as HTMLElement).offsetHeight; - - const viewTop = container.scrollTop; - const viewHeight = container.clientHeight; - - // The valid scroll range where both the album - // top and dropdown bottom are in view: - // scrollTop <= albumTop (card visible) - // scrollTop >= dropdownBottom - viewHeight - const minScroll = - dropdownBottom - viewHeight; - const maxScroll = albumTop; - - let newScrollTop: number; - - if (minScroll <= maxScroll) { - // Both can fit — clamp to the valid - // range, only scrolling if needed. - newScrollTop = Math.max( - minScroll, - Math.min(viewTop, maxScroll), - ); - } else { - // Combined height exceeds the viewport. - // Pin the album card top to the viewport - // top so it stays visible. - newScrollTop = albumTop; - } - - console.log( - '[scrollToShowDropdown]', - { - expandedIndex, - albumRow, - albumTop, - beforeRows, - dropdownTop, - dropdownOffsetHeight: - (dropdown as HTMLElement) - .offsetHeight, - dropdownBottom, - viewTop, - viewHeight, - minScroll, - maxScroll, - newScrollTop, - scrollHeight: - container.scrollHeight, - willScroll: - newScrollTop !== viewTop, - }, - ); - - if (newScrollTop !== viewTop) { - await this.restoreScrollTop(newScrollTop); - } - } - - /* ==================================================================== - * Album selection helpers - * ==================================================================== */ - - private selectAlbumRange( - from: number, - to: number, - ): Set { - const filtered = this.cachedFilteredAlbums; - const start = Math.min(from, to); - const end = Math.max(from, to); - const ids = new Set(); - - for (let i = start; i <= end; i++) { - const album = filtered[i]; - - if (album) { - ids.add(album.ID); - } - } - - return ids; - } - - private async getSelectedAlbumFilePaths(): Promise< - string[] - > { - const selected = this.albums.filter((a) => - this.selectedAlbums.has(a.ID), - ); - const allPaths: string[] = []; - - for (const album of selected) { - const paths = - await this.getAlbumFilePaths(album); - allPaths.push(...paths); - } - - return allPaths; - } - - /** - * 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. - */ - private async getContextMenuAlbumFilePaths(): Promise< - string[] - > { - if ( - this.contextMenuAlbumId !== null && - !this.selectedAlbums.has( - this.contextMenuAlbumId, - ) - ) { - const album = this.albums.find( - (a) => - a.ID === - this.contextMenuAlbumId, - ); - - if (album) { - return this.getAlbumFilePaths( - album, - ); - } - - return []; - } - - return this.getSelectedAlbumFilePaths(); - } - - private async getAlbumFilePaths( - album: library.Album, - ): Promise { - try { - const tracks = await GetAlbumTracks(album.ID); - - return tracks.map((t) => t.FilePath); - } catch (error) { - console.error( - 'Error loading album tracks:', - error, - ); - - return []; - } - } - - /** - * Pre-resolve file paths for all selected albums so - * that dragstart can read them synchronously. Called - * fire-and-forget whenever the album selection changes. - */ - private async warmAlbumFilePathCache(): Promise { - const selected = this.albums.filter((a) => - this.selectedAlbums.has(a.ID), - ); - - // Fetch missing entries. - for (const album of selected) { - if (this.albumFilePathCache.has(album.ID)) { - continue; - } - - try { - const tracks = await GetAlbumTracks( - album.ID, - ); - // Only store if still selected. - if (this.selectedAlbums.has(album.ID)) { - this.albumFilePathCache.set( - album.ID, - tracks.map((t) => t.FilePath), - ); - } - } catch { - // Silently skip — drag will just not - // include this album's paths. - } - } - } - - /** - * Read cached file paths for the current album - * selection. Returns an empty array if any albums - * haven't been cached yet. - */ - private getCachedSelectedAlbumFilePaths(): string[] { - const result: string[] = []; - - for (const album of this.albums) { - if (!this.selectedAlbums.has(album.ID)) { - continue; - } - - const paths = - this.albumFilePathCache.get(album.ID); - - if (paths) { - result.push(...paths); - } - } - - return result; - } - - /* ==================================================================== - * Track selection helpers - * ==================================================================== */ - - private selectTrackRange( - from: number, - to: number, - ): Set { - const start = Math.min(from, to); - const end = Math.max(from, to); - const paths = new Set(); - - for (let i = start; i <= end; i++) { - const track = this.expandedTracks[i]; - - if (track) { - paths.add(track.FilePath); - } - } - - return paths; - } - - private getSelectedTrackFilePaths(): string[] { - // Preserve the original track order - return this.expandedTracks - .filter((t) => - this.selectedTracks.has(t.FilePath), - ) - .map((t) => t.FilePath); - } - /* ==================================================================== * Dropdown (expand/collapse) * ==================================================================== */ @@ -2672,9 +1042,10 @@ export class CoverGrid extends LitElement { isShift && this.lastSelectedAlbumIndex !== null ) { - const range = this.selectAlbumRange( + const range = this.selMgr.selectAlbumRange( this.lastSelectedAlbumIndex, index, + this.cachedFilteredAlbums, ); const next = new Set(this.selectedAlbums); @@ -2684,7 +1055,9 @@ export class CoverGrid extends LitElement { this.selectedAlbums = next; this.syncDropdownToSelection(); - void this.warmAlbumFilePathCache(); + void this.selMgr.warmCache( + this.selectedAlbums, + ); } else if (isCtrl) { const next = new Set(this.selectedAlbums); @@ -2697,7 +1070,9 @@ export class CoverGrid extends LitElement { this.selectedAlbums = next; this.lastSelectedAlbumIndex = index; this.syncDropdownToSelection(); - void this.warmAlbumFilePathCache(); + void this.selMgr.warmCache( + this.selectedAlbums, + ); } else { // Plain click: if this album is the // sole selection, deselect + close. @@ -2717,7 +1092,9 @@ export class CoverGrid extends LitElement { } this.lastSelectedAlbumIndex = index; - void this.warmAlbumFilePathCache(); + void this.selMgr.warmCache( + this.selectedAlbums, + ); } }; @@ -2728,9 +1105,10 @@ export class CoverGrid extends LitElement { if (!hit) return; - const filePaths = await this.getAlbumFilePaths( - hit.album, - ); + const filePaths = + await this.selMgr.getAlbumFilePaths( + hit.album, + ); if (filePaths.length === 0) return; @@ -2766,7 +1144,9 @@ export class CoverGrid extends LitElement { } this.lastSelectedAlbumIndex = index; - void this.warmAlbumFilePathCache(); + void this.selMgr.warmCache( + this.selectedAlbums, + ); }; private onGridAlbumContextMenu = ( @@ -2781,7 +1161,7 @@ export class CoverGrid extends LitElement { this.contextMenuAlbumId = hit.album.ID; this.contextMenuTarget = { kind: 'album' }; - this.openContextMenuAt(e.clientX, e.clientY); + this.ctxMenu.openAt(e.clientX, e.clientY); }; /** @@ -2836,9 +1216,10 @@ export class CoverGrid extends LitElement { shiftKey && this.lastSelectedTrackIndex !== null ) { - const range = this.selectTrackRange( + const range = this.selMgr.selectTrackRange( this.lastSelectedTrackIndex, index, + this.expandedTracks, ); const next = new Set(this.selectedTracks); @@ -2894,7 +1275,7 @@ export class CoverGrid extends LitElement { } this.contextMenuTarget = { kind: 'track' }; - this.openContextMenuAt(clientX, clientY); + this.ctxMenu.openAt(clientX, clientY); }; /* ==================================================================== @@ -2910,7 +1291,10 @@ export class CoverGrid extends LitElement { if (this.selectedTracks.has(track.FilePath)) { filePaths = - this.getSelectedTrackFilePaths(); + this.selMgr.getSelectedTrackFilePaths( + this.selectedTracks, + this.expandedTracks, + ); } else { filePaths = [track.FilePath]; } @@ -2975,21 +1359,8 @@ export class CoverGrid extends LitElement { if (!hit) return; - if (this.albumFilePathCache.has(hit.album.ID)) { - return; - } - // Fire-and-forget: warm the cache entry. - void this.getAlbumFilePaths(hit.album).then( - (paths) => { - if (paths.length > 0) { - this.albumFilePathCache.set( - hit.album.ID, - paths, - ); - } - }, - ); + void this.selMgr.warmSingleAlbum(hit.album); }; private onAlbumDragStart = (e: DragEvent) => { @@ -3007,7 +1378,9 @@ export class CoverGrid extends LitElement { // Dragged album is part of the selection — // drag all selected albums' tracks. filePaths = - this.getCachedSelectedAlbumFilePaths(); + this.selMgr.getCachedSelectedPaths( + this.selectedAlbums, + ); isSingleAlbum = this.selectedAlbums.size === 1; } else { @@ -3015,7 +1388,7 @@ export class CoverGrid extends LitElement { // selection and drag only this album. this.selectedAlbums = new Set(); filePaths = - this.albumFilePathCache.get( + this.selMgr.getCachedAlbumPaths( hit.album.ID, ) ?? []; isSingleAlbum = true; @@ -3069,72 +1442,44 @@ export class CoverGrid extends LitElement { * ==================================================================== */ private onGridClick = (e: MouseEvent) => { - const path = e.composedPath(); + for (const el of e.composedPath()) { + if (!(el instanceof HTMLElement)) continue; - const clickedCard = path.some( - (el) => - el instanceof HTMLElement && - el.classList.contains('album-card'), - ); - - const clickedDropdown = path.some( - (el) => - el instanceof HTMLElement && - el.classList.contains('album-dropdown'), - ); - - if (!clickedCard && !clickedDropdown) { - this.selectedAlbums = new Set(); - this.lastSelectedAlbumIndex = null; - this.expandedAlbumId = null; - this.expandedTracks = []; - this.selectedTracks = new Set(); - this.lastSelectedTrackIndex = null; + if ( + el.classList.contains('album-card') || + el.classList.contains('album-dropdown') + ) { + return; + } } + + this.selectedAlbums = new Set(); + this.lastSelectedAlbumIndex = null; + this.expandedAlbumId = null; + this.expandedTracks = []; + this.selectedTracks = new Set(); + this.lastSelectedTrackIndex = null; }; /* ==================================================================== - * Context menu (shared between albums and tracks) + * Context menu actions * ==================================================================== */ - private openContextMenuAt( - clientX: number, - clientY: number, - ) { - this.contextMenuOpen = true; - - this.updateComplete.then(() => { - const popup = this.contextMenuPopup; - - if (popup) { - (popup as any).anchor = { - getBoundingClientRect() { - return { - width: 0, - height: 0, - x: clientX, - y: clientY, - top: clientY, - left: clientX, - right: clientX, - bottom: clientY, - }; - }, - }; - (popup as any).active = true; - } - }); - } - private async onContextMenuAction(action: string) { let filePaths: string[]; if (this.contextMenuTarget.kind === 'track') { filePaths = - this.getSelectedTrackFilePaths(); + this.selMgr.getSelectedTrackFilePaths( + this.selectedTracks, + this.expandedTracks, + ); } else { filePaths = - await this.getContextMenuAlbumFilePaths(); + await this.selMgr.getContextMenuAlbumFilePaths( + this.contextMenuAlbumId, + this.selectedAlbums, + ); } if (filePaths.length === 0) return; @@ -3154,7 +1499,17 @@ export class CoverGrid extends LitElement { break; } - this.closeContextMenu(true); + this.clearContextMenuSelection(); + this.ctxMenu.close(); + } + + /** Clear the selection that was active for the context menu. */ + private clearContextMenuSelection() { + if (this.contextMenuTarget.kind === 'track') { + this.selectedTracks = new Set(); + } else { + this.selectedAlbums = new Set(); + } } private openTrackDetails(filePath: string) { @@ -3165,7 +1520,10 @@ export class CoverGrid extends LitElement { if (!track) return; const coverArt = - this.resolveTrackCoverArt(track.Album); + this.selMgr.resolveTrackCoverArt( + track.Album, + this.expandedAlbumId, + ); this.trackDetailsDialog?.show( track, @@ -3173,119 +1531,23 @@ export class CoverGrid extends LitElement { ); } - private resolveTrackCoverArt( - albumName: string, - ): CoverArtUrls | null { - if (!albumName) return null; - - const album = this.albums.find( - (a) => a.Name === albumName, - ); - - if (!album || !album.CoverArtPath) return null; - - return { - coverArtPath: album.CoverArtPath, - coverArtSmall: album.CoverArtSmall, - coverArtMedium: album.CoverArtMedium, - coverArtLarge: album.CoverArtLarge, - }; - } - - private closeContextMenu(clearSelection = false) { - if (!this.contextMenuOpen) return; - - this.closePlaylistSubmenu(); - this.contextMenuOpen = false; - this.playlistFilePaths = []; - this.contextMenuAlbumId = null; - - if (clearSelection) { - if ( - this.contextMenuTarget.kind === 'track' - ) { - this.selectedTracks = new Set(); - } else { - this.selectedAlbums = new Set(); - } - } - - const popup = this.contextMenuPopup; - - if (popup) { - (popup as any).active = false; - } - } - - private 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; - + /** Resolve file paths for the playlist submenu. */ + private async getPlaylistSubmenuFilePaths(): Promise< + string[] + > { if (this.contextMenuTarget.kind === 'track') { - this.playlistFilePaths = - this.getSelectedTrackFilePaths(); - } else { - this.playlistFilePaths = - await this.getContextMenuAlbumFilePaths(); - } - - this.playlistSubmenuOpen = true; - - await this.updateComplete; - - const submenu = this.playlistSubmenuPopup; - const trigger = - this.shadowRoot?.querySelector( - '.submenu-item', + return this.selMgr.getSelectedTrackFilePaths( + this.selectedTracks, + this.expandedTracks, ); - - 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(); + return this.selMgr.getContextMenuAlbumFilePaths( + this.contextMenuAlbumId, + this.selectedAlbums, + ); } - 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(); - }; - /* ==================================================================== * Render: sort toolbar * ==================================================================== */ @@ -3581,6 +1843,12 @@ export class CoverGrid extends LitElement { * "before" and "after" grids. */ private renderSplitGrid() { + const sm = this.scrollMgr; + const ctr = this.scrollContainer; + const containerW = sm.getContainerWidth(ctr); + const rowW = sm.getGridRowWidth(ctr); + const afterEntries = this.getAfterEntries(); + return html` - ${this.getAfterEntries().length > 0 + ${afterEntries.length > 0 ? html` - ${this.contextMenuOpen + ${ctxMenu.contextMenuOpen ? html`
- this.closePlaylistSubmenu()} + ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + ctxMenu.closePlaylistSubmenu()} > { - this.clearSubmenuCloseTimer(); - void this.showPlaylistSubmenu(); + ctxMenu.clearSubmenuCloseTimer(); + void this.handleShowPlaylistSubmenu(); }} - @mouseleave=${this + @mouseleave=${ctxMenu .scheduleSubmenuClose} @click=${(e: Event) => { e.stopPropagation(); - void this.showPlaylistSubmenu(); + void this.handleShowPlaylistSubmenu(); }} > - this.closePlaylistSubmenu()} + ctxMenu.closePlaylistSubmenu()} > - ${this.playlistSubmenuOpen + ${ctxMenu.playlistSubmenuOpen ? html`
- this.clearSubmenuCloseTimer()} - @mouseleave=${this + ctxMenu.clearSubmenuCloseTimer()} + @mouseleave=${ctxMenu .scheduleSubmenuClose} > e.stopPropagation()} diff --git a/frontend/src/components/cover-grid/scroll-manager.ts b/frontend/src/components/cover-grid/scroll-manager.ts new file mode 100644 index 0000000..b3c4911 --- /dev/null +++ b/frontend/src/components/cover-grid/scroll-manager.ts @@ -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 { + // 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 { + 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 { + 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((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 { + 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, + ): 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; + })(); + } +} diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 445445b..3328dab 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -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`
- this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > { - 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(), + ); }} > - ${this.playlistSubmenuOpen + ${this.ctxMenu.playlistSubmenuOpen ? html`
- this.clearSubmenuCloseTimer()} + this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this + .ctxMenu .scheduleSubmenuClose} > 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`
- this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > { - 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()); }} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - ${this.playlistSubmenuOpen && + ${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection ? html`
- this.clearSubmenuCloseTimer()} + this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this + .ctxMenu .scheduleSubmenuClose} > e.stopPropagation()} diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index fa1db8a..ef74cb4 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -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`
- this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > { - 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()); }} > - this.closePlaylistSubmenu()} + this.ctxMenu.closePlaylistSubmenu()} > - ${this.playlistSubmenuOpen && + ${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection ? html`
- this.clearSubmenuCloseTimer()} + this.ctxMenu.clearSubmenuCloseTimer()} @mouseleave=${this - .scheduleSubmenuClose} + .ctxMenu.scheduleSubmenuClose} > diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index f0eca7a..34fbdd7 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -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`
this.onContextMenuAction('play')} - @mouseenter=${() => this.closePlaylistSubmenu()} + @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Play this.onContextMenuAction('add-to-queue')} - @mouseenter=${() => this.closePlaylistSubmenu()} + @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Add to Queue this.onContextMenuAction('play-next')} - @mouseenter=${() => this.closePlaylistSubmenu()} + @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > Play Next @@ -1810,13 +1659,13 @@ export class TrackList extends LitElement implements SelectionHost { { - 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()); }} > @@ -1830,8 +1679,8 @@ export class TrackList extends LitElement implements SelectionHost { this.onContextMenuAction( 'track-details', )} - @mouseenter=${() => - this.closePlaylistSubmenu()} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} > - ${this.playlistSubmenuOpen && this.selection.hasSelection + ${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection ? html`
- this.clearSubmenuCloseTimer()} - @mouseleave=${this.scheduleSubmenuClose} + this.ctxMenu.clearSubmenuCloseTimer()} + @mouseleave=${this.ctxMenu.scheduleSubmenuClose} > e.stopPropagation()} >
diff --git a/frontend/src/utils/context-menu-controller.ts b/frontend/src/utils/context-menu-controller.ts new file mode 100644 index 0000000..7e3c6b4 --- /dev/null +++ b/frontend/src/utils/context-menu-controller.ts @@ -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; + 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 { + 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; + } +`;