1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Refactor Search Page

This commit is contained in:
grafixeyehero 2024-06-11 00:23:57 +03:00
parent e1d17a0a6b
commit 9352ec12dc
10 changed files with 660 additions and 498 deletions

View file

@ -1,24 +1,25 @@
import React, { type FC, useEffect, useState } from 'react'; import React, { type FC, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useDebounceValue } from 'usehooks-ts';
import Page from 'components/Page';
import SearchFields from 'components/search/SearchFields';
import SearchResults from 'components/search/SearchResults';
import SearchSuggestions from 'components/search/SearchSuggestions';
import LiveTVSearchResults from 'components/search/LiveTVSearchResults';
import { usePrevious } from 'hooks/usePrevious'; import { usePrevious } from 'hooks/usePrevious';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
import Page from 'components/Page';
import SearchFields from 'components/search/SearchFields';
import SearchSuggestions from 'components/search/SearchSuggestions';
import SearchResults from 'components/search/SearchResults';
const COLLECTION_TYPE_PARAM = 'collectionType'; const COLLECTION_TYPE_PARAM = 'collectionType';
const PARENT_ID_PARAM = 'parentId'; const PARENT_ID_PARAM = 'parentId';
const QUERY_PARAM = 'query'; const QUERY_PARAM = 'query';
const SERVER_ID_PARAM = 'serverId';
const Search: FC = () => { const Search: FC = () => {
const [ searchParams, setSearchParams ] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const parentIdQuery = searchParams.get(PARENT_ID_PARAM) || undefined;
const collectionTypeQuery = searchParams.get(COLLECTION_TYPE_PARAM) || undefined;
const urlQuery = searchParams.get(QUERY_PARAM) || ''; const urlQuery = searchParams.get(QUERY_PARAM) || '';
const [ query, setQuery ] = useState(urlQuery); const [query, setQuery] = useState(urlQuery);
const prevQuery = usePrevious(query, ''); const prevQuery = usePrevious(query, '');
const [debouncedQuery] = useDebounceValue(query, 500);
useEffect(() => { useEffect(() => {
if (query !== prevQuery) { if (query !== prevQuery) {
@ -49,23 +50,17 @@ const Search: FC = () => {
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage' className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
> >
<SearchFields query={query} onSearch={setQuery} /> <SearchFields query={query} onSearch={setQuery} />
{!query {!query ? (
&& <SearchSuggestions <SearchSuggestions
parentId={searchParams.get(PARENT_ID_PARAM)} parentId={parentIdQuery}
/> />
} ) : (
<SearchResults <SearchResults
serverId={searchParams.get(SERVER_ID_PARAM) || window.ApiClient.serverId()} parentId={parentIdQuery}
parentId={searchParams.get(PARENT_ID_PARAM)} collectionType={collectionTypeQuery}
collectionType={searchParams.get(COLLECTION_TYPE_PARAM)} query={debouncedQuery}
query={query} />
/> )}
<LiveTVSearchResults
serverId={searchParams.get(SERVER_ID_PARAM) || window.ApiClient.serverId()}
parentId={searchParams.get(PARENT_ID_PARAM)}
collectionType={searchParams.get(COLLECTION_TYPE_PARAM)}
query={query}
/>
</Page> </Page>
); );
}; };

View file

@ -1,25 +1,22 @@
import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react'; import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react';
import AlphaPicker from '../alphaPicker/AlphaPickerComponent'; import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
import Input from 'elements/emby-input/Input'; import Input from 'elements/emby-input/Input';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import layoutManager from '../layoutManager'; import layoutManager from '../layoutManager';
import browser from '../../scripts/browser'; import browser from '../../scripts/browser';
import 'material-design-icons-iconfont'; import 'material-design-icons-iconfont';
import '../../styles/flexstyles.scss'; import '../../styles/flexstyles.scss';
import './searchfields.scss'; import './searchfields.scss';
type SearchFieldsProps = { interface SearchFieldsProps {
query: string, query: string,
onSearch?: (query: string) => void onSearch?: (query: string) => void
}; }
const SearchFields: FC<SearchFieldsProps> = ({ const SearchFields: FC<SearchFieldsProps> = ({
onSearch = () => { /* no-op */ }, onSearch = () => { /* no-op */ },
query query
}: SearchFieldsProps) => { }) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const onAlphaPicked = useCallback((e: Event) => { const onAlphaPicked = useCallback((e: Event) => {
@ -27,7 +24,8 @@ const SearchFields: FC<SearchFieldsProps> = ({
const inputValue = inputRef.current?.value || ''; const inputValue = inputRef.current?.value || '';
if (value === 'backspace') { if (value === 'backspace') {
onSearch(inputValue.length ? inputValue.substring(0, inputValue.length - 1) : ''); onSearch(inputValue.length ? inputValue.substring(0, inputValue.length - 1) : ''
);
} else { } else {
onSearch(inputValue + value); onSearch(inputValue + value);
} }

View file

@ -1,388 +1,58 @@
import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import React, { type FC } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; import { Section, useSearchItems } from 'hooks/searchHook';
import type { ApiClient } from 'jellyfin-apiclient';
import classNames from 'classnames';
import React, { type FC, useCallback, useEffect, useState } from 'react';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { useDebounceValue } from 'usehooks-ts';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
import SearchResultsRow from './SearchResultsRow';
import Loading from '../loading/LoadingComponent'; import Loading from '../loading/LoadingComponent';
import SearchResultsRow from './SearchResultsRow';
import { CardShape } from 'utils/card';
interface ParametersOptions { interface SearchResultsProps {
ParentId?: string | null; parentId?: string;
searchTerm?: string; collectionType?: string;
Limit?: number; query?: string;
Fields?: string;
Recursive?: boolean;
EnableTotalRecordCount?: boolean;
ImageTypeLimit?: number;
IncludePeople?: boolean;
IncludeMedia?: boolean;
IncludeGenres?: boolean;
IncludeStudios?: boolean;
IncludeArtists?: boolean;
IsMissing?: boolean;
IncludeItemTypes?: BaseItemKind;
MediaTypes?: string;
ExcludeItemTypes?: string;
} }
type SearchResultsProps = {
serverId?: string;
parentId?: string | null;
collectionType?: string | null;
query?: string;
};
const ensureNonNullItems = (result: BaseItemDtoQueryResult) => ({
...result,
Items: result.Items || []
});
const isMovies = (collectionType: string) => collectionType === CollectionType.Movies;
const isMusic = (collectionType: string) => collectionType === CollectionType.Music;
const isTVShows = (collectionType: string) => collectionType === CollectionType.Tvshows;
/* /*
* React component to display search result rows for global search and non-live tv library search * React component to display search result rows for global search and library view search
*/ */
const SearchResults: FC<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { const SearchResults: FC<SearchResultsProps> = ({
const [ movies, setMovies ] = useState<BaseItemDto[]>([]); parentId,
const [ shows, setShows ] = useState<BaseItemDto[]>([]); collectionType,
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]); query
const [ videos, setVideos ] = useState<BaseItemDto[]>([]); }) => {
const [ programs, setPrograms ] = useState<BaseItemDto[]>([]); const { isLoading, data } = useSearchItems(parentId, collectionType, query);
const [ channels, setChannels ] = useState<BaseItemDto[]>([]);
const [ playlists, setPlaylists ] = useState<BaseItemDto[]>([]);
const [ artists, setArtists ] = useState<BaseItemDto[]>([]);
const [ albums, setAlbums ] = useState<BaseItemDto[]>([]);
const [ songs, setSongs ] = useState<BaseItemDto[]>([]);
const [ photoAlbums, setPhotoAlbums ] = useState<BaseItemDto[]>([]);
const [ photos, setPhotos ] = useState<BaseItemDto[]>([]);
const [ audioBooks, setAudioBooks ] = useState<BaseItemDto[]>([]);
const [ books, setBooks ] = useState<BaseItemDto[]>([]);
const [ people, setPeople ] = useState<BaseItemDto[]>([]);
const [ collections, setCollections ] = useState<BaseItemDto[]>([]);
const [ isLoading, setIsLoading ] = useState(false);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({ if (isLoading) return <Loading />;
ParentId: parentId,
searchTerm: debouncedQuery,
Limit: 100,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [ parentId, debouncedQuery ]);
const fetchArtists = useCallback((apiClient: ApiClient, params: ParametersOptions = {}) => ( if (!data?.length) {
apiClient?.getArtists( return (
apiClient.getCurrentUserId(), <div className='noItemsMessage centerMessage'>
{ {globalize.translate('SearchResultsEmpty', query)}
...getDefaultParameters(), </div>
IncludeArtists: true, );
...params }
}
).then(ensureNonNullItems)
), [getDefaultParameters]);
const fetchItems = useCallback(async (apiClient?: ApiClient, params: ParametersOptions = {}) => { const renderSection = (section: Section, index: number) => {
if (!apiClient) { return (
console.error('[SearchResults] no apiClient; unable to fetch items'); <SearchResultsRow
return { key={`${section.title}-${index}`}
Items: [] title={globalize.translate(section.title)}
}; items={section.items}
} cardOptions={{
shape: CardShape.AutoOverflow,
const options: ParametersOptions = { scalable: true,
...getDefaultParameters(), showTitle: true,
IncludeMedia: true, overlayText: false,
...params centerText: true,
}; allowBottomPadding: false,
...section.cardOptions
if (params.IncludeItemTypes === BaseItemKind.Episode) { }}
const user = await apiClient.getCurrentUser(); />
if (!user?.Configuration?.DisplayMissingEpisodes) { );
options.IsMissing = false; };
}
}
return apiClient.getItems(
apiClient.getCurrentUserId(),
options
).then(ensureNonNullItems);
}, [getDefaultParameters]);
const fetchPeople = useCallback((apiClient: ApiClient, params: ParametersOptions = {}) => (
apiClient?.getPeople(
apiClient.getCurrentUserId(),
{
...getDefaultParameters(),
IncludePeople: true,
...params
}
).then(ensureNonNullItems)
), [getDefaultParameters]);
useEffect(() => {
if (query) setIsLoading(true);
}, [ query ]);
useEffect(() => {
// Reset state
setMovies([]);
setShows([]);
setEpisodes([]);
setVideos([]);
setPrograms([]);
setChannels([]);
setPlaylists([]);
setArtists([]);
setAlbums([]);
setSongs([]);
setPhotoAlbums([]);
setPhotos([]);
setAudioBooks([]);
setBooks([]);
setPeople([]);
setCollections([]);
if (!debouncedQuery) {
setIsLoading(false);
return;
}
const apiClient = ServerConnections.getApiClient(serverId);
const fetchPromises = [];
// Movie libraries
if (!collectionType || isMovies(collectionType)) {
fetchPromises.push(
// Movies row
fetchItems(apiClient, { IncludeItemTypes: 'Movie' })
.then(result => setMovies(result.Items))
.catch(() => setMovies([]))
);
}
// TV Show libraries
if (!collectionType || isTVShows(collectionType)) {
fetchPromises.push(
// Shows row
fetchItems(apiClient, { IncludeItemTypes: 'Series' })
.then(result => setShows(result.Items))
.catch(() => setShows([])),
// Episodes row
fetchItems(apiClient, { IncludeItemTypes: 'Episode' })
.then(result => setEpisodes(result.Items))
.catch(() => setEpisodes([]))
);
}
// People are included for Movies and TV Shows
if (!collectionType || isMovies(collectionType) || isTVShows(collectionType)) {
fetchPromises.push(
// People row
fetchPeople(apiClient)
.then(result => setPeople(result.Items))
.catch(() => setPeople([]))
);
}
// Music libraries
if (!collectionType || isMusic(collectionType)) {
fetchPromises.push(
// Playlists row
fetchItems(apiClient, { IncludeItemTypes: 'Playlist' })
.then(results => setPlaylists(results.Items))
.catch(() => setPlaylists([])),
// Artists row
fetchArtists(apiClient)
.then(result => setArtists(result.Items))
.catch(() => setArtists([])),
// Albums row
fetchItems(apiClient, { IncludeItemTypes: 'MusicAlbum' })
.then(result => setAlbums(result.Items))
.catch(() => setAlbums([])),
// Songs row
fetchItems(apiClient, { IncludeItemTypes: 'Audio' })
.then(result => setSongs(result.Items))
.catch(() => setSongs([]))
);
}
// Other libraries do not support in-library search currently
if (!collectionType) {
fetchPromises.push(
// Videos row
fetchItems(apiClient, {
MediaTypes: 'Video',
ExcludeItemTypes: 'Movie,Episode,TvChannel'
})
.then(result => setVideos(result.Items))
.catch(() => setVideos([])),
// Programs row
fetchItems(apiClient, { IncludeItemTypes: 'LiveTvProgram' })
.then(result => setPrograms(result.Items))
.catch(() => setPrograms([])),
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items))
.catch(() => setChannels([])),
// Photo Albums row
fetchItems(apiClient, { IncludeItemTypes: 'PhotoAlbum' })
.then(result => setPhotoAlbums(result.Items))
.catch(() => setPhotoAlbums([])),
// Photos row
fetchItems(apiClient, { IncludeItemTypes: 'Photo' })
.then(result => setPhotos(result.Items))
.catch(() => setPhotos([])),
// Audio Books row
fetchItems(apiClient, { IncludeItemTypes: 'AudioBook' })
.then(result => setAudioBooks(result.Items))
.catch(() => setAudioBooks([])),
// Books row
fetchItems(apiClient, { IncludeItemTypes: 'Book' })
.then(result => setBooks(result.Items))
.catch(() => setBooks([])),
// Collections row
fetchItems(apiClient, { IncludeItemTypes: 'BoxSet' })
.then(result => setCollections(result.Items))
.catch(() => setCollections([]))
);
}
Promise.all(fetchPromises)
.then(() => {
setIsLoading(false); // Set loading to false when all fetch calls are done
})
.catch((error) => {
console.error('An error occurred while fetching data:', error);
setIsLoading(false); // Set loading to false even if an error occurs
});
}, [collectionType, fetchArtists, fetchItems, fetchPeople, debouncedQuery, serverId]);
const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0);
return ( return (
<div <div className={'searchResults, padded-top, padded-bottom-page'}>
className={classNames( {data.map((section, index) => renderSection(section, index))}
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !debouncedQuery || collectionType === CollectionType.Livetv }
)}
>
{isLoading ? (
<Loading />
) : (
<>
<SearchResultsRow
title={globalize.translate('Movies')}
items={movies}
cardOptions={{ showYear: true }}
/>
<SearchResultsRow
title={globalize.translate('Shows')}
items={shows}
cardOptions={{ showYear: true }}
/>
<SearchResultsRow
title={globalize.translate('Episodes')}
items={episodes}
cardOptions={{
coverImage: true,
showParentTitle: true
}}
/>
<SearchResultsRow
title={globalize.translate('HeaderVideos')}
items={videos}
cardOptions={{ showParentTitle: true }}
/>
<SearchResultsRow
title={globalize.translate('Programs')}
items={programs}
cardOptions={{
preferThumb: true,
inheritThumb: false,
showParentTitleOrTitle: true,
showTitle: false,
coverImage: true,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
}}
/>
<SearchResultsRow
title={globalize.translate('Channels')}
items={channels}
cardOptions={{ shape: 'square' }}
/>
<SearchResultsRow
title={globalize.translate('Playlists')}
items={playlists}
/>
<SearchResultsRow
title={globalize.translate('Artists')}
items={artists}
cardOptions={{ coverImage: true }}
/>
<SearchResultsRow
title={globalize.translate('Albums')}
items={albums}
cardOptions={{ showParentTitle: true }}
/>
<SearchResultsRow
title={globalize.translate('Songs')}
items={songs}
cardOptions={{ showParentTitle: true }}
/>
<SearchResultsRow
title={globalize.translate('HeaderPhotoAlbums')}
items={photoAlbums}
/>
<SearchResultsRow
title={globalize.translate('Photos')}
items={photos}
/>
<SearchResultsRow
title={globalize.translate('HeaderAudioBooks')}
items={audioBooks}
/>
<SearchResultsRow
title={globalize.translate('Books')}
items={books}
/>
<SearchResultsRow
title={globalize.translate('Collections')}
items={collections}
/>
<SearchResultsRow
title={globalize.translate('People')}
items={people}
cardOptions={{ coverImage: true }}
/>
{allEmpty && debouncedQuery && !isLoading && (
<div className='noItemsMessage centerMessage'>
{globalize.translate('SearchResultsEmpty', debouncedQuery)}
</div>
)}
</>
)}
</div> </div>
); );
}; };

View file

@ -1,8 +1,8 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { FunctionComponent, useEffect, useRef } from 'react'; import React, { type FC, useEffect, useRef } from 'react';
import cardBuilder from '../cardbuilder/cardBuilder'; import cardBuilder from '../cardbuilder/cardBuilder';
import type { CardOptions } from 'types/cardOptions';
import '../../elements/emby-scroller/emby-scroller'; import '../../elements/emby-scroller/emby-scroller';
import '../../elements/emby-itemscontainer/emby-itemscontainer'; import '../../elements/emby-itemscontainer/emby-itemscontainer';
@ -16,46 +16,18 @@ const createScroller = ({ title = '' }) => ({
</div>` </div>`
}); });
type CardOptions = { interface SearchResultsRowProps {
itemsContainer?: HTMLElement,
parentContainer?: HTMLElement,
allowBottomPadding?: boolean,
centerText?: boolean,
coverImage?: boolean,
inheritThumb?: boolean,
overlayMoreButton?: boolean,
overlayText?: boolean,
preferThumb?: boolean,
scalable?: boolean,
shape?: string,
showParentTitle?: boolean,
showParentTitleOrTitle?: boolean,
showAirTime?: boolean,
showAirDateTime?: boolean,
showChannelName?: boolean,
showTitle?: boolean,
showYear?: boolean
};
type SearchResultsRowProps = {
title?: string; title?: string;
items?: BaseItemDto[]; items?: BaseItemDto[];
cardOptions?: CardOptions; cardOptions?: CardOptions;
}; }
const SearchResultsRow: FunctionComponent<SearchResultsRowProps> = ({ title, items = [], cardOptions = {} }: SearchResultsRowProps) => { const SearchResultsRow: FC<SearchResultsRowProps> = ({ title, items = [], cardOptions = {} }) => {
const element = useRef<HTMLDivElement>(null); const element = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
cardBuilder.buildCards(items, { cardBuilder.buildCards(items, {
itemsContainer: element.current?.querySelector('.itemsContainer'), itemsContainer: element.current?.querySelector('.itemsContainer'),
parentContainer: element.current,
shape: 'autooverflow',
scalable: true,
showTitle: true,
overlayText: false,
centerText: true,
allowBottomPadding: false,
...cardOptions ...cardOptions
}); });
}, [cardOptions, items]); }, [cardOptions, items]);

View file

@ -1,57 +1,19 @@
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; import React, { type FC } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; import { useSearchSuggestions } from 'hooks/searchHook';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; import Loading from 'components/loading/LoadingComponent';
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
import escapeHtml from 'escape-html';
import React, { FunctionComponent, useEffect, useState } from 'react';
import { appRouter } from '../router/appRouter'; import { appRouter } from '../router/appRouter';
import { useApi } from '../../hooks/useApi';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import LinkButton from 'elements/emby-button/LinkButton';
import '../../elements/emby-button/emby-button'; import '../../elements/emby-button/emby-button';
// There seems to be some compatibility issues here between interface SearchSuggestionsProps {
// React and our legacy web components, so we need to inject parentId?: string;
// them as an html string for now =/ }
const createSuggestionLink = ({ name, href }: { name: string, href: string }) => ({
__html: `<a
is='emby-linkbutton'
class='button-link'
style='display: inline-block; padding: 0.5em 1em;'
href='${href}'
>${escapeHtml(name)}</a>`
});
type SearchSuggestionsProps = { const SearchSuggestions: FC<SearchSuggestionsProps> = ({ parentId }) => {
parentId?: string | null; const { isLoading, data: suggestions } = useSearchSuggestions(parentId);
};
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId }: SearchSuggestionsProps) => { if (isLoading) return <Loading />;
const [ suggestions, setSuggestions ] = useState<BaseItemDto[]>([]);
const { api, user } = useApi();
useEffect(() => {
if (api && user?.Id) {
getItemsApi(api)
.getItems({
userId: user.Id,
sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random],
includeItemTypes: [BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicArtist],
limit: 20,
recursive: true,
imageTypeLimit: 0,
enableImages: false,
parentId: parentId || undefined,
enableTotalRecordCount: false
})
.then(result => setSuggestions(result.data.Items || []))
.catch(err => {
console.error('[SearchSuggestions] failed to fetch search suggestions', err);
setSuggestions([]);
});
}
}, [ api, parentId, user ]);
return ( return (
<div <div
@ -65,14 +27,19 @@ const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId
</div> </div>
<div className='searchSuggestionsList padded-left padded-right'> <div className='searchSuggestionsList padded-left padded-right'>
{suggestions.map(item => ( {suggestions?.map((item) => (
<div <div key={`suggestion-${item.Id}`}>
key={`suggestion-${item.Id}`} <LinkButton
dangerouslySetInnerHTML={createSuggestionLink({ className='button-link'
name: item.Name || '', href={appRouter.getRouteUrl(item)}
href: appRouter.getRouteUrl(item) style={{
})} display: 'inline-block',
/> padding: '0.5em 1em'
}}
>
{item.Name}
</LinkButton>
</div>
))} ))}
</div> </div>
</div> </div>

View file

@ -1458,7 +1458,7 @@ function renderChildren(page, item) {
imageLoader.lazyChildren(childrenItemsContainer); imageLoader.lazyChildren(childrenItemsContainer);
if (item.Type == 'BoxSet') { if (item.Type == 'BoxSet') {
const collectionItemTypes = [{ const collectionItemTypes = [{
name: globalize.translate('HeaderVideos'), name: globalize.translate('Videos'),
mediaType: 'Video' mediaType: 'Video'
}, { }, {
name: globalize.translate('Series'), name: globalize.translate('Series'),

View file

@ -0,0 +1,2 @@
export * from './useSearchItems';
export * from './useSearchSuggestions';

View file

@ -0,0 +1,509 @@
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import type {
ArtistsApiGetArtistsRequest,
BaseItemDto,
ItemsApiGetItemsRequest,
PersonsApiGetPersonsRequest
} from '@jellyfin/sdk/lib/generated-client';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api';
import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../useApi';
import type { CardOptions } from 'types/cardOptions';
import { CardShape } from 'utils/card';
const Parameters_OPTIONS = {
limit: 100,
fields: [
ItemFields.PrimaryImageAspectRatio,
ItemFields.CanDelete,
ItemFields.MediaSourceCount
],
enableTotalRecordCount: false,
imageTypeLimit: 1
};
const fetchItemsByType = async (
api: Api,
userId?: string,
params?: ItemsApiGetItemsRequest,
options?: AxiosRequestConfig
) => {
const response = await getItemsApi(api).getItems(
{
...Parameters_OPTIONS,
userId: userId,
recursive: true,
...params
},
options
);
return response.data;
};
const fetchPeople = async (
api: Api,
userId: string,
params?: PersonsApiGetPersonsRequest,
options?: AxiosRequestConfig
) => {
const response = await getPersonsApi(api).getPersons(
{
...Parameters_OPTIONS,
userId: userId,
...params
},
options
);
return response.data;
};
const fetchArtists = async (
api: Api,
userId: string,
params?: ArtistsApiGetArtistsRequest,
options?: AxiosRequestConfig
) => {
const response = await getArtistsApi(api).getArtists(
{
...Parameters_OPTIONS,
userId: userId,
...params
},
options
);
return response.data;
};
const isMovies = (collectionType: string) =>
collectionType === CollectionType.Movies;
const isMusic = (collectionType: string) =>
collectionType === CollectionType.Music;
const isTVShows = (collectionType: string) =>
collectionType === CollectionType.Tvshows;
const isLivetv = (collectionType: string) =>
collectionType === CollectionType.Livetv;
const Livetv_CARD_OPTIONS = {
preferThumb: true,
inheritThumb: false,
showParentTitleOrTitle: true,
showTitle: false,
coverImage: true,
overlayMoreButton: true,
showAirTime: true,
showAirDateTime: true,
showChannelName: true
};
export interface Section {
title: string
items: BaseItemDto[];
cardOptions?: CardOptions;
}
export const useSearchItems = (
parentId?: string,
collectionType?: string,
searchTerm?: string
) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['SearchItems', { parentId, collectionType, searchTerm }],
queryFn: async ({ signal }) => {
if (!api) throw new Error('No API instance available');
if (!userId) throw new Error('No User ID provided');
const sections: Section[] = [];
const addSection = (
title: string,
items: BaseItemDto[] | null | undefined,
cardOptions?: CardOptions
) => {
if (items && items?.length > 0) {
sections.push({ title, items, cardOptions });
}
};
// Livetv libraries
if (collectionType && isLivetv(collectionType)) {
// Movies row
const moviesData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
isMovie: true,
searchTerm: searchTerm
},
{ signal }
);
addSection('Movies', moviesData.Items, {
...Livetv_CARD_OPTIONS,
shape: CardShape.PortraitOverflow
});
// Episodes row
const episodesData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
isMovie: false,
isSeries: true,
isSports: false,
isKids: false,
isNews: false,
searchTerm: searchTerm
},
{ signal }
);
addSection('Episodes', episodesData.Items, {
...Livetv_CARD_OPTIONS
});
// Sports row
const sportsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
isSports: true,
searchTerm: searchTerm
},
{ signal }
);
addSection('Sports', sportsData.Items, {
...Livetv_CARD_OPTIONS
});
// Kids row
const kidsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
isKids: true,
searchTerm: searchTerm
},
{ signal }
);
addSection('Kids', kidsData.Items, {
...Livetv_CARD_OPTIONS
});
// News row
const newsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
isNews: true,
searchTerm: searchTerm
},
{ signal }
);
addSection('News', newsData.Items, {
...Livetv_CARD_OPTIONS
});
// Programs row
const programsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
isMovie: false,
isSeries: false,
isSports: false,
isKids: false,
isNews: false,
searchTerm: searchTerm
},
{ signal }
);
addSection('Programs', programsData.Items, {
...Livetv_CARD_OPTIONS
});
// Channels row
const channelsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.TvChannel],
searchTerm: searchTerm
},
{ signal }
);
addSection('Channels', channelsData.Items);
}
// Movie libraries
if (!collectionType || isMovies(collectionType)) {
// Movies row
const moviesData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Movie],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Movies', moviesData.Items, {
showYear: true
});
}
// TV Show libraries
if (!collectionType || isTVShows(collectionType)) {
// Shows row
const showsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Series],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Shows', showsData.Items, {
showYear: true
});
// Episodes row
const episodesData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Episode],
parentId: parentId,
isMissing: user?.Configuration?.DisplayMissingEpisodes,
searchTerm: searchTerm
},
{ signal }
);
addSection('Episodes', episodesData.Items, {
coverImage: true,
showParentTitle: true
});
}
// People are included for Movies and TV Shows
if (
!collectionType
|| isMovies(collectionType)
|| isTVShows(collectionType)
) {
// People row
const peopleData = await fetchPeople(
api,
userId,
{
searchTerm: searchTerm
},
{ signal }
);
addSection('People', peopleData.Items, {
coverImage: true
});
}
// Music libraries
if (!collectionType || isMusic(collectionType)) {
// Playlists row
const playlistsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Playlist],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Playlists', playlistsData.Items);
// Artists row
const artistsData = await fetchArtists(
api,
userId,
{
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Artists', artistsData.Items, {
coverImage: true
});
// Albums row
const albumsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.MusicAlbum],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Albums', albumsData.Items, {
showYear: true
});
// Songs row
const songsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Audio],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Songs', songsData.Items, {
showParentTitle: true,
shape: CardShape.SquareOverflow
});
}
// Other libraries do not support in-library search currently
if (!collectionType) {
// Videos row
const videosData = await fetchItemsByType(
api,
userId,
{
mediaTypes: [MediaType.Video],
excludeItemTypes: [
BaseItemKind.Movie,
BaseItemKind.Episode,
BaseItemKind.TvChannel
],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Videos', videosData.Items, {
showParentTitle: true
});
// Programs row
const programsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.LiveTvProgram],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Programs', programsData.Items, {
...Livetv_CARD_OPTIONS
});
// Channels row
const channelsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.TvChannel],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Channels', channelsData.Items);
// Photo Albums row
const photoAlbumsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.PhotoAlbum],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('PhotoAlbums', photoAlbumsData.Items);
// Photos row
const photosData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Photo],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Photos', photosData.Items);
// Audio Books row
const audioBooksData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.AudioBook],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('AudioBooks', audioBooksData.Items);
// Books row
const booksData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.Book],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Books', booksData.Items);
// Collections row
const collectionsData = await fetchItemsByType(
api,
userId,
{
includeItemTypes: [BaseItemKind.BoxSet],
parentId: parentId,
searchTerm: searchTerm
},
{ signal }
);
addSection('Collections', collectionsData.Items);
}
return sections;
},
enabled: !!api && !!userId
});
};

