Add playlists
This commit is contained in:
@@ -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';
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
44
src/screens/Music/stacks/Playlist.tsx
Normal file
44
src/screens/Music/stacks/Playlist.tsx
Normal 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;
|
||||||
111
src/screens/Music/stacks/Playlists.tsx
Normal file
111
src/screens/Music/stacks/Playlists.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
154
src/screens/Music/stacks/components/TrackListView.tsx
Normal file
154
src/screens/Music/stacks/components/TrackListView.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user