diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 2dbe72421..0efa521a3 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -31,6 +31,7 @@ import { activityReducer, settingsReducer, replayGainReducer, + infiniteScrollReducer, downloadMenuDialogReducer, shareDialogReducer, } from './reducers' @@ -72,6 +73,7 @@ const adminStore = createAdminStore({ activity: activityReducer, settings: settingsReducer, replayGain: replayGainReducer, + infiniteScroll: infiniteScrollReducer, }, }) diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 9f35f86a9..1fcd70317 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -6,3 +6,4 @@ export * from './dialogs' export * from './replayGain' export * from './serverEvents' export * from './settings' +export * from './infiniteScroll' diff --git a/ui/src/actions/infiniteScroll.js b/ui/src/actions/infiniteScroll.js new file mode 100644 index 000000000..8bb8d2f61 --- /dev/null +++ b/ui/src/actions/infiniteScroll.js @@ -0,0 +1,6 @@ +export const SET_INFINITE_SCROLL = 'SET_INFINITE_SCROLL' + +export const setInfiniteScroll = (enabled) => ({ + type: SET_INFINITE_SCROLL, + data: enabled, +}) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index f10f8dbd3..d9c7d1066 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -18,6 +18,7 @@ import { import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { + InfiniteScrollWrapper, List, QuickFilter, Title, @@ -179,6 +180,9 @@ const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { const { width } = props const albumView = useSelector((state) => state.albumView) + const infiniteScrollEnabled = useSelector( + (state) => state.infiniteScroll?.enabled ?? false, + ) const [perPage, perPageOptions] = useAlbumsPerPage(width) const location = useLocation() const version = useVersion() @@ -234,14 +238,22 @@ const AlbumList = (props) => { actions={} filters={} perPage={perPage} - pagination={} + pagination={ + infiniteScrollEnabled ? ( + false + ) : ( + + ) + } title={} > - {albumView.grid ? ( - - ) : ( - - )} + + {albumView.grid ? ( + + ) : ( + + )} + } /> diff --git a/ui/src/common/InfiniteScrollWrapper.jsx b/ui/src/common/InfiniteScrollWrapper.jsx new file mode 100644 index 000000000..fa0328a05 --- /dev/null +++ b/ui/src/common/InfiniteScrollWrapper.jsx @@ -0,0 +1,72 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { CircularProgress, Box } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { InfiniteScrollProvider, useInfiniteScroll } from './useInfiniteScroll' + +const useStyles = makeStyles((theme) => ({ + sentinel: { + width: '100%', + height: '1px', + marginTop: theme.spacing(2), + }, + loadingContainer: { + display: 'flex', + justifyContent: 'center', + padding: theme.spacing(2), + width: '100%', + }, +})) + +const InfiniteScrollContent = ({ children, ...props }) => { + const classes = useStyles() + const { + accumulatedData, + accumulatedIds, + loading, + loaded, + hasNextPage, + isFetchingMore, + sentinelRef, + } = useInfiniteScroll() + + const infiniteProps = { + ...props, + data: accumulatedData, + ids: accumulatedIds, + loading, + loaded, + } + + return ( + <> + {React.Children.map(children, (child) => + React.cloneElement(child, infiniteProps), + )} + {isFetchingMore && ( + + + + )} + {hasNextPage &&
} + + ) +} + +export const InfiniteScrollWrapper = ({ children, ...props }) => { + const infiniteScrollEnabled = useSelector( + (state) => state.infiniteScroll?.enabled ?? false, + ) + + if (!infiniteScrollEnabled) { + return React.Children.map(children, (child) => + React.cloneElement(child, props), + ) + } + + return ( + + {children} + + ) +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index f64d4fe0c..690fb76b9 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,3 +41,5 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './useInfiniteScroll.jsx' +export * from './InfiniteScrollWrapper' diff --git a/ui/src/common/useInfiniteScroll.jsx b/ui/src/common/useInfiniteScroll.jsx new file mode 100644 index 000000000..081514da2 --- /dev/null +++ b/ui/src/common/useInfiniteScroll.jsx @@ -0,0 +1,312 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useCallback, + useState, + useMemo, +} from 'react' +import { useListContext, useGetList } from 'react-admin' +import { useHistory, useLocation } from 'react-router-dom' + +const InfiniteScrollContext = createContext(null) + +const scrollStateCache = new Map() + +const getStorageKey = (pathname, filterValues) => { + const filterKey = JSON.stringify(filterValues || {}) + return `${pathname}:${filterKey}` +} + +const saveScrollState = (pathname, filterValues, pagesLoaded, scrollY) => { + const key = getStorageKey(pathname, filterValues) + scrollStateCache.set(key, { pagesLoaded, scrollY }) +} + +const getScrollState = (pathname, filterValues) => { + const key = getStorageKey(pathname, filterValues) + return scrollStateCache.get(key) || null +} + +const clearScrollState = (pathname, filterValues) => { + const key = getStorageKey(pathname, filterValues) + scrollStateCache.delete(key) +} + +export const InfiniteScrollProvider = ({ children }) => { + const history = useHistory() + const location = useLocation() + + const { + data: page1Data, + ids: page1Ids, + total, + perPage, + loading: page1Loading, + loaded: page1Loaded, + resource, + filterValues, + sort, + filter, + } = useListContext() + + const [nextPageToFetch, setNextPageToFetch] = useState(null) + const [additionalPagesData, setAdditionalPagesData] = useState({}) + const [isFetchingMore, setIsFetchingMore] = useState(false) + const [isRestoring, setIsRestoring] = useState(false) + const [targetPage, setTargetPage] = useState(null) + const [pendingScrollY, setPendingScrollY] = useState(null) + + const initializedRef = useRef(false) + const navigationTypeRef = useRef(null) + const sentinelRef = useRef(null) + + const combinedFilter = useMemo( + () => ({ + ...filter, + ...filterValues, + }), + [filter, filterValues], + ) + + useEffect(() => { + if (typeof window !== 'undefined' && window.performance) { + const navEntries = window.performance.getEntriesByType('navigation') + if (navEntries.length > 0) { + navigationTypeRef.current = navEntries[0].type + } + } + }, []) + + const { + data: additionalData, + ids: additionalIds, + loaded: additionalLoaded, + } = useGetList( + resource, + { page: nextPageToFetch || 1, perPage }, + sort, + combinedFilter, + { enabled: nextPageToFetch !== null && nextPageToFetch > 1 }, + ) + + const maxLoadedPage = useMemo(() => { + const additionalPages = Object.keys(additionalPagesData).map(Number) + if (additionalPages.length > 0) { + return Math.max(...additionalPages) + } + return page1Loaded ? 1 : 0 + }, [additionalPagesData, page1Loaded]) + + const hasNextPage = useMemo(() => { + if (!total || !perPage) return false + const totalPages = Math.ceil(total / perPage) + return maxLoadedPage < totalPages + }, [total, perPage, maxLoadedPage]) + + const { accumulatedData, accumulatedIds } = useMemo(() => { + const accData = { ...page1Data } + const accIds = [...(page1Ids || [])] + const seenIds = new Set(accIds) + + const sortedPages = Object.keys(additionalPagesData) + .map(Number) + .sort((a, b) => a - b) + + for (const pageNum of sortedPages) { + const pageInfo = additionalPagesData[pageNum] + if (pageInfo) { + Object.assign(accData, pageInfo.data) + for (const id of pageInfo.ids) { + if (!seenIds.has(id)) { + seenIds.add(id) + accIds.push(id) + } + } + } + } + + return { accumulatedData: accData, accumulatedIds: accIds } + }, [page1Data, page1Ids, additionalPagesData]) + + useEffect(() => { + if (initializedRef.current || !page1Loaded) return + initializedRef.current = true + + const isBackNavigation = + history.action === 'POP' || navigationTypeRef.current === 'back_forward' + + if (isBackNavigation) { + const saved = getScrollState(location.pathname, filterValues) + if (saved && saved.pagesLoaded > 1) { + setIsRestoring(true) + setTargetPage(saved.pagesLoaded) + if (saved.scrollY > 0) { + setPendingScrollY(saved.scrollY) + } + setNextPageToFetch(2) + return + } + } + + clearScrollState(location.pathname, filterValues) + window.scrollTo(0, 0) + }, [page1Loaded, history.action, location.pathname, filterValues]) + + useEffect(() => { + if ( + nextPageToFetch && + nextPageToFetch > 1 && + additionalLoaded && + additionalData && + additionalIds + ) { + setAdditionalPagesData((prev) => { + if (!prev[nextPageToFetch]) { + return { + ...prev, + [nextPageToFetch]: { + data: { ...additionalData }, + ids: [...additionalIds], + }, + } + } + return prev + }) + + setIsFetchingMore(false) + + if (isRestoring && targetPage && nextPageToFetch < targetPage) { + setNextPageToFetch(nextPageToFetch + 1) + } else if (isRestoring && targetPage && nextPageToFetch >= targetPage) { + if (pendingScrollY) { + setTimeout(() => { + window.scrollTo(0, pendingScrollY) + setPendingScrollY(null) + setIsRestoring(false) + setTargetPage(null) + }, 100) + } else { + setIsRestoring(false) + setTargetPage(null) + } + } + } + }, [ + nextPageToFetch, + additionalLoaded, + additionalData, + additionalIds, + isRestoring, + targetPage, + pendingScrollY, + ]) + + useEffect(() => { + if (!isRestoring && maxLoadedPage > 0 && page1Loaded) { + const handleScroll = () => { + saveScrollState( + location.pathname, + filterValues, + maxLoadedPage, + window.scrollY, + ) + } + + handleScroll() + + let scrollTimeout + const throttledScroll = () => { + clearTimeout(scrollTimeout) + scrollTimeout = setTimeout(handleScroll, 150) + } + + window.addEventListener('scroll', throttledScroll, { passive: true }) + return () => { + window.removeEventListener('scroll', throttledScroll) + clearTimeout(scrollTimeout) + } + } + }, [maxLoadedPage, isRestoring, location.pathname, filterValues, page1Loaded]) + + const loadMore = useCallback(() => { + if (!page1Loading && !isFetchingMore && hasNextPage && !isRestoring) { + setIsFetchingMore(true) + setNextPageToFetch(maxLoadedPage + 1) + } + }, [page1Loading, isFetchingMore, hasNextPage, maxLoadedPage, isRestoring]) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if ( + entry.isIntersecting && + hasNextPage && + !page1Loading && + !isFetchingMore && + !isRestoring + ) { + loadMore() + } + }, + { + root: null, + rootMargin: '200px', + threshold: 0, + }, + ) + + observer.observe(sentinel) + + return () => { + observer.disconnect() + } + }, [hasNextPage, page1Loading, isFetchingMore, loadMore, isRestoring]) + + const value = useMemo( + () => ({ + accumulatedData, + accumulatedIds, + hasNextPage, + isFetchingMore, + loading: page1Loading || isFetchingMore || isRestoring, + sentinelRef, + total, + loaded: page1Loaded && !isRestoring, + loadMore, + }), + [ + accumulatedData, + accumulatedIds, + hasNextPage, + isFetchingMore, + page1Loading, + isRestoring, + total, + page1Loaded, + loadMore, + ], + ) + + return ( + + {children} + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export const useInfiniteScroll = () => { + const context = useContext(InfiniteScrollContext) + if (!context) { + throw new Error( + 'useInfiniteScroll must be used within an InfiniteScrollProvider', + ) + } + return context +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 678abaabd..880ba0b47 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -610,6 +610,7 @@ "language": "Language", "defaultView": "Default View", "desktop_notifications": "Desktop Notifications", + "infinite_scroll": "Infinite scroll on album list", "lastfmNotConfigured": "Last.fm API-Key is not configured", "lastfmScrobbling": "Scrobble to Last.fm", "listenBrainzScrobbling": "Scrobble to ListenBrainz", diff --git a/ui/src/personal/InfiniteScrollToggle.jsx b/ui/src/personal/InfiniteScrollToggle.jsx new file mode 100644 index 000000000..9694b3981 --- /dev/null +++ b/ui/src/personal/InfiniteScrollToggle.jsx @@ -0,0 +1,34 @@ +import { useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { setInfiniteScroll } from '../actions' +import { FormControl, FormControlLabel, Switch } from '@material-ui/core' + +export const InfiniteScrollToggle = () => { + const translate = useTranslate() + const dispatch = useDispatch() + const currentSetting = useSelector( + (state) => state.infiniteScroll?.enabled ?? false, + ) + + const toggleInfiniteScroll = (event) => { + dispatch(setInfiniteScroll(event.target.checked)) + } + + return ( + + + } + label={ + {translate('menu.personal.options.infinite_scroll')} + } + /> + + ) +} diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.jsx index 84f9b63e6..0f01ff62b 100644 --- a/ui/src/personal/Personal.jsx +++ b/ui/src/personal/Personal.jsx @@ -9,6 +9,7 @@ import { LastfmScrobbleToggle } from './LastfmScrobbleToggle' import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle' import config from '../config' import { ReplayGainToggle } from './ReplayGainToggle' +import { InfiniteScrollToggle } from './InfiniteScrollToggle' const useStyles = makeStyles({ root: { marginTop: '1em' }, @@ -26,6 +27,7 @@ const Personal = () => { {config.enableReplayGain && } + {config.lastFMEnabled && } {config.listenBrainzEnabled && } diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 3db0b1dff..a904a3f6e 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -6,3 +6,4 @@ export * from './albumView' export * from './activityReducer' export * from './settingsReducer' export * from './replayGainReducer' +export * from './infiniteScrollReducer' diff --git a/ui/src/reducers/infiniteScrollReducer.js b/ui/src/reducers/infiniteScrollReducer.js new file mode 100644 index 000000000..49b3db5f2 --- /dev/null +++ b/ui/src/reducers/infiniteScrollReducer.js @@ -0,0 +1,21 @@ +import { SET_INFINITE_SCROLL } from '../actions' + +const initialState = { + enabled: false, +} + +export const infiniteScrollReducer = ( + previousState = initialState, + payload, +) => { + const { type, data } = payload + switch (type) { + case SET_INFINITE_SCROLL: + return { + ...previousState, + enabled: data, + } + default: + return previousState + } +} diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js index 4888e49e4..9ea168d19 100644 --- a/ui/src/store/createAdminStore.js +++ b/ui/src/store/createAdminStore.js @@ -65,6 +65,8 @@ const createAdminStore = ({ }))(state.player), albumView: state.albumView, settings: state.settings, + // Only persist the 'enabled' setting, not transient page/scroll state + infiniteScroll: { enabled: state.infiniteScroll?.enabled ?? false }, }) }), 1000,