Queue downloads seperately so we don't overwhelm the app

This commit is contained in:
Lei Nelissen
2022-01-15 17:25:24 +01:00
parent 81ccb6b1f9
commit 7ea4857997
10 changed files with 199 additions and 33 deletions

View File

@@ -11,6 +11,7 @@ import {
} from '@react-navigation/native'; } from '@react-navigation/native';
import { useColorScheme } from 'react-native'; import { useColorScheme } from 'react-native';
import { ColorSchemeContext, themes } from './Colors'; import { ColorSchemeContext, themes } from './Colors';
import DownloadManager from './DownloadManager';
// import ErrorReportingAlert from 'utility/ErrorReportingAlert'; // import ErrorReportingAlert from 'utility/ErrorReportingAlert';
export default function App(): JSX.Element { export default function App(): JSX.Element {
@@ -41,6 +42,7 @@ export default function App(): JSX.Element {
<ColorSchemeContext.Provider value={theme}> <ColorSchemeContext.Provider value={theme}>
<NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Routes /> <Routes />
<DownloadManager />
</NavigationContainer> </NavigationContainer>
</ColorSchemeContext.Provider> </ColorSchemeContext.Provider>
</PersistGate> </PersistGate>

View File

@@ -1,12 +1,14 @@
import React, { useEffect, useMemo, useRef } 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 CloudDownArrow from 'assets/cloud-down-arrow.svg';
import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg'; import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg';
import InternalDriveIcon from 'assets/internal-drive.svg'; 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, CircleProps } from 'react-native-svg'; import Svg, { Circle, CircleProps } from 'react-native-svg';
import { Animated, Easing } from 'react-native'; import { Animated, Easing } from 'react-native';
import styled from 'styled-components/native';
interface DownloadIconProps { interface DownloadIconProps {
trackId: EntityId; trackId: EntityId;
@@ -14,6 +16,17 @@ interface DownloadIconProps {
fill?: string; 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) { function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
// determine styles // determine styles
const defaultStyles = useDefaultStyles(); const defaultStyles = useDefaultStyles();
@@ -21,6 +34,7 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
// Get download icon from state // Get download icon from state
const entity = useTypedSelector((state) => state.downloads.entities[trackId]); 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 // Memoize calculations for radius and circumference of the circle
const radius = useMemo(() => size / 2, [size]); const radius = useMemo(() => size / 2, [size]);
@@ -52,43 +66,46 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
return () => offsetAnimation.removeListener(subscription); return () => offsetAnimation.removeListener(subscription);
}, [offsetAnimation]); }, [offsetAnimation]);
if (!entity) { if (!entity && !isQueued) {
return ( return (
<CloudIcon width={size} height={size} fill={iconFill} /> <CloudIcon width={size} height={size} fill={iconFill} />
); );
} }
const { isComplete, isFailed } = entity; if (entity?.isComplete) {
if (isComplete) {
return ( return (
<InternalDriveIcon width={size} height={size} fill={iconFill} /> <InternalDriveIcon width={size} height={size} fill={iconFill} />
); );
} }
if (isFailed) { if (entity?.isFailed) {
return ( return (
<CloudExclamationMarkIcon width={size} height={size} fill={iconFill} /> <CloudExclamationMarkIcon width={size} height={size} fill={iconFill} />
); );
} }
if (!isComplete && !isFailed) { if (isQueued || (!entity?.isFailed && !entity?.isComplete)) {
return ( return (
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}> <DownloadContainer>
<Circle <Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
cx={radius} <Circle
cy={radius} cx={radius}
r={radius - 1} cy={radius}
stroke={iconFill} r={radius - 1}
// @ts-expect-error react-native-svg has outdated react-native typings stroke={iconFill}
ref={circleRef} // @ts-expect-error react-native-svg has outdated react-native typings
strokeWidth={1.5} ref={circleRef}
strokeDasharray={[ circumference, circumference ]} strokeWidth={1.5}
strokeDashoffset={circumference} strokeDasharray={[ circumference, circumference ]}
strokeLinecap='round' strokeDashoffset={circumference}
fill='transparent' strokeLinecap='round'
/> fill='transparent'
</Svg> />
</Svg>
<IconOverlay>
<CloudDownArrow width={size} height={size} fill={iconFill} />
</IconOverlay>
</DownloadContainer>
); );
} }

