pre-fetching main view data on startup, batched rendering for cover-grid
This commit is contained in:
@@ -8,9 +8,17 @@ import '@components/playlist-view/playlist-view.ts';
|
|||||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { playlistStore } from '@store/playlist-store';
|
||||||
|
|
||||||
setBasePath('/dist/webawesome');
|
setBasePath('/dist/webawesome');
|
||||||
|
|
||||||
|
// Pre-fetch data for views not yet mounted so they're cached when navigated to.
|
||||||
|
// These are fire-and-forget — the singleton stores deduplicate concurrent fetches,
|
||||||
|
// so if a component mounts before this completes, it joins the in-flight request.
|
||||||
|
libraryStore.getAlbums();
|
||||||
|
playlistStore.getPlaylists();
|
||||||
|
|
||||||
// Navigation event listener for view switching
|
// Navigation event listener for view switching
|
||||||
document.addEventListener('navigate', (e: Event) => {
|
document.addEventListener('navigate', (e: Event) => {
|
||||||
const { view } = (e as CustomEvent).detail;
|
const { view } = (e as CustomEvent).detail;
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ type GridItem =
|
|||||||
/** Milliseconds to debounce scroll-position saves. */
|
/** Milliseconds to debounce scroll-position saves. */
|
||||||
const SCROLL_DEBOUNCE_MS = 100;
|
const SCROLL_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of album cards to render in the first batch.
|
||||||
|
* Sized to fill ~3-4 rows on a wide screen with overscan.
|
||||||
|
*/
|
||||||
|
const INITIAL_BATCH_SIZE = 40;
|
||||||
|
|
||||||
|
/** Number of album cards to append in each subsequent idle batch. */
|
||||||
|
const BATCH_SIZE = 200;
|
||||||
|
|
||||||
@customElement('cover-grid')
|
@customElement('cover-grid')
|
||||||
export class CoverGrid extends LitElement {
|
export class CoverGrid extends LitElement {
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
@@ -63,12 +72,17 @@ export class CoverGrid extends LitElement {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private currentColumnCount = 0;
|
private currentColumnCount = 0;
|
||||||
|
|
||||||
|
// Incremental rendering — render albums in batches
|
||||||
|
// to avoid blocking the main thread on first paint.
|
||||||
|
private batchRAF: number | null = null;
|
||||||
|
|
||||||
// buildGridItems() memoization cache
|
// buildGridItems() memoization cache
|
||||||
private gridItemsCache: GridItem[] = [];
|
private gridItemsCache: GridItem[] = [];
|
||||||
private gridItemsCacheAlbums: library.Album[] = [];
|
private gridItemsCacheAlbums: library.Album[] = [];
|
||||||
private gridItemsCacheExpandedId: number | null =
|
private gridItemsCacheExpandedId: number | null =
|
||||||
null;
|
null;
|
||||||
private gridItemsCacheColumns = 0;
|
private gridItemsCacheColumns = 0;
|
||||||
|
private gridItemsCacheRendered = 0;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
@@ -302,6 +316,10 @@ export class CoverGrid extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private selectedTracks: Set<string> = new Set();
|
private selectedTracks: Set<string> = new Set();
|
||||||
|
|
||||||
|
/** Number of albums rendered so far (incremental batching). */
|
||||||
|
@state()
|
||||||
|
private renderedCount = 0;
|
||||||
|
|
||||||
@query('#context-menu')
|
@query('#context-menu')
|
||||||
private contextMenuPopup!: HTMLElement;
|
private contextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
@@ -362,6 +380,8 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
|
|
||||||
|
this.cancelPendingBatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
@@ -369,16 +389,33 @@ export class CoverGrid extends LitElement {
|
|||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
|
|
||||||
private async loadAlbums() {
|
private async loadAlbums() {
|
||||||
|
this.cancelPendingBatch();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const albums =
|
const albums =
|
||||||
await this.libraryCtrl.getAlbums();
|
await this.libraryCtrl.getAlbums();
|
||||||
|
|
||||||
this.albums = albums ?? [];
|
this.albums = albums ?? [];
|
||||||
this.selectedAlbums = new Set();
|
this.selectedAlbums = new Set();
|
||||||
this.lastSelectedAlbumIndex = null;
|
this.lastSelectedAlbumIndex = null;
|
||||||
|
|
||||||
|
// Render only the first batch immediately.
|
||||||
|
this.renderedCount = Math.min(
|
||||||
|
INITIAL_BATCH_SIZE,
|
||||||
|
this.albums.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start image fetches while Lit builds DOM.
|
||||||
|
this.preloadVisibleThumbnails(
|
||||||
|
this.albums,
|
||||||
|
INITIAL_BATCH_SIZE,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading albums:', error);
|
console.error('Error loading albums:', error);
|
||||||
this.albums = [];
|
this.albums = [];
|
||||||
|
this.renderedCount = 0;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -386,15 +423,45 @@ export class CoverGrid extends LitElement {
|
|||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
this.restoreScrollPosition();
|
this.restoreScrollPosition();
|
||||||
this.setupResizeObserver();
|
this.setupResizeObserver();
|
||||||
|
this.scheduleNextBatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreScrollPosition() {
|
private restoreScrollPosition() {
|
||||||
const saved =
|
const saved =
|
||||||
this.libraryCtrl.getScrollPosition('albums');
|
this.libraryCtrl.getScrollPosition('albums');
|
||||||
|
|
||||||
if (saved > 0 && this.scrollContainer) {
|
if (saved <= 0 || !this.scrollContainer) return;
|
||||||
this.scrollContainer.scrollTop = saved;
|
|
||||||
|
// Fast-forward renderedCount so the DOM
|
||||||
|
// covers the saved scroll position before
|
||||||
|
// we restore it.
|
||||||
|
const {
|
||||||
|
GRID_ITEM_HEIGHT,
|
||||||
|
GRID_GAP,
|
||||||
|
GRID_PADDING,
|
||||||
|
} = CoverGrid;
|
||||||
|
const columns = this.getColumnCount();
|
||||||
|
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
||||||
|
const rowsNeeded = Math.ceil(
|
||||||
|
(saved +
|
||||||
|
this.scrollContainer.clientHeight -
|
||||||
|
GRID_PADDING) /
|
||||||
|
rowStep,
|
||||||
|
);
|
||||||
|
const albumsNeeded = rowsNeeded * columns;
|
||||||
|
|
||||||
|
if (albumsNeeded > this.renderedCount) {
|
||||||
|
this.renderedCount = Math.min(
|
||||||
|
albumsNeeded,
|
||||||
|
this.albums.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for the expanded renderedCount to
|
||||||
|
// produce DOM before setting scrollTop.
|
||||||
|
void this.updateComplete.then(() => {
|
||||||
|
this.scrollContainer.scrollTop = saved;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private onScroll = () => {
|
private onScroll = () => {
|
||||||
@@ -412,6 +479,108 @@ export class CoverGrid extends LitElement {
|
|||||||
}, SCROLL_DEBOUNCE_MS);
|
}, SCROLL_DEBOUNCE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ====================================================================
|
||||||
|
* Incremental rendering
|
||||||
|
*
|
||||||
|
* Albums are rendered in batches to keep the first
|
||||||
|
* paint fast. After the initial batch, subsequent
|
||||||
|
* chunks are appended during idle frames so the main
|
||||||
|
* thread stays responsive.
|
||||||
|
* ==================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next batch of album cards to render.
|
||||||
|
* Uses requestIdleCallback when available, falling
|
||||||
|
* back to setTimeout(…, 16) for one-frame yield.
|
||||||
|
*/
|
||||||
|
private scheduleNextBatch() {
|
||||||
|
if (this.renderedCount >= this.albums.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
this.batchRAF = null;
|
||||||
|
|
||||||
|
this.renderedCount = Math.min(
|
||||||
|
this.renderedCount + BATCH_SIZE,
|
||||||
|
this.albums.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.scheduleNextBatch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ric = window.requestIdleCallback;
|
||||||
|
|
||||||
|
if (ric) {
|
||||||
|
this.batchRAF = ric(callback, {
|
||||||
|
timeout: 100,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.batchRAF = setTimeout(
|
||||||
|
callback,
|
||||||
|
16,
|
||||||
|
) as unknown as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel any in-flight idle batch callback. */
|
||||||
|
private cancelPendingBatch() {
|
||||||
|
if (this.batchRAF === null) return;
|
||||||
|
|
||||||
|
const cic = window.cancelIdleCallback;
|
||||||
|
|
||||||
|
if (cic) {
|
||||||
|
cic(this.batchRAF);
|
||||||
|
} else {
|
||||||
|
clearTimeout(this.batchRAF);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.batchRAF = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height (in px) of a spacer that accounts for
|
||||||
|
* album rows not yet rendered. Keeps the scrollbar
|
||||||
|
* accurate from first paint.
|
||||||
|
*/
|
||||||
|
private getSpacerHeight(): number {
|
||||||
|
const columns = this.getColumnCount();
|
||||||
|
const remaining =
|
||||||
|
this.albums.length - this.renderedCount;
|
||||||
|
|
||||||
|
if (remaining <= 0 || columns === 0) return 0;
|
||||||
|
|
||||||
|
const rows = Math.ceil(remaining / columns);
|
||||||
|
const { GRID_ITEM_HEIGHT, GRID_GAP } = CoverGrid;
|
||||||
|
|
||||||
|
return rows * (GRID_ITEM_HEIGHT + GRID_GAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off browser-level image fetches for the first
|
||||||
|
* `count` album thumbnails. Runs before Lit creates
|
||||||
|
* the actual `<img>` elements so the HTTP requests
|
||||||
|
* overlap with DOM construction.
|
||||||
|
*/
|
||||||
|
private preloadVisibleThumbnails(
|
||||||
|
albums: library.Album[],
|
||||||
|
count: number,
|
||||||
|
) {
|
||||||
|
const limit = Math.min(count, albums.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const album = albums[i]!;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
album.CoverArtThumbnailPath ||
|
||||||
|
album.CoverArtPath;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
new Image().src = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
* Resize-aware scroll preservation
|
* Resize-aware scroll preservation
|
||||||
*
|
*
|
||||||
@@ -1182,6 +1351,7 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
private buildGridItems(): GridItem[] {
|
private buildGridItems(): GridItem[] {
|
||||||
const columns = this.getColumnCount();
|
const columns = this.getColumnCount();
|
||||||
|
const rendered = this.renderedCount;
|
||||||
|
|
||||||
// Return cached result when inputs are unchanged.
|
// Return cached result when inputs are unchanged.
|
||||||
if (
|
if (
|
||||||
@@ -1189,7 +1359,8 @@ export class CoverGrid extends LitElement {
|
|||||||
this.albums &&
|
this.albums &&
|
||||||
this.gridItemsCacheExpandedId ===
|
this.gridItemsCacheExpandedId ===
|
||||||
this.expandedAlbumId &&
|
this.expandedAlbumId &&
|
||||||
this.gridItemsCacheColumns === columns
|
this.gridItemsCacheColumns === columns &&
|
||||||
|
this.gridItemsCacheRendered === rendered
|
||||||
) {
|
) {
|
||||||
return this.gridItemsCache;
|
return this.gridItemsCache;
|
||||||
}
|
}
|
||||||
@@ -1205,19 +1376,22 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
let dropdownAfterIndex = -1;
|
let dropdownAfterIndex = -1;
|
||||||
|
|
||||||
if (expandedIndex >= 0) {
|
if (
|
||||||
|
expandedIndex >= 0 &&
|
||||||
|
expandedIndex < rendered
|
||||||
|
) {
|
||||||
const row = Math.floor(
|
const row = Math.floor(
|
||||||
expandedIndex / columns,
|
expandedIndex / columns,
|
||||||
);
|
);
|
||||||
dropdownAfterIndex = Math.min(
|
dropdownAfterIndex = Math.min(
|
||||||
(row + 1) * columns - 1,
|
(row + 1) * columns - 1,
|
||||||
this.albums.length - 1,
|
rendered - 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: GridItem[] = [];
|
const items: GridItem[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < this.albums.length; i++) {
|
for (let i = 0; i < rendered; i++) {
|
||||||
const album = this.albums[i]!;
|
const album = this.albums[i]!;
|
||||||
items.push({
|
items.push({
|
||||||
kind: 'album',
|
kind: 'album',
|
||||||
@@ -1240,6 +1414,7 @@ export class CoverGrid extends LitElement {
|
|||||||
this.gridItemsCacheExpandedId =
|
this.gridItemsCacheExpandedId =
|
||||||
this.expandedAlbumId;
|
this.expandedAlbumId;
|
||||||
this.gridItemsCacheColumns = columns;
|
this.gridItemsCacheColumns = columns;
|
||||||
|
this.gridItemsCacheRendered = rendered;
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -1305,6 +1480,12 @@ export class CoverGrid extends LitElement {
|
|||||||
(item) => item.key,
|
(item) => item.key,
|
||||||
this.renderGridItem,
|
this.renderGridItem,
|
||||||
)}
|
)}
|
||||||
|
${this.renderedCount <
|
||||||
|
this.albums.length
|
||||||
|
? html`<div
|
||||||
|
style="grid-column:1/-1;height:${this.getSpacerHeight()}px"
|
||||||
|
></div>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
Executable → Regular
Reference in New Issue
Block a user