Add experimental support for sharing desktop

This commit is contained in:
Jerko Steiner 2020-03-09 11:58:28 +01:00
parent ee209d7889
commit 61fc53bcf9
12 changed files with 157 additions and 34 deletions

View File

@ -32,6 +32,9 @@ window.navigator.mediaDevices.enumerateDevices = async () => {
window.navigator.mediaDevices.getUserMedia = async () => {
return {} as any
}
(window.navigator.mediaDevices as any).getDisplayMedia = async () => {
return {} as any
}
export const play = jest.fn()

View File

@ -1,5 +1,5 @@
import { makeAction, AsyncAction } from '../async'
import { MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_ENUMERATE, MEDIA_STREAM } from '../constants'
import { MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_ENUMERATE, MEDIA_STREAM, ME, ME_DESKTOP } from '../constants'
import _debug from 'debug'
const debug = _debug('peercalls')
@ -66,6 +66,11 @@ async function getUserMedia(
})
}
async function getDisplayMedia(): Promise<MediaStream> {
const mediaDevices = navigator.mediaDevices as any // eslint-disable-line
return mediaDevices.getDisplayMedia({video: true, audio: false})
}
export interface MediaVideoConstraintAction {
type: 'MEDIA_VIDEO_CONSTRAINT_SET'
payload: VideoConstraint
@ -106,12 +111,33 @@ export const getMediaStream = makeAction(
MEDIA_STREAM,
async (constraints: GetMediaConstraints) => {
debug('getMediaStream', constraints)
return getUserMedia(constraints)
const payload: MediaStreamPayload = {
stream: await getUserMedia(constraints),
userId: ME,
}
return payload
},
)
export const getDesktopStream = makeAction(
MEDIA_STREAM,
async () => {
debug('getDesktopStream')
const payload: MediaStreamPayload = {
stream: await getDisplayMedia(),
userId: ME_DESKTOP,
}
return payload
},
)
export interface MediaStreamPayload {
stream: MediaStream
userId: string
}
export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]>
export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStream>
export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStreamPayload>
export type MediaPlayAction = AsyncAction<'MEDIA_PLAY', void>
export type MediaAction =

View File

