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

Merge pull request #5203 from thornbill/search-query-param

Add support for searching via a url parameter
This commit is contained in:
Bill Thornton 2024-02-21 12:00:22 -05:00 committed by GitHub
commit fbd9a46033
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 289 additions and 185 deletions

View file

@ -2,7 +2,8 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient';
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
import classNames from 'classnames';
import React, { FunctionComponent, useEffect, useState } from 'react';
import React, { type FC, useCallback, useEffect, useState } from 'react';
import { useDebounceValue } from 'usehooks-ts';
import globalize from '../../scripts/globalize';
import ServerConnections from '../ServerConnections';
@ -30,7 +31,7 @@ type LiveTVSearchResultsProps = {
/*
* React component to display search result rows for live tv library search
*/
const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => {
const LiveTVSearchResults: FC<LiveTVSearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => {
const [ movies, setMovies ] = useState<BaseItemDto[]>([]);
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]);
const [ sports, setSports ] = useState<BaseItemDto[]>([]);
@ -38,23 +39,24 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
const [ news, setNews ] = useState<BaseItemDto[]>([]);
const [ programs, setPrograms ] = useState<BaseItemDto[]>([]);
const [ channels, setChannels ] = useState<BaseItemDto[]>([]);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: debouncedQuery,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [ parentId, debouncedQuery ]);
useEffect(() => {
const getDefaultParameters = () => ({
ParentId: parentId,
searchTerm: query,
Limit: 24,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
EnableTotalRecordCount: false,
ImageTypeLimit: 1,
IncludePeople: false,
IncludeMedia: false,
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
});
const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems(
apiClient?.getCurrentUserId(),
{
@ -73,65 +75,67 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
setPrograms([]);
setChannels([]);
if (query && collectionType === CollectionType.Livetv) {
const apiClient = ServerConnections.getApiClient(serverId);
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
if (!debouncedQuery || collectionType !== CollectionType.Livetv) {
return;
}
}, [collectionType, parentId, query, serverId]);
const apiClient = ServerConnections.getApiClient(serverId);
// Movies row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: true
})
.then(result => setMovies(result.Items || []))
.catch(() => setMovies([]));
// Episodes row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: true,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setEpisodes(result.Items || []))
.catch(() => setEpisodes([]));
// Sports row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsSports: true
})
.then(result => setSports(result.Items || []))
.catch(() => setSports([]));
// Kids row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsKids: true
})
.then(result => setKids(result.Items || []))
.catch(() => setKids([]));
// News row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsNews: true
})
.then(result => setNews(result.Items || []))
.catch(() => setNews([]));
// Programs row
fetchItems(apiClient, {
IncludeItemTypes: 'LiveTvProgram',
IsMovie: false,
IsSeries: false,
IsSports: false,
IsKids: false,
IsNews: false
})
.then(result => setPrograms(result.Items || []))
.catch(() => setPrograms([]));
// Channels row
fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' })
.then(result => setChannels(result.Items || []))
.catch(() => setChannels([]));
}, [collectionType, debouncedQuery, getDefaultParameters, parentId, serverId]);
return (
<div
@ -139,7 +143,7 @@ const LiveTVSearchResults: FunctionComponent<LiveTVSearchResultsProps> = ({ serv
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !query || collectionType !== CollectionType.Livetv }
{ 'hide': !debouncedQuery || collectionType !== CollectionType.Livetv }
)}
>
<SearchResultsRow

View file