View File

@@ -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<EntityId>());
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;

View File

@@ -51,7 +51,7 @@
"download-playlist": "Download Playlist", "download-playlist": "Download Playlist",
"no-downloads": "Je hebt nog geen nummers gedownload", "no-downloads": "Je hebt nog geen nummers gedownload",
"delete-track": "Verwijder Track", "delete-track": "Verwijder Track",
"delete-all-tracks": "Verwijder alle nummbers", "delete-all-tracks": "Verwijder alle nummers",
"delete-album": "Verwijder Album", "delete-album": "Verwijder Album",
"delete-playlist": "Verwijder Playlist", "delete-playlist": "Verwijder Playlist",
"total-download-size": "Totale grootte downloads", "total-download-size": "Totale grootte downloads",

View File

@@ -10,7 +10,7 @@ import ArrowClockwise from 'assets/arrow-clockwise.svg';
import { THEME_COLOR } from 'CONSTANTS'; import { THEME_COLOR } from 'CONSTANTS';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { EntityId } from '@reduxjs/toolkit'; 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 Button from 'components/Button';
import { t } from 'i18n-js'; import { t } from 'i18n-js';
import DownloadIcon from 'components/DownloadIcon'; import DownloadIcon from 'components/DownloadIcon';
@@ -51,7 +51,7 @@ function Downloads() {
// Retry a single failed track // Retry a single failed track
const retryTrack = useCallback((id: EntityId) => { const retryTrack = useCallback((id: EntityId) => {
dispatch(downloadTrack(id)); dispatch(queueTrackForDownload(id));
}, [dispatch]); }, [dispatch]);
// Retry all failed tracks // Retry all failed tracks

View File

@@ -20,7 +20,7 @@ import DownloadIcon from 'components/DownloadIcon';
import CloudDownArrow from 'assets/cloud-down-arrow.svg'; import CloudDownArrow from 'assets/cloud-down-arrow.svg';
import Trash from 'assets/trash.svg'; import Trash from 'assets/trash.svg';
import { useDispatch } from 'react-redux'; 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'; import { selectDownloadedTracks } from 'store/downloads/selectors';
const Screen = Dimensions.get('screen'); const Screen = Dimensions.get('screen');
@@ -111,7 +111,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] }); navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
}, [navigation, trackIds]); }, [navigation, trackIds]);
const downloadAllTracks = useCallback(() => { const downloadAllTracks = useCallback(() => {
trackIds.forEach((trackId) => dispatch(downloadTrack(trackId))); trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId)));
}, [dispatch, trackIds]); }, [dispatch, trackIds]);
const deleteAllTracks = useCallback(() => { const deleteAllTracks = useCallback(() => {
downloadedTracks.forEach((trackId) => dispatch(removeDownloadedTrack(trackId))); downloadedTracks.forEach((trackId) => dispatch(removeDownloadedTrack(trackId)));

View File

@@ -13,7 +13,7 @@ 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, removeDownloadedTrack } from 'store/downloads/actions'; import { queueTrackForDownload, removeDownloadedTrack } from 'store/downloads/actions';
import usePlayTracks from 'utility/usePlayTracks'; import usePlayTracks from 'utility/usePlayTracks';
import { selectIsDownloaded } from 'store/downloads/selectors'; import { selectIsDownloaded } from 'store/downloads/selectors';
@@ -57,7 +57,7 @@ function TrackPopupMenu() {
// Callback for downloading the track // Callback for downloading the track
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
dispatch(downloadTrack(trackId)); dispatch(queueTrackForDownload(trackId));
closeModal(); closeModal();
}, [trackId, dispatch, closeModal]); }, [trackId, dispatch, closeModal]);

