Use addTrack/removeTrack over addStream/removeStream

The addStream and removeStream are deprecated and the MDN docs
recommend using addStream/removeStream instead.

While we add tracks, we can also add event listeners to whether or not a
track has ended and then remove a stream once all tracks in the streams
have ended.
This commit is contained in:
Jerko Steiner 2020-03-10 11:21:33 +01:00
parent 53ddcdfcbf
commit f056048d62
16 changed files with 230 additions and 120 deletions

View File

@ -1,6 +1,7 @@
import { makeAction, AsyncAction } from '../async' import { makeAction, AsyncAction } from '../async'
import { MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_ENUMERATE, MEDIA_STREAM, ME, ME_DESKTOP } from '../constants' import { MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_ENUMERATE, MEDIA_STREAM, ME, STREAM_TYPE_CAMERA, STREAM_TYPE_DESKTOP } from '../constants'
import _debug from 'debug' import _debug from 'debug'
import { AddStreamPayload } from './StreamActions'
const debug = _debug('peercalls') const debug = _debug('peercalls')
@ -111,8 +112,9 @@ export const getMediaStream = makeAction(
MEDIA_STREAM, MEDIA_STREAM,
async (constraints: GetMediaConstraints) => { async (constraints: GetMediaConstraints) => {
debug('getMediaStream', constraints) debug('getMediaStream', constraints)
const payload: MediaStreamPayload = { const payload: AddStreamPayload = {
stream: await getUserMedia(constraints), stream: await getUserMedia(constraints),
type: STREAM_TYPE_CAMERA,
userId: ME, userId: ME,
} }
return payload return payload
@ -123,21 +125,17 @@ export const getDesktopStream = makeAction(
MEDIA_STREAM, MEDIA_STREAM,
async () => { async () => {
debug('getDesktopStream') debug('getDesktopStream')
const payload: MediaStreamPayload = { const payload: AddStreamPayload = {
stream: await getDisplayMedia(), stream: await getDisplayMedia(),
userId: ME_DESKTOP, type: STREAM_TYPE_DESKTOP,
userId: ME,
} }
return payload return payload
}, },
) )
export interface MediaStreamPayload {
stream: MediaStream
userId: string
}
export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]> export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]>
export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStreamPayload> export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', AddStreamPayload>
export type MediaPlayAction = AsyncAction<'MEDIA_PLAY', void> export type MediaPlayAction = AsyncAction<'MEDIA_PLAY', void>
export type MediaAction = export type MediaAction =

View File

