Merge e79493b7502cfe3faa0fc8604610b0001104f593 into 08a71320eae544af22bab98f119f8669c5c66282

This commit is contained in:
Boris Rorsvort 2026-02-19 12:31:37 +02:00 committed by GitHub
commit fade205497
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 474 additions and 6 deletions

View File

@ -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,
},
})

View File

@ -6,3 +6,4 @@ export * from './dialogs'
export * from './replayGain'
export * from './serverEvents'
export * from './settings'
export * from './infiniteScroll'

View File

@ -0,0 +1,6 @@
export const SET_INFINITE_SCROLL = 'SET_INFINITE_SCROLL'
export const setInfiniteScroll = (enabled) => ({
type: SET_INFINITE_SCROLL,
data: enabled,
})

View File

@ -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={<AlbumListActions />}
filters={<AlbumFilter />}
perPage={perPage}
pagination={<Pagination rowsPerPageOptions={perPageOptions} />}
pagination={
infiniteScrollEnabled ? (
false
) : (
<Pagination rowsPerPageOptions={perPageOptions} />
)
}
title={<AlbumListTitle albumListType={albumListType} />}
>
{albumView.grid ? (
<AlbumGridView albumListType={albumListType} {...props} />
) : (
<AlbumTableView {...props} />
)}
<InfiniteScrollWrapper>
{albumView.grid ? (
<AlbumGridView albumListType={albumListType} {...props} />
) : (
<AlbumTableView {...props} />
)}
</InfiniteScrollWrapper>
</List>
<ExpandInfoDialog content={<AlbumInfo />} />
</>

View File

@ -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 && (
<Box className={classes.loadingContainer}>
<CircularProgress size={24} />
</Box>
)}
{hasNextPage && <div ref={sentinelRef} className={classes.sentinel} />}
</>
)
}
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 (
<InfiniteScrollProvider>
<InfiniteScrollContent {...props}>{children}</InfiniteScrollContent>
</InfiniteScrollProvider>
)
}

View File

@ -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'

View File

@ -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 (
<InfiniteScrollContext.Provider value={value}>
{children}
</InfiniteScrollContext.Provider>
)
}
// 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
}

View File

@ -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",

View File

@ -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 (
<FormControl>
<FormControlLabel
control={
<Switch
id={'infinite-scroll'}
color="primary"
checked={currentSetting}
onChange={toggleInfiniteScroll}
/>
}
label={
<span>{translate('menu.personal.options.infinite_scroll')}</span>
}
/>
</FormControl>
)
}

View File

@ -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 = () => {
<SelectLanguage />
<SelectDefaultView />
{config.enableReplayGain && <ReplayGainToggle />}
<InfiniteScrollToggle />
<NotificationsToggle />
{config.lastFMEnabled && <LastfmScrobbleToggle />}
{config.listenBrainzEnabled && <ListenBrainzScrobbleToggle />}

View File

@ -6,3 +6,4 @@ export * from './albumView'
export * from './activityReducer'
export * from './settingsReducer'
export * from './replayGainReducer'
export * from './infiniteScrollReducer'

View File

@ -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
}
}

View File

@ -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,