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 CloudIcon from 'assets/cloud.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 { EntityId } from '@reduxjs/toolkit';
import Svg, { Circle } from 'react-native-svg';
import { Animated, Easing } from 'react-native';
interface DownloadIconProps {
trackId: EntityId;
@@ -14,10 +15,40 @@ interface DownloadIconProps {
}
function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
// determine styles
const defaultStyles = useDefaultStyles();
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
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) {
return (
@@ -25,7 +56,7 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
);
}
const { isComplete, isFailed, progress } = entity;
const { isComplete, isFailed } = entity;
if (isComplete) {
return (
@@ -40,9 +71,6 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
}
if (!isComplete && !isFailed) {
const radius = size / 2;
const circumference = radius * 2 * Math.PI;
return (
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
<Circle
@@ -50,9 +78,10 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
cy={radius}
r={radius - 1}
stroke={iconFill}
ref={circleRef}
strokeWidth={1.5}
strokeDasharray={[ circumference, circumference ]}
strokeDashoffset={circumference * (1 - progress)}
strokeDashoffset={circumference}
strokeLinecap='round'
fill='transparent'
/>

View File

@@ -50,8 +50,10 @@
"download-album": "Download Album",
"download-playlist": "Download Playlist",
"no-downloads": "You have not yet downloaded any tracks",
"delete-track": "Delete Track",
"delete-all-tracks": "Delete All Tracks",
"delete-album": "Delete Album",
"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'
| 'delete-album'
| 'delete-playlist'
| 'delete-track'
| 'total-download-size'
| 'no-downloads'
| 'no-downloads'
| 'retry-failed-downloads'

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,28 @@ import { createSelector, EntityId } from '@reduxjs/toolkit';
import { intersection } from 'lodash';
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[]) => (
createSelector(
(state: AppState) => state.downloads,
selectAllDownloads,
({ entities, ids }) => {
return intersection(trackIds, ids)
.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,
)
);