diff --git a/SCROLL_RESTORE_FINDINGS.md b/SCROLL_RESTORE_FINDINGS.md new file mode 100644 index 0000000..277f624 --- /dev/null +++ b/SCROLL_RESTORE_FINDINGS.md @@ -0,0 +1,150 @@ +# Scroll Position Restore: Findings & Status + +## Goal + +When switching between views (track-list, cover-grid), restore the scroll position so the user doesn't lose their place. Data is already cached via `LibraryStore` so re-queries aren't needed. + +## Architecture + +- **LibraryStore** (`frontend/src/store/library-store.ts`): Singleton that caches track/album data and stores a per-view scroll position (stored as the first visible item index, not pixel offset). +- **LibraryController** (`frontend/src/store/controllers/library-controller.ts`): ReactiveController bridging LibraryStore to Lit components. +- **Save mechanism**: Both components listen for `visibilityChanged` events on ``. The event carries `{ first, last }` (indices of first/last visible items). We store `first` in the LibraryStore on every event. +- **Restore mechanism**: On first `visibilityChanged` after mount, call `scrollToIndex(savedIndex, 'start')` to jump to the saved item. +- **Backend**: `LibraryScanComplete` event emitted from Go after library scan; frontend store listens and invalidates caches. + +## Key Technical Details + +### lit-virtualizer internals (v2.1.1) + +- `LitVirtualizer` extends `LitElement` but uses `createRenderRoot() { return this }` (no shadow DOM). +- The actual work is done by a `Virtualizer` class, created by the `virtualize()` directive during `LitVirtualizer.render()`. +- The `Virtualizer` instance is stored on the host element via `hostElement[virtualizerRef]`. +- `LitVirtualizer.layoutComplete` delegates to `this[virtualizerRef]?.layoutComplete` — returns `undefined` if the Virtualizer hasn't been created yet. + +### Virtualizer layout cycle + +1. `connected()` → `_schedule(_updateLayout)` (deferred via microtask) +2. `_updateLayout()` → `_updateView()` (reads viewport bounds via `getBoundingClientRect`) → `layout.reflowIfNeeded()` +3. Layout `_reflow()` → `_getActiveItems()` → `_updateVisibleIndices()` → `_sendStateChangedMessage()` +4. `_handleLayoutMessage('stateChanged')` → `_updateDOM()`: + - **`_notifyVisibility()`** → dispatches `visibilityChanged` event + - **`_notifyRange()`** → dispatches `rangeChanged` event + - **`_finishDOMUpdate()`**: + - `_positionChildren()` — positions child elements + - `_sizeHostElement()` — updates the sizer element (creates scrollable area) + - `_correctScrollError()` — calls native `scrollTo` if there's a scroll error + +**Critical**: `visibilityChanged` fires BEFORE `_finishDOMUpdate`. This means when the event handler runs, the sizer hasn't been updated yet and children haven't been positioned yet. + +### Sizer element + +The virtualizer creates scrollable area using an absolutely positioned hidden div with `style.transform = translate(Wpx, Hpx)`. This transform creates overflow that establishes `scrollHeight`. For scroller mode (`scroller=true`), this is the mechanism for scroll area. + +### `scrollToIndex` internals + +`scrollToIndex(index, 'start')` → `element(index).scrollIntoView({ block: 'start' })` → `_scrollElementIntoView`: +- If item is in rendered range: calls native `scrollIntoView()` on the DOM element (works immediately) +- If item is NOT in range: sets `this._layout.pin = options` → triggers async reflow via `_triggerReflow()` → `Promise.resolve().then(() => this.reflowIfNeeded())` + +The pin-triggered reflow: `_setPositionFromPin()` → calculates target scroll position → `_scrollError` → `_sendStateChangedMessage()` → `_updateDOM()` → `_finishDOMUpdate()` → `_sizeHostElement()` + `_correctScrollError()` → `_nativeScrollTo()`. + +**The sizer update and scrollTo happen in the same synchronous chain.** If the browser hasn't laid out the sizer yet, `scrollTo` may be clamped to the (incorrect) current `scrollHeight`. + +### `layoutComplete` internals + +- **Lazily created**: accessing `layoutComplete` creates a promise if one doesn't exist +- **Resolved by `_scheduleLayoutComplete()`** which is called from `_childrenSizeChanged` (ResizeObserver callback) +- **Uses internal double-rAF**: `requestAnimationFrame(() => requestAnimationFrame(() => resolve()))` +- **After resolution**: `_resetLayoutCompleteState()` nulls out the promise (next access creates a fresh one) +- `_scheduleLayoutComplete` only resolves if `_layoutCompletePromise` is non-null AND `_pendingLayoutComplete` is null + +### Flow layout vs Grid layout + +**Flow layout** (`track-list`): +- Computes item positions as `index * delta` — doesn't need cross-axis viewport width +- Works immediately on first layout cycle +- First `visibilityChanged` has real item indices + +**Grid layout** (`cover-grid`): +- Needs viewport width to compute number of columns (`rolumns`) +- Viewport width comes from `_updateView()` → `getBoundingClientRect()`, but on first cycle the element may have zero width +- When `_viewDim2 <= 0` (no width), `rolumns = 0`, `_first = -1`, `_last = -1` +- `_getItemPosition` divides by `rolumns` — division by 0 when columns=0 produces `Infinity` +- First `visibilityChanged` is premature: `first: 0, last: 0` (defaults from BaseLayout constructor, since `_updateVisibleIndices` returns early when `_first === -1`) +- Real layout happens after ResizeObserver reports viewport width → second reflow → second `visibilityChanged` with real indices + +### Browser frame order + +1. JavaScript execution (microtasks, macrotasks) +2. ResizeObserver callbacks +3. `requestAnimationFrame` callbacks +4. Style/Layout calculation +5. Paint + +## What Has Been Tried + +### Approach 1: `scrollTop` pixel offset with `await updateComplete` + double-rAF +**Result**: Failed for both components. +**Why**: `await this.updateComplete` only waits for the parent Lit component's render. The `LitVirtualizer` child element exists in the DOM but hasn't completed its own Lit render cycle — the `Virtualizer` instance doesn't exist yet. The double-rAF fires too early. + +### Approach 2: `scrollTop` pixel offset with `await updateComplete` + `await layoutComplete` +**Result**: Failed for both components. +**Why**: After `await this.updateComplete`, `this.virtualizer.layoutComplete` returns `undefined` because `virtualizerRef` hasn't been set yet (LitVirtualizer hasn't rendered). `await undefined` resolves immediately. + +### Approach 3: Index-based save/restore via `visibilityChanged` event + immediate `scrollToIndex` +**Result**: Track-list worked. Cover-grid did not. +**Why track-list worked**: Flow layout has real items on first `visibilityChanged`. `scrollToIndex` sets a pin, the reflow works correctly. +**Why cover-grid failed**: First `visibilityChanged` is premature (0 columns). `scrollToIndex` sets pin, but reflow with 0 columns produces garbage positions (division by 0). + +### Approach 4: `visibilityChanged` + `scrollHeight > clientHeight` guard + `layoutComplete?.then` + `scrollToIndex` +**Result**: Cover-grid partially worked (scrolled to correct position after one manual scroll, not on initial load). Track-list worked but with a flash. +**Why cover-grid failed**: `visibilityChanged` fires BEFORE `_finishDOMUpdate` updates the sizer. So `scrollHeight` reflects the PREVIOUS state (premature layout with scrollSize=1). The guard `scrollHeight <= clientHeight` was always true during the visibilityChanged handler, causing every event to be skipped. Only after a manual scroll (which triggers a fresh `visibilityChanged` with updated DOM state) did it work. +**Why track-list flashed**: `layoutComplete` uses internal double-rAF, so the scroll happens 2 frames after the initial render at position 0. + +### Approach 5: `visibilityChanged` + `scrollHeight > clientHeight` guard removed + `layoutComplete?.then` + `scrollToIndex` +**Result**: Cover-grid worked but with flash. Track-list worked but with flash. +**Why it flashed**: The double-rAF delay in `layoutComplete` means 2 frames render at position 0 before scrolling. + +### Approach 6: `visibilityChanged` + `requestAnimationFrame` + `scrollToIndex` (no guard for cover-grid) +**Result**: Track-list worked without flash. Cover-grid did not work at all. +**Why track-list worked**: Flow layout has real items immediately. rAF fires after sizer is set. `scrollToIndex` works. +**Why cover-grid failed**: First `visibilityChanged` is premature (0 columns). `hasRestoredScroll` set to true. rAF fires, but grid still has 0 columns → pin fails. Restore opportunity consumed. + +### Approach 7: `visibilityChanged` + `last <= 0` guard for cover-grid + `requestAnimationFrame` + `scrollToIndex` +**Result**: Track-list worked without flash. Cover-grid did not work. +**Why cover-grid failed**: The `last <= 0` guard correctly skips the premature event. The second `visibilityChanged` (real layout, `last > 0`) triggers the handler. `hasRestoredScroll = true`, schedules rAF. But in the rAF callback, `scrollToIndex` → pin → reflow → `_sizeHostElement` + `_correctScrollError` → `scrollTo`. The sizer was updated in `_finishDOMUpdate` (same JS execution context as the `visibilityChanged`), but the browser hasn't processed it into `scrollHeight` yet when `scrollTo` is called inside the pin-triggered reflow. The `scrollTo` is clamped to the old (small) scrollHeight. + +**Key insight**: For cover-grid, even after waiting for the real `visibilityChanged`, the pin mechanism's synchronous reflow chain does `_sizeHostElement` + `scrollTo` atomically. The browser never gets a chance to process the sizer into `scrollHeight` between these two operations. This is why `requestAnimationFrame` alone isn't enough for cover-grid — the problem isn't WHEN we call `scrollToIndex`, it's that `scrollToIndex`'s internal reflow always does sizer+scroll atomically. + +## Current State of Code + +The current code has: +- `track-list.ts`: `visibilityChanged` handler with `requestAnimationFrame` + `scrollToIndex` (works without flash) +- `cover-grid.ts`: `visibilityChanged` handler with `last <= 0` guard + `requestAnimationFrame` + `scrollToIndex` (does NOT work) + +## Untried Ideas + +1. **Bypass `scrollToIndex` entirely for cover-grid**: After `layoutComplete` resolves (sizer is painted), directly set `el.scrollTop` instead of `scrollToIndex`. This avoids the pin mechanism's atomic sizer+scroll problem. The virtualizer will react to the scroll event and re-render items at the new position. + +2. **Use `scrollToIndex` on the SECOND `visibilityChanged` after the real one**: The first real `visibilityChanged` triggers `_finishDOMUpdate` which sets the sizer. After the browser paints (next frame), `scrollHeight` is correct. If we could delay to the next `visibilityChanged`... but there might not be one without user interaction. + +3. **Pre-set the pin before the virtualizer initializes**: If we could inject the pin into the layout before the first `_updateLayout` runs, the virtualizer would start at the correct position. But the layout's pin setter is internal. + +4. **Use `element(index).scrollIntoView()` when the item IS in range**: After `layoutComplete`, if the target item happens to be in the rendered range, native `scrollIntoView` works. But for distant items it won't be in range. + +5. **Two-phase for cover-grid**: Use `layoutComplete?.then` to wait for sizer to be painted, then set `scrollTop` directly (not `scrollToIndex`). Calculate pixel offset: `offset = padding + Math.floor(index / columns) * (itemHeight + gap)`. Grid config is known: `itemSize: 230px height, gap: 16px, padding: 16px`. Columns can be derived from viewport width: `columns = Math.floor((viewportWidth - padding*2 + gap) / (itemWidth + gap))`. This is fragile but would work. + +6. **For cover-grid, after `layoutComplete` resolves, use `requestAnimationFrame` + direct `scrollTop`**: `layoutComplete` ensures sizer is painted → `scrollHeight` is correct. Then rAF + `el.scrollTop = computedOffset` avoids the pin mechanism entirely. The virtualizer reacts to the scroll event. + +7. **Hybrid approach**: Track-list uses `requestAnimationFrame` + `scrollToIndex` (works). Cover-grid uses `layoutComplete?.then` + direct `scrollTop` (avoids pin, avoids flash since we set scrollTop before paint in the rAF following layoutComplete... actually layoutComplete already used double-rAF so there would still be a flash). + +## File Locations + +- `frontend/src/store/library-store.ts` — LibraryStore singleton +- `frontend/src/store/controllers/library-controller.ts` — LibraryController +- `frontend/src/components/track-list/track-list.ts` — Track list component +- `frontend/src/components/cover-grid/cover-grid.ts` — Cover grid component +- `frontend/src/events.ts` — Frontend event constants +- `backend/events/events.go` — Backend event constants +- `backend/library/library.go` — Emits LibraryScanComplete +- `frontend/node_modules/@lit-labs/virtualizer/` — Virtualizer source (v2.1.1) diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql index 7e59491..a0ace65 100644 --- a/backend/database/sql/queries/release_groups.sql +++ b/backend/database/sql/queries/release_groups.sql @@ -43,13 +43,20 @@ SELECT * FROM release_groups ORDER BY name; -- name: GetAllAlbumsWithDetails :many -SELECT +SELECT rg.id, rg.name, rg.year, - COALESCE(ac.text, '') as artist_name, + COALESCE(ac.text, fallback_ac.text, '') as artist_name, COALESCE(ca.file_path, '') as cover_art_path FROM release_groups rg LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN ( + SELECT rgr.release_group_id, ac2.text + FROM release_group_recordings rgr + JOIN recordings rec ON rec.id = rgr.recording_id + JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id + GROUP BY rgr.release_group_id +) fallback_ac ON fallback_ac.release_group_id = rg.id ORDER BY rg.name; diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go index f765973..6e8a236 100644 --- a/backend/database/sql/sqlcgen/release_groups.sql.go +++ b/backend/database/sql/sqlcgen/release_groups.sql.go @@ -79,15 +79,22 @@ func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error { } const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many -SELECT +SELECT rg.id, rg.name, rg.year, - COALESCE(ac.text, '') as artist_name, + COALESCE(ac.text, fallback_ac.text, '') as artist_name, COALESCE(ca.file_path, '') as cover_art_path FROM release_groups rg LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN ( + SELECT rgr.release_group_id, ac2.text + FROM release_group_recordings rgr + JOIN recordings rec ON rec.id = rgr.recording_id + JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id + GROUP BY rgr.release_group_id +) fallback_ac ON fallback_ac.release_group_id = rg.id ORDER BY rg.name ` diff --git a/backend/events/events.go b/backend/events/events.go index 3e75157..3462a7d 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -49,3 +49,8 @@ const ( const ( LibraryConfigChanged = "LibraryConfigChanged" ) + +// Library events. +const ( + LibraryScanComplete = "LibraryScanComplete" +) diff --git a/backend/library/library.go b/backend/library/library.go index a6bf945..63d729f 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -335,6 +335,8 @@ func (l *Library) Scan() error { "library", l.conf.DirectoryPath, ) + runtime.EventsEmit(l.ctx, events.LibraryScanComplete) + return scanErr } @@ -510,18 +512,27 @@ func (l *Library) processMetadata(result importResult) (int64, error) { }) } - // 3. Get or create artist credit for album artist (if different) + // 3. Get or create artist credit for album artist. + // Always assign an album artist credit so the cover grid displays an + // artist name. When the AlbumArtist tag is absent or identical to the + // track Artist, reuse the track artist credit instead of leaving it NULL. var albumArtistCreditID sql.NullInt64 if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist { - albumArtistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, tags.AlbumArtist) + albumArtistCredit, err := l.db.Queries.UpsertArtistCredit( + l.ctx, tags.AlbumArtist, + ) if err != nil { l.logger.Warn("could not upsert album artist credit", "err", err) } else { - albumArtistCreditID = sql.NullInt64{Int64: albumArtistCredit.ID, Valid: true} + albumArtistCreditID = sql.NullInt64{ + Int64: albumArtistCredit.ID, Valid: true, + } - // Also create the artist record and link - albumArtist, err := l.db.Queries.UpsertArtist(l.ctx, tags.AlbumArtist) + // Also create the artist record and link. + albumArtist, err := l.db.Queries.UpsertArtist( + l.ctx, tags.AlbumArtist, + ) if err != nil { l.logger.Warn("could not upsert album artist", "err", err) } else { @@ -534,6 +545,12 @@ func (l *Library) processMetadata(result importResult) (int64, error) { ) } } + } else { + // AlbumArtist is empty or matches the track artist — reuse the + // track artist credit so the release group always has an artist. + albumArtistCreditID = sql.NullInt64{ + Int64: artistCredit.ID, Valid: true, + } } // 4. Get or create release group (album) diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index dca3d9e..257fbac 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -1,9 +1,14 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; -import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library'; +import { GetAlbumTracks } from '@go/library/Library'; import { library } from '@go/models'; import { QueueController } from '@store/controllers/queue-controller'; +import { LibraryController } from '@store/controllers/library-controller'; import '@lit-labs/virtualizer'; +import type { + LitVirtualizer, + VisibilityChangedEvent, +} from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; @@ -14,8 +19,16 @@ import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker @customElement('cover-grid') export class CoverGrid extends LitElement { private queue = new QueueController(this); + private libraryCtrl = new LibraryController(this); + + // Grid layout constants — must match the grid() config in render(). + private static readonly GRID_ITEM_WIDTH = 176; + private static readonly GRID_ITEM_HEIGHT = 230; + private static readonly GRID_GAP = 16; + private static readonly GRID_PADDING = 16; private lastSelectedIndex: number | null = null; + private hasRestoredScroll = false; private closeHandler = () => this.closeContextMenu(); @@ -31,6 +44,10 @@ export class CoverGrid extends LitElement { overflow-y: auto; } + lit-virtualizer.restoring { + visibility: hidden; + } + .album-card { display: flex; flex-direction: column; @@ -185,12 +202,18 @@ export class CoverGrid extends LitElement { @state() private playlistFilePaths: string[] = []; + @state() + private hiddenForRestore = false; + @query('#context-menu') private contextMenuPopup!: HTMLElement; @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; + @query('lit-virtualizer') + private virtualizer!: LitVirtualizer; + override connectedCallback() { super.connectedCallback(); this.loadAlbums(); @@ -199,6 +222,12 @@ export class CoverGrid extends LitElement { } override disconnectedCallback() { + this.virtualizer?.removeEventListener( + 'visibilityChanged', + this.onVisibilityChanged, + ); + this.hasRestoredScroll = false; + this.hiddenForRestore = false; super.disconnectedCallback(); document.removeEventListener('click', this.closeHandler); document.removeEventListener('contextmenu', this.closeHandler); @@ -207,7 +236,7 @@ export class CoverGrid extends LitElement { private async loadAlbums() { try { this.loading = true; - const albums = await GetAllAlbums(); + const albums = await this.libraryCtrl.getAlbums(); this.albums = albums ?? []; this.selectedAlbums = new Set(); this.lastSelectedIndex = null; @@ -217,6 +246,89 @@ export class CoverGrid extends LitElement { } finally { this.loading = false; } + + const savedIndex = + this.libraryCtrl.getScrollPosition('albums'); + + if (savedIndex > 0) { + this.hiddenForRestore = true; + } + + await this.updateComplete; + + if (this.isConnected && this.virtualizer) { + this.virtualizer.addEventListener( + 'visibilityChanged', + this.onVisibilityChanged, + ); + } + } + + private onVisibilityChanged = (e: Event) => { + const { first, last } = e as VisibilityChangedEvent; + + if (!this.hasRestoredScroll) { + // The grid layout fires a premature visibilityChanged + // before the viewport width is measured. Skip until + // real items are visible. + if (last <= 0) { + return; + } + + this.hasRestoredScroll = true; + + const savedIndex = + this.libraryCtrl.getScrollPosition('albums'); + + if (savedIndex > 0) { + void this.restoreScrollPosition(savedIndex); + + return; + } + + this.hiddenForRestore = false; + } + + this.libraryCtrl.setScrollPosition('albums', first); + }; + + private async restoreScrollPosition( + savedIndex: number, + ) { + // Wait for the virtualizer layout to settle. layoutComplete + // resolves after ResizeObserver + double-rAF, so the sizer + // transform has been painted and scrollHeight is correct. + await this.virtualizer?.layoutComplete; + + // Compute pixel offset matching the grid layout internals: + // offset = padding + row * (itemHeight + gap). + const vw = this.virtualizer?.clientWidth ?? 0; + const { + GRID_ITEM_WIDTH, + GRID_ITEM_HEIGHT, + GRID_GAP, + GRID_PADDING, + } = CoverGrid; + + const availableWidth = vw - GRID_PADDING * 2; + const columns = Math.max( + 1, + Math.floor( + (availableWidth + GRID_GAP) / + (GRID_ITEM_WIDTH + GRID_GAP), + ), + ); + const row = Math.floor(savedIndex / columns); + const pixelOffset = + GRID_PADDING + + row * (GRID_ITEM_HEIGHT + GRID_GAP); + + if (this.virtualizer) { + this.virtualizer.scrollTop = pixelOffset; + } + + // Reveal now that the virtualizer is at the correct position. + this.hiddenForRestore = false; } private selectRange( @@ -528,6 +640,7 @@ export class CoverGrid extends LitElement { return html` { const path = e.composedPath(); - const popup = this.savePlaylistPopup; - const btn = this.shadowRoot?.querySelector('.save-playlist-button'); + const popup = this.addToPlaylistPopup; + const btn = this.shadowRoot?.querySelector('.add-to-playlist-button'); if (popup && !path.includes(popup) && (!btn || !path.includes(btn))) { this.closePlaylistPicker(); @@ -94,7 +94,7 @@ export class QueuePanel extends LitElement { font-weight: 600; } - .save-playlist-button { + .add-to-playlist-button { background: none; border: none; color: inherit; @@ -104,16 +104,16 @@ export class QueuePanel extends LitElement { align-items: center; } - .save-playlist-button:hover { + .add-to-playlist-button:hover { color: #ffd43b; } - .save-playlist-button:disabled { + .add-to-playlist-button:disabled { color: #555; cursor: not-allowed; } - #save-playlist-popup { + #add-to-playlist-popup { z-index: 210; } @@ -231,15 +231,15 @@ export class QueuePanel extends LitElement { document.removeEventListener('click', this.closePickerHandler); } - private async handleSaveAsPlaylist() { + private async handleAddToPlaylist() { if (this.queue.tracks.length === 0) return; this.playlistPickerOpen = !this.playlistPickerOpen; await this.updateComplete; - const popup = this.savePlaylistPopup; - const btn = this.shadowRoot?.querySelector('.save-playlist-button'); + const popup = this.addToPlaylistPopup; + const btn = this.shadowRoot?.querySelector('.add-to-playlist-button'); if (popup && btn) { (popup as any).anchor = btn; @@ -260,7 +260,7 @@ export class QueuePanel extends LitElement { this.playlistPickerOpen = false; - const popup = this.savePlaylistPopup; + const popup = this.addToPlaylistPopup; if (popup) { (popup as any).active = false; @@ -336,17 +336,17 @@ export class QueuePanel extends LitElement {

