feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)

* fix(cover): per-server cache stats and cover pipeline perf probe

Stop count_cached_cover_ids from borrowing sibling bucket counts so
Settings progress no longer attributes one server's disk cache to another.

Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP
semaphores) to Performance Probe overlay, with clearer ui/lib labels.

* fix(browse): stabilize in-page infinite scroll and cap cover memory caches

Extract useInpageScrollSentinel for album grids and song lists so sentinel
reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with
sync loading refs, tighter root margin, and hasMore termination when dedupe
adds nothing. Pause middle-priority cover work during SQL pagination and bound
diskSrc/resolve/ensure tail maps on long cold-cache sessions.

* refactor(browse): unify in-page infinite scroll hooks and sentinel UI

Extract shared transport (viewport ref, async pagination guards, client slice)
and InpageScrollSentinel so Albums, New Releases, Artists, and song lists
use one pagination pattern instead of duplicated IntersectionObserver wiring.

* fix(browse): prioritize album SQL pagination over cover ensures

Pause the entire webview ensure pump during grid page fetches, resume after
SQL settles, add cover-queue backpressure before load-more, and re-probe the
sentinel when pagination finishes so cold-cache scroll does not stall.

* fix(browse): unblock covers, SQL spawn_blocking, and pagination retry

Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump
after SQL, retry load-more when the cover backlog drains while the sentinel
stays visible, and run album browse SQL on spawn_blocking so Tokio stays
responsive during library_advanced_search.

* feat(browse): All Albums client-slice scroll on local index (Artists-style)

Load the filtered catalog once from SQLite when the library index is ready,
then grow the visible grid with useClientSliceInfiniteScroll instead of
offset SQL pagination per scroll. Network-only servers keep page mode.

* fix(browse): lazy local catalog chunks instead of full 50k SQL fetch

All Albums slice mode now loads 200 albums first, shows the grid immediately,
then appends catalog chunks in the background as the user scrolls. Avoids the
blocking library_advanced_search that hung the app on large libraries.

* fix(browse): keep album covers loading during active grid scroll

Pass high ensure priority and the in-page scroll root to AlbumCard on All
Albums, stop pausing cover traffic for background catalog chunks, and never
trim high-priority ensure jobs from the queue during scroll bursts.

* fix(cover): viewport priority tiers and unstick ensure invoke pump

All Albums uses IO-driven high/middle instead of blanket high; release
only on unmount so scroll-ahead jobs are not dropped on reprioritize.
Ensure queue shares one Rust flight per cover id, attaches duplicate
waiters without consuming invoke slots, and times out wedged calls.
Warm the first viewport slice on large grids; acquire CPU permits before
spawn_blocking in cover_cache to avoid blocking-thread deadlocks.

* fix(cover): wire in-page scroll root on New Releases and Lossless grids

AlbumCard IO uses the same viewport id as VirtualCardGrid so cover
ensure priority tracks visible in-page rows like All Albums.

* fix(browse): lazy local artist catalog in 200-row chunks

Replace runLocalBrowseAllArtists bulk fetch with paginated local-index
chunks so large libraries do not hang on open; preserve text search,
starred, letter filter, and client-slice scroll behavior.

* feat(perf): add RSS and thread CPU groups to Performance Probe

Extend performance_cpu_snapshot with process RSS (psysonic + WebKit
children) and in-process thread CPU breakdown. Classify tokio-rt-worker
and tokio-* workers separately from glib, audio/pipewire, reqwest, and
other misc threads (Linux /proc only).

* feat(perf): redesign Performance Probe with tabs, pins, and overlay layout

Split the probe into Monitor (live metric cards, per-metric overlay pins,
corner and opacity controls) and Toggles (diagnostic tree). Share live
polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD.

* feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes

Add 1-minute pinned-metric sparklines with right-aligned growth and a shared
poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite
loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar
rescale flicker.

* docs: CHANGELOG and credits for PR #890

* perf(probe): scoped CPU poll, adjustable interval, lazy thread groups

Read only psysonic + WebKit children instead of the full process table;
macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s
poll slider (default 2s). Collect /proc thread groups only when the
Monitor section is open or a thread metric is pinned.

* feat(perf): three-way overlay mode switch (off / FPS / pinned)

