Add playlists

This commit is contained in:
Lei Nelissen
2022-01-01 19:09:21 +01:00
parent 4460bdf7f9
commit 75e8ece60a
20 changed files with 529 additions and 145 deletions

View File

@@ -1,3 +1,4 @@
export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7; export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7;
export const PLAYLIST_CACHE_AMOUNT_OF_DAYS = 7;
export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ '; export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
export const THEME_COLOR = '#FF3C00'; export const THEME_COLOR = '#FF3C00';

View File

@@ -39,5 +39,10 @@
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
"more-info": "More Info", "more-info": "More Info",
"track": "Track" "track": "Track",
"playlists": "Playlists",
"playlist": "Playlist",
"play-playlist": "Play Playlist",
"shuffle-album": "Shuffle Album",
"shuffle-playlist": "Shuffle Playlist"
} }

View File

@@ -1,6 +1,6 @@
{ {
"play-next": "Speel volgende", "play-next": "Speel volgende",
"play-album": "Speel album", "play-album": "Speel Album",
"queue": "Wachtrij", "queue": "Wachtrij",
"add-to-queue": "Voeg toe aan wachtrij", "add-to-queue": "Voeg toe aan wachtrij",
"clear-queue": "Wis wachtrij", "clear-queue": "Wis wachtrij",

View File

@@ -38,4 +38,9 @@ export type LocaleKeys = 'play-next'
| 'enable' | 'enable'
| 'disable' | 'disable'
| 'more-info' | 'more-info'
| 'track' | 'track'
| 'playlists'
| 'playlist'
| 'play-playlist'
| 'shuffle-album'
| 'shuffle-playlist'

View File

@@ -8,6 +8,8 @@ import Search from './stacks/Search';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import { t } from '@localisation'; import { t } from '@localisation';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import Playlists from './stacks/Playlists';
import Playlist from './stacks/Playlist';
const Stack = createStackNavigator<StackParams>(); const Stack = createStackNavigator<StackParams>();
@@ -22,6 +24,8 @@ function MusicStack() {
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} /> <Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} />
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} /> <Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} /> <Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
<Stack.Screen name="Playlist" component={Playlist} options={{ headerTitle: t('playlist') }} />
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search') }} /> <Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search') }} />
</Stack.Navigator> </Stack.Navigator>
); );

View File

