mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
Merge e79493b7502cfe3faa0fc8604610b0001104f593 into 08a71320eae544af22bab98f119f8669c5c66282
This commit is contained in:
commit
fade205497
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -6,3 +6,4 @@ export * from './dialogs'
|
||||
export * from './replayGain'
|
||||
export * from './serverEvents'
|
||||
export * from './settings'
|
||||
export * from './infiniteScroll'
|
||||
|
||||
6
ui/src/actions/infiniteScroll.js
Normal file
6
ui/src/actions/infiniteScroll.js
Normal file
@ -0,0 +1,6 @@
|
||||
export const SET_INFINITE_SCROLL = 'SET_INFINITE_SCROLL'
|
||||
|
||||
export const setInfiniteScroll = (enabled) => ({
|
||||
type: SET_INFINITE_SCROLL,
|
||||
data: enabled,
|
||||
})
|
||||
@ -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 />} />
|
||||
</>
|
||||
|
||||
72
ui/src/common/InfiniteScrollWrapper.jsx
Normal file
72
ui/src/common/InfiniteScrollWrapper.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
312
ui/src/common/useInfiniteScroll.jsx
Normal file
312
ui/src/common/useInfiniteScroll.jsx
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
34
ui/src/personal/InfiniteScrollToggle.jsx
Normal file
34
ui/src/personal/InfiniteScrollToggle.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
@ -6,3 +6,4 @@ export * from './albumView'
|
||||
export * from './activityReducer'
|
||||
export * from './settingsReducer'
|
||||
export * from './replayGainReducer'
|
||||
export * from './infiniteScrollReducer'
|
||||
|
||||
21
ui/src/reducers/infiniteScrollReducer.js
Normal file
21
ui/src/reducers/infiniteScrollReducer.js
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user