From 0425bd434899c0f6ea0af9755511625f126cc139 Mon Sep 17 00:00:00 2001 From: aforge Date: Wed, 3 May 2023 22:50:51 -0700 Subject: [PATCH] Video conferencing skeleton. --- css/base.css | 89 +++++++ src/lib/formatters.js | 2 + src/views/messages-view.jsx | 56 ++-- src/views/tinode-web.jsx | 46 +++- src/widgets/call-message.jsx | 17 +- src/widgets/chat-message.jsx | 8 +- src/widgets/context-menu.jsx | 3 +- src/widgets/vc-carousel-item.jsx | 94 +++++++ src/widgets/vc-carousel.jsx | 102 ++++++++ src/widgets/vc-panel.jsx | 424 +++++++++++++++++++++++++++++++ 10 files changed, 808 insertions(+), 33 deletions(-) create mode 100644 src/widgets/vc-carousel-item.jsx create mode 100644 src/widgets/vc-carousel.jsx create mode 100644 src/widgets/vc-panel.jsx diff --git a/css/base.css b/css/base.css index 9e0c0858..40992581 100644 --- a/css/base.css +++ b/css/base.css @@ -309,6 +309,11 @@ ul.menu > li.separator:hover { background-color: #ccc; } +ul.menu > li.disabled { + pointer-events: none; + opacity: 0.6; +} + /* The down arrow head - trigger for context menu */ .menuTrigger { position: absolute; @@ -3242,3 +3247,87 @@ ul.phone-country-selector span.country { width: unset; } } + +/* Video conferencing. */ +.call-party-speaking { + border: 2px solid red; +} + +.call-party.carousel-item .caller-name.inactive { + position: absolute; + bottom: 0.25rem; + right: 0.25rem; +} + +.call-party.carousel-item .muted { + position: absolute; + top: 0.25rem; + right: 0.25rem; +} + +.avatar-box.carousel-item { + /* Center the avatar in parent view. */ + width: 45px; + height: 45px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +#carousel-wrapper { + width: 100%; + max-width: 800px; + position: relative; + align-self: center; + padding-top: 1rem; +} + +#carousel { + overflow: auto; + scroll-behavior: smooth; + scrollbar-width: none; +} + +#carousel-prev, #carousel-next { + display: flex; + justify-content: center; + align-content: center; + background: white; + border: none; + padding: 8px; + border-radius: 50%; + outline: 0; + cursor: pointer; + position: absolute; + min-width: unset; + height: unset; +} + +#carousel-prev { + top: 50%; + left: 0; + transform: translate(50%, -50%); + display: none; +} + +#carousel-next { + top: 50%; + right: 0; + transform: translate(-50%, -50%); +} + +#carousel-content { + display: grid; + justify-content: center; + grid-gap: 16px; + grid-auto-flow: column; + margin: auto; + box-sizing: border-box; +} + +.carousel-item { + width: 180px; + height: 100px; + position: relative; +} diff --git a/src/lib/formatters.js b/src/lib/formatters.js index 1b13322b..1dfcbdd4 100644 --- a/src/lib/formatters.js +++ b/src/lib/formatters.js @@ -136,6 +136,8 @@ export function fullFormatter(style, data, values, key, stack) { attr.callState = data.state; attr.incoming = data.incoming; attr.duration = data.duration; + attr.vc = data.vc; + attr.onCallJoin = this.onCallJoin; } break; case 'VD': diff --git a/src/views/messages-view.jsx b/src/views/messages-view.jsx index 29790984..474296c8 100644 --- a/src/views/messages-view.jsx +++ b/src/views/messages-view.jsx @@ -19,6 +19,7 @@ import LogoView from './logo-view.jsx'; import MetaMessage from '../widgets/meta-message.jsx'; import PinnedMessages from '../widgets/pinned-messages.jsx'; import SendMessage from '../widgets/send-message.jsx'; +import VCPanel from '../widgets/vc-panel.jsx'; import VideoPreview from '../widgets/video-preview.jsx'; import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDIATE_P2P_SUBSCRIPTION, @@ -1440,23 +1441,44 @@ class MessagesView extends React.Component { onSendMessage={this.sendFileAttachment} /> ); } else if (this.state.rtcPanel) { - component2 = ( - - ); + onError={this.props.onError} + onHangup={this.handleCallHangup} + onInvite={this.props.onCallInvite} + onSendOffer={this.props.onCallSendOffer} + onIceCandidate={this.props.onCallIceCandidate} + onSendAnswer={this.props.onCallSendAnswer} /> + ); + } else { + // Video conference or stream. + component2 = ( + + ); + } } else { const topic = this.props.tinode.getTopic(this.state.topic); const isChannel = topic.isChannelType(); @@ -1537,6 +1559,7 @@ class MessagesView extends React.Component { messageNodes.push( diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index 86701c84..b908b49d 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -176,6 +176,7 @@ class TinodeWeb extends React.Component { this.handleCallSendOffer = this.handleCallSendOffer.bind(this); this.handleCallIceCandidate = this.handleCallIceCandidate.bind(this); this.handleCallSendAnswer = this.handleCallSendAnswer.bind(this); + this.handleVCCallJoin = this.handleVCCallJoin.bind(this); this.handleCallAccept = this.handleCallAccept.bind(this); @@ -1591,7 +1592,7 @@ class TinodeWeb extends React.Component { } let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false, - archived = false, webrtc = false; + archived = false, webrtc = false, writer = false; if (topic) { subscribed = topic.isSubscribed(); archived = topic.isArchived(); @@ -1602,10 +1603,12 @@ class TinodeWeb extends React.Component { blocked = !acs.isJoiner(); self_blocked = !acs.isJoiner('want'); deleter = acs.isDeleter(); + writer = acs.isWriter(); } } - webrtc = !!this.tinode.getServerParam('iceServers'); + webrtc = writer && !!this.tinode.getServerParam('iceServers'); + const mediaServerAvailable = !!this.tinode.getServerParam('vcEndpoint'); return [ subscribed ? { @@ -1616,8 +1619,9 @@ class TinodeWeb extends React.Component { title: this.props.intl.formatMessage(messages.menu_item_audio_call), handler: this.handleStartAudioCall } : null, - subscribed && Tinode.isP2PTopicName(topicName) && webrtc ? { + subscribed && webrtc ? { title: this.props.intl.formatMessage(messages.menu_item_video_call), + disabled: Tinode.isGroupTopicName(topicName) && !mediaServerAvailable, handler: this.handleStartVideoCall } : null, subscribed ? 'messages_clear' : null, @@ -1772,13 +1776,19 @@ class TinodeWeb extends React.Component { switch (callState) { case CALL_STATE_OUTGOING_INITATED: const head = { webrtc: CALL_HEAD_STARTED, aonly: !!audioOnly }; - this.handleSendMessage(Drafty.videoCall(audioOnly), undefined, undefined, head) + let msg = Drafty.videoCall(audioOnly); + if (Tinode.isGroupTopicName(callTopic)) { + // Video conference. + head.vc = true; + } + return this.handleSendMessage(msg, undefined, undefined, head) .then(ctrl => { if (ctrl.code < 200 || ctrl.code >= 300 || !ctrl.params || !ctrl.params.seq) { this.handleCallClose(); - return; + return ctrl; } this.setState({callSeq: ctrl.params['seq']}); + return ctrl }); break; case CALL_STATE_IN_PROGRESS: @@ -1836,6 +1846,15 @@ class TinodeWeb extends React.Component { topic.videoCall('answer', callSeq, sdp); } + handleVCCallJoin(callTopic, callSeq) { + const topic = this.tinode.getTopic(callTopic); + if (!topic) { + return; + } + + topic.videoCall('vc-join', callSeq); + } + handleCallClose() { if (this.callTimeoutTimer) { clearTimeout(this.callTimeoutTimer); @@ -1847,16 +1866,21 @@ class TinodeWeb extends React.Component { }); } - handleCallAccept(topicName) { + handleCallAccept(topicName, setCallTopic, callSeq) { const topic = this.tinode.getTopic(topicName); if (!topic) { return; } if (topic.isSubscribed()) { - this.handleTopicSelected(this.state.callTopic); - this.setState({ + this.handleTopicSelected(setCallTopic ? topicName : this.state.callTopic); + let upd = { callState: CALL_STATE_IN_PROGRESS - }); + } + if (setCallTopic) { + upd.callTopic = topicName; + upd.callSeq = callSeq; + } + this.setState(upd); } else { // We need to switch and subscribe to callTopic first. this.setState({ @@ -1894,7 +1918,7 @@ class TinodeWeb extends React.Component { } handleDataMessage(data) { - if (data.head && data.head.webrtc && data.head.webrtc == CALL_HEAD_STARTED) { + if (data.head && data.head.webrtc && data.head.webrtc == CALL_HEAD_STARTED && Tinode.isP2PTopicName(data.topic)) { // If it's a video call invite message. // See if we need to display incoming call UI. const topic = this.tinode.getTopic(data.topic); @@ -2089,11 +2113,13 @@ class TinodeWeb extends React.Component { callState={this.state.callState} callAudioOnly={this.state.callAudioOnly} onCallHangup={this.handleCallHangup} + onAcceptCall={this.handleCallAccept} onCallInvite={this.handleCallInvite} onCallSendOffer={this.handleCallSendOffer} onCallIceCandidate={this.handleCallIceCandidate} onCallSendAnswer={this.handleCallSendAnswer} + onVCJoin={this.handleVCCallJoin} errorText={this.state.errorText} errorLevel={this.state.errorLevel} diff --git a/src/widgets/call-message.jsx b/src/widgets/call-message.jsx index 650161ec..68dc5610 100644 --- a/src/widgets/call-message.jsx +++ b/src/widgets/call-message.jsx @@ -7,11 +7,17 @@ import { secondsToTime } from '../lib/strformat.js'; export default class CallMessage extends React.PureComponent { render() { + const bigIcon = this.props.vc ? 'video_call' : 'call'; const isCallDropped = ['busy', 'declined', 'disconnected', 'missed'].includes(this.props.callState); const successClass = 'material-icons medium ' + (isCallDropped ? 'red' : 'green'); const callIcon = this.props.incoming ? (isCallDropped ? 'call_missed' : 'call_received') : (isCallDropped ? 'call_missed_outgoing' : 'call_made'); + const title = this.props.vc ? + : + this.props.incoming ? + : + ; let duration; if (isCallDropped) { switch (this.props.callState) { @@ -33,14 +39,15 @@ export default class CallMessage extends React.PureComponent { } else { duration = {secondsToTime(this.props.duration / 1000)}; } + const allowJoin = this.props.vc && this.props.callState == 'started'; return
-
call
+
{bigIcon}
-
{this.props.incoming ? - : - - }
+
{title}
{callIcon} {duration}
+ {allowJoin ? + Join + : null}
; } diff --git a/src/widgets/chat-message.jsx b/src/widgets/chat-message.jsx index 39cba7d3..c44c0c0d 100644 --- a/src/widgets/chat-message.jsx +++ b/src/widgets/chat-message.jsx @@ -29,6 +29,7 @@ class BaseChatMessage extends React.PureComponent { this.handleContextClick = this.handleContextClick.bind(this); this.handleCancelUpload = this.handleCancelUpload.bind(this); this.handleQuoteClick = this.handleQuoteClick.bind(this); + this.handleCallJoin = this.handleCallJoin.bind(this); this.formatterContext = { formatMessage: props.intl.formatMessage.bind(props.intl), @@ -38,7 +39,8 @@ class BaseChatMessage extends React.PureComponent { onImagePreview: this.handleExpandImage, onVideoPreview: this.handlePlayVideo, onFormButtonClick: this.handleFormButtonClick, - onQuoteClick: this.handleQuoteClick + onQuoteClick: this.handleQuoteClick, + onCallJoin: this.handleCallJoin }; } @@ -149,6 +151,10 @@ class BaseChatMessage extends React.PureComponent { } } + handleCallJoin() { + this.props.onAcceptCall(this.props.topic, true, this.props.seq); + } + render() { const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'); const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble'; diff --git a/src/widgets/context-menu.jsx b/src/widgets/context-menu.jsx index f6b07e8f..d519feac 100644 --- a/src/widgets/context-menu.jsx +++ b/src/widgets/context-menu.jsx @@ -493,11 +493,12 @@ class ContextMenu extends React.Component { item = this.MenuItems[item]; } if (item && item.title) { + const className = item.disabled ? 'disabled' : undefined; menu.push( item.title == '-' ?
  • : -
  • {item.title}
  • +
  • {item.title}
  • ); } count++; diff --git a/src/widgets/vc-carousel-item.jsx b/src/widgets/vc-carousel-item.jsx new file mode 100644 index 00000000..2ebabf4e --- /dev/null +++ b/src/widgets/vc-carousel-item.jsx @@ -0,0 +1,94 @@ +// Video conference carousel item. +import React from 'react'; +import { injectIntl } from 'react-intl'; +import { + Track, +} from 'livekit-client'; + +import LetterTile from './letter-tile.jsx'; + +class VCCarouselItem extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + videoRef: undefined, + audioRef: undefined + }; + + this.handleVideoRefChange = this.handleVideoRefChange.bind(this); + this.handleAudioRefChange = this.handleAudioRefChange.bind(this); + } + + componentDidUpdate(prevProps) { + if (this.state.videoRef && !this.state.videoRef.srcObject && this.props.cameraPub && this.props.cameraPub.videoTrack) { + this.props.cameraPub.videoTrack.attach(this.state.videoRef); + } + if (this.state.audioRef && !this.state.audioRef.srcObject && this.props.micPub && this.props.micPub.audioTrack) { + this.props.micPub.audioTrack.attach(this.state.audioRef); + } + } + + handleVideoRefChange(node) { + if (!node) { return; } + this.setState({videoRef: node}); + + // Attach video track to video element. + const cameraPub = this.props.cameraPub; + const cameraEnabled = cameraPub && cameraPub.isSubscribed && !cameraPub.isMuted; + if (cameraEnabled && cameraPub.videoTrack) { + cameraPub.videoTrack.attach(node); + } + } + + handleAudioRefChange(node) { + if (!node) { return; } + this.setState({audioRef: node}); + + // Attach audio track to audio element. + const micPub = this.props.micPub; + const micEnabled = micPub && micPub.isSubscribed && !micPub.isMuted; + if (micEnabled && this.state.audioTrack) { + this.state.audioTrack.attach(node); + } + } + + render() { + const name = this.props.name; + let classes = "call-party carousel-item"; + if (this.props.isSpeaking) { + classes += " call-party-speaking"; + } + + const micPub = this.props.micPub; + const cameraPub = this.props.cameraPub; + return ( +
    + <> + + + {micPub && micPub.isMuted ? + mic_off + : null + } + {cameraPub && cameraPub.isMuted ? + <> +
    + +
    + + : null} +
    + {this.props.name} +
    + +
    + ); + } +} + +export default injectIntl(VCCarouselItem); diff --git a/src/widgets/vc-carousel.jsx b/src/widgets/vc-carousel.jsx new file mode 100644 index 00000000..204175af --- /dev/null +++ b/src/widgets/vc-carousel.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { injectIntl } from 'react-intl'; +import { + Track, +} from 'livekit-client'; + +import LetterTile from './letter-tile.jsx'; +import VCCarouselItem from './vc-carousel-item.jsx'; + +class VCCarousel extends React.Component { + constructor(props) { + super(props); + this.handlePrevClick = this.handlePrevClick.bind(this); + this.handleNextClick = this.handleNextClick.bind(this); + + this.carouselRef = React.createRef(); + this.contentRef = React.createRef(); + this.prevRef = React.createRef(); + this.nextRef = React.createRef(); + } + + handlePrevClick() { + const gap = 16; + const width = this.carouselRef.current.offsetWidth; + this.carouselRef.current.scrollBy(-(width + gap), 0); + if (this.carouselRef.current.scrollLeft - width - gap <= 0) { + this.prevRef.current.style.display = "none"; + } + if (!this.contentRef.current.scrollWidth - width - gap <= this.carouselRef.current.scrollLeft + width) { + this.nextRef.current.style.display = "flex"; + } + } + + handleNextClick() { + const gap = 16; + const width = this.carouselRef.current.offsetWidth; + this.carouselRef.current.scrollBy(width + gap, 0); + if (this.carouselRef.current.scrollWidth !== 0) { + this.prevRef.current.style.display = "flex"; + } + if (this.contentRef.current.scrollWidth - width - gap <= this.carouselRef.current.scrollLeft + width) { + this.nextRef.current.style.display = "none"; + } + } + + render() { + let peers = []; + if (this.props.participants) { + Object.entries(this.props.participants).forEach(([identity, party], i) => { + const participant = party.participant; + const cameraPub = participant.getTrack(Track.Source.Camera); + const micPub = participant.getTrack(Track.Source.Microphone); + + peers.push(); + }); + } + return ( + <> + + + ); + } +} + +export default injectIntl(VCCarousel); diff --git a/src/widgets/vc-panel.jsx b/src/widgets/vc-panel.jsx new file mode 100644 index 00000000..ed7401fa --- /dev/null +++ b/src/widgets/vc-panel.jsx @@ -0,0 +1,424 @@ +// Video conference or broadcast: local and/or peer viewports and controls. +import React from 'react'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { + connect, + LocalParticipant, + MediaDeviceFailure, + Participant, + ParticipantEvent, + RemoteParticipant, + RemoteTrack, + RemoteTrackPublication, + Room, + RoomEvent, + Track, + VideoPresets, +} from 'livekit-client'; + +import LetterTile from './letter-tile.jsx'; +import VCCarousel from './vc-carousel.jsx'; +import { makeImageUrl } from '../lib/blob-helpers.js'; + +import { MAX_PEER_TITLE_LENGTH } from '../config.js'; +import { CALL_STATE_OUTGOING_INITATED } from '../constants.js'; + +import { clipStr } from '../lib/utils.js' + +class VCPanel extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + room: undefined, + + participants: {}, + activeSpeaker: undefined, + + previousOnInfo: undefined, + }; + + this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED; + const topic = this.props.tinode.getTopic(this.props.topic); + this.isBroadcast = topic && topic.chan; + + this.localRef = React.createRef(); + this.remoteRef = React.createRef(); + + this.onInfo = this.onInfo.bind(this); + this.start = this.start.bind(this); + this.stop = this.stop.bind(this); + + + this.reportError = this.reportError.bind(this); + + this.handleCloseClick = this.handleCloseClick.bind(this); + this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this); + this.handleToggleMicClick = this.handleToggleMicClick.bind(this); + + this.handleRemoteHangup = this.handleRemoteHangup.bind(this); + + this.handleTrackSubscribed2 = this.handleTrackSubscribed2.bind(this); + this.handleTrackUnsubscribed2 = this.handleTrackUnsubscribed2.bind(this); + this.handleActiveSpeakerChange2 = this.handleActiveSpeakerChange2.bind(this); + this.handleDisconnect2 = this.handleDisconnect2.bind(this); + this.handleLocalTrackUnpublished2 = this.handleLocalTrackUnpublished2.bind(this); + this.handleLocalTrackPublished2 = this.handleLocalTrackPublished2.bind(this); + this.handleParticipantConnected2 = this.handleParticipantConnected2.bind(this); + this.handleParticipantDisconnected2 = this.handleParticipantDisconnected2.bind(this); + this.handleData2 = this.handleData2.bind(this); + this.handleDevicesChanged = this.handleDevicesChanged.bind(this); + this.startRoom = this.startRoom.bind(this); + + this.participants = {}; + } + + componentDidMount() { + const topic = this.props.tinode.getTopic(this.props.topic); + this.previousOnInfo = topic.onInfo; + topic.onInfo = this.onInfo; + this.start(); + } + + componentWillUnmount() { + const topic = this.props.tinode.getTopic(this.props.topic); + topic.onInfo = this.previousOnInfo; + this.stop(); + + } + + onInfo(info) { + if (info.what != 'call') { + return; + } + switch (info.event) { + case 'vc-token': + const ts = info.payload; + this.setState({token: ts.token}); + this.startRoom(ts.token); + break; + case 'hang-up': + this.handleRemoteHangup(info); + break; + default: + console.warn("Unknown call event", info.event); + break; + } + } + + + handleParticipantConnected2(participant) { + if (!participant) return; + if (participant instanceof RemoteParticipant) { + participant + .on(ParticipantEvent.TrackMuted, pub => { + console.log('track was muted', pub.trackSid, participant.identity); + this.forceUpdate(); // Redraw carousel. + }) + .on(ParticipantEvent.TrackUnmuted, pub => { + console.log('track was unmuted', pub.trackSid, participant.identity); + this.forceUpdate(); // Redraw carousel. + }) + .on(ParticipantEvent.IsSpeakingChanged, _ => { + this.forceUpdate(); // Redraw carousel. + }) + .on(ParticipantEvent.ConnectionQualityChanged, _ => { + this.forceUpdate(); // Redraw carousel. + }); + + const me = this.props.tinode.getMeTopic(); + const c = me.getContact(participant.identity); + const name = c && c.public && c.public.fn ? c.public.fn : participant.identity; + const photo = makeImageUrl(c && c.public ? c.public.photo : null); + const p = { + name: name, + photo: photo, + participant: participant + }; + this.participants[participant.identity] = p + console.log('participant connected', this.participants); + this.setState({participants: this.participants}, _ => { this.forceUpdate(); }); + } + } + + handleParticipantDisconnected2(participant) { + console.log('Deleting participant ', participant.identity); + delete this.participants[participant.identity]; + this.setState({participants: this.participants}); + } + + handleData2(msg, participant) { + console.log('Data message ', msg, participant); + } + + handleTrackSubscribed2(track, publication, participant) { + if (participant instanceof RemoteParticipant) { + // Check if we're presenting a remote video stream. + if (this.remoteRef.current && !(this.remoteRef.current.src != '' || this.remoteRef.srcObject)) { + track.attach(this.remoteRef.current); + } + } + // Redraw the carousel and remote view. + this.forceUpdate(); + } + + handleTrackUnsubscribed2(track, publication, participant) { + console.log('handleTrackUnsubscribed2', track); + // remove tracks from all attached elements + track.detach(); + this.forceUpdate(); + } + + handleLocalTrackPublished2(pub) { + console.log('Local track published', pub); + const track = pub.track; + if (this.localRef.current) { + const elem = this.localRef.current; + track.attach(elem); + // flip. + elem.style.transform = 'scale(-1, 1)'; + } + } + + handleLocalTrackUnpublished2(pub) { + const track = pub.track; + console.log('handleLocalTrackUnpublished2', track); + track.detach(); + // Redraw carousel. + this.forceUpdate(); + } + + handleActiveSpeakerChange2(speakers) { + console.log('handleActiveSpeakerChange2', speakers); + speakers.forEach(s => { + if (s.isSpeaking) { + console.log('Speaking participant ', s); + + // Display speaker in remote peer view. + const elem = this.remoteRef.current; + const me = this.props.tinode.getMeTopic(); + const c = me.getContact(s.identity); + const name = c && c.public && c.public.fn ? c.public.fn : undefined; + this.setState({activeSpeaker: name}); + + const cameraPub = s.getTrack(Track.Source.Camera); + const cameraEnabled = cameraPub && cameraPub.isSubscribed && !cameraPub.isMuted; + if (cameraEnabled && cameraPub.videoTrack) { + cameraPub.videoTrack.attach(elem); + } + } + }); + } + + handleDevicesChanged() { + console.log('devices changed'); + // TODO: implement. + } + + handleDisconnect2() { + console.log('disconnected from room'); + this.handleCloseClick(); + } + + startRoom(token) { + const roomOpts = { + adaptiveStream: true, + dynacast: true, + publishDefaults: { + simulcast: true, + videoSimulcastLayers: [VideoPresets.h90, VideoPresets.h216], + videoCodec: 'vp8' + }, + videoCaptureDefaults: { + resolution: VideoPresets.h720.resolution + } + }; + + const connectOpts = { + autoSubscribe: true + }; + + // creates a new room with options + const room = new Room(roomOpts); + + this.setState({room: room}); + + const startTime = Date.now(); + const topic = this.props.tinode.getTopic(this.props.topic); + const shouldPublish = this.isOutgoingCall || !this.isBroadcast; + + room + .on(RoomEvent.ParticipantConnected, this.handleParticipantConnected2) + .on(RoomEvent.ParticipantDisconnected, this.handleParticipantDisconnected2) + .on(RoomEvent.DataReceived, this.handleData2) + .on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed2) + .on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed2) + .on(RoomEvent.ActiveSpeakersChanged, this.handleActiveSpeakerChange2) + .on(RoomEvent.Disconnected, this.handleDisconnect2) + .on(RoomEvent.LocalTrackUnpublished, this.handleLocalTrackUnpublished2) + .on(RoomEvent.LocalTrackPublished, this.handleLocalTrackPublished2) + + .on(RoomEvent.RoomMetadataChanged, metadata => { + console.log('VC room new metadata ', metadata); + }) + .on(RoomEvent.MediaDevicesChanged, this.handleDevicesChanged) + .on(RoomEvent.AudioPlaybackStatusChanged, e => { + // TODO: handle it. + console.log('Audio playback status change: ', e); + }) + .on(RoomEvent.MediaDevicesError, e => { + const failure = MediaDeviceFailure.getFailure(e); + this.reportError(failure); + }) + .on(RoomEvent.ConnectionQualityChanged, (quality, participant) => { + const id = participant ? participant.identity : 'nil'; + console.log('Connection quality changed', id, ' -> ', quality); + }) + .on(RoomEvent.SignalConnected, _ => { + const signalConnectionTime = Date.now() - startTime; + console.log(`signal connection established in ${signalConnectionTime}ms`); + // speed up publishing by starting to publish before it's fully connected + // publishing is accepted as soon as signal connection has established + if (shouldPublish) { + room.localParticipant.enableCameraAndMicrophone(); + console.log(`tracks published in ${Date.now() - startTime}ms`); + } + }); + + const vcEndpoint = this.props.tinode.getServerParam('vcEndpoint', null); + room.connect(vcEndpoint, token, connectOpts).then(_ => { + console.log('Connected to VC room', room.name, '. Access token', token); + + room.participants.forEach((participant) => { + this.handleParticipantConnected2(participant); + }); + this.handleParticipantConnected2(room.localParticipant); + }); + + } + + start() { + if (this.state.localStream) { + this.props.onError(this.props.intl.formatMessage(messages.already_in_call), 'info'); + return; + } + if (this.isOutgoingCall) { + this.props.onCreateCall(this.props.topic, this.props.seq, this.props.callState, this.props.callAudioOnly) + .then(ctrl => { + const token = ctrl.params.token; + const seq = ctrl.params.seq; + this.setState({callSeq: seq, token: token}); + this.startRoom(token); + }); + } else { + this.props.onJoin(this.props.topic, this.props.seq); + } + } + + stop() { + if (this.state.room) { + this.state.room.disconnect(); + } + } + + reportError(err) { + this.props.onError(err.message, 'err'); + } + + // Call disconnected by remote. + handleRemoteHangup() { + this.handleCloseClick(); + } + + handleCloseClick() { + this.stop(); + this.props.onHangup(this.props.topic, this.props.seq); + } + + handleToggleCameraClick() { + if (!this.state.room) return; + const enabled = this.state.room.localParticipant.isCameraEnabled; + this.state.room.localParticipant.setCameraEnabled(!enabled) + .then(_ => this.forceUpdate()); + this.forceUpdate(); + } + + handleToggleMicClick() { + if (!this.state.room) return; + const enabled = this.state.room.localParticipant.isMicrophoneEnabled; + this.state.room.localParticipant.setMicrophoneEnabled(!enabled) + .then(_ => this.forceUpdate()); + + // Make sure we redraw the mute/unmute icons (e.g. mic -> mic_off). + this.forceUpdate(); + } + + render() { + const isPublishing = this.isOutgoingCall || !this.isBroadcast; + const disabled = !isPublishing || !this.state.room || !this.state.room.localParticipant; + const audioIcon = !disabled && this.state.room.localParticipant.isMicrophoneEnabled ? 'mic' : 'mic_off'; + const videoIcon = !disabled && this.state.room.localParticipant.isCameraEnabled ? 'videocam' : 'videocam_off'; + + const peerTitle = this.state.activeSpeaker ? this.state.activeSpeaker : clipStr(this.props.title, MAX_PEER_TITLE_LENGTH); + + const remoteActive = this.remoteRef.current && (this.remoteRef.current.src != '' || this.remoteRef.current.srcObject) + + return ( + <> +
    + { !this.isBroadcast && Object.keys(this.state.participants).length > 0 ? + : + null + } +
    + { isPublishing ? +
    +
    : + null } +
    +
    +
    +
    + + + +
    +
    + + ); + } +}; + +export default injectIntl(VCPanel);