mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user