@ -63,6 +63,10 @@ class PeerHandler {
// we no longer automatically send the stream to the peer.
peer.addStream(localStream.stream)
}
const desktopStream = state.streams[constants.ME_DESKTOP]
if (desktopStream && desktopStream.stream) {
peer.addStream(desktopStream.stream)
}
}
handleStream = (stream: MediaStream) => {
const { user, dispatch } = this

View File

@ -13,6 +13,7 @@ import Notifications from './Notifications'
import { Side } from './Side'
import Toolbar from './Toolbar'
import Video from './Video'
import { getDesktopStream } from '../actions/MediaActions'
export interface AppProps {
active: string | null
@ -25,6 +26,7 @@ export interface AppProps {
play: () => void
sendMessage: (message: TextMessage) => void
streams: Record<string, AddStreamPayload>
getDesktopStream: typeof getDesktopStream
removeStream: typeof removeStream
onSendFile: (file: File) => void
toggleActive: (userId: string) => void
@ -35,6 +37,8 @@ export interface AppState {
chatVisible: boolean
}
const localStreams: string[] = [constants.ME, constants.ME_DESKTOP]
export default class App extends React.PureComponent<AppProps, AppState> {
state: AppState = {
videos: {},
@ -61,6 +65,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
}
onHangup = () => {
this.props.removeStream(constants.ME)
this.props.removeStream(constants.ME_DESKTOP)
}
render () {
const {
@ -93,6 +98,9 @@ export default class App extends React.PureComponent<AppProps, AppState> {
onSendFile={onSendFile}
onHangup={this.onHangup}
stream={streams[constants.ME]}
desktopStream={streams[constants.ME_DESKTOP]}
onGetDesktopStream={this.props.getDesktopStream}
onRemoveStream={this.props.removeStream}
/>
</Side>
<Side className={chatVisibleClassName} top zIndex={1}>
@ -109,19 +117,19 @@ export default class App extends React.PureComponent<AppProps, AppState> {
visible={this.state.chatVisible}
/>
<div className={classnames('videos', chatVisibleClassName)}>
{streams[constants.ME] && (
{localStreams.map(userId => (
<Video
videos={videos}
active={active === constants.ME}
key={userId}
active={active === userId}
onClick={toggleActive}
play={play}
stream={streams[constants.ME]}
userId={constants.ME}
stream={streams[userId]}
userId={userId}
muted
mirrored
/>
)}
))}
{
map(peers, (_, userId) => userId)
.filter(stream => !!stream)

View File

@ -4,7 +4,9 @@ import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
import Toolbar, { ToolbarProps } from './Toolbar'
import { MediaStream } from '../window'
import { AddStreamPayload } from '../actions/StreamActions'
import { AddStreamPayload, removeStream } from '../actions/StreamActions'
import { ME_DESKTOP } from '../constants'
import { getDesktopStream } from '../actions/MediaActions'
describe('components/Toolbar', () => {
@ -15,15 +17,19 @@ describe('components/Toolbar', () => {
class ToolbarWrapper extends React.PureComponent<ToolbarProps, StreamState> {
state = {
stream: null,
desktopStream: null,
}
render () {
return <Toolbar
chatVisible={this.props.chatVisible}
onToggleChat={this.props.onToggleChat}
onHangup={this.props.onHangup}
onGetDesktopStream={this.props.onGetDesktopStream}
onRemoveStream={this.props.onRemoveStream}
onSendFile={this.props.onSendFile}
messagesCount={this.props.messagesCount}
stream={this.state.stream || this.props.stream}
desktopStream={this.state.desktopStream || this.props.desktopStream}
/>
}
}
@ -34,11 +40,16 @@ describe('components/Toolbar', () => {
let onToggleChat: jest.Mock<() => void>
let onSendFile: jest.Mock<(file: File) => void>
let onHangup: jest.Mock<() => void>
let onGetDesktopStream: jest.MockedFunction<typeof getDesktopStream>
let onRemoveStream: jest.MockedFunction<typeof removeStream>
let desktopStream: AddStreamPayload | undefined
async function render () {
mediaStream = new MediaStream()
onToggleChat = jest.fn()
onSendFile = jest.fn()
onHangup = jest.fn()
onGetDesktopStream = jest.fn()
onRemoveStream = jest.fn()
const div = document.createElement('div')
await new Promise<ToolbarWrapper>(resolve => {
ReactDOM.render(
@ -50,6 +61,9 @@ describe('components/Toolbar', () => {
onSendFile={onSendFile}
messagesCount={1}
stream={{ userId: '', stream: mediaStream, url }}
desktopStream={desktopStream}
onGetDesktopStream={onGetDesktopStream}
onRemoveStream={onRemoveStream}
/>,
div,
)
@ -136,4 +150,26 @@ describe('components/Toolbar', () => {
})
})
describe('desktop sharing', () => {
it('starts desktop sharing', async () => {
const shareDesktop = node.querySelector('.stream-desktop')!
expect(shareDesktop).toBeDefined()
TestUtils.Simulate.click(shareDesktop)
await Promise.resolve()
expect(onGetDesktopStream.mock.calls.length).toBe(1)
})
it('stops desktop sharing', async () => {
desktopStream = {
userId: ME_DESKTOP,
stream: new MediaStream(),
}
await render()
const shareDesktop = node.querySelector('.share-desktop')!
expect(shareDesktop).toBeDefined()
TestUtils.Simulate.click(shareDesktop)
await Promise.resolve()
expect(onRemoveStream.mock.calls.length).toBe(1)
})
})
})

View File

@ -1,7 +1,9 @@
import classnames from 'classnames'
import React from 'react'
import screenfull from 'screenfull'
import { AddStreamPayload } from '../actions/StreamActions'
import { AddStreamPayload, removeStream } from '../actions/StreamActions'
import { ME_DESKTOP } from '../constants'
import { getDesktopStream } from '../actions/MediaActions'
const hidden = {
display: 'none',
@ -10,7 +12,10 @@ const hidden = {
export interface ToolbarProps {
messagesCount: number
stream: AddStreamPayload
desktopStream: AddStreamPayload | undefined
onToggleChat: () => void
onGetDesktopStream: typeof getDesktopStream
onRemoveStream: typeof removeStream
onSendFile: (file: File) => void
onHangup: () => void
chatVisible: boolean
@ -56,6 +61,7 @@ function ToolbarButton(props: ToolbarButtonProps) {
export default class Toolbar
extends React.PureComponent<ToolbarProps, ToolbarState> {
file = React.createRef<HTMLInputElement>()
desktopStream: MediaStream | undefined
constructor(props: ToolbarProps) {
super(props)
@ -113,6 +119,13 @@ extends React.PureComponent<ToolbarProps, ToolbarState> {
})
this.props.onToggleChat()
}
handleToggleShareDesktop = () => {
if (this.props.desktopStream) {
this.props.onRemoveStream(ME_DESKTOP)
} else {
this.props.onGetDesktopStream()
}
}
render () {
const { messagesCount, stream } = this.props
const unreadCount = messagesCount - this.state.readMessages
@ -145,6 +158,14 @@ extends React.PureComponent<ToolbarProps, ToolbarState> {
title='Send File'
/>
<ToolbarButton
className='stream-desktop'
icon='icon-display'
onClick={this.handleToggleShareDesktop}
on={!!this.desktopStream}
title='Share Desktop'
/>
{stream && (
<React.Fragment>
<ToolbarButton

View File

@ -8,6 +8,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR'
export const INIT = 'INIT'
export const ME = '_me_'
export const ME_DESKTOP = '_me_desktop'
export const NOTIFY = 'NOTIFY'
export const NOTIFY_DISMISS = 'NOTIFY_DISMISS'

View File

@ -1,6 +1,6 @@
import { connect } from 'react-redux'
import { init } from '../actions/CallActions'
import { play } from '../actions/MediaActions'
import { getDesktopStream, play } from '../actions/MediaActions'
import { dismissNotification } from '../actions/NotifyActions'
import { sendFile, sendMessage } from '../actions/PeerActions'
import { toggleActive, removeStream } from '../actions/StreamActions'
@ -22,6 +22,7 @@ const mapDispatchToProps = {
toggleActive,
sendMessage,
dismissNotification,
getDesktopStream,
removeStream,
init,
onSendFile: sendFile,

View File

@ -1,5 +1,5 @@
import * as MediaActions from '../actions/MediaActions'
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM, ME, PEERS_DESTROY, PEER_ADD } from '../constants'
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM, ME, PEERS_DESTROY, PEER_ADD, ME_DESKTOP } from '../constants'
import { createStore, Store } from '../store'
import SimplePeer from 'simple-peer'
@ -97,11 +97,12 @@ describe('media', () => {
})
async function dispatch() {
const promise = store.dispatch(MediaActions.getMediaStream({
const result = await store.dispatch(MediaActions.getMediaStream({
audio: true,
video: true,
}))
expect(await promise).toBe(stream)
expect(result.stream).toBe(stream)
expect(result.userId).toBe(ME)
}
describe('reducers/streams', () => {
@ -148,7 +149,7 @@ describe('media', () => {
})
})
it('replaces local stream on all peers', async () => {
it('replaces local camera stream on all peers', async () => {
await dispatch()
peers.forEach(peer => {
expect((peer.addStream as jest.Mock).mock.calls)
@ -183,10 +184,30 @@ describe('media', () => {
})
expect(promise.type).toBe('MEDIA_STREAM')
expect(promise.status).toBe('pending')
expect(await promise).toBe(stream)
const result = await promise
expect(result.stream).toBe(stream)
expect(result.userId).toBe(ME)
})
})
})
})
describe('getDesktopStream (getDisplayMedia)', () => {
const stream: MediaStream = {} as MediaStream
beforeEach(() => {
(navigator.mediaDevices as any).getDisplayMedia = async () => stream
})
async function dispatch() {
const result = await store.dispatch(MediaActions.getDesktopStream())
expect(result.stream).toBe(stream)
expect(result.userId).toBe(ME_DESKTOP)
}
it('adds the local stream to the map of videos', async () => {
expect(store.getState().streams[ME_DESKTOP]).toBeFalsy()
await dispatch()
expect(store.getState().streams[ME_DESKTOP]).toBeTruthy()
expect(store.getState().streams[ME_DESKTOP].stream).toBe(stream)
})
})
})

View File

@ -9,7 +9,7 @@ export type PeersState = Record<string, Peer.Instance>
const defaultState: PeersState = {}
let localStream: MediaStream | undefined
let localStreams: Record<string, MediaStream> = {}
export default function peers(
state = defaultState,
@ -24,16 +24,18 @@ export default function peers(
case constants.PEER_REMOVE:
return omit(state, [action.payload.userId])
case constants.PEERS_DESTROY:
localStream = undefined
localStreams = {}
forEach(state, peer => peer.destroy())
return defaultState
case constants.MEDIA_STREAM:
if (action.status === 'resolved') {
// userId can be ME or ME_DESKTOP
forEach(state, peer => {
const localStream = localStreams[action.payload.userId]
localStream && peer.removeStream(localStream)
peer.addStream(action.payload)
peer.addStream(action.payload.stream)
})
localStream = action.payload
localStreams[action.payload.userId] = action.payload.stream
}
return state
default:

View File

@ -1,9 +1,9 @@
import _debug from 'debug'
import omit from 'lodash/omit'
import { AddStreamAction, AddStreamPayload, RemoveStreamAction, StreamAction } from '../actions/StreamActions'
import { STREAM_ADD, STREAM_REMOVE, MEDIA_STREAM, ME } from '../constants'
import { STREAM_ADD, STREAM_REMOVE, MEDIA_STREAM } from '../constants'
import { createObjectURL, revokeObjectURL } from '../window'
import { MediaStreamAction } from '../actions/MediaActions'
import { MediaStreamPayload, MediaStreamAction } from '../actions/MediaActions'
const debug = _debug('peercalls')
const defaultState = Object.freeze({})
@ -52,14 +52,14 @@ function removeStream (
return omit(state, [userId])
}
function replaceStream(state: StreamsState, stream: MediaStream): StreamsState {
function replaceStream(
state: StreamsState,
payload: MediaStreamPayload,
): StreamsState {
state = removeStream(state, {
userId: ME,
})
return addStream(state, {
userId: ME,
stream,
userId: payload.userId,
})
return addStream(state, payload)
}
export default function streams(

View File

@ -1,10 +1,10 @@
@font-face {
font-family: 'icons';
src: url('../res/fonts/icons.eot?tcgv6b');
src: url('../res/fonts/icons.eot?tcgv6b#iefix') format('embedded-opentype'),
url('../res/fonts/icons.woff?tcgv6b') format('woff'),
url('../res/fonts/icons.ttf?tcgv6b') format('truetype'),
url('../res/fonts/icons.svg?tcgv6b#icons') format('svg');
src: url('../res/fonts/icons.eot?ny6drs');
src: url('../res/fonts/icons.eot?ny6drs#iefix') format('embedded-opentype'),
url('../res/fonts/icons.ttf?ny6drs') format('truetype'),
url('../res/fonts/icons.woff?ny6drs') format('woff'),
url('../res/fonts/icons.svg?ny6drs#icons') format('svg');
font-weight: normal;
font-style: normal;
}