Queue

diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 29b9b70..8e5a86a 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -1,12 +1,15 @@ -import { GetAllTracks } from '@go/library/Library'; import { library } from '@go/models'; -import { LogPrint } from '@runtime/runtime'; import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; import { formatMilliseconds } from '@utils/time'; import { PlayerController } from '@store/controllers/player-controller'; import { QueueController } from '@store/controllers/queue-controller'; +import { LibraryController } from '@store/controllers/library-controller'; import '@lit-labs/virtualizer'; +import type { + LitVirtualizer, + VisibilityChangedEvent, +} from '@lit-labs/virtualizer'; import { flow } from '@lit-labs/virtualizer/layouts/flow.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; @@ -14,10 +17,16 @@ 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'; +const COLUMN_STORAGE_KEY = 'track-list-column-widths'; +const MIN_COLUMN_WIDTH = 50; +const DEFAULT_DURATION_WIDTH = 80; +const COLUMN_COUNT = 3; + @customElement('track-list') export class TrackList extends LitElement { private player = new PlayerController(this); private queue = new QueueController(this); + private libraryCtrl = new LibraryController(this); @state() private tracks: library.Track[] = []; @@ -37,12 +46,160 @@ export class TrackList extends LitElement { @query('#playlist-submenu') private playlistSubmenuPopup!: HTMLElement; + @query('lit-virtualizer') + private virtualizer!: LitVirtualizer; + private lastSelectedIndex: number | null = null; private closeHandler = () => this.closeContextMenu(); + @state() + private columnWidths: number[] = []; + + private resizingColumn: number | null = null; + private resizeStartX = 0; + private resizeStartWidths: number[] = []; + private resizeObserver: ResizeObserver | null = null; + private flowLayout = flow(); + private hasRestoredScroll = false; + + private get gridTemplateColumns(): string { + if (this.columnWidths.length === 0) { + return '1fr 1fr 80px'; + } + + return this.columnWidths + .map((w) => `${w}px`) + .join(' '); + } + + private get colBoundaryPositions(): number[] { + if (this.columnWidths.length === 0) return []; + + const padding = 8; + const positions: number[] = []; + let cumulative = padding; + + for (let i = 0; i < this.columnWidths.length - 1; i++) { + cumulative += this.columnWidths[i] ?? 0; + positions.push(cumulative); + } + + return positions; + } + + private initColumnWidths() { + const saved = this.loadColumnWidths(); + + if (saved) { + this.columnWidths = saved; + + return; + } + + this.computeDefaultWidths(); + } + + private computeDefaultWidths() { + const totalWidth = this.clientWidth; + + if (totalWidth <= 0) return; + + const remaining = totalWidth - DEFAULT_DURATION_WIDTH; + const half = Math.floor(remaining / 2); + + this.columnWidths = [ + half, + remaining - half, + DEFAULT_DURATION_WIDTH, + ]; + } + + private loadColumnWidths(): number[] | null { + try { + const raw = localStorage.getItem(COLUMN_STORAGE_KEY); + + if (!raw) return null; + + const parsed: unknown = JSON.parse(raw); + + if ( + !Array.isArray(parsed) || + parsed.length !== COLUMN_COUNT || + !parsed.every( + (v: unknown) => + typeof v === 'number' && v >= MIN_COLUMN_WIDTH, + ) + ) { + return null; + } + + return parsed as number[]; + } catch { + return null; + } + } + + private saveColumnWidths() { + try { + localStorage.setItem( + COLUMN_STORAGE_KEY, + JSON.stringify(this.columnWidths), + ); + } catch { + // Ignore storage errors. + } + } + + private onColResizeStart = (e: MouseEvent, columnIndex: number) => { + e.preventDefault(); + this.resizingColumn = columnIndex; + this.resizeStartX = e.clientX; + this.resizeStartWidths = [...this.columnWidths]; + this.requestUpdate(); + }; + + private onColResizeMove = (e: MouseEvent) => { + if (this.resizingColumn === null) return; + + const delta = e.clientX - this.resizeStartX; + const col = this.resizingColumn; + const nextCol = col + 1; + const startLeft = this.resizeStartWidths[col] ?? 0; + const startRight = this.resizeStartWidths[nextCol] ?? 0; + const total = startLeft + startRight; + + let newLeft = startLeft + delta; + let newRight = startRight - delta; + + if (newLeft < MIN_COLUMN_WIDTH) { + newLeft = MIN_COLUMN_WIDTH; + newRight = total - MIN_COLUMN_WIDTH; + } + + if (newRight < MIN_COLUMN_WIDTH) { + newRight = MIN_COLUMN_WIDTH; + newLeft = total - MIN_COLUMN_WIDTH; + } + + const updated = [...this.resizeStartWidths]; + + updated[col] = newLeft; + updated[nextCol] = newRight; + this.columnWidths = updated; + }; + + private onColResizeEnd = () => { + if (this.resizingColumn === null) return; + + this.resizingColumn = null; + this.saveColumnWidths(); + this.requestUpdate(); + }; + static override styles = css` :host { + position: relative; display: flex; flex-direction: column; overflow: hidden; @@ -50,7 +207,6 @@ export class TrackList extends LitElement { .header-row { display: grid; - grid-template-columns: 1fr 1fr 80px; padding: 8px; font-weight: bold; color: #fff; @@ -58,6 +214,44 @@ export class TrackList extends LitElement { flex-shrink: 0; } + .header-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .resize-overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 2; + } + + .col-resize-handle { + position: absolute; + top: 0; + height: 100%; + width: 1px; + cursor: col-resize; + pointer-events: auto; + background-color: #444; + transition: background-color 0.15s ease; + } + + .col-resize-handle::before { + content: ''; + position: absolute; + top: 0; + left: -3px; + width: 7px; + height: 100%; + } + + .col-resize-handle:hover, + .col-resize-handle.active { + background-color: #6c757d; + } + lit-virtualizer { flex: 1; overflow-y: auto; @@ -65,7 +259,6 @@ export class TrackList extends LitElement { .track-row { display: grid; - grid-template-columns: 1fr 1fr 80px; font-size: 12px; padding: 8px; border-bottom: 1px solid #333; @@ -79,6 +272,11 @@ export class TrackList extends LitElement { min-width: 0; } + .header-cell + .header-cell, + .track-row > :not(:first-child) { + padding-left: 6px; + } + .track-row:hover { background-color: rgba(255, 255, 255, 0.05); } @@ -160,29 +358,124 @@ export class TrackList extends LitElement { this.loadTracks(); document.addEventListener('click', this.closeHandler); document.addEventListener('contextmenu', this.closeHandler); + document.addEventListener('mousemove', this.onColResizeMove); + document.addEventListener('mouseup', this.onColResizeEnd); + + this.resizeObserver = new ResizeObserver(() => { + this.onHostResize(); + }); + + this.resizeObserver.observe(this); } override disconnectedCallback() { + this.virtualizer?.removeEventListener( + 'visibilityChanged', + this.onVisibilityChanged, + ); + this.hasRestoredScroll = false; super.disconnectedCallback(); document.removeEventListener('click', this.closeHandler); document.removeEventListener('contextmenu', this.closeHandler); + document.removeEventListener('mousemove', this.onColResizeMove); + document.removeEventListener('mouseup', this.onColResizeEnd); + + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + } + + override firstUpdated() { + this.initColumnWidths(); + } + + override updated(changed: Map) { + if (changed.has('columnWidths')) { + this.virtualizer?.requestUpdate(); + } + } + + private previousHostWidth = 0; + + private onHostResize() { + const newWidth = this.clientWidth; + + if ( + newWidth <= 0 || + this.columnWidths.length === 0 || + this.resizingColumn !== null + ) { + return; + } + + if (this.previousHostWidth === 0) { + this.previousHostWidth = newWidth; + + return; + } + + const oldTotal = this.columnWidths.reduce( + (sum, w) => sum + w, + 0, + ); + + if (oldTotal <= 0) return; + + const scale = newWidth / oldTotal; + + this.columnWidths = this.columnWidths.map((w) => + Math.max( + MIN_COLUMN_WIDTH, + Math.round(w * scale), + ), + ); + + this.previousHostWidth = newWidth; + this.saveColumnWidths(); } async loadTracks() { try { - const tracks = await GetAllTracks(); + const tracks = await this.libraryCtrl.getTracks(); this.tracks = tracks; this.selectedTracks = new Set(); this.lastSelectedIndex = null; + await this.updateComplete; - if (tracks[0]) { - LogPrint(tracks[0].TrackName); + if (this.isConnected && this.virtualizer) { + this.virtualizer.addEventListener( + 'visibilityChanged', + this.onVisibilityChanged, + ); } } catch (error) { console.error('Error loading tracks:', error); } } + private onVisibilityChanged = (e: Event) => { + const { first } = e as VisibilityChangedEvent; + + if (!this.hasRestoredScroll) { + this.hasRestoredScroll = true; + + const savedIndex = + this.libraryCtrl.getScrollPosition('tracks'); + + if (savedIndex > 0) { + requestAnimationFrame(() => { + this.virtualizer?.scrollToIndex( + savedIndex, + 'start', + ); + }); + + return; + } + } + + this.libraryCtrl.setScrollPosition('tracks', first); + }; + private getSelectedFilePaths(): string[] { return this.tracks .filter((t) => this.selectedTracks.has(t.FilePath)) @@ -395,10 +688,15 @@ export class TrackList extends LitElement { .filter(Boolean) .join(' '); + const colStyle = + `grid-template-columns: ${this.gridTemplateColumns}`; + return html`
this.onTrackRowClick(e, track, index)} + style=${colStyle} + @click=${(e: MouseEvent) => + this.onTrackRowClick(e, track, index)} @dblclick=${() => this.onTrackRowDblClick(track)} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)} @@ -417,19 +715,35 @@ export class TrackList extends LitElement { ${this.tracks.length === 0 ? html`

Loading tracks...

` : html` -
- Track Name - Artist - Track Length +
+
Track Name
+
Artist
+
Track Length
`} +
+ ${this.colBoundaryPositions.map( + (pos, i) => html` +
+ this.onColResizeStart(e, i)} + >
+ `, + )} +
+ void; + + constructor(host: ReactiveControllerHost) { + this.host = host; + host.addController(this); + } + + // =================================================================== + // LIFECYCLE HOOKS + // =================================================================== + + hostConnected(): void { + this.unsubscribe = libraryStore.subscribe(() => { + this.host.requestUpdate(); + }); + } + + hostDisconnected(): void { + this.unsubscribe?.(); + } + + // =================================================================== + // DATA ACCESS + // =================================================================== + + async getTracks(): Promise { + return libraryStore.getTracks(); + } + + async getAlbums(): Promise { + return libraryStore.getAlbums(); + } + + get cachedTracks(): library.Track[] | null { + return libraryStore.getCachedTracks(); + } + + get cachedAlbums(): library.Album[] | null { + return libraryStore.getCachedAlbums(); + } + + get tracksLoading(): boolean { + return libraryStore.isTracksLoading(); + } + + get albumsLoading(): boolean { + return libraryStore.isAlbumsLoading(); + } + + // =================================================================== + // SCROLL POSITION + // =================================================================== + + getScrollPosition(view: ViewName): number { + return libraryStore.getScrollPosition(view); + } + + setScrollPosition(view: ViewName, offset: number): void { + libraryStore.setScrollPosition(view, offset); + } +} diff --git a/frontend/src/store/library-store.ts b/frontend/src/store/library-store.ts new file mode 100644 index 0000000..5c3853f --- /dev/null +++ b/frontend/src/store/library-store.ts @@ -0,0 +1,168 @@ +import { EventsOn } from '@runtime/runtime'; +import { GetAllTracks, GetAllAlbums } from '@go/library/Library'; +import type { library } from '@go/models'; +import { Events } from '../events'; + +type ViewName = 'tracks' | 'albums'; + +type Subscriber = () => void; + +class LibraryStore { + private tracks: library.Track[] | null = null; + private albums: library.Album[] | null = null; + + private tracksLoading = false; + private albumsLoading = false; + + private scrollPositions: Record = { + tracks: 0, + albums: 0, + }; + + private subscribers = new Set(); + + constructor() { + EventsOn(Events.LibraryScanComplete, () => { + this.invalidate(); + }); + } + + // =================================================================== + // DATA ACCESS + // Returns cached data or fetches from backend on first access. + // =================================================================== + + async getTracks(): Promise { + if (this.tracks !== null) { + return this.tracks; + } + + if (this.tracksLoading) { + return this.waitForTracks(); + } + + this.tracksLoading = true; + this.notify(); + + try { + const tracks = await GetAllTracks(); + this.tracks = tracks; + + return tracks; + } finally { + this.tracksLoading = false; + this.notify(); + } + } + + async getAlbums(): Promise { + if (this.albums !== null) { + return this.albums; + } + + if (this.albumsLoading) { + return this.waitForAlbums(); + } + + this.albumsLoading = true; + this.notify(); + + try { + const albums = await GetAllAlbums(); + this.albums = albums; + + return albums; + } finally { + this.albumsLoading = false; + this.notify(); + } + } + + // =================================================================== + // STATE ACCESSORS + // Synchronous access for controllers that need current cached values. + // =================================================================== + + getCachedTracks(): library.Track[] | null { + return this.tracks; + } + + getCachedAlbums(): library.Album[] | null { + return this.albums; + } + + isTracksLoading(): boolean { + return this.tracksLoading; + } + + isAlbumsLoading(): boolean { + return this.albumsLoading; + } + + // =================================================================== + // SCROLL POSITION + // =================================================================== + + getScrollPosition(view: ViewName): number { + return this.scrollPositions[view]; + } + + setScrollPosition(view: ViewName, offset: number): void { + this.scrollPositions[view] = offset; + } + + // =================================================================== + // INVALIDATION + // =================================================================== + + private invalidate(): void { + this.tracks = null; + this.albums = null; + this.scrollPositions = { tracks: 0, albums: 0 }; + this.notify(); + } + + // =================================================================== + // SUBSCRIPTION SYSTEM + // =================================================================== + + subscribe(callback: Subscriber): () => void { + this.subscribers.add(callback); + + return () => this.subscribers.delete(callback); + } + + private notify(): void { + this.subscribers.forEach((callback) => callback()); + } + + // =================================================================== + // HELPERS + // Wait for an in-flight fetch to complete. + // =================================================================== + + private waitForTracks(): Promise { + return new Promise((resolve) => { + const unsub = this.subscribe(() => { + if (!this.tracksLoading && this.tracks !== null) { + unsub(); + resolve(this.tracks); + } + }); + }); + } + + private waitForAlbums(): Promise { + return new Promise((resolve) => { + const unsub = this.subscribe(() => { + if (!this.albumsLoading && this.albums !== null) { + unsub(); + resolve(this.albums); + } + }); + }); + } +} + +// Singleton instance. +export const libraryStore = new LibraryStore();