@ -57,20 +57,18 @@ class PeerHandler {
const state = getState() const state = getState()
const peer = state.peers[user.id] const peer = state.peers[user.id]
const localStream = state.streams[constants.ME] const localStream = state.streams[constants.ME]
if (localStream && localStream.stream) { localStream && localStream.streams.forEach(s => {
// If the local user pressed join call before this peer has joined the // If the local user pressed join call before this peer has joined the
// call, now is the time to share local media stream with the peer since // call, now is the time to share local media stream with the peer since
// we no longer automatically send the stream to the peer. // we no longer automatically send the stream to the peer.
peer.addStream(localStream.stream) s.stream.getTracks().forEach(track => {
peer.addTrack(track, s.stream)
})
})
} }
const desktopStream = state.streams[constants.ME_DESKTOP] handleTrack = (track: MediaStreamTrack, stream: MediaStream) => {
if (desktopStream && desktopStream.stream) {
peer.addStream(desktopStream.stream)
}
}
handleStream = (stream: MediaStream) => {
const { user, dispatch } = this const { user, dispatch } = this
debug('peer: %s, stream', user.id) debug('peer: %s, track', user.id)
dispatch(StreamActions.addStream({ dispatch(StreamActions.addStream({
userId: user.id, userId: user.id,
stream, stream,
@ -160,7 +158,7 @@ export function createPeer (options: CreatePeerOptions) {
peer.once(constants.PEER_EVENT_CONNECT, handler.handleConnect) peer.once(constants.PEER_EVENT_CONNECT, handler.handleConnect)
peer.once(constants.PEER_EVENT_CLOSE, handler.handleClose) peer.once(constants.PEER_EVENT_CLOSE, handler.handleClose)
peer.on(constants.PEER_EVENT_SIGNAL, handler.handleSignal) peer.on(constants.PEER_EVENT_SIGNAL, handler.handleSignal)
peer.on(constants.PEER_EVENT_STREAM, handler.handleStream) peer.on(constants.PEER_EVENT_TRACK, handler.handleTrack)
peer.on(constants.PEER_EVENT_DATA, handler.handleData) peer.on(constants.PEER_EVENT_DATA, handler.handleData)
dispatch(addPeer({ peer, userId })) dispatch(addPeer({ peer, userId }))

View File

@ -136,7 +136,7 @@ describe('SocketActions', () => {
}] }]
}, },
} }
peer.emit(constants.PEER_EVENT_STREAM, stream) peer.emit(constants.PEER_EVENT_TRACK, stream.getTracks()[0], stream)
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
b: { b: {
@ -151,7 +151,8 @@ describe('SocketActions', () => {
describe('close', () => { describe('close', () => {
beforeEach(() => { beforeEach(() => {
const stream = new MediaStream() const stream = new MediaStream()
peer.emit(constants.PEER_EVENT_STREAM, stream) const track = new MediaStreamTrack()
peer.emit(constants.PEER_EVENT_TRACK, track, stream)
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
b: { b: {
userId: 'b', userId: 'b',

View File

@ -1,9 +1,11 @@
import * as constants from '../constants' import * as constants from '../constants'
export type StreamType = 'camera' | 'desktop'
export interface AddStreamPayload { export interface AddStreamPayload {
userId: string userId: string
type?: StreamType
stream: MediaStream stream: MediaStream
url?: string
} }
export interface AddStreamAction { export interface AddStreamAction {
@ -18,6 +20,7 @@ export interface RemoveStreamAction {
export interface RemoveStreamPayload { export interface RemoveStreamPayload {
userId: string userId: string
stream?: MediaStream
} }
export interface SetActiveStreamAction { export interface SetActiveStreamAction {
@ -39,9 +42,12 @@ export const addStream = (payload: AddStreamPayload): AddStreamAction => ({
payload, payload,
}) })
export const removeStream = (userId: string): RemoveStreamAction => ({ export const removeStream = (
userId: string,
stream?: MediaStream,
): RemoveStreamAction => ({
type: constants.STREAM_REMOVE, type: constants.STREAM_REMOVE,
payload: { userId }, payload: { userId, stream },
}) })
export const setActive = (userId: string): SetActiveStreamAction => ({ export const setActive = (userId: string): SetActiveStreamAction => ({

View File

@ -5,7 +5,7 @@ import Peer from 'simple-peer'
import { Message } from '../actions/ChatActions' import { Message } from '../actions/ChatActions'
import { dismissNotification, Notification } from '../actions/NotifyActions' import { dismissNotification, Notification } from '../actions/NotifyActions'
import { TextMessage } from '../actions/PeerActions' import { TextMessage } from '../actions/PeerActions'
import { AddStreamPayload, removeStream } from '../actions/StreamActions' import { removeStream } from '../actions/StreamActions'
import * as constants from '../constants' import * as constants from '../constants'
import Chat from './Chat' import Chat from './Chat'
import { Media } from './Media' import { Media } from './Media'
@ -14,6 +14,7 @@ import { Side } from './Side'
import Toolbar from './Toolbar' import Toolbar from './Toolbar'
import Video from './Video' import Video from './Video'
import { getDesktopStream } from '../actions/MediaActions' import { getDesktopStream } from '../actions/MediaActions'
import { StreamsState } from '../reducers/streams'
export interface AppProps { export interface AppProps {
active: string | null active: string | null
@ -25,7 +26,7 @@ export interface AppProps {
peers: Record<string, Peer.Instance> peers: Record<string, Peer.Instance>
play: () => void play: () => void
sendMessage: (message: TextMessage) => void sendMessage: (message: TextMessage) => void
streams: Record<string, AddStreamPayload> streams: StreamsState
getDesktopStream: typeof getDesktopStream getDesktopStream: typeof getDesktopStream
removeStream: typeof removeStream removeStream: typeof removeStream
onSendFile: (file: File) => void onSendFile: (file: File) => void
@ -37,8 +38,6 @@ export interface AppState {
chatVisible: boolean chatVisible: boolean
} }
const localStreams: string[] = [constants.ME, constants.ME_DESKTOP]
export default class App extends React.PureComponent<AppProps, AppState> { export default class App extends React.PureComponent<AppProps, AppState> {
state: AppState = { state: AppState = {
videos: {}, videos: {},
@ -65,7 +64,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
} }
onHangup = () => { onHangup = () => {
this.props.removeStream(constants.ME) this.props.removeStream(constants.ME)
this.props.removeStream(constants.ME_DESKTOP)
} }
render () { render () {
const { const {
@ -88,6 +86,11 @@ export default class App extends React.PureComponent<AppProps, AppState> {
'chat-visible': this.state.chatVisible, 'chat-visible': this.state.chatVisible,
}) })
const localStreams = streams[constants.ME] || {
userId: constants.ME,
streams: [],
}
return ( return (
<div className="app"> <div className="app">
<Side align='flex-end' left zIndex={2}> <Side align='flex-end' left zIndex={2}>
@ -97,8 +100,14 @@ export default class App extends React.PureComponent<AppProps, AppState> {
onToggleChat={this.handleToggleChat} onToggleChat={this.handleToggleChat}
onSendFile={onSendFile} onSendFile={onSendFile}
onHangup={this.onHangup} onHangup={this.onHangup}
stream={streams[constants.ME]} stream={
desktopStream={streams[constants.ME_DESKTOP]} localStreams.streams
.filter(s => s.type === constants.STREAM_TYPE_CAMERA)[0]
}
desktopStream={
localStreams.streams
.filter(s => s.type === constants.STREAM_TYPE_DESKTOP)[0]
}
onGetDesktopStream={this.props.getDesktopStream} onGetDesktopStream={this.props.getDesktopStream}
onRemoveStream={this.props.removeStream} onRemoveStream={this.props.removeStream}
/> />
@ -117,33 +126,43 @@ export default class App extends React.PureComponent<AppProps, AppState> {
visible={this.state.chatVisible} visible={this.state.chatVisible}
/> />
<div className={classnames('videos', chatVisibleClassName)}> <div className={classnames('videos', chatVisibleClassName)}>
{localStreams.filter(userId => !!streams[userId]).map(userId => ( {localStreams.streams.map((s, i) => {
const key = localStreams.userId + '_' + i
return (
<Video <Video
videos={videos} videos={videos}
key={userId} key={key}
active={active === userId} active={active === key}
onClick={toggleActive} onClick={toggleActive}
play={play} play={play}
stream={streams[userId]} stream={s}
userId={userId} userId={key}
muted muted
mirrored={userId == constants.ME} mirrored={s.type === 'camera'}
/> />
))} )
})}
{ {
map(peers, (_, userId) => userId) map(peers, (_, userId) => userId)
.filter(stream => !!stream) .filter(stream => !!stream)
.map(userId => .map(userId => streams[userId])
.filter(userStreams => !!userStreams)
.map(userStreams => {
return userStreams.streams.map((s, i) => {
const key = userStreams.userId + '_' + i
return (
<Video <Video
active={userId === active} active={key === active}
key={userId} key={key}
onClick={toggleActive} onClick={toggleActive}
play={play} play={play}
stream={streams[userId]} stream={s}
userId={userId} userId={key}
videos={videos} videos={videos}
/>, />
) )
})
})
} }
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import { MediaState } from '../reducers/media'
import { State } from '../store' import { State } from '../store'
import { Alerts, Alert } from './Alerts' import { Alerts, Alert } from './Alerts'
import { info, warning, error } from '../actions/NotifyActions' import { info, warning, error } from '../actions/NotifyActions'
import { ME } from '../constants' import { ME, STREAM_TYPE_CAMERA } from '../constants'
export type MediaProps = MediaState & { export type MediaProps = MediaState & {
visible: boolean visible: boolean
@ -21,7 +21,10 @@ export type MediaProps = MediaState & {
function mapStateToProps(state: State) { function mapStateToProps(state: State) {
const localStream = state.streams[ME] const localStream = state.streams[ME]
const visible = !localStream const hidden = !!localStream &&
localStream.streams.filter(s => s.type === STREAM_TYPE_CAMERA).length > 0
const visible = !hidden
console.log('visible', visible)
return { return {
...state.media, ...state.media,
visible, visible,

View File

@ -2,11 +2,12 @@ jest.mock('../window')
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import Toolbar, { ToolbarProps } from './Toolbar'
import { MediaStream } from '../window'
import { AddStreamPayload, removeStream } from '../actions/StreamActions'
import { ME_DESKTOP } from '../constants'
import { getDesktopStream } from '../actions/MediaActions' import { getDesktopStream } from '../actions/MediaActions'
import { AddStreamPayload, removeStream } from '../actions/StreamActions'
import { STREAM_TYPE_CAMERA, STREAM_TYPE_DESKTOP } from '../constants'
import { StreamWithURL } from '../reducers/streams'
import { MediaStream } from '../window'
import Toolbar, { ToolbarProps } from './Toolbar'
describe('components/Toolbar', () => { describe('components/Toolbar', () => {
@ -42,7 +43,7 @@ describe('components/Toolbar', () => {
let onHangup: jest.Mock<() => void> let onHangup: jest.Mock<() => void>
let onGetDesktopStream: jest.MockedFunction<typeof getDesktopStream> let onGetDesktopStream: jest.MockedFunction<typeof getDesktopStream>
let onRemoveStream: jest.MockedFunction<typeof removeStream> let onRemoveStream: jest.MockedFunction<typeof removeStream>
let desktopStream: AddStreamPayload | undefined let desktopStream: StreamWithURL | undefined
async function render () { async function render () {
mediaStream = new MediaStream() mediaStream = new MediaStream()
onToggleChat = jest.fn() onToggleChat = jest.fn()
@ -51,6 +52,11 @@ describe('components/Toolbar', () => {
onGetDesktopStream = jest.fn() onGetDesktopStream = jest.fn()
onRemoveStream = jest.fn() onRemoveStream = jest.fn()
const div = document.createElement('div') const div = document.createElement('div')
const stream: StreamWithURL = {
stream: mediaStream,
type: STREAM_TYPE_CAMERA,
url,
}
await new Promise<ToolbarWrapper>(resolve => { await new Promise<ToolbarWrapper>(resolve => {
ReactDOM.render( ReactDOM.render(
<ToolbarWrapper <ToolbarWrapper
@ -60,7 +66,7 @@ describe('components/Toolbar', () => {
onToggleChat={onToggleChat} onToggleChat={onToggleChat}
onSendFile={onSendFile} onSendFile={onSendFile}
messagesCount={1} messagesCount={1}
stream={{ userId: '', stream: mediaStream, url }} stream={stream}
desktopStream={desktopStream} desktopStream={desktopStream}
onGetDesktopStream={onGetDesktopStream} onGetDesktopStream={onGetDesktopStream}
onRemoveStream={onRemoveStream} onRemoveStream={onRemoveStream}
@ -160,8 +166,8 @@ describe('components/Toolbar', () => {
}) })
it('stops desktop sharing', async () => { it('stops desktop sharing', async () => {
desktopStream = { desktopStream = {
userId: ME_DESKTOP,
stream: new MediaStream(), stream: new MediaStream(),
type: STREAM_TYPE_DESKTOP,
} }
await render() await render()
const shareDesktop = node.querySelector('.share-desktop')! const shareDesktop = node.querySelector('.share-desktop')!

View File

@ -1,9 +1,10 @@
import classnames from 'classnames' import classnames from 'classnames'
import React from 'react' import React from 'react'
import screenfull from 'screenfull' import screenfull from 'screenfull'
import { AddStreamPayload, removeStream } from '../actions/StreamActions' import { removeStream } from '../actions/StreamActions'
import { ME_DESKTOP } from '../constants'
import { getDesktopStream } from '../actions/MediaActions' import { getDesktopStream } from '../actions/MediaActions'
import { StreamWithURL } from '../reducers/streams'
import { ME } from '../constants'
const hidden = { const hidden = {
display: 'none', display: 'none',
@ -11,8 +12,8 @@ const hidden = {
export interface ToolbarProps { export interface ToolbarProps {
messagesCount: number messagesCount: number
stream: AddStreamPayload stream: StreamWithURL
desktopStream: AddStreamPayload | undefined desktopStream: StreamWithURL | undefined
onToggleChat: () => void onToggleChat: () => void
onGetDesktopStream: typeof getDesktopStream onGetDesktopStream: typeof getDesktopStream
onRemoveStream: typeof removeStream onRemoveStream: typeof removeStream
@ -61,7 +62,6 @@ function ToolbarButton(props: ToolbarButtonProps) {
export default class Toolbar export default class Toolbar
extends React.PureComponent<ToolbarProps, ToolbarState> { extends React.PureComponent<ToolbarProps, ToolbarState> {
file = React.createRef<HTMLInputElement>() file = React.createRef<HTMLInputElement>()
desktopStream: MediaStream | undefined
constructor(props: ToolbarProps) { constructor(props: ToolbarProps) {
super(props) super(props)
@ -121,7 +121,7 @@ extends React.PureComponent<ToolbarProps, ToolbarState> {
} }
handleToggleShareDesktop = () => { handleToggleShareDesktop = () => {
if (this.props.desktopStream) { if (this.props.desktopStream) {
this.props.onRemoveStream(ME_DESKTOP) this.props.onRemoveStream(ME, this.props.desktopStream.stream)
} else { } else {
this.props.onGetDesktopStream().catch(() => {}) this.props.onGetDesktopStream().catch(() => {})
} }
@ -162,7 +162,7 @@ extends React.PureComponent<ToolbarProps, ToolbarState> {
className='stream-desktop' className='stream-desktop'
icon='icon-display' icon='icon-display'
onClick={this.handleToggleShareDesktop} onClick={this.handleToggleShareDesktop}
on={!!this.desktopStream} on={!!this.props.desktopStream}
title='Share Desktop' title='Share Desktop'
/> />

View File

@ -2,14 +2,15 @@ jest.mock('../window')
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import { AddStreamPayload } from '../actions/StreamActions'
import Video, { VideoProps } from './Video' import Video, { VideoProps } from './Video'
import { MediaStream } from '../window' import { MediaStream } from '../window'
import { STREAM_TYPE_CAMERA } from '../constants'
import { StreamWithURL } from '../reducers/streams'
describe('components/Video', () => { describe('components/Video', () => {
interface VideoState { interface VideoState {
stream: null | AddStreamPayload stream: null | StreamWithURL
} }
const play = jest.fn() const play = jest.fn()
@ -61,12 +62,17 @@ describe('components/Video', () => {
mediaStream = new MediaStream() mediaStream = new MediaStream()
const div = document.createElement('div') const div = document.createElement('div')
component = await new Promise<VideoWrapper>(resolve => { component = await new Promise<VideoWrapper>(resolve => {
const stream: StreamWithURL = {
stream: mediaStream,
url,
type: STREAM_TYPE_CAMERA,
}
ReactDOM.render( ReactDOM.render(
<VideoWrapper <VideoWrapper
ref={instance => resolve(instance!)} ref={instance => resolve(instance!)}
videos={videos} videos={videos}
active={flags.active} active={flags.active}
stream={{ stream: mediaStream, url, userId: 'test' }} stream={stream}
onClick={onClick} onClick={onClick}
play={play} play={play}
userId="test" userId="test"
@ -100,22 +106,38 @@ describe('components/Video', () => {
it('updates src only when changed', () => { it('updates src only when changed', () => {
mediaStream = new MediaStream() mediaStream = new MediaStream()
component.setState({ component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' }, stream: {
url: 'test',
stream: mediaStream,
type: STREAM_TYPE_CAMERA,
},
}) })
expect(video.videoRef.current!.src).toBe('http://localhost/test') expect(video.videoRef.current!.src).toBe('http://localhost/test')
component.setState({ component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' }, stream: {
url: 'test',
stream: mediaStream,
type: STREAM_TYPE_CAMERA,
},
}) })
}) })
it('updates srcObject only when changed', () => { it('updates srcObject only when changed', () => {
video.videoRef.current!.srcObject = null video.videoRef.current!.srcObject = null
mediaStream = new MediaStream() mediaStream = new MediaStream()
component.setState({ component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' }, stream: {
url: 'test',
stream: mediaStream,
type: STREAM_TYPE_CAMERA,
},
}) })
expect(video.videoRef.current!.srcObject).toBe(mediaStream) expect(video.videoRef.current!.srcObject).toBe(mediaStream)
component.setState({ component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' }, stream: {
url: 'test',
stream: mediaStream,
type: STREAM_TYPE_CAMERA,
},
}) })
}) })
}) })

View File

@ -1,13 +1,13 @@
import React, { ReactEventHandler } from 'react' import React, { ReactEventHandler } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import socket from '../socket' import socket from '../socket'
import { AddStreamPayload } from '../actions/StreamActions' import { StreamWithURL } from '../reducers/streams'
export interface VideoProps { export interface VideoProps {
videos: Record<string, unknown> videos: Record<string, unknown>
onClick: (userId: string) => void onClick: (userId: string) => void
active: boolean active: boolean
stream?: AddStreamPayload stream?: StreamWithURL
userId: string userId: string
muted: boolean muted: boolean
mirrored: boolean mirrored: boolean

View File

@ -8,7 +8,6 @@ export const ALERT_CLEAR = 'ALERT_CLEAR'
export const INIT = 'INIT' export const INIT = 'INIT'
export const ME = '_me_' export const ME = '_me_'
export const ME_DESKTOP = '_me_desktop'
export const NOTIFY = 'NOTIFY' export const NOTIFY = 'NOTIFY'
export const NOTIFY_DISMISS = 'NOTIFY_DISMISS' export const NOTIFY_DISMISS = 'NOTIFY_DISMISS'
@ -30,7 +29,7 @@ export const PEER_EVENT_ERROR = 'error'
export const PEER_EVENT_CONNECT = 'connect' export const PEER_EVENT_CONNECT = 'connect'
export const PEER_EVENT_CLOSE = 'close' export const PEER_EVENT_CLOSE = 'close'
export const PEER_EVENT_SIGNAL = 'signal' export const PEER_EVENT_SIGNAL = 'signal'
export const PEER_EVENT_STREAM = 'stream' export const PEER_EVENT_TRACK = 'track'
export const PEER_EVENT_DATA = 'data' export const PEER_EVENT_DATA = 'data'
export const SOCKET_EVENT_SIGNAL = 'signal' export const SOCKET_EVENT_SIGNAL = 'signal'
@ -38,3 +37,6 @@ export const SOCKET_EVENT_USERS = 'users'
export const STREAM_ADD = 'PEER_STREAM_ADD' export const STREAM_ADD = 'PEER_STREAM_ADD'
export const STREAM_REMOVE = 'PEER_STREAM_REMOVE' export const STREAM_REMOVE = 'PEER_STREAM_REMOVE'
export const STREAM_TYPE_CAMERA = 'camera'
export const STREAM_TYPE_DESKTOP = 'desktop'

View File

@ -79,13 +79,19 @@ describe('App', () => {
state.streams = { state.streams = {
[constants.ME]: { [constants.ME]: {
userId: constants.ME, userId: constants.ME,
streams: [{
stream: new MediaStream(), stream: new MediaStream(),
type: constants.STREAM_TYPE_CAMERA,
url: 'blob://', url: 'blob://',
}],
}, },
'other-user': { 'other-user': {
userId: 'other-user', userId: 'other-user',
streams: [{
stream: new MediaStream(), stream: new MediaStream(),
type: undefined,
url: 'blob://', url: 'blob://',
}],
}, },
} }
state.peers = { state.peers = {

View File

@ -1,5 +1,5 @@
import * as MediaActions from '../actions/MediaActions' import * as MediaActions from '../actions/MediaActions'
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM, ME, PEERS_DESTROY, PEER_ADD, ME_DESKTOP } from '../constants' import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM, ME, PEERS_DESTROY, PEER_ADD, STREAM_TYPE_CAMERA, STREAM_TYPE_DESKTOP } from '../constants'
import { createStore, Store } from '../store' import { createStore, Store } from '../store'
import SimplePeer from 'simple-peer' import SimplePeer from 'simple-peer'
@ -102,6 +102,7 @@ describe('media', () => {
video: true, video: true,
})) }))
expect(result.stream).toBe(stream) expect(result.stream).toBe(stream)
expect(result.type).toBe(STREAM_TYPE_CAMERA)
expect(result.userId).toBe(ME) expect(result.userId).toBe(ME)
} }
@ -109,8 +110,11 @@ describe('media', () => {
it('adds the local stream to the map of videos', async () => { it('adds the local stream to the map of videos', async () => {
expect(store.getState().streams[ME]).toBeFalsy() expect(store.getState().streams[ME]).toBeFalsy()
await dispatch() await dispatch()
expect(store.getState().streams[ME]).toBeTruthy() const localStreams = store.getState().streams[ME]
expect(store.getState().streams[ME].stream).toBe(stream) expect(localStreams).toBeTruthy()
expect(localStreams.streams.length).toBe(1)
expect(localStreams.streams[0].type).toBe(STREAM_TYPE_CAMERA)
expect(localStreams.streams[0].stream).toBe(stream)
}) })
}) })
@ -200,13 +204,17 @@ describe('media', () => {
async function dispatch() { async function dispatch() {
const result = await store.dispatch(MediaActions.getDesktopStream()) const result = await store.dispatch(MediaActions.getDesktopStream())
expect(result.stream).toBe(stream) expect(result.stream).toBe(stream)
expect(result.userId).toBe(ME_DESKTOP) expect(result.type).toBe(STREAM_TYPE_DESKTOP)
expect(result.userId).toBe(ME)
} }
it('adds the local stream to the map of videos', async () => { it('adds the local stream to the map of videos', async () => {
expect(store.getState().streams[ME_DESKTOP]).toBeFalsy() expect(store.getState().streams[ME]).toBeFalsy()
await dispatch() await dispatch()
expect(store.getState().streams[ME_DESKTOP]).toBeTruthy() const localStreams = store.getState().streams[ME]
expect(store.getState().streams[ME_DESKTOP].stream).toBe(stream) expect(localStreams).toBeTruthy()
expect(localStreams.streams.length).toBe(1)
expect(localStreams.streams[0].type).toBe(STREAM_TYPE_DESKTOP)
expect(localStreams.streams[0].stream).toBe(stream)
}) })
}) })

View File

@ -12,7 +12,7 @@ export default function notifications (
action: AnyAction, action: AnyAction,
) { ) {
if (isRejectedAction(action)) { if (isRejectedAction(action)) {
action = error(action.payload) action = error('' + action.payload)
} }
return handleNotifications(state, action) return handleNotifications(state, action)
} }

View File

@ -29,11 +29,15 @@ export default function peers(
return defaultState return defaultState
case constants.MEDIA_STREAM: case constants.MEDIA_STREAM:
if (action.status === 'resolved') { if (action.status === 'resolved') {
// userId can be ME or ME_DESKTOP
forEach(state, peer => { forEach(state, peer => {
const localStream = localStreams[action.payload.userId] const localStream = localStreams[action.payload.userId]
localStream && peer.removeStream(localStream) localStream && localStream.getTracks().forEach(track => {
peer.addStream(action.payload.stream) peer.removeTrack(track, localStream)
})
const stream = action.payload.stream
stream.getTracks().forEach(track => {
peer.addTrack(track, stream)
})
}) })
localStreams[action.payload.userId] = action.payload.stream localStreams[action.payload.userId] = action.payload.stream
} }

View File

@ -1,9 +1,9 @@
import _debug from 'debug' import _debug from 'debug'
import omit from 'lodash/omit' import omit from 'lodash/omit'
import { AddStreamAction, AddStreamPayload, RemoveStreamAction, StreamAction } from '../actions/StreamActions' import { AddStreamAction, RemoveStreamAction, StreamAction, StreamType } from '../actions/StreamActions'
import { STREAM_ADD, STREAM_REMOVE, MEDIA_STREAM } from '../constants' import { STREAM_ADD, STREAM_REMOVE, MEDIA_STREAM } from '../constants'
import { createObjectURL, revokeObjectURL } from '../window' import { createObjectURL, revokeObjectURL } from '../window'
import { MediaStreamPayload, MediaStreamAction } from '../actions/MediaActions' import { MediaStreamAction } from '../actions/MediaActions'
const debug = _debug('peercalls') const debug = _debug('peercalls')
const defaultState = Object.freeze({}) const defaultState = Object.freeze({})
@ -17,8 +17,19 @@ function safeCreateObjectURL (stream: MediaStream) {
} }
} }
export interface StreamWithURL {
stream: MediaStream
type: StreamType | undefined
url?: string
}
export interface UserStreams {
userId: string
streams: StreamWithURL[]
}
export interface StreamsState { export interface StreamsState {
[userId: string]: AddStreamPayload [userId: string]: UserStreams
} }
function addStream ( function addStream (
@ -26,40 +37,66 @@ function addStream (
): StreamsState { ): StreamsState {
const { userId, stream } = payload const { userId, stream } = payload
const userStream: AddStreamPayload = { const userStreams = state[userId] || {
userId, userId,
streams: [],
}
if (userStreams.streams.map(s => s.stream).indexOf(stream) >= 0) {
return state
}
const streamWithURL: StreamWithURL = {
stream, stream,
type: payload.type,
url: safeCreateObjectURL(stream), url: safeCreateObjectURL(stream),
} }
return { return {
...state, ...state,
[userId]: userStream, [userId]: {
userId,
streams: [...userStreams.streams, streamWithURL],
},
} }
} }
function removeStream ( function removeStream (
state: StreamsState, payload: RemoveStreamAction['payload'], state: StreamsState, payload: RemoveStreamAction['payload'],
): StreamsState { ): StreamsState {
const { userId } = payload const { userId, stream } = payload
const stream = state[userId] const userStreams = state[userId]
if (stream && stream.stream) { if (!userStreams) {
stream.stream.getTracks().forEach(track => track.stop()) return state
}
if (stream && stream.url) {
revokeObjectURL(stream.url)
}
return omit(state, [userId])
} }
function replaceStream( if (stream) {
state: StreamsState, const streams = userStreams.streams.filter(s => {
payload: MediaStreamPayload, const found = s.stream === stream
): StreamsState { if (found) {
state = removeStream(state, { stream.getTracks().forEach(track => track.stop())
userId: payload.userId, s.url && revokeObjectURL(s.url)
}
return !found
}) })
return addStream(state, payload) if (userStreams.streams.length > 0) {
return {
...state,
[userId]: {
userId,
streams,
},
}
} else {
omit(state, [userId])
}
}
userStreams && userStreams.streams.forEach(s => {
s.stream.getTracks().forEach(track => track.stop())
s.url && revokeObjectURL(s.url)
})
return omit(state, [userId])
} }
export default function streams( export default function streams(
@ -73,7 +110,7 @@ export default function streams(
return removeStream(state, action.payload) return removeStream(state, action.payload)
case MEDIA_STREAM: case MEDIA_STREAM:
if (action.status === 'resolved') { if (action.status === 'resolved') {
return replaceStream(state, action.payload) return addStream(state, action.payload)
} else { } else {
return state return state
} }