Files
jellyfin-audio-player/src/screens/Search/index.tsx

335 lines
12 KiB
TypeScript
Raw Normal View History

2022-01-01 19:09:21 +01:00
import React, { useState, useEffect, useRef, useCallback } from 'react';
2020-07-05 23:15:30 +02:00
import Input from 'components/Input';
2022-05-17 23:05:17 +02:00
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native';
2020-07-05 23:15:30 +02:00
import styled from 'styled-components/native';
2022-01-02 19:29:20 +01:00
import { useTypedSelector } from 'store';
2020-07-05 23:15:30 +02:00
import Fuse from 'fuse.js';
2021-04-24 14:50:43 +02:00
import { Album, AlbumTrack } from 'store/music/types';
2020-07-06 16:40:30 +02:00
import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from 'components/TouchableHandler';
import { useNavigation } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi';
import FastImage from 'react-native-fast-image';
2020-11-02 22:50:00 +01:00
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
2021-04-24 14:50:43 +02:00
import { searchAndFetchAlbums } from 'store/music/actions';
2021-04-24 15:30:07 +02:00
import { debounce } from 'lodash';
2022-01-02 19:29:20 +01:00
import { useDispatch } from 'react-redux';
2022-05-11 23:57:30 +02:00
import { Text } from 'components/Typography';
2022-05-16 22:17:00 +02:00
import { MusicNavigationProp } from 'screens/Music/types';
2022-05-11 23:57:30 +02:00
import DownloadIcon from 'components/DownloadIcon';
import ChevronRight from 'assets/icons/chevron-right.svg';
import SearchIcon from 'assets/icons/magnifying-glass.svg';
2022-05-16 22:17:00 +02:00
import { ShadowWrapper } from 'components/Shadow';
2022-05-17 23:05:17 +02:00
import { useKeyboardHeight } from 'utility/useKeyboardHeight';
2022-05-16 22:17:00 +02:00
// import MicrophoneIcon from 'assets/icons/microphone.svg';
// import AlbumIcon from 'assets/icons/collection.svg';
// import TrackIcon from 'assets/icons/note.svg';
// import PlaylistIcon from 'assets/icons/note-list.svg';
// import StreamIcon from 'assets/icons/cloud.svg';
// import LocalIcon from 'assets/icons/internal-drive.svg';
// import SelectableFilter from './components/SelectableFilter';
2020-07-05 23:15:30 +02:00
2022-05-17 23:05:17 +02:00
const Container = styled(Animated.View)`
2022-05-16 22:17:00 +02:00
padding: 4px 32px 0 32px;
margin-bottom: 0px;
padding-bottom: 0px;
2022-05-17 23:05:17 +02:00
border-top-width: 0.5px;
2021-04-24 15:30:07 +02:00
`;
2022-05-16 22:17:00 +02:00
const FullSizeContainer = styled.View`
flex: 1;
`;
2021-04-24 15:30:07 +02:00
const Loading = styled.View`
position: absolute;
2022-05-16 22:17:00 +02:00
right: 12px;
2021-04-24 15:30:07 +02:00
top: 0;
height: 100%;
flex: 1;
justify-content: center;
2020-07-05 23:15:30 +02:00
`;
2020-07-06 16:40:30 +02:00
const AlbumImage = styled(FastImage)`
border-radius: 4px;
2022-05-11 23:57:30 +02:00
width: 32px;
height: 32px;
2020-07-06 16:40:30 +02:00
margin-right: 10px;
`;
const HalfOpacity = styled.Text`
opacity: 0.5;
margin-top: 2px;
font-size: 12px;
2022-05-11 23:57:30 +02:00
flex: 1 1 auto;
2020-07-06 16:40:30 +02:00
`;
const SearchResult = styled.View`
flex-direction: row;
align-items: center;
2022-05-11 23:57:30 +02:00
padding: 8px 32px;
height: 54px;
2020-07-06 16:40:30 +02:00
`;
2022-05-16 22:17:00 +02:00
const SearchIndicator = styled(SearchIcon)`
position: absolute;
left: 16px;
top: 26px;
`;
const fuseOptions: Fuse.IFuseOptions<Album> = {
2020-07-06 16:40:30 +02:00
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
threshold: 0.1,
includeScore: true,
2022-05-16 22:17:00 +02:00
fieldNormWeight: 1,
2020-07-06 16:40:30 +02:00
};
2021-04-24 14:50:43 +02:00
type AudioResult = {
type: 'Audio',
id: string;
album: string;
name: string;
};
type AlbumResult = {
type: 'AlbumArtist',
id: string;
album: undefined;
name: undefined;
}
type CombinedResults = (AudioResult | AlbumResult)[];
2020-07-05 23:15:30 +02:00
export default function Search() {
const defaultStyles = useDefaultStyles();
2022-05-16 22:17:00 +02:00
// Prepare state for fuse and albums
2021-05-12 22:13:44 +02:00
const [fuseIsReady, setFuseReady] = useState(false);
2020-07-05 23:15:30 +02:00
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setLoading] = useState(false);
2021-04-24 15:30:07 +02:00
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const albums = useTypedSelector(state => state.music.albums.entities);
2021-04-24 14:50:43 +02:00
const fuse = useRef<Fuse<Album>>();
2020-07-06 16:40:30 +02:00
// Prepare helpers
2022-01-01 22:36:05 +01:00
const navigation = useNavigation<MusicNavigationProp>();
2022-05-17 23:05:17 +02:00
const keyboardHeight = useKeyboardHeight();
2020-07-06 16:40:30 +02:00
const getImage = useGetImage();
2022-01-02 19:29:20 +01:00
const dispatch = useDispatch();
2020-07-05 23:15:30 +02:00
2020-07-06 16:40:30 +02:00
/**
* Since it is impractical to have a global fuse variable, we need to
* instantiate it for thsi function. With this effect, we generate a new
* Fuse instance every time the albums change. This can of course be done
* more intelligently by removing and adding the changed albums, but this is
* an open todo.
*/
2020-07-05 23:15:30 +02:00
useEffect(() => {
2020-07-06 16:40:30 +02:00
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
2021-05-12 22:13:44 +02:00
setFuseReady(true);
}, [albums, setFuseReady]);
2020-07-05 23:15:30 +02:00
2021-04-24 15:30:07 +02:00
/**
* This function retrieves search results from Jellyfin. It is a seperate
* callback, so that we can make sure it is properly debounced and doesn't
* cause execessive jank in the interface.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
// First, query the Jellyfin API
2022-01-02 19:29:20 +01:00
// @ts-expect-error need to fix this with AppDispatch
2021-04-24 15:30:07 +02:00
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
// Convert the current results to album ids
const albumIds = currentResults.map(item => item.id);
// Parse the result in correct typescript form
const results = (payload as { results: (Album | AlbumTrack)[] }).results;
// Filter any results that are already displayed
const items = results.filter(item => (
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id))
// Then convert the results to proper result form
)).map((item) => ({
type: item.Type,
id: item.Id,
album: item.Type === 'Audio'
? item.AlbumId
: undefined,
name: item.Type === 'Audio'
? item.Name
: undefined,
}));
// Lastly, we'll merge the two and assign them to the state
setJellyfinResults([...items] as CombinedResults);
// Loading is now complete
setLoading(false);
}, 50), [dispatch, setJellyfinResults]);
2020-07-06 16:40:30 +02:00
/**
* Whenever the search term changes, we gather results from Fuse and assign
* them to state
*/
2020-07-05 23:15:30 +02:00
useEffect(() => {
2021-04-24 15:30:07 +02:00
if (!searchTerm) {
return;
}
2021-04-24 14:50:43 +02:00
const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since
// it is assigned via refs. In this case, we can't handle any searching.
if (!fuse.current) {
return;
}
// First set the immediate results from fuse
const fuseResults = fuse.current.search(searchTerm);
const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({
id: item.Id,
type: 'AlbumArtist',
album: undefined,
name: undefined,
}));
// Assign the preliminary results
2021-04-24 15:30:07 +02:00
setFuseResults(albums);
setLoading(true);
try {
// Wrap the call in a try/catch block so that we catch any
// network issues in search and just use local search if the
// network is unavailable
fetchJellyfinResults(searchTerm, albums);
} catch {
// Reset the loading indicator if the network fails
setLoading(false);
}
2021-04-24 14:50:43 +02:00
};
retrieveResults();
2021-04-24 15:30:07 +02:00
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
2020-07-05 23:15:30 +02:00
2020-07-06 16:40:30 +02:00
// Handlers
const selectAlbum = useCallback((id: string) =>
navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]
);
const HeaderComponent = React.useMemo(() => (
2022-05-16 22:17:00 +02:00
<View>
2022-05-17 23:05:17 +02:00
<Container style={[
defaultStyles.border,
defaultStyles.view,
{ transform: [{ translateY: keyboardHeight }]},
]}>
2022-05-16 22:17:00 +02:00
<View>
<Input
value={searchTerm}
onChangeText={setSearchTerm}
style={[defaultStyles.input, { marginBottom: 12 }]}
placeholder={t('search') + '...'}
2022-05-17 23:05:17 +02:00
icon
2022-05-16 22:17:00 +02:00
/>
<SearchIndicator width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />
{isLoading && <Loading><ActivityIndicator /></Loading>}
</View>
</Container>
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
<SelectableFilter
text="Artists"
icon={MicrophoneIcon}
active
/>
<SelectableFilter
text="Albums"
icon={AlbumIcon}
active={false}
/>
<SelectableFilter
text="Tracks"
icon={TrackIcon}
active={false}
/>
<SelectableFilter
text="Playlist"
icon={PlaylistIcon}
active={false}
/>
<SelectableFilter
text="Streaming"
icon={StreamIcon}
active={false}
/>
<SelectableFilter
text="Local Playback"
icon={LocalIcon}
active={false}
/>
</View>
</ScrollView> */}
</View>
2022-05-17 23:45:57 +02:00
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight]);
2021-04-24 15:30:07 +02:00
2020-07-06 16:40:30 +02:00
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
2021-05-12 22:13:44 +02:00
if (!fuseIsReady) {
2020-07-06 16:40:30 +02:00
return null;
}
return (
<SafeAreaView style={{ flex: 1 }}>
2021-04-24 14:50:43 +02:00
<FlatList
2022-05-16 22:17:00 +02:00
style={{ flex: 2 }}
2021-04-24 15:30:07 +02:00
data={[...jellyfinResults, ...fuseResults]}
2021-04-24 14:50:43 +02:00
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id];
2021-04-24 15:30:07 +02:00
// GUARD: If the album cannot be found in the store, we
// cannot display it.
2021-04-24 14:50:43 +02:00
if (!album) {
return null;
}
return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum}>
2022-05-11 23:57:30 +02:00
<SearchResult>
2022-05-16 22:17:00 +02:00
<ShadowWrapper>
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
2022-05-11 23:57:30 +02:00
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>
{trackName || album.Name}
2021-04-24 14:50:43 +02:00
</Text>
2022-05-11 23:57:30 +02:00
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist'
? `${t('album')}${album.AlbumArtist}`
: `${t('track')}${album.AlbumArtist}${album.Name}`
}
2021-04-24 14:50:43 +02:00
</HalfOpacity>
</View>
2022-05-11 23:57:30 +02:00
<View style={{ marginLeft: 16 }}>
<DownloadIcon trackId={id} />
</View>
<View style={{ marginLeft: 16 }}>
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
</View>
2021-04-24 14:50:43 +02:00
</SearchResult>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]}
/>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer>
) : null}
2022-05-16 22:17:00 +02:00
{HeaderComponent}
</SafeAreaView>
2020-07-05 23:15:30 +02:00
);
}