From 2de5cc8e6c7e2657bad789d312433676337505b3 Mon Sep 17 00:00:00 2001 From: Lei Nelissen Date: Sat, 24 Apr 2021 15:30:07 +0200 Subject: [PATCH] Polish search engine UX --- src/screens/Music/stacks/Search.tsx | 116 ++++++++++++++++++++-------- src/screens/Music/types.ts | 67 +--------------- src/store/music/index.ts | 3 +- 3 files changed, 86 insertions(+), 100 deletions(-) diff --git a/src/screens/Music/stacks/Search.tsx b/src/screens/Music/stacks/Search.tsx index e562231..a4eadbf 100644 --- a/src/screens/Music/stacks/Search.tsx +++ b/src/screens/Music/stacks/Search.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; 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 { useAppDispatch, useTypedSelector } from 'store'; import Fuse from 'fuse.js'; @@ -14,9 +14,20 @@ import FastImage from 'react-native-fast-image'; import { t } from '@localisation'; import useDefaultStyles from 'components/Colors'; import { searchAndFetchAlbums } from 'store/music/actions'; +import { debounce } from 'lodash'; const Container = styled.View` 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)` @@ -69,14 +80,15 @@ export default function Search() { // Prepare state const [searchTerm, setSearchTerm] = useState(''); const albums = useTypedSelector(state => state.music.albums.entities); - const [results, setResults] = useState([]); - // const [isLoading, setLoading] = useState(false); + const [fuseResults, setFuseResults] = useState([]); + const [jellyfinResults, setJellyfinResults] = useState([]); + + const [isLoading, setLoading] = useState(false); const fuse = useRef>(); // Prepare helpers const navigation = useNavigation(); const getImage = useGetImage(); - const credentials = useTypedSelector(state => state.settings.jellyfin); const dispatch = useAppDispatch(); /** @@ -90,11 +102,53 @@ export default function Search() { fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions); }, [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 * them to state */ useEffect(() => { + if (!searchTerm) { + return; + } + 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. @@ -111,36 +165,23 @@ export default function Search() { album: undefined, name: undefined, })); - const albumIds = fuseResults.map(({ item }) => item.Id); // Assign the preliminary results - setResults(albums); - - // Then query the Jellyfin API - const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm })); - - const items = (payload as - { results: (Album | AlbumTrack)[] } - ).results.filter(item => ( - !(item.Type === 'MusicAlbum' && albumIds.includes(item.Id)) - )).map((item) => ({ - 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); + 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); + } }; retrieveResults(); - }, [searchTerm, setResults, fuse, credentials, dispatch]); + }, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]); // Handlers const selectAlbum = useCallback((id: string) => @@ -150,11 +191,17 @@ export default function Search() { const HeaderComponent = React.useMemo(() => ( - {(searchTerm.length && !results.length) + {isLoading && } + + ), [searchTerm, setSearchTerm, defaultStyles, isLoading]); + + const FooterComponent = React.useMemo(() => ( + + {(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? {t('no-results')} : null} - ), [searchTerm, results, setSearchTerm, defaultStyles]); + ), [searchTerm, jellyfinResults, fuseResults, isLoading]); // GUARD: We cannot search for stuff unless Fuse is loaded with results. // Therefore we delay rendering to when we are certain it's there. @@ -165,12 +212,14 @@ export default function Search() { return ( <> { const album = albums[trackAlbum || id]; + // GUARD: If the album cannot be found in the store, we + // cannot display it. if (!album) { - console.log('Couldnt find ', trackAlbum, id); return null; } @@ -192,6 +241,7 @@ export default function Search() { }} keyExtractor={(item) => item.id} ListHeaderComponent={HeaderComponent} + ListFooterComponent={FooterComponent} extraData={[searchTerm, albums]} /> diff --git a/src/screens/Music/types.ts b/src/screens/Music/types.ts index fd3e479..6c1a12d 100644 --- a/src/screens/Music/types.ts +++ b/src/screens/Music/types.ts @@ -1,70 +1,5 @@ import { StackNavigationProp } from '@react-navigation/stack'; - -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; -} +import { Album } from 'store/music/types'; export type StackParams = { Albums: undefined; diff --git a/src/store/music/index.ts b/src/store/music/index.ts index 4f5fcc8..f8a1ae4 100644 --- a/src/store/music/index.ts +++ b/src/store/music/index.ts @@ -77,9 +77,10 @@ const music = createSlice({ builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; }); builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; }); + builder.addCase(searchAndFetchAlbums.pending, (state) => { state.albums.isLoading = true; }); builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => { - console.log('INSERTING', payload.albums); albumAdapter.upsertMany(state.albums, payload.albums); + state.albums.isLoading = false; }); // Reset any caches we have when a new server is set