264 lines
6.9 KiB
TypeScript
264 lines
6.9 KiB
TypeScript
import * as ChatActions from '../actions/ChatActions'
|
|
import * as NotifyActions from '../actions/NotifyActions'
|
|
import * as StreamActions from '../actions/StreamActions'
|
|
import * as constants from '../constants'
|
|
import Peer from 'simple-peer'
|
|
import forEach from 'lodash/forEach'
|
|
import _debug from 'debug'
|
|
import { play, iceServers } from '../window'
|
|
import { Dispatch, GetState } from '../store'
|
|
import { ClientSocket } from '../socket'
|
|
|
|
const debug = _debug('peercalls')
|
|
|
|
export interface Peers {
|
|
[id: string]: Peer.Instance
|
|
}
|
|
|
|
export interface PeerHandlerOptions {
|
|
socket: ClientSocket
|
|
user: { id: string }
|
|
dispatch: Dispatch
|
|
getState: GetState
|
|
}
|
|
|
|
class PeerHandler {
|
|
socket: ClientSocket
|
|
user: { id: string }
|
|
dispatch: Dispatch
|
|
getState: GetState
|
|
|
|
constructor (readonly options: PeerHandlerOptions) {
|
|
this.socket = options.socket
|
|
this.user = options.user
|
|
this.dispatch = options.dispatch
|
|
this.getState = options.getState
|
|
}
|
|
handleError = (err: Error) => {
|
|
const { dispatch, getState, user } = this
|
|
debug('peer: %s, error %s', user.id, err.stack)
|
|
dispatch(NotifyActions.error('A peer connection error occurred'))
|
|
const peer = getState().peers[user.id]
|
|
peer && peer.destroy()
|
|
dispatch(removePeer(user.id))
|
|
}
|
|
handleSignal = (signal: unknown) => {
|
|
const { socket, user } = this
|
|
debug('peer: %s, signal: %o', user.id, signal)
|
|
|
|
const payload = { userId: user.id, signal }
|
|
socket.emit('signal', payload)
|
|
}
|
|
handleConnect = () => {
|
|
const { dispatch, user } = this
|
|
debug('peer: %s, connect', user.id)
|
|
dispatch(NotifyActions.warning('Peer connection established'))
|
|
play()
|
|
}
|
|
handleStream = (stream: MediaStream) => {
|
|
const { user, dispatch } = this
|
|
debug('peer: %s, stream', user.id)
|
|
dispatch(StreamActions.addStream({
|
|
userId: user.id,
|
|
stream,
|
|
}))
|
|
}
|
|
handleData = (buffer: ArrayBuffer) => {
|
|
const { dispatch, user } = this
|
|
const message = JSON.parse(new window.TextDecoder('utf-8').decode(buffer))
|
|
debug('peer: %s, message: %o', user.id, buffer)
|
|
switch (message.type) {
|
|
case 'file':
|
|
dispatch(ChatActions.addMessage({
|
|
userId: user.id,
|
|
message: message.payload.name,
|
|
timestamp: new Date().toLocaleString(),
|
|
image: message.payload.data,
|
|
}))
|
|
break
|
|
default:
|
|
dispatch(ChatActions.addMessage({
|
|
userId: user.id,
|
|
message: message.payload,
|
|
timestamp: new Date().toLocaleString(),
|
|
image: undefined,
|
|
}))
|
|
}
|
|
}
|
|
handleClose = () => {
|
|
const { dispatch, user } = this
|
|
debug('peer: %s, close', user.id)
|
|
dispatch(NotifyActions.error('Peer connection closed'))
|
|
dispatch(StreamActions.removeStream(user.id))
|
|
dispatch(removePeer(user.id))
|
|
}
|
|
}
|
|
|
|
export interface CreatePeerOptions {
|
|
socket: ClientSocket
|
|
user: { id: string }
|
|
initiator: string
|
|
stream?: MediaStream
|
|
}
|
|
|
|
/**
|
|
* @param {Object} options
|
|
* @param {Socket} options.socket
|
|
* @param {User} options.user
|
|
* @param {String} options.user.id
|
|
* @param {Boolean} [options.initiator=false]
|
|
* @param {MediaStream} [options.stream]
|
|
*/
|
|
export function createPeer (options: CreatePeerOptions) {
|
|
const { socket, user, initiator, stream } = options
|
|
|
|
return (dispatch: Dispatch, getState: GetState) => {
|
|
const userId = user.id
|
|
debug('create peer: %s, stream:', userId, stream)
|
|
dispatch(NotifyActions.warning('Connecting to peer...'))
|
|
|
|
const oldPeer = getState().peers[userId]
|
|
if (oldPeer) {
|
|
dispatch(NotifyActions.info('Cleaning up old connection...'))
|
|
oldPeer.destroy()
|
|
dispatch(removePeer(userId))
|
|
}
|
|
|
|
const peer = new Peer({
|
|
initiator: socket.id === initiator,
|
|
config: { iceServers },
|
|
// Allow the peer to receive video, even if it's not sending stream:
|
|
// https://github.com/feross/simple-peer/issues/95
|
|
offerConstraints: {
|
|
offerToReceiveAudio: true,
|
|
offerToReceiveVideo: true,
|
|
},
|
|
stream,
|
|
})
|
|
|
|
const handler = new PeerHandler({
|
|
socket,
|
|
user,
|
|
dispatch,
|
|
getState,
|
|
})
|
|
|
|
peer.once(constants.PEER_EVENT_ERROR, handler.handleError)
|
|
peer.once(constants.PEER_EVENT_CONNECT, handler.handleConnect)
|
|
peer.once(constants.PEER_EVENT_CLOSE, handler.handleClose)
|
|
peer.on(constants.PEER_EVENT_SIGNAL, handler.handleSignal)
|
|
peer.on(constants.PEER_EVENT_STREAM, handler.handleStream)
|
|
peer.on(constants.PEER_EVENT_DATA, handler.handleData)
|
|
|
|
dispatch(addPeer({ peer, userId }))
|
|
}
|
|
}
|
|
|
|
export interface AddPeerParams {
|
|
peer: Peer.Instance
|
|
userId: string
|
|
}
|
|
|
|
export interface AddPeerAction {
|
|
type: 'PEER_ADD'
|
|
payload: AddPeerParams
|
|
}
|
|
|
|
export const addPeer = (payload: AddPeerParams): AddPeerAction => ({
|
|
type: constants.PEER_ADD,
|
|
payload,
|
|
})
|
|
|
|
export interface RemovePeerAction {
|
|
type: 'PEER_REMOVE'
|
|
payload: { userId: string }
|
|
}
|
|
|
|
export const removePeer = (userId: string): RemovePeerAction => ({
|
|
type: constants.PEER_REMOVE,
|
|
payload: { userId },
|
|
})
|
|
|
|
export interface DestroyPeersAction {
|
|
type: 'PEERS_DESTROY'
|
|
}
|
|
|
|
export const destroyPeers = (): DestroyPeersAction => ({
|
|
type: constants.PEERS_DESTROY,
|
|
})
|
|
|
|
export type PeerAction =
|
|
AddPeerAction |
|
|
RemovePeerAction |
|
|
DestroyPeersAction
|
|
|
|
export interface TextMessage {
|
|
type: 'text'
|
|
payload: string
|
|
}
|
|
|
|
export interface Base64File {
|
|
name: string
|
|
size: number
|
|
type: string
|
|
data: string
|
|
}
|
|
|
|
export interface FileMessage {
|
|
type: 'file'
|
|
payload: Base64File
|
|
}
|
|
|
|
export type Message = TextMessage | FileMessage
|
|
|
|
export const sendMessage = (message: Message) =>
|
|
(dispatch: Dispatch, getState: GetState) => {
|
|
const { peers } = getState()
|
|
debug('Sending message type: %s to %s peers.',
|
|
message.type, Object.keys(peers).length)
|
|
forEach(peers, (peer, userId) => {
|
|
switch (message.type) {
|
|
case 'file':
|
|
dispatch(ChatActions.addMessage({
|
|
userId: 'You',
|
|
message: 'Send file: "' +
|
|
message.payload.name + '" to peer: ' + userId,
|
|
timestamp: new Date().toLocaleString(),
|
|
image: message.payload.data,
|
|
}))
|
|
break
|
|
default:
|
|
dispatch(ChatActions.addMessage({
|
|
userId: 'You',
|
|
message: message.payload,
|
|
timestamp: new Date().toLocaleString(),
|
|
image: undefined,
|
|
}))
|
|
}
|
|
peer.send(JSON.stringify(message))
|
|
})
|
|
}
|
|
|
|
export const sendFile = (file: File) =>
|
|
async (dispatch: Dispatch, getState: GetState) => {
|
|
const { name, size, type } = file
|
|
if (!window.FileReader) {
|
|
dispatch(NotifyActions.error('File API is not supported by your browser'))
|
|
return
|
|
}
|
|
const reader = new window.FileReader()
|
|
const base64File = await new Promise<Base64File>(resolve => {
|
|
reader.addEventListener('load', () => {
|
|
resolve({
|
|
name,
|
|
size,
|
|
type,
|
|
data: reader.result as string,
|
|
})
|
|
})
|
|
reader.readAsDataURL(file)
|
|
})
|
|
|
|
sendMessage({ payload: base64File, type: 'file' })(dispatch, getState)
|
|
}
|