Merge pull request #66 from leinelissen/feature/playlists

Add playlists
This commit is contained in:
Lei Nelissen
2022-01-01 22:58:47 +01:00
committed by GitHub
37 changed files with 640 additions and 207 deletions

13
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Install Node dependencies
run: npm install
- name: Run linter
run: npm run lint

5
package-lock.json generated
View File

@@ -19696,13 +19696,12 @@
"dev": true,
"requires": {
"@types/react": "*",
"@types/react-native": "^0.65",
"@types/react-native": "^0.66.10",
"@types/styled-components": "*"
},
"dependencies": {
"@types/react-native": {
"version": "0.65.13",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.65.13.tgz",
"version": "https://registry.npmjs.org/@types/react-native/-/react-native-0.65.13.tgz",
"integrity": "sha512-yJ5QyXZFgDD7Cjwi7Bd32VACVqOJgRzb6KiZJPi4GJpwxmycMaw+EvPk3PQ/3dwQmiHM4iSRWcxtuE/xvcsMXg==",
"dev": true,
"requires": {

View File

@@ -8,7 +8,7 @@
"ios": "react-native run-ios --scheme \"Jellyfin Player\"",
"start": "react-native start",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"build:ios": "react-native bundle --entry-file='index.ts' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'"
},
"dependencies": {
@@ -82,5 +82,8 @@
"json",
"node"
]
},
"overrides": {
"@types/react-native": "^0.66.10"
}
}

View File

@@ -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';

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState } from 'react';
import { SvgProps } from 'react-native-svg';
import {
PressableProps, ViewProps,
PressableProps, ViewProps, View,
} from 'react-native';
import { THEME_COLOR } from 'CONSTANTS';
import styled, { css } from 'styled-components/native';
@@ -13,7 +13,6 @@ interface ButtonProps extends PressableProps {
style?: ViewProps['style'];
}
const BaseButton = styled.Pressable`
padding: 16px;
border-radius: 8px;
@@ -32,7 +31,7 @@ const ButtonText = styled.Text<{ active?: boolean }>`
`}
`;
export default function Button(props: ButtonProps) {
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
const { icon: Icon, title, ...rest } = props;
const defaultStyles = useDefaultStyles();
const [isPressed, setPressed] = useState(false);
@@ -42,6 +41,7 @@ export default function Button(props: ButtonProps) {
return (
<BaseButton
{...rest}
ref={ref}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
@@ -62,4 +62,6 @@ export default function Button(props: ButtonProps) {
<ButtonText active={isPressed}>{title}</ButtonText>
</BaseButton>
);
}
});
export default Button;

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components/native';
import { SafeAreaView, Pressable } from 'react-native';
import { Pressable } from 'react-native';
import { useNavigation, StackActions } from '@react-navigation/native';
import useDefaultStyles from './Colors';
@@ -16,8 +16,9 @@ const Background = styled(Pressable)`
const Container = styled(Pressable)<Pick<Props, 'fullSize'>>`
margin: auto 20px;
padding: 4px;
border-radius: 8px;
overflow: hidden;
border-radius: 12px;
flex: 0 0 auto;
background: salmon;
${props => props.fullSize && css`
flex: 1;
@@ -35,11 +36,9 @@ const Modal: React.FC<Props> = ({ children, fullSize = true }) => {
return (
<Background style={defaultStyles.modal} onPress={closeModal}>
<SafeAreaView style={{ flex: 1 }}>
<Container style={defaultStyles.modalInner} fullSize={fullSize}>
{children}
</Container>
</SafeAreaView>
<Container style={defaultStyles.modalInner} fullSize={fullSize}>
{children}
</Container>
</Background>
);
};

View File

@@ -10,4 +10,5 @@ export const Header = styled(Text)`
export const SubHeader = styled(Text)`
font-size: 24px;
margin: 12px 0;
font-weight: 500;
`;

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components/native';
import Button from './Button';
export const WrappableButtonRow = styled.View`
flex: 0 0 auto;
flex-direction: row;
flex-wrap: wrap;
margin: 6px -2px;
`;
export const WrappableButton = styled(Button)`
margin: 2px;
`;

View File

@@ -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"
}

View File

