pre-fetching main view data on startup, batched rendering for cover-grid

This commit is contained in:
2026-02-17 12:47:35 -05:00
parent 50718fd633
commit 7f72df98ed
5 changed files with 228 additions and 39 deletions
+8
View File
@@ -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/components/icon/icon.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');
// 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
document.addEventListener('navigate', (e: Event) => {
const { view } = (e as CustomEvent).detail;
+220 -39
View File
@@ -34,6 +34,15 @@ type GridItem =
/** Milliseconds to debounce scroll-position saves. */
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')
export class CoverGrid extends LitElement {
private libraryCtrl = new LibraryController(this);
@@ -63,12 +72,17 @@ export class CoverGrid extends LitElement {
} | null = null;
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
private gridItemsCache: GridItem[] = [];
private gridItemsCacheAlbums: library.Album[] = [];
private gridItemsCacheExpandedId: number | null =
null;
private gridItemsCacheColumns = 0;
private gridItemsCacheRendered = 0;
static override styles = css`
:host {
@@ -302,6 +316,10 @@ export class CoverGrid extends LitElement {
@state()
private selectedTracks: Set<string> = new Set();
/** Number of albums rendered so far (incremental batching). */
@state()
private renderedCount = 0;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@@ -362,6 +380,8 @@ export class CoverGrid extends LitElement {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.cancelPendingBatch();
}
/* ====================================================================
@@ -369,16 +389,33 @@ export class CoverGrid extends LitElement {
* ==================================================================== */
private async loadAlbums() {
this.cancelPendingBatch();
try {
this.loading = true;
const albums =
await this.libraryCtrl.getAlbums();
this.albums = albums ?? [];
this.selectedAlbums = new Set();
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) {
console.error('Error loading albums:', error);
this.albums = [];
this.renderedCount = 0;
} finally {
this.loading = false;
}
@@ -386,15 +423,45 @@ export class CoverGrid extends LitElement {
await this.updateComplete;
this.restoreScrollPosition();
this.setupResizeObserver();
this.scheduleNextBatch();
}
private restoreScrollPosition() {
const saved =
this.libraryCtrl.getScrollPosition('albums');
if (saved > 0 && this.scrollContainer) {
this.scrollContainer.scrollTop = saved;
if (saved <= 0 || !this.scrollContainer) return;
// 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 = () => {
@@ -412,6 +479,108 @@ export class CoverGrid extends LitElement {
}, 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
*
@@ -691,7 +860,7 @@ export class CoverGrid extends LitElement {
1,
Math.floor(
(availableWidth + GRID_GAP) /
(GRID_ITEM_WIDTH + GRID_GAP),
(GRID_ITEM_WIDTH + GRID_GAP),
),
);
}
@@ -1140,13 +1309,13 @@ export class CoverGrid extends LitElement {
>
<div class="cover-container">
${album.CoverArtPath
? html`<img
? html`<img
class="cover-image"
src="${album.CoverArtThumbnailPath || album.CoverArtPath}"
alt="${album.Name} cover"
loading="lazy"
/>`
: html`<div
: html`<div
class="placeholder-cover"
>
${this.getAlbumInitial(album.Name)}
@@ -1164,8 +1333,8 @@ export class CoverGrid extends LitElement {
title="${album.ArtistName}"
>
${album.ArtistName}${album.Year
? ` - ${album.Year}`
: ''}
? ` - ${album.Year}`
: ''}
</div>
</div>
</div>
@@ -1182,14 +1351,16 @@ export class CoverGrid extends LitElement {
private buildGridItems(): GridItem[] {
const columns = this.getColumnCount();
const rendered = this.renderedCount;
// Return cached result when inputs are unchanged.
if (
this.gridItemsCacheAlbums ===
this.albums &&
this.albums &&
this.gridItemsCacheExpandedId ===
this.expandedAlbumId &&
this.gridItemsCacheColumns === columns
this.expandedAlbumId &&
this.gridItemsCacheColumns === columns &&
this.gridItemsCacheRendered === rendered
) {
return this.gridItemsCache;
}
@@ -1197,27 +1368,30 @@ export class CoverGrid extends LitElement {
const expandedIndex =
this.expandedAlbumId !== null
? this.albums.findIndex(
(a) =>
a.ID ===
this.expandedAlbumId,
)
(a) =>
a.ID ===
this.expandedAlbumId,
)
: -1;
let dropdownAfterIndex = -1;
if (expandedIndex >= 0) {
if (
expandedIndex >= 0 &&
expandedIndex < rendered
) {
const row = Math.floor(
expandedIndex / columns,
);
dropdownAfterIndex = Math.min(
(row + 1) * columns - 1,
this.albums.length - 1,
rendered - 1,
);
}
const items: GridItem[] = [];
for (let i = 0; i < this.albums.length; i++) {
for (let i = 0; i < rendered; i++) {
const album = this.albums[i]!;
items.push({
kind: 'album',
@@ -1240,6 +1414,7 @@ export class CoverGrid extends LitElement {
this.gridItemsCacheExpandedId =
this.expandedAlbumId;
this.gridItemsCacheColumns = columns;
this.gridItemsCacheRendered = rendered;
return items;
}
@@ -1301,10 +1476,16 @@ export class CoverGrid extends LitElement {
@track-contextmenu=${this.onTrackContextMenu}
>
${repeat(
this.buildGridItems(),
(item) => item.key,
this.renderGridItem,
)}
this.buildGridItems(),
(item) => item.key,
this.renderGridItem,
)}
${this.renderedCount <
this.albums.length
? html`<div
style="grid-column:1/-1;height:${this.getSpacerHeight()}px"
></div>`
: nothing}
</div>
</div>
@@ -1314,13 +1495,13 @@ export class CoverGrid extends LitElement {
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
this.onContextMenuAction(
'play',
)}
>
<wa-icon
slot="icon"
@@ -1330,9 +1511,9 @@ export class CoverGrid extends LitElement {
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
this.onContextMenuAction(
'add-to-queue',
)}
>
<wa-icon
slot="icon"
@@ -1342,9 +1523,9 @@ export class CoverGrid extends LitElement {
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
this.onContextMenuAction(
'play-next',
)}
>
<wa-icon
slot="icon"
@@ -1355,11 +1536,11 @@ export class CoverGrid extends LitElement {
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() =>
this.showPlaylistSubmenu()}
this.showPlaylistSubmenu()}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
@@ -1373,7 +1554,7 @@ export class CoverGrid extends LitElement {
</wa-dropdown-item>
</div>
`
: nothing}
: nothing}
</wa-popup>
<wa-popup
@@ -1382,15 +1563,15 @@ export class CoverGrid extends LitElement {
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen
? html`
? html`
<playlist-picker
.filePaths=${this.playlistFilePaths}
@playlist-action-complete=${this.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
e.stopPropagation()}
></playlist-picker>
`
: nothing}
: nothing}
</wa-popup>
`;
}
View File
Vendored Executable → Regular
View File
View File