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'; import { albumBrowseHasGenreFilter, albumBrowseHasServerFilters, fetchAlbumBrowseGenreOptions, fetchAlbumBrowsePage, fetchLocalAlbumCatalogChunk, filterAlbumsByCompilation, filterAlbumsByStarred, type AlbumBrowseQuery, type GenreFilterOption, } from '../utils/library/albumBrowseLoad'; 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; indexEnabled: boolean; musicLibraryFilterVersion: number; sort: AlbumBrowseQuery['sort']; selectedGenres: string[]; yearFrom: string; yearTo: string; losslessOnly: boolean; starredOnly: boolean; compFilter: AlbumCompFilter; starredOverrides: Record; /** 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; /** Bootstrap visible slice size when restoring scroll after album-detail back. */ restoreDisplayCount?: number; }; 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, musicLibraryFilterVersion, sort, selectedGenres, yearFrom, yearTo, losslessOnly, starredOnly, compFilter, starredOverrides, getScrollRoot, scrollRootEl, restoreDisplayCount, }: UseAlbumBrowseDataArgs) { const [albums, setAlbums] = useState([]); 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('page'); const [genreCatalogOptions, setGenreCatalogOptions] = useState(null); const yearFields = useMemo(() => ({ from: yearFrom, to: yearTo }), [yearFrom, yearTo]); const debouncedYearFields = useDebouncedValue(yearFields, ALBUM_YEAR_FILTER_DEBOUNCE_MS); const { active: yearFilterActive, bounds: yearFilterBounds } = useMemo( () => resolveAlbumYearBounds(debouncedYearFields.from, debouncedYearFields.to), [debouncedYearFields.from, debouncedYearFields.to], ); const browseQuery = useMemo(() => ({ sort, genres: selectedGenres, year: yearFilterActive ? yearFilterBounds : undefined, losslessOnly, starredOnly, compFilter, }), [sort, selectedGenres, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]); const browseQueryWithoutGenre = useMemo(() => ({ sort, genres: [], year: yearFilterActive ? yearFilterBounds : undefined, losslessOnly, starredOnly, compFilter, }), [sort, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]); const compFilterActive = compFilter !== 'all'; const compFilterClientOnly = compFilterActive && !indexEnabled; const visibleAlbums = useMemo(() => { let out = compFilterActive ? filterAlbumsByCompilation(albums, compFilter) : albums; if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides); 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, restoreDisplayCount, }); 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; const compScanExhausted = useMemo( () => compFilterClientOnly && !genreFiltered && albumBrowseCompScanComplete(albums, compFilter, hasMore), [compFilterClientOnly, genreFiltered, albums, compFilter, hasMore], ); 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, offset: number, append = false, ) => { const generation = ++loadGenerationRef.current; 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 => { 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 pageResult = await fetchAlbumBrowsePage( serverId, indexEnabled, query, offset, PAGE_SIZE, { onPartial: partial => { if (generation !== loadGenerationRef.current) return; applyPage(partial); loadingRef.current = false; if (append) setLoadingMore(false); else setLoading(false); }, }, ); applyPage(pageResult); } finally { 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); 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) { setGenreCatalogOptions(null); return; } let cancelled = false; void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => { if (!cancelled) setGenreCatalogOptions(options); }); return () => { cancelled = true; }; }, [ narrowGenreList, serverId, indexEnabled, browseQueryWithoutGenre, musicLibraryFilterVersion, ]); 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 = pageRef.current + 1; pageRef.current = next; setPage(next); void loadBrowse(browseQuery, next * PAGE_SIZE, true); }, [ hasMore, browseQuery, loadBrowse, genreFiltered, compFilterClientOnly, visibleAlbums.length, albums, 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 (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, loadingMore: gridLoadingMore, hasMore: gridHasMore, displayAlbums, browseMode, PAGE_SIZE, browseQuery, browseQueryWithoutGenre, visibleAlbums, genreFiltered, serverFilterActive, narrowGenreList, genreCatalogOptions, yearFilterActive, debouncedYearFields, compFilterActive, compFilterClientOnly, compScanExhausted, pendingClientFilterMatch, loadMore, bindLoadMoreSentinel, }; }