Queue downloads seperately so we don't overwhelm the app
This commit is contained in:
@@ -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 {
|
||||
<ColorSchemeContext.Provider value={theme}>
|
||||
<NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Routes />
|
||||
<DownloadManager />
|
||||
</NavigationContainer>
|
||||
</ColorSchemeContext.Provider>
|
||||
</PersistGate>
|
||||
|
||||
@@ -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 (
|
||||
<CloudIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
const { isComplete, isFailed } = entity;
|
||||
|
||||
if (isComplete) {
|
||||
if (entity?.isComplete) {
|
||||
return (
|
||||
<InternalDriveIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
if (entity?.isFailed) {
|
||||
return (
|
||||
<CloudExclamationMarkIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isComplete && !isFailed) {
|
||||
if (isQueued || (!entity?.isFailed && !entity?.isComplete)) {
|
||||
return (
|
||||
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
|
||||
<Circle
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
r={radius - 1}
|
||||
stroke={iconFill}
|
||||
// @ts-expect-error react-native-svg has outdated react-native typings
|
||||
ref={circleRef}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={[ circumference, circumference ]}
|
||||
strokeDashoffset={circumference}
|
||||
strokeLinecap='round'
|
||||
fill='transparent'
|
||||
/>
|
||||
</Svg>
|
||||
<DownloadContainer>
|
||||
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
|
||||
<Circle
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
r={radius - 1}
|
||||
stroke={iconFill}
|
||||
// @ts-expect-error react-native-svg has outdated react-native typings
|
||||
ref={circleRef}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={[ circumference, circumference ]}
|
||||
strokeDashoffset={circumference}
|
||||
strokeLinecap='round'
|
||||
fill='transparent'
|
||||
/>
|
||||
</Svg>
|
||||
<IconOverlay>
|
||||
<CloudDownArrow width={size} height={size} fill={iconFill} />
|
||||
</IconOverlay>
|
||||
</DownloadContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
110
src/components/DownloadManager.ts
Normal file
110
src/components/DownloadManager.ts
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TrackListViewProps> = ({
|
||||
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)));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const downloadAdapter = createEntityAdapter<DownloadEntity>({
|
||||
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 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`);
|
||||
}
|
||||
|
||||
@@ -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<DownloadEntity>;
|
||||
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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<AppState> = {
|
||||
const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
|
||||
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<AppState> = {
|
||||
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<typeof reducers>;
|
||||
export type AppState = ReturnType<typeof reducers> & { _persist: PersistState };
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
|
||||
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
|
||||
Reference in New Issue
Block a user