@@ -1,133 +1,43 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { StackParams } from '../types'; import { StackParams } from '../types';
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native'; import { useRoute, RouteProp } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi'; import { useAppDispatch, useTypedSelector } from 'store';
import styled, { css } from 'styled-components/native'; import TrackListView from './components/TrackListView';
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
import FastImage from 'react-native-fast-image';
import { useDispatch } from 'react-redux';
import { differenceInDays } from 'date-fns';
import { useTypedSelector } from 'store';
import { fetchTracksByAlbum } from 'store/music/actions'; import { fetchTracksByAlbum } from 'store/music/actions';
import { ALBUM_CACHE_AMOUNT_OF_DAYS, THEME_COLOR } from 'CONSTANTS'; import { differenceInDays } from 'date-fns';
import usePlayAlbum from 'utility/usePlayAlbum'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import TouchableHandler from 'components/TouchableHandler';
import useCurrentTrack from 'utility/useCurrentTrack';
import TrackPlayer from 'react-native-track-player';
import { t } from '@localisation'; import { t } from '@localisation';
import Button from 'components/Button';
import Play from 'assets/play.svg';
import useDefaultStyles from 'components/Colors';
type Route = RouteProp<StackParams, 'Album'>; type Route = RouteProp<StackParams, 'Album'>;
const Screen = Dimensions.get('screen');
const styles = StyleSheet.create({
name: {
fontSize: 36,
fontWeight: 'bold'
},
artist: {
fontSize: 24,
opacity: 0.5,
marginBottom: 24
},
index: {
width: 20,
opacity: 0.5,
marginRight: 5
}
});
const AlbumImage = styled(FastImage)`
border-radius: 10px;
width: ${Screen.width * 0.6}px;
height: ${Screen.width * 0.6}px;
margin: 10px auto;
`;
const TrackContainer = styled.View<{isPlaying: boolean}>`
padding: 15px;
border-bottom-width: 1px;
flex-direction: row;
${props => props.isPlaying && css`
background-color: ${THEME_COLOR}16;
margin: 0 -20px;
padding: 15px 35px;
`}
`;
const Album: React.FC = () => { const Album: React.FC = () => {
const defaultStyles = useDefaultStyles();
// Retrieve state
const { params: { id } } = useRoute<Route>(); const { params: { id } } = useRoute<Route>();
const tracks = useTypedSelector((state) => state.music.tracks.entities); const dispatch = useAppDispatch();
// Retrieve the album data from the store
const album = useTypedSelector((state) => state.music.albums.entities[id]); const album = useTypedSelector((state) => state.music.albums.entities[id]);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading); const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
// Retrieve helpers // Define a function for refreshing this entity
const dispatch = useDispatch(); const refresh = useCallback(() => dispatch(fetchTracksByAlbum(id)), [id, dispatch]);
const getImage = useGetImage();
const playAlbum = usePlayAlbum();
const { track: currentTrack } = useCurrentTrack();
const navigation = useNavigation();
// Setup callbacks // Auto-fetch the track data periodically
const selectAlbum = useCallback(() => { playAlbum(id); }, [playAlbum, id]);
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
const selectTrack = useCallback(async (index: number) => {
await playAlbum(id, false);
await TrackPlayer.skip(index);
await TrackPlayer.play();
}, [playAlbum, id]);
const longPressTrack = useCallback((index: number) => {
navigation.navigate('TrackPopupMenu', { trackId: album?.Tracks?.[index] });
}, [navigation, album]);
// Retrieve album tracks on load
useEffect(() => { useEffect(() => {
if (!album?.lastRefreshed || differenceInDays(album?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) { if (!album?.lastRefreshed || differenceInDays(album?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
refresh(); refresh();
} }
}, [album?.lastRefreshed, refresh]); }, [album?.lastRefreshed, refresh]);
// GUARD: If there is no album, we cannot render a thing
if (!album) {
return null;
}
return ( return (
<ScrollView <TrackListView
contentContainerStyle={{ padding: 20, paddingBottom: 50 }} trackIds={albumTracks || []}
refreshControl={ title={album?.Name}
<RefreshControl refreshing={isLoading} onRefresh={refresh} /> artist={album?.AlbumArtist}
} entityId={id}
> refresh={refresh}
<AlbumImage source={{ uri: getImage(album?.Id) }} style={defaultStyles.imageBackground} /> playButtonText={t('play-album')}
<Text style={[ defaultStyles.text, styles.name ]} >{album?.Name}</Text> shuffleButtonText={t('shuffle-album')}
<Text style={[ defaultStyles.text, styles.artist ]}>{album?.AlbumArtist}</Text> />
<Button title={t('play-album')} icon={Play} onPress={selectAlbum} />
<View style={{ marginTop: 15 }}>
{album?.Tracks?.length ? album.Tracks.map((trackId, i) =>
<TouchableHandler
key={trackId}
id={i}
onPress={selectTrack}
onLongPress={longPressTrack}
>
<TrackContainer isPlaying={currentTrack?.backendId === trackId || false} style={defaultStyles.border}>
<Text style={[ defaultStyles.text, styles.index ]}>
{tracks[trackId]?.IndexNumber}
</Text>
<Text style={defaultStyles.text}>{tracks[trackId]?.Name}</Text>
</TrackContainer>
</TouchableHandler>
) : undefined}
</View>
</ScrollView>
); );
}; };

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react'; import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi'; import { useGetImage } from 'utility/JellyfinApi';
import { Album, NavigationProp } from '../types'; import { NavigationProp } from '../types';
import { Text, SafeAreaView, SectionList, View } from 'react-native'; import { Text, SafeAreaView, SectionList, View } from 'react-native';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -15,6 +15,7 @@ import AlphabetScroller from 'components/AlphabetScroller';
import { EntityId } from '@reduxjs/toolkit'; import { EntityId } from '@reduxjs/toolkit';
import styled from 'styled-components/native'; import styled from 'styled-components/native';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import { Album } from 'store/music/types';
interface VirtualizedItemInfo { interface VirtualizedItemInfo {
section: SectionedId, section: SectionedId,
@@ -92,7 +93,7 @@ const Albums: React.FC = () => {
// Retrieve data from store // Retrieve data from store
const { entities: albums } = useTypedSelector((state) => state.music.albums); const { entities: albums } = useTypedSelector((state) => state.music.albums);
const isLoading = useTypedSelector((state) => state.music.albums.isLoading); const isLoading = useTypedSelector((state) => state.music.albums.isLoading);
const lastRefreshed = useTypedSelector((state) => state.music.lastRefreshed); const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed);
const sections = useTypedSelector(selectAlbumsByAlphabet); const sections = useTypedSelector(selectAlbumsByAlphabet);
// Initialise helpers // Initialise helpers

View File

@@ -0,0 +1,44 @@
import React, { useCallback, useEffect } from 'react';
import { StackParams } from '../types';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store';
import TrackListView from './components/TrackListView';
import { fetchTracksByPlaylist } from 'store/music/actions';
import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation';
type Route = RouteProp<StackParams, 'Album'>;
const Playlist: React.FC = () => {
const { params: { id } } = useRoute<Route>();
const dispatch = useAppDispatch();
// Retrieve the album data from the store
const playlist = useTypedSelector((state) => state.music.playlists.entities[id]);
const playlistTracks = useTypedSelector((state) => state.music.tracks.byPlaylist[id]);
// Define a function for refreshing this entity
const refresh = useCallback(() => dispatch(fetchTracksByPlaylist(id)), [dispatch, id]);
// Auto-fetch the track data periodically
useEffect(() => {
if (!playlist?.lastRefreshed || differenceInDays(playlist?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
refresh();
}
}, [playlist?.lastRefreshed, refresh]);
return (
<TrackListView
trackIds={playlistTracks || []}
title={playlist?.Name}
entityId={id}
refresh={refresh}
listNumberingStyle='index'
playButtonText={t('play-playlist')}
shuffleButtonText={t('shuffle-playlist')}
/>
);
};
export default Playlist;

View File

@@ -0,0 +1,111 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { NavigationProp } from '../types';
import { Text, SafeAreaView, View, FlatList, ListRenderItem } from 'react-native';
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { differenceInDays } from 'date-fns';
import { useTypedSelector } from 'store';
import { fetchAllPlaylists } from 'store/music/actions';
import { PLAYLIST_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import TouchableHandler from 'components/TouchableHandler';
import AlbumImage, { AlbumItem } from './components/AlbumImage';
import { EntityId } from '@reduxjs/toolkit';
import useDefaultStyles from 'components/Colors';
import { Playlist } from 'store/music/types';
interface GeneratedAlbumItemProps {
id: ReactText;
imageUrl: string;
name: string;
onPress: (id: string) => void;
}
const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: GeneratedAlbumItemProps) {
const defaultStyles = useDefaultStyles();
const { id, imageUrl, name, onPress } = props;
return (
<TouchableHandler id={id as string} onPress={onPress}>
<AlbumItem>
<AlbumImage source={{ uri: imageUrl }} style={defaultStyles.imageBackground} />
<Text numberOfLines={1} style={defaultStyles.text}>{name}</Text>
</AlbumItem>
</TouchableHandler>
);
});
const Playlists: React.FC = () => {
// Retrieve data from store
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
const lastRefreshed = useTypedSelector((state) => state.music.playlists.lastRefreshed);
// Initialise helpers
const dispatch = useDispatch();
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const listRef = useRef<FlatList<EntityId>>(null);
const getItemLayout = useCallback((data: EntityId[] | null | undefined, index: number): { offset: number, length: number, index: number } => {
const length = 220;
const offset = length * index;
return { index, length, offset };
}, []);
// Set callbacks
const retrieveData = useCallback(() => dispatch(fetchAllPlaylists()), [dispatch]);
const selectAlbum = useCallback((id: string) => navigation.navigate('Playlist', { id, playlist: entities[id] as Playlist }), [navigation, entities]);
const generateItem: ListRenderItem<EntityId> = useCallback(({ item, index }) => {
if (index % 2 === 1) {
return <View key={item} />;
}
const nextItemId = ids[index + 1];
const nextItem = entities[nextItemId];
return (
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item}>
<GeneratedPlaylistItem
id={item}
imageUrl={getImage(item as string)}
name={entities[item]?.Name || ''}
onPress={selectAlbum}
/>
{nextItem &&
<GeneratedPlaylistItem
id={nextItemId}
imageUrl={getImage(nextItemId as string)}
name={nextItem.Name || ''}
onPress={selectAlbum}
/>
}
</View>
);
}, [entities, getImage, selectAlbum, ids]);
// Retrieve data on mount
useEffect(() => {
// GUARD: Only refresh this API call every set amounts of days
if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > PLAYLIST_CACHE_AMOUNT_OF_DAYS) {
retrieveData();
}
});
return (
<SafeAreaView>
<FlatList
data={ids}
refreshing={isLoading}
onRefresh={retrieveData}
getItemLayout={getItemLayout}
ref={listRef}
keyExtractor={(item, index) => `${item}_${index}`}
renderItem={generateItem}
/>
</SafeAreaView>
);
};
export default Playlists;

View File

@@ -27,11 +27,13 @@ const NavigationHeader: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]); const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
const handleSearchClick = useCallback(() => { navigation.navigate('Search'); }, [navigation]); const handleSearchClick = useCallback(() => { navigation.navigate('Search'); }, [navigation]);
return ( return (
<> <>
<ListButton onPress={handleAllAlbumsClick}>{t('all-albums')}</ListButton> <ListButton onPress={handleAllAlbumsClick}>{t('all-albums')}</ListButton>
<ListButton onPress={handlePlaylistsClick}>{t('playlists')}</ListButton>
<ListButton onPress={handleSearchClick}>{t('search')}</ListButton> <ListButton onPress={handleSearchClick}>{t('search')}</ListButton>
<ListContainer> <ListContainer>
<Header style={defaultStyles.text}>{t('recent-albums')}</Header> <Header style={defaultStyles.text}>{t('recent-albums')}</Header>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, Ref } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input'; import Input from 'components/Input';
import { ActivityIndicator, Text, TextInput, View } from 'react-native'; import { ActivityIndicator, Text, TextInput, View } from 'react-native';
import styled from 'styled-components/native'; import styled from 'styled-components/native';

View File

@@ -0,0 +1,154 @@
import React, { useCallback } from 'react';
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native';
import { useNavigation } from '@react-navigation/native';
import FastImage from 'react-native-fast-image';
import { useTypedSelector } from 'store';
import { THEME_COLOR } from 'CONSTANTS';
import TouchableHandler from 'components/TouchableHandler';
import useCurrentTrack from 'utility/useCurrentTrack';
import TrackPlayer from 'react-native-track-player';
import Button from 'components/Button';
import Play from 'assets/play.svg';
import Shuffle from 'assets/shuffle.svg';
import useDefaultStyles from 'components/Colors';
import usePlayTracks from 'utility/usePlayTracks';
import { EntityId } from '@reduxjs/toolkit';
const Screen = Dimensions.get('screen');
const styles = StyleSheet.create({
name: {
fontSize: 36,
fontWeight: 'bold'
},
artist: {
fontSize: 24,
opacity: 0.5,
marginBottom: 24
},
index: {
width: 20,
opacity: 0.5,
marginRight: 5
}
});
const AlbumImage = styled(FastImage)`
border-radius: 10px;
width: ${Screen.width * 0.6}px;
height: ${Screen.width * 0.6}px;
margin: 10px auto;
`;
const TrackContainer = styled.View<{isPlaying: boolean}>`
padding: 15px;
border-bottom-width: 1px;
flex-direction: row;
${props => props.isPlaying && css`
background-color: ${THEME_COLOR}16;
margin: 0 -20px;
padding: 15px 35px;
`}
`;
interface TrackListViewProps {
title?: string;
artist?: string;
trackIds: EntityId[];
entityId: string;
refresh: () => void;
playButtonText: string;
shuffleButtonText: string;
listNumberingStyle?: 'album' | 'index';
}
const TrackListView: React.FC<TrackListViewProps> = ({
trackIds,
entityId,
title,
artist,
refresh,
playButtonText,
shuffleButtonText,
listNumberingStyle = 'album',
}) => {
const defaultStyles = useDefaultStyles();
// Retrieve state
const tracks = useTypedSelector((state) => state.music.tracks.entities);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
// Retrieve helpers
const getImage = useGetImage();
const playTracks = usePlayTracks();
const { track: currentTrack } = useCurrentTrack();
const navigation = useNavigation();
// Setup callbacks
const playEntity = useCallback(() => { playTracks(trackIds); }, [playTracks, trackIds]);
const shuffleEntity = useCallback(() => { playTracks(trackIds, true, true); }, [playTracks, trackIds]);
const selectTrack = useCallback(async (index: number) => {
await playTracks(trackIds, false);
await TrackPlayer.skip(index);
await TrackPlayer.play();
}, [playTracks, trackIds]);
const longPressTrack = useCallback((index: number) => {
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
}, [navigation, trackIds]);
return (
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 50 }}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
}
>
<AlbumImage source={{ uri: getImage(entityId) }} style={defaultStyles.imageBackground} />
<Text style={[ defaultStyles.text, styles.name ]} >{title}</Text>
<Text style={[ defaultStyles.text, styles.artist ]}>{artist}</Text>
<Button title={playButtonText} icon={Play} onPress={playEntity} />
<View style={{ height: 4 }}></View>
<Button title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} />
<View style={{ marginTop: 15 }}>
{trackIds.map((trackId, i) =>
<TouchableHandler
key={trackId}
id={i}
onPress={selectTrack}
onLongPress={longPressTrack}
>
<TrackContainer isPlaying={currentTrack?.backendId === trackId || false} style={defaultStyles.border}>
<Text
style={[
defaultStyles.text,
styles.index,
currentTrack?.backendId === trackId && {
color: THEME_COLOR,
opacity: 1
}
]}
>
{listNumberingStyle === 'index'
? i + 1
: tracks[trackId]?.IndexNumber}
</Text>
<Text
style={currentTrack?.backendId === trackId
? { color: THEME_COLOR, fontWeight: '700' }
: defaultStyles.text
}
>
{tracks[trackId]?.Name}
</Text>
</TrackContainer>
</TouchableHandler>
)}
</View>
</ScrollView>
);
};
export default TrackListView;

