diff options
| author | Bob Zeleznik <zzzman@gmail.com> | 2020-02-09 17:02:47 -0500 |
|---|---|---|
| committer | Bob Zeleznik <zzzman@gmail.com> | 2020-02-09 17:02:47 -0500 |
| commit | 5274e784fbd8107abf0f20e16afcf4e7032e9117 (patch) | |
| tree | 3e727c69c98d2f1c7e64db5d890e1203d02cd940 /src/client | |
| parent | 96b2f5f5334fb475180a095905e19e45a0414233 (diff) | |
| parent | 4bb5910e0a853d225c3304aa7a958c2f9e9108c7 (diff) | |
Merge branch 'master' of https://github.com/browngraphicslab/Dash-Web
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/DocServer.ts | 2 | ||||
| -rw-r--r-- | src/client/documents/DocumentTypes.ts | 1 | ||||
| -rw-r--r-- | src/client/documents/Documents.ts | 8 | ||||
| -rw-r--r-- | src/client/views/DocumentDecorations.tsx | 14 | ||||
| -rw-r--r-- | src/client/views/MainView.tsx | 3 | ||||
| -rw-r--r-- | src/client/views/nodes/DocumentContentsView.tsx | 4 | ||||
| -rw-r--r-- | src/client/views/webcam/DashWebRTCVideo.scss | 46 | ||||
| -rw-r--r-- | src/client/views/webcam/DashWebRTCVideo.tsx | 94 | ||||
| -rw-r--r-- | src/client/views/webcam/WebCamLogic.js | 275 |
9 files changed, 444 insertions, 3 deletions
diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts index d793b56af..7fbf30af8 100644 --- a/src/client/DocServer.ts +++ b/src/client/DocServer.ts @@ -21,7 +21,7 @@ import { Id, HandleUpdate } from '../new_fields/FieldSymbols'; */ export namespace DocServer { let _cache: { [id: string]: RefField | Promise<Opt<RefField>> } = {}; - let _socket: SocketIOClient.Socket; + export let _socket: SocketIOClient.Socket; // this client's distinct GUID created at initialization let GUID: string; // indicates whether or not a document is currently being udpated, and, if so, its id diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index 6ef23ef08..a3025be75 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -18,6 +18,7 @@ export enum DocumentType { TEMPLATE = "template", EXTENSION = "extension", YOUTUBE = "youtube", + WEBCAM = "webcam", FONTICON = "fonticonbox", PRES = "presentation", LINKFOLLOW = "linkfollow", diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 3d43c6eb2..82112836b 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -46,6 +46,7 @@ import { ProxyField } from "../../new_fields/Proxy"; import { DocumentType } from "./DocumentTypes"; import { LinkFollowBox } from "../views/linking/LinkFollowBox"; import { PresElementBox } from "../views/presentationview/PresElementBox"; +import { DashWebRTCVideo } from "../views/webcam/DashWebRTCVideo"; import { QueryBox } from "../views/nodes/QueryBox"; import { ColorBox } from "../views/nodes/ColorBox"; import { DocuLinkBox } from "../views/nodes/DocuLinkBox"; @@ -245,6 +246,9 @@ export namespace Docs { [DocumentType.LINKFOLLOW, { layout: { view: LinkFollowBox, dataField: data } }], + [DocumentType.WEBCAM, { + layout: { view: DashWebRTCVideo, dataField: data } + }], [DocumentType.PRESELEMENT, { layout: { view: PresElementBox, dataField: data } }], @@ -444,6 +448,10 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.YOUTUBE), new YoutubeField(new URL(url)), options); } + export function WebCamDocument(url: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.WEBCAM), "", options); + } + export function AudioDocument(url: string, options: DocumentOptions = {}) { return InstanceFromProto(Prototypes.get(DocumentType.AUDIO), new AudioField(new URL(url)), options); } diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index a0ba16ea4..4ec1659cc 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -35,6 +35,8 @@ library.add(faCloudUploadAlt); library.add(faSyncAlt); library.add(faShare); +export type CloseCall = (toBeDeleted: DocumentView[]) => void; + @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { static Instance: DocumentDecorations; @@ -59,6 +61,8 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> @observable public pullIcon: IconProp = "arrow-alt-circle-down"; @observable public pullColor: string = "white"; @observable public openHover = false; + @observable private addedCloseCalls: CloseCall[] = []; + constructor(props: Readonly<{}>) { super(props); @@ -69,6 +73,14 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> @action titleChanged = (event: any) => this._accumulatedTitle = event.target.value; + addCloseCall = (handler: CloseCall) => { + const currentOffset = this.addedCloseCalls.length - 1; + this.addedCloseCalls.push((toBeDeleted: DocumentView[]) => { + this.addedCloseCalls.splice(currentOffset, 1); + handler(toBeDeleted); + }); + } + titleBlur = undoBatch(action((commit: boolean) => { this._edtingTitle = false; if (commit) { @@ -225,6 +237,8 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> const recent = Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc) as Doc; const selected = SelectionManager.SelectedDocuments().slice(); SelectionManager.DeselectAll(); + this.addedCloseCalls.forEach(handler => handler(selected)); + selected.map(dv => { recent && Doc.AddDocToList(recent, "data", dv.props.Document, undefined, true, true); dv.props.removeDocument && dv.props.removeDocument(dv.props.Document); diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index a839f9fd2..a9e81093a 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -1,7 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faFileAlt, faStickyNote, faArrowDown, faBullseye, faFilter, faArrowUp, faBolt, faCaretUp, faCat, faCheck, faChevronRight, faClone, faCloudUploadAlt, faCommentAlt, faCut, faEllipsisV, faExclamation, faFilePdf, faFilm, faFont, faGlobeAsia, faLongArrowAltRight, - faMusic, faObjectGroup, faPause, faMousePointer, faPenNib, faFileAudio, faPen, faEraser, faPlay, faPortrait, faRedoAlt, faThumbtack, faTree, faTv, faUndoAlt, faHighlighter, faMicrophone, faCompressArrowsAlt, faPhone, faStamp, faClipboard + faMusic, faObjectGroup, faPause, faMousePointer, faPenNib, faFileAudio, faPen, faEraser, faPlay, faPortrait, faRedoAlt, faThumbtack, faTree, faTv, faUndoAlt, faHighlighter, faMicrophone, faCompressArrowsAlt, faPhone, faStamp, faClipboard, faVideo, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, configure, observable, reaction, runInAction } from 'mobx'; @@ -139,6 +139,7 @@ export class MainView extends React.Component { library.add(faArrowUp); library.add(faCloudUploadAlt); library.add(faBolt); + library.add(faVideo); library.add(faChevronRight); library.add(faEllipsisV); library.add(faMusic); diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 284600bb4..5df430411 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -33,6 +33,8 @@ import { VideoBox } from "./VideoBox"; import { WebBox } from "./WebBox"; import { InkingStroke } from "../InkingStroke"; import React = require("react"); +import { DashWebRTCVideo } from "../webcam/DashWebRTCVideo"; + import { TraceMobx } from "../../../new_fields/util"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? @@ -107,7 +109,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { FormattedTextBox, ImageBox, IconBox, DirectoryImportBox, FontIconBox: FontIconBox, ButtonBox, SliderBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox, PresBox, YoutubeBox, LinkFollowBox, PresElementBox, QueryBox, - ColorBox, DocuLinkBox, InkingStroke, DocumentBox + ColorBox, DashWebRTCVideo, DocuLinkBox, InkingStroke, DocumentBox }} bindings={this.CreateBindings()} jsx={this.layout} diff --git a/src/client/views/webcam/DashWebRTCVideo.scss b/src/client/views/webcam/DashWebRTCVideo.scss new file mode 100644 index 000000000..2f35eeca2 --- /dev/null +++ b/src/client/views/webcam/DashWebRTCVideo.scss @@ -0,0 +1,46 @@ +@import "../globalCssVariables"; + +.webcam-cont { + background: whitesmoke; + color: grey; + border-radius: 15px; + box-shadow: $intermediate-color 0.2vw 0.2vw 0.4vw; + border: solid #BBBBBBBB 5px; + pointer-events: all; + display: flex; + flex-direction: column; + overflow: hidden; + + .webcam-header { + height: 50px; + text-align: center; + text-transform: uppercase; + letter-spacing: 2px; + font-size: 16px; + width: 100%; + } + + #roomName { + outline: none; + border-radius: inherit; + border: 1px solid #BBBBBBBB; + } + + .side { + width: 25%; + height: 20%; + position: absolute; + top: 65%; + z-index: 2; + right: 5%; + } + + .main { + position: absolute; + width: 95%; + height: 75%; + top: 20%; + align-self: center; + } + +}
\ No newline at end of file diff --git a/src/client/views/webcam/DashWebRTCVideo.tsx b/src/client/views/webcam/DashWebRTCVideo.tsx new file mode 100644 index 000000000..0eefbbc91 --- /dev/null +++ b/src/client/views/webcam/DashWebRTCVideo.tsx @@ -0,0 +1,94 @@ +import { observer } from "mobx-react"; +import React = require("react"); +import { CollectionFreeFormDocumentViewProps } from "../nodes/CollectionFreeFormDocumentView"; +import { FieldViewProps, FieldView } from "../nodes/FieldView"; +import { observable, action } from "mobx"; +import { DocumentDecorations, CloseCall } from "../DocumentDecorations"; +import { InkingControl } from "../InkingControl"; +import "../../views/nodes/WebBox.scss"; +import "./DashWebRTCVideo.scss"; +import adapter from 'webrtc-adapter'; +import { initialize, hangup } from "./WebCamLogic"; + +/** + * This models the component that will be rendered, that can be used as a doc that will reflect the video cams. + */ +@observer +export class DashWebRTCVideo extends React.Component<CollectionFreeFormDocumentViewProps & FieldViewProps> { + + private roomText: HTMLInputElement | undefined; + @observable remoteVideoAdded: boolean = false; + + componentDidMount() { + DocumentDecorations.Instance.addCloseCall(this.closeConnection); + } + + closeConnection: CloseCall = () => { + hangup(); + } + + @action + changeUILook = () => { + this.remoteVideoAdded = true; + } + + /** + * Function that submits the title entered by user on enter press. + */ + private onEnterKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === 13) { + let submittedTitle = this.roomText!.value; + this.roomText!.value = ""; + this.roomText!.blur(); + initialize(submittedTitle, this.changeUILook); + } + } + + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DashWebRTCVideo, fieldKey); } + + _ignore = 0; + onPreWheel = (e: React.WheelEvent) => { + this._ignore = e.timeStamp; + } + onPrePointer = (e: React.PointerEvent) => { + this._ignore = e.timeStamp; + } + onPostPointer = (e: React.PointerEvent) => { + if (this._ignore !== e.timeStamp) { + e.stopPropagation(); + } + } + onPostWheel = (e: React.WheelEvent) => { + if (this._ignore !== e.timeStamp) { + e.stopPropagation(); + } + } + + render() { + let content = + <div className="webcam-cont" style={{ width: "100%", height: "100%" }} onWheel={this.onPostWheel} onPointerDown={this.onPostPointer} onPointerMove={this.onPostPointer} onPointerUp={this.onPostPointer}> + <div className="webcam-header">DashWebRTC</div> + <input id="roomName" type="text" placeholder="Enter room name" ref={(e) => this.roomText = e!} onKeyDown={this.onEnterKeyDown} /> + <video id="localVideo" className={"RTCVideo" + (this.remoteVideoAdded ? " side" : " main")} autoPlay playsInline ref={(e) => { + }}></video> + <video id="remoteVideo" className="RTCVideo main" autoPlay playsInline ref={(e) => { + }}></video> + + </div >; + + let frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; + let classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); + + + return ( + <> + <div className={classname} > + {content} + </div> + {!frozen ? (null) : <div className="webBox-overlay" onWheel={this.onPreWheel} onPointerDown={this.onPrePointer} onPointerMove={this.onPrePointer} onPointerUp={this.onPrePointer} />} + </>); + } + + +}
\ No newline at end of file diff --git a/src/client/views/webcam/WebCamLogic.js b/src/client/views/webcam/WebCamLogic.js new file mode 100644 index 000000000..a7af9c2c4 --- /dev/null +++ b/src/client/views/webcam/WebCamLogic.js @@ -0,0 +1,275 @@ +'use strict'; +import io from "socket.io-client"; + +var socket; +var isChannelReady = false; +var isInitiator = false; +var isStarted = false; +var localStream; +var pc; +var remoteStream; +var turnReady; +var room; + +export function initialize(roomName, handlerUI) { + + var pcConfig = { + 'iceServers': [{ + 'urls': 'stun:stun.l.google.com:19302' + }] + }; + + // Set up audio and video regardless of what devices are present. + var sdpConstraints = { + offerToReceiveAudio: true, + offerToReceiveVideo: true + }; + + ///////////////////////////////////////////// + + room = roomName; + + socket = io.connect(`${window.location.protocol}//${window.location.hostname}:${4321}`); + + if (room !== '') { + socket.emit('create or join', room); + console.log('Attempted to create or join room', room); + } + + socket.on('created', function (room) { + console.log('Created room ' + room); + isInitiator = true; + }); + + socket.on('full', function (room) { + console.log('Room ' + room + ' is full'); + }); + + socket.on('join', function (room) { + console.log('Another peer made a request to join room ' + room); + console.log('This peer is the initiator of room ' + room + '!'); + isChannelReady = true; + }); + + socket.on('joined', function (room) { + console.log('joined: ' + room); + isChannelReady = true; + }); + + socket.on('log', function (array) { + console.log.apply(console, array); + }); + + //////////////////////////////////////////////// + + + // This client receives a message + socket.on('message', function (message) { + console.log('Client received message:', message); + if (message === 'got user media') { + maybeStart(); + } else if (message.type === 'offer') { + if (!isInitiator && !isStarted) { + maybeStart(); + } + pc.setRemoteDescription(new RTCSessionDescription(message)); + doAnswer(); + } else if (message.type === 'answer' && isStarted) { + pc.setRemoteDescription(new RTCSessionDescription(message)); + } else if (message.type === 'candidate' && isStarted) { + var candidate = new RTCIceCandidate({ + sdpMLineIndex: message.label, + candidate: message.candidate + }); + pc.addIceCandidate(candidate); + } else if (message === 'bye' && isStarted) { + handleRemoteHangup(); + } + }); + + //////////////////////////////////////////////////// + + var localVideo = document.querySelector('#localVideo'); + var remoteVideo = document.querySelector('#remoteVideo'); + + const gotStream = (stream) => { + console.log('Adding local stream.'); + localStream = stream; + localVideo.srcObject = stream; + sendMessage('got user media'); + if (isInitiator) { + maybeStart(); + } + } + + + navigator.mediaDevices.getUserMedia({ + audio: true, + video: true + }) + .then(gotStream) + .catch(function (e) { + alert('getUserMedia() error: ' + e.name); + }); + + + + var constraints = { + video: true + }; + + console.log('Getting user media with constraints', constraints); + + if (location.hostname !== 'localhost') { + requestTurn( + 'https://computeengineondemand.appspot.com/turn?username=41784574&key=4080218913' + ); + } + + const maybeStart = () => { + console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady); + if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) { + console.log('>>>>>> creating peer connection'); + createPeerConnection(); + pc.addStream(localStream); + isStarted = true; + console.log('isInitiator', isInitiator); + if (isInitiator) { + doCall(); + } + } + }; + + window.onbeforeunload = function () { + sendMessage('bye'); + }; + + ///////////////////////////////////////////////////////// + + const createPeerConnection = () => { + try { + pc = new RTCPeerConnection(null); + pc.onicecandidate = handleIceCandidate; + pc.onaddstream = handleRemoteStreamAdded; + pc.onremovestream = handleRemoteStreamRemoved; + console.log('Created RTCPeerConnnection'); + } catch (e) { + console.log('Failed to create PeerConnection, exception: ' + e.message); + alert('Cannot create RTCPeerConnection object.'); + return; + } + } + + const handleIceCandidate = (event) => { + console.log('icecandidate event: ', event); + if (event.candidate) { + sendMessage({ + type: 'candidate', + label: event.candidate.sdpMLineIndex, + id: event.candidate.sdpMid, + candidate: event.candidate.candidate + }); + } else { + console.log('End of candidates.'); + } + } + + const handleCreateOfferError = (event) => { + console.log('createOffer() error: ', event); + } + + const doCall = () => { + console.log('Sending offer to peer'); + pc.createOffer(setLocalAndSendMessage, handleCreateOfferError); + } + + const doAnswer = () => { + console.log('Sending answer to peer.'); + pc.createAnswer().then( + setLocalAndSendMessage, + onCreateSessionDescriptionError + ); + } + + const setLocalAndSendMessage = (sessionDescription) => { + pc.setLocalDescription(sessionDescription); + console.log('setLocalAndSendMessage sending message', sessionDescription); + sendMessage(sessionDescription); + } + + const onCreateSessionDescriptionError = (error) => { + trace('Failed to create session description: ' + error.toString()); + } + + const requestTurn = (turnURL) => { + var turnExists = false; + for (var i in pcConfig.iceServers) { + if (pcConfig.iceServers[i].urls.substr(0, 5) === 'turn:') { + turnExists = true; + turnReady = true; + break; + } + } + if (!turnExists) { + console.log('Getting TURN server from ', turnURL); + // No TURN server. Get one from computeengineondemand.appspot.com: + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4 && xhr.status === 200) { + var turnServer = JSON.parse(xhr.responseText); + console.log('Got TURN server: ', turnServer); + pcConfig.iceServers.push({ + 'urls': 'turn:' + turnServer.username + '@' + turnServer.turn, + 'credential': turnServer.password + }); + turnReady = true; + } + }; + xhr.open('GET', turnURL, true); + xhr.send(); + } + } + + const handleRemoteStreamAdded = (event) => { + console.log('Remote stream added.'); + remoteStream = event.stream; + remoteVideo.srcObject = remoteStream; + handlerUI(); + + }; + + const handleRemoteStreamRemoved = (event) => { + console.log('Remote stream removed. Event: ', event); + } +} + +export function hangup() { + console.log('Hanging up.'); + stop(); + sendMessage('bye'); + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + } +} + +function stop() { + isStarted = false; + if (pc) { + pc.close(); + } + pc = null; +} + +function handleRemoteHangup() { + console.log('Session terminated.'); + stop(); + isInitiator = false; + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + } +} + +function sendMessage(message) { + console.log('Client sending message: ', message); + socket.emit('message', message, room); +};
\ No newline at end of file |
