Add playlists
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7;
|
||||
export const PLAYLIST_CACHE_AMOUNT_OF_DAYS = 7;
|
||||
export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
|
||||
export const THEME_COLOR = '#FF3C00';
|
||||
@@ -39,5 +39,10 @@
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"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-album": "Speel album",
|
||||
"play-album": "Speel Album",
|
||||
"queue": "Wachtrij",
|
||||
"add-to-queue": "Voeg toe aan wachtrij",
|
||||
"clear-queue": "Wis wachtrij",
|
||||
|
||||
@@ -38,4 +38,9 @@ export type LocaleKeys = 'play-next'
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
| '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 { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import Playlists from './stacks/Playlists';
|
||||
import Playlist from './stacks/Playlist';
|
||||
|
||||
const Stack = createStackNavigator<StackParams>();
|
||||
|
||||
@@ -22,6 +24,8 @@ function MusicStack() {
|
||||
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} />
|
||||
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
|
||||
<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.Navigator>
|
||||
);
|
||||
|
||||
@@ -1,133 +1,43 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { StackParams } from '../types';
|
||||
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
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 { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
import { fetchTracksByAlbum } from 'store/music/actions';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS, THEME_COLOR } from 'CONSTANTS';
|
||||
import usePlayAlbum from 'utility/usePlayAlbum';
|
||||
import TouchableHandler from 'components/TouchableHandler';
|
||||
import useCurrentTrack from 'utility/useCurrentTrack';
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
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'>;
|
||||
|
||||
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 defaultStyles = useDefaultStyles();
|
||||
|
||||
// Retrieve state
|
||||
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 isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
|
||||
|
||||
// Retrieve helpers
|
||||
const dispatch = useDispatch();
|
||||
const getImage = useGetImage();
|
||||
const playAlbum = usePlayAlbum();
|
||||
const { track: currentTrack } = useCurrentTrack();
|
||||
const navigation = useNavigation();
|
||||
// Define a function for refreshing this entity
|
||||
const refresh = useCallback(() => dispatch(fetchTracksByAlbum(id)), [id, dispatch]);
|
||||
|
||||
// Setup callbacks
|
||||
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
|
||||
// Auto-fetch the track data periodically
|
||||
useEffect(() => {
|
||||
if (!album?.lastRefreshed || differenceInDays(album?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
|
||||
refresh();
|
||||
}
|
||||
}, [album?.lastRefreshed, refresh]);
|
||||
|
||||
// GUARD: If there is no album, we cannot render a thing
|
||||
if (!album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 50 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
|
||||
}
|
||||
>
|
||||
<AlbumImage source={{ uri: getImage(album?.Id) }} style={defaultStyles.imageBackground} />
|
||||
<Text style={[ defaultStyles.text, styles.name ]} >{album?.Name}</Text>
|
||||
<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>
|
||||
<TrackListView
|
||||
trackIds={albumTracks || []}
|
||||
title={album?.Name}
|
||||
artist={album?.AlbumArtist}
|
||||
entityId={id}
|
||||
refresh={refresh}
|
||||
playButtonText={t('play-album')}
|
||||
shuffleButtonText={t('shuffle-album')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { Album, NavigationProp } from '../types';
|
||||
import { NavigationProp } from '../types';
|
||||
import { Text, SafeAreaView, SectionList, View } from 'react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -15,6 +15,7 @@ import AlphabetScroller from 'components/AlphabetScroller';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import styled from 'styled-components/native';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import { Album } from 'store/music/types';
|
||||
|
||||
interface VirtualizedItemInfo {
|
||||
section: SectionedId,
|
||||
@@ -92,7 +93,7 @@ const Albums: React.FC = () => {
|
||||
// Retrieve data from store
|
||||
const { entities: albums } = useTypedSelector((state) => state.music.albums);
|
||||
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);
|
||||
|
||||
// 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 defaultStyles = useDefaultStyles();
|
||||
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
|
||||
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
|
||||
const handleSearchClick = useCallback(() => { navigation.navigate('Search'); }, [navigation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListButton onPress={handleAllAlbumsClick}>{t('all-albums')}</ListButton>
|
||||
<ListButton onPress={handlePlaylistsClick}>{t('playlists')}</ListButton>
|
||||
<ListButton onPress={handleSearchClick}>{t('search')}</ListButton>
|
||||
<ListContainer>
|
||||
<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 { ActivityIndicator, Text, TextInput, View } from 'react-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 Text from 'components/Text';
|
||||
import Button from 'components/Button';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
|
||||
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
|
||||
padding: 10px;
|
||||
@@ -61,8 +62,8 @@ export default function Queue() {
|
||||
currentIndex === i ? defaultStyles.activeBackground : {},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.trackTitle}>{track.title}</Text>
|
||||
<Text style={defaultStyles.textHalfOpacity}>{track.artist}</Text>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
|
||||
</QueueItem>
|
||||
</TouchableHandler>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,7 @@ class CredentialGenerator extends Component<Props> {
|
||||
handleStateChange = () => {
|
||||
// Call a debounced version to check if the credentials are there
|
||||
this.checkIfCredentialsAreThere();
|
||||
}
|
||||
};
|
||||
|
||||
checkIfCredentialsAreThere = debounce(() => {
|
||||
// Inject some javascript to check if the credentials can be extracted
|
||||
@@ -52,7 +52,7 @@ class CredentialGenerator extends Component<Props> {
|
||||
access_token: credentials.AccessToken,
|
||||
device_id: deviceId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { serverUrl } = this.props;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack } from './types';
|
||||
import { Album, AlbumTrack, Playlist } from './types';
|
||||
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>({
|
||||
selectId: album => album.Id,
|
||||
@@ -76,4 +76,31 @@ AsyncThunkAPI
|
||||
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 { Album, AlbumTrack } from './types';
|
||||
import { Album, AlbumTrack, Playlist } from './types';
|
||||
import { setJellyfinCredentials } from 'store/settings/actions';
|
||||
|
||||
export interface State {
|
||||
@@ -8,13 +18,21 @@ export interface State {
|
||||
isLoading: boolean;
|
||||
entities: Dictionary<Album>;
|
||||
ids: EntityId[];
|
||||
lastRefreshed?: number,
|
||||
},
|
||||
tracks: {
|
||||
isLoading: boolean;
|
||||
entities: Dictionary<AlbumTrack>;
|
||||
ids: EntityId[];
|
||||
byAlbum: Dictionary<EntityId[]>;
|
||||
byPlaylist: Dictionary<EntityId[]>;
|
||||
},
|
||||
lastRefreshed?: number,
|
||||
playlists: {
|
||||
isLoading: boolean;
|
||||
entities: Dictionary<Playlist>;
|
||||
ids: EntityId[];
|
||||
lastRefreshed?: number,
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
@@ -25,7 +43,13 @@ const initialState: State = {
|
||||
tracks: {
|
||||
...trackAdapter.getInitialState(),
|
||||
isLoading: false,
|
||||
byAlbum: {},
|
||||
byPlaylist: {},
|
||||
},
|
||||
playlists: {
|
||||
...playlistAdapter.getInitialState(),
|
||||
isLoading: false,
|
||||
}
|
||||
};
|
||||
|
||||
const music = createSlice({
|
||||
@@ -41,7 +65,7 @@ const music = createSlice({
|
||||
builder.addCase(fetchAllAlbums.fulfilled, (state, { payload }) => {
|
||||
albumAdapter.setAll(state.albums, payload);
|
||||
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.rejected, (state) => { state.albums.isLoading = false; });
|
||||
@@ -59,7 +83,7 @@ const music = createSlice({
|
||||
/**
|
||||
* Fetch tracks by album
|
||||
*/
|
||||
builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload }) => {
|
||||
builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload, meta }) => {
|
||||
if (!payload.length) {
|
||||
return;
|
||||
}
|
||||
@@ -67,9 +91,9 @@ const music = createSlice({
|
||||
trackAdapter.upsertMany(state.tracks, payload);
|
||||
|
||||
// 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) {
|
||||
album.Tracks = payload.map(d => d.Id);
|
||||
album.lastRefreshed = new Date().getTime();
|
||||
}
|
||||
state.tracks.isLoading = false;
|
||||
@@ -82,6 +106,40 @@ const music = createSlice({
|
||||
albumAdapter.upsertMany(state.albums, payload.albums);
|
||||
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
|
||||
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
|
||||
*/
|
||||
function splitByAlphabet(state: AppState['music']['albums']): SectionedId[] {
|
||||
function splitAlbumsByAlphabet(state: AppState['music']['albums']): SectionedId[] {
|
||||
const { entities: albums } = state;
|
||||
const albumIds = albumsByArtist(state);
|
||||
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(
|
||||
(state: AppState) => state.music.albums,
|
||||
splitByAlphabet,
|
||||
);
|
||||
splitAlbumsByAlphabet,
|
||||
);
|
||||
|
||||
@@ -74,4 +74,24 @@ export interface State {
|
||||
entities: Dictionary<Album>;
|
||||
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;
|
||||
}
|
||||
|
||||
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 TrackPlayer, { Track } from 'react-native-track-player';
|
||||
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
|
||||
* supplied id.
|
||||
*/
|
||||
export default function usePlayAlbum() {
|
||||
export default function usePlayTracks() {
|
||||
const credentials = useTypedSelector(state => state.settings.jellyfin);
|
||||
const albums = useTypedSelector(state => state.music.albums.entities);
|
||||
const tracks = useTypedSelector(state => state.music.tracks.entities);
|
||||
|
||||
return useCallback(async function playAlbum(albumId: string, play: boolean = true): Promise<Track[] | undefined> {
|
||||
const album = albums[albumId];
|
||||
const backendTrackIds = album?.Tracks;
|
||||
|
||||
// GUARD: Check if the album has songs
|
||||
if (!backendTrackIds?.length) {
|
||||
return useCallback(async function playTracks(
|
||||
trackIds: EntityId[] | undefined,
|
||||
play: boolean = true,
|
||||
shuffle: boolean = false,
|
||||
): Promise<Track[] | undefined> {
|
||||
if (!trackIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert all backendTrackIds to the relevant format for react-native-track-player
|
||||
const newTracks = backendTrackIds.map((trackId) => {
|
||||
// Convert all trackIds to the relevant format for react-native-track-player
|
||||
const newTracks = trackIds.map((trackId) => {
|
||||
const track = tracks[trackId];
|
||||
|
||||
if (!trackId || !track) {
|
||||
return;
|
||||
}
|
||||
@@ -33,7 +35,7 @@ export default function usePlayAlbum() {
|
||||
|
||||
// Clear the queue and add all tracks
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(newTracks);
|
||||
await TrackPlayer.add(shuffle ? shuffleArray(newTracks) : newTracks);
|
||||
|
||||
// Play the queue
|
||||
if (play) {
|
||||
@@ -41,5 +43,5 @@ export default function usePlayAlbum() {
|
||||
}
|
||||
|
||||
return newTracks;
|
||||
}, [credentials, albums, tracks]);
|
||||
}, [credentials, tracks]);
|
||||
}
|
||||
Reference in New Issue
Block a user