Add Alpha Picker to Genres view and implement infinite scroll (#8167)

* Add Alpha Picker to Genres view and implement infinite scroll

* Fix CI

* Keep alphabet picker mounted while genres load

* Use Paper background

* Apply review suggestions
This commit is contained in:
Tim Eisele
2026-08-25 17:43:27 -04:00
committed by GitHub
parent b689e671f4
commit 517c56558a
5 changed files with 201 additions and 101 deletions
@@ -1,38 +1,31 @@
import React, { useCallback } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import type { LibraryViewSettings } from 'types/library';
import 'components/alphaPicker/style.scss';
interface AlphabetPickerProps {
libraryViewSettings: LibraryViewSettings;
setLibraryViewSettings: React.Dispatch<
React.SetStateAction<LibraryViewSettings>
>;
value?: string | null;
onChange: (value: string | null | undefined) => void;
}
const LETTER_VALUES = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
const AlphabetPicker: React.FC<AlphabetPickerProps> = ({
libraryViewSettings,
setLibraryViewSettings
value,
onChange
}) => {
const handleValue = useCallback(
(
event: React.MouseEvent<HTMLElement>,
newValue: string | null | undefined
) => {
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: 0,
Alphabet: newValue
}));
onChange(newValue);
},
[setLibraryViewSettings]
[onChange]
);
return (
@@ -56,36 +49,44 @@ const AlphabetPicker: React.FC<AlphabetPickerProps> = ({
zIndex: theme.zIndex.appBar - 1
})}
>
<ToggleButtonGroup
orientation='vertical'
value={libraryViewSettings.Alphabet}
exclusive
color='primary'
size='small'
onChange={handleValue}
<Paper
elevation={0}
sx={{
borderRadius: 1,
overflow: 'hidden'
}}
>
{LETTER_VALUES.map((l) => (
<ToggleButton
key={l}
value={l}
sx={{
borderWidth: 0,
paddingTop: {
xs: 0,
md: 0.25
},
paddingBottom: {
xs: 0,
md: 0.25
},
paddingLeft: 0.5,
paddingRight: 0.5
}}
>
{l}
</ToggleButton>
))}
</ToggleButtonGroup>
<ToggleButtonGroup
orientation='vertical'
value={value}
exclusive
color='primary'
size='small'
onChange={handleValue}
>
{LETTER_VALUES.map((l) => (
<ToggleButton
key={l}
value={l}
sx={{
borderWidth: 0,
paddingTop: {
xs: 0,
md: 0.25
},
paddingBottom: {
xs: 0,
md: 0.25
},
paddingLeft: 0.5,
paddingRight: 0.5
}}
>
{l}
</ToggleButton>
))}
</ToggleButtonGroup>
</Paper>
</Box>
);
};
@@ -1,12 +1,17 @@
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import React, { FC } from 'react';
import { useGetGenres } from 'hooks/useFetchItems';
import Box from '@mui/material/Box';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { useIntersectionObserver } from 'usehooks-ts';
import NoItemsMessage from 'components/common/NoItemsMessage';
import Loading from 'components/loading/LoadingComponent';
import GenresSectionContainer from './GenresSectionContainer';
import type { ParentId } from 'types/library';
import { useGenres } from '../hooks/api/useGenres';
import GenresSectionContainer from './GenresSectionContainer';
import AlphabetPicker from './AlphabetPicker';
interface GenresItemsContainerProps {
parentId: ParentId;
collectionType: CollectionType | undefined;
@@ -17,27 +22,74 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
parentId,
collectionType,
itemType
// eslint-disable-next-line sonarjs/function-return-type
}) => {
const { isLoading, data: genresResult } = useGetGenres(itemType, parentId);
const [alphabet, setAlphabet] = useState<string | null>();
const {
isLoading,
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useGenres({
parentId,
includeItemTypes: itemType,
alphabet
});
if (isLoading) {
return <Loading />;
}
const genres = useMemo(
() => data?.pages.flatMap((page) => page?.Items ?? []) ?? [],
[data]
);
if (!genresResult?.Items?.length) {
const { ref: sentinelRef, isIntersecting } = useIntersectionObserver({
rootMargin: '200px'
});
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);
// No genres at all (no letter filter active) - nothing to pick from
if (!isLoading && !genres.length && alphabet == null) {
return <NoItemsMessage message='MessageNoGenresAvailable' />;
}
return genresResult.Items.map((genre) => (
<GenresSectionContainer
key={genre.Id}
collectionType={collectionType}
parentId={parentId}
itemType={itemType}
genre={genre}
/>
));
const renderGenres = () => {
if (isLoading) {
return <Loading />;
}
if (!genres.length) {
return <NoItemsMessage message='MessageNoGenresAvailable' />;
}
return (
<>
{genres.map((genre) => (
<GenresSectionContainer
key={genre.Id}
collectionType={collectionType}
parentId={parentId}
itemType={itemType}
genre={genre}
/>
))}
{hasNextPage && <Box ref={sentinelRef} sx={{ height: '1px' }} />}
{isFetchingNextPage && <Loading />}
</>
);
};
return (
<>
<AlphabetPicker value={alphabet} onChange={setAlphabet} />
{renderGenres()}
</>
);
};
export default GenresItemsContainer;
@@ -33,8 +33,11 @@ const ItemsView: FC = () => {
} = useLibrary();
const viewType = content?.viewType ?? LibraryTab.Movies;
const libraryViewSettings = viewSettings ?? getDefaultLibraryViewSettings(viewType);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const setLibraryViewSettings = setViewSettings ?? ((action: SetStateAction<LibraryViewSettings>) => { /* no-op */ });
const setLibraryViewSettings = useMemo(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
() => setViewSettings ?? ((action: SetStateAction<LibraryViewSettings>) => { /* no-op */ }),
[setViewSettings]
);
const { isAlphabetPickerEnabled, noItemsMessage } = content ?? {};
// Check if the alphabet picker will fit in the current viewport
const isAlphabetPickerSupported = useMediaQuery(t => [
@@ -180,6 +183,14 @@ const ItemsView: FC = () => {
noItemsMessage
]);
const handleAlphabetChange = useCallback((newValue: string | null | undefined) => {
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: 0,
Alphabet: newValue
}));
}, [setLibraryViewSettings]);
const hasSortName = !libraryViewSettings.SortBy.includes(ItemSortBy.Random);
const itemsContainerClass = classNames(
@@ -193,8 +204,8 @@ const ItemsView: FC = () => {
<Box className='padded-bottom-page'>
{isAlphabetPickerSupported && isAlphabetPickerEnabled && hasSortName && (
<AlphabetPicker
libraryViewSettings={libraryViewSettings}
setLibraryViewSettings={setLibraryViewSettings}
value={libraryViewSettings.Alphabet}
onChange={handleAlphabetChange}
/>
)}
@@ -0,0 +1,72 @@
import type { Api } from '@jellyfin/sdk/lib/api';
import type { GenreApiGetGenresRequest } from '@jellyfin/sdk/lib/generated-client/api/genre-api';
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import { getGenreApi } from '@jellyfin/sdk/lib/utils/api/genre-api';
import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import type { ItemDtoQueryResult } from 'types/base/models/item-dto-query-result';
import type { ParentId } from 'types/library';
export const GENRES_PAGE_SIZE = 10;
interface GenresParams {
parentId?: ParentId;
includeItemTypes?: BaseItemKind[];
/** Filter by the first letter of the genre name; '#' matches everything sorting before 'A'. */
alphabet?: string | null;
userId?: string;
}
const fetchGenres = async (
api: Api,
params: GenreApiGetGenresRequest,
options?: AxiosRequestConfig
) => {
const response = await getGenreApi(api).getGenres(params, options);
return response.data as ItemDtoQueryResult;
};
/** Query options for fetching genres. */
export const getGenresQuery = (
api?: Api,
params: GenresParams = {}
) => infiniteQueryOptions({
queryKey: ['Genres', params.parentId, params.includeItemTypes, params.alphabet],
queryFn: ({ pageParam, signal }) => fetchGenres(
api!,
{
userId: params.userId,
includeItemTypes: params.includeItemTypes,
parentId: params.parentId ?? undefined,
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
nameLessThan: params.alphabet === '#' ? 'A' : undefined,
nameStartsWith: params.alphabet === '#' ? undefined : (params.alphabet ?? undefined),
enableTotalRecordCount: false,
startIndex: pageParam * GENRES_PAGE_SIZE,
limit: GENRES_PAGE_SIZE
},
{ signal }
),
initialPageParam: 0,
// Stop once a page returns fewer items than requested (cheaper than enabling total record count)
getNextPageParam: (lastPage, allPages) =>
(lastPage?.Items?.length ?? 0) < GENRES_PAGE_SIZE ? undefined : allPages.length,
enabled: !!api && !!params.userId && !!params.parentId
});
/** Hook for fetching genres. */
export const useGenres = (params?: GenresParams) => {
const { api, user } = useApi();
return useInfiniteQuery(getGenresQuery(
api,
{
...params,
userId: params?.userId || user?.Id
}
));
};
-36
View File
@@ -10,7 +10,6 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
import { getArtistApi } from '@jellyfin/sdk/lib/utils/api/artist-api';
import { getFilterApi } from '@jellyfin/sdk/lib/utils/api/filter-api';
import { getGenreApi } from '@jellyfin/sdk/lib/utils/api/genre-api';
import { getPersonApi } from '@jellyfin/sdk/lib/utils/api/person-api';
import { getStudioApi } from '@jellyfin/sdk/lib/utils/api/studio-api';
import { getShowApi } from '@jellyfin/sdk/lib/utils/api/show-api';
@@ -72,41 +71,6 @@ export const useGetItems = (parametersOptions: LibraryApiGetItemsRequest) => {
});
};
const fetchGetGenres = async (
currentApi: JellyfinApiContext,
itemType: BaseItemKind[],
parentId: ParentId,
options?: AxiosRequestConfig
) => {
const { api, user } = currentApi;
if (api && user?.Id) {
const response = await getGenreApi(api).getGenres(
{
userId: user.Id,
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
includeItemTypes: itemType,
enableTotalRecordCount: false,
parentId: parentId ?? undefined
},
{
signal: options?.signal
}
);
return response.data as ItemDtoQueryResult;
}
};
export const useGetGenres = (itemType: BaseItemKind[], parentId: ParentId) => {
const currentApi = useApi();
return useQuery({
queryKey: ['Genres', parentId],
queryFn: ({ signal }) =>
fetchGetGenres(currentApi, itemType, parentId, { signal }),
enabled: !!currentApi.api && !!currentApi.user?.Id && !!parentId
});
};
const fetchGetStudios = async (
currentApi: JellyfinApiContext,
parentId: ParentId,