Refactor some generic components
This commit is contained in:
@@ -4,7 +4,7 @@ import TrackPlayer from 'react-native-track-player';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import Routes from '../screens';
|
||||
import store, { persistedStore } from '../store';
|
||||
import store, { persistedStore } from 'store';
|
||||
|
||||
interface State {
|
||||
isReady: boolean;
|
||||
|
||||
25
src/components/TouchableHandler.tsx
Normal file
25
src/components/TouchableHandler.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
|
||||
interface TouchableHandlerProps {
|
||||
id: string;
|
||||
onPress: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a generic handler that accepts id as a prop, and return it when it is
|
||||
* pressed. This comes in handy with lists in which albums / tracks need to be selected.
|
||||
*/
|
||||
const TouchableHandler: React.FC<TouchableHandlerProps> = ({ id, onPress, children }) => {
|
||||
const handlePress = useCallback(() => {
|
||||
return onPress(id);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default TouchableHandler;
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import { StackParams } from '../types';
|
||||
import { Text, ScrollView, Dimensions, Button, TouchableOpacity, RefreshControl } from 'react-native';
|
||||
import { generateTrack, useGetImage } from '../../../utility/JellyfinApi';
|
||||
import { Text, ScrollView, Dimensions, Button, RefreshControl } from 'react-native';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import styled from 'styled-components/native';
|
||||
import { useRoute, RouteProp } 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 { ALBUM_CACHE_AMOUNT_OF_DAYS } from '../../../CONSTANTS';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { fetchTracksByAlbum } from 'store/music/actions';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import usePlayAlbum from 'utility/usePlayAlbum';
|
||||
import usePlayTrack from 'utility/usePlayTrack';
|
||||
import TouchableHandler from 'components/TouchableHandler';
|
||||
|
||||
type Route = RouteProp<StackParams, 'Album'>;
|
||||
|
||||
@@ -30,52 +32,21 @@ const TrackContainer = styled.View`
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
interface TouchableTrackProps {
|
||||
id: string;
|
||||
onPress: (id: string) => void;
|
||||
}
|
||||
|
||||
const TouchableTrack: React.FC<TouchableTrackProps> = ({ id, onPress, children }) => {
|
||||
const handlePress = useCallback(() => {
|
||||
return onPress(id);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<TrackContainer>
|
||||
{children}
|
||||
</TrackContainer>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const Album: React.FC = () => {
|
||||
// Retrieve state
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
const album = useTypedSelector((state) => state.music.albums.entities[id]);
|
||||
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||
const credentials = useTypedSelector((state) => state.settings.jellyfin);
|
||||
|
||||
// Retrieve helpers
|
||||
const dispatch = useDispatch();
|
||||
const getImage = useGetImage();
|
||||
const playAlbum = usePlayAlbum();
|
||||
|
||||
// Set callbacks
|
||||
const selectTrack = useCallback(async (trackId) => {
|
||||
const newTrack = generateTrack(tracks[trackId], credentials);
|
||||
console.log(newTrack);
|
||||
await TrackPlayer.add([ newTrack ]);
|
||||
await TrackPlayer.skip(trackId);
|
||||
TrackPlayer.play();
|
||||
}, [tracks, credentials]);
|
||||
const playAlbum = useCallback(async () => {
|
||||
const newTracks = album.Tracks.map((trackId) => generateTrack(tracks[trackId], credentials));
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
await TrackPlayer.add(newTracks);
|
||||
await TrackPlayer.skip(album.Tracks[0]);
|
||||
TrackPlayer.play();
|
||||
}, [tracks, credentials]);
|
||||
// Setup callbacks
|
||||
const selectAlbum = useCallback(() => { playAlbum(id); }, [playAlbum]);
|
||||
const selectTrack = usePlayTrack();
|
||||
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id]);
|
||||
|
||||
// Retrieve album tracks on load
|
||||
@@ -85,6 +56,11 @@ const Album: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// GUARD: If there is no album, we cannot render a thing
|
||||
if (!album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ backgroundColor: '#f6f6f6', padding: 20, paddingBottom: 50 }}
|
||||
@@ -92,15 +68,17 @@ const Album: React.FC = () => {
|
||||
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
|
||||
}
|
||||
>
|
||||
<AlbumImage source={{ uri: getImage(album.Id) }} />
|
||||
<AlbumImage source={{ uri: getImage(album?.Id) }} />
|
||||
<Text style={{ fontSize: 36, fontWeight: 'bold' }} >{album?.Name}</Text>
|
||||
<Text style={{ fontSize: 24, opacity: 0.5, marginBottom: 24 }}>{album?.AlbumArtist}</Text>
|
||||
<Button title="Play Album" onPress={playAlbum} />
|
||||
<Button title="Play Album" onPress={selectAlbum} />
|
||||
{album?.Tracks?.length ? album.Tracks.map((trackId) =>
|
||||
<TouchableTrack key={trackId} id={trackId} onPress={selectTrack}>
|
||||
<Text style={{ width: 20, opacity: 0.5, marginRight: 5 }}>{tracks[trackId]?.IndexNumber}</Text>
|
||||
<Text>{tracks[trackId]?.Name}</Text>
|
||||
</TouchableTrack>
|
||||
<TouchableHandler key={trackId} id={trackId} onPress={selectTrack}>
|
||||
<TrackContainer>
|
||||
<Text style={{ width: 20, opacity: 0.5, marginRight: 5 }}>{tracks[trackId]?.IndexNumber}</Text>
|
||||
<Text>{tracks[trackId]?.Name}</Text>
|
||||
</TrackContainer>
|
||||
</TouchableHandler>
|
||||
) : undefined}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useGetImage } from '../../../utility/JellyfinApi';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { Album, NavigationProp } from '../types';
|
||||
import { Text, SafeAreaView, FlatList, Dimensions } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import { TouchableOpacity } from 'react-native-gesture-handler';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useTypedSelector } from '../../../store';
|
||||
import { fetchAllAlbums } from '../../../store/music/actions';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from '../../../CONSTANTS';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { fetchAllAlbums } from 'store/music/actions';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import TouchableHandler from 'components/TouchableHandler';
|
||||
|
||||
const Screen = Dimensions.get('screen');
|
||||
|
||||
@@ -35,25 +35,6 @@ const AlbumImage = styled(FastImage)`
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
interface TouchableAlbumItemProps {
|
||||
id: string;
|
||||
onPress: (id: string) => void;
|
||||
}
|
||||
|
||||
const TouchableAlbumItem: React.FC<TouchableAlbumItemProps> = ({ id, onPress, children }) => {
|
||||
const handlePress = useCallback(() => {
|
||||
return onPress(id);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<AlbumItem>
|
||||
{children}
|
||||
</AlbumItem>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const Albums: React.FC = () => {
|
||||
// Retrieve data from store
|
||||
const { ids, entities: albums } = useTypedSelector((state) => state.music.albums);
|
||||
@@ -71,6 +52,7 @@ const Albums: React.FC = () => {
|
||||
|
||||
// Retrieve data on mount
|
||||
useEffect(() => {
|
||||
// GUARD: Only refresh this API call every set amounts of days
|
||||
if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
|
||||
retrieveData();
|
||||
}
|
||||
@@ -86,11 +68,13 @@ const Albums: React.FC = () => {
|
||||
numColumns={2}
|
||||
keyExtractor={d => d}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableAlbumItem id={item} onPress={selectAlbum}>
|
||||
<AlbumImage source={{ uri: getImage(item) }} />
|
||||
<Text>{albums[item]?.Name}</Text>
|
||||
<Text style={{ opacity: 0.5 }}>{albums[item]?.AlbumArtist}</Text>
|
||||
</TouchableAlbumItem>
|
||||
<TouchableHandler id={item} onPress={selectAlbum}>
|
||||
<AlbumItem>
|
||||
<AlbumImage source={{ uri: getImage(item) }} />
|
||||
<Text>{albums[item]?.Name}</Text>
|
||||
<Text style={{ opacity: 0.5 }}>{albums[item]?.AlbumArtist}</Text>
|
||||
</AlbumItem>
|
||||
</TouchableHandler>
|
||||
)}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TouchableOpacity } from 'react-native';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
|
||||
import { faPlay, faPause, faBackward, faForward } from '@fortawesome/free-solid-svg-icons';
|
||||
import styled from 'styled-components/native';
|
||||
import { useHasQueue } from '../../../utility/useQueue';
|
||||
import { useHasQueue } from 'utility/useQueue';
|
||||
|
||||
const MAIN_SIZE = 48;
|
||||
const BUTTON_SIZE = 32;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Text, Dimensions, View } from 'react-native';
|
||||
import useCurrentTrack from '../../../utility/useCurrentTrack';
|
||||
import useCurrentTrack from 'utility/useCurrentTrack';
|
||||
import styled from 'styled-components/native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import useQueue from '../../../utility/useQueue';
|
||||
import useQueue from 'utility/useQueue';
|
||||
import { View, Text } from 'react-native';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
import useCurrentTrack from '../../../utility/useCurrentTrack';
|
||||
import useCurrentTrack from 'utility/useCurrentTrack';
|
||||
|
||||
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean }>`
|
||||
padding: 10px;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Picker } from '@react-native-community/picker';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
import styled from 'styled-components/native';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from '../../store';
|
||||
import { AppState } from 'store';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '..';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Component, createRef } from 'react';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { debounce } from 'lodash';
|
||||
import { AppState } from '../../../../store';
|
||||
import { AppState } from 'store';
|
||||
|
||||
interface Props {
|
||||
serverUrl: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Text, Button, View } from 'react-native';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Input from '../../../components/Input';
|
||||
import { setJellyfinCredentials } from '../../../store/settings/actions';
|
||||
import Modal from 'components/Modal';
|
||||
import Input from 'components/Input';
|
||||
import { setJellyfinCredentials } from 'store/settings/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import CredentialGenerator from './components/CredentialGenerator';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
|
||||
import { Album, AlbumTrack } from './types';
|
||||
import { AsyncThunkAPI } from '..';
|
||||
import { retrieveAlbums, retrieveAlbumTracks } from '../../utility/JellyfinApi';
|
||||
import { retrieveAlbums, retrieveAlbumTracks } from 'utility/JellyfinApi';
|
||||
|
||||
export const albumAdapter = createEntityAdapter<Album>({
|
||||
selectId: album => album.Id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Track } from 'react-native-track-player';
|
||||
import { AppState, useTypedSelector } from '../store';
|
||||
import { AlbumTrack } from '../store/music/types';
|
||||
import { AppState, useTypedSelector } from 'store';
|
||||
import { AlbumTrack } from 'store/music/types';
|
||||
|
||||
type Credentials = AppState['settings']['jellyfin'];
|
||||
|
||||
|
||||
40
src/utility/usePlayAlbum.ts
Normal file
40
src/utility/usePlayAlbum.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useTypedSelector } from 'store';
|
||||
import { useCallback } from 'react';
|
||||
import TrackPlayer, { Track } from 'react-native-track-player';
|
||||
import { generateTrack } from './JellyfinApi';
|
||||
|
||||
/**
|
||||
* Generate a callback function that starts playing a full album given its
|
||||
* supplied id.
|
||||
*/
|
||||
export default function usePlayAlbum() {
|
||||
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) {
|
||||
const album = albums[albumId];
|
||||
const trackIds = album?.Tracks;
|
||||
|
||||
// GUARD: Check that the album actually has tracks
|
||||
if (!album || !trackIds?.length || !tracks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return generateTrack(track, credentials);
|
||||
}).filter((t): t is Track => typeof t !== 'undefined');
|
||||
|
||||
// Clear the queue and add all tracks
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
await TrackPlayer.add(newTracks);
|
||||
await TrackPlayer.skip(trackIds[0]);
|
||||
TrackPlayer.play();
|
||||
}, [credentials, albums, tracks]);
|
||||
}
|
||||
36
src/utility/usePlayTrack.ts
Normal file
36
src/utility/usePlayTrack.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { generateTrack } from './JellyfinApi';
|
||||
import useQueue from './useQueue';
|
||||
|
||||
/**
|
||||
* A hook that generates a callback that can setup and start playing a
|
||||
* particular trackId in the player.
|
||||
*/
|
||||
export default function usePlayTrack() {
|
||||
const credentials = useTypedSelector(state => state.settings.jellyfin);
|
||||
const tracks = useTypedSelector(state => state.music.tracks.entities);
|
||||
const queue = useQueue();
|
||||
|
||||
return useCallback(async function playTrack(trackId: string) {
|
||||
// Get the relevant track
|
||||
const track = tracks[trackId];
|
||||
|
||||
// GUARD: Check if the track actually exists in the store
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
// GUARD: Check if the track is already in the queue
|
||||
if (!queue?.some((t) => t.id === trackId)) {
|
||||
// If it is not, we must then generate it, and add it to the queue
|
||||
const newTrack = generateTrack(track, credentials);
|
||||
await TrackPlayer.add([ newTrack ]);
|
||||
}
|
||||
|
||||
// Then we'll skip to it and play it
|
||||
await TrackPlayer.skip(trackId);
|
||||
TrackPlayer.play();
|
||||
}, [credentials, tracks]);
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
"baseUrl": "./src", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
|
||||
Reference in New Issue
Block a user