diff --git a/src/components/DownloadIcon.tsx b/src/components/DownloadIcon.tsx index 8fe41ee..71bbfc0 100644 --- a/src/components/DownloadIcon.tsx +++ b/src/components/DownloadIcon.tsx @@ -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(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 ( diff --git a/src/localisation/lang/en/locale.json b/src/localisation/lang/en/locale.json index 1e5bb70..454eaa9 100644 --- a/src/localisation/lang/en/locale.json +++ b/src/localisation/lang/en/locale.json @@ -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" } \ No newline at end of file diff --git a/src/localisation/types.ts b/src/localisation/types.ts index c0d929e..513130b 100644 --- a/src/localisation/types.ts +++ b/src/localisation/types.ts @@ -50,5 +50,7 @@ export type LocaleKeys = 'play-next' | 'download-playlist' | 'delete-album' | 'delete-playlist' +| 'delete-track' | 'total-download-size' -| 'no-downloads' \ No newline at end of file +| 'no-downloads' +| 'retry-failed-downloads' \ No newline at end of file diff --git a/src/screens/Downloads/index.tsx b/src/screens/Downloads/index.tsx index 320c31c..3f6af97 100644 --- a/src/screens/Downloads/index.tsx +++ b/src/screens/Downloads/index.tsx @@ -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((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(() => ( + + + {t('total-download-size')}: {formatBytes(totalDownloadSize)} + + + + + ), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]); + + const renderItem = useCallback['renderItem']>>(({ item }) => ( + + + + + + + {tracks[item]?.Name} + + + {tracks[item]?.AlbumArtist} ({tracks[item]?.Album}) + + + + {entities[item]?.isComplete && entities[item]?.size ? ( + + {formatBytes(entities[item]?.size || 0)} + + ) : null} + handleDelete(item)}> + + + {!entities[item]?.isComplete && ( + retryTrack(item)}> + + + )} + + + ), [entities, retryTrack, handleDelete, defaultStyles, tracks]); + + // If no tracks have been downloaded, show a short message describing this if (!ids.length) { return ( @@ -52,67 +131,11 @@ function Downloads() { return ( - {t('total-download-size')}: {formatBytes(totalDownloadSize)} - - } - ListFooterComponent={ - - - - } - data={ids} - renderItem={({ item }) => ( - - - - - - - {tracks[item]?.Name} - - - {tracks[item]?.AlbumArtist} ({tracks[item]?.Album}) - - - - {entities[item]?.isComplete && entities[item]?.size ? ( - - {formatBytes(entities[item]?.size || 0)} - - ) : null} - handleDelete(item)}> - - - {!entities[item]?.isComplete && ( - retryTrack(item)}> - - - )} - - - )} + ListHeaderComponent={ListHeaderComponent} + renderItem={renderItem} /> ); diff --git a/src/screens/modals/TrackPopupMenu.tsx b/src/screens/modals/TrackPopupMenu.tsx index be03a01..93f3a0f 100644 --- a/src/screens/modals/TrackPopupMenu.tsx +++ b/src/screens/modals/TrackPopupMenu.tsx @@ -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; @@ -24,30 +26,46 @@ const Container = styled.View` `; function TrackPopupMenu() { - // Retrieve helpers + // Retrieve trackId from route const { params: { trackId } } = useRoute(); + + // 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 ( @@ -57,7 +75,11 @@ function TrackPopupMenu() { - + {isDownloaded ? ( + + ) : ( + + )} diff --git a/src/store/downloads/actions.ts b/src/store/downloads/actions.ts index e20cb30..fa310c4 100644 --- a/src/store/downloads/actions.ts +++ b/src/store/downloads/actions.ts @@ -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 diff --git a/src/store/downloads/selectors.ts b/src/store/downloads/selectors.ts index cb77823..f2ef04e 100644 --- a/src/store/downloads/selectors.ts +++ b/src/store/downloads/selectors.ts @@ -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, + ) +); \ No newline at end of file