View File

@@ -9,6 +9,7 @@ import { t } from '@localisation';
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import Text from 'components/Text'; import Text from 'components/Text';
import Button from 'components/Button'; import Button from 'components/Button';
import { THEME_COLOR } from 'CONSTANTS';
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>` const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
padding: 10px; padding: 10px;
@@ -61,8 +62,8 @@ export default function Queue() {
currentIndex === i ? defaultStyles.activeBackground : {}, currentIndex === i ? defaultStyles.activeBackground : {},
]} ]}
> >
<Text style={styles.trackTitle}>{track.title}</Text> <Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
<Text style={defaultStyles.textHalfOpacity}>{track.artist}</Text> <Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
</QueueItem> </QueueItem>
</TouchableHandler> </TouchableHandler>
))} ))}

View File

@@ -14,7 +14,7 @@ class CredentialGenerator extends Component<Props> {
handleStateChange = () => { handleStateChange = () => {
// Call a debounced version to check if the credentials are there // Call a debounced version to check if the credentials are there
this.checkIfCredentialsAreThere(); this.checkIfCredentialsAreThere();
} };
checkIfCredentialsAreThere = debounce(() => { checkIfCredentialsAreThere = debounce(() => {
// Inject some javascript to check if the credentials can be extracted // Inject some javascript to check if the credentials can be extracted
@@ -52,7 +52,7 @@ class CredentialGenerator extends Component<Props> {
access_token: credentials.AccessToken, access_token: credentials.AccessToken,
device_id: deviceId, device_id: deviceId,
}); });
} };
render() { render() {
const { serverUrl } = this.props; const { serverUrl } = this.props;

View File

@@ -1,7 +1,7 @@
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit'; import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { Album, AlbumTrack } from './types'; import { Album, AlbumTrack, Playlist } from './types';
import { AsyncThunkAPI } from '..'; import { AsyncThunkAPI } from '..';
import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum } from 'utility/JellyfinApi'; import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum, retrieveAllPlaylists, retrievePlaylistTracks } from 'utility/JellyfinApi';
export const albumAdapter = createEntityAdapter<Album>({ export const albumAdapter = createEntityAdapter<Album>({
selectId: album => album.Id, selectId: album => album.Id,
@@ -76,4 +76,31 @@ AsyncThunkAPI
results results
}; };
} }
);
export const playlistAdapter = createEntityAdapter<Playlist>({
selectId: (playlist) => playlist.Id,
sortComparer: (a, b) => a.Name.localeCompare(b.Name),
});
/**
* Fetch all playlists available
*/
export const fetchAllPlaylists = createAsyncThunk<Playlist[], undefined, AsyncThunkAPI>(
'/playlists/all',
async (empty, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAllPlaylists(credentials) as Promise<Playlist[]>;
}
);
/**
* Retrieve all tracks from a particular playlist
*/
export const fetchTracksByPlaylist = createAsyncThunk<AlbumTrack[], string, AsyncThunkAPI>(
'/tracks/byPlaylist',
async (ItemId, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrievePlaylistTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
}
); );

View File

@@ -1,6 +1,16 @@
import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums, searchAndFetchAlbums } from './actions'; import {
fetchAllAlbums,
albumAdapter,
fetchTracksByAlbum,
trackAdapter,
fetchRecentAlbums,
searchAndFetchAlbums,
playlistAdapter,
fetchAllPlaylists,
fetchTracksByPlaylist
} from './actions';
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { Album, AlbumTrack } from './types'; import { Album, AlbumTrack, Playlist } from './types';
import { setJellyfinCredentials } from 'store/settings/actions'; import { setJellyfinCredentials } from 'store/settings/actions';
export interface State { export interface State {
@@ -8,13 +18,21 @@ export interface State {
isLoading: boolean; isLoading: boolean;
entities: Dictionary<Album>; entities: Dictionary<Album>;
ids: EntityId[]; ids: EntityId[];
lastRefreshed?: number,
}, },
tracks: { tracks: {
isLoading: boolean; isLoading: boolean;
entities: Dictionary<AlbumTrack>; entities: Dictionary<AlbumTrack>;
ids: EntityId[]; ids: EntityId[];
byAlbum: Dictionary<EntityId[]>;
byPlaylist: Dictionary<EntityId[]>;
}, },
lastRefreshed?: number, playlists: {
isLoading: boolean;
entities: Dictionary<Playlist>;
ids: EntityId[];
lastRefreshed?: number,
}
} }
const initialState: State = { const initialState: State = {
@@ -25,7 +43,13 @@ const initialState: State = {
tracks: { tracks: {
...trackAdapter.getInitialState(), ...trackAdapter.getInitialState(),
isLoading: false, isLoading: false,
byAlbum: {},
byPlaylist: {},
}, },
playlists: {
...playlistAdapter.getInitialState(),
isLoading: false,
}
}; };
const music = createSlice({ const music = createSlice({
@@ -41,7 +65,7 @@ const music = createSlice({
builder.addCase(fetchAllAlbums.fulfilled, (state, { payload }) => { builder.addCase(fetchAllAlbums.fulfilled, (state, { payload }) => {
albumAdapter.setAll(state.albums, payload); albumAdapter.setAll(state.albums, payload);
state.albums.isLoading = false; state.albums.isLoading = false;
state.lastRefreshed = new Date().getTime(); state.albums.lastRefreshed = new Date().getTime();
}); });
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; }); builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; }); builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
@@ -59,7 +83,7 @@ const music = createSlice({
/** /**
* Fetch tracks by album * Fetch tracks by album
*/ */
builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload }) => { builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload, meta }) => {
if (!payload.length) { if (!payload.length) {
return; return;
} }
@@ -67,9 +91,9 @@ const music = createSlice({
trackAdapter.upsertMany(state.tracks, payload); trackAdapter.upsertMany(state.tracks, payload);
// Also store all the track ids in the album // Also store all the track ids in the album
const album = state.albums.entities[payload[0].AlbumId]; state.tracks.byAlbum[meta.arg] = payload.map(d => d.Id);
const album = state.albums.entities[meta.arg];
if (album) { if (album) {
album.Tracks = payload.map(d => d.Id);
album.lastRefreshed = new Date().getTime(); album.lastRefreshed = new Date().getTime();
} }
state.tracks.isLoading = false; state.tracks.isLoading = false;
@@ -82,6 +106,40 @@ const music = createSlice({
albumAdapter.upsertMany(state.albums, payload.albums); albumAdapter.upsertMany(state.albums, payload.albums);
state.albums.isLoading = false; state.albums.isLoading = false;
}); });
/**
* Fetch all playlists
*/
builder.addCase(fetchAllPlaylists.fulfilled, (state, { payload }) => {
playlistAdapter.setAll(state.playlists, payload);
state.playlists.isLoading = false;
state.playlists.lastRefreshed = new Date().getTime();
});
builder.addCase(fetchAllPlaylists.pending, (state) => { state.playlists.isLoading = true; });
builder.addCase(fetchAllPlaylists.rejected, (state) => { state.playlists.isLoading = false; });
/**
* Fetch tracks by playlist
*/
builder.addCase(fetchTracksByPlaylist.fulfilled, (state, { payload, meta }) => {
if (!payload.length) {
return;
}
// Upsert the retrieved tracks
trackAdapter.upsertMany(state.tracks, payload);
// Also store all the track ids in the playlist
state.tracks.byPlaylist[meta.arg] = payload.map(d => d.Id);
state.tracks.isLoading = false;
const playlist = state.playlists.entities[meta.arg];
if (playlist) {
playlist.lastRefreshed = new Date().getTime();
}
});
builder.addCase(fetchTracksByPlaylist.pending, (state) => { state.tracks.isLoading = true; });
builder.addCase(fetchTracksByPlaylist.rejected, (state) => { state.tracks.isLoading = false; });
// Reset any caches we have when a new server is set // Reset any caches we have when a new server is set
builder.addCase(setJellyfinCredentials, () => initialState); builder.addCase(setJellyfinCredentials, () => initialState);

View File

@@ -55,7 +55,7 @@ export type SectionedId = SectionListData<EntityId>;
/** /**
* Splits a set of albums into a list that is split by alphabet letters * Splits a set of albums into a list that is split by alphabet letters
*/ */
function splitByAlphabet(state: AppState['music']['albums']): SectionedId[] { function splitAlbumsByAlphabet(state: AppState['music']['albums']): SectionedId[] {
const { entities: albums } = state; const { entities: albums } = state;
const albumIds = albumsByArtist(state); const albumIds = albumsByArtist(state);
const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] })); const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] }));
@@ -75,5 +75,5 @@ function splitByAlphabet(state: AppState['music']['albums']): SectionedId[] {
*/ */
export const selectAlbumsByAlphabet = createSelector( export const selectAlbumsByAlphabet = createSelector(
(state: AppState) => state.music.albums, (state: AppState) => state.music.albums,
splitByAlphabet, splitAlbumsByAlphabet,
); );

View File

@@ -74,4 +74,24 @@ export interface State {
entities: Dictionary<Album>; entities: Dictionary<Album>;
isLoading: boolean; isLoading: boolean;
} }
}
export interface Playlist {
Name: string;
ServerId: string;
Id: string;
CanDelete: boolean;
SortName: string;
ChannelId?: any;
RunTimeTicks: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
PrimaryImageAspectRatio: number;
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
Tracks?: string[];
lastRefreshed?: number;
} }

View File

@@ -193,4 +193,43 @@ export async function searchItem(
return results.Items; return results.Items;
} }
const playlistOptions = {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Playlist',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
ImageTypeLimit: '1',
EnableImageTypes: 'Primary,Backdrop,Banner,Thumb',
MediaTypes: 'Audio',
};
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAllPlaylists(credentials: Credentials) {
const config = generateConfig(credentials);
const playlistParams = new URLSearchParams(playlistOptions).toString();
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${playlistParams}`, config)
.then(response => response.json());
return albums.Items;
}
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrievePlaylistTracks(ItemId: string, credentials: Credentials) {
const singlePlaylistOptions = {
SortBy: 'SortName',
UserId: credentials?.user_id || '',
};
const singlePlaylistParams = new URLSearchParams(singlePlaylistOptions).toString();
const config = generateConfig(credentials);
const playlists = await fetch(`${credentials?.uri}/Playlists/${ItemId}/Items?${singlePlaylistParams}`, config)
.then(response => response.json());
return playlists.Items;
}

