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,