From 5370b39fb612d434c31caf163c0fecb6b0024bf0 Mon Sep 17 00:00:00 2001 From: aforge Date: Sun, 13 Feb 2022 19:31:06 -0800 Subject: [PATCH] Video calls --- css/base.css | 30 +++ src/views/call-view.jsx | 179 ++++++++++++++++++ src/views/messages-view.jsx | 78 +++++++- src/views/tinode-web.jsx | 283 +++++++++++++++++++++++++++- src/widgets/rtc-panel.jsx | 363 ++++++++++++++++++++++++++++++++++++ 5 files changed, 930 insertions(+), 3 deletions(-) create mode 100644 src/views/call-view.jsx create mode 100644 src/widgets/rtc-panel.jsx diff --git a/css/base.css b/css/base.css index 3129077b..257a032d 100644 --- a/css/base.css +++ b/css/base.css @@ -2590,3 +2590,33 @@ ul.tabbar li.active > a { opacity: 0.3; } } + +.h-100 { + height: 100%!important; +} + +#video_container { + display: flex; + width: 85%; + margin: auto; + height: 80% +} + +video { + width:100%; +} + +.align-items-center { + -ms-flex-align: center!important; + align-items: center!important; +} + +.local_div { + width:50%; + float:left +} + +.remote_div { + width:50%; + float:left +} diff --git a/src/views/call-view.jsx b/src/views/call-view.jsx new file mode 100644 index 00000000..957b50b0 --- /dev/null +++ b/src/views/call-view.jsx @@ -0,0 +1,179 @@ +// InfoView: panel with topic/user info. +import React from 'react'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import Tinode from 'tinode-sdk'; + +import AvatarUpload from '../widgets/avatar-upload.jsx'; +import BadgeList from '../widgets/badge-list.jsx'; +import MenuCancel from '../widgets/menu-cancel.jsx'; + +import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js'; + +import { makeImageUrl } from '../lib/blob-helpers.js'; +import { arrayEqual, theCard } from '../lib/utils.js'; + +function _clip(str, length) { + return str && str.substring(0, length); +} + +class CallView extends React.Component { + constructor(props) { + super(props); + + this.state = { + topic: null, + fullName: undefined, + avatar: null, + trustedBadges: [], + previousMetaDesc: undefined, + offerSent: false, + }; + + this.resetDesc = this.resetDesc.bind(this); + this.onMetaDesc = this.onMetaDesc.bind(this); + this.handleRejectCall = this.handleRejectCall.bind(this); + this.handleAcceptCall = this.handleAcceptCall.bind(this); + } + + componentDidMount() { + const topic = this.props.tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + this.resetDesc(topic, this.props); + if (this.props.callState == 2) { + this.props.onRinging(this.props.topic, this.props.seq); + } + } + + // No need to separately handle component mount. + componentDidUpdate(props) { + const topic = this.props.tinode.getTopic(props.topic); + if (!topic) { + return; + } + + if (this.onMetaDesc != topic.onMetaDesc) { + this.previousMetaDesc = topic.onMetaDesc; + topic.onMetaDesc = this.onMetaDesc; + } + + if (this.state.topic != props.topic) { + this.setState({topic: props.topic}); + this.resetDesc(topic, props); + } + } + + componentWillUnmount() { + const topic = this.props.tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + this.setState({topic: null}); + topic.onMetaDesc = this.previousMetaDesc; + } + + resetDesc(topic, props) { + const defacs = topic.getDefaultAccess() || {}; + const acs = topic.getAccessMode(); + const badges = []; + if (topic.trusted) { + for (const [key, val] of Object.entries(topic.trusted)) { + if (val) { + badges.push(key); + } + } + } + + this.setState({ + fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH), + avatar: makeImageUrl(topic.public ? topic.public.photo : null), + trustedBadges: badges, + }); + } + + onMetaDesc(desc) { + const topic = this.props.tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + this.resetDesc(topic, this.props); + + if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) { + this.previousMetaDesc(desc); + } + } + + handleAcceptCall() { + this.props.onAcceptCall(this.props.topic); + } + + handleRejectCall() { + this.props.onReject(this.props.topic, this.props.seq); + this.props.onClose(); + } + + render() { + const {formatMessage} = this.props.intl; + let panelTitle = 'Unknown'; + switch (this.props.callState) { + case 1: + panelTitle = 'Calling'; + break; + case 2: + panelTitle = 'Incoming Call From'; + break; + case 3: + panelTitle = 'Call Established'; + break; + } + + return ( +
+
+
{panelTitle}
+
+ +
+
+
+
+
+ +
+
+
{this.state.fullName}
+
+
+ +
+
+ {this.props.callState >= 1 && this.props.callState < 3 ? + + : + null + } + {this.props.callState >= 2 && this.props.callState < 3 ? + + : + null + } +
+
+
+
+ ); + } +}; + +export default injectIntl(CallView); diff --git a/src/views/messages-view.jsx b/src/views/messages-view.jsx index 946184df..238682f5 100644 --- a/src/views/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -14,6 +14,7 @@ import Invitation from '../widgets/invitation.jsx'; import LetterTile from '../widgets/letter-tile.jsx'; import LoadSpinner from '../widgets/load-spinner.jsx'; import LogoView from './logo-view.jsx'; +import RtcPanel from '../widgets/rtc-panel.jsx'; import SendMessage from '../widgets/send-message.jsx'; import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, @@ -113,6 +114,8 @@ class MessagesView extends React.Component { this.handlePickReply = this.handlePickReply.bind(this); this.handleCancelReply = this.handleCancelReply.bind(this); this.handleQuoteClick = this.handleQuoteClick.bind(this); + this.handleRtcClick = this.handleRtcClick.bind(this); + this.handleRtcClose = this.handleRtcClose.bind(this); this.chatMessageRefs = {}; this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this); @@ -173,7 +176,17 @@ class MessagesView extends React.Component { topic.onPres = this.handleSubsUpdated; } } - + /* + if (this.props.callTopic == this.props.topic && !this.state.rtcPanel) { + this.handleRtcClick(); + } + */ + /* + if (this.state.rtcPanel == null && topic && topic.name == this.props.callTopic && (this.props.callState == 1 || this.props.callState == 3)) { + console.log('--> setting rtc panel, call state = ', this.props.callState); + this.setState({rtcPanel: { topic: this.props.callTopic }}); + } + */ if (!this.props.applicationVisible) { // If application is not visible, flush all unsent 'read' notifications. this.clearNotificationQueue(); @@ -189,6 +202,11 @@ class MessagesView extends React.Component { static getDerivedStateFromProps(nextProps, prevState) { let nextState = {}; + /* + if (nextProps.callState == 3 || nextProps.callState == 1) { + nextState.rtcPanel = { topic: nextProps.topic }; + } + */ if (!nextProps.topic) { // Default state: no topic. nextState = { @@ -204,6 +222,7 @@ class MessagesView extends React.Component { docPreview: null, imagePreview: null, imagePostview: null, + rtcPanel: null,//typeof nextProps.topic != 'undefined' && nextPropstopic.name == this.props.callTopic && (nextProps.callState == 1 || nextProps.callState == 3) ? { topic: nextProps.topic } : null, typingIndicator: false, scrollPosition: 0, fetchingMessages: false, @@ -229,6 +248,7 @@ class MessagesView extends React.Component { docPreview: null, imagePreview: null, imagePostview: null, + rtcPanel: null, typingIndicator: false, scrollPosition: 0, fetchingMessages: false, @@ -278,6 +298,12 @@ class MessagesView extends React.Component { latestClearId: topic.maxClearId(), channel: topic.isChannelType() }); + + if (nextProps.callTopic == topic.name && (nextProps.callState == 1 || nextProps.callState == 3)) { + Object.assign(nextState, { + rtcPanel: nextProps.callTopic, + }); + } } else { // Invalid topic. Object.assign(nextState, { @@ -290,6 +316,13 @@ class MessagesView extends React.Component { channel: false }); } + } else { + // same topic + if (!prevState.rtcPanel && nextProps.callTopic == prevState.topic && (nextProps.callState == 1 || nextProps.callState == 3)) { + Object.assign(nextState, { + rtcPanel: nextProps.callTopic, + }); + } } if (nextProps.acs) { @@ -750,6 +783,28 @@ class MessagesView extends React.Component { } } + handleRtcClick() { + this.setState({ + rtcPanel: { + /* + file: file, + name: file.name, + size: file.size, + type: file.type + */ + topic: this.state.topic + } + }); + + } + + handleRtcClose() { + this.props.onVideoCallClosed(); + this.setState({ + rtcPanel: null + }); + } + // sendImageAttachment sends the image bits inband as Drafty message. sendImageAttachment(caption, blob) { const mime = this.state.imagePreview.mime; @@ -934,6 +989,25 @@ class MessagesView extends React.Component { onClose={this.handleClosePreview} onSendMessage={this.sendFileAttachment} /> ); + } else if (this.state.rtcPanel) { + const topic = this.props.tinode.getTopic(this.state.topic); + component2 = ( + + ); } else { const topic = this.props.tinode.getTopic(this.state.topic); const isChannel = topic.isChannelType(); @@ -1124,7 +1198,7 @@ class MessagesView extends React.Component { ); } - + // component =
{component2}
} return component; diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index c65d70cc..23bb20b3 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -12,6 +12,7 @@ import ContextMenu from '../widgets/context-menu.jsx'; import ForwardDialog from '../widgets/forward-dialog.jsx'; import InfoView from './info-view.jsx'; +import CallView from './call-view.jsx'; import MessagesView from './messages-view.jsx'; import SidepanelView from './sidepanel-view.jsx'; @@ -63,6 +64,11 @@ const messages = defineMessages({ id: 'menu_item_info', defaultMessage: 'Info', description: 'Show extended topic information' + }, + menu_item_video_call: { + id: 'menu_item_video_call', + defaultMessage: 'Video Call', + description: 'Start video call' } }); @@ -145,6 +151,22 @@ class TinodeWeb extends React.Component { this.handleShowForwardDialog = this.handleShowForwardDialog.bind(this); this.handleHideForwardDialog = this.handleHideForwardDialog.bind(this); + this.handleStartVideoCall = this.handleStartVideoCall.bind(this); + this.handleTeleMessage = this.handleTeleMessage.bind(this); + this.handleCallClose = this.handleCallClose.bind(this); + + this.handleTeleInvite = this.handleTeleInvite.bind(this); + this.handleTeleRinging = this.handleTeleRinging.bind(this); + this.handleTeleHangup = this.handleTeleHangup.bind(this); + this.handleTeleSendOffer = this.handleTeleSendOffer.bind(this); + this.handleTeleIceCandidate = this.handleTeleIceCandidate.bind(this); + this.handleTeleSendAnswer = this.handleTeleSendAnswer.bind(this); + + this.handleTeleAcceptCall = this.handleTeleAcceptCall.bind(this); + this.handleTeleUpdateLease = this.handleTeleUpdateLease.bind(this); + this.handleTeleLeaseReply = this.handleTeleLeaseReply.bind(this); + this.setLeaseTimer = this.setLeaseTimer.bind(this); + this.sendMessageToTopic = this.sendMessageToTopic.bind(this); } @@ -197,6 +219,10 @@ class TinodeWeb extends React.Component { infoPanel: undefined, mobilePanel: 'sidepanel', + callTopic: undefined, + callState: 0, + //isCallOriginator: false, + contextMenuVisible: false, contextMenuBounds: null, contextMenuClickAt: null, @@ -252,6 +278,7 @@ class TinodeWeb extends React.Component { this.tinode.onConnect = this.handleConnected; this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration; + this.tinode.onTeleMessage = this.handleTeleMessage; }).then(() => { // Initialize desktop alerts. if (this.state.desktopAlertsEnabled) { @@ -825,7 +852,26 @@ class TinodeWeb extends React.Component { this.receivedTimer = undefined; topic.noteRecv(data.seq); }, RECEIVED_DELAY); + + /* + if (typeof data.head != 'undefined' && data.head['mime'] == 'application/tinode-video-call' && topic.msgRecvCount(data.seq) == 0) { + this.setState({ + callTopic: data.topic, + callState: 2 + }); + } + */ } + + /* + // Incoming call. + if (data.from != this.state.myUserId && data.seq > topic.maxMsgSeq()) { + this.setState({ + callTopic: data.topic, + callState: 2 + }); + } + */ } /* Fnd topic: find contacts by tokens */ @@ -1355,6 +1401,7 @@ class TinodeWeb extends React.Component { this.tinode.onConnect = this.handleConnected; this.tinode.onDisconnect = this.handleDisconnect; this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration; + this.tinode.onTeleMessage = this.handleTeleMessage; HashNavigation.navigateTo(''); } @@ -1498,6 +1545,10 @@ class TinodeWeb extends React.Component { title: this.props.intl.formatMessage(messages.menu_item_info), handler: this.handleShowInfoView } : null, + subscribed && Tinode.isP2PTopicName(topicName) ? { + title: this.props.intl.formatMessage(messages.menu_item_video_call), + handler: this.handleStartVideoCall + } : null, subscribed ? 'messages_clear' : null, subscribed && deleter ? 'messages_clear_hard' : null, muted ? (blocked ? null : 'topic_unmute') : 'topic_mute', @@ -1628,6 +1679,209 @@ class TinodeWeb extends React.Component { } } + handleStartVideoCall() { + this.setState({ + callTopic: this.state.topicSelected, + callState: 1, + }); + } + + setLeaseTimer(seq, leaseExp, now, shift) { + if (leaseExp < now) { + // Lease has expired. + this.handleCallClose(); + return; + } + let period = leaseExp - now - shift; + if (period < 0) { + period = 0; + } + console.log('X: setting timeout to ', period, ', seq = ', seq, ', leaseExp = ', leaseExp, ', now = ', now, ', shift = ', shift); + + //setTimeout(() => {console.log("before callTimeout")}, period - 500); + let callTimeout = setTimeout(this.handleTeleUpdateLease, period); + console.log('setting call timeout -> ', callTimeout); + let callState = { + callLeaseExpires: leaseExp, + callTimeout: callTimeout + }; + + if (seq) { + callState.callSeq = seq; + } + this.setState(callState); + } + + handleTeleLeaseReply(ctrl, shift) { + if (ctrl.code < 200 || ctrl.code >= 300 || !('params' in ctrl)) { + this.handleCallClose(); + return; + } + const ds = ctrl.params['lease-expires']; + if (!ds) { + this.handleCallClose(); + return; + } + const leaseExp = new Date(ds); + const now = Date.now(); + this.setLeaseTimer(ctrl.params['seq'], leaseExp.getTime(), now, shift); + } + + handleTeleUpdateLease() { + console.log('update lease event'); + const topic = this.tinode.getTopic(this.state.callTopic); + if (!topic) { + return; + } + const now = Date.now(); + if (now >= this.state.callLeaseExpires) { + // Lease has expired. + this.handleCallClose(); + return; + } + /* + if (!extend) { + this.handleCallClose(); + return; + } + */ + + let until = new Date(this.state.callLeaseExpires); //this.state.callLeaseExpires.getSeconds() + 10; + until.setSeconds(until.getSeconds() + 10); + topic.call('extend-lease', this.state.callSeq, undefined, until) + .then((ctrl) => { + this.handleTeleLeaseReply(ctrl, 2000); + }); + } + + handleTeleInvite(callTopic, callSeq, callState) { + const topic = this.tinode.getTopic(this.state.topicSelected); + if (!topic) { + return; + } + + if (callState == 1) { + topic.call('invite').then((ctrl) => { + if (ctrl.params && ctrl.params['sip-code'] == 100) { + this.handleTeleLeaseReply(ctrl, 0); + } + }); + } else if (callState == 3) { + topic.call('accept', callSeq).then((ctrl) => { + this.handleTeleLeaseReply(ctrl, 2000); + }); + } + } + + handleTeleRinging(callTopic, callSeq) { + const topic = this.tinode.getTopic(callTopic); + if (!topic) { + return; + } + + topic.call('ringing', callSeq); + } + + handleTeleHangup(callTopic, callSeq) { + const topic = this.tinode.getTopic(callTopic); + if (!topic) { + return; + } + + topic.call('hang-up', callSeq); + } + + handleTeleSendOffer(callTopic, callSeq, sdp) { + const topic = this.tinode.getTopic(callTopic); + if (!topic) { + return; + } + + topic.call('offer', callSeq, sdp); + } + + handleTeleIceCandidate(callTopic, callSeq, candidate) { + const topic = this.tinode.getTopic(callTopic); + if (!topic) { + return; + } + + topic.call('ice-candidate', callSeq, candidate); + } + handleTeleSendAnswer(callTopic, callSeq, sdp) { + const topic = this.tinode.getTopic(callTopic); + if (!topic) { + return; + } + + topic.call('answer', callSeq, sdp); + } + + handleCallClose() { + if (this.state.callTimeout) { + console.log('closing call timeout ', this.state.callTimeout); + clearTimeout(this.state.callTimeout); + } + this.setState({ + callTopic: undefined, + callState: 0, + callLeaseExpires: undefined, + callTimeout: undefined + }); + } + + handleTeleAcceptCall(topicName) { + this.handleTopicSelected(this.state.callTopic); + this.setState({ + callState: 3 + }); + } + + handleTeleMessage(tele) { + // + switch (tele.what) { + case 'invite': + console.log('invite: ', tele); + if (tele.from != this.state.myUserId) { + // incoming call. + this.setState({ + callTopic: tele.src, + callState: 2, + callSeq: tele.seq, + sdp: tele.sdp + }); + } + break; + case 'accept': + console.log('TinodeWeb: received accept msg ', tele); + // If another my session has accepted the call. + if (tele.topic == 'me' && tele.from == this.state.myUserId) { + this.setState({ + callTopic: null,//tele.src, + callState: 0, + callSeq: null,//tele.seq, + //sdp: tele.sdp + }); + } + if (tele.topic == this.state.callTopic) { + // Update state. + this.setState({callState: 3}); + if (this.state.callTimeout) { + clearTimeout(this.state.callTimeout); + this.setState({callTimeout: undefined}); + this.setLeaseTimer(this.state.callSeq, this.state.callLeaseExpires, Date.now(), 2000); + } + } + break; + case 'hang-up': + //if (tele.topic == 'me') { + // remote hangup. + this.handleCallClose(); + break; + //} + } + } + render() { return (
@@ -1765,6 +2019,17 @@ class TinodeWeb extends React.Component { forwardMessage={this.state.forwardMessage} onCancelForwardMessage={this.handleHideForwardDialog} + callTopic={this.state.callTopic} + callSeq={this.state.callSeq} + callState={this.state.callState} + onTeleHangup={this.handleTeleHangup} + + onTeleInvite={this.handleTeleInvite} + onTeleSendOffer={this.handleTeleSendOffer} + onTeleIceCandidate={this.handleTeleIceCandidate} + onTeleSendAnswer={this.handleTeleSendAnswer} + //onTemeMedia + errorText={this.state.errorText} errorLevel={this.state.errorLevel} errorAction={this.state.errorAction} @@ -1779,7 +2044,8 @@ class TinodeWeb extends React.Component { showContextMenu={this.handleShowContextMenu} onChangePermissions={this.handleChangePermissions} onNewChat={this.handleNewChatInvitation} - sendMessage={this.handleSendMessage} /> + sendMessage={this.handleSendMessage} + onVideoCallClosed={this.handleCallClose} /> {this.state.infoPanel ? + : + null + }
); } diff --git a/src/widgets/rtc-panel.jsx b/src/widgets/rtc-panel.jsx new file mode 100644 index 00000000..48f2f29e --- /dev/null +++ b/src/widgets/rtc-panel.jsx @@ -0,0 +1,363 @@ +import React from 'react'; + +export default class RtcPanel extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + isChannelReady: false, + isInitiator: false, + isStarted: false, + localStream: undefined, + pc: undefined, + remoteStream: undefined, + + previousOnTele: undefined + }; + + this.localStreamConstraints = { + audio: true, + video: true + }; + + this.pcConfig = { + iceServers: [ + { urls: [ "stun:bn-turn1.xirsys.com" ] }, + { + username: "0kYXFmQL9xojOrUy4VFemlTnNPVFZpp7jfPjpB3AjxahuRe4QWrCs6Ll1vDc7TTjAAAAAGAG2whXZWJUdXRzUGx1cw==", + credential: "285ff060-5a58-11eb-b269-0242ac140004", + urls: [ + "turn:bn-turn1.xirsys.com:80?transport=udp", + "turn:bn-turn1.xirsys.com:3478?transport=udp", + "turn:bn-turn1.xirsys.com:80?transport=tcp", + "turn:bn-turn1.xirsys.com:3478?transport=tcp", + "turns:bn-turn1.xirsys.com:443?transport=tcp", + "turns:bn-turn1.xirsys.com:5349?transport=tcp" + ] + } + ]}; + + this.localRef = React.createRef(); + this.remoteRef = React.createRef(); + + this.onTele = this.onTele.bind(this); + this.launchStream = this.launchStream.bind(this); + + this.createPeerConnection = this.createPeerConnection.bind(this); + + this.handleNegotiationNeededEvent = this.handleNegotiationNeededEvent.bind(this); + this.handleICECandidateEvent = this.handleICECandidateEvent.bind(this); + this.handleNewICECandidateMsg = this.handleNewICECandidateMsg.bind(this); + this.handleICEConnectionStateChangeEvent = this.handleICEConnectionStateChangeEvent.bind(this); + this.handleSignalingStateChangeEvent = this.handleSignalingStateChangeEvent.bind(this); + this.handleICEGatheringStateChangeEvent = this.handleICEGatheringStateChangeEvent.bind(this); + this.handleTrackEvent = this.handleTrackEvent.bind(this); + + this.handleVideoOfferMsg = this.handleVideoOfferMsg.bind(this); + this.handleVideoAnswerMsg = this.handleVideoAnswerMsg.bind(this); + this.handleNewICECandidateMsg = this.handleNewICECandidateMsg.bind(this); + + this.reportError = this.reportError.bind(this); + this.handleGetUserMediaError = this.handleGetUserMediaError.bind(this); + + this.stop = this.stop.bind(this); + this.stopTracks = this.stopTracks.bind(this); + + this.handleCloseClick = this.handleCloseClick.bind(this); + this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this); + this.handleToggleMicClick = this.handleToggleMicClick.bind(this); + this.toggleMedia = this.toggleMedia.bind(this); + + this.handleRemoteHangup = this.handleRemoteHangup.bind(this); + this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this); + } + + handleVideoCallAccepted(tele) { + const pc = this.createPeerConnection(); + const stream = this.state.localStream; + stream.getTracks().forEach(track => pc.addTrack(track, stream)); + console.log('RtcPanel: tele msg', tele); + } + + onTele(tele) { + console.log('tele --> ', tele); + switch (tele.what) { + //case 'invite' + case 'accept': + // Call accepted. + this.handleVideoCallAccepted(tele); + break; + case 'offer': + this.handleVideoOfferMsg(tele); + break; + case 'answer': + this.handleVideoAnswerMsg(tele); + break; + case 'ice-candidate': + this.handleNewICECandidateMsg(tele); + break; + case 'hang-up': + this.handleRemoteHangup(tele); + break; + } + } + + componentDidUpdate(props) { + const topic = this.props.topic; + if (this.onTele != topic.onTele) { + this.previousOnTele = topic.onTele; + topic.onTele = this.onTele; + } + } + + componentDidMount() { + if ((this.props.callState == 1 || this.props.callState == 3) && this.localRef.current) { + this.launchStream(); + } + } + + componentWillUnmount() { + this.props.topic.onTele = this.previousOnTele; + } + + launchStream() { + if (this.state.localStream) { + console.log("You can't start a call because you already have one open!"); + } else { + navigator.mediaDevices.getUserMedia(this.localStreamConstraints) + .then(stream => { + console.log('Adding local stream.', this.localRef.current); + this.setState({localStream: stream}); + this.localRef.current.srcObject = stream; + // Send call invitation (or accept call). + this.props.onInvite(this.props.topic.name, this.props.seq, this.props.callState); + }) + .catch(this.handleGetUserMediaError); + } + } + + createPeerConnection() { + var pc = new RTCPeerConnection(this.pcConfig); + + pc.onicecandidate = this.handleICECandidateEvent; + pc.oniceconnectionstatechange = this.handleICEConnectionStateChangeEvent; + pc.onicegatheringstatechange = this.handleICEGatheringStateChangeEvent; + pc.onsignalingstatechange = this.handleSignalingStateChangeEvent; + pc.onnegotiationneeded = this.handleNegotiationNeededEvent; + pc.ontrack = this.handleTrackEvent; + + this.setState({pc: pc}); + console.log('Created RTCPeerConnnection'); + return pc; + } + + handleVideoAnswerMsg(tele) { + console.log("*** Call recipient has accepted our call"); + + // Configure the remote description, which is the SDP payload + // in our "video-answer" message. + + var desc = new RTCSessionDescription(tele.payload); + this.state.pc.setRemoteDescription(desc).catch(this.reportError); + } + + reportError(errMessage) { + console.log(`Error ${errMessage.name}: ${errMessage.message}`); + } + + handleNegotiationNeededEvent() { + this.state.pc.createOffer().then(offer => { + return this.state.pc.setLocalDescription(offer); + }) + .then(() => { + this.props.onSendOffer(this.props.topic.name, this.props.seq, this.state.pc.localDescription.toJSON()); + }) + .catch(reportError); + } + + handleICECandidateEvent(event) { + if (event.candidate) { + this.props.onIceCandidate(this.props.topic.name, this.props.seq, event.candidate.toJSON()); + } + } + + handleNewICECandidateMsg(tele) { + var candidate = new RTCIceCandidate(tele.payload); + + this.state.pc.addIceCandidate(candidate) + .catch(reportError); + } + + handleICEConnectionStateChangeEvent(event) { + switch (this.state.pc.iceConnectionState) { + case "closed": + case "failed": + this.handleCloseClick(); + break; + } + } + + handleSignalingStateChangeEvent(event) { + switch (this.state.pc.signalingState) { + case "closed": + this.handleCloseClick(); + break; + } + } + + handleICEGatheringStateChangeEvent(event) { + // Our sample just logs information to console here, + // but you can do whatever you need. + } + + handleTrackEvent(event) { + this.remoteRef.current.srcObject = event.streams[0]; + } + + handleGetUserMediaError(e) { + console.log('-->', e); + switch(e.name) { + case "NotFoundError": + console.log("Unable to open your call because no camera and/or microphone" + + "were found."); + break; + case "SecurityError": + case "PermissionDeniedError": + // Do nothing; this is the same as the user canceling the call. + break; + default: + console.log("Error opening your camera and/or microphone: " + e.message); + break; + } + + // Make sure we shut down our end of the RTCPeerConnection so we're + // ready to try again. + this.handleCloseClick(); + } + + + handleVideoOfferMsg(tele) { + var localStream = null; + + const pc = this.createPeerConnection(); + + var desc = new RTCSessionDescription(tele.payload); + + pc.setRemoteDescription(desc).then(() => { + return navigator.mediaDevices.getUserMedia(this.localStreamConstraints); + }) + .then(stream => { + localStream = stream; + this.localRef.current.srcObject = stream; + this.setState({localStream: stream}); + + localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); + }) + .then(() => { + return pc.createAnswer(); + }) + .then(answer => { + return pc.setLocalDescription(answer); + }) + .then(() => { + this.props.onSendAnswer(this.props.topic.name, this.props.seq, pc.localDescription.toJSON()); + }) + .catch(this.handleGetUserMediaError); + } + + handleRemoteHangup() { + this.handleCloseClick(); + } + + stopTracks(el) { + //let lr = this.localRef.current; + if (el == null) { return; } + let stream = el.srcObject; + if (stream == null) { return; } + let tracks = stream.getTracks(); + + if (tracks != null) { + tracks.forEach(function(track) { + track.stop(); + }); + } + el.srcObject = null; + } + + stop() { + this.stopTracks(this.localRef.current); + this.stopTracks(this.remoteRef.current); + if (this.state.pc != null) { + this.state.pc.ontrack = null; + this.state.pc.onremovetrack = null; + this.state.pc.onremovestream = null; + this.state.pc.onicecandidate = null; + this.state.pc.oniceconnectionstatechange = null; + this.state.pc.onsignalingstatechange = null; + this.state.pc.onicegatheringstatechange = null; + this.state.pc.onnegotiationneeded = null; + + this.state.pc.close(); + } + this.setState({isStarted: false, isChannelReady: false, isInitiator: false, pc: null}); + } + + handleCloseClick() { + this.stop(); + this.props.onHangup(this.props.topic.name, this.props.seq); + + this.props.onClose(); + } + + toggleMedia(kind) { + const video = this.localRef.current; + + // A video's MediaStream object is available through its srcObject attribute + const mediaStream = video.srcObject; + + // Through the MediaStream, you can get the MediaStreamTracks with getTracks(): + const tracks = mediaStream.getTracks(); + + // Or stop all like so: + tracks.forEach(track => { + if (track.kind == kind) { + track.stop(); + } + }); + } + + handleToggleCameraClick() { + this.toggleMedia('video'); + } + + handleToggleMicClick() { + this.toggleMedia('audio'); + } + + render() { + if (!this.props.topic) { + return null; + } + + return ( +
+
+
+ +
+
+ +
+
+
+ + + +
+ +
+ ); + } +};