View File

@@ -8,6 +8,7 @@ export const downloadAdapter = createEntityAdapter<DownloadEntity>({
selectId: (entity) => entity.id, selectId: (entity) => entity.id,
}); });
export const queueTrackForDownload = createAction<EntityId>('download/queue');
export const initializeDownload = createAction<{ id: EntityId, size?: number, jobId?: number }>('download/initialize'); 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 progressDownload = createAction<{ id: EntityId, progress: number, jobId?: number }>('download/progress');
export const completeDownload = createAction<{ id: EntityId, location: string, size?: number }>('download/complete'); export const completeDownload = createAction<{ id: EntityId, location: string, size?: number }>('download/complete');
@@ -46,7 +47,7 @@ export const downloadTrack = createAsyncThunk(
); );
export const removeDownloadedTrack = createAsyncThunk( export const removeDownloadedTrack = createAsyncThunk(
'/downloads/track/remove', '/downloads/remove/track',
async(id: EntityId) => { async(id: EntityId) => {
return unlink(`${DocumentDirectoryPath}/${id}.mp3`); return unlink(`${DocumentDirectoryPath}/${id}.mp3`);
} }

View File

@@ -1,15 +1,25 @@
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit'; 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'; import { DownloadEntity } from './types';
interface State { interface State {
entities: Dictionary<DownloadEntity>; entities: Dictionary<DownloadEntity>;
ids: EntityId[]; ids: EntityId[];
queued: EntityId[];
} }
export const initialState: State = { export const initialState: State = {
entities: {}, entities: {},
ids: [], ids: [],
queued: [],
}; };
const downloads = createSlice({ const downloads = createSlice({
@@ -32,6 +42,7 @@ const downloads = createSlice({
}); });
}); });
builder.addCase(completeDownload, (state, action) => { builder.addCase(completeDownload, (state, action) => {
// Update the item to be completed
downloadAdapter.updateOne(state, { downloadAdapter.updateOne(state, {
id: action.payload.id, id: action.payload.id,
changes: { changes: {
@@ -40,6 +51,11 @@ const downloads = createSlice({
isComplete: true, 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) => { builder.addCase(failDownload, (state, action) => {
downloadAdapter.updateOne(state, { downloadAdapter.updateOne(state, {
@@ -52,7 +68,17 @@ const downloads = createSlice({
}); });
}); });
builder.addCase(removeDownloadedTrack.fulfilled, (state, action) => { builder.addCase(removeDownloadedTrack.fulfilled, (state, action) => {
// Remove the download if it exists
downloadAdapter.removeOne(state, action.meta.arg); 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);
}); });
}, },
}); });

View File

@@ -9,10 +9,10 @@ import music, { initialState as musicInitialState } from './music';
import downloads, { initialState as downloadsInitialState } from './downloads'; import downloads, { initialState as downloadsInitialState } from './downloads';
import { PersistState } from 'redux-persist/es/types'; import { PersistState } from 'redux-persist/es/types';
const persistConfig: PersistConfig<AppState> = { const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
key: 'root', key: 'root',
storage: AsyncStorage, storage: AsyncStorage,
version: 1, version: 2,
stateReconciler: autoMergeLevel2, stateReconciler: autoMergeLevel2,
migrate: createMigrate({ migrate: createMigrate({
// @ts-expect-error migrations are poorly typed // @ts-expect-error migrations are poorly typed
@@ -23,6 +23,16 @@ const persistConfig: PersistConfig<AppState> = {
downloads: downloadsInitialState, downloads: downloadsInitialState,
music: musicInitialState 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<typeof reducers>; export type AppState = ReturnType<typeof reducers> & { _persist: PersistState };
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch }; export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector; export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;