@@ -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,5 +38,11 @@
"enable-error-reporting-description": "Dit helpt de appervaring te verbeteren door ons rapportages te sturen van crashes en andere foutmeldingen.",
"enable": "Zet aan",
"disable": "Zet uit",
"more-info": "Meer informatie"
"more-info": "Meer informatie",
"track": "Track",
"playlists": "Playlists",
"playlist": "Playlist",
"play-playlist": "Speel Playlist",
"shuffle-album": "Shuffle Album",
"shuffle-playlist": "Shuffle Playlist"
}

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { StackParams } from './types';
import { MusicStackParams } from './types';
import Albums from './stacks/Albums';
import Album from './stacks/Album';
import RecentAlbums from './stacks/RecentAlbums';
@@ -8,8 +8,10 @@ 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>();
const Stack = createStackNavigator<MusicStackParams>();
function MusicStack() {
const defaultStyles = useDefaultStyles();
@@ -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>
);

View File

@@ -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 { MusicStackParams } from '../types';
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;
`}
`;
type Route = RouteProp<MusicStackParams, 'Album'>;
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')}
/>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { Album, NavigationProp } from '../types';
import { MusicNavigationProp } 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,12 +93,12 @@ 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
const dispatch = useDispatch();
const navigation = useNavigation<NavigationProp>();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
const listRef = useRef<SectionList<EntityId>>(null);

View File

@@ -0,0 +1,44 @@
import React, { useCallback, useEffect } from 'react';
import { MusicStackParams } 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<MusicStackParams, '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,110 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types';
import { Text, 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';
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<MusicNavigationProp>();
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 });
}, [navigation]);
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 (
<FlatList
data={ids}
refreshing={isLoading}
onRefresh={retrieveData}
getItemLayout={getItemLayout}
ref={listRef}
keyExtractor={(item, index) => `${item}_${index}`}
renderItem={generateItem}
/>
);
};
export default Playlists;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { NavigationProp } from '../types';
import { MusicNavigationProp } from '../types';
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
@@ -24,14 +24,16 @@ const styles = StyleSheet.create({
});
const NavigationHeader: React.FC = () => {
const navigation = useNavigation();
const navigation = useNavigation<MusicNavigationProp>();
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>
@@ -50,7 +52,7 @@ const RecentAlbums: React.FC = () => {
// Initialise helpers
const dispatch = useDispatch();
const navigation = useNavigation<NavigationProp>();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
// Set callbacks

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 { ActivityIndicator, Text, TextInput, View } from 'react-native';
import styled from 'styled-components/native';
@@ -9,7 +9,7 @@ import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from 'components/TouchableHandler';
import { useNavigation } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi';
import { NavigationProp } from '../types';
import { MusicNavigationProp } from '../types';
import FastImage from 'react-native-fast-image';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
@@ -94,7 +94,7 @@ export default function Search() {
const searchElement = useRef<TextInput>(null);
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
const navigation = useNavigation<MusicNavigationProp>();
const getImage = useGetImage();
const dispatch = useAppDispatch();
@@ -206,7 +206,6 @@ export default function Search() {
const HeaderComponent = React.useMemo(() => (
<Container>
<Input
// @ts-expect-error Ref typing shenanigans
ref={searchElement}
value={searchTerm}
onChangeText={setSearchTerm}

View File

@@ -0,0 +1,156 @@
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 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';
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
import { MusicNavigationProp } from 'screens/Music/types';
const Screen = Dimensions.get('screen');
const styles = StyleSheet.create({
name: {
fontSize: 36,
fontWeight: 'bold'
},
artist: {
fontSize: 24,
opacity: 0.5,
marginBottom: 12
},
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<MusicNavigationProp>();
// 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>
<WrappableButtonRow>
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} />
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} />
</WrappableButtonRow>
<View style={{ marginTop: 8 }}>
{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

@@ -1,12 +1,14 @@
import { StackNavigationProp } from '@react-navigation/stack';
import { Album } from 'store/music/types';
export type StackParams = {
export type MusicStackParams = {
[key: string]: Record<string, unknown> | undefined;
Albums: undefined;
Album: { id: string, album: Album };
Playlists: undefined;
Playlist: { id: string };
RecentAlbums: undefined;
Search: undefined;
};
export type NavigationProp = StackNavigationProp<StackParams>;
export type MusicNavigationProp = StackNavigationProp<MusicStackParams>;

View File

@@ -31,16 +31,18 @@ export default class ProgressBar extends Component<{}, State> {
state: State = {
position: 0,
duration: 0,
}
};
timer: number = 0;
timer: NodeJS.Timeout | null = null;
componentDidMount() {
this.timer = setInterval(this.updateProgress, 500);
}
componentWillUnmount() {
clearInterval(this.timer);
if (this.timer) {
clearInterval(this.timer);
}
}
updateProgress = async () => {
@@ -50,18 +52,18 @@ export default class ProgressBar extends Component<{}, State> {
]);
this.setState({ position, duration });
}
};
handleGesture = async (gesture: number) => {
// Set relative translation in state
this.setState({ gesture });
}
};
handleEndOfGesture = (position: number) => {
// Calculate and set the new position
TrackPlayer.seekTo(position);
this.setState({ gesture: undefined, position });
}
};
render() {
const { position, duration, gesture } = this.state;

View File

@@ -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>
))}

View File

@@ -9,9 +9,10 @@ import { useNavigation } from '@react-navigation/native';
import ListButton from 'components/ListButton';
import { THEME_COLOR } from 'CONSTANTS';
import Sentry from './components/Sentry';
import { SettingsNavigationProp } from './types';
export function SettingsList() {
const navigation = useNavigation();
const navigation = useNavigation<SettingsNavigationProp>();
const handleLibraryClick = useCallback(() => { navigation.navigate('Library'); }, [navigation]);
const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]);
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);

