Basic download implementation
This commit is contained in:
85
src/screens/Downloads/index.tsx
Normal file
85
src/screens/Downloads/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTypedSelector } from 'store';
|
||||
import formatBytes from 'utility/formatBytes';
|
||||
import TrashIcon from 'assets/trash.svg';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { removeDownloadedTrack } from 'store/downloads/actions';
|
||||
import Button from 'components/Button';
|
||||
import { t } from 'i18n-js';
|
||||
|
||||
function Downloads() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { entities, ids } = useTypedSelector((state) => state.downloads);
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
|
||||
// Calculate the total download size
|
||||
const totalDownloadSize = useMemo(() => (
|
||||
ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0)
|
||||
), [ids, entities]);
|
||||
|
||||
const handleDelete = useCallback((id: EntityId) => {
|
||||
dispatch(removeDownloadedTrack(id));
|
||||
}, [dispatch]);
|
||||
const handleDeleteAllTracks = useCallback(() => {
|
||||
ids.forEach((id) => dispatch(removeDownloadedTrack(id)));
|
||||
}, [dispatch, ids]);
|
||||
|
||||
if (!ids.length) {
|
||||
return (
|
||||
<View style={{ margin: 24, flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={[{ textAlign: 'center'}, defaultStyles.textHalfOpacity]}>
|
||||
{t('no-downloads')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, marginHorizontal: 24 }}>
|
||||
<FlatList
|
||||
style={{ flex: 1 }}
|
||||
ListHeaderComponent={
|
||||
<Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}>
|
||||
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
||||
</Text>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
title={t('delete-all-tracks')}
|
||||
onPress={handleDeleteAllTracks}
|
||||
disabled={!ids.length}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
}
|
||||
data={ids}
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ flex: 1, flexDirection: 'row', paddingVertical: 8, alignItems: 'center' }}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 16, marginBottom: 4 }} numberOfLines={1}>{tracks[item]?.Name}</Text>
|
||||
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>{tracks[item]?.AlbumArtist} ({tracks[item]?.Album})</Text>
|
||||
</View>
|
||||
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={[defaultStyles.textHalfOpacity, { marginRight: 6 }]}>
|
||||
{formatBytes(entities[item]?.size || 0)}
|
||||
</Text>
|
||||
<Pressable onPress={() => handleDelete(item)}>
|
||||
<TrashIcon height={24} width={24} fill={THEME_COLOR} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default Downloads;
|
||||
0
src/screens/Downloads/types.ts
Normal file
0
src/screens/Downloads/types.ts
Normal file
@@ -37,6 +37,7 @@ const Album: React.FC = () => {
|
||||
refresh={refresh}
|
||||
playButtonText={t('play-album')}
|
||||
shuffleButtonText={t('shuffle-album')}
|
||||
downloadText={t('download-album')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ const Playlist: React.FC = () => {
|
||||
listNumberingStyle='index'
|
||||
playButtonText={t('play-playlist')}
|
||||
shuffleButtonText={t('shuffle-playlist')}
|
||||
downloadText={t('download-playlist')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,11 @@ import usePlayTracks from 'utility/usePlayTracks';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
|
||||
import { MusicNavigationProp } from 'screens/Music/types';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
import Button from 'components/Button';
|
||||
import CloudDownArrow from 'assets/cloud-down-arrow.svg';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { downloadTrack } from 'store/downloads/actions';
|
||||
|
||||
const Screen = Dimensions.get('screen');
|
||||
|
||||
@@ -44,14 +49,14 @@ const AlbumImage = styled(FastImage)`
|
||||
`;
|
||||
|
||||
const TrackContainer = styled.View<{isPlaying: boolean}>`
|
||||
padding: 15px;
|
||||
padding: 15px 4px;
|
||||
border-bottom-width: 1px;
|
||||
flex-direction: row;
|
||||
|
||||
${props => props.isPlaying && css`
|
||||
background-color: ${THEME_COLOR}16;
|
||||
margin: 0 -20px;
|
||||
padding: 15px 35px;
|
||||
padding: 15px 24px;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -63,6 +68,7 @@ interface TrackListViewProps {
|
||||
refresh: () => void;
|
||||
playButtonText: string;
|
||||
shuffleButtonText: string;
|
||||
downloadText: string;
|
||||
listNumberingStyle?: 'album' | 'index';
|
||||
}
|
||||
|
||||
@@ -74,6 +80,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
refresh,
|
||||
playButtonText,
|
||||
shuffleButtonText,
|
||||
downloadText,
|
||||
listNumberingStyle = 'album',
|
||||
}) => {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
@@ -87,18 +94,22 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
const playTracks = usePlayTracks();
|
||||
const { track: currentTrack } = useCurrentTrack();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Setup callbacks
|
||||
const playEntity = useCallback(() => { playTracks(trackIds); }, [playTracks, trackIds]);
|
||||
const shuffleEntity = useCallback(() => { playTracks(trackIds, true, true); }, [playTracks, trackIds]);
|
||||
const shuffleEntity = useCallback(() => { playTracks(trackIds, { shuffle: true }); }, [playTracks, trackIds]);
|
||||
const selectTrack = useCallback(async (index: number) => {
|
||||
await playTracks(trackIds, false);
|
||||
await playTracks(trackIds, { play: false });
|
||||
await TrackPlayer.skip(index);
|
||||
await TrackPlayer.play();
|
||||
}, [playTracks, trackIds]);
|
||||
const longPressTrack = useCallback((index: number) => {
|
||||
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
|
||||
}, [navigation, trackIds]);
|
||||
const downloadAllTracks = useCallback(() => {
|
||||
trackIds.forEach((trackId) => dispatch(downloadTrack(trackId)));
|
||||
}, [dispatch, trackIds]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -145,9 +156,13 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
>
|
||||
{tracks[trackId]?.Name}
|
||||
</Text>
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<DownloadIcon trackId={trackId} />
|
||||
</View>
|
||||
</TrackContainer>
|
||||
</TouchableHandler>
|
||||
)}
|
||||
<Button icon={CloudDownArrow} title={downloadText} style={{ marginTop: 12 }} onPress={downloadAllTracks} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -10,10 +10,14 @@ import useDefaultStyles from 'components/Colors';
|
||||
import Text from 'components/Text';
|
||||
import Button from 'components/Button';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
|
||||
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
|
||||
padding: 10px;
|
||||
border-bottom-width: 1px;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
${props => props.active && css`
|
||||
font-weight: 900;
|
||||
@@ -62,8 +66,13 @@ export default function Queue() {
|
||||
currentIndex === i ? defaultStyles.activeBackground : {},
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
<View>
|
||||
<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>
|
||||
</View>
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<DownloadIcon trackId={track.backendId} />
|
||||
</View>
|
||||
</QueueItem>
|
||||
</TouchableHandler>
|
||||
))}
|
||||
|
||||
@@ -2,17 +2,21 @@ import React from 'react';
|
||||
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import SetJellyfinServer from './modals/SetJellyfinServer';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
|
||||
import Player from './Player';
|
||||
import Music from './Music';
|
||||
import Settings from './Settings';
|
||||
import Downloads from './Downloads';
|
||||
import Onboarding from './Onboarding';
|
||||
import TrackPopupMenu from './modals/TrackPopupMenu';
|
||||
import SetJellyfinServer from './modals/SetJellyfinServer';
|
||||
|
||||
import PlayPauseIcon from 'assets/play-pause-fill.svg';
|
||||
import NotesIcon from 'assets/notes.svg';
|
||||
import GearIcon from 'assets/gear.svg';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import DownloadsIcon from 'assets/arrow-down-to-line.svg';
|
||||
import { useTypedSelector } from 'store';
|
||||
import Onboarding from './Onboarding';
|
||||
import TrackPopupMenu from './modals/TrackPopupMenu';
|
||||
import { ModalStackParams } from './types';
|
||||
import { t } from '@localisation';
|
||||
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
||||
@@ -35,6 +39,8 @@ function getIcon(route: string): React.FC<any> | null {
|
||||
return NotesIcon;
|
||||
case 'Settings':
|
||||
return GearIcon;
|
||||
case 'Downloads':
|
||||
return DownloadsIcon;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -70,6 +76,7 @@ function Screens() {
|
||||
>
|
||||
<Tab.Screen name="NowPlaying" component={Player} options={{ tabBarLabel: t('now-playing') }} />
|
||||
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music') }} />
|
||||
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads')}} />
|
||||
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings') }} />
|
||||
</Tab.Navigator>
|
||||
</>
|
||||
|
||||
@@ -5,12 +5,15 @@ import { ModalStackParams } from 'screens/types';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { SubHeader } from 'components/Typography';
|
||||
import styled from 'styled-components/native';
|
||||
import usePlayTrack from 'utility/usePlayTrack';
|
||||
import { t } from '@localisation';
|
||||
import PlayIcon from 'assets/play.svg';
|
||||
import DownloadIcon from 'assets/cloud-down-arrow.svg';
|
||||
import QueueAppendIcon from 'assets/queue-append.svg';
|
||||
import Text from 'components/Text';
|
||||
import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { downloadTrack } from 'store/downloads/actions';
|
||||
import usePlayTracks from 'utility/usePlayTracks';
|
||||
|
||||
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
|
||||
|
||||
@@ -24,8 +27,9 @@ function TrackPopupMenu() {
|
||||
// Retrieve helpers
|
||||
const { params: { trackId } } = useRoute<Route>();
|
||||
const navigation = useNavigation();
|
||||
const dispatch = useDispatch();
|
||||
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
|
||||
const playTrack = usePlayTrack();
|
||||
const playTracks = usePlayTracks();
|
||||
|
||||
// Set callback to close the modal
|
||||
const closeModal = useCallback(() => {
|
||||
@@ -33,13 +37,17 @@ function TrackPopupMenu() {
|
||||
}, [navigation]);
|
||||
|
||||
const handlePlayNext = useCallback(() => {
|
||||
playTrack(trackId, false, false);
|
||||
playTracks([trackId], { method: 'add-after-currently-playing', play: false });
|
||||
closeModal();
|
||||
}, [playTrack, closeModal, trackId]);
|
||||
}, [playTracks, closeModal, trackId]);
|
||||
const handleAddToQueue = useCallback(() => {
|
||||
playTrack(trackId, false, true);
|
||||
playTracks([trackId], { method: 'add-to-end', play: false });
|
||||
closeModal();
|
||||
}, [playTrack, closeModal, trackId]);
|
||||
}, [playTracks, closeModal, trackId]);
|
||||
const handleDownload = useCallback(() => {
|
||||
dispatch(downloadTrack(trackId));
|
||||
closeModal();
|
||||
}, [trackId, dispatch]);
|
||||
|
||||
return (
|
||||
<Modal fullSize={false}>
|
||||
@@ -49,6 +57,7 @@ function TrackPopupMenu() {
|
||||
<WrappableButtonRow>
|
||||
<WrappableButton title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
|
||||
<WrappableButton title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
|
||||
<WrappableButton title={t('download-track')} icon={DownloadIcon} onPress={handleDownload} />
|
||||
</WrappableButtonRow>
|
||||
</Container>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user