@ -1,89 +1,61 @@
import debounce from 'lodash-es/debounce';
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
import React, { type ChangeEvent, type FC, useCallback } from 'react';
import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
import Input from 'elements/emby-input/Input';
import globalize from '../../scripts/globalize';
import 'material-design-icons-iconfont';
import '../../elements/emby-input/emby-input';
import '../../styles/flexstyles.scss';
import './searchfields.scss';
import layoutManager from '../layoutManager';
import browser from '../../scripts/browser';
// There seems to be some compatibility issues here between
// React and our legacy web components, so we need to inject
// them as an html string for now =/
const createInputElement = () => ({
__html: `<input
is="emby-input"
class="searchfields-txtSearch"
type="text"
data-keyboard="true"
placeholder="${globalize.translate('Search')}"
autocomplete="off"
maxlength="40"
autofocus
/>`
});
import 'material-design-icons-iconfont';
const normalizeInput = (value = '') => value.trim();
import '../../styles/flexstyles.scss';
import './searchfields.scss';
type SearchFieldsProps = {
query: string,
onSearch?: (query: string) => void
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const SearchFields: FunctionComponent<SearchFieldsProps> = ({ onSearch = () => {} }: SearchFieldsProps) => {
const element = useRef<HTMLDivElement>(null);
const getSearchInput = () => element?.current?.querySelector<HTMLInputElement>('.searchfields-txtSearch');
const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]);
useEffect(() => {
getSearchInput()?.addEventListener('input', e => {
debouncedOnSearch(normalizeInput((e.target as HTMLInputElement).value));
});
getSearchInput()?.focus();
return () => {
debouncedOnSearch.cancel();
};
}, [debouncedOnSearch]);
const SearchFields: FC<SearchFieldsProps> = ({
onSearch = () => { /* no-op */ },
query
}: SearchFieldsProps) => {
const onAlphaPicked = useCallback((e: Event) => {
const value = (e as CustomEvent).detail.value;
const searchInput = getSearchInput();
if (!searchInput) {
console.error('Unexpected null reference');
return;
}
if (value === 'backspace') {
const currentValue = searchInput.value;
searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : '';
onSearch(query.length ? query.substring(0, query.length - 1) : '');
} else {
searchInput.value += value;
onSearch(query + value);
}
}, [ onSearch, query ]);
searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true }));
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
}, [ onSearch ]);
return (
<div
className='padded-left padded-right searchFields'
ref={element}
>
<div className='padded-left padded-right searchFields'>
<div className='searchFieldsInner flex align-items-center justify-content-center'>
<span className='searchfields-icon material-icons search' aria-hidden='true' />
<div
className='inputContainer flex-grow'
style={{ marginBottom: 0 }}
dangerouslySetInnerHTML={createInputElement()}
/>
>
<Input
id='searchTextInput'
className='searchfields-txtSearch'
type='text'
data-keyboard='true'
placeholder={globalize.translate('Search')}
autoComplete='off'
maxLength={40}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={query}
onChange={onChange}
/>
</div>
</div>
{layoutManager.tv && !browser.tv
&& <AlphaPicker onAlphaPicked={onAlphaPicked} />

View file

@ -1,8 +1,9 @@
import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import type { ApiClient } from 'jellyfin-apiclient';
import classNames from 'classnames';
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
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 ServerConnections from '../ServerConnections';
@ -30,7 +31,7 @@ const isTVShows = (collectionType: string) => collectionType === CollectionType.
/*
* React component to display search result rows for global search and non-live tv library search
*/
const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => {
const SearchResults: FC<SearchResultsProps> = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => {
const [ movies, setMovies ] = useState<BaseItemDto[]>([]);
const [ shows, setShows ] = useState<BaseItemDto[]>([]);
const [ episodes, setEpisodes ] = useState<BaseItemDto[]>([]);
@ -47,11 +48,12 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
const [ books, setBooks ] = useState<BaseItemDto[]>([]);
const [ people, setPeople ] = useState<BaseItemDto[]>([]);
const [ collections, setCollections ] = useState<BaseItemDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ debouncedQuery ] = useDebounceValue(query, 500);
const getDefaultParameters = useCallback(() => ({
ParentId: parentId,
searchTerm: query,
searchTerm: debouncedQuery,
Limit: 100,
Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
Recursive: true,
@ -62,7 +64,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
IncludeGenres: false,
IncludeStudios: false,
IncludeArtists: false
}), [parentId, query]);
}), [ parentId, debouncedQuery ]);
const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => (
apiClient?.getArtists(
@ -97,6 +99,10 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
).then(ensureNonNullItems)
), [getDefaultParameters]);
useEffect(() => {
if (query) setIsLoading(true);
}, [ query ]);
useEffect(() => {
// Reset state
setMovies([]);
@ -116,13 +122,11 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
setPeople([]);
setCollections([]);
if (!query) {
if (!debouncedQuery) {
setIsLoading(false);
return;
}
setIsLoading(true);
const apiClient = ServerConnections.getApiClient(serverId);
const fetchPromises = [];
@ -230,7 +234,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
console.error('An error occurred while fetching data:', error);
setIsLoading(false); // Set loading to false even if an error occurs
});
}, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]);
}, [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);
@ -240,7 +244,7 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
'searchResults',
'padded-bottom-page',
'padded-top',
{ 'hide': !query || collectionType === CollectionType.Livetv }
{ 'hide': !debouncedQuery || collectionType === CollectionType.Livetv }
)}
>
{isLoading ? (
@ -335,8 +339,10 @@ const SearchResults: FunctionComponent<SearchResultsProps> = ({ serverId = windo
cardOptions={{ coverImage: true }}
/>
{allEmpty && query && !isLoading && (
<div className='sorry-text'>{globalize.translate('SearchResultsEmpty', query)}</div>
{allEmpty && debouncedQuery && !isLoading && (
<div className='noItemsMessage centerMessage'>
{globalize.translate('SearchResultsEmpty', debouncedQuery)}
</div>
)}
</>
)}

View file

@ -9,14 +9,3 @@
font-size: 2em;
align-self: flex-end;
}
.sorry-text {
font-size: 2em;
text-align: center;
font-family: inherit;
width: 100%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}