Add Monitor control for overlay visibility: hidden, FPS-only, or pinned
metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
This commit is contained in:
cucadmuh
2026-05-29 04:40:31 +03:00
committed by GitHub
parent 839c438a6d
commit 9925771a86
67 changed files with 5298 additions and 1043 deletions
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicAlbum } from '../api/subsonicTypes';
// Keep pagination termination rules aligned with the hook implementation.
function resolveHasMoreAfterPage(
page: { albums: SubsonicAlbum[]; hasMore: boolean },
append: boolean,
prevCount: number,
mergedCount: number,
): boolean {
if (page.albums.length === 0) return false;
if (append && mergedCount === prevCount) return false;
return page.hasMore;
}
describe('resolveHasMoreAfterPage', () => {
it('stops when the server returns an empty page', () => {
expect(resolveHasMoreAfterPage({ albums: [], hasMore: true }, true, 30, 30)).toBe(false);
});
it('stops when dedupe adds no new albums', () => {
expect(resolveHasMoreAfterPage({ albums: [{ id: 'a1' } as SubsonicAlbum], hasMore: true }, true, 1, 1)).toBe(false);
});
it('continues while the page grows and the server reports more', () => {
expect(resolveHasMoreAfterPage({ albums: [{ id: 'a2' } as SubsonicAlbum], hasMore: true }, true, 1, 2)).toBe(true);
});
});
+282 -22
View File
@@ -1,5 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import {
coverTrafficBeginGridPagination,
coverTrafficEndGridPagination,
coverTrafficGridPaginationDepth,
} from '../cover/coverTraffic';
import { coverEnsureQueueBacklog, coverEnsureResumePump, coverEnsureSubscribeBacklogDrain } from '../cover/ensureQueue';
import { dedupeById } from '../utils/dedupeById';
import { albumBrowseCompScanComplete } from '../utils/library/albumCompilation';
import type { AlbumCompFilter } from '../utils/library/albumCompilation';
@@ -8,6 +20,7 @@ import {
albumBrowseHasServerFilters,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
fetchLocalAlbumCatalogChunk,
filterAlbumsByCompilation,
filterAlbumsByStarred,
type AlbumBrowseQuery,
@@ -17,9 +30,18 @@ import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
} from '../utils/library/albumYearFilter';
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
import { useDebouncedValue } from './useDebouncedValue';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
const PAGE_SIZE = 30;
const CLIENT_SLICE_PAGE_SIZE = 60;
/** Local-index catalog buffer grows by this many albums per background SQL chunk. */
const CATALOG_CHUNK_SIZE = 200;
/** Wait for visible-row cover ensures to drain before fetching the next SQL page (network mode). */
const LOAD_MORE_COVER_BACKLOG_MAX = 12;
type AlbumBrowseMode = 'slice' | 'page';
export type UseAlbumBrowseDataArgs = {
serverId: string;
@@ -33,8 +55,23 @@ export type UseAlbumBrowseDataArgs = {
starredOnly: boolean;
compFilter: AlbumCompFilter;
starredOverrides: Record<string, boolean>;
/** IntersectionObserver scroll root (Albums in-page viewport). */
getScrollRoot?: () => HTMLElement | null;
/** Bumps when the scroll root mounts so the sentinel observer can rebind. */
scrollRootEl?: HTMLElement | null;
};
function resolveHasMoreAfterPage(
page: { albums: SubsonicAlbum[]; hasMore: boolean },
append: boolean,
prevCount: number,
mergedCount: number,
): boolean {
if (page.albums.length === 0) return false;
if (append && mergedCount === prevCount) return false;
return page.hasMore;
}
export function useAlbumBrowseData({
serverId,
indexEnabled,
@@ -47,11 +84,17 @@ export function useAlbumBrowseData({
starredOnly,
compFilter,
starredOverrides,
getScrollRoot,
scrollRootEl,
}: UseAlbumBrowseDataArgs) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [browseMode, setBrowseMode] = useState<AlbumBrowseMode>('page');
const [genreCatalogOptions, setGenreCatalogOptions] = useState<GenreFilterOption[] | null>(null);
const yearFields = useMemo(() => ({ from: yearFrom, to: yearTo }), [yearFrom, yearTo]);
@@ -91,6 +134,33 @@ export function useAlbumBrowseData({
return out;
}, [albums, compFilter, compFilterActive, starredOnly, starredOverrides]);
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: CLIENT_SLICE_PAGE_SIZE,
resetDeps: [
browseMode,
sort,
selectedGenres,
yearFilterActive,
yearFilterBounds,
losslessOnly,
starredOnly,
compFilter,
musicLibraryFilterVersion,
serverId,
],
getScrollRoot,
scrollRootEl,
});
const displayAlbums = useMemo(() => {
if (browseMode !== 'slice') return visibleAlbums;
return visibleAlbums.slice(0, visibleCount);
}, [browseMode, visibleAlbums, visibleCount]);
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
@@ -104,7 +174,76 @@ export function useAlbumBrowseData({
const pendingClientFilterMatch =
compFilterClientOnly && visibleAlbums.length === 0 && hasMore && !genreFiltered && !compScanExhausted;
const gridHasMore = browseMode === 'slice'
? visibleCount < visibleAlbums.length || catalogHasMore
: hasMore && !genreFiltered;
const gridLoadingMore = browseMode === 'slice'
? sliceLoadingMore || catalogLoadingMore
: loadingMore;
const loadGenerationRef = useRef(0);
const pageRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
const loadMoreRef = useRef<() => void>(() => {});
const sentinelIntersectingRef = useRef(false);
const browseModeRef = useRef(browseMode);
browseModeRef.current = browseMode;
useEffect(() => {
while (coverTrafficGridPaginationDepth() > 0) {
coverTrafficEndGridPagination();
}
coverEnsureResumePump();
}, []);
useEffect(() => {
return coverEnsureSubscribeBacklogDrain(() => {
if (browseModeRef.current !== 'page') return;
if (!sentinelIntersectingRef.current) return;
if (loadingRef.current || loadPendingRef.current) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
loadMoreRef.current();
});
}, []);
useEffect(() => {
pageRef.current = page;
}, [page]);
const loadCatalogChunk = useCallback(async (
query: AlbumBrowseQuery,
offset: number,
append: boolean,
) => {
if (catalogLoadingRef.current) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchLocalAlbumCatalogChunk(serverId, query, offset, CATALOG_CHUNK_SIZE);
if (generation !== loadGenerationRef.current || chunk == null) return;
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...chunk.albums]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setAlbums(chunk.albums);
catalogOffsetRef.current = chunk.albums.length;
}
setCatalogHasMore(chunk.hasMore);
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [serverId]);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
@@ -112,15 +251,26 @@ export function useAlbumBrowseData({
append = false,
) => {
const generation = ++loadGenerationRef.current;
setLoading(true);
const applyPage = (page: { albums: SubsonicAlbum[]; hasMore: boolean }) => {
loadingRef.current = true;
loadPendingRef.current = true;
coverTrafficBeginGridPagination();
if (append) setLoadingMore(true);
else setLoading(true);
const applyPage = (pageResult: { albums: SubsonicAlbum[]; hasMore: boolean }) => {
if (generation !== loadGenerationRef.current) return;
if (append) setAlbums(prev => dedupeById([...prev, ...page.albums]));
else setAlbums(page.albums);
setHasMore(page.hasMore);
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...pageResult.albums]);
setHasMore(resolveHasMoreAfterPage(pageResult, true, prev.length, merged.length));
return merged;
});
} else {
setAlbums(pageResult.albums);
setHasMore(resolveHasMoreAfterPage(pageResult, false, 0, pageResult.albums.length));
}
};
try {
const page = await fetchAlbumBrowsePage(
const pageResult = await fetchAlbumBrowsePage(
serverId,
indexEnabled,
query,
@@ -130,20 +280,72 @@ export function useAlbumBrowseData({
onPartial: partial => {
if (generation !== loadGenerationRef.current) return;
applyPage(partial);
setLoading(false);
loadingRef.current = false;
if (append) setLoadingMore(false);
else setLoading(false);
},
},
);
applyPage(page);
applyPage(pageResult);
} finally {
if (generation === loadGenerationRef.current) setLoading(false);
coverTrafficEndGridPagination();
coverEnsureResumePump();
if (generation === loadGenerationRef.current) {
loadingRef.current = false;
loadPendingRef.current = false;
if (append) setLoadingMore(false);
else setLoading(false);
}
}
}, [indexEnabled, serverId]);
useEffect(() => {
let cancelled = false;
pageRef.current = 0;
catalogOffsetRef.current = 0;
loadPendingRef.current = false;
catalogLoadingRef.current = false;
setPage(0);
loadBrowse(browseQuery, 0, false);
}, [browseQuery, loadBrowse, musicLibraryFilterVersion]);
setAlbums([]);
setHasMore(true);
setCatalogHasMore(false);
setCatalogLoadingMore(false);
setLoading(true);
void (async () => {
if (indexEnabled && serverId) {
const generation = ++loadGenerationRef.current;
coverTrafficBeginGridPagination();
try {
const first = await fetchLocalAlbumCatalogChunk(
serverId,
browseQuery,
0,
CATALOG_CHUNK_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
setBrowseMode('slice');
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
setCatalogHasMore(first.hasMore);
setLoading(false);
return;
}
} finally {
coverTrafficEndGridPagination();
coverEnsureResumePump();
}
}
if (cancelled) return;
setBrowseMode('page');
await loadBrowse(browseQuery, 0, false);
})();
return () => {
cancelled = true;
};
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
useEffect(() => {
if (!narrowGenreList) {
@@ -165,19 +367,19 @@ export function useAlbumBrowseData({
musicLibraryFilterVersion,
]);
const loadMore = useCallback(() => {
if (loading || !hasMore || genreFiltered) return;
const loadMorePage = useCallback(() => {
if (loadingRef.current || loadPendingRef.current || !hasMore || genreFiltered) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
if (compFilterClientOnly && visibleAlbums.length === 0
&& albumBrowseCompScanComplete(albums, compFilter, hasMore)) {
return;
}
const next = page + 1;
const next = pageRef.current + 1;
pageRef.current = next;
setPage(next);
loadBrowse(browseQuery, next * PAGE_SIZE, true);
void loadBrowse(browseQuery, next * PAGE_SIZE, true);
}, [
loading,
hasMore,
page,
browseQuery,
loadBrowse,
genreFiltered,
@@ -187,15 +389,72 @@ export function useAlbumBrowseData({
compFilter,
]);
const loadMoreGrid = useCallback(() => {
if (visibleCount < visibleAlbums.length) {
sliceLoadMore();
return;
}
if (catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(browseQuery, catalogOffsetRef.current, true);
}
}, [
visibleCount,
visibleAlbums.length,
catalogHasMore,
sliceLoadMore,
loadCatalogChunk,
browseQuery,
]);
const loadMore = useCallback(() => {
if (browseMode === 'slice') {
loadMoreGrid();
return;
}
loadMorePage();
}, [browseMode, loadMoreGrid, loadMorePage]);
loadMoreRef.current = loadMore;
useEffect(() => {
if (!pendingClientFilterMatch || loading) return;
loadMore();
}, [pendingClientFilterMatch, loading, loadMore]);
if (browseMode !== 'page') return;
if (!pendingClientFilterMatch || loadingRef.current || loadPendingRef.current) return;
loadMorePage();
}, [browseMode, pendingClientFilterMatch, loading, loadMorePage]);
useEffect(() => {
if (browseMode !== 'slice') return;
if (!sentinelIntersectingRef.current) return;
if (visibleCount >= visibleAlbums.length - CLIENT_SLICE_PAGE_SIZE
&& catalogHasMore
&& !catalogLoadingRef.current) {
void loadCatalogChunk(browseQuery, catalogOffsetRef.current, true);
}
}, [
browseMode,
visibleCount,
visibleAlbums.length,
catalogHasMore,
loadCatalogChunk,
browseQuery,
]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: gridHasMore,
getScrollRoot,
scrollRootEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: gridLoadingMore,
intersectingRef: sentinelIntersectingRef,
});
return {
albums,
loading,
hasMore,
loadingMore: gridLoadingMore,
hasMore: gridHasMore,
displayAlbums,
browseMode,
PAGE_SIZE,
browseQuery,
browseQueryWithoutGenre,
@@ -211,5 +470,6 @@ export function useAlbumBrowseData({
compScanExhausted,
pendingClientFilterMatch,
loadMore,
bindLoadMoreSentinel,
};
}
+130
View File
@@ -0,0 +1,130 @@
import { getArtists } from '../api/subsonicArtists';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { dedupeById } from '../utils/dedupeById';
import {
fetchLocalArtistCatalogChunk,
fetchNetworkStarredArtists,
} from '../utils/library/browseTextSearch';
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
export type ArtistsBrowseMode = 'slice' | 'network';
export type UseArtistsBrowseCatalogArgs = {
serverId: string | null | undefined;
indexEnabled: boolean;
starredOnly: boolean;
musicLibraryFilterVersion: number;
};
export function useArtistsBrowseCatalog({
serverId,
indexEnabled,
starredOnly,
musicLibraryFilterVersion,
}: UseArtistsBrowseCatalogArgs) {
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [browseMode, setBrowseMode] = useState<ArtistsBrowseMode>('network');
const loadGenerationRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadCatalogChunk = useCallback(async (append: boolean) => {
if (!serverId || catalogLoadingRef.current) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchLocalArtistCatalogChunk(
serverId,
catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (generation !== loadGenerationRef.current || chunk == null) return;
if (append) {
setCatalogArtists(prev => {
const merged = dedupeById([...prev, ...chunk.artists]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setCatalogArtists(chunk.artists);
catalogOffsetRef.current = chunk.artists.length;
}
setCatalogHasMore(chunk.hasMore);
setBrowseMode('slice');
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [serverId]);
useEffect(() => {
let cancelled = false;
const generation = ++loadGenerationRef.current;
catalogOffsetRef.current = 0;
catalogLoadingRef.current = false;
setCatalogArtists([]);
setCatalogHasMore(false);
setCatalogLoadingMore(false);
setBrowseMode('network');
setLoading(true);
void (async () => {
try {
if (starredOnly) {
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await fetchNetworkStarredArtists());
}
return;
}
if (indexEnabled && serverId) {
const first = await fetchLocalArtistCatalogChunk(
serverId,
0,
ARTIST_CATALOG_CHUNK_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
setBrowseMode('slice');
setCatalogArtists(first.artists);
catalogOffsetRef.current = first.artists.length;
setCatalogHasMore(first.hasMore);
return;
}
}
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await getArtists());
}
} catch {
/* ignore */
} finally {
if (!cancelled && generation === loadGenerationRef.current) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
return {
catalogArtists,
loading,
catalogHasMore,
catalogLoadingMore,
browseMode,
loadCatalogChunk,
catalogLoadingRef,
};
}
+7 -89
View File
@@ -1,91 +1,9 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface UseArtistsInfiniteScrollArgs {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
/** IntersectionObserver root (e.g. Artists in-page overlay viewport). */
getScrollRoot?: () => HTMLElement | null;
}
interface UseArtistsInfiniteScrollResult {
visibleCount: number;
loadingMore: boolean;
/** Callback ref — attaches IntersectionObserver when the sentinel mounts (fixes first paint behind `loading`). */
observerTarget: React.RefCallback<HTMLDivElement | null>;
loadMore: () => void;
}
/**
* Page through the artists list with a sentinel-driven
* IntersectionObserver. `pageSize` is dynamic because artist-images
* mode wants smaller batches to keep disk I/O sane on big libraries
* (5000+ artists).
*
* `resetDeps` is the list of values that should snap `visibleCount`
* back to one page — filter text, letter pick, starred-only,
* view-mode, page-size itself.
*
* The observer doesn't take a `hasMore` flag — the page only renders
* the sentinel `<div ref={observerTarget}>` while there is more data,
* so the observer naturally disconnects when the last page is reached
* (callback ref runs with `node === null` as the sentinel unmounts).
* @deprecated Import {@link useClientSliceInfiniteScroll} instead.
* Kept as a thin alias for existing call sites.
*/
export function useArtistsInfiniteScroll({
pageSize,
resetDeps,
getScrollRoot,
}: UseArtistsInfiniteScrollArgs): UseArtistsInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [loadingMore, setLoadingMore] = useState(false);
const loadMoreRef = useRef<() => void>(() => {});
const observerInst = useRef<IntersectionObserver | null>(null);
/** Blocks overlapping sentinel callbacks until `visibleCount` commits. */
const loadPendingRef = useRef(false);
const loadMore = useCallback(() => {
if (loadPendingRef.current) return;
loadPendingRef.current = true;
setLoadingMore(true);
setVisibleCount(prev => prev + pageSize);
}, [pageSize]);
loadMoreRef.current = loadMore;
useEffect(() => {
loadPendingRef.current = false;
setLoadingMore(false);
}, [visibleCount]);
useEffect(() => {
setVisibleCount(pageSize);
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
const observerTarget = useCallback((node: HTMLDivElement | null) => {
observerInst.current?.disconnect();
observerInst.current = null;
if (!node) return;
const rootEl = getScrollRoot?.() ?? document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const observer = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) loadMoreRef.current();
},
{
root: rootEl instanceof HTMLElement ? rootEl : null,
rootMargin: '200px',
},
);
observer.observe(node);
observerInst.current = observer;
}, [getScrollRoot]);
useEffect(() => () => {
observerInst.current?.disconnect();
observerInst.current = null;
}, []);
return { visibleCount, loadingMore, observerTarget, loadMore };
}
export {
useClientSliceInfiniteScroll as useArtistsInfiniteScroll,
type UseClientSliceInfiniteScrollArgs as UseArtistsInfiniteScrollArgs,
type UseClientSliceInfiniteScrollResult as UseArtistsInfiniteScrollResult,
} from './useClientSliceInfiniteScroll';
@@ -0,0 +1,47 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useAsyncInpagePagination } from './useAsyncInpagePagination';
describe('useAsyncInpagePagination', () => {
it('blocks concurrent page requests', async () => {
let resolveLoad: (() => void) | undefined;
const onPage = vi.fn(
() =>
new Promise<void>(resolve => {
resolveLoad = resolve;
}),
);
const { result } = renderHook(() => useAsyncInpagePagination(30));
act(() => {
expect(result.current.requestNextPage(onPage)).toBe(true);
expect(result.current.requestNextPage(onPage)).toBe(false);
});
expect(onPage).toHaveBeenCalledTimes(1);
expect(onPage).toHaveBeenCalledWith(30);
await act(async () => {
resolveLoad?.();
});
expect(result.current.loading).toBe(false);
expect(result.current.page).toBe(1);
});
it('resetPage clears guards and page index', () => {
const { result } = renderHook(() => useAsyncInpagePagination(30));
act(() => {
result.current.pageRef.current = 3;
result.current.loadPendingRef.current = true;
result.current.setPage(3);
result.current.resetPage();
});
expect(result.current.page).toBe(0);
expect(result.current.pageRef.current).toBe(0);
expect(result.current.loadPendingRef.current).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react';
type UseAsyncInpagePaginationOptions = {
/** Initial `loading` state (e.g. true when the first fetch runs on mount). */
initialLoading?: boolean;
};
/** Sync guards for offset-based server pagination inside in-page scroll areas. */
export function useAsyncInpagePagination(
pageSize: number,
options?: UseAsyncInpagePaginationOptions,
) {
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(options?.initialLoading ?? false);
const pageRef = useRef(0);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
useEffect(() => {
pageRef.current = page;
}, [page]);
const isBlocked = useCallback(
() => loadingRef.current || loadPendingRef.current,
[],
);
const resetPage = useCallback(() => {
pageRef.current = 0;
loadPendingRef.current = false;
setPage(0);
}, []);
const runLoad = useCallback(async (fn: () => Promise<void>) => {
loadingRef.current = true;
loadPendingRef.current = true;
setLoading(true);
try {
await fn();
} finally {
loadingRef.current = false;
loadPendingRef.current = false;
setLoading(false);
}
}, []);
const requestNextPage = useCallback(
(onPage: (offset: number) => void | Promise<void>) => {
if (isBlocked()) return false;
loadPendingRef.current = true;
const next = pageRef.current + 1;
pageRef.current = next;
setPage(next);
void onPage(next * pageSize);
return true;
},
[isBlocked, pageSize],
);
return {
page,
setPage,
loading,
setLoading,
pageRef,
loadingRef,
loadPendingRef,
isBlocked,
resetPage,
runLoad,
requestNextPage,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useAsyncInpagePagination } from './useAsyncInpagePagination';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
export type UseAsyncInpageScrollArgs = {
pageSize: number;
active: boolean;
hasMore: boolean;
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
onLoadOffset: (offset: number, append: boolean) => void | Promise<void>;
};
/**
* Offset-based server pagination with stable in-page sentinel wiring.
*/
export function useAsyncInpageScroll({
pageSize,
active,
hasMore,
getScrollRoot,
scrollRootEl,
rootMargin,
onLoadOffset,
}: UseAsyncInpageScrollArgs) {
const paging = useAsyncInpagePagination(pageSize);
const loadMore = useCallback(() => {
if (!active || !hasMore || paging.isBlocked()) return;
paging.requestNextPage(offset => onLoadOffset(offset, true));
}, [active, hasMore, onLoadOffset, paging]);
const bindSentinel = useInpageScrollSentinel({
active: active && hasMore,
getScrollRoot,
scrollRootEl,
rootMargin,
onIntersect: loadMore,
});
return {
...paging,
bindSentinel,
loadMore,
};
}
@@ -0,0 +1,31 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
vi.mock('./useInpageScrollSentinel', () => ({
useInpageScrollSentinel: () => vi.fn(),
}));
describe('useClientSliceInfiniteScroll', () => {
it('grows visibleCount by pageSize and clears loadingMore', () => {
const { result, rerender } = renderHook(
({ filter }: { filter: string }) =>
useClientSliceInfiniteScroll({
pageSize: 50,
resetDeps: [filter],
}),
{ initialProps: { filter: '' } },
);
expect(result.current.visibleCount).toBe(50);
act(() => {
result.current.loadMore();
});
expect(result.current.visibleCount).toBe(100);
rerender({ filter: 'a' });
expect(result.current.visibleCount).toBe(50);
expect(result.current.loadingMore).toBe(false);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
export type UseClientSliceInfiniteScrollArgs = {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
};
export type UseClientSliceInfiniteScrollResult = {
visibleCount: number;
loadingMore: boolean;
bindSentinel: ReturnType<typeof useInpageScrollSentinel>;
/** @deprecated Use `bindSentinel`. */
observerTarget: ReturnType<typeof useInpageScrollSentinel>;
loadMore: () => void;
};
/**
* Client-side infinite scroll: grow a visible slice from an in-memory list.
* Used by Artists and Composers browse grids.
*/
export function useClientSliceInfiniteScroll({
pageSize,
resetDeps,
getScrollRoot,
scrollRootEl,
rootMargin = '200px',
}: UseClientSliceInfiniteScrollArgs): UseClientSliceInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [loadingMore, setLoadingMore] = useState(false);
const loadPendingRef = useRef(false);
const loadMore = useCallback(() => {
if (loadPendingRef.current) return;
loadPendingRef.current = true;
setLoadingMore(true);
setVisibleCount(prev => prev + pageSize);
}, [pageSize]);
useEffect(() => {
loadPendingRef.current = false;
setLoadingMore(false);
}, [visibleCount]);
useEffect(() => {
setVisibleCount(pageSize);
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
const bindSentinel = useInpageScrollSentinel({
active: true,
getScrollRoot: () =>
getScrollRoot?.() ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
scrollRootEl,
rootMargin,
onIntersect: loadMore,
});
return {
visibleCount,
loadingMore,
bindSentinel,
observerTarget: bindSentinel,
loadMore,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
describe('useInpageScrollSentinel', () => {
it('returns a callback ref function', () => {
const onIntersect = vi.fn();
const { result } = renderHook(() =>
useInpageScrollSentinel({
active: true,
onIntersect,
}),
);
expect(typeof result.current).toBe('function');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, type MutableRefObject, type RefCallback } from 'react';
const DEFAULT_ROOT_MARGIN = '400px';
export type UseInpageScrollSentinelArgs = {
/** When false, disconnect and ignore the sentinel. */
active: boolean;
getScrollRoot?: () => HTMLElement | null;
/** Rebind when the in-page scroll viewport mounts (callback-ref body). */
scrollRootEl?: HTMLElement | null;
onIntersect: () => void;
rootMargin?: string;
/** Re-fire `onIntersect` when this changes and the sentinel is still visible. */
drainSignal?: unknown;
/** Updated when the sentinel enters/leaves the scroll root viewport. */
intersectingRef?: MutableRefObject<boolean>;
};
/**
* Stable IntersectionObserver callback ref for in-page infinite scroll.
* Matches {@link useClientSliceInfiniteScroll} — avoids reconnect storms when
* `onIntersect` / `loadMore` identities change every render.
*/
export function useInpageScrollSentinel({
active,
getScrollRoot,
scrollRootEl,
onIntersect,
rootMargin = DEFAULT_ROOT_MARGIN,
drainSignal,
intersectingRef,
}: UseInpageScrollSentinelArgs): RefCallback<HTMLDivElement | null> {
const onIntersectRef = useRef(onIntersect);
onIntersectRef.current = onIntersect;
const setIntersecting = useCallback((hit: boolean) => {
if (intersectingRef) intersectingRef.current = hit;
}, [intersectingRef]);
const observerInst = useRef<IntersectionObserver | null>(null);
const bindSentinel = useCallback((node: HTMLDivElement | null) => {
observerInst.current?.disconnect();
observerInst.current = null;
if (!node) {
setIntersecting(false);
return;
}
if (!active) {
setIntersecting(false);
return;
}
const rootEl = getScrollRoot?.() ?? null;
const observer = new IntersectionObserver(
entries => {
const hit = Boolean(entries[0]?.isIntersecting);
setIntersecting(hit);
if (hit) onIntersectRef.current();
},
{
root: rootEl instanceof HTMLElement ? rootEl : null,
rootMargin,
},
);
observer.observe(node);
observerInst.current = observer;
}, [active, getScrollRoot, scrollRootEl, rootMargin, setIntersecting]);
useEffect(() => {
const observer = observerInst.current;
if (!observer || !active) return;
for (const entry of observer.takeRecords()) {
const hit = entry.isIntersecting;
setIntersecting(hit);
if (hit) onIntersectRef.current();
}
}, [active, drainSignal, setIntersecting]);
useEffect(() => () => {
observerInst.current?.disconnect();
observerInst.current = null;
}, []);
return bindSentinel;
}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback, useRef, useState } from 'react';
/** In-page overlay scroll viewport ref + state for IntersectionObserver roots. */
export function useInpageScrollViewport() {
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const getScrollRoot = useCallback(() => scrollBodyRef.current, []);
return { scrollBodyRef, scrollBodyEl, bindScrollBody, getScrollRoot };
}
+57 -130
View File
@@ -1,50 +1,50 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { acquirePerfLivePoll, patchPerfLiveAnalysis } from '../utils/perf/perfLiveStore';
import { setPerfProbeTelemetryActive } from '../utils/perf/perfTelemetry';
import {
getAnalysisTracksPerMinute,
useAnalysisPerfLast,
} from '../utils/perf/analysisPerfStore';
import { useAnalysisPerfLast } from '../utils/perf/analysisPerfStore';
import { useAnalysisPerfListener } from './useAnalysisPerfListener';
interface PerfCpu {
app: number;
webkit: number;
supported: boolean;
}
interface PerfDiagRates {
progress: number;
waveform: number;
home: number;
}
interface AnalysisPerfDiag {
tracksPerMinute: number;
lastTotalMs: number | null;
lastFetchMs: number | null;
lastSeedMs: number | null;
lastBpmMs: number | null;
}
import {
getPerfProbeFlags,
subscribePerfProbeFlags,
} from '../utils/perf/perfFlags';
import { hasAnyLiveMetricPollNeed, usePerfLiveOverlayPins } from '../utils/perf/perfOverlayPins';
import { syncPerfLiveThreadGroupsNeed } from '../utils/perf/perfLivePollSettings';
import { useSyncExternalStore } from 'react';
interface Result {
perfProbeOpen: boolean;
setPerfProbeOpen: (open: boolean) => void;
perfCpu: PerfCpu | null;
perfDiagRates: PerfDiagRates | null;
analysisPerf: AnalysisPerfDiag | null;
}
/** Wires up Ctrl+Shift+D to open the perf probe; polls CPU + diag-rate counters
* every 2s while it is open. */
function useNeedAnalysisTelemetry(perfProbeOpen: boolean, livePins: ReadonlySet<string>): boolean {
return useSyncExternalStore(
subscribePerfProbeFlags,
() => {
const flags = getPerfProbeFlags();
return (
perfProbeOpen
|| flags.showAnalysisPerfOverlay
|| livePins.has('analysis:tpm')
|| livePins.has('analysis:last')
);
},
() => perfProbeOpen,
);
}
/** Wires Ctrl+Shift+D probe modal and shared live metric polling. */
export function useSidebarPerfProbe(): Result {
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const [perfCpu, setPerfCpu] = useState<PerfCpu | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<PerfDiagRates | null>(null);
const [analysisTpm, setAnalysisTpm] = useState(0);
const livePins = usePerfLiveOverlayPins();
const analysisLast = useAnalysisPerfLast();
const needAnalysis = useNeedAnalysisTelemetry(perfProbeOpen, livePins);
useAnalysisPerfListener(perfProbeOpen);
useAnalysisPerfListener(needAnalysis);
useEffect(() => {
if (perfProbeOpen) return;
syncPerfLiveThreadGroupsNeed(false, livePins);
}, [perfProbeOpen, livePins]);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
@@ -61,89 +61,27 @@ export function useSidebarPerfProbe(): Result {
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
type Snapshot = {
supported: boolean;
total_jiffies: number;
app_jiffies: number;
webkit_jiffies: number;
logical_cpus: number;
};
let cancelled = false;
let prev: Snapshot | null = null;
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
let prevCountersAt = 0;
let timer: number | null = null;
const poll = async () => {
try {
const snap = await invoke<Snapshot>('performance_cpu_snapshot');
if (cancelled) return;
if (!snap.supported) {
setPerfCpu({ app: 0, webkit: 0, supported: false });
return;
}
if (prev) {
const totalDelta = snap.total_jiffies - prev.total_jiffies;
const appDelta = snap.app_jiffies - prev.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prev.webkit_jiffies;
if (totalDelta > 0) {
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const appPct = Math.max(0, Math.min(1000, (appDelta / totalDelta) * cpuScale));
const webkitPct = Math.max(0, Math.min(1000, (webkitDelta / totalDelta) * cpuScale));
setPerfCpu({
app: Number.isFinite(appPct) ? appPct : 0,
webkit: Number.isFinite(webkitPct) ? webkitPct : 0,
supported: true,
});
}
}
const now = Date.now();
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
const nextCounters = {
progress: counters.audioProgressEvents ?? 0,
waveform: counters.waveformDraws ?? 0,
home: counters.homeCommits ?? 0,
};
if (prevCounters && prevCountersAt > 0) {
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
setPerfDiagRates({
progress: (nextCounters.progress - prevCounters.progress) / dt,
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
home: (nextCounters.home - prevCounters.home) / dt,
});
}
prevCounters = nextCounters;
prevCountersAt = now;
prev = snap;
} catch {
if (!cancelled) setPerfCpu({ app: 0, webkit: 0, supported: false });
} finally {
if (!cancelled) timer = window.setTimeout(poll, 2000);
}
};
void poll();
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [perfProbeOpen]);
const releases: Array<() => void> = [];
if (perfProbeOpen) releases.push(acquirePerfLivePoll('modal'));
if (hasAnyLiveMetricPollNeed()) releases.push(acquirePerfLivePoll('overlay-pins'));
if (releases.length === 0) return;
return () => releases.forEach(release => release());
}, [perfProbeOpen, livePins.size]);
useEffect(() => {
if (!perfProbeOpen) return;
const refresh = () => setAnalysisTpm(getAnalysisTracksPerMinute());
refresh();
const id = window.setInterval(refresh, 2000);
return () => window.clearInterval(id);
}, [perfProbeOpen, analysisLast?.at]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
setAnalysisTpm(0);
}
}, [perfProbeOpen]);
patchPerfLiveAnalysis({
lastTotalMs: analysisLast?.totalMs ?? null,
lastFetchMs: analysisLast?.fetchMs ?? null,
lastSeedMs: analysisLast?.seedMs ?? null,
lastBpmMs: analysisLast?.bpmMs ?? null,
});
}, [
analysisLast?.at,
analysisLast?.totalMs,
analysisLast?.fetchMs,
analysisLast?.seedMs,
analysisLast?.bpmMs,
]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -151,10 +89,10 @@ export function useSidebarPerfProbe(): Result {
if (e.key.toLowerCase() !== 'd') return;
const target = e.target as HTMLElement | null;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.tagName === 'SELECT'
|| target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
@@ -166,16 +104,5 @@ export function useSidebarPerfProbe(): Result {
return {
perfProbeOpen,
setPerfProbeOpen,
perfCpu,
perfDiagRates,
analysisPerf: perfProbeOpen
? {
tracksPerMinute: analysisTpm,
lastTotalMs: analysisLast?.totalMs ?? null,
lastFetchMs: analysisLast?.fetchMs ?? null,
lastSeedMs: analysisLast?.seedMs ?? null,
lastBpmMs: analysisLast?.bpmMs ?? null,
}
: null,
};
}
+4 -8
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo } from 'react';
import { GRID_COVER_PRIME_ALL_MAX } from '../cover/layoutSizes';
import {
collectAlbumCoverWarmItems,
ensureAlbumCoverMisses,
@@ -10,7 +9,7 @@ import type { CoverSurfaceKind } from '../cover/types';
const DEFAULT_LIMIT = 120;
/**
* Peek after mount (non-blocking); for small grids (≤48) queue ensures only for disk misses.
* Peek after mount (non-blocking); ensure disk misses for the warmed viewport slice.
*/
export function useWarmGridCovers(
items: ReadonlyArray<{ coverArt?: string | null }>,
@@ -35,8 +34,6 @@ export function useWarmGridCovers(
return `${displayCssPx}:${slice.map(a => a.coverArt ?? '').join('\u0001')}`;
}, [items, displayCssPx, limit, opts?.warmKey]);
const primeAllMisses = items.length > 0 && items.length <= GRID_COVER_PRIME_ALL_MAX;
useEffect(() => {
if (!enabled || displayCssPx <= 0) return;
@@ -46,13 +43,12 @@ export function useWarmGridCovers(
if (cancelled || batch.length === 0) return;
await warmCoverDiskSrcBatch(batch);
if (cancelled) return;
if (primeAllMisses) {
await ensureAlbumCoverMisses(items, displayCssPx, { surface, limit });
}
// Prime disk misses for the warmed viewport slice (not only tiny grids).
await ensureAlbumCoverMisses(items, displayCssPx, { surface, limit });
})();
return () => {
cancelled = true;
};
}, [enabled, warmKey, items, displayCssPx, limit, surface, primeAllMisses]);
}, [enabled, warmKey, items, displayCssPx, limit, surface]);
}