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; } /** * 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', ''); // 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: file.size, })); }); } hydrateOrphanedDownloads(); setHasRehydratedOrphans(true); }, [rehydrated, ids, hasRehydratedOrphans, dispatch]); return null; } export default DownloadManager;