本地小窗预览(.call-party.self)此前只由 audioOnly/minimized 控制显隐, 没有校验连接是否真正建立(this.state.remoteStream),导致视频通话振铃 阶段(对方尚未接听、只有本地摄像头画面)小窗就已经以 display:block 可见, 且 dragStart 里对应的分支同样没有这层校验,振铃阶段就能拖动这个本应还 不存在的预览框。 render() 里 .call-party.self 的 disabled 属性补上 !connected 判断, dragStart 里进入 'self' 拖动分支的条件补上 this.state.remoteStream 校验, 两处保持一致:只有远端媒体真正到达(连接建立)后小窗才出现并可拖动。 验证:用 canvas.captureStream()/AudioContext 构造的合法 MediaStreamTrack 配合 monkey-patch getUserMedia,绕开已知后端通话邀请 400 缺陷直接驱动 CallPanel 组件状态。振铃阶段(waitingForPeer=true, remoteStream 未设置) 下 .call-party.self 计算样式 display:none、无 [disabled] 属性对应的 cursor:grab;调用 handleTrackEvent 模拟远端媒体到达后,display:block、 cursor:grab,且用真实鼠标拖拽(非合成事件)验证了拖动/边界收紧都正常。
1027 lines
41 KiB
React
1027 lines
41 KiB
React
// 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.
|
|
<div id="video-container" className="minimized audio-minimized" ref={this.containerRef}>
|
|
<div className="call-pill-row">
|
|
<div className="call-pill draggable">
|
|
<i className="material-icons">call</i>
|
|
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
|
|
</div>
|
|
<button className="full-screen" onClick={this.props.onToggleMinimize}>
|
|
<i className="material-icons">{fullScreen}</i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div id="video-container" className={minimizedClass} ref={this.containerRef}>
|
|
{connected &&
|
|
<div className="call-duration">{formatElapsedTime(this.state.elapsedSeconds)}</div>}
|
|
<div id="video-container-panel" className="draggable">
|
|
<div className="call-party self" ref={this.selfBoxRef}
|
|
disabled={this.state.audioOnly || this.props.minimized || !connected}>
|
|
<video ref={this.localRef} autoPlay muted playsInline />
|
|
<div className="caller-name inactive">
|
|
<FormattedMessage id="calls_you_label"
|
|
defaultMessage="You" description="Shown over the local video screen" />
|
|
</div>
|
|
</div>
|
|
<div className="call-party peer" disabled={!remoteActive}>
|
|
{remoteActive ?
|
|
<>
|
|
<video ref={this.remoteRef} autoPlay playsInline />
|
|
<div className="caller-name inactive">{peerTitle}</div>
|
|
</> :
|
|
<>
|
|
<audio ref={this.remoteRef} autoPlay />
|
|
<div className={`caller-card${pulseAnimation} draggable`}>
|
|
<div className="avatar-box">
|
|
<LetterTile
|
|
authorizeURL={this.props.tinode.authorizeURL}
|
|
avatar={this.props.avatar}
|
|
topic={this.props.topic}
|
|
title={this.props.title} />
|
|
</div>
|
|
<div className="caller-name">{peerTitle}</div>
|
|
{/* This same !remoteActive branch also renders once the call is live but the peer's
|
|
own camera happens to be off -- only the pre-connect phase should claim we're
|
|
still waiting on them to answer. Reference screenshots (视频通话—等待对方接受
|
|
邀请中.png etc.) and this project's own call-experience-view.jsx demo both show
|
|
this line; only the real call-panel.jsx was missing it. */}
|
|
{!connected ? <p className="caller-status">正在等待对方接受邀请…</p> : null}
|
|
</div>
|
|
</>
|
|
}
|
|
</div>
|
|
</div>
|
|
<button className="full-screen" onClick={this.props.onToggleMinimize}>
|
|
<i className="material-icons">{fullScreen}</i>
|
|
</button>
|
|
<div id="controls" className={minimizedClass}>
|
|
<button className="danger" onClick={this.handleCloseClick}>
|
|
<i className="material-icons">call_end</i>
|
|
</button>
|
|
<button className={`secondary${videoOn ? '' : ' off'}`} onClick={this.handleToggleCameraClick} disabled={disabled}>
|
|
<i className="material-icons">{videoIcon}</i>
|
|
</button>
|
|
<button className={`secondary${micOn ? '' : ' off'}`} onClick={this.handleToggleMicClick} disabled={disabled}>
|
|
<i className="material-icons">{audioIcon}</i>
|
|
</button>
|
|
{speakerSupported &&
|
|
<button className={`secondary${this.state.speakerOn ? '' : ' off'}`}
|
|
onClick={this.handleToggleSpeakerClick} disabled={disabled}>
|
|
<i className="material-icons">{speakerIcon}</i>
|
|
</button>}
|
|
{!this.state.audioOnly &&
|
|
<button className="secondary" onClick={this.handleSwitchCameraClick} disabled={disabled || !videoOn}>
|
|
<i className="material-icons">flip_camera_android</i>
|
|
</button>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
export default injectIntl(CallPanel);
|