Polish search engine UX

This commit is contained in:
Lei Nelissen
2021-04-24 15:30:07 +02:00
parent 24d484ca25
commit 2de5cc8e6c
3 changed files with 86 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input'; import Input from 'components/Input';
import { Text, View } from 'react-native'; import { ActivityIndicator, Text, View } from 'react-native';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from 'store'; import { useAppDispatch, useTypedSelector } from 'store';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
@@ -14,9 +14,20 @@ import FastImage from 'react-native-fast-image';
import { t } from '@localisation'; import { t } from '@localisation';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions'; import { searchAndFetchAlbums } from 'store/music/actions';
import { debounce } from 'lodash';
const Container = styled.View` const Container = styled.View`
padding: 0 20px; padding: 0 20px;
position: relative;
`;
const Loading = styled.View`
position: absolute;
right: 32px;
top: 0;
height: 100%;
flex: 1;
justify-content: center;
`; `;
const AlbumImage = styled(FastImage)` const AlbumImage = styled(FastImage)`
@@ -69,14 +80,15 @@ export default function Search() {
// Prepare state // Prepare state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const albums = useTypedSelector(state => state.music.albums.entities); const albums = useTypedSelector(state => state.music.albums.entities);
const [results, setResults] = useState<CombinedResults>([]); const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
// const [isLoading, setLoading] = useState(false); const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const [isLoading, setLoading] = useState(false);
const fuse = useRef<Fuse<Album>>(); const fuse = useRef<Fuse<Album>>();
// Prepare helpers // Prepare helpers
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage(); const getImage = useGetImage();
const credentials = useTypedSelector(state => state.settings.jellyfin);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
/** /**
@@ -90,11 +102,53 @@ export default function Search() {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions); fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
}, [albums]); }, [albums]);
/**
* 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
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]);
/** /**
* Whenever the search term changes, we gather results from Fuse and assign * Whenever the search term changes, we gather results from Fuse and assign
* them to state * them to state
*/ */
useEffect(() => { useEffect(() => {
if (!searchTerm) {
return;
}
const retrieveResults = async () => { const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since // 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. // it is assigned via refs. In this case, we can't handle any searching.
@@ -111,36 +165,23 @@ export default function Search() {
album: undefined, album: undefined,
name: undefined, name: undefined,
})); }));
const albumIds = fuseResults.map(({ item }) => item.Id);
// Assign the preliminary results // Assign the preliminary results
setResults(albums); setFuseResults(albums);
setLoading(true);
// Then query the Jellyfin API try {
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm })); // Wrap the call in a try/catch block so that we catch any
// network issues in search and just use local search if the
const items = (payload as // network is unavailable
{ results: (Album | AlbumTrack)[] } fetchJellyfinResults(searchTerm, albums);
).results.filter(item => ( } catch {
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id)) // Reset the loading indicator if the network fails
)).map((item) => ({ setLoading(false);
type: item.Type, }
id: item.Id,
album: item.Type === 'Audio'
? item.AlbumId
: undefined,
name: item.Type === 'Audio'
? item.Name
: undefined,
}));
// Then add those to the results
// console.log(results, items);
setResults([...albums, ...items] as CombinedResults);
}; };
retrieveResults(); retrieveResults();
}, [searchTerm, setResults, fuse, credentials, dispatch]); }, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Handlers // Handlers
const selectAlbum = useCallback((id: string) => const selectAlbum = useCallback((id: string) =>
@@ -150,11 +191,17 @@ export default function Search() {
const HeaderComponent = React.useMemo(() => ( const HeaderComponent = React.useMemo(() => (
<Container> <Container>
<Input value={searchTerm} onChangeText={setSearchTerm} style={defaultStyles.input} placeholder={t('search') + '...'} /> <Input value={searchTerm} onChangeText={setSearchTerm} style={defaultStyles.input} placeholder={t('search') + '...'} />
{(searchTerm.length && !results.length) {isLoading && <Loading><ActivityIndicator /></Loading>}
</Container>
), [searchTerm, setSearchTerm, defaultStyles, isLoading]);
const FooterComponent = React.useMemo(() => (
<Container>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading)
? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text> ? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text>
: null} : null}
</Container> </Container>
), [searchTerm, results, setSearchTerm, defaultStyles]); ), [searchTerm, jellyfinResults, fuseResults, isLoading]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results. // GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there. // Therefore we delay rendering to when we are certain it's there.
@@ -165,12 +212,14 @@ export default function Search() {
return ( return (
<> <>
<FlatList <FlatList
data={results} style={{ flex: 1 }}
data={[...jellyfinResults, ...fuseResults]}
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => { renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id]; const album = albums[trackAlbum || id];
// GUARD: If the album cannot be found in the store, we
// cannot display it.
if (!album) { if (!album) {
console.log('Couldnt find ', trackAlbum, id);
return null; return null;
} }
@@ -192,6 +241,7 @@ export default function Search() {
}} }}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
ListHeaderComponent={HeaderComponent} ListHeaderComponent={HeaderComponent}
ListFooterComponent={FooterComponent}
extraData={[searchTerm, albums]} extraData={[searchTerm, albums]}
/> />
</> </>

View File

@@ -1,70 +1,5 @@
import { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
import { Album } from 'store/music/types';
export interface UserData {
PlaybackPositionTicks: number;
PlayCount: number;
IsFavorite: boolean;
Played: boolean;
Key: string;
}
export interface ArtistItem {
Name: string;
Id: string;
}
export interface AlbumArtist {
Name: string;
Id: string;
}
export interface ImageTags {
Primary: string;
}
export interface Album {
Name: string;
ServerId: string;
Id: string;
SortName: string;
RunTimeTicks: number;
ProductionYear: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
PrimaryImageAspectRatio: number;
Artists: string[];
ArtistItems: ArtistItem[];
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
DateCreated: string;
}
export interface AlbumTrack {
Name: string;
ServerId: string;
Id: string;
RunTimeTicks: number;
ProductionYear: number;
IndexNumber: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
Artists: string[];
ArtistItems: ArtistItem[];
Album: string;
AlbumId: string;
AlbumPrimaryImageTag: string;
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
}
export type StackParams = { export type StackParams = {
Albums: undefined; Albums: undefined;

View File

@@ -77,9 +77,10 @@ const music = createSlice({
builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; }); builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; });
builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; }); builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; });
builder.addCase(searchAndFetchAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => { builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => {
console.log('INSERTING', payload.albums);
albumAdapter.upsertMany(state.albums, payload.albums); albumAdapter.upsertMany(state.albums, payload.albums);
state.albums.isLoading = false;
}); });
// Reset any caches we have when a new server is set // Reset any caches we have when a new server is set