Add compatibility layer for iOS 11

This commit is contained in:
Jerko Steiner 2017-06-20 19:11:51 -04:00
parent 967d623b32
commit cc1639eade
13 changed files with 200 additions and 24 deletions

View File

@ -16,7 +16,7 @@
"js": "browserify -t babelify ./src/client/index.js -o ./build/index.js", "js": "browserify -t babelify ./src/client/index.js -o ./build/index.js",
"js:watch": "watchify -d -v -t babelify ./src/client/index.js -o ./build/index.js", "js:watch": "watchify -d -v -t babelify ./src/client/index.js -o ./build/index.js",
"css": "node-sass ./src/scss/style.scss -o ./build/", "css": "node-sass ./src/scss/style.scss -o ./build/",
"css:watch": "node-sass --watch ./src/scss/style.scss -o ./build/", "css:watch": "npm run css && node-sass --watch ./src/scss/style.scss -o ./build/",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"ci": "npm run lint && npm run test:coverage && npm run build" "ci": "npm run lint && npm run test:coverage && npm run build"

View File

@ -1,8 +1,10 @@
import Promise from 'bluebird' import Promise from 'bluebird'
export const createObjectURL = object => 'blob://' + String(object) export const createObjectURL = jest.fn()
.mockImplementation(object => 'blob://' + String(object))
export const revokeObjectURL = jest.fn()
class MediaStream {} export class MediaStream {}
export function getUserMedia () { export function getUserMedia () {
return !getUserMedia.shouldFail return !getUserMedia.shouldFail
? Promise.resolve(getUserMedia.stream) ? Promise.resolve(getUserMedia.stream)

View File

@ -1,5 +1,6 @@
jest.mock('../actions/CallActions.js') jest.mock('../actions/CallActions.js')
jest.mock('../socket.js') jest.mock('../socket.js')
jest.mock('../window.js')
import * as constants from '../constants.js' import * as constants from '../constants.js'
import App from '../containers/App.js' import App from '../containers/App.js'
@ -8,6 +9,7 @@ import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import configureStore from 'redux-mock-store' import configureStore from 'redux-mock-store'
import reducers from '../reducers' import reducers from '../reducers'
import { MediaStream } from '../window.js'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { init } from '../actions/CallActions.js' import { init } from '../actions/CallActions.js'
import { middlewares } from '../store.js' import { middlewares } from '../store.js'
@ -44,9 +46,12 @@ describe('App', () => {
describe('state', () => { describe('state', () => {
let alert let alert
beforeEach(() => { beforeEach(() => {
state.streams = state.streams.merge({ state.streams = {
test: 'blob://' test: {
}) mediaStream: new MediaStream(),
url: 'blob://'
}
}
state.peers = { state.peers = {
test: {} test: {}
} }

View File

@ -2,6 +2,7 @@ import Promise from 'bluebird'
import { import {
createObjectURL, createObjectURL,
revokeObjectURL,
getUserMedia, getUserMedia,
navigator, navigator,
play, play,
@ -100,6 +101,15 @@ describe('window', () => {
}) })
describe('createObjectURL', () => {
it('calls window.URL.revokeObjectURL', () => {
window.URL.revokeObjectURL = jest.fn()
expect(revokeObjectURL()).toBe(undefined)
})
})
describe('valueOf', () => { describe('valueOf', () => {
let input let input

View File

@ -124,7 +124,10 @@ describe('SocketActions', () => {
peer.emit(constants.PEER_EVENT_STREAM, stream) peer.emit(constants.PEER_EVENT_STREAM, stream)
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
b: jasmine.any(String) b: {
mediaStream: stream,
url: jasmine.any(String)
}
}) })
}) })
}) })
@ -134,7 +137,10 @@ describe('SocketActions', () => {
const stream = {} const stream = {}
peer.emit(constants.PEER_EVENT_STREAM, stream) peer.emit(constants.PEER_EVENT_STREAM, stream)
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
b: jasmine.any(String) b: {
mediaStream: stream,
url: jasmine.any(String)
}
}) })
}) })

View File

