Do not join call automatically
Present a user with a menu to join call manually
This commit is contained in:
parent
22380ea381
commit
a8f3757d53
@ -55,6 +55,8 @@ overrides:
|
||||
- files:
|
||||
- '*.test.ts'
|
||||
- '*.test.tsx'
|
||||
- '**/__mocks__/*.ts'
|
||||
- '**/__mocks__/*.tsx'
|
||||
rules:
|
||||
'@typescript-eslint/no-explicit-any': off
|
||||
- files:
|
||||
|
||||
@ -14,18 +14,16 @@ export class MediaStream {
|
||||
}]
|
||||
}
|
||||
}
|
||||
export function getUserMedia () {
|
||||
return !getUserMedia.shouldFail
|
||||
? Promise.resolve(getUserMedia.stream)
|
||||
: Promise.reject(new Error('test'))
|
||||
}
|
||||
getUserMedia.shouldFail = false
|
||||
getUserMedia.fail = (shouldFail: boolean) =>
|
||||
getUserMedia.shouldFail = shouldFail
|
||||
getUserMedia.stream = new MediaStream()
|
||||
|
||||
export const navigator = window.navigator
|
||||
|
||||
;(window as any).navigator.mediaDevices = {}
|
||||
window.navigator.mediaDevices.enumerateDevices = async () => {
|
||||
return []
|
||||
}
|
||||
window.navigator.mediaDevices.getUserMedia = async () => {
|
||||
return {} as any
|
||||
}
|
||||
|
||||
export const play = jest.fn()
|
||||
|
||||
export const valueOf = jest.fn()
|
||||
|
||||
@ -6,7 +6,7 @@ import * as CallActions from './CallActions'
|
||||
import * as SocketActions from './SocketActions'
|
||||
import * as constants from '../constants'
|
||||
import socket from '../socket'
|
||||
import { callId, getUserMedia } from '../window'
|
||||
import { callId } from '../window'
|
||||
import { bindActionCreators, createStore, AnyAction, combineReducers, applyMiddleware } from 'redux'
|
||||
import reducers from '../reducers'
|
||||
import { middlewares } from '../middlewares'
|
||||
@ -33,7 +33,6 @@ describe('CallActions', () => {
|
||||
applyMiddleware(...middlewares),
|
||||
)
|
||||
callActions = bindActionCreators(CallActions, store.dispatch);
|
||||
(getUserMedia as any).fail(false);
|
||||
(SocketActions.handshake as jest.Mock).mockReturnValue(jest.fn())
|
||||
})
|
||||
|
||||
@ -55,19 +54,12 @@ describe('CallActions', () => {
|
||||
message: 'Connected to server socket',
|
||||
type: 'warning',
|
||||
},
|
||||
}, {
|
||||
type: constants.STREAM_ADD,
|
||||
payload: {
|
||||
stream: jasmine.anything(),
|
||||
userId: constants.ME,
|
||||
},
|
||||
}, {
|
||||
type: constants.INIT,
|
||||
}])
|
||||
expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{
|
||||
socket,
|
||||
roomName: callId,
|
||||
stream: (getUserMedia as any).stream,
|
||||
}]])
|
||||
})
|
||||
|
||||
@ -94,7 +86,6 @@ describe('CallActions', () => {
|
||||
})
|
||||
|
||||
it('dispatches alert when failed to get media stream', async () => {
|
||||
(getUserMedia as any).fail(true)
|
||||
const promise = callActions.init()
|
||||
socket.emit('connect', undefined)
|
||||
await promise
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import * as constants from '../constants'
|
||||
import socket from '../socket'
|
||||
import { Dispatch, ThunkResult } from '../store'
|
||||
import { callId, getUserMedia } from '../window'
|
||||
import { callId } from '../window'
|
||||
import { ClientSocket } from '../socket'
|
||||
import * as NotifyActions from './NotifyActions'
|
||||
import * as SocketActions from './SocketActions'
|
||||
import * as StreamActions from './StreamActions'
|
||||
|
||||
export interface InitAction {
|
||||
type: 'INIT'
|
||||
@ -23,12 +21,12 @@ const initialize = (): InitializeAction => ({
|
||||
export const init = (): ThunkResult<Promise<void>> =>
|
||||
async (dispatch, getState) => {
|
||||
const socket = await dispatch(connect())
|
||||
const stream = await dispatch(getCameraStream())
|
||||
// const stream = await dispatch(getCameraStream())
|
||||
|
||||
dispatch(SocketActions.handshake({
|
||||
socket,
|
||||
roomName: callId,
|
||||
stream,
|
||||
// stream,
|
||||
}))
|
||||
|
||||
dispatch(initialize())
|
||||
@ -48,16 +46,17 @@ export const connect = () => (dispatch: Dispatch) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const getCameraStream = () => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const stream = await getUserMedia({
|
||||
video: { facingMode: 'user' },
|
||||
audio: true,
|
||||
})
|
||||
dispatch(StreamActions.addStream({ stream, userId: constants.ME }))
|
||||
return stream
|
||||
} catch (err) {
|
||||
dispatch(NotifyActions.alert('Could not get access to microphone & camera'))
|
||||
return
|
||||
}
|
||||
}
|
||||
// export const getCameraStream = () => async (dispatch: Dispatch) => {
|
||||
// try {
|
||||
// const stream = await getUserMedia({
|
||||
// video: { facingMode: 'user' },
|
||||
// audio: true,
|
||||
// })
|
||||
// dispatch(StreamActions.addStream({ stream, userId: constants.ME }))
|
||||
// return stream
|
||||
// } catch (err) {
|
||||
// dispatch(
|
||||
// NotifyActions.alert('Could not get access to microphone & camera'))
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
@ -11,6 +11,7 @@ import Chat from './Chat'
|
||||
import Notifications from './Notifications'
|
||||
import Toolbar from './Toolbar'
|
||||
import Video from './Video'
|
||||
import { Media } from './Media'
|
||||
|
||||
export interface AppProps {
|
||||
active: string | null
|
||||
@ -84,6 +85,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
|
||||
/>
|
||||
<Alerts alerts={alerts} dismiss={dismissAlert} />
|
||||
<Notifications notifications={notifications} />
|
||||
<Media />
|
||||
<Chat
|
||||
messages={messages}
|
||||
onClose={this.handleHideChat}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
jest.mock('../window')
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import TestUtils from 'react-dom/test-utils'
|
||||
@ -8,9 +10,7 @@ import { MEDIA_ENUMERATE } from '../constants'
|
||||
|
||||
describe('Media', () => {
|
||||
|
||||
const onSave = jest.fn()
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
store = createStore()
|
||||
store.dispatch({
|
||||
type: MEDIA_ENUMERATE,
|
||||
@ -34,7 +34,7 @@ describe('Media', () => {
|
||||
ReactDOM.render(
|
||||
<div ref={div => resolve(div!)}>
|
||||
<Provider store={store}>
|
||||
<Media onSave={onSave} />
|
||||
<Media />
|
||||
</Provider>
|
||||
</div>,
|
||||
div,
|
||||
@ -44,18 +44,27 @@ describe('Media', () => {
|
||||
}
|
||||
|
||||
describe('submit', () => {
|
||||
it('calls onSave', async () => {
|
||||
const stream = {} as MediaStream
|
||||
let promise: Promise<MediaStream>
|
||||
beforeEach(() => {
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
promise = Promise.resolve(stream)
|
||||
return promise
|
||||
}
|
||||
})
|
||||
it('tries to retrieve audio/video media stream', async () => {
|
||||
const node = await render()
|
||||
expect(node.tagName).toBe('FORM')
|
||||
TestUtils.Simulate.submit(node)
|
||||
expect(onSave.mock.calls.length).toBe(1)
|
||||
expect(promise).toBeDefined()
|
||||
await promise
|
||||
})
|
||||
})
|
||||
|
||||
describe('onVideoChange', () => {
|
||||
it('calls onSetVideoConstraint', async () => {
|
||||
const node = await render()
|
||||
const select = node.querySelector('select.media-video')!
|
||||
const select = node.querySelector('select[name=video-input]')!
|
||||
TestUtils.Simulate.change(select, {
|
||||
target: {
|
||||
value: '{"deviceId":123}',
|
||||
@ -68,7 +77,7 @@ describe('Media', () => {
|
||||
describe('onAudioChange', () => {
|
||||
it('calls onSetAudioConstraint', async () => {
|
||||
const node = await render()
|
||||
const select = node.querySelector('select.media-audio')!
|
||||
const select = node.querySelector('select[name="audio-input"]')!
|
||||
TestUtils.Simulate.change(select, {
|
||||
target: {
|
||||
value: '{"deviceId":456}',
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { AudioConstraint, MediaDevice, setAudioConstraint, setVideoConstraint, VideoConstraint } from '../actions/MediaActions'
|
||||
import { AudioConstraint, MediaDevice, setAudioConstraint, setVideoConstraint, VideoConstraint, getMediaStream, enumerateDevices } from '../actions/MediaActions'
|
||||
import { MediaState } from '../reducers/media'
|
||||
import { State } from '../store'
|
||||
|
||||
export type MediaProps = MediaState & {
|
||||
enumerateDevices: typeof enumerateDevices
|
||||
onSetVideoConstraint: typeof setVideoConstraint
|
||||
onSetAudioConstraint: typeof setAudioConstraint
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function getId(constraint: VideoConstraint | AudioConstraint) {
|
||||
return typeof constraint === 'object' && 'deviceId' in constraint
|
||||
? constraint.deviceId
|
||||
: ''
|
||||
getMediaStream: typeof getMediaStream
|
||||
}
|
||||
|
||||
function mapStateToProps(state: State) {
|
||||
@ -23,17 +18,27 @@ function mapStateToProps(state: State) {
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
enumerateDevices,
|
||||
onSetVideoConstraint: setVideoConstraint,
|
||||
onSetAudioConstraint: setAudioConstraint,
|
||||
getMediaStream,
|
||||
}
|
||||
|
||||
const c = connect(mapStateToProps, mapDispatchToProps)
|
||||
|
||||
export const Media = c(React.memo(function Media(props: MediaProps) {
|
||||
|
||||
function onSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
React.useMemo(async () => await props.enumerateDevices(), [])
|
||||
|
||||
async function onSave(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
props.onSave()
|
||||
const { audio, video } = props
|
||||
try {
|
||||
await props.getMediaStream({ audio, video })
|
||||
} catch (err) {
|
||||
console.error(err.stack)
|
||||
// TODO display a message
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
@ -46,12 +51,17 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
|
||||
props.onSetAudioConstraint(constraint)
|
||||
}
|
||||
|
||||
const videoId = getId(props.video)
|
||||
const audioId = getId(props.audio)
|
||||
const videoId = JSON.stringify(props.video)
|
||||
const audioId = JSON.stringify(props.audio)
|
||||
|
||||
return (
|
||||
<form className='media' onSubmit={onSave}>
|
||||
<select className='media-video' onChange={onVideoChange} value={videoId}>
|
||||
<label htmlFor='video-input'>Video</label>
|
||||
<select
|
||||
name='video-input'
|
||||
onChange={onVideoChange}
|
||||
value={videoId}
|
||||
>
|
||||
<Options
|
||||
devices={props.devices}
|
||||
default='{"facingMode":"user"}'
|
||||
@ -59,7 +69,12 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
|
||||
/>
|
||||
</select>
|
||||
|
||||
<select className='media-audio' onChange={onAudioChange} value={audioId}>
|
||||
<label htmlFor='video-input'>Audio</label>
|
||||
<select
|
||||
name='audio-input'
|
||||
onChange={onAudioChange}
|
||||
value={audioId}
|
||||
>
|
||||
<Options
|
||||
devices={props.devices}
|
||||
default='true'
|
||||
@ -68,7 +83,7 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
|
||||
</select>
|
||||
|
||||
<button type='submit'>
|
||||
Save
|
||||
Join Call
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
@ -91,7 +106,9 @@ function Options(props: OptionsProps) {
|
||||
.map(device =>
|
||||
<option
|
||||
key={device.id}
|
||||
value={JSON.stringify({deviceId: device.id})}>{device.name}
|
||||
value={JSON.stringify({deviceId: device.id})}
|
||||
>
|
||||
{device.name || device.type}
|
||||
</option>,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import * as MediaActions from '../actions/MediaActions'
|
||||
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM } from '../constants'
|
||||
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM, ME, PEERS_DESTROY, PEER_ADD } from '../constants'
|
||||
import { createStore, Store } from '../store'
|
||||
import SimplePeer from 'simple-peer'
|
||||
|
||||
describe('media', () => {
|
||||
|
||||
@ -95,14 +96,73 @@ describe('media', () => {
|
||||
navigator.mediaDevices.getUserMedia = async () => stream
|
||||
})
|
||||
|
||||
it('returns a promise with media stream', async () => {
|
||||
const promise = MediaActions.getMediaStream({
|
||||
async function dispatch() {
|
||||
const promise = store.dispatch(MediaActions.getMediaStream({
|
||||
audio: true,
|
||||
video: true,
|
||||
})
|
||||
expect(promise.type).toBe('MEDIA_STREAM')
|
||||
expect(promise.status).toBe('pending')
|
||||
}))
|
||||
expect(await promise).toBe(stream)
|
||||
}
|
||||
|
||||
describe('reducers/streams', () => {
|
||||
it('adds the local stream to the map of videos', async () => {
|
||||
expect(store.getState().streams[ME]).toBeFalsy()
|
||||
await dispatch()
|
||||
expect(store.getState().streams[ME]).toBeTruthy()
|
||||
expect(store.getState().streams[ME].stream).toBe(stream)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reducers/peers', () => {
|
||||
const peer1 = new SimplePeer()
|
||||
peer1.addStream = jest.fn()
|
||||
peer1.removeStream = jest.fn()
|
||||
const peer2 = new SimplePeer()
|
||||
peer2.addStream = jest.fn()
|
||||
peer2.removeStream = jest.fn()
|
||||
const peers = [peer1, peer2]
|
||||
|
||||
beforeEach(() => {
|
||||
store.dispatch({
|
||||
type: PEERS_DESTROY,
|
||||
})
|
||||
store.dispatch({
|
||||
type: PEER_ADD,
|
||||
payload: {
|
||||
userId: '1',
|
||||
peer: peer1,
|
||||
},
|
||||
})
|
||||
store.dispatch({
|
||||
type: PEER_ADD,
|
||||
payload: {
|
||||
userId: '2',
|
||||
peer: peer2,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
store.dispatch({
|
||||
type: PEERS_DESTROY,
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces local stream on all peers', async () => {
|
||||
await dispatch()
|
||||
peers.forEach(peer => {
|
||||
expect((peer.addStream as jest.Mock).mock.calls)
|
||||
.toEqual([[ stream ]])
|
||||
expect((peer.removeStream as jest.Mock).mock.calls).toEqual([])
|
||||
})
|
||||
await dispatch()
|
||||
peers.forEach(peer => {
|
||||
expect((peer.addStream as jest.Mock).mock.calls)
|
||||
.toEqual([[ stream ], [ stream ]])
|
||||
expect((peer.removeStream as jest.Mock).mock.calls)
|
||||
.toEqual([[ stream ]])
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@ -3,14 +3,17 @@ import omit from 'lodash/omit'
|
||||
import Peer from 'simple-peer'
|
||||
import { PeerAction } from '../actions/PeerActions'
|
||||
import * as constants from '../constants'
|
||||
import { MediaStreamAction } from '../actions/MediaActions'
|
||||
|
||||
export type PeersState = Record<string, Peer.Instance>
|
||||
|
||||
const defaultState: PeersState = {}
|
||||
|
||||
let localStream: MediaStream | undefined
|
||||
|
||||
export default function peers(
|
||||
state = defaultState,
|
||||
action: PeerAction,
|
||||
action: PeerAction | MediaStreamAction,
|
||||
): PeersState {
|
||||
switch (action.type) {
|
||||
case constants.PEER_ADD:
|
||||
@ -21,8 +24,18 @@ export default function peers(
|
||||
case constants.PEER_REMOVE:
|
||||
return omit(state, [action.payload.userId])
|
||||
case constants.PEERS_DESTROY:
|
||||
localStream = undefined
|
||||
forEach(state, peer => peer.destroy())
|
||||
return defaultState
|
||||
case constants.MEDIA_STREAM:
|
||||
if (action.status === 'resolved') {
|
||||
forEach(state, peer => {
|
||||
localStream && peer.removeStream(localStream)
|
||||
peer.addStream(action.payload)
|
||||
})
|
||||
localStream = action.payload
|
||||
}
|
||||
return state
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import _debug from 'debug'
|
||||
import omit from 'lodash/omit'
|
||||
import { AddStreamAction, AddStreamPayload, RemoveStreamAction, StreamAction } from '../actions/StreamActions'
|
||||
import { STREAM_ADD, STREAM_REMOVE } from '../constants'
|
||||
import { STREAM_ADD, STREAM_REMOVE, MEDIA_STREAM, ME } from '../constants'
|
||||
import { createObjectURL, revokeObjectURL } from '../window'
|
||||
import { MediaStreamAction } from '../actions/MediaActions'
|
||||
|
||||
const debug = _debug('peercalls')
|
||||
const defaultState = Object.freeze({})
|
||||
@ -21,9 +22,9 @@ export interface StreamsState {
|
||||
}
|
||||
|
||||
function addStream (
|
||||
state: StreamsState, action: AddStreamAction,
|
||||
state: StreamsState, payload: AddStreamAction['payload'],
|
||||
): StreamsState {
|
||||
const { userId, stream } = action.payload
|
||||
const { userId, stream } = payload
|
||||
|
||||
const userStream: AddStreamPayload = {
|
||||
userId,
|
||||
@ -38,9 +39,9 @@ function addStream (
|
||||
}
|
||||
|
||||
function removeStream (
|
||||
state: StreamsState, action: RemoveStreamAction,
|
||||
state: StreamsState, payload: RemoveStreamAction['payload'],
|
||||
): StreamsState {
|
||||
const { userId } = action.payload
|
||||
const { userId } = payload
|
||||
const stream = state[userId]
|
||||
if (stream && stream.url) {
|
||||
revokeObjectURL(stream.url)
|
||||
@ -48,15 +49,31 @@ function removeStream (
|
||||
return omit(state, [userId])
|
||||
}
|
||||
|
||||
function replaceStream(state: StreamsState, stream: MediaStream): StreamsState {
|
||||
state = removeStream(state, {
|
||||
userId: ME,
|
||||
})
|
||||
return addStream(state, {
|
||||
userId: ME,
|
||||
stream,
|
||||
})
|
||||
}
|
||||
|
||||
export default function streams(
|
||||
state = defaultState,
|
||||
action: StreamAction,
|
||||
action: StreamAction | MediaStreamAction,
|
||||
): StreamsState {
|
||||
switch (action.type) {
|
||||
case STREAM_ADD:
|
||||
return addStream(state, action)
|
||||
return addStream(state, action.payload)
|
||||
case STREAM_REMOVE:
|
||||
return removeStream(state, action)
|
||||
return removeStream(state, action.payload)
|
||||
case MEDIA_STREAM:
|
||||
if (action.status === 'resolved') {
|
||||
return replaceStream(state, action.payload)
|
||||
} else {
|
||||
return state
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
||||
@ -1,58 +1,7 @@
|
||||
import {
|
||||
createObjectURL,
|
||||
revokeObjectURL,
|
||||
getUserMedia,
|
||||
navigator,
|
||||
play,
|
||||
valueOf,
|
||||
} from './window'
|
||||
import { createObjectURL, play, revokeObjectURL, valueOf } from './window'
|
||||
|
||||
describe('window', () => {
|
||||
|
||||
describe('getUserMedia', () => {
|
||||
|
||||
class MediaStream {}
|
||||
const stream = new MediaStream()
|
||||
const constraints = { video: true }
|
||||
|
||||
afterEach(() => {
|
||||
delete (navigator as any).mediaDevices
|
||||
delete navigator.getUserMedia
|
||||
delete (navigator as any).webkitGetUserMedia
|
||||
})
|
||||
|
||||
it('calls navigator.mediaDevices.getUserMedia', async () => {
|
||||
const promise = Promise.resolve(stream);
|
||||
(navigator as any).mediaDevices = {
|
||||
getUserMedia: jest.fn().mockReturnValue(promise),
|
||||
}
|
||||
expect(await getUserMedia(constraints)).toBe(stream)
|
||||
})
|
||||
|
||||
;['getUserMedia', 'webkitGetUserMedia'].forEach((method) => {
|
||||
it(`it calls navigator.${method} as a fallback`, () => {
|
||||
(navigator as any)[method] = jest.fn()
|
||||
.mockImplementation(
|
||||
(constraints, onSuccess, onError) => onSuccess(stream),
|
||||
)
|
||||
return getUserMedia(constraints)
|
||||
.then(s => expect(s).toBe(stream))
|
||||
})
|
||||
})
|
||||
|
||||
it('throws error when no supported method', async () => {
|
||||
let error: Error
|
||||
try {
|
||||
await getUserMedia(constraints)
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
expect(error!).toBeTruthy()
|
||||
expect(error!.message).toBe('Browser unsupported')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('play', () => {
|
||||
|
||||
let v1: HTMLVideoElement & { play: jest.Mock }
|
||||
|
||||
@ -2,27 +2,10 @@ import _debug from 'debug'
|
||||
|
||||
const debug = _debug('peercalls')
|
||||
|
||||
export async function getUserMedia (constraints: MediaStreamConstraints) {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
return navigator.mediaDevices.getUserMedia(constraints)
|
||||
}
|
||||
|
||||
return new Promise<MediaStream>((resolve, reject) => {
|
||||
const getMedia = navigator.getUserMedia ||
|
||||
(navigator as unknown as {
|
||||
webkitGetUserMedia: typeof navigator.getUserMedia
|
||||
}).webkitGetUserMedia
|
||||
if (!getMedia) reject(new Error('Browser unsupported'))
|
||||
getMedia.call(navigator, constraints, resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
export const createObjectURL = (object: unknown) =>
|
||||
window.URL.createObjectURL(object)
|
||||
export const revokeObjectURL = (url: string) => window.URL.revokeObjectURL(url)
|
||||
|
||||
export const navigator = window.navigator
|
||||
|
||||
export function play () {
|
||||
const videos = window.document.querySelectorAll('video')
|
||||
Array.prototype.forEach.call(videos, (video, index) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user