Add experimental support for sharing desktop
This commit is contained in:
parent
ee209d7889
commit
61fc53bcf9
@ -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()
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user