feat: [closes #67] Screen sharing (#68)

* feat: [#67] stand up useRoomScreenShare hook
* feat: [#67] stand up RoomScreenShareControls
* feat: [#67] display screen share streams
* fix: [#67] don't flip screen share preview
* feat: don't display screen share controls in unsupported environments
* fix: [#67] always remove media streams for exiting peers
This commit is contained in:
Jeremy Kahn 2022-11-13 17:11:09 -06:00 committed by GitHub
parent f6314501a2
commit 75a804abbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 377 additions and 44 deletions

13
package-lock.json generated
View File

@ -34,7 +34,7 @@
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^3.0.1",
"sass": "^1.54.3",
"trystero": "^0.11.4",
"trystero": "github:jeremyckahn/trystero#bugfix/stream-metadata-type",
"typeface-public-sans": "^1.1.13",
"typeface-roboto": "^1.1.13",
"typescript": "^4.7.4",
@ -22977,9 +22977,9 @@
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA=="
},
"node_modules/trystero": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/trystero/-/trystero-0.11.4.tgz",
"integrity": "sha512-x5SkPXlodoNhs1o2TLjarABfBLPtuR2iKovk3mtboCCr349eclpGrK3JPjs4Rp+coAMqC+l/ZcC39GCR9VCvlQ==",
"version": "0.11.6",
"resolved": "git+ssh://git@github.com/jeremyckahn/trystero.git#b270de4b79b2fef1ca550187b6c0fbe72d4e8118",
"license": "MIT",
"dependencies": {
"firebase": "^9.6.5",
"ipfs-core": "0.9.0",
@ -41257,9 +41257,8 @@
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA=="
},
"trystero": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/trystero/-/trystero-0.11.4.tgz",
"integrity": "sha512-x5SkPXlodoNhs1o2TLjarABfBLPtuR2iKovk3mtboCCr349eclpGrK3JPjs4Rp+coAMqC+l/ZcC39GCR9VCvlQ==",
"version": "git+ssh://git@github.com/jeremyckahn/trystero.git#b270de4b79b2fef1ca550187b6c0fbe72d4e8118",
"from": "trystero@github:jeremyckahn/trystero#bugfix/stream-metadata-type",
"requires": {
"firebase": "^9.6.5",
"ipfs-core": "0.9.0",

View File

@ -30,7 +30,7 @@
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^3.0.1",
"sass": "^1.54.3",
"trystero": "^0.11.4",
"trystero": "github:jeremyckahn/trystero#bugfix/stream-metadata-type",
"typeface-public-sans": "^1.1.13",
"typeface-roboto": "^1.1.13",
"typescript": "^4.7.4",

View File

@ -4,8 +4,8 @@ import Paper from '@mui/material/Paper'
import { PeerNameDisplay } from 'components/PeerNameDisplay'
interface PeerVideoProps {
isSelf?: boolean
numberOfPeers: number
isSelfVideo?: boolean
numberOfVideos: number
userId: string
videoStream: MediaStream
}
@ -18,8 +18,8 @@ const nextPerfectSquare = (base: number) => {
}
export const PeerVideo = ({
isSelf,
numberOfPeers,
isSelfVideo,
numberOfVideos,
userId,
videoStream,
}: PeerVideoProps) => {
@ -33,7 +33,7 @@ export const PeerVideo = ({
video.srcObject = videoStream
}, [videoRef, videoStream])
const sizePercent = 100 / Math.sqrt(nextPerfectSquare(numberOfPeers - 1))
const sizePercent = 100 / Math.sqrt(nextPerfectSquare(numberOfVideos - 1))
return (
<Paper
@ -59,7 +59,7 @@ export const PeerVideo = ({
marginLeft: 'auto',
marginRight: 'auto',
height: '100%',
...(isSelf && {
...(isSelfVideo && {
transform: 'rotateY(180deg)',
}),
}}

View File

@ -15,6 +15,7 @@ import { ChatTranscript } from 'components/ChatTranscript'
import { useRoom } from './useRoom'
import { RoomAudioControls } from './RoomAudioControls'
import { RoomVideoControls } from './RoomVideoControls'
import { RoomScreenShareControls } from './RoomScreenShareControls'
import { RoomVideoDisplay } from './RoomVideoDisplay'
export interface RoomProps {
@ -93,6 +94,7 @@ export function Room({
>
<RoomAudioControls peerRoom={peerRoom} />
<RoomVideoControls peerRoom={peerRoom} />
<RoomScreenShareControls peerRoom={peerRoom} />
</Box>
</AccordionDetails>
</Accordion>

View File

@ -0,0 +1,58 @@
import Box from '@mui/material/Box'
import ScreenShare from '@mui/icons-material/ScreenShare'
import StopScreenShare from '@mui/icons-material/StopScreenShare'
import Fab from '@mui/material/Fab'
import Tooltip from '@mui/material/Tooltip'
import { PeerRoom } from 'services/PeerRoom/PeerRoom'
import { useRoomScreenShare } from './useRoomScreenShare'
export interface RoomVideoControlsProps {
peerRoom: PeerRoom
}
export function RoomScreenShareControls({ peerRoom }: RoomVideoControlsProps) {
const { isSharingScreen, handleScreenShareStart, handleScreenShareStop } =
useRoomScreenShare({
peerRoom,
})
const handleToggleScreenShareButtonClick = () => {
if (isSharingScreen) {
handleScreenShareStop()
} else {
handleScreenShareStart()
}
}
if (!window.navigator?.mediaDevices?.getDisplayMedia) {
return <></>
}
return (
<Box
sx={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
px: 1,
}}
>
<Tooltip
title={
isSharingScreen ? 'Stop sharing screen' : 'Share screen with room'
}
>
<Fab
color={isSharingScreen ? 'error' : 'success'}
aria-label="share screen"
onClick={handleToggleScreenShareButtonClick}
>
{isSharingScreen ? <StopScreenShare /> : <ScreenShare />}
</Fab>
</Tooltip>
</Box>
)
}

View File

@ -66,7 +66,7 @@ export function RoomVideoControls({ peerRoom }: RoomVideoControlsProps) {
<Tooltip title={isCameraEnabled ? 'Turn off camera' : 'Turn on camera'}>
<Fab
color={isCameraEnabled ? 'error' : 'success'}
aria-label="call"
aria-label="toggle camera"
onClick={handleEnableCameraClick}
>
{isCameraEnabled ? <VideocamOff /> : <Videocam />}

View File

@ -1,4 +1,4 @@
import { useContext } from 'react'
import { Fragment, useContext } from 'react'
import Paper from '@mui/material/Paper'
import { RoomContext } from 'contexts/RoomContext'
@ -7,7 +7,11 @@ import { Peer } from 'models/chat'
import { PeerVideo } from './PeerVideo'
type PeerWithVideo = { peer: Peer; videoStream: MediaStream }
type PeerWithVideo = {
peer: Peer
videoStream?: MediaStream
screenStream?: MediaStream
}
export interface RoomVideoDisplayProps {
userId: string
@ -18,15 +22,23 @@ export const RoomVideoDisplay = ({ userId }: RoomVideoDisplayProps) => {
const roomContext = useContext(RoomContext)
const { peerList } = shellContext
const { peerVideoStreams, selfVideoStream } = roomContext
const {
peerVideoStreams,
selfVideoStream,
peerScreenStreams,
selfScreenStream,
} = roomContext
const peersWithVideo: PeerWithVideo[] = peerList.reduce(
(acc: PeerWithVideo[], peer: Peer) => {
const videoStream = peerVideoStreams[peer.peerId]
if (videoStream) {
const screenStream = peerScreenStreams[peer.peerId]
if (videoStream || screenStream) {
acc.push({
peer,
videoStream,
screenStream,
})
}
@ -35,7 +47,15 @@ export const RoomVideoDisplay = ({ userId }: RoomVideoDisplayProps) => {
[]
)
const numberOfPeers = (selfVideoStream ? 1 : 0) + peersWithVideo.length
const numberOfVideos =
(selfVideoStream ? 1 : 0) +
(selfScreenStream ? 1 : 0) +
peersWithVideo.reduce((sum, peerWithVideo) => {
if (peerWithVideo.videoStream) sum++
if (peerWithVideo.screenStream) sum++
return sum
}, 0)
return (
<Paper
@ -46,7 +66,7 @@ export const RoomVideoDisplay = ({ userId }: RoomVideoDisplayProps) => {
alignContent: 'center',
alignItems: 'center',
display: 'flex',
flexDirection: numberOfPeers === 1 ? 'column' : 'row',
flexDirection: numberOfVideos === 1 ? 'column' : 'row',
flexGrow: 1,
flexWrap: 'wrap',
justifyContent: 'center',
@ -56,19 +76,36 @@ export const RoomVideoDisplay = ({ userId }: RoomVideoDisplayProps) => {
>
{selfVideoStream && (
<PeerVideo
isSelf
numberOfPeers={numberOfPeers}
isSelfVideo
numberOfVideos={numberOfVideos}
userId={userId}
videoStream={selfVideoStream}
/>
)}
{peersWithVideo.map(peerWithVideo => (
{selfScreenStream && (
<PeerVideo
key={peerWithVideo.peer.peerId}
numberOfPeers={numberOfPeers}
numberOfVideos={numberOfVideos}
userId={userId}
videoStream={selfScreenStream}
/>
)}
{peersWithVideo.map(peerWithVideo => (
<Fragment key={peerWithVideo.peer.peerId}>
{peerWithVideo.videoStream && (
<PeerVideo
numberOfVideos={numberOfVideos}
userId={peerWithVideo.peer.userId}
videoStream={peerWithVideo.videoStream}
/>
)}
{peerWithVideo.screenStream && (
<PeerVideo
numberOfVideos={numberOfVideos}
userId={peerWithVideo.peer.userId}
videoStream={peerWithVideo.screenStream}
/>
)}
</Fragment>
))}
</Paper>
)

View File

@ -13,6 +13,7 @@ import {
ReceivedMessage,
UnsentMessage,
VideoState,
ScreenShareState,
isMessageReceived,
} from 'models/chat'
import { getPeerName } from 'components/PeerNameDisplay'
@ -62,14 +63,34 @@ export function useRoom(
Record<string, MediaStream>
>({})
const [selfScreenStream, setSelfScreenStream] = useState<MediaStream | null>(
null
)
const [peerScreenStreams, setPeerScreenStreams] = useState<
Record<string, MediaStream>
>({})
const roomContextValue = useMemo(
() => ({
selfVideoStream,
setSelfVideoStream,
peerVideoStreams,
setPeerVideoStreams,
selfScreenStream,
setSelfScreenStream,
peerScreenStreams,
setPeerScreenStreams,
}),
[selfVideoStream, setSelfVideoStream, peerVideoStreams, setPeerVideoStreams]
[
selfVideoStream,
setSelfVideoStream,
peerVideoStreams,
setPeerVideoStreams,
selfScreenStream,
setSelfScreenStream,
peerScreenStreams,
setPeerScreenStreams,
]
)
useEffect(() => {
@ -131,6 +152,7 @@ export function useRoom(
userId,
audioState: AudioState.STOPPED,
videoState: VideoState.STOPPED,
screenShareState: ScreenShareState.NOT_SHARING,
},
])
} else {
@ -216,8 +238,11 @@ export function useRoom(
}
})
const showVideoDisplay =
selfVideoStream || Object.values(peerVideoStreams).length > 0
const showVideoDisplay = Boolean(
selfVideoStream ||
selfScreenStream ||
Object.values({ ...peerVideoStreams, ...peerScreenStreams }).length > 0
)
return {
isMessageSending,

View File

@ -0,0 +1,159 @@
import { useContext, useEffect, useCallback, useState } from 'react'
import { isRecord } from 'utils'
import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext'
import { PeerActions } from 'models/network'
import { ScreenShareState, Peer, VideoStreamType } from 'models/chat'
import { PeerRoom, PeerHookType, PeerStreamType } from 'services/PeerRoom'
import { usePeerRoomAction } from './usePeerRoomAction'
interface UseRoomScreenShareConfig {
peerRoom: PeerRoom
}
export function useRoomScreenShare({ peerRoom }: UseRoomScreenShareConfig) {
const shellContext = useContext(ShellContext)
const roomContext = useContext(RoomContext)
const [isSharingScreen, setIsSharingScreen] = useState(false)
const { peerList, setPeerList, setScreenState } = shellContext
const {
peerScreenStreams,
selfScreenStream,
setPeerScreenStreams,
setSelfScreenStream,
} = roomContext
const [sendScreenShare, receiveScreenShare] =
usePeerRoomAction<ScreenShareState>(peerRoom, PeerActions.SCREEN_SHARE)
receiveScreenShare((screenState, peerId) => {
const newPeerList = peerList.map(peer => {
const newPeer: Peer = { ...peer }
if (peer.peerId === peerId) {
newPeer.screenShareState = screenState
if (screenState === ScreenShareState.NOT_SHARING) {
deletePeerScreen(peerId)
}
}
return newPeer
})
setPeerList(newPeerList)
})
peerRoom.onPeerStream(PeerStreamType.SCREEN, (stream, peerId, metadata) => {
const isScreenShareStream =
isRecord(metadata) &&
'type' in metadata &&
metadata.type === VideoStreamType.SCREEN_SHARE
if (!isScreenShareStream) return
setPeerScreenStreams({
...peerScreenStreams,
[peerId]: stream,
})
})
const cleanupScreenStream = useCallback(() => {
if (!selfScreenStream) return
for (const screenStreamTrack of selfScreenStream.getTracks()) {
screenStreamTrack.stop()
selfScreenStream.removeTrack(screenStreamTrack)
}
}, [selfScreenStream])
const handleScreenShareStart = async () => {
if (selfScreenStream) return
const displayMedia = await window.navigator.mediaDevices.getDisplayMedia({
audio: true,
video: true,
})
peerRoom.addStream(displayMedia, null, {
type: VideoStreamType.SCREEN_SHARE,
})
setSelfScreenStream(displayMedia)
sendScreenShare(ScreenShareState.SHARING)
setScreenState(ScreenShareState.SHARING)
setIsSharingScreen(true)
}
const handleScreenShareStop = () => {
if (!selfScreenStream) return
cleanupScreenStream()
peerRoom.removeStream(selfScreenStream, peerRoom.getPeers())
sendScreenShare(ScreenShareState.NOT_SHARING)
setScreenState(ScreenShareState.NOT_SHARING)
setSelfScreenStream(null)
setIsSharingScreen(false)
}
useEffect(() => {
return () => {
cleanupScreenStream()
}
}, [cleanupScreenStream])
useEffect(() => {
return () => {
if (selfScreenStream) {
setSelfScreenStream(null)
setScreenState(ScreenShareState.NOT_SHARING)
}
}
}, [selfScreenStream, setSelfScreenStream, setScreenState])
useEffect(() => {
return () => {
setPeerScreenStreams({})
}
}, [setPeerScreenStreams])
const deletePeerScreen = (peerId: string) => {
const newPeerScreens = { ...peerScreenStreams }
delete newPeerScreens[peerId]
setPeerScreenStreams(newPeerScreens)
}
const handleScreenForNewPeer = (peerId: string) => {
if (selfScreenStream) {
peerRoom.addStream(selfScreenStream, peerId, {
type: VideoStreamType.SCREEN_SHARE,
})
}
}
const handleScreenForLeavingPeer = (peerId: string) => {
if (selfScreenStream) {
peerRoom.removeStream(selfScreenStream, peerId)
}
deletePeerScreen(peerId)
}
peerRoom.onPeerJoin(PeerHookType.SCREEN, (peerId: string) => {
handleScreenForNewPeer(peerId)
})
peerRoom.onPeerLeave(PeerHookType.SCREEN, (peerId: string) => {
handleScreenForLeavingPeer(peerId)
})
return {
handleScreenShareStart,
handleScreenShareStop,
isSharingScreen,
setIsSharingScreen,
}
}

View File

@ -3,9 +3,11 @@ import { useContext, useEffect, useCallback, useState } from 'react'
import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext'
import { PeerActions } from 'models/network'
import { VideoState, Peer } from 'models/chat'
import { VideoState, Peer, VideoStreamType } from 'models/chat'
import { PeerRoom, PeerHookType, PeerStreamType } from 'services/PeerRoom'
import { isRecord } from 'utils'
import { usePeerRoomAction } from './usePeerRoomAction'
interface UseRoomVideoConfig {
@ -60,7 +62,9 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
},
})
peerRoom.addStream(newSelfStream)
peerRoom.addStream(newSelfStream, null, {
type: VideoStreamType.WEBCAM,
})
setSelfVideoStream(newSelfStream)
setSelfVideoStream(newSelfStream)
@ -91,10 +95,13 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
setPeerList(newPeerList)
})
peerRoom.onPeerStream(PeerStreamType.VIDEO, (stream, peerId) => {
const videoTracks = stream.getVideoTracks()
peerRoom.onPeerStream(PeerStreamType.VIDEO, (stream, peerId, metadata) => {
const isWebcamStream =
isRecord(metadata) &&
'type' in metadata &&
metadata.type === VideoStreamType.WEBCAM
if (videoTracks.length === 0) return
if (!isWebcamStream) return
setPeerVideoStreams({
...peerVideoStreams,
@ -122,7 +129,9 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
: true,
})
peerRoom.addStream(newSelfStream)
peerRoom.addStream(newSelfStream, null, {
type: VideoStreamType.WEBCAM,
})
sendVideoChange(VideoState.PLAYING)
setVideoState(VideoState.PLAYING)
setSelfVideoStream(newSelfStream)
@ -191,7 +200,7 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
},
})
peerRoom.addStream(newSelfStream)
peerRoom.addStream(newSelfStream, null, { type: VideoStreamType.WEBCAM })
setSelfVideoStream(newSelfStream)
}
@ -203,15 +212,18 @@ export function useRoomVideo({ peerRoom }: UseRoomVideoConfig) {
const handleVideoForNewPeer = (peerId: string) => {
if (selfVideoStream) {
peerRoom.addStream(selfVideoStream, peerId)
peerRoom.addStream(selfVideoStream, peerId, {
type: VideoStreamType.WEBCAM,
})
}
}
const handleVideoForLeavingPeer = (peerId: string) => {
if (selfVideoStream) {
peerRoom.removeStream(selfVideoStream, peerId)
deletePeerVideo(peerId)
}
deletePeerVideo(peerId)
}
peerRoom.onPeerJoin(PeerHookType.VIDEO, (peerId: string) => {

View File

@ -15,7 +15,7 @@ import { AlertColor } from '@mui/material/Alert'
import { ShellContext } from 'contexts/ShellContext'
import { SettingsContext } from 'contexts/SettingsContext'
import { AlertOptions } from 'models/shell'
import { AudioState, VideoState, Peer } from 'models/chat'
import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat'
import { ErrorBoundary } from 'components/ErrorBoundary'
import { Drawer } from './Drawer'
@ -46,6 +46,9 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
const [tabHasFocus, setTabHasFocus] = useState(true)
const [audioState, setAudioState] = useState<AudioState>(AudioState.STOPPED)
const [videoState, setVideoState] = useState<VideoState>(VideoState.STOPPED)
const [screenState, setScreenState] = useState<ScreenShareState>(
ScreenShareState.NOT_SHARING
)
const showAlert = useCallback<
(message: string, options?: AlertOptions) => void
@ -72,6 +75,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setAudioState,
videoState,
setVideoState,
screenState,
setScreenState,
}),
[
isPeerListOpen,
@ -87,6 +92,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setAudioState,
videoState,
setVideoState,
screenState,
setScreenState,
]
)

View File

@ -5,6 +5,10 @@ interface RoomContextProps {
setSelfVideoStream: Dispatch<SetStateAction<MediaStream | null>>
peerVideoStreams: Record<string, MediaStream>
setPeerVideoStreams: Dispatch<SetStateAction<Record<string, MediaStream>>>
selfScreenStream: MediaStream | null
setSelfScreenStream: Dispatch<SetStateAction<MediaStream | null>>
peerScreenStreams: Record<string, MediaStream>
setPeerScreenStreams: Dispatch<SetStateAction<Record<string, MediaStream>>>
}
export const RoomContext = createContext<RoomContextProps>({
@ -12,4 +16,8 @@ export const RoomContext = createContext<RoomContextProps>({
setSelfVideoStream: () => {},
peerVideoStreams: {},
setPeerVideoStreams: () => {},
selfScreenStream: null,
setSelfScreenStream: () => {},
peerScreenStreams: {},
setPeerScreenStreams: () => {},
})

View File

@ -1,7 +1,7 @@
import { createContext, Dispatch, SetStateAction } from 'react'
import { AlertOptions } from 'models/shell'
import { AudioState, VideoState, Peer } from 'models/chat'
import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat'
interface ShellContextProps {
numberOfPeers: number
@ -18,6 +18,8 @@ interface ShellContextProps {
setAudioState: Dispatch<SetStateAction<AudioState>>
videoState: VideoState
setVideoState: Dispatch<SetStateAction<VideoState>>
screenState: ScreenShareState
setScreenState: Dispatch<SetStateAction<ScreenShareState>>
}
export const ShellContext = createContext<ShellContextProps>({
@ -35,4 +37,6 @@ export const ShellContext = createContext<ShellContextProps>({
setAudioState: () => {},
videoState: VideoState.STOPPED,
setVideoState: () => {},
screenState: ScreenShareState.NOT_SHARING,
setScreenState: () => {},
})

View File

@ -15,11 +15,22 @@ export enum VideoState {
STOPPED = 'STOPPED',
}
export enum VideoStreamType {
WEBCAM = 'WEBCAM',
SCREEN_SHARE = 'SCREEN_SHARE',
}
export enum ScreenShareState {
SHARING = 'SHARING',
NOT_SHARING = 'NOT_SHARING',
}
export interface Peer {
peerId: string
userId: string
audioState: AudioState
videoState: VideoState
screenShareState: ScreenShareState
}
export interface ReceivedMessage extends UnsentMessage {

View File

@ -5,4 +5,5 @@ export enum PeerActions {
PEER_NAME = 'PEER_NAME',
AUDIO_CHANGE = 'AUDIO_CHANGE',
VIDEO_CHANGE = 'VIDEO_CHANGE',
SCREEN_SHARE = 'SCREEN_SHARE',
}

View File

@ -5,11 +5,13 @@ export enum PeerHookType {
NEW_PEER = 'NEW_PEER',
AUDIO = 'AUDIO',
VIDEO = 'VIDEO',
SCREEN = 'SCREEN',
}
export enum PeerStreamType {
AUDIO = 'AUDIO',
VIDEO = 'VIDEO',
SCREEN = 'SCREEN',
}
export class PeerRoom {
@ -107,8 +109,8 @@ export class PeerRoom {
return this.room.makeAction<T>(namespace)
}
addStream: Room['addStream'] = stream => {
return this.room.addStream(stream)
addStream: Room['addStream'] = (...args) => {
return this.room.addStream(...args)
}
removeStream: Room['removeStream'] = (stream, targetPeers) => {

View File

@ -2,3 +2,11 @@ export const sleep = (milliseconds: number): Promise<void> =>
new Promise<void>(res => {
setTimeout(res, milliseconds)
})
export const isRecord = (variable: any): variable is Record<string, any> => {
return (
typeof variable === 'object' &&
!Array.isArray(variable) &&
variable !== null
)
}