@ -4,7 +4,7 @@ import Input from './Input.js'
import Notifications, { NotificationPropTypes } from './Notifications.js' import Notifications, { NotificationPropTypes } from './Notifications.js'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import Video from './Video.js' import Video, { StreamPropType } from './Video.js'
import _ from 'underscore' import _ from 'underscore'
export default class App extends React.PureComponent { export default class App extends React.PureComponent {
@ -17,7 +17,7 @@ export default class App extends React.PureComponent {
notify: PropTypes.func.isRequired, notify: PropTypes.func.isRequired,
peers: PropTypes.object.isRequired, peers: PropTypes.object.isRequired,
sendMessage: PropTypes.func.isRequired, sendMessage: PropTypes.func.isRequired,
streams: PropTypes.objectOf(PropTypes.string).isRequired, streams: PropTypes.objectOf(StreamPropType).isRequired,
toggleActive: PropTypes.func.isRequired toggleActive: PropTypes.func.isRequired
} }
componentDidMount () { componentDidMount () {

View File

@ -45,6 +45,7 @@ export default class Input extends React.PureComponent {
type="text" type="text"
value={message} value={message}
/> />
<input type="submit" value="Send"/>
</form> </form>
) )
} }

View File

@ -2,12 +2,18 @@ import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import { ME } from '../constants.js' import { ME } from '../constants.js'
import { MediaStream } from '../window.js'
export const StreamPropType = PropTypes.shape({
mediaStream: PropTypes.instanceOf(MediaStream).isRequired,
url: PropTypes.string
})
export default class Video extends React.PureComponent { export default class Video extends React.PureComponent {
static propTypes = { static propTypes = {
onClick: PropTypes.func, onClick: PropTypes.func,
active: PropTypes.bool.isRequired, active: PropTypes.bool.isRequired,
stream: PropTypes.string, stream: StreamPropType,
userId: PropTypes.string.isRequired userId: PropTypes.string.isRequired
} }
handleClick = e => { handleClick = e => {
@ -19,8 +25,26 @@ export default class Video extends React.PureComponent {
e.preventDefault() e.preventDefault()
e.target.play() e.target.play()
} }
componentDidMount () {
this.componentDidUpdate()
}
componentDidUpdate () {
const { stream } = this.props
const { video } = this.refs
const mediaStream = stream && stream.mediaStream
const url = stream && stream.url
if ('srcObject' in video) {
if (video.srcObject !== mediaStream) {
this.refs.video.srcObject = mediaStream
}
} else {
if (video.src !== url) {
video.src = url
}
}
}
render () { render () {
const { active, stream, userId } = this.props const { active, userId } = this.props
const className = classnames('video-container', { active }) const className = classnames('video-container', { active })
return ( return (
<div className={className}> <div className={className}>
@ -28,7 +52,7 @@ export default class Video extends React.PureComponent {
muted={userId === ME} muted={userId === ME}
onClick={this.handleClick} onClick={this.handleClick}
onLoadedMetadata={this.play} onLoadedMetadata={this.play}
src={stream} ref="video"
/> />
</div> </div>
) )

View File

@ -0,0 +1,76 @@
jest.mock('../../window.js')
import React from 'react'
import TestUtils from 'react-dom/test-utils'
import Video from '../Video.js'
import { MediaStream } from '../../window.js'
describe('components/Video', () => {
class VideoWrapper extends React.PureComponent {
static propTypes = Video.propTypes
constructor () {
super()
this.state = {}
}
render () {
return <Video
active={this.props.active}
stream={this.state.stream || this.props.stream}
onClick={this.props.onClick}
userId="test"
/>
}
}
let component, video, onClick, mediaStream, url
function render () {
onClick = jest.fn()
mediaStream = new MediaStream()
component = TestUtils.renderIntoDocument(
<VideoWrapper
active
stream={{ mediaStream, url }}
onClick={onClick}
userId="test"
/>
)
video = TestUtils.findRenderedComponentWithType(component, Video)
}
describe('render', () => {
it('should not fail', () => {
render()
})
})
describe('componentDidUpdate', () => {
describe('src', () => {
beforeEach(() => {
render()
delete video.refs.video.srcObject
})
it('updates src only when changed', () => {
mediaStream = new MediaStream()
component.setState({
stream: { url: 'test', mediaStream }
})
expect(video.refs.video.src).toBe('test')
component.setState({
stream: { url: 'test', mediaStream }
})
})
it('updates srcObject only when changed', () => {
video.refs.video.srcObject = null
mediaStream = new MediaStream()
component.setState({
stream: { url: 'test', mediaStream }
})
expect(video.refs.video.srcObject).toBe(mediaStream)
component.setState({
stream: { url: 'test', mediaStream }
})
})
})
})
})

View File

@ -1,13 +1,14 @@
jest.mock('../../window.js') jest.mock('../../window.js')
import * as StreamActions from '../../actions/StreamActions.js' import * as StreamActions from '../../actions/StreamActions.js'
import reducers from '../index.js'
import { MediaStream } from '../../window.js'
import { applyMiddleware, createStore } from 'redux' import { applyMiddleware, createStore } from 'redux'
import { create } from '../../middlewares.js' import { create } from '../../middlewares.js'
import reducers from '../index.js' import { createObjectURL } from '../../window.js'
describe('reducers/alerts', () => { describe('reducers/alerts', () => {
class MediaStream {}
let store, stream, userId let store, stream, userId
beforeEach(() => { beforeEach(() => {
store = createStore( store = createStore(
@ -18,6 +19,11 @@ describe('reducers/alerts', () => {
stream = new MediaStream() stream = new MediaStream()
}) })
afterEach(() => {
createObjectURL
.mockImplementation(object => 'blob://' + String(object))
})
describe('defaultState', () => { describe('defaultState', () => {
it('should have default state set', () => { it('should have default state set', () => {
expect(store.getState().streams).toEqual({}) expect(store.getState().streams).toEqual({})
@ -28,7 +34,21 @@ describe('reducers/alerts', () => {
it('adds a stream', () => { it('adds a stream', () => {
store.dispatch(StreamActions.addStream({ userId, stream })) store.dispatch(StreamActions.addStream({ userId, stream }))
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
[userId]: jasmine.any(String) [userId]: {
mediaStream: stream,
url: jasmine.any(String)
}
})
})
it('does not fail when createObjectURL fails', () => {
createObjectURL
.mockImplementation(() => { throw new Error('test') })
store.dispatch(StreamActions.addStream({ userId, stream }))
expect(store.getState().streams).toEqual({
[userId]: {
mediaStream: stream,
url: null
}
}) })
}) })
}) })
@ -39,6 +59,9 @@ describe('reducers/alerts', () => {
store.dispatch(StreamActions.removeStream(userId)) store.dispatch(StreamActions.removeStream(userId))
expect(store.getState().streams).toEqual({}) expect(store.getState().streams).toEqual({})
}) })
it('does not fail when no stream', () => {
store.dispatch(StreamActions.removeStream(userId))
})
}) })
}) })

View File

@ -1,17 +1,39 @@
import * as constants from '../constants.js' import * as constants from '../constants.js'
import Immutable from 'seamless-immutable' import _ from 'underscore'
import { createObjectURL } from '../window.js' import { createObjectURL, revokeObjectURL } from '../window.js'
import _debug from 'debug'
const defaultState = Immutable({}) const debug = _debug('peercalls')
const defaultState = Object.freeze({})
function safeCreateObjectURL (stream) {
try {
return createObjectURL(stream)
} catch (err) {
debug('Error using createObjectURL: %s', err.message)
return null
}
}
function addStream (state, action) { function addStream (state, action) {
const { userId, stream } = action.payload const { userId, stream } = action.payload
return state.merge({ return Object.freeze({
[userId]: createObjectURL(stream) ...state,
[userId]: Object.freeze({
mediaStream: stream,
url: safeCreateObjectURL(stream)
})
}) })
} }
const removeStream = (state, action) => state.without(action.payload.userId) function removeStream (state, action) {
const { userId } = action.payload
const stream = state[userId]
if (stream && stream.url) {
revokeObjectURL(stream.url)
}
return Object.freeze(_.omit(state, [userId]))
}
export default function streams (state = defaultState, action) { export default function streams (state = defaultState, action) {
switch (action && action.type) { switch (action && action.type) {

View File

@ -16,6 +16,7 @@ export function getUserMedia (constraints) {
} }
export const createObjectURL = object => window.URL.createObjectURL(object) export const createObjectURL = object => window.URL.createObjectURL(object)
export const revokeObjectURL = url => window.URL.revokeObjectURL(url)
export const navigator = window.navigator export const navigator = window.navigator
@ -38,3 +39,5 @@ export const valueOf = id => {
export const callId = valueOf('callId') export const callId = valueOf('callId')
export const iceServers = JSON.parse(valueOf('iceServers')) export const iceServers = JSON.parse(valueOf('iceServers'))
export const MediaStream = window.MediaStream

View File

@ -265,13 +265,17 @@ body.call {
input { input {
box-shadow: 0px 0px 5px black; box-shadow: 0px 0px 5px black;
background-color: black; // background-color: black;
background-color: rgba(0, 0, 0, 0.5); background-color: #333;
border: none; border: none;
color: #ccc; color: #ccc;
padding: 0.5rem; padding: 0.5rem;
font-family: $font-monospace; font-family: $font-monospace;
} }
input[type="submit"] {
}
} }
} }