View File

@@ -2,28 +2,30 @@ import { useTypedSelector } from 'store';
import { useCallback } from 'react'; import { useCallback } from 'react';
import TrackPlayer, { Track } from 'react-native-track-player'; import TrackPlayer, { Track } from 'react-native-track-player';
import { generateTrack } from './JellyfinApi'; import { generateTrack } from './JellyfinApi';
import { EntityId } from '@reduxjs/toolkit';
import { shuffle as shuffleArray } from 'lodash';
/** /**
* Generate a callback function that starts playing a full album given its * Generate a callback function that starts playing a full album given its
* supplied id. * supplied id.
*/ */
export default function usePlayAlbum() { export default function usePlayTracks() {
const credentials = useTypedSelector(state => state.settings.jellyfin); const credentials = useTypedSelector(state => state.settings.jellyfin);
const albums = useTypedSelector(state => state.music.albums.entities);
const tracks = useTypedSelector(state => state.music.tracks.entities); const tracks = useTypedSelector(state => state.music.tracks.entities);
return useCallback(async function playAlbum(albumId: string, play: boolean = true): Promise<Track[] | undefined> { return useCallback(async function playTracks(
const album = albums[albumId]; trackIds: EntityId[] | undefined,
const backendTrackIds = album?.Tracks; play: boolean = true,
shuffle: boolean = false,
// GUARD: Check if the album has songs ): Promise<Track[] | undefined> {
if (!backendTrackIds?.length) { if (!trackIds) {
return; return;
} }
// Convert all backendTrackIds to the relevant format for react-native-track-player // Convert all trackIds to the relevant format for react-native-track-player
const newTracks = backendTrackIds.map((trackId) => { const newTracks = trackIds.map((trackId) => {
const track = tracks[trackId]; const track = tracks[trackId];
if (!trackId || !track) { if (!trackId || !track) {
return; return;
} }
@@ -33,7 +35,7 @@ export default function usePlayAlbum() {
// Clear the queue and add all tracks // Clear the queue and add all tracks
await TrackPlayer.reset(); await TrackPlayer.reset();
await TrackPlayer.add(newTracks); await TrackPlayer.add(shuffle ? shuffleArray(newTracks) : newTracks);
// Play the queue // Play the queue
if (play) { if (play) {
@@ -41,5 +43,5 @@ export default function usePlayAlbum() {
} }
return newTracks; return newTracks;
}, [credentials, albums, tracks]); }, [credentials, tracks]);
} }