frontend fixes
album artist gets populated with artist as fallback (frontend also uses this as display name fallback for albums). added scroll position persistence when switching main views. also added frontend cache for faster switching.
This commit is contained in:
@@ -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 `<lit-virtualizer>`. 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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -49,3 +49,8 @@ const (
|
||||
const (
|
||||
LibraryConfigChanged = "LibraryConfigChanged"
|
||||
)
|
||||
|
||||
// Library events.
|
||||
const (
|
||||
LibraryScanComplete = "LibraryScanComplete"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
<lit-virtualizer
|
||||
class=${this.hiddenForRestore ? 'restoring' : ''}
|
||||
scroller
|
||||
.items=${this.albums}
|
||||
.renderItem=${this.renderAlbumCard}
|
||||
|
||||
@@ -23,13 +23,13 @@ export class QueuePanel extends LitElement {
|
||||
@state()
|
||||
private playlistPickerOpen = false;
|
||||
|
||||
@query('#save-playlist-popup')
|
||||
private savePlaylistPopup!: HTMLElement;
|
||||
@query('#add-to-playlist-popup')
|
||||
private addToPlaylistPopup!: HTMLElement;
|
||||
|
||||
private closePickerHandler = (e: MouseEvent) => {
|
||||
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 {
|
||||
<div class="header">
|
||||
<h3>Queue</h3>
|
||||
<button
|
||||
class="save-playlist-button"
|
||||
@click=${this.handleSaveAsPlaylist}
|
||||
class="add-to-playlist-button"
|
||||
@click=${this.handleAddToPlaylist}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Save queue as playlist"
|
||||
title="Add queue to playlist"
|
||||
>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<wa-popup
|
||||
id="save-playlist-popup"
|
||||
id="add-to-playlist-popup"
|
||||
placement="bottom-end"
|
||||
.active=${this.playlistPickerOpen}
|
||||
>
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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`
|
||||
<div
|
||||
class=${classes}
|
||||
@click=${(e: MouseEvent) => 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`<p>Loading tracks...</p>`
|
||||
: html`
|
||||
<div class="header-row">
|
||||
<span>Track Name</span>
|
||||
<span>Artist</span>
|
||||
<span>Track Length</span>
|
||||
<div
|
||||
class="header-row"
|
||||
style="grid-template-columns: ${this.gridTemplateColumns}"
|
||||
>
|
||||
<div class="header-cell">Track Name</div>
|
||||
<div class="header-cell">Artist</div>
|
||||
<div class="header-cell">Track Length</div>
|
||||
</div>
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
.items=${this.tracks}
|
||||
.renderItem=${this.renderTrackRow}
|
||||
.layout=${flow()}
|
||||
.layout=${this.flowLayout}
|
||||
></lit-virtualizer>
|
||||
`}
|
||||
|
||||
<div class="resize-overlay">
|
||||
${this.colBoundaryPositions.map(
|
||||
(pos, i) => html`
|
||||
<div
|
||||
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
|
||||
style="left: ${pos}px"
|
||||
@mousedown=${(e: MouseEvent) =>
|
||||
this.onColResizeStart(e, i)}
|
||||
></div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<wa-popup
|
||||
id="context-menu"
|
||||
placement="bottom-start"
|
||||
|
||||
@@ -33,6 +33,9 @@ export const Events = {
|
||||
RequestAddTracksToQueue: "RequestAddTracksToQueue",
|
||||
RequestPlayTracksNext: "RequestPlayTracksNext",
|
||||
RequestPlayQueueIndex: "RequestPlayQueueIndex",
|
||||
|
||||
// Library events
|
||||
LibraryScanComplete: "LibraryScanComplete",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type { library } from '@go/models';
|
||||
import { libraryStore } from '../library-store';
|
||||
|
||||
type ViewName = 'tracks' | 'albums';
|
||||
|
||||
/**
|
||||
* LibraryController connects a Lit component to the LibraryStore.
|
||||
*
|
||||
* Usage in a component:
|
||||
*
|
||||
* private library = new LibraryController(this);
|
||||
*
|
||||
* async connectedCallback() {
|
||||
* super.connectedCallback();
|
||||
* this.tracks = await this.library.getTracks();
|
||||
* }
|
||||
*/
|
||||
export class LibraryController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => 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<library.Track[]> {
|
||||
return libraryStore.getTracks();
|
||||
}
|
||||
|
||||
async getAlbums(): Promise<library.Album[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ViewName, number> = {
|
||||
tracks: 0,
|
||||
albums: 0,
|
||||
};
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
constructor() {
|
||||
EventsOn(Events.LibraryScanComplete, () => {
|
||||
this.invalidate();
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// DATA ACCESS
|
||||
// Returns cached data or fetches from backend on first access.
|
||||
// ===================================================================
|
||||
|
||||
async getTracks(): Promise<library.Track[]> {
|
||||
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<library.Album[]> {
|
||||
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<library.Track[]> {
|
||||
return new Promise((resolve) => {
|
||||
const unsub = this.subscribe(() => {
|
||||
if (!this.tracksLoading && this.tracks !== null) {
|
||||
unsub();
|
||||
resolve(this.tracks);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private waitForAlbums(): Promise<library.Album[]> {
|
||||
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();
|
||||
Reference in New Issue
Block a user