Add track results to search queries
This commit is contained in:
@@ -38,5 +38,6 @@
|
||||
"enable-error-reporting-description": "This helps improve the app experience by sending crash and error reports to us.",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"more-info": "More Info"
|
||||
"more-info": "More Info",
|
||||
"track": "Track"
|
||||
}
|
||||
@@ -37,4 +37,5 @@ export type LocaleKeys = 'play-next'
|
||||
| 'enable-error-reporting-description'
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
| 'more-info'
|
||||
| 'more-info'
|
||||
| 'track'
|
||||
@@ -2,9 +2,9 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Input from 'components/Input';
|
||||
import { Text, View } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Album } from 'store/music/types';
|
||||
import { Album, AlbumTrack } from 'store/music/types';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import TouchableHandler from 'components/TouchableHandler';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -13,6 +13,7 @@ import { NavigationProp } from '../types';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import { searchAndFetchAlbums } from 'store/music/actions';
|
||||
|
||||
const Container = styled.View`
|
||||
padding: 0 20px;
|
||||
@@ -46,18 +47,37 @@ const fuseOptions = {
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
type AudioResult = {
|
||||
type: 'Audio',
|
||||
id: string;
|
||||
album: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AlbumResult = {
|
||||
type: 'AlbumArtist',
|
||||
id: string;
|
||||
album: undefined;
|
||||
name: undefined;
|
||||
}
|
||||
|
||||
type CombinedResults = (AudioResult | AlbumResult)[];
|
||||
|
||||
export default function Search() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
// Prepare state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const albums = useTypedSelector(state => state.music.albums.entities);
|
||||
const [results, setResults] = useState<Fuse.FuseResult<Album>[]>([]);
|
||||
const fuse = useRef<Fuse<Album, typeof fuseOptions>>();
|
||||
const [results, setResults] = useState<CombinedResults>([]);
|
||||
// const [isLoading, setLoading] = useState(false);
|
||||
const fuse = useRef<Fuse<Album>>();
|
||||
|
||||
// Prepare helpers
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const credentials = useTypedSelector(state => state.settings.jellyfin);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
/**
|
||||
* Since it is impractical to have a global fuse variable, we need to
|
||||
@@ -75,14 +95,52 @@ export default function Search() {
|
||||
* them to state
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
setResults(fuse.current.search(searchTerm));
|
||||
}, [searchTerm, setResults, fuse]);
|
||||
// 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,
|
||||
}));
|
||||
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);
|
||||
};
|
||||
|
||||
retrieveResults();
|
||||
}, [searchTerm, setResults, fuse, credentials, dispatch]);
|
||||
|
||||
// Handlers
|
||||
const selectAlbum = useCallback((id: string) =>
|
||||
@@ -92,7 +150,9 @@ export default function Search() {
|
||||
const HeaderComponent = React.useMemo(() => (
|
||||
<Container>
|
||||
<Input value={searchTerm} onChangeText={setSearchTerm} style={defaultStyles.input} placeholder={t('search') + '...'} />
|
||||
{(searchTerm.length && !results.length) ? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text> : null}
|
||||
{(searchTerm.length && !results.length)
|
||||
? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text>
|
||||
: null}
|
||||
</Container>
|
||||
), [searchTerm, results, setSearchTerm, defaultStyles]);
|
||||
|
||||
@@ -103,24 +163,37 @@ export default function Search() {
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={results}
|
||||
renderItem={({ item: { item: album } }) =>(
|
||||
<TouchableHandler id={album.Id} onPress={selectAlbum}>
|
||||
<SearchResult style={defaultStyles.border}>
|
||||
<AlbumImage source={{ uri: getImage(album.Id) }} />
|
||||
<View>
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" style={defaultStyles.text}>
|
||||
{album.Name} - {album.AlbumArtist}
|
||||
</Text>
|
||||
<HalfOpacity style={defaultStyles.text}>{t('album')}</HalfOpacity>
|
||||
</View>
|
||||
</SearchResult>
|
||||
</TouchableHandler>
|
||||
)}
|
||||
keyExtractor={(item) => item.refIndex.toString()}
|
||||
ListHeaderComponent={HeaderComponent}
|
||||
extraData={searchTerm}
|
||||
/>
|
||||
<>
|
||||
<FlatList
|
||||
data={results}
|
||||
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
|
||||
const album = albums[trackAlbum || id];
|
||||
|
||||
if (!album) {
|
||||
console.log('Couldnt find ', trackAlbum, id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableHandler id={album.Id} onPress={selectAlbum}>
|
||||
<SearchResult style={defaultStyles.border}>
|
||||
<AlbumImage source={{ uri: getImage(album.Id) }} />
|
||||
<View>
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" style={defaultStyles.text}>
|
||||
{trackName || album.Name} - {album.AlbumArtist}
|
||||
</Text>
|
||||
<HalfOpacity style={defaultStyles.text}>
|
||||
{type === 'AlbumArtist' ? t('album'): t('track')}
|
||||
</HalfOpacity>
|
||||
</View>
|
||||
</SearchResult>
|
||||
</TouchableHandler>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListHeaderComponent={HeaderComponent}
|
||||
extraData={[searchTerm, albums]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { configureStore, getDefaultMiddleware, combineReducers } from '@reduxjs/toolkit';
|
||||
import { useSelector, TypedUseSelectorHook } from 'react-redux';
|
||||
import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux';
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import { persistStore, persistReducer, PersistConfig } from 'redux-persist';
|
||||
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
|
||||
@@ -34,6 +34,7 @@ export type AppState = ReturnType<typeof reducers>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
|
||||
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
|
||||
export const persistedStore = persistStore(store);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack } from './types';
|
||||
import { AsyncThunkAPI } from '..';
|
||||
import { retrieveAlbums, retrieveAlbumTracks, retrieveRecentAlbums } from 'utility/JellyfinApi';
|
||||
import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum } from 'utility/JellyfinApi';
|
||||
|
||||
export const albumAdapter = createEntityAdapter<Album>({
|
||||
selectId: album => album.Id,
|
||||
@@ -15,7 +15,7 @@ export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI
|
||||
'/albums/all',
|
||||
async (empty, thunkAPI) => {
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveAlbums(credentials) as Promise<Album[]>;
|
||||
return retrieveAllAlbums(credentials) as Promise<Album[]>;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -44,4 +44,36 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
|
||||
const credentials = thunkAPI.getState().settings.jellyfin;
|
||||
return retrieveAlbumTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
|
||||
}
|
||||
);
|
||||
|
||||
type SearchAndFetchResults = {
|
||||
albums: Album[];
|
||||
results: (Album | AlbumTrack)[];
|
||||
};
|
||||
|
||||
export const searchAndFetchAlbums = createAsyncThunk<
|
||||
SearchAndFetchResults,
|
||||
{ term: string, limit?: number },
|
||||
AsyncThunkAPI
|
||||
>(
|
||||
'/search',
|
||||
async ({ term, limit = 24 }, thunkAPI) => {
|
||||
const state = thunkAPI.getState();
|
||||
const results = await searchItem(state.settings.jellyfin, term, limit);
|
||||
|
||||
const albums = await Promise.all(results.filter((item) => (
|
||||
!state.music.albums.ids.includes(item.Type === 'MusicAlbum' ? item.Id : item.AlbumId)
|
||||
)).map(async (item) => {
|
||||
if (item.Type === 'MusicAlbum') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return retrieveAlbum(state.settings.jellyfin, item.AlbumId);
|
||||
}));
|
||||
|
||||
return {
|
||||
albums,
|
||||
results
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums } from './actions';
|
||||
import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums, searchAndFetchAlbums } from './actions';
|
||||
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack } from './types';
|
||||
import { setJellyfinCredentials } from 'store/settings/actions';
|
||||
@@ -77,6 +77,11 @@ const music = createSlice({
|
||||
builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; });
|
||||
builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; });
|
||||
|
||||
builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => {
|
||||
console.log('INSERTING', payload.albums);
|
||||
albumAdapter.upsertMany(state.albums, payload.albums);
|
||||
});
|
||||
|
||||
// Reset any caches we have when a new server is set
|
||||
builder.addCase(setJellyfinCredentials, () => initialState);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface Album {
|
||||
RunTimeTicks: number;
|
||||
ProductionYear: number;
|
||||
IsFolder: boolean;
|
||||
Type: string;
|
||||
Type: 'MusicAlbum';
|
||||
UserData: UserData;
|
||||
PrimaryImageAspectRatio: number;
|
||||
Artists: string[];
|
||||
@@ -53,7 +53,7 @@ export interface AlbumTrack {
|
||||
ProductionYear: number;
|
||||
IndexNumber: number;
|
||||
IsFolder: boolean;
|
||||
Type: string;
|
||||
Type: 'Audio';
|
||||
UserData: UserData;
|
||||
Artists: string[];
|
||||
ArtistItems: ArtistItem[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Track } from 'react-native-track-player';
|
||||
import { AppState, useTypedSelector } from 'store';
|
||||
import { AlbumTrack } from 'store/music/types';
|
||||
import { Album, AlbumTrack } from 'store/music/types';
|
||||
|
||||
type Credentials = AppState['settings']['jellyfin'];
|
||||
|
||||
@@ -70,7 +70,7 @@ const albumParams = new URLSearchParams(albumOptions).toString();
|
||||
/**
|
||||
* Retrieve all albums that are available on the Jellyfin server
|
||||
*/
|
||||
export async function retrieveAlbums(credentials: Credentials) {
|
||||
export async function retrieveAllAlbums(credentials: Credentials) {
|
||||
const config = generateConfig(credentials);
|
||||
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${albumParams}`, config)
|
||||
.then(response => response.json());
|
||||
@@ -78,6 +78,15 @@ export async function retrieveAlbums(credentials: Credentials) {
|
||||
return albums.Items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single album
|
||||
*/
|
||||
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
|
||||
const config = generateConfig(credentials);
|
||||
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
|
||||
.then(response => response.json());
|
||||
}
|
||||
|
||||
const latestAlbumsOptions = {
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Fields: 'DateCreated',
|
||||
@@ -122,11 +131,66 @@ export async function retrieveAlbumTracks(ItemId: string, credentials: Credentia
|
||||
return album.Items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an image URL for a given ItemId
|
||||
*/
|
||||
export function getImage(ItemId: string, credentials: Credentials): string {
|
||||
return encodeURI(`${credentials?.uri}/Items/${ItemId}/Images/Primary?format=jpeg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hook that can convert ItemIds to image URLs
|
||||
*/
|
||||
export function useGetImage() {
|
||||
const credentials = useTypedSelector((state) => state.settings.jellyfin);
|
||||
return (ItemId: string) => getImage(ItemId, credentials);
|
||||
}
|
||||
}
|
||||
|
||||
const trackParams = {
|
||||
SortBy: 'AlbumArtist,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Recursive: 'true',
|
||||
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve all possible tracks that can be found in Jellyfin
|
||||
*/
|
||||
export async function retrieveAllTracks(credentials: Credentials) {
|
||||
const config = generateConfig(credentials);
|
||||
const tracks = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${trackParams}`, config)
|
||||
.then(response => response.json());
|
||||
|
||||
return tracks.Items;
|
||||
}
|
||||
|
||||
const searchParams = {
|
||||
IncludeItemTypes: 'Audio,MusicAlbum',
|
||||
SortBy: 'Album,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Recursive: 'true',
|
||||
};
|
||||
|
||||
/**
|
||||
* Remotely search the Jellyfin library for a particular search term
|
||||
*/
|
||||
export async function searchItem(
|
||||
credentials: Credentials,
|
||||
term: string, limit = 24
|
||||
): Promise<(Album | AlbumTrack)[]> {
|
||||
const config = generateConfig(credentials);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
...searchParams,
|
||||
SearchTerm: term,
|
||||
Limit: limit.toString(),
|
||||
}).toString();
|
||||
|
||||
const results = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${params}`, config)
|
||||
.then(response => response.json());
|
||||
|
||||
return results.Items;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user