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';
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>

View File

@@ -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>
);
}

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;