Queue downloads seperately so we don't overwhelm the app
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,28 +66,27 @@ 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 (
|
||||||
|
<DownloadContainer>
|
||||||
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
|
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
|
||||||
<Circle
|
<Circle
|
||||||
cx={radius}
|
cx={radius}
|
||||||
@@ -89,6 +102,10 @@ function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
|
|||||||
fill='transparent'
|
fill='transparent'
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</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",
|
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user