diff --git a/package-lock.json b/package-lock.json
index 0dd3ff0..e319130 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1613,6 +1613,12 @@
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
},
+ "@types/dom-mediacapture-record": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.4.tgz",
+ "integrity": "sha512-3u6HCFY83HQ0Aqs5wq+bTSGRfSb4QAYERz+7nvm4sJf2h48NkJlGlLi082j3+xwC6ce3en9mRl3QUCH8mlLWmg==",
+ "dev": true
+ },
"@types/eslint-visitor-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
@@ -12074,6 +12080,11 @@
"util.promisify": "^1.0.0"
}
},
+ "recorderjs": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/recorderjs/-/recorderjs-1.0.1.tgz",
+ "integrity": "sha1-+CEaD5C9zc+i9DGll7bLlKG5NXE="
+ },
"recursive-readdir": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz",
diff --git a/package.json b/package.json
index 78b6708..123608a 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"react-dropzone": "^10.2.1",
"react-redux": "^7.2.0",
"react-scripts": "3.3.1",
+ "recorderjs": "^1.0.1",
"redux-batched-actions": "^0.4.1",
"typescript": "~3.7.2",
"worker-loader": "^2.0.0"
@@ -65,6 +66,7 @@
]
},
"devDependencies": {
+ "@types/dom-mediacapture-record": "^1.0.4",
"gh-pages": "^2.2.0"
}
}
diff --git a/public/extra-service-worker.js b/public/extra-service-worker.js
index c7bce3e..40bfda8 100644
--- a/public/extra-service-worker.js
+++ b/public/extra-service-worker.js
@@ -1,6 +1,6 @@
// List of extra files to be precached for offline use.
// WARNING: don't forget to update the revision before deploy
-var revision = "7";
+var revision = "8";
self.__precacheManifest = (self.__precacheManifest || []).concat([
{
"revision": revision,
@@ -14,4 +14,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"revision": revision,
"url": "/webminidisc/ffmpeg-core.js"
},
+ {
+ "revision": revision,
+ "url": "/webminidisc/recorderWorker.js"
+ },
]);
diff --git a/public/recorderWorker.js b/public/recorderWorker.js
new file mode 120000
index 0000000..0feb165
--- /dev/null
+++ b/public/recorderWorker.js
@@ -0,0 +1 @@
+../node_modules/recorderjs/recorderWorker.js
\ No newline at end of file
diff --git a/src/components/dump-dialog.tsx b/src/components/dump-dialog.tsx
new file mode 100644
index 0000000..b0d09f4
--- /dev/null
+++ b/src/components/dump-dialog.tsx
@@ -0,0 +1,144 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import { useShallowEqualSelector } from '../utils';
+
+import { recordTracks } from '../redux/actions';
+import { actions as dumpDialogActions } from '../redux/dump-dialog-feature';
+
+import Dialog from '@material-ui/core/Dialog';
+import DialogActions from '@material-ui/core/DialogActions';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import Slide from '@material-ui/core/Slide';
+import Button from '@material-ui/core/Button';
+import { makeStyles } from '@material-ui/core/styles';
+import FormControl from '@material-ui/core/FormControl';
+import Select from '@material-ui/core/Select';
+import MenuItem from '@material-ui/core/MenuItem';
+import Typography from '@material-ui/core/Typography';
+import FormHelperText from '@material-ui/core/FormHelperText';
+import { Controls } from './controls';
+import Box from '@material-ui/core/Box';
+import serviceRegistry from '../services/registry';
+
+const Transition = React.forwardRef(function Transition(props, ref) {
+ return ;
+});
+
+const useStyles = makeStyles(theme => ({
+ container: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ justifyContent: 'space-between',
+ marginRight: -theme.spacing(2),
+ },
+ formControl: {
+ minWidth: 120,
+ },
+ selectEmpty: {
+ marginTop: theme.spacing(2),
+ },
+ head: {
+ textShadow: '0px 0px 12px rgba(150, 150, 150, 1)',
+ fontSize: theme.typography.h2.fontSize,
+ textAlign: 'center',
+ marginBottom: theme.spacing(2),
+ },
+}));
+
+export const DumpDialog = ({ trackIndexes }: { trackIndexes: number[] }) => {
+ const dispatch = useDispatch();
+ const classes = useStyles();
+
+ const [devices, setDevices] = useState<{ deviceId: string; label: string }[]>([]);
+ const [inputDeviceId, setInputDeviceId] = useState('');
+
+ let { visible } = useShallowEqualSelector(state => state.dumpDialog);
+
+ const handleClose = useCallback(() => {
+ setInputDeviceId('');
+ serviceRegistry.mediaRecorderService?.stopTestInput();
+ dispatch(dumpDialogActions.setVisible(false));
+ }, [dispatch]);
+
+ const handleChange = useCallback(
+ (ev: React.ChangeEvent<{ value: unknown }>) => {
+ const deviceId = ev.target.value as string;
+ setInputDeviceId(deviceId);
+ serviceRegistry.mediaRecorderService?.stopTestInput();
+ serviceRegistry.mediaRecorderService?.playTestInput(deviceId);
+ },
+ [setInputDeviceId]
+ );
+
+ const handleStartTransfer = useCallback(() => {
+ dispatch(recordTracks(trackIndexes, inputDeviceId));
+ handleClose();
+ }, [trackIndexes, inputDeviceId, dispatch, handleClose]);
+
+ useEffect(() => {
+ async function updateDeviceList() {
+ let devices = await navigator.mediaDevices.enumerateDevices();
+ let inputDevices = devices
+ .filter(device => device.kind === 'audioinput')
+ .map(device => ({ deviceId: device.deviceId, label: device.label }));
+ setDevices(inputDevices);
+ }
+ if (visible) {
+ updateDeviceList();
+ }
+ }, [visible, setDevices]);
+
+ return (
+
+ );
+};
diff --git a/src/components/main.tsx b/src/components/main.tsx
index 0aa2889..17b9914 100644
--- a/src/components/main.tsx
+++ b/src/components/main.tsx
@@ -5,6 +5,7 @@ import { useDropzone } from 'react-dropzone';
import { listContent, deleteTracks, moveTrack } from '../redux/actions';
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
import { actions as convertDialogActions } from '../redux/convert-dialog-feature';
+import { actions as dumpDialogActions } from '../redux/dump-dialog-feature';
import { formatTimeFromFrames, getTracks, Encoding } from 'netmd-js';
@@ -32,9 +33,11 @@ import { batchActions } from 'redux-batched-actions';
import { RenameDialog } from './rename-dialog';
import { UploadDialog } from './upload-dialog';
+import { RecordDialog } from './record-dialog';
import { ErrorDialog } from './error-dialog';
import { ConvertDialog } from './convert-dialog';
import { AboutDialog } from './about-dialog';
+import { DumpDialog } from './dump-dialog';
import { TopMenu } from './topmenu';
import Checkbox from '@material-ui/core/Checkbox';
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
@@ -136,9 +139,13 @@ export const Main = (props: {}) => {
dispatch(moveTrack(selected[0], destIndex));
handleCloseMoveMenu();
},
- [dispatch, selected]
+ [dispatch, selected, handleCloseMoveMenu]
);
+ const handleShowDumpDialog = useCallback(() => {
+ dispatch(dumpDialogActions.setVisible(true));
+ }, [dispatch]);
+
useEffect(() => {
dispatch(listContent());
}, [dispatch]);
@@ -248,7 +255,6 @@ export const Main = (props: {}) => {
{disc?.title || `Untitled Disc`}
)}
-
{selectedCount === 1 ? (
@@ -280,6 +286,16 @@ export const Main = (props: {}) => {
) : null}
+ {selectedCount > 0 ? (
+
+
+
+
+
+ ) : null}
+
{selectedCount > 0 ? (
@@ -340,6 +356,8 @@ export const Main = (props: {}) => {
+
+
);
diff --git a/src/components/record-dialog.tsx b/src/components/record-dialog.tsx
new file mode 100644
index 0000000..369d960
--- /dev/null
+++ b/src/components/record-dialog.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import { useShallowEqualSelector } from '../utils';
+
+import Dialog from '@material-ui/core/Dialog';
+import DialogActions from '@material-ui/core/DialogActions';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogContentText from '@material-ui/core/DialogContentText';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import Slide from '@material-ui/core/Slide';
+import LinearProgress from '@material-ui/core/LinearProgress';
+import Box from '@material-ui/core/Box';
+import { makeStyles } from '@material-ui/core/styles';
+
+const useStyles = makeStyles(theme => ({
+ progressPerc: {
+ marginTop: theme.spacing(1),
+ },
+ progressBar: {
+ marginTop: theme.spacing(3),
+ },
+}));
+
+const Transition = React.forwardRef(function Transition(props, ref) {
+ return ;
+});
+
+export const RecordDialog = (props: {}) => {
+ const classes = useStyles();
+
+ let { visible, trackTotal, trackDone, trackCurrent, titleCurrent } = useShallowEqualSelector(state => state.recordDialog);
+
+ let progressValue = Math.round(trackCurrent);
+ return (
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index 87bd3ba..1a1e61b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -14,10 +14,12 @@ import App from './components/app';
import './index.css';
import { FFMpegAudioExportService } from './services/audio-export';
+import { MediaRecorderService } from './services/mediarecorder';
serviceRegistry.netmdService = new NetMDUSBService({ debug: true });
// serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached
serviceRegistry.audioExportService = new FFMpegAudioExportService();
+serviceRegistry.mediaRecorderService = new MediaRecorderService();
(function setupEventHandlers() {
window.addEventListener('beforeunload', ev => {
diff --git a/src/redux/actions.ts b/src/redux/actions.ts
index f3dc31b..d70f58e 100644
--- a/src/redux/actions.ts
+++ b/src/redux/actions.ts
@@ -3,12 +3,13 @@ import { AppDispatch, RootState } from './store';
import { actions as uploadDialogActions } from './upload-dialog-feature';
import { actions as renameDialogActions } from './rename-dialog-feature';
import { actions as errorDialogAction } from './error-dialog-feature';
+import { actions as recordDialogAction } from './record-dialog-feature';
import { actions as appStateActions } from './app-feature';
import { actions as mainActions } from './main-feature';
import serviceRegistry from '../services/registry';
import { Wireformat, getTracks } from 'netmd-js';
import { AnyAction } from '@reduxjs/toolkit';
-import { getAvailableCharsForTrackTitle } from '../utils';
+import { getAvailableCharsForTrackTitle, framesToSec, sleepWithProgressCallback, sleep } from '../utils';
export function pair() {
return async function(dispatch: AppDispatch, getState: () => RootState) {
@@ -105,6 +106,75 @@ export function moveTrack(srcIndex: number, destIndex: number) {
};
}
+export function recordTracks(indexes: number[], deviceId: string) {
+ return async function(dispatch: AppDispatch, getState: () => RootState) {
+ dispatch(
+ batchActions([
+ recordDialogAction.setVisible(true),
+ recordDialogAction.setProgress({ trackTotal: indexes.length, trackDone: 0, trackCurrent: 0, titleCurrent: '' }),
+ ])
+ );
+
+ let disc = getState().main.disc;
+ let tracks = getTracks(disc!).filter(t => indexes.indexOf(t.index) >= 0);
+
+ const { netmdService, mediaRecorderService } = serviceRegistry;
+
+ for (let [i, track] of tracks.entries()) {
+ dispatch(
+ recordDialogAction.setProgress({
+ trackTotal: tracks.length,
+ trackDone: i,
+ trackCurrent: -1,
+ titleCurrent: track.title ?? '',
+ })
+ );
+
+ // Wait for the track to be ready to play from 0:00
+ await netmdService!.gotoTrack(track.index);
+ await netmdService!.play();
+ console.log('Waiting for track to be ready to play');
+ let position = await netmdService!.getPosition();
+ let expected = [track.index, 0, 0, 1];
+ while (position === null || !expected.every((_, i) => expected[i] === position![i])) {
+ await sleep(250);
+ position = await netmdService!.getPosition();
+ }
+ await netmdService!.pause();
+ await netmdService?.gotoTrack(track.index);
+ console.log('Track is ready to play');
+
+ // Start recording and play track
+ await mediaRecorderService?.initStream(deviceId);
+ await mediaRecorderService?.startRecording();
+ await netmdService!.play();
+
+ // Wait until track is finished
+ let durationInSec = framesToSec(track.duration);
+ // await sleep(durationInSec * 1000);
+ await sleepWithProgressCallback(durationInSec * 1000, (perc: number) => {
+ dispatch(
+ recordDialogAction.setProgress({
+ trackTotal: tracks.length,
+ trackDone: i,
+ trackCurrent: perc,
+ titleCurrent: track.title ?? '',
+ })
+ );
+ });
+
+ // Stop recording and download the wav
+ await mediaRecorderService?.stopRecording();
+ mediaRecorderService?.downloadRecorded(`${track.title}`);
+
+ await mediaRecorderService?.closeStream();
+ }
+
+ await netmdService!.stop();
+ dispatch(recordDialogAction.setVisible(false));
+ };
+}
+
export const WireformatDict: { [k: string]: Wireformat } = {
SP: Wireformat.pcm,
LP2: Wireformat.lp2,
diff --git a/src/redux/dump-dialog-feature.ts b/src/redux/dump-dialog-feature.ts
new file mode 100644
index 0000000..c3cded0
--- /dev/null
+++ b/src/redux/dump-dialog-feature.ts
@@ -0,0 +1,28 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { enableBatching } from 'redux-batched-actions';
+
+export interface DumpDialogState {
+ visible: boolean;
+ inputDeviceId: string;
+}
+
+const initialState: DumpDialogState = {
+ visible: false,
+ inputDeviceId: '',
+};
+
+export const slice = createSlice({
+ name: 'dumpDialog',
+ initialState,
+ reducers: {
+ setVisible: (state, action: PayloadAction) => {
+ state.visible = action.payload;
+ },
+ setInputDeviceId: (state, action: PayloadAction) => {
+ state.inputDeviceId = action.payload;
+ },
+ },
+});
+
+export const { reducer, actions } = slice;
+export default enableBatching(reducer);
diff --git a/src/redux/record-dialog-feature.ts b/src/redux/record-dialog-feature.ts
new file mode 100644
index 0000000..ac0ad03
--- /dev/null
+++ b/src/redux/record-dialog-feature.ts
@@ -0,0 +1,51 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { enableBatching } from 'redux-batched-actions';
+
+export interface RecordingDialogState {
+ visible: boolean;
+
+ trackTotal: number;
+ trackDone: number;
+ trackCurrent: number;
+
+ titleCurrent: string;
+}
+
+const initialState: RecordingDialogState = {
+ visible: false,
+
+ trackTotal: 1,
+ trackDone: 0,
+ trackCurrent: 0,
+
+ titleCurrent: '',
+
+ // visible: true,
+ // trackTotal: 4,
+ // trackDone: 1,
+ // trackCurrent: 25,
+
+ // titleCurrent: 'Seconda traccia',
+};
+
+export const slice = createSlice({
+ name: 'recordDialog',
+ initialState,
+ reducers: {
+ setVisible: (state, action: PayloadAction) => {
+ state.visible = action.payload;
+ },
+ setProgress: (
+ state,
+ action: PayloadAction<{ trackTotal: number; trackDone: number; trackCurrent: number; titleCurrent: string }>
+ ) => {
+ state.trackTotal = action.payload.trackTotal;
+ state.trackDone = action.payload.trackDone;
+ state.trackCurrent = action.payload.trackCurrent;
+ state.titleCurrent = action.payload.titleCurrent;
+ },
+ },
+});
+
+export const { reducer, actions } = slice;
+export default enableBatching(reducer);
diff --git a/src/redux/store.ts b/src/redux/store.ts
index 9be41dd..dd08b0d 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -3,6 +3,8 @@ import uploadDialog from './upload-dialog-feature';
import renameDialog from './rename-dialog-feature';
import errorDialog from './error-dialog-feature';
import convertDialog from './convert-dialog-feature';
+import dumpDialog from './dump-dialog-feature';
+import recordDialog from './record-dialog-feature';
import appState from './app-feature';
import main from './main-feature';
@@ -12,6 +14,8 @@ export const store = configureStore({
uploadDialog,
errorDialog,
convertDialog,
+ dumpDialog,
+ recordDialog,
appState,
main,
},
diff --git a/src/services/mediarecorder.ts b/src/services/mediarecorder.ts
new file mode 100644
index 0000000..8ae4410
--- /dev/null
+++ b/src/services/mediarecorder.ts
@@ -0,0 +1,81 @@
+import { sanitizeTitle, getPublicPathFor } from '../utils';
+import Recorder from 'recorderjs';
+
+export class MediaRecorderService {
+ public recorder: any;
+ public stream?: MediaStream;
+ public audioContext?: AudioContext;
+ public analyserNode?: AnalyserNode;
+ public gainNode?: GainNode;
+
+ playTestInput(deviceId: string) {
+ this.audioContext = new AudioContext();
+ this.gainNode = this.audioContext.createGain();
+ this.analyserNode = this.audioContext.createAnalyser();
+
+ this.initStream(deviceId).then(() => {
+ const source = this.audioContext!.createMediaStreamSource(this.stream!);
+ source.connect(this.gainNode!);
+ this.gainNode!.connect(this.analyserNode!);
+ this.analyserNode!.connect(this.audioContext!.destination);
+ });
+ }
+
+ stopTestInput() {
+ if (!this.audioContext) {
+ return;
+ }
+ this.audioContext?.close();
+ delete this.audioContext;
+ this.closeStream();
+ }
+
+ async initStream(deviceId: string) {
+ const recordConstraints = {
+ // Try to set the best recording params for ripping the audio tracks
+ autoGainControl: false,
+ channelCount: 2,
+ deviceId: deviceId,
+ echoCancellation: false,
+ noiseSuppression: false,
+ sampleRate: 44100,
+ highpassFilter: false,
+ };
+ this.stream = await navigator.mediaDevices.getUserMedia({ audio: recordConstraints });
+
+ // Dump recording settings
+ const audioTracks = this.stream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ console.log('Record Setings:', audioTracks[0].getSettings());
+ }
+ }
+
+ async startRecording() {
+ this.audioContext = new AudioContext();
+ const input = this.audioContext.createMediaStreamSource(this.stream!);
+ this.recorder = new Recorder(input, { workerPath: getPublicPathFor(`recorderWorker.js`) });
+ this.recorder.record();
+ }
+
+ async stopRecording() {
+ this.recorder.stop();
+ }
+
+ async closeStream() {
+ this.stream?.getTracks().forEach(track => track.stop());
+ }
+
+ downloadRecorded(title: string) {
+ this.recorder.exportWAV((buffer: Blob) => {
+ let url = URL.createObjectURL(buffer);
+ let a = document.createElement('a');
+ document.body.appendChild(a);
+ a.style.display = 'none';
+ a.href = url;
+ a.download = `${sanitizeTitle(title)}.wav`;
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ });
+ }
+}
diff --git a/src/services/netmd-mock.ts b/src/services/netmd-mock.ts
index 42c7de3..1e7109f 100644
--- a/src/services/netmd-mock.ts
+++ b/src/services/netmd-mock.ts
@@ -4,6 +4,7 @@ import { sleep, sanitizeTitle } from '../utils';
import { assert } from 'netmd-js/dist/utils';
class NetMDMockService implements NetMDService {
+ public _currentTrack: number = 0;
public _tracksTitlesMaxLength = 1700;
public _discTitle: string = 'Mock Disc';
public _discCapacity: number = 80 * 60 * 512;
@@ -150,6 +151,9 @@ class NetMDMockService implements NetMDService {
async play() {
console.log('play');
}
+ async pause() {
+ console.log('pause');
+ }
async stop() {
console.log('stop');
}
@@ -159,6 +163,14 @@ class NetMDMockService implements NetMDService {
async prev() {
console.log('prev');
}
+ async gotoTrack(index: number) {
+ this._currentTrack = index;
+ await sleep(500);
+ }
+
+ async getPosition() {
+ return [this._currentTrack, 0, 0, 1];
+ }
}
export { NetMDMockService };
diff --git a/src/services/netmd.ts b/src/services/netmd.ts
index b8a7ec4..f2d9b95 100644
--- a/src/services/netmd.ts
+++ b/src/services/netmd.ts
@@ -24,9 +24,12 @@ export interface NetMDService {
): Promise;
play(): Promise;
+ pause(): Promise;
stop(): Promise;
next(): Promise;
prev(): Promise;
+ gotoTrack(index: number): Promise;
+ getPosition(): Promise;
}
export class NetMDUSBService implements NetMDService {
@@ -144,6 +147,9 @@ export class NetMDUSBService implements NetMDService {
async play() {
await this.netmdInterface!.play();
}
+ async pause() {
+ await this.netmdInterface!.pause();
+ }
async stop() {
await this.netmdInterface!.stop();
}
@@ -153,4 +159,10 @@ export class NetMDUSBService implements NetMDService {
async prev() {
await this.netmdInterface!.previousTrack();
}
+ async gotoTrack(index: number) {
+ await this.netmdInterface!.gotoTrack(index);
+ }
+ async getPosition() {
+ return await this.netmdInterface!.getPosition();
+ }
}
diff --git a/src/services/registry.ts b/src/services/registry.ts
index 123a7d5..399f4f5 100644
--- a/src/services/registry.ts
+++ b/src/services/registry.ts
@@ -1,9 +1,11 @@
import { NetMDService } from './netmd';
import { AudioExportService } from './audio-export';
+import { MediaRecorderService } from './mediarecorder';
interface ServiceRegistry {
netmdService?: NetMDService;
audioExportService?: AudioExportService;
+ mediaRecorderService?: MediaRecorderService;
}
const ServiceRegistry: ServiceRegistry = {};
diff --git a/src/types.d.ts b/src/types.d.ts
index ca7c877..ceecc40 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -1,2 +1,3 @@
declare module '@ffmpeg/ffmpeg';
declare module '@ffmpeg/ffmpeg/src/index';
+declare module 'recorderjs';
diff --git a/src/utils.ts b/src/utils.ts
index 092ece9..67beecb 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -7,6 +7,16 @@ export function sleep(ms: number) {
});
}
+export async function sleepWithProgressCallback(ms: number, cb: (perc: number) => void) {
+ let elapsedSecs = 1;
+ let interval = setInterval(() => {
+ elapsedSecs++;
+ cb(Math.min(100, ((elapsedSecs * 1000) / ms) * 100));
+ }, 1000);
+ await sleep(ms);
+ window.clearInterval(interval);
+}
+
export function useShallowEqualSelector(selector: (state: TState) => TSelected): TSelected {
return useSelector(selector, shallowEqual);
}