// CallPanel displays call in progress: local and remote viewports and controls. import React from 'react'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import LetterTile from './letter-tile.jsx'; import { MAX_PEER_TITLE_LENGTH } from '../config.js'; import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js'; import { truncateString } from '../lib/strformat.js'; const RING_SOUND = new Audio('audio/call-out.m4a'); RING_SOUND.loop = true; const CALL_ENDED_SOUND = new Audio('audio/call-end.m4a'); CALL_ENDED_SOUND.loop = true; const DIALING_SOUND = new Audio('audio/dialing.m4a'); const VIDEO_MUTED_EVENT = 'video:muted'; const VIDEO_UNMUTED_EVENT = 'video:unmuted'; // See handleVideoOfferMsg's use of this below. const UNMOUNTED_DURING_OFFER = Symbol('unmounted-during-offer'); function formatElapsedTime(totalSeconds) { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; const pad = n => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; } const messages = defineMessages({ already_in_call: { id: 'already_in_call', defaultMessage: 'You already in an ongoing call!', description: 'Error message when the user tried to accept a new call without finishing pervious one', } }); class CallPanel extends React.PureComponent { constructor(props) { super(props); this.state = { localStream: undefined, remoteStream: undefined, pc: undefined, dataChannel: undefined, previousOnInfo: undefined, waitingForPeer: false, // If true, the client has received a remote SDP from the peer and has sent a local SDP to the peer. callInitialSetupComplete: false, audioOnly: props.callAudioOnly, // Video mute/unmute in progress. videoToggleInProgress: false, // Indicates if the remote peer has informed us that their camera is on. remoteVideoLive: false, // Seconds elapsed since the call was connected (remote media received). elapsedSeconds: 0, // Whether remote audio is routed to the loud speaker output (best-effort, see handleToggleSpeakerClick). speakerOn: true, }; this.callStartedAt = null; this.elapsedTimerId = null; this.localStreamConstraints = { audio: true, video: !props.callAudioOnly }; this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED; this.containerRef = React.createRef(); this.selfBoxRef = React.createRef(); this.localRef = React.createRef(); this.remoteRef = React.createRef(); // Cache for remote ice candidates until initial setup gets completed. this.remoteIceCandidatesCache = []; this.onInfo = this.onInfo.bind(this); this.start = this.start.bind(this); this.stop = this.stop.bind(this); this.createPeerConnection = this.createPeerConnection.bind(this); this.canSendOffer = this.canSendOffer.bind(this); this.drainRemoteIceCandidatesCache = this.drainRemoteIceCandidatesCache.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.handleIceCandidateErrorEvent = this.handleIceCandidateErrorEvent.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.stopTracks = this.stopTracks.bind(this); this.disconnectMedia = this.disconnectMedia.bind(this); this.handleCloseClick = this.handleCloseClick.bind(this); this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this); this.handleToggleMicClick = this.handleToggleMicClick.bind(this); this.handleToggleSpeakerClick = this.handleToggleSpeakerClick.bind(this); this.handleSwitchCameraClick = this.handleSwitchCameraClick.bind(this); this.startElapsedTimer = this.startElapsedTimer.bind(this); this.stopElapsedTimer = this.stopElapsedTimer.bind(this); this.handleRemoteHangup = this.handleRemoteHangup.bind(this); this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this); this.muteVideo = this.muteVideo.bind(this); this.unmuteVideo = this.unmuteVideo.bind(this); this.emptyVideoTrack = this.emptyVideoTrack.bind(this); this.handleDataChannelEvent = this.handleDataChannelEvent.bind(this); this.handleDataChannelError = this.handleDataChannelError.bind(this); this.handleDataChannelMessage = this.handleDataChannelMessage.bind(this); this.handleDataChannelOpen = this.handleDataChannelOpen.bind(this); this.handleDataChannelClose = this.handleDataChannelClose.bind(this); this.bounds = {}; // Which element the in-progress drag is moving: 'container' for the whole minimized bubble, // 'self' for just the local self-view preview box inside a fullscreen connected video call. // Null when no drag is in progress (also doubles as the pointermove/pointerup no-op guard). this.dragTarget = null; this.dragStart = (event) => { if (!event.target.closest('.draggable')) { // Prevent dragging when clicking on buttons or other non-draggable elements. return; } if (window.matchMedia('(width<=640px)').matches) { // No dragging on small screens. return; } if (this.props.minimized && this.containerRef) { event.stopPropagation(); const el = this.containerRef.current; el.setPointerCapture(event.pointerId); el.style.cursor = 'grabbing'; this.bounds.left = el.offsetLeft; this.bounds.top = el.offsetTop; this.bounds.width = el.offsetWidth; this.bounds.height = el.offsetHeight; // Switch from right/auto-left CSS positioning to explicit left/top so drag() can move the element freely. el.style.right = 'auto'; el.style.left = `${this.bounds.left}px`; el.style.top = `${this.bounds.top}px`; const parent = el.offsetParent; this.bounds.maxX = parent.offsetWidth; this.bounds.maxY = parent.offsetHeight; this.dragTarget = 'container'; return; } // Fullscreen connected video call: let the user drag their own small self-view preview // out of the way instead of it being stuck wherever it defaults to (spec 8.9 "双方视频 // 通话中": "小窗应支持拖动且不能遮挡关键按钮"). Only relevant when the box actually exists // (base.css hides it entirely for audio-only calls / while minimized, and now also while // still ringing -- see the render()'s `disabled` prop just below -- via // .call-party.self[disabled]) and only when the drag actually starts on that box, not on // the peer's video/avatar card behind it. The `this.state.remoteStream` check mirrors // render()'s own `connected` gate: without it, a pointerdown during the pre-connect ringing // phase (box still hidden by CSS, but not yet [disabled] before this fix) would already set // dragTarget='self' and start moving an invisible box. if (!this.props.minimized && !this.state.audioOnly && this.state.remoteStream && this.selfBoxRef.current && event.target.closest('.call-party.self')) { event.stopPropagation(); const el = this.selfBoxRef.current; // Pointer events are captured on the outer container (same element the listeners below // are attached to) rather than the box itself, purely so drag()/dragEnd() below have one // consistent place to check hasPointerCapture() regardless of which element is moving. this.containerRef.current.setPointerCapture(event.pointerId); el.style.cursor = 'grabbing'; this.bounds.left = el.offsetLeft; this.bounds.top = el.offsetTop; this.bounds.width = el.offsetWidth; this.bounds.height = el.offsetHeight; // Switch from right/bottom CSS positioning to explicit left/top so drag() can move it freely. el.style.right = 'auto'; el.style.bottom = 'auto'; el.style.left = `${this.bounds.left}px`; el.style.top = `${this.bounds.top}px`; const parent = el.offsetParent; this.bounds.maxX = parent.offsetWidth; this.bounds.maxY = parent.offsetHeight; this.dragTarget = 'self'; } }; this.drag = (event) => { event.stopPropagation(); const container = this.containerRef.current; if (!this.dragTarget || !container.hasPointerCapture(event.pointerId)) { return; } const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container; if (!el) { return; } // Calculate new position. Restrict to the visible area of the parent element. this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0), this.bounds.maxX - this.bounds.width); this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0), this.bounds.maxY - this.bounds.height); el.style.left = `${this.bounds.left}px`; el.style.top = `${this.bounds.top}px`; }; this.dragEnd = (event) => { event.stopPropagation(); const container = this.containerRef.current; if (!this.dragTarget) { return; } const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container; if (container && container.hasPointerCapture(event.pointerId)) { container.releasePointerCapture(event.pointerId); } if (el) { el.style.cursor = 'grab'; } this.dragTarget = null; }; // dragStart's inline left/top/right (set the first time the minimized bubble is dragged, see // above) always win over base.css's #video-container.minimized:not(.audio-minimized) mobile // rule -- inline style beats any stylesheet selector regardless of media query. A desktop user // who drags the bubble somewhere and then narrows the window below 640px (a real everyday // action -- snapping the browser to half the screen, say) would otherwise be stuck with the // bubble pinned at its old desktop-width position/size instead of docking as the intended // mobile full-width strip. Clearing the manual offsets once the mobile breakpoint applies lets // the stylesheet rule take back over; harmless (a no-op) whenever nothing was ever dragged. // Same idea for the self-view box's own drag above, keyed off its own (768px) breakpoint // (base.css ".call-party.self" mobile rule only ever touches `bottom`, but a stale inline // left/top from a desktop-width drag would still leave it sitting at the wrong height once // that rule's extra bottom clearance was meant to apply). this.handleWindowResize = () => { if (this.props.minimized && this.containerRef.current && window.matchMedia('(width<=640px)').matches) { const el = this.containerRef.current; el.style.removeProperty('left'); el.style.removeProperty('right'); el.style.removeProperty('top'); } if (!this.props.minimized && this.selfBoxRef.current && window.matchMedia('(width<=768px)').matches) { const el = this.selfBoxRef.current; el.style.removeProperty('left'); el.style.removeProperty('right'); el.style.removeProperty('top'); el.style.removeProperty('bottom'); } }; } componentDidMount() { const topic = this.props.tinode.getTopic(this.props.topic); this.previousOnInfo = topic.onInfo; topic.onInfo = this.onInfo; if ((this.props.callState == CALL_STATE_OUTGOING_INITATED || this.props.callState == CALL_STATE_IN_PROGRESS) && this.localRef.current) { this.start(); } if (this.containerRef) { this.containerRef.current.addEventListener('pointerdown', this.dragStart); this.containerRef.current.addEventListener('pointermove', this.drag); this.containerRef.current.addEventListener('pointerup', this.dragEnd); } window.addEventListener('resize', this.handleWindowResize); } componentWillUnmount() { this._unmounted = true; const topic = this.props.tinode.getTopic(this.props.topic); topic.onInfo = this.previousOnInfo; this.stop(); window.removeEventListener('resize', this.handleWindowResize); if (this.containerRef) { this.containerRef.current.removeEventListener('pointerdown', this.dragStart); this.containerRef.current.removeEventListener('pointermove', this.drag); this.containerRef.current.removeEventListener('pointerup', this.dragEnd); } } handleVideoCallAccepted(info) { RING_SOUND.pause(); // Create peer connection with a data channel. const pc = this.createPeerConnection(true); const stream = this.state.localStream; stream.getTracks().forEach(track => { pc.addTrack(track, stream); if (track.kind == 'video' && this.state.audioOnly) { // This is an audio-only call. // Remove dummy video track (placeholder remains). track.enabled = false; track.stop(); stream.removeTrack(track); } }); } onInfo(info) { if (info.what != 'call') { // This call panel takes over topic.onInfo wholesale for the lifetime of the call (including // while minimized -- the product lets messaging continue during a call), but was only ever // handling 'call' events itself and dropping everything else on the floor instead of forwarding // to the handler it displaced (messages-view.jsx's handleInfoReceipt, which drives the "typing" // indicator and read/received checkmarks). Real-time {info} isn't replayed later, so every // typing/read/recv update for this topic was silently lost for as long as the panel stayed // mounted. if (this.previousOnInfo) { this.previousOnInfo(info); } return; } switch (info.event) { case 'accept': this.handleVideoCallAccepted(info); break; case 'answer': this.handleVideoAnswerMsg(info); break; case 'ice-candidate': this.handleNewICECandidateMsg(info); break; case 'hang-up': this.handleRemoteHangup(info); break; case 'offer': this.handleVideoOfferMsg(info); break; case 'ringing': // play() throws if the user did not click the app first: https://goo.gl/xX8pDD. RING_SOUND.play().catch(_ => {}); break; default: console.warn("Unknown call event", info.event); break; } } // Creates an empty video track placeholder. emptyVideoTrack() { const width = 640; const height = 480; const canvas = Object.assign(document.createElement("canvas"), {width, height}); canvas.getContext('2d').fillRect(0, 0, width, height); const stream = canvas.captureStream(0); return Object.assign(stream.getVideoTracks()[0], {enabled: false}); } start() { if (this.state.localStream) { this.props.onError(this.props.intl.formatMessage(messages.already_in_call), 'info'); return; } if (this.props.callState == CALL_STATE_IN_PROGRESS) { // We apparently just accepted the call. this.props.onInvite(this.props.topic, this.props.seq, CALL_STATE_IN_PROGRESS, this.props.callAudioOnly); return; } // This is an outgoing call waiting for the other side to pick up. // Start local video. navigator.mediaDevices.getUserMedia(this.localStreamConstraints) .then(stream => { // The permission prompt this getUserMedia call triggers can still be pending when the call // is torn down from elsewhere (the ring-timeout hangup, a hangup note from the peer, or the // messages-view.jsx componentWillUnmount hangup added this round) -- resolving after that // touches this.localRef.current (already null post-unmount, so the srcObject assignment // below would throw) and would otherwise go on to announce an invite for a call nothing is // showing anymore. if (this._unmounted) { stream.getTracks().forEach(t => t.stop()); return; } if (!this.localStreamConstraints.video) { // Starting an audio-only call. Create a dummy video track // (so video can be enabled during the call if the user desires). stream.addTrack(this.emptyVideoTrack()); } this.setState({localStream: stream, waitingForPeer: true}); this.localRef.current.srcObject = stream; DIALING_SOUND.play(); // Send call invitation. this.props.onInvite(this.props.topic, this.props.seq, this.props.callState, this.props.callAudioOnly); }) .catch(this.handleGetUserMediaError); } stop() { CALL_ENDED_SOUND.pause(); CALL_ENDED_SOUND.currentTime = 0; RING_SOUND.pause(); RING_SOUND.currentTime = 0; this.stopElapsedTimer(); this.stopTracks(this.state.localStream); this.stopTracks(this.state.remoteStream); this.disconnectMedia(this.localRef.current); this.disconnectMedia(this.remoteRef.current); if (this.state.pc) { 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.onicecandidateerror = null; this.state.pc.ondatachannel = null; if (this.state.dataChannel && (this.state.dataChannel.readyState == 'open' || this.state.dataChannel.readyState == 'connecting')) { this.state.dataChannel.close(); } this.state.pc.close(); } this.setState({pc: null, waitingForPeer: false}); } disconnectMedia(el) { if (!el) { return; } el.srcObject = null; el.src = ''; } stopTracks(stream) { if (!stream) { return; } let tracks = stream.getTracks(); if (tracks) { tracks.forEach(track => { track.stop(); track.enabled = false; }); } } handleDataChannelError(error) { console.error('data channel error', error); } handleDataChannelMessage(event) { switch (event.data) { case VIDEO_MUTED_EVENT: this.setState({remoteVideoLive: false}, _ => { this.remoteRef.current.srcObject = this.state.remoteStream; }); break; case VIDEO_UNMUTED_EVENT: this.setState({remoteVideoLive: true}, _ => { this.remoteRef.current.srcObject = this.state.remoteStream; }); break; default: break; } } handleDataChannelOpen(event) { if (!this.state.audioOnly) { event.target.send(VIDEO_UNMUTED_EVENT); } } handleDataChannelClose(event) { console.info('close data channel:', event); } handleDataChannelEvent(event) { console.info('data channel event:', event); const channel = event.channel; channel.onerror = this.handleDataChannelError; channel.onmessage = this.handleDataChannelMessage; channel.onopen = this.handleDataChannelOpen; channel.onclose = this.handleDataChannelClose; this.setState({dataChannel: channel}); } createPeerConnection(withDataChannel) { const iceServers = this.props.tinode.getServerParam('iceServers', null); const pc = iceServers ? new RTCPeerConnection({iceServers: iceServers}) : new RTCPeerConnection(); pc.onicecandidate = this.handleICECandidateEvent; pc.oniceconnectionstatechange = this.handleICEConnectionStateChangeEvent; pc.onicegatheringstatechange = this.handleICEGatheringStateChangeEvent; pc.onsignalingstatechange = this.handleSignalingStateChangeEvent; pc.onnegotiationneeded = this.handleNegotiationNeededEvent; pc.onicecandidateerror = this.handleIceCandidateErrorEvent; pc.ontrack = this.handleTrackEvent; pc.ondatachannel = this.handleDataChannelEvent; let stateUpdate = {pc: pc, waitingForPeer: false}; if (withDataChannel) { // Create data channel for exchanging events. const channel = pc.createDataChannel("events", {ordered: true}); channel.onerror = this.handleDataChannelError; channel.onmessage = this.handleDataChannelMessage; channel.onopen = this.handleDataChannelOpen; channel.onclose = this.handleDataChannelClose; stateUpdate.dataChannel = channel; } this.setState(stateUpdate); return pc; } handleVideoAnswerMsg(info) { // Configure the remote description, which is the SDP payload // in 'info' message. const desc = new RTCSessionDescription(info.payload); this.state.pc.setRemoteDescription(desc) .then(_ => { this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache()); }) .catch(err => this.reportError(err)); } reportError(err) { this.props.onError(err.message, 'err'); } canSendOffer() { return this.isOutgoingCall || this.state.callInitialSetupComplete; } handleNegotiationNeededEvent(event) { const pc = event.target; if (!this.canSendOffer()) { return; } pc.createOffer().then(offer => { return pc.setLocalDescription(offer); }) .then(_ => { this.props.onSendOffer(this.props.topic, this.props.seq, pc.localDescription.toJSON()); }) .catch(err => this.reportError(err)); } handleIceCandidateErrorEvent(event) { console.warn("ICE candidate error:", event); } handleICECandidateEvent(event) { if (event.candidate) { this.props.onIceCandidate(this.props.topic, this.props.seq, event.candidate.toJSON()); } } handleNewICECandidateMsg(info) { const candidate = new RTCIceCandidate(info.payload); if (this.state.callInitialSetupComplete) { this.state.pc.addIceCandidate(candidate) .catch(err => { if (candidate.candidate) { this.reportError(err); } console.warn("Error adding new ice candidate", candidate, err); }); } else { this.remoteIceCandidatesCache.push(candidate); } } drainRemoteIceCandidatesCache() { this.remoteIceCandidatesCache.forEach(candidate => { this.state.pc.addIceCandidate(candidate) .catch(err => { if (candidate.candidate) { this.reportError(err); } console.warn("Error adding cached ice candidate", candidate, err); }); }); this.remoteIceCandidatesCache = []; } handleICEConnectionStateChangeEvent(event) { switch (event.target.iceConnectionState) { case 'closed': case 'failed': this.handleCloseClick(); break; } } handleSignalingStateChangeEvent(event) { if (event.target.signalingState == 'closed') { this.handleCloseClick(); } } handleICEGatheringStateChangeEvent(event) { // ICE gathering change state } handleTrackEvent(event) { // Remote video becomes available. this.remoteRef.current.srcObject = event.streams[0]; this.setState({remoteStream: event.streams[0]}); // First remote media (audio or video) means the call is actually connected. this.startElapsedTimer(); } startElapsedTimer() { if (this.elapsedTimerId) { // Already running. return; } this.callStartedAt = Date.now(); this.elapsedTimerId = setInterval(_ => { this.setState({elapsedSeconds: Math.floor((Date.now() - this.callStartedAt) / 1000)}); }, 1000); } stopElapsedTimer() { if (this.elapsedTimerId) { clearInterval(this.elapsedTimerId); this.elapsedTimerId = null; } this.callStartedAt = null; } handleGetUserMediaError(e) { console.error("Error opening camera and/or microphone", e); switch(e.name) { case 'NotFoundError': // Cannot start the call b/c no camera and/or microphone found. this.reportError(e); break; case 'SecurityError': case 'PermissionDeniedError': // Do nothing; this is the same as the user canceling the call. break; default: this.reportError(e); break; } // Make sure we shut down our end of the RTCPeerConnection so we're // ready to try again. this.handleCloseClick(); } handleVideoOfferMsg(info) { let localStream = null; // If needed, create peer connection. // Not creating data channel since it should be created by the peer. const pc = this.state.pc ? this.state.pc : this.createPeerConnection(false); const desc = new RTCSessionDescription(info.payload); pc.setRemoteDescription(desc).then(_ => { return navigator.mediaDevices.getUserMedia(this.localStreamConstraints); }) .then(stream => { // Same permission-prompt-outlives-the-call race as start()/unmuteVideo()/ // handleSwitchCameraClick() -- this is the callee-side counterpart (answering an offer), // a separate getUserMedia call this round's fix missed: start() takes an early-return // branch for an already-in-progress call and never reaches its own getUserMedia here. // A bare `return` wouldn't be enough on its own to bail out of the rest of *this* chain // (createAnswer/setLocalDescription/onSendAnswer below still run on the next tick with // `undefined`) -- rejecting with the sentinel skips straight past all of those (none of // them pass an onRejected handler to .then()) to the .catch() at the end, which checks for // it explicitly so this cancellation isn't reported as a real getUserMedia failure. if (this._unmounted) { stream.getTracks().forEach(t => t.stop()); return Promise.reject(UNMOUNTED_DURING_OFFER); } let dummyVideo; if (!this.localStreamConstraints.video) { // Starting an audio-only call. Create an empty video track so // so the user can enable the video during the call. dummyVideo = this.emptyVideoTrack(); stream.addTrack(dummyVideo); } localStream = stream; this.localRef.current.srcObject = stream; this.setState({localStream: stream}); localStream.getTracks().forEach(track => { pc.addTrack(track, localStream); }); if (dummyVideo) { dummyVideo.enabled = false; dummyVideo.stop(); stream.removeTrack(dummyVideo); } }) .then(_ => { return pc.createAnswer(); }) .then(answer => { return pc.setLocalDescription(answer); }) .then(_ => { this.props.onSendAnswer(this.props.topic, this.props.seq, pc.localDescription.toJSON()); this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache()); }) .catch(err => { if (err !== UNMOUNTED_DURING_OFFER) { this.handleGetUserMediaError(err); } }); } // Call disconnected by remote. handleRemoteHangup() { if (!this.state.waitingForPeer) { // This is live call, just hang up. this.handleCloseClick(); } else { // This is a call which is not yet connected. // Stop pulse animation. this.setState({waitingForPeer: false}); // Change sound and wait a bit before ending it. RING_SOUND.pause(); RING_SOUND.currentTime = 0; CALL_ENDED_SOUND.loop = true; CALL_ENDED_SOUND.play().catch(_ => {}); setTimeout(_ => { this.handleCloseClick(); }, 2000); } } handleCloseClick() { this.stop(); this.props.onHangup(this.props.topic, this.props.seq); } // Ends video track and turns off the camera. Also usable before the peer connection // exists yet (e.g. caller previewing/toggling their camera while waiting for pickup) -- // in that case there is no peer to notify, so the data channel send is skipped. muteVideo() { const stream = this.state.localStream; const t = stream.getVideoTracks()[0]; if (!t) { this.setState({videoToggleInProgress: false}); return; } t.enabled = false; t.stop(); stream.removeTrack(t); this.sendOnDataChannel(VIDEO_MUTED_EVENT); this.setState({videoToggleInProgress: false}); } // Swaps in a fresh local video track (new camera, or re-enabling after mute), hot-swapping // the peer connection's sender if one exists, or adding one if the call never negotiated // video (e.g. started audio-only, or was muted before the peer connection was created -- // handleVideoCallAccepted/handleVideoOfferMsg will pick up whatever is in localStream when // they run). Returns a promise that resolves once the local preview is updated. replaceLocalVideoTrack(track) { this.localRef.current.srcObject = null; // Drop any existing video track (e.g. the disabled placeholder from an audio-only // call, or the camera being switched away from) before adding the fresh one. this.state.localStream.getVideoTracks().forEach(old => this.state.localStream.removeTrack(old)); this.state.localStream.addTrack(track); let result = Promise.resolve(); if (this.state.pc) { const sender = this.state.pc.getSenders().find(s => s.track && s.track.kind == 'video'); if (sender) { result = sender.replaceTrack(track); } else { // No video sender was ever negotiated -- add one now; triggers renegotiation. this.state.pc.addTrack(track, this.state.localStream); } } return result.then(_ => { this.localRef.current.srcObject = this.state.localStream; }); } // Turns the camera back on. Works both before the peer connection exists (the fresh // track is picked up later by handleVideoCallAccepted/handleVideoOfferMsg when they add // all current local tracks to the new pc) and mid-call (hot-swaps or adds a sender). unmuteVideo() { navigator.mediaDevices.getUserMedia({ video: true }) .then(stream => { const track = stream.getVideoTracks()[0]; stream.removeTrack(track); // Same permission-prompt-outlives-the-call race as start() above -- replaceLocalVideoTrack // touches this.localRef.current/this.state.localStream, both invalid once unmounted. if (this._unmounted) { track.stop(); return; } return this.replaceLocalVideoTrack(track); }) .then(_ => { this.sendOnDataChannel(VIDEO_UNMUTED_EVENT); }) .catch(err => this.handleGetUserMediaError(err)) // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off). .finally(_ => { if (!this._unmounted) { this.setState({videoToggleInProgress: false}); } }); } // Data channel exists as soon as the peer connection is created, but isn't actually usable // until the underlying handshake completes -- sending before then throws InvalidStateError. sendOnDataChannel(message) { if (this.state.dataChannel && this.state.dataChannel.readyState == 'open') { this.state.dataChannel.send(message); } } // Cycles to the next available camera (front/back on mobile, or the next webcam on desktop). handleSwitchCameraClick() { if (this.state.videoToggleInProgress || this.state.audioOnly) { return; } const currentTrack = this.state.localStream.getVideoTracks()[0]; const currentDeviceId = currentTrack && currentTrack.getSettings && currentTrack.getSettings().deviceId; this.setState({videoToggleInProgress: true}, _ => { navigator.mediaDevices.enumerateDevices() .then(devices => { const cameras = devices.filter(d => d.kind == 'videoinput'); if (cameras.length < 2) { // Nothing to switch to. return null; } const idx = cameras.findIndex(d => d.deviceId == currentDeviceId); const next = cameras[(idx + 1) % cameras.length]; return navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: next.deviceId } } }); }) .then(stream => { if (!stream) { return null; } const track = stream.getVideoTracks()[0]; stream.removeTrack(track); // Same permission/device-enumeration-outlives-the-call race as start()/unmuteVideo(). if (this._unmounted) { track.stop(); return null; } return this.replaceLocalVideoTrack(track); }) .catch(err => this.handleGetUserMediaError(err)) .finally(_ => { if (!this._unmounted) { this.setState({videoToggleInProgress: false}); } }); }); } handleToggleCameraClick() { if (this.state.videoToggleInProgress) { // Toggle currently in progress. return; } const tracks = this.state.localStream.getVideoTracks(); this.setState({videoToggleInProgress: true}, _ => { if (tracks && tracks.length > 0 && tracks[0].enabled && tracks[0].readyState == 'live') { this.muteVideo(); } else { this.unmuteVideo(); } this.setState({audioOnly: !this.state.audioOnly}); }); } handleToggleMicClick() { const stream = this.state.localStream; const t = stream.getAudioTracks()[0]; t.enabled = !t.enabled; // Make sure we redraw the mute/unmute icons (e.g. mic -> mic_off). this.forceUpdate(); } static speakerSwitchingSupported() { return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype; } handleToggleSpeakerClick() { if (!CallPanel.speakerSwitchingSupported() || !this.remoteRef.current) { return; } const nextOn = !this.state.speakerOn; navigator.mediaDevices.enumerateDevices() .then(devices => { const outputs = devices.filter(d => d.kind == 'audiooutput'); // Best-effort: look for a device labeled as the loud speaker vs. the earpiece/default output. // Device labels are not standardized, so fall back to the system default sink when no match is found. const match = nextOn ? outputs.find(d => /speaker/i.test(d.label)) : outputs.find(d => /earpiece|receiver/i.test(d.label)); // Same unmount race as the gUM-based methods -- enumerateDevices can still be pending // after the call ends. Currently silent either way (the .catch below only logs), so this // just avoids a pointless TypeError in the console rather than fixing a visible symptom. if (this._unmounted || !this.remoteRef.current) { return null; } return this.remoteRef.current.setSinkId(match ? match.deviceId : ''); }) .then(_ => { if (!this._unmounted) { this.setState({speakerOn: nextOn}); } }) .catch(err => console.error('Failed to switch audio output device', err)); } render() { const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks(); const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks(); // Mic/camera/speaker only need a local stream to exist -- toggling doesn't require a // peer connection (e.g. caller previewing their camera while waiting for pickup). const disabled = !(audioTracks && audioTracks[0]); const micOn = !!(audioTracks && audioTracks[0] && audioTracks[0].enabled); const audioIcon = micOn ? 'mic' : 'mic_off'; const videoOn = !!(videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live'); const videoIcon = videoOn ? 'videocam' : 'videocam_off'; const speakerSupported = CallPanel.speakerSwitchingSupported(); const speakerIcon = this.state.speakerOn ? 'volume_up' : 'volume_off'; const peerTitle = truncateString(this.props.title, MAX_PEER_TITLE_LENGTH); const pulseAnimation = this.state.waitingForPeer ? ' pulse' : ''; const connected = !!this.state.remoteStream; let remoteActive = false; if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) { const rstream = this.remoteRef.current.srcObject; if (rstream.getVideoTracks().length > 0) { const t = rstream.getVideoTracks()[0]; remoteActive = t.enabled && t.readyState == 'live'; } } const minimizedClass = this.props.minimized ? 'minimized' : null; const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit'; if (this.props.minimized && this.state.audioOnly) { // Voice calls minimize to a small pill (icon + timer), not the video-shaped box. return ( // The restore button used to live inside .call-pill.draggable -- dragStart only checks // event.target.closest('.draggable') before calling setPointerCapture() on the whole // container, and once a pointer is captured, Pointer Events retargets subsequent events // (including the click) to the capturing element instead of the button underneath it. The // video-minimized layout below already keeps its own restore button as a sibling of the // draggable region for exactly this reason; this pill just hadn't followed the same rule.
call {formatElapsedTime(this.state.elapsedSeconds)}
); } return (
{connected &&
{formatElapsedTime(this.state.elapsedSeconds)}
}
{remoteActive ? <>
{speakerSupported && } {!this.state.audioOnly && }
); } }; export default injectIntl(CallPanel);