View File

@@ -0,0 +1,11 @@
import { StackNavigationProp } from '@react-navigation/stack';
export type SettingsStackParams = {
[key: string]: Record<string, unknown> | undefined;
SettingList: undefined;
Library: undefined;
Cache: undefined;
Sentry: undefined;
};
export type SettingsNavigationProp = StackNavigationProp<SettingsStackParams>;

View File

@@ -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;

View File

@@ -7,24 +7,17 @@ import { SubHeader } from 'components/Typography';
import styled from 'styled-components/native';
import usePlayTrack from 'utility/usePlayTrack';
import { t } from '@localisation';
import Button from 'components/Button';
import PlayIcon from 'assets/play.svg';
import QueueAppendIcon from 'assets/queue-append.svg';
import Text from 'components/Text';
import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow';
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
const Container = styled.View`
padding: 20px;
`;
const Buttons = styled.View`
margin-top: 20px;
`;
const ButtonSpacing = styled.View`
width: 8px;
height: 4px;
flex: 0 0 auto;
flex-direction: column;
`;
function TrackPopupMenu() {
@@ -51,13 +44,12 @@ function TrackPopupMenu() {
return (
<Modal fullSize={false}>
<Container>
<SubHeader>{track?.Name}</SubHeader>
<Text>{track?.Album} - {track?.AlbumArtist}</Text>
<Buttons>
<Button title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
<ButtonSpacing />
<Button title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
</Buttons>
<SubHeader style={{ textAlign: 'center' }}>{track?.Name}</SubHeader>
<Text style={{ marginBottom: 18, textAlign: 'center' }}>{track?.Album} - {track?.AlbumArtist}</Text>
<WrappableButtonRow>
<WrappableButton title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
<WrappableButton title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
</WrappableButtonRow>
</Container>
</Modal>
);

View File

@@ -1,5 +1,9 @@
import { StackNavigationProp } from '@react-navigation/stack';
export interface ModalStackParams {
[key: string]: Record<string, unknown> | undefined;
SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string };
}
}
export type ModalNavigationProp = StackNavigationProp<ModalStackParams>;

View File

@@ -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[]>;
}
);

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 { 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);

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
*/
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,
);

View File

@@ -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;
}

3
src/typings/env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module '@env' {
export const SENTRY_DSN: string;
}

View File

@@ -2,8 +2,4 @@ declare module '*.svg' {
import { SvgProps } from 'react-native-svg';
const content: React.FC<SvgProps>;
export default content;
}
declare module '@env' {
export const SENTRY_DSN: string;
}

View File

@@ -6,6 +6,7 @@ import { setReceivedErrorReportingAlert } from 'store/settings/actions';
import { setSentryStatus } from './Sentry';
import { useNavigation } from '@react-navigation/native';
import { useDispatch } from 'react-redux';
import { ModalNavigationProp } from 'screens/types';
/**
* This will send out an alert message asking the user if they want to enable
@@ -13,7 +14,7 @@ import { useDispatch } from 'react-redux';
*/
export default function ErrorReportingAlert() {
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
const navigation = useNavigation();
const navigation = useNavigation<ModalNavigationProp>();
const dispatch = useDispatch();
useEffect(() => {
@@ -53,7 +54,7 @@ export default function ErrorReportingAlert() {
dispatch(setReceivedErrorReportingAlert());
}
}, []);
}, [dispatch, hasReceivedErrorReportingAlert, navigation]);
return null;
}

View File

@@ -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;
}

View File

@@ -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]);
}

View File

@@ -58,7 +58,8 @@
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support
// for emitting type metadata for decorators. */
"resolveJsonModule": true
"resolveJsonModule": true,
"skipLibCheck": true,
},
"exclude": [
"node_modules", "babel.config.js", "metro.config.js", "jest.config.js"