import { push } from 'connected-react-router'; import { ExtendedKeyboardEvent } from 'mousetrap'; import React, { FormEvent, KeyboardEvent, SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import Autosuggest from 'react-autosuggest'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { useDebouncedCallback } from 'use-debounce'; import { Tag } from 'App/State/TagsAppState'; import Icon from 'Components/Icon'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts'; import { icons } from 'Helpers/Props'; import Movie from 'Movie/Movie'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; import translate from 'Utilities/String/translate'; import MovieSearchResult from './MovieSearchResult'; import styles from './MovieSearchInput.css'; const ADD_NEW_TYPE = 'addNew'; interface Match { key: string; refIndex: number; } interface AddNewMovieSuggestion { type: 'addNew'; title: string; } export interface SuggestedMovie extends Pick< Movie, | 'title' | 'year' | 'titleSlug' | 'sortTitle' | 'images' | 'alternateTitles' | 'tmdbId' | 'imdbId' > { firstCharacter: string; tags: Tag[]; } interface MovieSuggestion { title: string; indices: number[]; item: SuggestedMovie; matches: Match[]; refIndex: number; } interface Section { title: string; loading?: boolean; suggestions: MovieSuggestion[] | AddNewMovieSuggestion[]; } function createUnoptimizedSelector() { return createSelector( createAllMoviesSelector(), createTagsSelector(), (allMovies, allTags) => { return allMovies.map((movie): SuggestedMovie => { const { title, year, titleSlug, sortTitle, images, alternateTitles = [], tmdbId, imdbId, tags = [], } = movie; return { title, year, titleSlug, sortTitle, images, alternateTitles, tmdbId, imdbId, firstCharacter: title.charAt(0).toLowerCase(), tags: tags.reduce((acc, id) => { const matchingTag = allTags.find((tag) => tag.id === id); if (matchingTag) { acc.push(matchingTag); } return acc; }, []), }; }); } ); } function createMoviesSelector() { return createDeepEqualSelector( createUnoptimizedSelector(), (movies) => movies ); } function MovieSearchInput() { const movies = useSelector(createMoviesSelector()); const dispatch = useDispatch(); const { bindShortcut, unbindShortcut } = useKeyboardShortcuts(); const [value, setValue] = useState(''); const [requestLoading, setRequestLoading] = useState(false); const [suggestions, setSuggestions] = useState([]); const autosuggestRef = useRef(null); const inputRef = useRef(null); const worker = useRef(null); const isLoading = useRef(false); const requestValue = useRef(null); const suggestionGroups = useMemo(() => { const result: Section[] = []; if (suggestions.length || isLoading.current) { result.push({ title: translate('ExistingMovies'), loading: isLoading.current, suggestions, }); } result.push({ title: translate('AddNewMovie'), suggestions: [ { type: ADD_NEW_TYPE, title: value, }, ], }); return result; }, [suggestions, value]); const handleSuggestionsReceived = useCallback( (message: { data: { value: string; suggestions: MovieSuggestion[] } }) => { const { value, suggestions } = message.data; if (!isLoading.current) { requestValue.current = null; setRequestLoading(false); } else if (value === requestValue.current) { setSuggestions(suggestions); requestValue.current = null; setRequestLoading(false); isLoading.current = false; // setLoading(false); } else { setSuggestions(suggestions); setRequestLoading(true); const payload = { value: requestValue, movies, }; worker.current?.postMessage(payload); } }, [movies] ); const requestSuggestions = useDebouncedCallback((value: string) => { console.warn({ value }); if (!isLoading.current) { return; } requestValue.current = value; setRequestLoading(true); if (!requestLoading) { const payload = { value, movies, }; worker.current?.postMessage(payload); } }, 250); const reset = useCallback(() => { setValue(''); setSuggestions([]); // setLoading(false); isLoading.current = false; }, []); const focusInput = useCallback((event: ExtendedKeyboardEvent) => { event.preventDefault(); inputRef.current?.focus(); }, []); const getSectionSuggestions = useCallback((section: Section) => { return section.suggestions; }, []); const renderSectionTitle = useCallback((section: Section) => { return (
{section.title} {section.loading && ( )}
); }, []); const getSuggestionValue = useCallback(({ title }: { title: string }) => { return title; }, []); const renderSuggestion = useCallback( ( item: AddNewMovieSuggestion | MovieSuggestion, { query }: { query: string } ) => { if ('type' in item) { return (
{translate('SearchForQuery', { query })}
); } return ; }, [] ); const handleChange = useCallback( ( _event: FormEvent, { newValue, method, }: { newValue: string; method: 'down' | 'up' | 'escape' | 'enter' | 'click' | 'type'; } ) => { if (method === 'up' || method === 'down') { return; } setValue(newValue); }, [] ); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.shiftKey || event.altKey || event.ctrlKey) { return; } if (event.key === 'Escape') { reset(); return; } if (event.key !== 'Tab' && event.key !== 'Enter') { return; } if (!autosuggestRef.current) { return; } const { highlightedSectionIndex, highlightedSuggestionIndex } = autosuggestRef.current.state; if (!suggestions.length || highlightedSectionIndex) { dispatch( push( `${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}` ) ); inputRef.current?.blur(); reset(); return; } // If a suggestion is not selected go to the first movie, // otherwise go to the selected movie. const selectedSuggestion = highlightedSuggestionIndex == null ? suggestions[0] : suggestions[highlightedSuggestionIndex]; dispatch( push( `${window.Radarr.urlBase}/movie/${selectedSuggestion.item.titleSlug}` ) ); inputRef.current?.blur(); reset(); }, [value, suggestions, dispatch, reset] ); const handleBlur = useCallback(() => { reset(); }, [reset]); const handleSuggestionsFetchRequested = useCallback( ({ value }: { value: string }) => { isLoading.current = true; requestSuggestions(value); }, [requestSuggestions] ); const handleSuggestionsClearRequested = useCallback(() => { setSuggestions([]); isLoading.current = false; }, []); const handleSuggestionSelected = useCallback( ( _event: SyntheticEvent, { suggestion }: { suggestion: MovieSuggestion | AddNewMovieSuggestion } ) => { if ('type' in suggestion) { dispatch( push( `${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}` ) ); } else { setValue(''); dispatch( push(`${window.Radarr.urlBase}/movie/${suggestion.item.titleSlug}`) ); } }, [value, dispatch] ); const inputProps = { ref: inputRef, className: styles.input, name: 'movieSearch', value, placeholder: translate('Search'), autoComplete: 'off', spellCheck: false, onChange: handleChange, onKeyDown: handleKeyDown, onBlur: handleBlur, }; const theme = { container: styles.container, containerOpen: styles.containerOpen, suggestionsContainer: styles.movieContainer, suggestionsList: styles.list, suggestion: styles.listItem, suggestionHighlighted: styles.highlighted, }; useEffect(() => { worker.current = new Worker(new URL('./fuse.worker.ts', import.meta.url)); return () => { if (worker.current) { worker.current.terminate(); worker.current = null; } }; }, []); useEffect(() => { worker.current?.addEventListener( 'message', handleSuggestionsReceived, false ); return () => { if (worker.current) { worker.current.removeEventListener( 'message', handleSuggestionsReceived, false ); } }; }, [handleSuggestionsReceived]); useEffect(() => { bindShortcut('focusMovieSearchInput', focusInput); return () => { unbindShortcut('focusMovieSearchInput'); }; }, [bindShortcut, unbindShortcut, focusInput]); return (
); } export default MovieSearchInput;