diff --git a/package.json b/package.json
index 6f69397..90d5a5a 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"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",
"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:fix": "eslint . --fix",
"ci": "npm run lint && npm run test:coverage && npm run build"
diff --git a/src/client/__mocks__/window.js b/src/client/__mocks__/window.js
index 62fe787..e589b8f 100644
--- a/src/client/__mocks__/window.js
+++ b/src/client/__mocks__/window.js
@@ -1,8 +1,10 @@
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 () {
return !getUserMedia.shouldFail
? Promise.resolve(getUserMedia.stream)
diff --git a/src/client/__tests__/App-test.js b/src/client/__tests__/App-test.js
index b5f485e..036e5df 100644
--- a/src/client/__tests__/App-test.js
+++ b/src/client/__tests__/App-test.js
@@ -1,5 +1,6 @@
jest.mock('../actions/CallActions.js')
jest.mock('../socket.js')
+jest.mock('../window.js')
import * as constants from '../constants.js'
import App from '../containers/App.js'
@@ -8,6 +9,7 @@ import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
import configureStore from 'redux-mock-store'
import reducers from '../reducers'
+import { MediaStream } from '../window.js'
import { Provider } from 'react-redux'
import { init } from '../actions/CallActions.js'
import { middlewares } from '../store.js'
@@ -44,9 +46,12 @@ describe('App', () => {
describe('state', () => {
let alert
beforeEach(() => {
- state.streams = state.streams.merge({
- test: 'blob://'
- })
+ state.streams = {
+ test: {
+ mediaStream: new MediaStream(),
+ url: 'blob://'
+ }
+ }
state.peers = {
test: {}
}
diff --git a/src/client/__tests__/window-test.js b/src/client/__tests__/window-test.js
index 30f0531..097f473 100644
--- a/src/client/__tests__/window-test.js
+++ b/src/client/__tests__/window-test.js
@@ -2,6 +2,7 @@ import Promise from 'bluebird'
import {
createObjectURL,
+ revokeObjectURL,
getUserMedia,
navigator,
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', () => {
let input
diff --git a/src/client/actions/__tests__/SocketActions-test.js b/src/client/actions/__tests__/SocketActions-test.js
index 2d99b1d..eb5f2e5 100644
--- a/src/client/actions/__tests__/SocketActions-test.js
+++ b/src/client/actions/__tests__/SocketActions-test.js
@@ -124,7 +124,10 @@ describe('SocketActions', () => {
peer.emit(constants.PEER_EVENT_STREAM, stream)
expect(store.getState().streams).toEqual({
- b: jasmine.any(String)
+ b: {
+ mediaStream: stream,
+ url: jasmine.any(String)
+ }
})
})
})
@@ -134,7 +137,10 @@ describe('SocketActions', () => {
const stream = {}
peer.emit(constants.PEER_EVENT_STREAM, stream)
expect(store.getState().streams).toEqual({
- b: jasmine.any(String)
+ b: {
+ mediaStream: stream,
+ url: jasmine.any(String)
+ }
})
})
diff --git a/src/client/components/App.js b/src/client/components/App.js
index 0e6fbb8..50db8bf 100644
--- a/src/client/components/App.js
+++ b/src/client/components/App.js
@@ -4,7 +4,7 @@ import Input from './Input.js'
import Notifications, { NotificationPropTypes } from './Notifications.js'
import PropTypes from 'prop-types'
import React from 'react'
-import Video from './Video.js'
+import Video, { StreamPropType } from './Video.js'
import _ from 'underscore'
export default class App extends React.PureComponent {
@@ -17,7 +17,7 @@ export default class App extends React.PureComponent {
notify: PropTypes.func.isRequired,
peers: PropTypes.object.isRequired,
sendMessage: PropTypes.func.isRequired,
- streams: PropTypes.objectOf(PropTypes.string).isRequired,
+ streams: PropTypes.objectOf(StreamPropType).isRequired,
toggleActive: PropTypes.func.isRequired
}
componentDidMount () {
diff --git a/src/client/components/Input.js b/src/client/components/Input.js
index bdade9a..2c5e61d 100644
--- a/src/client/components/Input.js
+++ b/src/client/components/Input.js
@@ -45,6 +45,7 @@ export default class Input extends React.PureComponent {
type="text"
value={message}
/>
+
)
}
diff --git a/src/client/components/Video.js b/src/client/components/Video.js
index 80f2cce..97d7034 100644
--- a/src/client/components/Video.js
+++ b/src/client/components/Video.js
@@ -2,12 +2,18 @@ import PropTypes from 'prop-types'
import React from 'react'
import classnames from 'classnames'
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 {
static propTypes = {
onClick: PropTypes.func,
active: PropTypes.bool.isRequired,
- stream: PropTypes.string,
+ stream: StreamPropType,
userId: PropTypes.string.isRequired
}
handleClick = e => {
@@ -19,8 +25,26 @@ export default class Video extends React.PureComponent {
e.preventDefault()
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 () {
- const { active, stream, userId } = this.props
+ const { active, userId } = this.props
const className = classnames('video-container', { active })
return (
@@ -28,7 +52,7 @@ export default class Video extends React.PureComponent {
muted={userId === ME}
onClick={this.handleClick}
onLoadedMetadata={this.play}
- src={stream}
+ ref="video"
/>
)
diff --git a/src/client/components/__tests__/Video-test.js b/src/client/components/__tests__/Video-test.js
new file mode 100644
index 0000000..9047362
--- /dev/null
+++ b/src/client/components/__tests__/Video-test.js
@@ -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
+ }
+ }
+
+ let component, video, onClick, mediaStream, url
+ function render () {
+ onClick = jest.fn()
+ mediaStream = new MediaStream()
+ component = TestUtils.renderIntoDocument(
+
+ )
+ 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 }
+ })
+ })
+ })
+ })
+
+})
diff --git a/src/client/reducers/__tests__/streams-test.js b/src/client/reducers/__tests__/streams-test.js
index d8d97be..e7f2649 100644
--- a/src/client/reducers/__tests__/streams-test.js
+++ b/src/client/reducers/__tests__/streams-test.js
@@ -1,13 +1,14 @@
jest.mock('../../window.js')
import * as StreamActions from '../../actions/StreamActions.js'
+import reducers from '../index.js'
+import { MediaStream } from '../../window.js'
import { applyMiddleware, createStore } from 'redux'
import { create } from '../../middlewares.js'
-import reducers from '../index.js'
+import { createObjectURL } from '../../window.js'
describe('reducers/alerts', () => {
- class MediaStream {}
let store, stream, userId
beforeEach(() => {
store = createStore(
@@ -18,6 +19,11 @@ describe('reducers/alerts', () => {
stream = new MediaStream()
})
+ afterEach(() => {
+ createObjectURL
+ .mockImplementation(object => 'blob://' + String(object))
+ })
+
describe('defaultState', () => {
it('should have default state set', () => {
expect(store.getState().streams).toEqual({})
@@ -28,7 +34,21 @@ describe('reducers/alerts', () => {
it('adds a stream', () => {
store.dispatch(StreamActions.addStream({ userId, stream }))
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))
expect(store.getState().streams).toEqual({})
})
+ it('does not fail when no stream', () => {
+ store.dispatch(StreamActions.removeStream(userId))
+ })
})
})
diff --git a/src/client/reducers/streams.js b/src/client/reducers/streams.js
index b572a9c..43b050a 100644
--- a/src/client/reducers/streams.js
+++ b/src/client/reducers/streams.js
@@ -1,17 +1,39 @@
import * as constants from '../constants.js'
-import Immutable from 'seamless-immutable'
-import { createObjectURL } from '../window.js'
+import _ from 'underscore'
+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) {
const { userId, stream } = action.payload
- return state.merge({
- [userId]: createObjectURL(stream)
+ return Object.freeze({
+ ...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) {
switch (action && action.type) {
diff --git a/src/client/window.js b/src/client/window.js
index 7f1fc60..8c2d41d 100644
--- a/src/client/window.js
+++ b/src/client/window.js
@@ -16,6 +16,7 @@ export function getUserMedia (constraints) {
}
export const createObjectURL = object => window.URL.createObjectURL(object)
+export const revokeObjectURL = url => window.URL.revokeObjectURL(url)
export const navigator = window.navigator
@@ -38,3 +39,5 @@ export const valueOf = id => {
export const callId = valueOf('callId')
export const iceServers = JSON.parse(valueOf('iceServers'))
+
+export const MediaStream = window.MediaStream
diff --git a/src/scss/style.scss b/src/scss/style.scss
index 9290aee..c114ca2 100644
--- a/src/scss/style.scss
+++ b/src/scss/style.scss
@@ -265,13 +265,17 @@ body.call {
input {
box-shadow: 0px 0px 5px black;
- background-color: black;
- background-color: rgba(0, 0, 0, 0.5);
+ // background-color: black;
+ background-color: #333;
border: none;
color: #ccc;
padding: 0.5rem;
font-family: $font-monospace;
}
+
+ input[type="submit"] {
+
+ }
}
}