Now using redux to not create sound player every time the phaser is also implicitely cached
This commit is contained in:
@@ -5,23 +5,28 @@ import { useEffect, useRef } from 'react';
|
||||
import Phaser from 'phaser';
|
||||
import useColorScheme from '../../hooks/colorScheme';
|
||||
import { PartitionContext } from '../../views/PlayView';
|
||||
import { on } from 'events';
|
||||
import store, { RootState, useSelector } from '../../state/Store';
|
||||
import { setSoundPlayer as setSPStore } from '../../state/SoundPlayerSlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import SoundFont from 'soundfont-player';
|
||||
import * as SAC from 'standardized-audio-context';
|
||||
import { SplendidGrandPiano, CacheStorage } from 'smplr';
|
||||
// import * as Tone from 'tone';
|
||||
|
||||
let globalTimestamp = 0;
|
||||
let globalStatus: 'playing' | 'paused' | 'stopped' = 'playing';
|
||||
|
||||
const playNotes = (notes: any[], soundPlayer: SoundFont.Player, audioContext: SAC.AudioContext) => {
|
||||
const isValidSoundPlayer = (soundPlayer: SplendidGrandPiano | undefined) => {
|
||||
return soundPlayer && soundPlayer.loaded;
|
||||
}
|
||||
|
||||
const playNotes = (notes: any[], soundPlayer: SplendidGrandPiano) => {
|
||||
notes.forEach(({ note, duration }) => {
|
||||
const fixedKey =
|
||||
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)?.fixedKey ?? 0;
|
||||
const midiNumber = note.halfTone - fixedKey * 12;
|
||||
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
|
||||
soundPlayer!.play(midiNumber.toString(), audioContext.currentTime, {
|
||||
duration,
|
||||
gain,
|
||||
});
|
||||
soundPlayer.start({ note: midiNumber, duration, velocity: gain * 127 });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -29,8 +34,7 @@ const getPianoScene = (
|
||||
partitionB64: string,
|
||||
cursorPositions: PianoCursorPosition[],
|
||||
onEndReached: () => void,
|
||||
soundPlayer: SoundFont.Player,
|
||||
audioContext: SAC.AudioContext,
|
||||
soundPlayer: SplendidGrandPiano,
|
||||
colorScheme: 'light' | 'dark'
|
||||
) => {
|
||||
class PianoScene extends Phaser.Scene {
|
||||
@@ -67,7 +71,7 @@ const getPianoScene = (
|
||||
return false;
|
||||
});
|
||||
if (cP) {
|
||||
playNotes(cP.notes, soundPlayer, audioContext);
|
||||
playNotes(cP.notes, soundPlayer);
|
||||
const tw = {
|
||||
targets: this!.cursor,
|
||||
x: cP!.x,
|
||||
@@ -76,7 +80,6 @@ const getPianoScene = (
|
||||
};
|
||||
if (this.cursorPositionsIdx === cursorPositions.length - 1) {
|
||||
tw.onComplete = () => {
|
||||
soundPlayer.stop();
|
||||
onEndReached();
|
||||
};
|
||||
}
|
||||
@@ -88,6 +91,21 @@ const getPianoScene = (
|
||||
return PianoScene;
|
||||
};
|
||||
|
||||
const getSoundPlayer = async (audioContext: AudioContext) => {
|
||||
const soundPlayerStore = store.getState().soundPlayer.soundPlayer;
|
||||
if (soundPlayerStore) {
|
||||
console.log('csp', soundPlayerStore);
|
||||
return soundPlayerStore as unknown as SplendidGrandPiano;
|
||||
}
|
||||
const soundPlayer = await new SplendidGrandPiano(audioContext, {
|
||||
storage: new CacheStorage(),
|
||||
}).loaded();
|
||||
console.log('sp', soundPlayer);
|
||||
setSPStore(soundPlayer);
|
||||
console.log('asp', soundPlayer);
|
||||
return soundPlayer;
|
||||
};
|
||||
|
||||
export type PianoCursorPosition = {
|
||||
// offset in pixels
|
||||
x: number;
|
||||
@@ -110,43 +128,62 @@ export type PhaserCanvasProps = {
|
||||
|
||||
const PhaserCanvas = ({ partitionB64, cursorPositions, onEndReached }: PhaserCanvasProps) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const audioContext = new SAC.AudioContext();
|
||||
const [soundPlayer, setSoundPlayer] = React.useState<SoundFont.Player>();
|
||||
const dispatch = useDispatch();
|
||||
const soundPlayer = useSelector((state: RootState) => state.soundPlayer.soundPlayer);
|
||||
const { timestamp } = React.useContext(PartitionContext);
|
||||
const [game, setGame] = React.useState<Phaser.Game | null>(null);
|
||||
|
||||
globalTimestamp = timestamp;
|
||||
|
||||
useEffect(() => {
|
||||
Promise.resolve(
|
||||
SoundFont.instrument(audioContext as unknown as AudioContext, 'electric_piano_1')
|
||||
).then((sound) => {
|
||||
setSoundPlayer(sound);
|
||||
|
||||
const pianoScene = getPianoScene(
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
sound,
|
||||
audioContext,
|
||||
colorScheme
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'phaser-canvas',
|
||||
width: 1000,
|
||||
height: 400,
|
||||
scene: pianoScene,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
};
|
||||
|
||||
setGame(new Phaser.Game(config));
|
||||
});
|
||||
if (isValidSoundPlayer(soundPlayer)) {
|
||||
console.log('cache soundplayer', soundPlayer);
|
||||
return;
|
||||
}
|
||||
console.log('creating soundplayer');
|
||||
new SplendidGrandPiano(new AudioContext(), {
|
||||
storage: new CacheStorage(),
|
||||
})
|
||||
.loaded()
|
||||
.then((sp) => {
|
||||
console.log('sp', sp);
|
||||
dispatch(setSPStore(sp));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('soundPlayer', soundPlayer);
|
||||
if (!isValidSoundPlayer(soundPlayer) || !soundPlayer) return;
|
||||
const pianoScene = getPianoScene(
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
soundPlayer,
|
||||
colorScheme
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'phaser-canvas',
|
||||
width: 1000,
|
||||
height: 400,
|
||||
scene: pianoScene,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
};
|
||||
|
||||
setGame(new Phaser.Game(config));
|
||||
return () => {
|
||||
console.log('destroying phaser game sp');
|
||||
if (game) {
|
||||
// currently the condition is always false
|
||||
game.destroy(true);
|
||||
}
|
||||
}
|
||||
}, [soundPlayer]);
|
||||
|
||||
return <div id="phaser-canvas"></div>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Model, { ModelValidator } from './Model';
|
||||
import * as yup from 'yup';
|
||||
import ResponseHandler from './ResponseHandler';
|
||||
|
||||
export const SearchType = ['song', 'artist', 'album'] as const;
|
||||
export const SearchType = ['song', 'artist', 'album', 'genre'] as const;
|
||||
export type SearchType = (typeof SearchType)[number];
|
||||
|
||||
const SearchHistoryValidator = yup
|
||||
@@ -27,7 +27,7 @@ export const SearchHistoryHandler: ResponseHandler<
|
||||
|
||||
interface SearchHistory extends Model {
|
||||
query: string;
|
||||
type: 'song' | 'artist' | 'album' | 'genre';
|
||||
type: SearchType;
|
||||
userId: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -69,8 +69,10 @@
|
||||
"react-timer-hook": "^3.0.5",
|
||||
"react-use-precision-timer": "^3.3.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"smplr": "^0.6.1",
|
||||
"soundfont-player": "^0.12.0",
|
||||
"standardized-audio-context": "^25.3.51",
|
||||
"tone": "^14.7.77",
|
||||
"type-fest": "^3.6.0",
|
||||
"yup": "^1.2.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { SplendidGrandPiano } from 'smplr';
|
||||
|
||||
export const soundPlayerSlice = createSlice({
|
||||
name: 'soundPlayer',
|
||||
initialState: {
|
||||
soundPlayer: undefined as SplendidGrandPiano | undefined,
|
||||
},
|
||||
reducers: {
|
||||
setSoundPlayer: (state, action: PayloadAction<SplendidGrandPiano>) => {
|
||||
state.soundPlayer = action.payload;
|
||||
},
|
||||
unsetSoundPlayer: (state) => {
|
||||
state.soundPlayer = undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { setSoundPlayer, unsetSoundPlayer } = soundPlayerSlice.actions;
|
||||
export default soundPlayerSlice.reducer;
|
||||
@@ -1,5 +1,6 @@
|
||||
import userReducer from '../state/UserSlice';
|
||||
import settingsReduder from './SettingsSlice';
|
||||
import settingsReducer from './SettingsSlice';
|
||||
import SoundPlayerSliceReducer from './SoundPlayerSlice';
|
||||
import { StateFromReducersMapObject, configureStore } from '@reduxjs/toolkit';
|
||||
import languageReducer from './LanguageSlice';
|
||||
import {
|
||||
@@ -28,7 +29,8 @@ const persistConfig = {
|
||||
const reducers = {
|
||||
user: userReducer,
|
||||
language: languageReducer,
|
||||
settings: settingsReduder,
|
||||
settings: settingsReducer,
|
||||
soundPlayer: SoundPlayerSliceReducer,
|
||||
};
|
||||
|
||||
type State = StateFromReducersMapObject<typeof reducers>;
|
||||
|
||||
+74
-82
@@ -99,15 +99,13 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
const onPause = () => {
|
||||
stopwatch.pause();
|
||||
setPause(true);
|
||||
if (webSocket.current?.readyState == WebSocket.OPEN) {
|
||||
webSocket.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'pause',
|
||||
paused: true,
|
||||
time: getElapsedTime(),
|
||||
})
|
||||
);
|
||||
}
|
||||
webSocket.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'pause',
|
||||
paused: true,
|
||||
time: getElapsedTime(),
|
||||
})
|
||||
);
|
||||
};
|
||||
const onResume = () => {
|
||||
if (stopwatch.isStarted()) {
|
||||
@@ -116,26 +114,20 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
stopwatch.start();
|
||||
}
|
||||
setPause(false);
|
||||
if (webSocket.current?.readyState == WebSocket.OPEN) {
|
||||
webSocket.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'pause',
|
||||
paused: false,
|
||||
time: getElapsedTime(),
|
||||
})
|
||||
);
|
||||
}
|
||||
webSocket.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'pause',
|
||||
paused: false,
|
||||
time: getElapsedTime(),
|
||||
})
|
||||
);
|
||||
};
|
||||
const onEnd = () => {
|
||||
setTime(0);
|
||||
stopwatch.stop();
|
||||
if (webSocket.current?.readyState == WebSocket.OPEN) {
|
||||
webSocket.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'end',
|
||||
})
|
||||
);
|
||||
}
|
||||
// webSocket.current?.send(
|
||||
// JSON.stringify({
|
||||
// type: 'end',
|
||||
// })
|
||||
// );
|
||||
};
|
||||
|
||||
const onMIDISuccess = (access: MIDIAccess) => {
|
||||
@@ -147,63 +139,63 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
}
|
||||
setMidiKeyboardFound(true);
|
||||
let inputIndex = 0;
|
||||
webSocket.current = new WebSocket(scoroBaseApiUrl);
|
||||
webSocket.current.onopen = () => {
|
||||
webSocket.current!.send(
|
||||
JSON.stringify({
|
||||
type: 'start',
|
||||
id: song.data!.id,
|
||||
mode: type,
|
||||
bearer: accessToken,
|
||||
})
|
||||
);
|
||||
};
|
||||
webSocket.current.onmessage = (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'end') {
|
||||
navigation.navigate('Score', { songId: song.data!.id, ...data });
|
||||
return;
|
||||
}
|
||||
const points = data.info.score;
|
||||
const maxPoints = data.info.max_score || 1;
|
||||
//webSocket.current = new WebSocket(scoroBaseApiUrl);
|
||||
// webSocket.current.onopen = () => {
|
||||
// webSocket.current!.send(
|
||||
// JSON.stringify({
|
||||
// type: 'start',
|
||||
// id: song.data!.id,
|
||||
// mode: type,
|
||||
// bearer: accessToken,
|
||||
// })
|
||||
// );
|
||||
// };
|
||||
// webSocket.current.onmessage = (message) => {
|
||||
// try {
|
||||
// const data = JSON.parse(message.data);
|
||||
// if (data.type == 'end') {
|
||||
// navigation.navigate('Score', { songId: song.data!.id, ...data });
|
||||
// return;
|
||||
// }
|
||||
// const points = data.info.score;
|
||||
// const maxPoints = data.info.max_score || 1;
|
||||
|
||||
setScore(Math.floor((Math.max(points, 0) * 100) / maxPoints));
|
||||
// setScore(Math.floor((Math.max(points, 0) * 100) / maxPoints));
|
||||
|
||||
let formattedMessage = '';
|
||||
let messageColor: ColorSchemeType | undefined;
|
||||
// let formattedMessage = '';
|
||||
// let messageColor: ColorSchemeType | undefined;
|
||||
|
||||
if (data.type == 'miss') {
|
||||
formattedMessage = translate('missed');
|
||||
messageColor = 'black';
|
||||
} else if (data.type == 'timing' || data.type == 'duration') {
|
||||
formattedMessage = translate(data[data.type]);
|
||||
switch (data[data.type]) {
|
||||
case 'perfect':
|
||||
messageColor = 'green';
|
||||
break;
|
||||
case 'great':
|
||||
messageColor = 'blue';
|
||||
break;
|
||||
case 'short':
|
||||
case 'long':
|
||||
case 'good':
|
||||
messageColor = 'lightBlue';
|
||||
break;
|
||||
case 'too short':
|
||||
case 'too long':
|
||||
case 'wrong':
|
||||
messageColor = 'trueGray';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
setLastScoreMessage({ content: formattedMessage, color: messageColor });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
// if (data.type == 'miss') {
|
||||
// formattedMessage = translate('missed');
|
||||
// messageColor = 'black';
|
||||
// } else if (data.type == 'timing' || data.type == 'duration') {
|
||||
// formattedMessage = translate(data[data.type]);
|
||||
// switch (data[data.type]) {
|
||||
// case 'perfect':
|
||||
// messageColor = 'green';
|
||||
// break;
|
||||
// case 'great':
|
||||
// messageColor = 'blue';
|
||||
// break;
|
||||
// case 'short':
|
||||
// case 'long':
|
||||
// case 'good':
|
||||
// messageColor = 'lightBlue';
|
||||
// break;
|
||||
// case 'too short':
|
||||
// case 'too long':
|
||||
// case 'wrong':
|
||||
// messageColor = 'trueGray';
|
||||
// break;
|
||||
// default:
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// setLastScoreMessage({ content: formattedMessage, color: messageColor });
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// };
|
||||
inputs.forEach((input) => {
|
||||
if (inputIndex != 0) {
|
||||
return;
|
||||
@@ -232,7 +224,7 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
|
||||
const interval = setInterval(() => {
|
||||
setTime(() => getElapsedTime()); // Countdown
|
||||
}, 1);
|
||||
}, 10);
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
|
||||
@@ -1246,6 +1246,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.11"
|
||||
|
||||
"@babel/runtime@^7.22.6":
|
||||
version "7.22.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.6.tgz#57d64b9ae3cff1d67eb067ae117dac087f5bd438"
|
||||
integrity sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.11"
|
||||
|
||||
"@babel/runtime@~7.5.4":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
|
||||
@@ -6027,6 +6034,14 @@ automation-events@^6.0.4:
|
||||
"@babel/runtime" "^7.22.3"
|
||||
tslib "^2.5.3"
|
||||
|
||||
automation-events@^6.0.8:
|
||||
version "6.0.8"
|
||||
resolved "https://registry.yarnpkg.com/automation-events/-/automation-events-6.0.8.tgz#52929699924cd791eaefc51916ffc033c4d0f42f"
|
||||
integrity sha512-OXI9rEbA0LwWr+Tmvka4EHtVHBIVw8KD2NM7fIGjd4dyGnuiM3ULZL+Jlo4aKXZDY98raT4R4rEDOHAbz8Jm9A==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.22.6"
|
||||
tslib "^2.6.1"
|
||||
|
||||
autoprefixer@^9.8.6:
|
||||
version "9.8.8"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.8.tgz#fd4bd4595385fa6f06599de749a4d5f7a474957a"
|
||||
@@ -16987,6 +17002,11 @@ smart-buffer@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
||||
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||
|
||||
smplr@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/smplr/-/smplr-0.6.1.tgz#f24cbe7ce3ad318bb6ce226d9aa933d1cab7dc56"
|
||||
integrity sha512-040QDtYRavqIje9346zWBYDc3oN/ARSZmheOGELAujQVYr3p4e8nrOsojH3VQsE0zcrAhjJ4MDeg74qIHQCC7A==
|
||||
|
||||
snapdragon-node@^2.0.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
|
||||
@@ -17266,6 +17286,15 @@ stacktrace-parser@^0.1.3:
|
||||
dependencies:
|
||||
type-fest "^0.7.1"
|
||||
|
||||
standardized-audio-context@^25.1.8:
|
||||
version "25.3.55"
|
||||
resolved "https://registry.yarnpkg.com/standardized-audio-context/-/standardized-audio-context-25.3.55.tgz#4d87ea6052de80ecf5abf56eb71ecd71f7e52e4e"
|
||||
integrity sha512-ym9g7FZ5S1FykbQ1///ktTJgk+zTtGF1hGR/BFRQjRkN6G2Xy9GbL5kOcM7DlzflV2yJtqVwfU2gL042b1oHwg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.22.6"
|
||||
automation-events "^6.0.8"
|
||||
tslib "^2.6.1"
|
||||
|
||||
standardized-audio-context@^25.3.51:
|
||||
version "25.3.51"
|
||||
resolved "https://registry.yarnpkg.com/standardized-audio-context/-/standardized-audio-context-25.3.51.tgz#0eb54629355d1ddf2070897e586eaa8dfec8c0f5"
|
||||
@@ -18038,6 +18067,14 @@ toidentifier@1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
|
||||
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||
|
||||
tone@^14.7.77:
|
||||
version "14.7.77"
|
||||
resolved "https://registry.yarnpkg.com/tone/-/tone-14.7.77.tgz#12a2a9f033952ccdb552275a6384ca5d36d4b5ed"
|
||||
integrity sha512-tCfK73IkLHyzoKUvGq47gyDyxiKLFvKiVCOobynGgBB9Dl0NkxTM2p+eRJXyCYrjJwy9Y0XCMqD3uOYsYt2Fdg==
|
||||
dependencies:
|
||||
standardized-audio-context "^25.1.8"
|
||||
tslib "^2.0.1"
|
||||
|
||||
toposort@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
|
||||
@@ -18125,6 +18162,11 @@ tslib@^2.5.3:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913"
|
||||
integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==
|
||||
|
||||
tslib@^2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410"
|
||||
integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==
|
||||
|
||||
tsutils@^3.21.0:
|
||||
version "3.21.0"
|
||||
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
|
||||
|
||||
Reference in New Issue
Block a user