View file

@ -0,0 +1,49 @@
import type { AxiosRequestConfig } from 'axios';
import type { Api } from '@jellyfin/sdk';
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from '../useApi';
const fetchGetItems = async (
api?: Api,
userId?: string,
parentId?: string,
options?: AxiosRequestConfig
) => {
if (!api) throw new Error('No API instance available');
if (!userId) throw new Error('No User ID provided');
const response = await getItemsApi(api).getItems(
{
userId: userId,
sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random],
includeItemTypes: [
BaseItemKind.Movie,
BaseItemKind.Series,
BaseItemKind.MusicArtist
],
limit: 20,
recursive: true,
imageTypeLimit: 0,
enableImages: false,
parentId: parentId,
enableTotalRecordCount: false
},
options
);
return response.data.Items || [];
};
export const useSearchSuggestions = (parentId?: string) => {
const { api, user } = useApi();
const userId = user?.Id;
return useQuery({
queryKey: ['SearchSuggestions', { parentId }],
queryFn: ({ signal }) =>
fetchGetItems(api, userId, parentId, { signal }),
enabled: !!api && !!userId
});
};

View file

@ -59,6 +59,7 @@
"AsManyAsPossible": "As many as possible", "AsManyAsPossible": "As many as possible",
"AspectRatio": "Aspect Ratio", "AspectRatio": "Aspect Ratio",
"Audio": "Audio", "Audio": "Audio",
"AudioBooks": "Audio Books",
"Author": "Author", "Author": "Author",
"Authorize": "Authorize", "Authorize": "Authorize",
"AuthProviderHelp": "Select an authentication provider to be used to authenticate this user's password.", "AuthProviderHelp": "Select an authentication provider to be used to authenticate this user's password.",
@ -359,7 +360,6 @@
"HeaderApiKeysHelp": "External applications are required to have an API key in order to communicate with the server. Keys are issued by logging in with a normal user account or manually granting the application a key.", "HeaderApiKeysHelp": "External applications are required to have an API key in order to communicate with the server. Keys are issued by logging in with a normal user account or manually granting the application a key.",
"HeaderApp": "App", "HeaderApp": "App",
"HeaderAppearsOn": "Appears On", "HeaderAppearsOn": "Appears On",
"HeaderAudioBooks": "Audio Books",
"HeaderAudioSettings": "Audio Settings", "HeaderAudioSettings": "Audio Settings",
"HeaderAutoDiscovery": "Network Discovery", "HeaderAutoDiscovery": "Network Discovery",
"HeaderBlockItemsWithNoRating": "Block items with no or unrecognized rating information", "HeaderBlockItemsWithNoRating": "Block items with no or unrecognized rating information",
@ -452,7 +452,6 @@
"HeaderPasswordReset": "Password Reset", "HeaderPasswordReset": "Password Reset",
"HeaderPaths": "Paths", "HeaderPaths": "Paths",
"HeaderPerformance": "Performance", "HeaderPerformance": "Performance",
"HeaderPhotoAlbums": "Photo Albums",
"HeaderPlayAll": "Play All", "HeaderPlayAll": "Play All",
"HeaderPlayback": "Media playback", "HeaderPlayback": "Media playback",
"HeaderPlaybackError": "Playback Error", "HeaderPlaybackError": "Playback Error",
@ -515,7 +514,6 @@
"HeaderUsers": "Users", "HeaderUsers": "Users",
"HeaderVideoAdvanced": "Video Advanced", "HeaderVideoAdvanced": "Video Advanced",
"HeaderVideoQuality": "Video Quality", "HeaderVideoQuality": "Video Quality",
"HeaderVideos": "Videos",
"HeaderVideoType": "Video Type", "HeaderVideoType": "Video Type",
"HeaderVideoTypes": "Video Types", "HeaderVideoTypes": "Video Types",
"HeaderYears": "Years", "HeaderYears": "Years",
@ -1255,6 +1253,7 @@
"PersonRole": "as {0}", "PersonRole": "as {0}",
"Photo": "Photo", "Photo": "Photo",
"Photos": "Photos", "Photos": "Photos",
"PhotoAlbums": "Photo Albums",
"PictureInPicture": "Picture in picture", "PictureInPicture": "Picture in picture",
"PlaceFavoriteChannelsAtBeginning": "Place favorite channels at the beginning", "PlaceFavoriteChannelsAtBeginning": "Place favorite channels at the beginning",
"Play": "Play", "Play": "Play",
@ -1552,6 +1551,7 @@
"ValueTimeLimitSingleHour": "Time limit: 1 hour", "ValueTimeLimitSingleHour": "Time limit: 1 hour",
"Vertical": "Vertical", "Vertical": "Vertical",
"Video": "Video", "Video": "Video",
"Videos": "Videos",
"VideoAudio": "Video Audio", "VideoAudio": "Video Audio",
"ViewAlbum": "View album", "ViewAlbum": "View album",
"ViewAlbumArtist": "View album artist", "ViewAlbumArtist": "View album artist",