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 }
+
+
+ {remoteActive ?
+ <>
+
{peerTitle}
+ > :
+ <>
+
+ >
+ }
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+};
+
+export default injectIntl(VCPanel);