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;