Allow for retring individual tracks

This commit is contained in:
Lei Nelissen
2022-01-02 19:16:12 +01:00
parent 74ddc58f83
commit cc0dfc2528
7 changed files with 175 additions and 81 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { useTypedSelector } from 'store'; import { useTypedSelector } from 'store';
import CloudIcon from 'assets/cloud.svg'; import CloudIcon from 'assets/cloud.svg';
import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg'; import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg';
@@ -6,6 +6,7 @@ import InternalDriveIcon from 'assets/internal-drive.svg';
import useDefaultStyles from './Colors'; import useDefaultStyles from './Colors';
import { EntityId } from '@reduxjs/toolkit'; import { EntityId } from '@reduxjs/toolkit';
import Svg, { Circle } from 'react-native-svg'; import Svg, { Circle } from 'react-native-svg';
import { Animated, Easing } from 'react-native';
interface DownloadIconProps { interface DownloadIconProps {
trackId: EntityId; trackId: EntityId;
@@ -14,10 +15,40 @@ interface DownloadIconProps {
} }
function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) { function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
// determine styles
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
const iconFill = fill || defaultStyles.textHalfOpacity.color; const iconFill = fill || defaultStyles.textHalfOpacity.color;
// Get download icon from state
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
// Memoize calculations for radius and circumference of the circle
const radius = useMemo(() => size / 2, [size]);
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
// Initialize refs for the circle and the animated value
const circleRef = useRef<Circle>(null);
const offsetAnimation = useRef(new Animated.Value(entity?.progress || 0)).current;
// Whenever the progress changes, trigger the animation
useEffect(() => {
Animated.timing(offsetAnimation, {
toValue: (circumference * (1 - (entity?.progress || 0))),
duration: 250,
useNativeDriver: false,
easing: Easing.ease,
}).start();
}, [entity?.progress, offsetAnimation, circumference]);
// On mount, subscribe to changes in the animation value and then
// apply them to the circle using native props
useEffect(() => {
const subscription = offsetAnimation.addListener((offset) => {
circleRef.current?.setNativeProps({ strokeDashoffset: offset.value });
});
return () => offsetAnimation.removeListener(subscription);
}, [offsetAnimation]);
if (!entity) { if (!entity) {
return ( return (
@@ -25,7 +56,7 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
); );
} }
const { isComplete, isFailed, progress } = entity; const { isComplete, isFailed } = entity;
if (isComplete) { if (isComplete) {
return ( return (
@@ -40,9 +71,6 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
} }
if (!isComplete && !isFailed) { if (!isComplete && !isFailed) {
const radius = size / 2;
const circumference = radius * 2 * Math.PI;
return ( return (
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}> <Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
<Circle <Circle
@@ -50,9 +78,10 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
cy={radius} cy={radius}
r={radius - 1} r={radius - 1}
stroke={iconFill} stroke={iconFill}
ref={circleRef}
strokeWidth={1.5} strokeWidth={1.5}
strokeDasharray={[ circumference, circumference ]} strokeDasharray={[ circumference, circumference ]}
strokeDashoffset={circumference * (1 - progress)} strokeDashoffset={circumference}
strokeLinecap='round' strokeLinecap='round'
fill='transparent' fill='transparent'
/> />

View File

@@ -50,8 +50,10 @@
"download-album": "Download Album", "download-album": "Download Album",
"download-playlist": "Download Playlist", "download-playlist": "Download Playlist",
"no-downloads": "You have not yet downloaded any tracks", "no-downloads": "You have not yet downloaded any tracks",
"delete-track": "Delete Track",
"delete-all-tracks": "Delete All Tracks", "delete-all-tracks": "Delete All Tracks",
"delete-album": "Delete Album", "delete-album": "Delete Album",
"delete-playlist": "Delete Playlist", "delete-playlist": "Delete Playlist",
"total-download-size": "Total Download Size" "total-download-size": "Total Download Size",
"retry-failed-downloads": "Retry Failed Downloads"
} }

View File

@@ -50,5 +50,7 @@ export type LocaleKeys = 'play-next'
| 'download-playlist' | 'download-playlist'
| 'delete-album' | 'delete-album'
| 'delete-playlist' | 'delete-playlist'
| 'delete-track'
| 'total-download-size' | 'total-download-size'
| 'no-downloads' | 'no-downloads'
| 'retry-failed-downloads'

View File

@@ -1,6 +1,6 @@
import useDefaultStyles from 'components/Colors'; import useDefaultStyles from 'components/Colors';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { Text, TouchableOpacity, View } from 'react-native'; import { FlatListProps, Text, TouchableOpacity, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler'; import { FlatList } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useTypedSelector } from 'store'; import { useTypedSelector } from 'store';
@@ -14,6 +14,16 @@ import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
import Button from 'components/Button'; import Button from 'components/Button';
import { t } from 'i18n-js'; import { t } from 'i18n-js';
import DownloadIcon from 'components/DownloadIcon'; import DownloadIcon from 'components/DownloadIcon';
import styled from 'styled-components/native';
const DownloadedTrack = styled.View`
flex: 1 0 auto;
flex-direction: row;
padding: 8px 0;
align-items: center;
margin: 0 20px;
border-bottom-width: 1px;
`;
function Downloads() { function Downloads() {
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
@@ -27,40 +37,38 @@ function Downloads() {
ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0) ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0)
), [ids, entities]); ), [ids, entities]);
// Describe handlers /**
* Handlers for actions in this components
*/
// Delete a single downloaded track
const handleDelete = useCallback((id: EntityId) => { const handleDelete = useCallback((id: EntityId) => {
dispatch(removeDownloadedTrack(id)); dispatch(removeDownloadedTrack(id));
}, [dispatch]); }, [dispatch]);
const handleDeleteAllTracks = useCallback(() => {
ids.forEach((id) => dispatch(removeDownloadedTrack(id))); // Delete all downloaded tracks
}, [dispatch, ids]); const handleDeleteAllTracks = useCallback(() => ids.forEach(handleDelete), [handleDelete, ids]);
// Retry a single failed track
const retryTrack = useCallback((id: EntityId) => { const retryTrack = useCallback((id: EntityId) => {
dispatch(downloadTrack(id)); dispatch(downloadTrack(id));
}, [dispatch]); }, [dispatch]);
// If no tracks have beend ownloaded, show a short message describing this // Retry all failed tracks
if (!ids.length) { const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]);
return ( const handleRetryFailed = useCallback(() => (
<View style={{ margin: 24, flex: 1, alignItems: 'center', justifyContent: 'center' }}> failedIds.forEach(retryTrack)
<Text style={[{ textAlign: 'center'}, defaultStyles.textHalfOpacity]}> ), [failedIds, retryTrack]);
{t('no-downloads')}
</Text>
</View>
);
}
return ( /**
<SafeAreaView style={{ flex: 1 }}> * Render section
<FlatList */
style={{ flex: 1 }}
contentContainerStyle={{ flexGrow: 1 }} const ListHeaderComponent = useMemo(() => (
ListHeaderComponent={ <View style={{ marginHorizontal: 20, marginBottom: 12 }}>
<Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}> <Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}>
{t('total-download-size')}: {formatBytes(totalDownloadSize)} {t('total-download-size')}: {formatBytes(totalDownloadSize)}
</Text> </Text>
}
ListFooterComponent={
<View style={{ marginHorizontal: 20 }}>
<Button <Button
icon={TrashIcon} icon={TrashIcon}
title={t('delete-all-tracks')} title={t('delete-all-tracks')}
@@ -68,23 +76,18 @@ function Downloads() {
disabled={!ids.length} disabled={!ids.length}
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
/> />
<Button
icon={ArrowClockwise}
title={t('retry-failed-downloads')}
onPress={handleRetryFailed}
disabled={failedIds.length === 0}
style={{ marginTop: 4 }}
/>
</View> </View>
} ), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
data={ids}
renderItem={({ item }) => ( const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
<View <DownloadedTrack style={defaultStyles.border}>
style={[
{
flex: 1,
flexDirection: 'row',
paddingVertical: 12,
alignItems: 'center',
marginHorizontal: 20,
borderBottomWidth: 1,
},
defaultStyles.border,
]}
>
<View style={{ marginRight: 12 }}> <View style={{ marginRight: 12 }}>
<DownloadIcon trackId={item} /> <DownloadIcon trackId={item} />
</View> </View>
@@ -111,8 +114,28 @@ function Downloads() {
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
</DownloadedTrack>
), [entities, retryTrack, handleDelete, defaultStyles, tracks]);
// If no tracks have been downloaded, show a short message describing this
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> </View>
)} );
}
return (
<SafeAreaView style={{ flex: 1 }}>
<FlatList
data={ids}
style={{ flex: 1 }}
contentContainerStyle={{ flexGrow: 1 }}
ListHeaderComponent={ListHeaderComponent}
renderItem={renderItem}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -9,11 +9,13 @@ import { t } from '@localisation';
import PlayIcon from 'assets/play.svg'; import PlayIcon from 'assets/play.svg';
import DownloadIcon from 'assets/cloud-down-arrow.svg'; import DownloadIcon from 'assets/cloud-down-arrow.svg';
import QueueAppendIcon from 'assets/queue-append.svg'; import QueueAppendIcon from 'assets/queue-append.svg';
import TrashIcon from 'assets/trash.svg';
import Text from 'components/Text'; import Text from 'components/Text';
import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow'; import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { downloadTrack } from 'store/downloads/actions'; import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
import usePlayTracks from 'utility/usePlayTracks'; import usePlayTracks from 'utility/usePlayTracks';
import { selectIsDownloaded } from 'store/downloads/selectors';
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>; type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
@@ -24,30 +26,46 @@ const Container = styled.View`
`; `;
function TrackPopupMenu() { function TrackPopupMenu() {
// Retrieve helpers // Retrieve trackId from route
const { params: { trackId } } = useRoute<Route>(); const { params: { trackId } } = useRoute<Route>();
// Retrieve helpers
const navigation = useNavigation(); const navigation = useNavigation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
const playTracks = usePlayTracks(); const playTracks = usePlayTracks();
// Retrieve data from store
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
const isDownloaded = useTypedSelector(selectIsDownloaded(trackId));
// Set callback to close the modal // Set callback to close the modal
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
navigation.dispatch(StackActions.popToTop()); navigation.dispatch(StackActions.popToTop());
}, [navigation]); }, [navigation]);
// Callback for adding the track to the queue as the next song
const handlePlayNext = useCallback(() => { const handlePlayNext = useCallback(() => {
playTracks([trackId], { method: 'add-after-currently-playing', play: false }); playTracks([trackId], { method: 'add-after-currently-playing', play: false });
closeModal(); closeModal();
}, [playTracks, closeModal, trackId]); }, [playTracks, closeModal, trackId]);
// Callback for adding the track to the end of the queue
const handleAddToQueue = useCallback(() => { const handleAddToQueue = useCallback(() => {
playTracks([trackId], { method: 'add-to-end', play: false }); playTracks([trackId], { method: 'add-to-end', play: false });
closeModal(); closeModal();
}, [playTracks, closeModal, trackId]); }, [playTracks, closeModal, trackId]);
// Callback for downloading the track
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
dispatch(downloadTrack(trackId)); dispatch(downloadTrack(trackId));
closeModal(); closeModal();
}, [trackId, dispatch]); }, [trackId, dispatch, closeModal]);
// Callback for removing the downloaded track
const handleDelete = useCallback(() => {
dispatch(removeDownloadedTrack(trackId));
closeModal();
}, [trackId, dispatch, closeModal]);
return ( return (
<Modal fullSize={false}> <Modal fullSize={false}>
@@ -57,7 +75,11 @@ function TrackPopupMenu() {
<WrappableButtonRow> <WrappableButtonRow>
<WrappableButton title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} /> <WrappableButton title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
<WrappableButton title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} /> <WrappableButton title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
{isDownloaded ? (
<WrappableButton title={t('delete-track')} icon={TrashIcon} onPress={handleDelete} />
) : (
<WrappableButton title={t('download-track')} icon={DownloadIcon} onPress={handleDownload} /> <WrappableButton title={t('download-track')} icon={DownloadIcon} onPress={handleDownload} />
)}
</WrappableButtonRow> </WrappableButtonRow>
</Container> </Container>
</Modal> </Modal>

View File

@@ -26,7 +26,7 @@ export const downloadTrack = createAsyncThunk(
// Actually kick off the download // Actually kick off the download
const { promise } = await downloadFile({ const { promise } = await downloadFile({
fromUrl: url, fromUrl: url,
progressInterval: 50, progressInterval: 250,
background: true, background: true,
begin: ({ jobId, contentLength }) => { begin: ({ jobId, contentLength }) => {
// Dispatch the initialization // Dispatch the initialization

View File

@@ -2,12 +2,28 @@ import { createSelector, EntityId } from '@reduxjs/toolkit';
import { intersection } from 'lodash'; import { intersection } from 'lodash';
import { AppState } from 'store'; import { AppState } from 'store';
export const selectAllDownloads = (state: AppState) => state.downloads;
export const selectDownloadedEntities = (state: AppState) => state.downloads.entities;
/**
* Only retain the supplied trackIds that have successfully been downloaded
*/
export const selectDownloadedTracks = (trackIds: EntityId[]) => ( export const selectDownloadedTracks = (trackIds: EntityId[]) => (
createSelector( createSelector(
(state: AppState) => state.downloads, selectAllDownloads,
({ entities, ids }) => { ({ entities, ids }) => {
return intersection(trackIds, ids) return intersection(trackIds, ids)
.filter((id) => entities[id]?.isComplete); .filter((id) => entities[id]?.isComplete);
} }
) )
); );
/**
* Select a boolean that indicates whether the track is downloaded
*/
export const selectIsDownloaded = (trackId: string) => (
createSelector(
selectDownloadedEntities,
(entities) => entities[trackId]?.isComplete,
)
);