From 7ea4857997f3bd3261b45469d6451cb660fb12b9 Mon Sep 17 00:00:00 2001 From: Lei Nelissen Date: Sat, 15 Jan 2022 17:25:24 +0100 Subject: [PATCH] Queue downloads seperately so we don't overwhelm the app --- src/components/App.tsx | 2 + src/components/DownloadIcon.tsx | 59 ++++++---- src/components/DownloadManager.ts | 110 ++++++++++++++++++ src/localisation/lang/nl/locale.json | 2 +- src/screens/Downloads/index.tsx | 4 +- .../Music/stacks/components/TrackListView.tsx | 4 +- src/screens/modals/TrackPopupMenu.tsx | 4 +- src/store/downloads/actions.ts | 3 +- src/store/downloads/index.ts | 28 ++++- src/store/index.ts | 16 ++- 10 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 src/components/DownloadManager.ts diff --git a/src/components/App.tsx b/src/components/App.tsx index 2300b03..712c699 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -11,6 +11,7 @@ import { } from '@react-navigation/native'; import { useColorScheme } from 'react-native'; import { ColorSchemeContext, themes } from './Colors'; +import DownloadManager from './DownloadManager'; // import ErrorReportingAlert from 'utility/ErrorReportingAlert'; export default function App(): JSX.Element { @@ -41,6 +42,7 @@ export default function App(): JSX.Element { + diff --git a/src/components/DownloadIcon.tsx b/src/components/DownloadIcon.tsx index 7aa3550..6dce0e5 100644 --- a/src/components/DownloadIcon.tsx +++ b/src/components/DownloadIcon.tsx @@ -1,12 +1,14 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { useTypedSelector } from 'store'; import CloudIcon from 'assets/cloud.svg'; +import CloudDownArrow from 'assets/cloud-down-arrow.svg'; import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg'; import InternalDriveIcon from 'assets/internal-drive.svg'; import useDefaultStyles from './Colors'; import { EntityId } from '@reduxjs/toolkit'; import Svg, { Circle, CircleProps } from 'react-native-svg'; import { Animated, Easing } from 'react-native'; +import styled from 'styled-components/native'; interface DownloadIconProps { trackId: EntityId; @@ -14,6 +16,17 @@ interface DownloadIconProps { fill?: string; } +const DownloadContainer = styled.View` + position: relative; +`; + +const IconOverlay = styled.View` + position: absolute; + top: 0; + left: 0; + transform: scale(0.5); +`; + function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) { // determine styles const defaultStyles = useDefaultStyles(); @@ -21,6 +34,7 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) { // Get download icon from state const entity = useTypedSelector((state) => state.downloads.entities[trackId]); + const isQueued = useTypedSelector((state) => state.downloads.queued.includes(trackId)); // Memoize calculations for radius and circumference of the circle const radius = useMemo(() => size / 2, [size]); @@ -52,43 +66,46 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) { return () => offsetAnimation.removeListener(subscription); }, [offsetAnimation]); - if (!entity) { + if (!entity && !isQueued) { return ( ); } - const { isComplete, isFailed } = entity; - - if (isComplete) { + if (entity?.isComplete) { return ( ); } - if (isFailed) { + if (entity?.isFailed) { return ( ); } - if (!isComplete && !isFailed) { + if (isQueued || (!entity?.isFailed && !entity?.isComplete)) { return ( - - - + + + + + + + + ); } diff --git a/src/components/DownloadManager.ts b/src/components/DownloadManager.ts new file mode 100644 index 0000000..e700851 --- /dev/null +++ b/src/components/DownloadManager.ts @@ -0,0 +1,110 @@ +import { EntityId } from '@reduxjs/toolkit'; +import { xor } from 'lodash'; +import { useEffect, useRef, useState } from 'react'; +import { DocumentDirectoryPath, readDir } from 'react-native-fs'; +import { useDispatch } from 'react-redux'; +import { useTypedSelector } from 'store'; +import { completeDownload, downloadTrack } from 'store/downloads/actions'; + +/** + * The maximum number of concurrent downloads we allow to take place at once. + * This is hardcoded at 5 for now, but might be extracted to a setting later. + */ +const MAX_CONCURRENT_DOWNLOADS = 5; + +/** + * This is a component that tracks queued downloads, and starts them one-by-one, + * so that we don't overload react-native-fs, as well as the render performance. + */ +function DownloadManager () { + // Retrieve store helpers + const { queued, ids } = useTypedSelector((state) => state.downloads); + const rehydrated = useTypedSelector((state) => state._persist.rehydrated); + const dispatch = useDispatch(); + + // Keep state for the currently active downloads (i.e. the downloads that + // have actually been pushed out to react-native-fs). + const [hasRehydratedOrphans, setHasRehydratedOrphans] = useState(false); + const activeDownloads = useRef(new Set()); + + useEffect(() => { + // GUARD: Check if the queue is empty + if (!queued.length) { + // If so, clear any current downloads + activeDownloads.current.clear(); + return; + } + + // Apparently, the queue has changed, and we need to manage + // First, we pick the first n downloads + const queue = queued.slice(0, MAX_CONCURRENT_DOWNLOADS); + + // We then filter for new downloads + queue.filter((id) => !activeDownloads.current.has(id)) + .forEach((id) => { + // We dispatch the actual call to start downloading + dispatch(downloadTrack(id)); + // And add it to the active downloads + activeDownloads.current.add(id); + }); + + // Lastly, if something isn't part of the queue, but is of active + // downloads, we can assume the download completed. + xor(Array.from(activeDownloads.current), queue) + .forEach((id) => activeDownloads.current.delete(id)); + + }, [queued, dispatch, activeDownloads]); + + useEffect(() => { + // GUARD: We only run this functino once + if (hasRehydratedOrphans) { + return; + } + + // GUARD: If the state has not been rehydrated, we cannot check against + // the store ids. + if (!rehydrated) { + return; + } + + console.log(ids); + + /** + * Whenever the store is cleared, existing downloads get "lost" because + * the only reference we have is the store. This function checks for + * those lost downloads and adds them to the store + */ + async function hydrateOrphanedDownloads() { + // Retrieve all files for this app + const files = await readDir(DocumentDirectoryPath); + + // Loop through the mp3 files + files.filter((file) => file.isFile() && file.name.endsWith('.mp3')) + .forEach((file) => { + const id = file.name.replace('.mp3', ''); + console.log(id, ids.includes(id)); + + // GUARD: If the id is already in the store, there's nothing + // left for us to do. + if (ids.includes(id)) { + return; + } + + // Add the download to the store + dispatch(completeDownload({ + id, + location: file.path, + size: Number.parseInt(file.size), + })); + }); + } + + hydrateOrphanedDownloads(); + setHasRehydratedOrphans(true); + }, [rehydrated, ids, hasRehydratedOrphans, dispatch]); + + + return null; +} + +export default DownloadManager; diff --git a/src/localisation/lang/nl/locale.json b/src/localisation/lang/nl/locale.json index 16580e1..3a7d612 100644 --- a/src/localisation/lang/nl/locale.json +++ b/src/localisation/lang/nl/locale.json @@ -51,7 +51,7 @@ "download-playlist": "Download Playlist", "no-downloads": "Je hebt nog geen nummers gedownload", "delete-track": "Verwijder Track", - "delete-all-tracks": "Verwijder alle nummbers", + "delete-all-tracks": "Verwijder alle nummers", "delete-album": "Verwijder Album", "delete-playlist": "Verwijder Playlist", "total-download-size": "Totale grootte downloads", diff --git a/src/screens/Downloads/index.tsx b/src/screens/Downloads/index.tsx index 1e17f72..9b7c171 100644 --- a/src/screens/Downloads/index.tsx +++ b/src/screens/Downloads/index.tsx @@ -10,7 +10,7 @@ import ArrowClockwise from 'assets/arrow-clockwise.svg'; import { THEME_COLOR } from 'CONSTANTS'; import { useDispatch } from 'react-redux'; import { EntityId } from '@reduxjs/toolkit'; -import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions'; +import { queueTrackForDownload, removeDownloadedTrack } from 'store/downloads/actions'; import Button from 'components/Button'; import { t } from 'i18n-js'; import DownloadIcon from 'components/DownloadIcon'; @@ -51,7 +51,7 @@ function Downloads() { // Retry a single failed track const retryTrack = useCallback((id: EntityId) => { - dispatch(downloadTrack(id)); + dispatch(queueTrackForDownload(id)); }, [dispatch]); // Retry all failed tracks diff --git a/src/screens/Music/stacks/components/TrackListView.tsx b/src/screens/Music/stacks/components/TrackListView.tsx index 08f97e6..e6377b9 100644 --- a/src/screens/Music/stacks/components/TrackListView.tsx +++ b/src/screens/Music/stacks/components/TrackListView.tsx @@ -20,7 +20,7 @@ import DownloadIcon from 'components/DownloadIcon'; import CloudDownArrow from 'assets/cloud-down-arrow.svg'; import Trash from 'assets/trash.svg'; import { useDispatch } from 'react-redux'; -import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions'; +import { queueTrackForDownload, removeDownloadedTrack } from 'store/downloads/actions'; import { selectDownloadedTracks } from 'store/downloads/selectors'; const Screen = Dimensions.get('screen'); @@ -111,7 +111,7 @@ const TrackListView: React.FC = ({ navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] }); }, [navigation, trackIds]); const downloadAllTracks = useCallback(() => { - trackIds.forEach((trackId) => dispatch(downloadTrack(trackId))); + trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId))); }, [dispatch, trackIds]); const deleteAllTracks = useCallback(() => { downloadedTracks.forEach((trackId) => dispatch(removeDownloadedTrack(trackId))); diff --git a/src/screens/modals/TrackPopupMenu.tsx b/src/screens/modals/TrackPopupMenu.tsx index 93f3a0f..275dd87 100644 --- a/src/screens/modals/TrackPopupMenu.tsx +++ b/src/screens/modals/TrackPopupMenu.tsx @@ -13,7 +13,7 @@ import TrashIcon from 'assets/trash.svg'; import Text from 'components/Text'; import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow'; import { useDispatch } from 'react-redux'; -import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions'; +import { queueTrackForDownload, removeDownloadedTrack } from 'store/downloads/actions'; import usePlayTracks from 'utility/usePlayTracks'; import { selectIsDownloaded } from 'store/downloads/selectors'; @@ -57,7 +57,7 @@ function TrackPopupMenu() { // Callback for downloading the track const handleDownload = useCallback(() => { - dispatch(downloadTrack(trackId)); + dispatch(queueTrackForDownload(trackId)); closeModal(); }, [trackId, dispatch, closeModal]); diff --git a/src/store/downloads/actions.ts b/src/store/downloads/actions.ts index fa310c4..1b8a5f6 100644 --- a/src/store/downloads/actions.ts +++ b/src/store/downloads/actions.ts @@ -8,6 +8,7 @@ export const downloadAdapter = createEntityAdapter({ selectId: (entity) => entity.id, }); +export const queueTrackForDownload = createAction('download/queue'); export const initializeDownload = createAction<{ id: EntityId, size?: number, jobId?: number }>('download/initialize'); export const progressDownload = createAction<{ id: EntityId, progress: number, jobId?: number }>('download/progress'); export const completeDownload = createAction<{ id: EntityId, location: string, size?: number }>('download/complete'); @@ -46,7 +47,7 @@ export const downloadTrack = createAsyncThunk( ); export const removeDownloadedTrack = createAsyncThunk( - '/downloads/track/remove', + '/downloads/remove/track', async(id: EntityId) => { return unlink(`${DocumentDirectoryPath}/${id}.mp3`); } diff --git a/src/store/downloads/index.ts b/src/store/downloads/index.ts index fb14416..9b741a8 100644 --- a/src/store/downloads/index.ts +++ b/src/store/downloads/index.ts @@ -1,15 +1,25 @@ import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; -import { completeDownload, downloadAdapter, failDownload, initializeDownload, progressDownload, removeDownloadedTrack } from './actions'; +import { + completeDownload, + downloadAdapter, + failDownload, + initializeDownload, + progressDownload, + queueTrackForDownload, + removeDownloadedTrack +} from './actions'; import { DownloadEntity } from './types'; interface State { entities: Dictionary; ids: EntityId[]; + queued: EntityId[]; } export const initialState: State = { entities: {}, ids: [], + queued: [], }; const downloads = createSlice({ @@ -32,6 +42,7 @@ const downloads = createSlice({ }); }); builder.addCase(completeDownload, (state, action) => { + // Update the item to be completed downloadAdapter.updateOne(state, { id: action.payload.id, changes: { @@ -40,6 +51,11 @@ const downloads = createSlice({ isComplete: true, } }); + + // Remove the item from the queue + const newSet = new Set(state.queued); + newSet.delete(action.payload.id); + state.queued = Array.from(newSet); }); builder.addCase(failDownload, (state, action) => { downloadAdapter.updateOne(state, { @@ -52,7 +68,17 @@ const downloads = createSlice({ }); }); builder.addCase(removeDownloadedTrack.fulfilled, (state, action) => { + // Remove the download if it exists downloadAdapter.removeOne(state, action.meta.arg); + + // Remove the item from the queue if it is in there + const newSet = new Set(state.queued); + newSet.delete(action.meta.arg); + state.queued = Array.from(newSet); + }); + builder.addCase(queueTrackForDownload, (state, action) => { + const newSet = new Set(state.queued).add(action.payload); + state.queued = Array.from(newSet); }); }, }); diff --git a/src/store/index.ts b/src/store/index.ts index b7746da..5586ff7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -9,10 +9,10 @@ import music, { initialState as musicInitialState } from './music'; import downloads, { initialState as downloadsInitialState } from './downloads'; import { PersistState } from 'redux-persist/es/types'; -const persistConfig: PersistConfig = { +const persistConfig: PersistConfig> = { key: 'root', storage: AsyncStorage, - version: 1, + version: 2, stateReconciler: autoMergeLevel2, migrate: createMigrate({ // @ts-expect-error migrations are poorly typed @@ -23,6 +23,16 @@ const persistConfig: PersistConfig = { downloads: downloadsInitialState, music: musicInitialState }; + }, + // @ts-expect-error migrations are poorly typed + 2: (state: AppState) => { + return { + ...state, + downloads: { + ...state.downloads, + queued: [] + } + }; } }) }; @@ -48,7 +58,7 @@ const store = configureStore({ ), }); -export type AppState = ReturnType; +export type AppState = ReturnType & { _persist: PersistState }; export type AppDispatch = typeof store.dispatch; export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch }; export const useTypedSelector: TypedUseSelectorHook = useSelector;