resolved conflicts
This commit is contained in:
parent
07bbdf94ac
commit
3db5d43493
9 changed files with 775 additions and 598 deletions
|
|
@ -62,6 +62,7 @@ function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }:
|
|||
stream={localStream ? localStream.stream : null}
|
||||
muted
|
||||
height={anyRemoteEnabled ? 50 : 'unset'}
|
||||
local
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -142,6 +142,10 @@ function AssistActions({
|
|||
});
|
||||
};
|
||||
|
||||
const removeIncomeStream = () => {
|
||||
setIncomeStream([]);
|
||||
};
|
||||
|
||||
function call(additionalAgentIds?: string[]) {
|
||||
RequestLocalStream()
|
||||
.then((lStream) => {
|
||||
|
|
@ -153,6 +157,7 @@ function AssistActions({
|
|||
() => {
|
||||
player.assistManager.ping(AssistActionsPing.call.end, agentId)
|
||||
lStream.stop.bind(lStream);
|
||||
removeIncomeStream(lStream.stream);
|
||||
},
|
||||
onReject,
|
||||
onError
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ interface Props {
|
|||
muted?: boolean;
|
||||
height?: number | string;
|
||||
setRemoteEnabled?: (isEnabled: boolean) => void;
|
||||
local?: boolean;
|
||||
}
|
||||
|
||||
function VideoContainer({ stream, muted = false, height = 280, setRemoteEnabled }: Props) {
|
||||
function VideoContainer({ stream, muted = false, height = 280, setRemoteEnabled, local }: Props) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [isEnabled, setEnabled] = React.useState(false);
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ function VideoContainer({ stream, muted = false, height = 280, setRemoteEnabled
|
|||
width: isEnabled ? undefined : '0px!important',
|
||||
height: isEnabled ? undefined : '0px!important',
|
||||
border: '1px solid grey',
|
||||
transform: local ? 'scaleX(-1)' : undefined,
|
||||
}}
|
||||
>
|
||||
<video autoPlay ref={ref} muted={muted} style={{ height: height }} />
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ export default class AssistManager {
|
|||
this.canvasReceiver = new CanvasReceiver(this.peerID, this.config, this.getNode, {
|
||||
...this.session.agentInfo,
|
||||
id: agentId,
|
||||
});
|
||||
}, socket);
|
||||
|
||||
document.addEventListener('visibilitychange', this.onVisChange);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ export default class Call {
|
|||
socket.on('call_end', () => {
|
||||
this.onRemoteCallEnd()
|
||||
});
|
||||
socket.on('videofeed', ({ streamId, enabled }) => {
|
||||
|
||||
socket.on('videofeed', (data: { data: { streamId: string; enabled: boolean }}) => {
|
||||
const { streamId, enabled } = data.data;
|
||||
if (this.videoStreams[streamId]) {
|
||||
this.videoStreams[streamId].enabled = enabled;
|
||||
}
|
||||
|
|
@ -68,41 +70,40 @@ export default class Call {
|
|||
this.store.update({ calling: CallingState.NoCall });
|
||||
});
|
||||
|
||||
socket.on('webrtc_offer', (data: { from: string, offer: RTCSessionDescriptionInit }) => {
|
||||
this.handleOffer(data);
|
||||
socket.on('webrtc_call_answer', (data: { data: { from: string, answer: RTCSessionDescriptionInit } }) => {
|
||||
this.handleAnswer(data.data);
|
||||
});
|
||||
socket.on('webrtc_answer', (data: { from: string, answer: RTCSessionDescriptionInit }) => {
|
||||
this.handleAnswer(data);
|
||||
});
|
||||
socket.on('webrtc_ice_candidate', (data: { from: string, candidate: RTCIceCandidateInit }) => {
|
||||
this.handleIceCandidate(data);
|
||||
socket.on('webrtc_call_ice_candidate', (data: { data: { from: string, candidate: RTCIceCandidateInit } }) => {
|
||||
this.handleIceCandidate({ candidate: data.data.candidate, from: data.data.from });
|
||||
});
|
||||
|
||||
this.assistVersion = this.getAssistVersion();
|
||||
}
|
||||
|
||||
// СОЗДАНИЕ ЛОКАЛЬНОГО ПИРА
|
||||
private async createPeerConnection(remotePeerId: string): Promise<RTCPeerConnection> {
|
||||
// создаем pc с конфигом ice
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
console.log("PC1", pc)
|
||||
|
||||
// Если есть локальный поток, добавляем его треки в соединение.
|
||||
// Если есть локальный поток добавляем его треки в соединение
|
||||
if (this.callArgs && this.callArgs.localStream && this.callArgs.localStream.stream) {
|
||||
this.callArgs.localStream.stream.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, this.callArgs!.localStream.stream);
|
||||
});
|
||||
}
|
||||
|
||||
// когда готов ice отсылваем его
|
||||
pc.onicecandidate = (event) => {
|
||||
console.log("ICE GENERATED");
|
||||
if (event.candidate) {
|
||||
this.socket.emit('webrtc_ice_candidate', { to: remotePeerId, candidate: event.candidate });
|
||||
this.socket.emit('webrtc_call_ice_candidate', { from: remotePeerId, candidate: event.candidate });
|
||||
} else {
|
||||
console.log("Сбор ICE-кандидатов завершён.");
|
||||
console.log("Сбор ICE-кандидатов завершён");
|
||||
}
|
||||
};
|
||||
|
||||
// когда получаем удаленный трек записываем его в videoStreams[peerId]
|
||||
pc.ontrack = (event) => {
|
||||
const stream = event.streams[0];
|
||||
if (stream) {
|
||||
|
|
@ -113,10 +114,23 @@ export default class Call {
|
|||
if (this.callArgs) {
|
||||
this.callArgs.onStream(stream);
|
||||
}
|
||||
try {
|
||||
|
||||
// Создаем элемент <video>
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.srcObject = stream;
|
||||
|
||||
// Добавляем <video> в <body>
|
||||
document.body.appendChild(video);
|
||||
} catch (error) {
|
||||
console.error('Error accessing media devices:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Следим за состоянием соединения
|
||||
// Если связь отвалилась заканчиваем звонок
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === "disconnected" || pc.connectionState === "failed") {
|
||||
this.onRemoteCallEnd();
|
||||
|
|
@ -138,6 +152,7 @@ export default class Call {
|
|||
return pc;
|
||||
}
|
||||
|
||||
// УСТАНОВКА СОЕДИНЕНИЯ
|
||||
private async _peerConnection(remotePeerId: string) {
|
||||
try {
|
||||
// Создаём RTCPeerConnection
|
||||
|
|
@ -149,14 +164,12 @@ export default class Call {
|
|||
await pc.setLocalDescription(offer);
|
||||
|
||||
// Отправляем offer
|
||||
console.log('sending webrtc_offer to', remotePeerId);
|
||||
this.socket.emit('webrtc_call_offer', { to: remotePeerId, offer: offer });
|
||||
this.socket.emit('webrtc_call_offer', { from: remotePeerId, offer });
|
||||
this.connectAttempts = 0;
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
// Пробуем переподключиться
|
||||
const tryReconnect = async (error: any) => {
|
||||
console.log(error.type, this.connectAttempts);
|
||||
if (error.type === 'peer-unavailable' && this.connectAttempts < 5) {
|
||||
this.connectAttempts++;
|
||||
console.log('reconnecting', this.connectAttempts);
|
||||
|
|
@ -171,47 +184,39 @@ export default class Call {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleOffer(data: { from: string, offer: RTCSessionDescriptionInit }) {
|
||||
const remotePeerId = data.from;
|
||||
try {
|
||||
const pc = await this.createPeerConnection(remotePeerId);
|
||||
this.connections[remotePeerId] = pc;
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||
|
||||
// Генерируем answer и устанавливаем локальное описание
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
|
||||
// Отправляем answer
|
||||
this.socket.emit('webrtc_call_answer', { to: remotePeerId, answer: answer });
|
||||
} catch (e) {
|
||||
console.error("Error handling offer:", e);
|
||||
this.callArgs?.onError?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Обрабатываем полученный answer на offer
|
||||
private async handleAnswer(data: { from: string, answer: RTCSessionDescriptionInit }) {
|
||||
// устанавливаем в remotePeerId data.from
|
||||
const remotePeerId = data.from;
|
||||
// получаем peer
|
||||
const pc = this.connections[remotePeerId];
|
||||
if (!pc) {
|
||||
console.error("No connection found for remote peer", remotePeerId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
// если связь еще не установлена то устанвливаем remoteDescription в peer
|
||||
if (pc.signalingState !== "stable") {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
} else {
|
||||
console.warn("Skipping setRemoteDescription: Already in stable state");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error setting remote description from answer", e);
|
||||
this.callArgs?.onError?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
// обрабатываем полученный iceCandidate
|
||||
private async handleIceCandidate(data: { from: string, candidate: RTCIceCandidateInit }) {
|
||||
const remotePeerId = data.from;
|
||||
// получаем peer
|
||||
const pc = this.connections[remotePeerId];
|
||||
if (!pc) return;
|
||||
// если есть ice кандидаты
|
||||
if (data.candidate && (data.candidate.sdpMid || data.candidate.sdpMLineIndex !== null)) {
|
||||
try {
|
||||
// добавляем кандидат в peer
|
||||
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
} catch (e) {
|
||||
console.error("Error adding ICE candidate", e);
|
||||
|
|
@ -221,13 +226,18 @@ export default class Call {
|
|||
}
|
||||
}
|
||||
|
||||
// обрабатываем окончания звонка
|
||||
private handleCallEnd() {
|
||||
// Если звонок не завершен, то вызываем onCallEnd
|
||||
if (this.store.get().calling !== CallingState.NoCall) {
|
||||
this.callArgs && this.callArgs.onCallEnd();
|
||||
}
|
||||
// меняем state на NoCall
|
||||
this.store.update({ calling: CallingState.NoCall });
|
||||
// Закрываем все созданные RTCPeerConnection
|
||||
Object.values(this.connections).forEach((pc) => pc.close());
|
||||
this.callArgs?.onCallEnd();
|
||||
// Очищаем connections
|
||||
this.connections = {};
|
||||
this.callArgs = null;
|
||||
}
|
||||
|
|
@ -235,11 +245,16 @@ export default class Call {
|
|||
// Обработчик события завершения вызова по сигналу
|
||||
private onRemoteCallEnd = () => {
|
||||
if ([CallingState.Requesting, CallingState.Connecting].includes(this.store.get().calling)) {
|
||||
// Если вызов еще не начался, то вызываем onReject
|
||||
this.callArgs && this.callArgs.onReject();
|
||||
// Закрываем все соединения и обнуляем callArgs
|
||||
Object.values(this.connections).forEach((pc) => pc.close());
|
||||
this.connections = {};
|
||||
this.callArgs?.onCallEnd();
|
||||
this.store.update({ calling: CallingState.NoCall });
|
||||
this.callArgs = null;
|
||||
} else {
|
||||
// Вызываем полный обработчик завершения вызова
|
||||
this.handleCallEnd();
|
||||
}
|
||||
};
|
||||
|
|
@ -253,8 +268,10 @@ export default class Call {
|
|||
|
||||
private emitData = (event: string, data?: any) => {
|
||||
if (this.getAssistVersion() === 1) {
|
||||
console.log('SEND EVENT', event)
|
||||
this.socket?.emit(event, data);
|
||||
} else {
|
||||
console.log('SEND EVENT', event)
|
||||
this.socket?.emit(event, { meta: { tabId: this.store.get().currentTab }, data });
|
||||
}
|
||||
};
|
||||
|
|
@ -300,19 +317,15 @@ export default class Call {
|
|||
// Уведомление пиров об изменении состояния локального видео
|
||||
toggleVideoLocalStream(enabled: boolean) {
|
||||
// Передаём сигнал через socket
|
||||
this.socket.emit('videofeed', { streamId: this.peerID, enabled });
|
||||
this.emitData('videofeed', { streamId: this.peerID, enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Соединение с другими агентами
|
||||
*/
|
||||
// Соединяемся с другими агентами
|
||||
addPeerCall(thirdPartyPeers: string[]) {
|
||||
thirdPartyPeers.forEach((peerId) => this._peerConnection(peerId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Соединение с основным пользователем приложения.
|
||||
*/
|
||||
// Вызывает метод создания соединения с пиром
|
||||
private _callSessionPeer() {
|
||||
if (![CallingState.NoCall, CallingState.Reconnecting].includes(this.store.get().calling)) {
|
||||
return;
|
||||
|
|
@ -322,6 +335,7 @@ export default class Call {
|
|||
if (!tab) {
|
||||
console.warn('No tab data to connect to peer');
|
||||
}
|
||||
|
||||
// Формируем идентификатор пира в зависимости от версии ассиста
|
||||
const peerId =
|
||||
this.getAssistVersion() === 1
|
||||
|
|
@ -338,5 +352,6 @@ export default class Call {
|
|||
void this.initiateCallEnd();
|
||||
Object.values(this.connections).forEach((pc) => pc.close());
|
||||
this.connections = {};
|
||||
this.callArgs?.onCallEnd();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { VElement } from 'Player/web/managers/DOM/VirtualDOM';
|
||||
import MessageManager from 'Player/web/MessageManager';
|
||||
import { Socket } from 'socket.io-client';
|
||||
|
||||
let frameCounter = 0;
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ export default class CanvasReceiver {
|
|||
private streams: Map<string, MediaStream> = new Map();
|
||||
// Храним RTCPeerConnection для каждого удалённого пира
|
||||
private connections: Map<string, RTCPeerConnection> = new Map();
|
||||
private id: string;
|
||||
private cId: string;
|
||||
|
||||
//sendSignal – для отправки сигналов (offer/answer/ICE)
|
||||
constructor(
|
||||
|
|
@ -27,31 +28,50 @@ export default class CanvasReceiver {
|
|||
private readonly config: RTCIceServer[] | null,
|
||||
private readonly getNode: MessageManager['getNode'],
|
||||
private readonly agentInfo: Record<string, any>,
|
||||
private readonly sendSignal: (data: any) => void
|
||||
private readonly socket: Socket,
|
||||
) {
|
||||
// Формируем идентификатор как в PeerJS
|
||||
this.id = `${this.peerIdPrefix}-${this.agentInfo.id}-canvas`;
|
||||
this.cId = `${this.peerIdPrefix}-${this.agentInfo.id}-canvas`;
|
||||
|
||||
this.socket.on('webrtc_canvas_offer', (data: { data: { offer: RTCSessionDescriptionInit, id: string }}) => {
|
||||
const { offer, id } = data.data;
|
||||
if (checkId(id, this.cId)) {
|
||||
this.handleOffer(offer, id);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('webrtc_canvas_ice_candidate', (data: { data: { candidate: RTCIceCandidateInit, id: string }}) => {
|
||||
const {candidate, id } = data.data;
|
||||
if (checkId(id, this.cId)) {
|
||||
this.handleCandidate(candidate, id);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('webrtc_canvas_restart', () => {
|
||||
this.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async handleOffer(from: string, offer: RTCSessionDescriptionInit): Promise<void> {
|
||||
async handleOffer(offer: RTCSessionDescriptionInit, id: string): Promise<void> {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: this.config ? this.config : [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
|
||||
// Сохраняем соединение
|
||||
this.connections.set(from, pc);
|
||||
this.connections.set(id, pc);
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.sendSignal({ to: from, type: 'canvas_ice_candidate', candidate: event.candidate });
|
||||
this.socket.emit('webrtc_canvas_ice_candidate', ({ candidate: event.candidate, id }));
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
|
||||
const stream = event.streams[0];
|
||||
if (stream) {
|
||||
// Определяем canvasId из удалённого peer id
|
||||
const canvasId = from.split('-')[2];
|
||||
const canvasId = id.split('-')[4];
|
||||
this.streams.set(canvasId, stream);
|
||||
setTimeout(() => {
|
||||
const node = this.getNode(parseInt(canvasId, 10));
|
||||
|
|
@ -62,6 +82,8 @@ export default class CanvasReceiver {
|
|||
node.node as HTMLCanvasElement,
|
||||
(node.node as HTMLCanvasElement).getContext('2d') as CanvasRenderingContext2D
|
||||
);
|
||||
} else {
|
||||
console.log('NODE', canvasId, 'IS NOT FOUND');
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
|
@ -72,11 +94,11 @@ export default class CanvasReceiver {
|
|||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
|
||||
this.sendSignal({ to: from, type: 'canvas_answer', answer: answer });
|
||||
this.socket.emit('webrtc_canvas_answer', { answer: answer, id });
|
||||
}
|
||||
|
||||
async handleCandidate(from: string, candidate: RTCIceCandidateInit): Promise<void> {
|
||||
const pc = this.connections.get(from);
|
||||
async handleCandidate(candidate: RTCIceCandidateInit, id: string): Promise<void> {
|
||||
const pc = this.connections.get(id);
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
|
|
@ -167,6 +189,10 @@ function spawnDebugVideo(stream: MediaStream, node: VElement) {
|
|||
});
|
||||
}
|
||||
|
||||
function checkId(id: string, cId: string): boolean {
|
||||
return id.includes(cId);
|
||||
}
|
||||
|
||||
/** simple peer example
|
||||
* // @ts-ignore
|
||||
* const peer = new SLPeer({ initiator: false })
|
||||
|
|
|
|||
|
|
@ -1,490 +1,545 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenReplay | Assist</title>
|
||||
|
||||
<!--CSS -->
|
||||
<!-- <link href="css/styles.css" rel="stylesheet"> -->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.connecting-message {
|
||||
/* margin-top: 50%; */
|
||||
font-size: 20px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
display: none;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
<head>
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenReplay | Assist</title>
|
||||
|
||||
.status-connecting .connecting-message {
|
||||
/* display: block; */
|
||||
}
|
||||
.status-connecting .card {
|
||||
/* display: none; */
|
||||
}
|
||||
<!--CSS -->
|
||||
<!-- <link href="css/styles.css" rel="stylesheet"> -->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card{
|
||||
font: 14px 'Roboto', sans-serif;
|
||||
/* min-width: 324px; */
|
||||
width: 300px;
|
||||
/* max-width: 800px; */
|
||||
/* border: solid thin #ccc; */
|
||||
/* box-shadow: 0 0 10px #aaa; */
|
||||
border: solid 4px rgba(0, 0, 0, 0.2);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-footers {
|
||||
display: flex;
|
||||
border-bottom: solid thin #CCC;
|
||||
padding: 5px 5px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.connecting-message {
|
||||
/* margin-top: 50%; */
|
||||
font-size: 20px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
display: none;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.card-footers .assist-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.status-connecting .connecting-message {
|
||||
/* display: block; */
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #CC0000 !important;
|
||||
color: white;
|
||||
}
|
||||
.status-connecting .card {
|
||||
/* display: none; */
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #FF0000 !important;
|
||||
color: white;
|
||||
}
|
||||
.card {
|
||||
font: 14px 'Roboto', sans-serif;
|
||||
/* min-width: 324px; */
|
||||
width: 300px;
|
||||
/* max-width: 800px; */
|
||||
/* border: solid thin #ccc; */
|
||||
/* box-shadow: 0 0 10px #aaa; */
|
||||
border: solid 4px rgba(0, 0, 0, 0.2);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 8px;
|
||||
font-size: 14px;
|
||||
border-radius: .5rem;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.card-footers {
|
||||
display: flex;
|
||||
border-bottom: solid thin #CCC;
|
||||
padding: 5px 5px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.card-footers .assist-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #CC0000 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card .card-header{
|
||||
cursor: move;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: solid thin #ccc;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #FF0000 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#agent-name, #duration{
|
||||
cursor:default;
|
||||
}
|
||||
.btn {
|
||||
padding: 5px 8px;
|
||||
font-size: 14px;
|
||||
border-radius: .5rem;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
background-color: rgb(90, 90, 90);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* width: 300px; */
|
||||
}
|
||||
.btn span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#video-container video {
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
#local-stream, #remote-stream {
|
||||
/* display:none; */ /* TODO uncomment this line */
|
||||
}
|
||||
#video-container.remote #remote-stream {
|
||||
display: block;
|
||||
}
|
||||
#video-container.local {
|
||||
min-height: 100px;
|
||||
}
|
||||
#video-container.local #local-stream {
|
||||
display: block;
|
||||
}
|
||||
.btn:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
#local-stream{
|
||||
width: 35%;
|
||||
/* top: 50%; */
|
||||
/* left: 70%; */
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
border: thin solid rgba(255,255,255, .3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card .card-header {
|
||||
cursor: move;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: solid thin #ccc;
|
||||
}
|
||||
|
||||
#audio-btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
#audio-btn .bi-mic {
|
||||
fill: #CC0000;
|
||||
}
|
||||
#audio-btn .bi-mic-mute {
|
||||
display:none;
|
||||
}
|
||||
#audio-btn:after {
|
||||
/* text-transform: capitalize; */
|
||||
color: #CC0000;
|
||||
content: 'Mute';
|
||||
padding-left: 5px;
|
||||
}
|
||||
#audio-btn.muted .bi-mic-mute {
|
||||
display: inline-block;
|
||||
}
|
||||
#audio-btn.muted .bi-mic {
|
||||
display:none;
|
||||
}
|
||||
#audio-btn.muted:after {
|
||||
content: 'Unmute';
|
||||
padding-left: 5px;
|
||||
}
|
||||
#agent-name,
|
||||
#duration {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
background-color: rgb(90, 90, 90);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* width: 300px; */
|
||||
}
|
||||
|
||||
#video-container video {
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-stream,
|
||||
#remote-stream {
|
||||
/* display:none; */
|
||||
/* TODO uncomment this line */
|
||||
}
|
||||
|
||||
#video-container.remote #remote-stream {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#video-container.local {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
#video-container.local #local-stream {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#local-stream {
|
||||
width: 35%;
|
||||
/* top: 50%; */
|
||||
/* left: 70%; */
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
border: thin solid rgba(255, 255, 255, .3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#audio-btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#audio-btn .bi-mic {
|
||||
fill: #CC0000;
|
||||
}
|
||||
|
||||
#audio-btn .bi-mic-mute {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#audio-btn:after {
|
||||
/* text-transform: capitalize; */
|
||||
color: #CC0000;
|
||||
content: 'Mute';
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
#audio-btn.muted .bi-mic-mute {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#audio-btn.muted .bi-mic {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#audio-btn.muted:after {
|
||||
content: 'Unmute';
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
|
||||
#video-btn .bi-camera-video {
|
||||
fill: #CC0000;
|
||||
}
|
||||
#video-btn .bi-camera-video-off {
|
||||
display:none;
|
||||
}
|
||||
#video-btn:after {
|
||||
/* text-transform: capitalize; */
|
||||
color: #CC0000;
|
||||
content: 'Stop Video';
|
||||
padding-left: 5px;
|
||||
}
|
||||
#video-btn.off:after {
|
||||
content: 'Start Video';
|
||||
padding-left: 5px;
|
||||
}
|
||||
#video-btn.off .bi-camera-video-off {
|
||||
display: inline-block;
|
||||
}
|
||||
#video-btn.off .bi-camera-video {
|
||||
display:none;
|
||||
}
|
||||
#video-btn .bi-camera-video {
|
||||
fill: #CC0000;
|
||||
}
|
||||
|
||||
/* CHART */
|
||||
#chat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
}
|
||||
#video-btn .bi-camera-video-off {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages { display: none; }
|
||||
#chat-card .chat-input { display: none; }
|
||||
#chat-card .chat-header .arrow-state { transform: rotate(180deg); }
|
||||
#chat-card.active .chat-messages { display: flex; }
|
||||
#chat-card.active .chat-input { display: flex; }
|
||||
#chat-card.active .chat-header .arrow-state { transform: rotate(0deg); }
|
||||
#video-btn:after {
|
||||
/* text-transform: capitalize; */
|
||||
color: #CC0000;
|
||||
content: 'Stop Video';
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
#chat-card .chat-header {
|
||||
border-bottom: solid thin #ccc;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
#video-btn.off:after {
|
||||
content: 'Start Video';
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
#chat-card .chat-header .chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
#video-btn.off .bi-camera-video-off {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#chat-card .chat-header .chat-title span {
|
||||
margin-left: 6px;
|
||||
}
|
||||
#video-btn.off .bi-camera-video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages {
|
||||
padding: 8px 16px;
|
||||
overflow-y: auto;
|
||||
height: 250px;
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* CHART */
|
||||
#chat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#chat-card .message-text {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
color: #666666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
#chat-card .chat-messages {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat-card .message .message-text {
|
||||
/* max-width: 70%; */
|
||||
width: fit-content;
|
||||
}
|
||||
#chat-card .message {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
#chat-card .chat-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages .message.left .message-text {
|
||||
text-align: left;
|
||||
background: #D7E2E2;
|
||||
border-radius: 0px 30px 30px 30px;
|
||||
}
|
||||
#chat-card .chat-header .arrow-state {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
#chat-card .message .message-user {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #999999;
|
||||
}
|
||||
#chat-card .message .message-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
#chat-card.active .chat-messages {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages .message.right {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
#chat-card.active .chat-input {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages .message.right .message-text {
|
||||
background: #E4E4E4;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 30px 30px 0px 30px;
|
||||
}
|
||||
#chat-card.active .chat-header .arrow-state {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
#chat-card .chat-input {
|
||||
margin: 10px;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
|
||||
background-color: #DDDDDD;
|
||||
position: relative;
|
||||
}
|
||||
#chat-card .chat-header {
|
||||
border-bottom: solid thin #ccc;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#chat-card .chat-input .input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
padding: 8px 16px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
background-color: transparent;
|
||||
}
|
||||
#chat-card .chat-header .chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background-color: #AAA;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.send-btn:hover {
|
||||
background-color: #999;
|
||||
}
|
||||
.send-btn svg {
|
||||
fill: #DDDDDD;
|
||||
}
|
||||
#chat-card .chat-header .chat-title span {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.confirm-window .title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.confirm-window {
|
||||
font: 14px 'Roboto', sans-serif;
|
||||
padding: 20px;
|
||||
background-color: #F3F3F3;
|
||||
border-radius: .5rem;
|
||||
/* position: absolute; */
|
||||
width: fit-content;
|
||||
color: #666666;
|
||||
display: none;
|
||||
}
|
||||
.confirm-window .actions {
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
box-shadow: 0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
#chat-card .chat-messages {
|
||||
padding: 8px 16px;
|
||||
overflow-y: auto;
|
||||
height: 250px;
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
#chat-card .message-text {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
color: #666666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: rgba(0, 167, 47, 1);
|
||||
color: white;
|
||||
}
|
||||
#chat-card .message .message-text {
|
||||
/* max-width: 70%; */
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* .btn-error:hover,
|
||||
#chat-card .message {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages .message.left .message-text {
|
||||
text-align: left;
|
||||
background: #D7E2E2;
|
||||
border-radius: 0px 30px 30px 30px;
|
||||
}
|
||||
|
||||
#chat-card .message .message-user {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
#chat-card .message .message-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages .message.right {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#chat-card .chat-messages .message.right .message-text {
|
||||
background: #E4E4E4;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 30px 30px 0px 30px;
|
||||
}
|
||||
|
||||
#chat-card .chat-input {
|
||||
margin: 10px;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
|
||||
background-color: #DDDDDD;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#chat-card .chat-input .input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
padding: 8px 16px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background-color: #AAA;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background-color: #999;
|
||||
}
|
||||
|
||||
.send-btn svg {
|
||||
fill: #DDDDDD;
|
||||
}
|
||||
|
||||
.confirm-window .title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.confirm-window {
|
||||
font: 14px 'Roboto', sans-serif;
|
||||
padding: 20px;
|
||||
background-color: #F3F3F3;
|
||||
border-radius: .5rem;
|
||||
/* position: absolute; */
|
||||
width: fit-content;
|
||||
color: #666666;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.confirm-window .actions {
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
box-shadow: 0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: rgba(0, 167, 47, 1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* .btn-error:hover,
|
||||
.btn-success:hover {
|
||||
filter: brightness(0.9);
|
||||
} */
|
||||
|
||||
.btn-error {
|
||||
background: #FFE9E9;
|
||||
/* border-color: #d43f3a; */
|
||||
color: #CC0000;
|
||||
}
|
||||
</style>
|
||||
.btn-error {
|
||||
background: #FFE9E9;
|
||||
/* border-color: #d43f3a; */
|
||||
color: #CC0000;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
</head>
|
||||
|
||||
|
||||
<body data-openreplay-hidden>
|
||||
<div id="remote-control-confirm" class="confirm-window">
|
||||
<div class="title">The agent is requesting remote control</div>
|
||||
<div class="actions">
|
||||
<button class="text-uppercase btn btn-lg btn-success" style="margin-right: 10px">Grant remote access</button>
|
||||
<button class="text-uppercase btn btn-lg btn-error">Reject</button>
|
||||
</div>
|
||||
<body data-openreplay-hidden>
|
||||
<div id="remote-control-confirm" class="confirm-window">
|
||||
<div class="title">The agent is requesting remote control</div>
|
||||
<div class="actions">
|
||||
<button class="text-uppercase btn btn-lg btn-success" style="margin-right: 10px">Grant remote access</button>
|
||||
<button class="text-uppercase btn btn-lg btn-error">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="call-confirm" class="confirm-window">
|
||||
<div class="title">Answer the call so the agent can assist.</div>
|
||||
<div class="actions">
|
||||
<button class="text-uppercase btn btn-lg btn-success" style="margin-right: 10px">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-telephone" viewBox="0 0 16 16">
|
||||
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
|
||||
</svg>
|
||||
<span>Answer</span>
|
||||
</button>
|
||||
<button class="text-uppercase btn btn-lg btn-error">Reject</button>
|
||||
</div>
|
||||
<div id="call-confirm" class="confirm-window">
|
||||
<div class="title">Answer the call so the agent can assist.</div>
|
||||
<div class="actions">
|
||||
<button class="text-uppercase btn btn-lg btn-success" style="margin-right: 10px">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-telephone"
|
||||
viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z" />
|
||||
</svg>
|
||||
<span>Answer</span>
|
||||
</button>
|
||||
<button class="text-uppercase btn btn-lg btn-error">Reject</button>
|
||||
</div>
|
||||
<section id="or-assist" class="status-connecting">
|
||||
<div class="connecting-message"> Connecting... </div>
|
||||
<div class="card shadow">
|
||||
<div class="drag-area card-header d-flex justify-content-between">
|
||||
<div class="user-info">
|
||||
<span>Call with</span>
|
||||
<!-- User Name -->
|
||||
<span id="agent-name" class="person-name fw-light" >Support Agent</span>
|
||||
</div>
|
||||
<div class="call-duration">
|
||||
<!--Call Duration. -->
|
||||
<span id="duration" class="card-subtitle mb-2 text-muted fw-light" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Duration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="video-container" class="card-body bg-dark p-0 d-flex align-items-center position-relative">
|
||||
<div id="local-stream" class="ratio ratio-4x3 rounded m-0 p-0 shadow">
|
||||
<!-- <p class="text-white m-auto text-center">Starting video...</p> -->
|
||||
<video id="video-local" autoplay muted></video>
|
||||
</div>
|
||||
|
||||
<div id="remote-stream" class="ratio ratio-4x3 m-0 p-0">
|
||||
<!-- <p id="remote-stream-placeholder" class="text-white m-auto text-center">Starting video...</p> -->
|
||||
<video id="video-remote" autoplay></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footers">
|
||||
<div class="assist-controls">
|
||||
<!-- Add class .muted to #audio-btn when user mutes audio -->
|
||||
<button
|
||||
href="#"
|
||||
id="audio-btn"
|
||||
class="btn btn-light btn-sm text-uppercase me-2"
|
||||
>
|
||||
<i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-mic" viewBox="0 0 16 16">
|
||||
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0v5zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-mic-mute" viewBox="0 0 16 16">
|
||||
<path d="M13 8c0 .564-.094 1.107-.266 1.613l-.814-.814A4.02 4.02 0 0 0 12 8V7a.5.5 0 0 1 1 0v1zm-5 4c.818 0 1.578-.245 2.212-.667l.718.719a4.973 4.973 0 0 1-2.43.923V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 1 0v1a4 4 0 0 0 4 4zm3-9v4.879l-1-1V3a2 2 0 0 0-3.997-.118l-.845-.845A3.001 3.001 0 0 1 11 3z"/>
|
||||
<path d="m9.486 10.607-.748-.748A2 2 0 0 1 6 8v-.878l-1-1V8a3 3 0 0 0 4.486 2.607zm-7.84-9.253 12 12 .708-.708-12-12-.708.708z"/>
|
||||
</svg>
|
||||
</i>
|
||||
</button>
|
||||
|
||||
<!--Add class .off to #video-btn when user stops video -->
|
||||
<button
|
||||
href="#"
|
||||
id="video-btn"
|
||||
class="btn btn-light btn-sm text-uppercase ms-2"
|
||||
>
|
||||
<i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-camera-video" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-camera-video-off" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10.961 12.365a1.99 1.99 0 0 0 .522-1.103l3.11 1.382A1 1 0 0 0 16 11.731V4.269a1 1 0 0 0-1.406-.913l-3.111 1.382A2 2 0 0 0 9.5 3H4.272l.714 1H9.5a1 1 0 0 1 1 1v6a1 1 0 0 1-.144.518l.605.847zM1.428 4.18A.999.999 0 0 0 1 5v6a1 1 0 0 0 1 1h5.014l.714 1H2a2 2 0 0 1-2-2V5c0-.675.334-1.272.847-1.634l.58.814zM15 11.73l-3.5-1.555v-4.35L15 4.269v7.462zm-4.407 3.56-10-14 .814-.58 10 14-.814.58z"/>
|
||||
</svg>
|
||||
</i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<button id="end-call-btn" href="#" class="btn btn-danger btn-sm text-uppercase" style="margin-right: 8px;">End</button>
|
||||
</div>
|
||||
|
||||
<!-- CHAT - add .active class to show the messages and input -->
|
||||
<div id="chat-card" class="active">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-chat" viewBox="0 0 16 16">
|
||||
<path d="M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z"/>
|
||||
</svg>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="bi bi-chevron-up arrow-state" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-messages">
|
||||
<div class="message left">
|
||||
<div class="message-text"> Hey, did you get the key? </div>
|
||||
<div>
|
||||
<span class="message-user">Username</span>
|
||||
<span class="message-time"> 00:00 </span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message right">
|
||||
<div class="message-text">
|
||||
Oui, merci!
|
||||
</div>
|
||||
<div>
|
||||
<span class="message-user">Username</span>
|
||||
<span class="message-time">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<input type="text" class="input" placeholder="Type a message...">
|
||||
<div class="send-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="or-assist" class="status-connecting">
|
||||
<div class="connecting-message"> Connecting... </div>
|
||||
<div class="card shadow">
|
||||
<div class="drag-area card-header d-flex justify-content-between">
|
||||
<div class="user-info">
|
||||
<span>Call with</span>
|
||||
<!-- User Name -->
|
||||
<span id="agent-name" class="person-name fw-light">Support Agent</span>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
<div class="call-duration">
|
||||
<!--Call Duration. -->
|
||||
<span id="duration" class="card-subtitle mb-2 text-muted fw-light" data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom" title="Duration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="video-container" class="card-body bg-dark p-0 d-flex align-items-center position-relative">
|
||||
<div id="local-stream" class="ratio ratio-4x3 rounded m-0 p-0 shadow scale-x-[-1]">
|
||||
<!-- <p class="text-white m-auto text-center">Starting video...</p> -->
|
||||
<video id="video-local" autoplay muted class="scale-x-[-1]"></video>
|
||||
</div>
|
||||
|
||||
<div id="remote-stream" class="ratio ratio-4x3 m-0 p-0">
|
||||
<!-- <p id="remote-stream-placeholder" class="text-white m-auto text-center">Starting video...</p> -->
|
||||
<video id="video-remote" autoplay></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footers">
|
||||
<div class="assist-controls">
|
||||
<!-- Add class .muted to #audio-btn when user mutes audio -->
|
||||
<button href="#" id="audio-btn" class="btn btn-light btn-sm text-uppercase me-2">
|
||||
<i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-mic" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" />
|
||||
<path d="M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0v5zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-mic-mute" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M13 8c0 .564-.094 1.107-.266 1.613l-.814-.814A4.02 4.02 0 0 0 12 8V7a.5.5 0 0 1 1 0v1zm-5 4c.818 0 1.578-.245 2.212-.667l.718.719a4.973 4.973 0 0 1-2.43.923V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 1 0v1a4 4 0 0 0 4 4zm3-9v4.879l-1-1V3a2 2 0 0 0-3.997-.118l-.845-.845A3.001 3.001 0 0 1 11 3z" />
|
||||
<path
|
||||
d="m9.486 10.607-.748-.748A2 2 0 0 1 6 8v-.878l-1-1V8a3 3 0 0 0 4.486 2.607zm-7.84-9.253 12 12 .708-.708-12-12-.708.708z" />
|
||||
</svg>
|
||||
</i>
|
||||
</button>
|
||||
|
||||
<!--Add class .off to #video-btn when user stops video -->
|
||||
<button href="#" id="video-btn" class="btn btn-light btn-sm text-uppercase ms-2">
|
||||
<i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-camera-video" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-camera-video-off" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10.961 12.365a1.99 1.99 0 0 0 .522-1.103l3.11 1.382A1 1 0 0 0 16 11.731V4.269a1 1 0 0 0-1.406-.913l-3.111 1.382A2 2 0 0 0 9.5 3H4.272l.714 1H9.5a1 1 0 0 1 1 1v6a1 1 0 0 1-.144.518l.605.847zM1.428 4.18A.999.999 0 0 0 1 5v6a1 1 0 0 0 1 1h5.014l.714 1H2a2 2 0 0 1-2-2V5c0-.675.334-1.272.847-1.634l.58.814zM15 11.73l-3.5-1.555v-4.35L15 4.269v7.462zm-4.407 3.56-10-14 .814-.58 10 14-.814.58z" />
|
||||
</svg>
|
||||
</i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<button id="end-call-btn" href="#" class="btn btn-danger btn-sm text-uppercase"
|
||||
style="margin-right: 8px;">End</button>
|
||||
</div>
|
||||
|
||||
<!-- CHAT - add .active class to show the messages and input -->
|
||||
<div id="chat-card" class="active">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-chat"
|
||||
viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z" />
|
||||
</svg>
|
||||
<span>Chat</span>
|
||||
</div>
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="bi bi-chevron-up arrow-state"
|
||||
viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-messages">
|
||||
<div class="message left">
|
||||
<div class="message-text"> Hey, did you get the key? </div>
|
||||
<div>
|
||||
<span class="message-user">Username</span>
|
||||
<span class="message-time"> 00:00 </span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message right">
|
||||
<div class="message-text">
|
||||
Oui, merci!
|
||||
</div>
|
||||
<div>
|
||||
<span class="message-user">Username</span>
|
||||
<span class="message-time">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<input type="text" class="input" placeholder="Type a message...">
|
||||
<div class="send-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" class="bi bi-arrow-right-short"
|
||||
viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -152,8 +152,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<div id="video-container" class="card-body bg-dark p-0 d-flex align-items-center position-relative">
|
||||
<div id="local-stream" class="ratio ratio-4x3 rounded m-0 p-0 shadow">
|
||||
<video id="video-local" autoplay muted></video>
|
||||
<div id="local-stream" class="ratio ratio-4x3 rounded m-0 p-0 shadow scale-x-[-1]">
|
||||
<!-- пофиксить отображение по горизонтали -->
|
||||
<video id="video-local" autoplay muted class="scale-x-[-1]"></video>
|
||||
</div>
|
||||
|
||||
<div id="remote-stream" class="ratio ratio-4x3 m-0 p-0">
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default class Assist {
|
|||
|
||||
private socket: Socket | null = null
|
||||
private calls: Record<string, RTCPeerConnection> = {};
|
||||
private canvasPeers: Record<number, RTCPeerConnection | null> = {}
|
||||
private canvasPeers: { [id: number]: RTCPeerConnection | null } = {}
|
||||
private canvasNodeCheckers: Map<number, any> = new Map()
|
||||
private assistDemandedRestart = false
|
||||
private callingState: CallingState = CallingState.False
|
||||
|
|
@ -88,10 +88,6 @@ export default class Assist {
|
|||
private readonly options: Options
|
||||
private readonly canvasMap: Map<number, Canvas> = new Map()
|
||||
|
||||
// Для локального аудио/видео потока
|
||||
private localStream: MediaStream | null = null;
|
||||
private isCalling: boolean = false;
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
options?: Partial<Options>,
|
||||
|
|
@ -340,11 +336,12 @@ export default class Assist {
|
|||
|
||||
|
||||
// TODO: restrict by id
|
||||
socket.on('moveAnnotation', (id, event) => processEvent(id, event, (_, d) => annot && annot.move(d)))
|
||||
socket.on('startAnnotation', (id, event) => processEvent(id, event, (_, d) => annot?.start(d)))
|
||||
socket.on('moveAnnotation', (id, event) => processEvent(id, event, (_, d) => annot && annot.move(d)))
|
||||
socket.on('startAnnotation', (id, event) => processEvent(id, event, (_, d) => annot?.start(d)))
|
||||
socket.on('stopAnnotation', (id, event) => processEvent(id, event, annot?.stop))
|
||||
|
||||
socket.on('NEW_AGENT', (id: string, info: AgentInfo) => {
|
||||
this.cleanCanvasConnections();
|
||||
this.agents[id] = {
|
||||
onDisconnect: this.options.onAgentConnect?.(info),
|
||||
agentInfo: info, // TODO ?
|
||||
|
|
@ -367,8 +364,10 @@ export default class Assist {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('AGENTS_CONNECTED', (ids: string[]) => {
|
||||
ids.forEach(id =>{
|
||||
this.cleanCanvasConnections();
|
||||
ids.forEach(id => {
|
||||
const agentInfo = this.agents[id]?.agentInfo
|
||||
this.agents[id] = {
|
||||
agentInfo,
|
||||
|
|
@ -398,19 +397,26 @@ export default class Assist {
|
|||
this.agents[id]?.onDisconnect?.()
|
||||
delete this.agents[id]
|
||||
|
||||
Object.values(this.calls).forEach(pc => pc.close())
|
||||
this.calls = {}
|
||||
|
||||
recordingState.stopAgentRecording(id)
|
||||
endAgentCall(id)
|
||||
})
|
||||
|
||||
socket.on('NO_AGENT', () => {
|
||||
Object.values(this.agents).forEach(a => a.onDisconnect?.())
|
||||
this.cleanCanvasConnections();
|
||||
this.agents = {}
|
||||
if (recordingState.isActive) recordingState.stopRecording()
|
||||
})
|
||||
|
||||
socket.on('call_end', (id) => {
|
||||
if (!callingAgents.has(id)) {
|
||||
app.debug.warn('Received call_end from unknown agent', id)
|
||||
return
|
||||
}
|
||||
|
||||
endAgentCall(id)
|
||||
})
|
||||
|
||||
|
|
@ -418,17 +424,38 @@ export default class Assist {
|
|||
if (app.getTabId() !== info.meta.tabId) return
|
||||
const name = info.data
|
||||
callingAgents.set(id, name)
|
||||
|
||||
if (!this.isCalling) {
|
||||
setupCallSignaling();
|
||||
}
|
||||
updateCallerNames()
|
||||
})
|
||||
|
||||
socket.on('webrtc_canvas_answer', async (_, data: { answer, id }) => {
|
||||
const pc = this.canvasPeers[data.id];
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
} catch (e) {
|
||||
app.debug.error('Error adding ICE candidate', e);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('webrtc_canvas_ice_candidate', async (_, data: { candidate, id }) => {
|
||||
const pc = this.canvasPeers[data.id];
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
} catch (e) {
|
||||
app.debug.error('Error adding ICE candidate', e);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Если приходит videofeed то в ui показываем видео
|
||||
socket.on('videofeed', (_, info) => {
|
||||
if (app.getTabId() !== info.meta.tabId) return
|
||||
const feedState = info.data
|
||||
callUI?.toggleVideoStream(feedState)
|
||||
})
|
||||
|
||||
socket.on('request_recording', (id, info) => {
|
||||
if (app.getTabId() !== info.meta.tabId) return
|
||||
const agentData = info.data
|
||||
|
|
@ -446,6 +473,21 @@ export default class Assist {
|
|||
}
|
||||
})
|
||||
|
||||
socket.on('webrtc_call_offer', async (_, data: { from: string, offer: RTCSessionDescriptionInit }) => {
|
||||
await handleIncomingCallOffer(data.from, data.offer);
|
||||
});
|
||||
|
||||
socket.on('webrtc_call_ice_candidate', async (data: { from: string, candidate: RTCIceCandidateInit }) => {
|
||||
const pc = this.calls[data.from];
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
} catch (e) {
|
||||
app.debug.error('Error adding ICE candidate', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const callingAgents: Map<string, string> = new Map() // !! uses socket.io ID
|
||||
// TODO: merge peerId & socket.io id (simplest way - send peerId with the name)
|
||||
const lStreams: Record<string, LocalStream> = {}
|
||||
|
|
@ -455,21 +497,16 @@ export default class Assist {
|
|||
}
|
||||
function endAgentCall(id: string) {
|
||||
callingAgents.delete(id)
|
||||
|
||||
if (callingAgents.size === 0) {
|
||||
handleCallEnd()
|
||||
} else {
|
||||
updateCallerNames()
|
||||
//TODO: close() specific call and corresponding lStreams (after connecting peerId & socket.io id)
|
||||
}
|
||||
}
|
||||
const handleCallEnd = () => { // Complete stop and clear all calls
|
||||
// Streams
|
||||
Object.values(this.calls).forEach(pc => pc.close())
|
||||
Object.keys(this.calls).forEach(peerId => {
|
||||
delete this.calls[peerId]
|
||||
})
|
||||
Object.values(lStreams).forEach((stream) => { stream.stop() })
|
||||
Object.keys(lStreams).forEach((peerId: string) => { delete lStreams[peerId] })
|
||||
|
||||
// обработка окончания вызова
|
||||
const handleCallEnd = () => {
|
||||
// UI
|
||||
closeCallConfirmWindow()
|
||||
if (this.remoteControl?.status === RCStatus.Disabled) {
|
||||
|
|
@ -495,46 +532,23 @@ export default class Assist {
|
|||
}
|
||||
}
|
||||
|
||||
const setupCallSignaling = () => {
|
||||
console.log("SETUP CALL 2");
|
||||
socket.on('webrtc_call_offer', async (_, data: { from: string, offer: RTCSessionDescriptionInit }) => {
|
||||
console.log('Incoming call offer from', data, data.from, data.offer);
|
||||
await handleIncomingCallOffer(data.from, data.offer);
|
||||
});
|
||||
socket.on('webrtc_call_answer', async (data: { from: string, answer: RTCSessionDescriptionInit }) => {
|
||||
const pc = this.calls[data.from];
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
} catch (e) {
|
||||
app.debug.error('Error setting remote description from answer', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('webrtc_ice_candidate', async (data: { from: string, candidate: RTCIceCandidateInit }) => {
|
||||
const pc = this.calls[data.from];
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
} catch (e) {
|
||||
app.debug.error('Error adding ICE candidate', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// обрабатываем входящий вызов
|
||||
const handleIncomingCallOffer = async (from: string, offer: RTCSessionDescriptionInit) => {
|
||||
app.debug.log('handleIncomingCallOffer', from)
|
||||
let confirmAnswer: Promise<boolean>
|
||||
const callingPeerIds = JSON.parse(sessionStorage.getItem(this.options.session_calling_peer_key) || '[]')
|
||||
// если звонящий уже в списке, то сразу принимаем вызов без ui
|
||||
if (callingPeerIds.includes(from) || this.callingState === CallingState.True) {
|
||||
confirmAnswer = Promise.resolve(true)
|
||||
} else {
|
||||
// ставим стейт в ожидание подтверждения
|
||||
this.setCallingState(CallingState.Requesting)
|
||||
// вызываем окно подтверждения вызова
|
||||
confirmAnswer = requestCallConfirm()
|
||||
this.playNotificationSound() // For every new agent during confirmation here
|
||||
// звуковое уведомление звонка
|
||||
this.playNotificationSound()
|
||||
|
||||
// TODO: only one (latest) timeout
|
||||
// через 30 сек сбрасываем вызов
|
||||
setTimeout(() => {
|
||||
if (this.callingState !== CallingState.Requesting) { return }
|
||||
initiateCallEnd()
|
||||
|
|
@ -542,34 +556,66 @@ export default class Assist {
|
|||
}
|
||||
|
||||
try {
|
||||
// ждем рещения по принятию вызова
|
||||
const agreed = await confirmAnswer
|
||||
// если отказали, то завершаем вызов
|
||||
if (!agreed) {
|
||||
initiateCallEnd()
|
||||
this.options.onCallDeny?.()
|
||||
return
|
||||
}
|
||||
// Request local stream for the new connection
|
||||
if (!lStreams[from]) {
|
||||
app.debug.log('starting new stream for', from)
|
||||
lStreams[from] = await RequestLocalStream()
|
||||
// если приняли то чекаем ui, если окна вызова нет то создаем, привязываем toggle локального видео в тоглу через сокет
|
||||
if (!callUI) {
|
||||
callUI = new CallWindow(app.debug.error, this.options.callUITemplate)
|
||||
callUI.setVideoToggleCallback((args: { enabled: boolean }) =>
|
||||
this.emit('videofeed', { streamId: from, enabled: args.enabled })
|
||||
);
|
||||
}
|
||||
const pc = new RTCPeerConnection(this.options.config);
|
||||
// показыаем кнопочки в окне вызова
|
||||
callUI.showControls(initiateCallEnd)
|
||||
if (!annot) {
|
||||
annot = new AnnotationCanvas()
|
||||
annot.mount()
|
||||
}
|
||||
|
||||
// callUI.setLocalStreams(Object.values(lStreams))
|
||||
try {
|
||||
// если нет локальных стримов в lStrems то устанавливаем
|
||||
if (!lStreams[from]) {
|
||||
app.debug.log('starting new stream for', from)
|
||||
// запрашиваем локальный стрим, и устанавливаем в lStreams
|
||||
lStreams[from] = await RequestLocalStream()
|
||||
}
|
||||
// полученные дорожки передаем в Call ui
|
||||
callUI.setLocalStreams(Object.values(lStreams))
|
||||
} catch (e) {
|
||||
app.debug.error('Error requesting local stream', e);
|
||||
// если что-то не получилось то обрываем вызов
|
||||
initiateCallEnd();
|
||||
return;
|
||||
}
|
||||
// создаем новый RTCPeerConnection с конфигом ice серверов
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
|
||||
// получаем все локальные треки и добавляем их в RTCPeerConnection
|
||||
lStreams[from].stream.getTracks().forEach(track => {
|
||||
pc.addTrack(track, lStreams[from].stream);
|
||||
});
|
||||
// Обработка ICE-кандидатов
|
||||
console.log("should generate ice");
|
||||
|
||||
// Когда получаем локальные ice кандидаты эмитим их через сокет
|
||||
pc.onicecandidate = (event) => {
|
||||
console.log("GENERATING ICE CANDIDATE", event);
|
||||
if (event.candidate) {
|
||||
socket.emit('webrtc_ice_candidate', { to: from, candidate: event.candidate });
|
||||
socket.emit('webrtc_call_ice_candidate', { from, candidate: event.candidate });
|
||||
}
|
||||
};
|
||||
// Обработка входящего медиапотока
|
||||
|
||||
// когда получаем удаленный поток, добавляем его в call ui
|
||||
pc.ontrack = (event) => {
|
||||
const rStream = event.streams[0];
|
||||
if (rStream && callUI) {
|
||||
|
||||
callUI.addRemoteStream(rStream, from);
|
||||
const onInteraction = () => {
|
||||
callUI?.playRemote();
|
||||
|
|
@ -578,33 +624,26 @@ export default class Assist {
|
|||
document.addEventListener('click', onInteraction);
|
||||
}
|
||||
};
|
||||
// Сохраняем соединение
|
||||
|
||||
// Сохраняем соединение с звонящим
|
||||
this.calls[from] = pc;
|
||||
// устанавливаем remote description, создаём answer
|
||||
console.log('1111111', offer);
|
||||
|
||||
// устанавливаем remote description на входящий запрос
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
console.log('2222222');
|
||||
// создаем ответ на входящий запрос
|
||||
const answer = await pc.createAnswer();
|
||||
// устанавливаем ответ как локальный
|
||||
await pc.setLocalDescription(answer);
|
||||
socket.emit('webrtc_call_answer', { to: from, answer });
|
||||
if (!callUI) {
|
||||
callUI = new CallWindow(app.debug.error, this.options.callUITemplate)
|
||||
callUI.setVideoToggleCallback((args: { enabled: boolean }) =>
|
||||
this.emit('videofeed', { streamId: from, enabled: args.enabled })
|
||||
);
|
||||
}
|
||||
callUI.showControls(initiateCallEnd)
|
||||
if (!annot) {
|
||||
annot = new AnnotationCanvas()
|
||||
annot.mount()
|
||||
}
|
||||
callUI.setLocalStreams(Object.values(lStreams))
|
||||
// Обработка ошибок соединения
|
||||
// передаем ответ
|
||||
socket.emit('webrtc_call_answer', { from, answer });
|
||||
|
||||
// Если меняется стейт на ощибку обрываем звонок
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
||||
initiateCallEnd();
|
||||
}
|
||||
};
|
||||
|
||||
// Обновление трека при изменении локального видео
|
||||
lStreams[from].onVideoTrack(vTrack => {
|
||||
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
|
||||
|
|
@ -614,12 +653,17 @@ export default class Assist {
|
|||
}
|
||||
sender.replaceTrack(vTrack)
|
||||
})
|
||||
|
||||
// если пользователеь закрыл вкладку или переключился, то завершаем вызов
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
initiateCallEnd()
|
||||
})
|
||||
|
||||
// когда все установилось то стейт переводим в true
|
||||
this.setCallingState(CallingState.True)
|
||||
if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() }
|
||||
const callingPeerIdsNow = Object.keys(this.calls)
|
||||
// в session storage записываем всех с кем установлен вызов
|
||||
sessionStorage.setItem(this.options.session_calling_peer_key, JSON.stringify(callingPeerIdsNow))
|
||||
this.emit('UPDATE_SESSION', { agentIds: callingPeerIdsNow, isCallActive: true })
|
||||
} catch (reason) {
|
||||
|
|
@ -642,39 +686,45 @@ export default class Assist {
|
|||
});
|
||||
};
|
||||
|
||||
// функция завершения вызова
|
||||
const initiateCallEnd = () => {
|
||||
this.emit('call_end');
|
||||
handleCallEnd();
|
||||
};
|
||||
|
||||
const startCanvasStream = (stream: MediaStream, id: number) => {
|
||||
const canvasPID = `${app.getProjectKey()}-${sessionId}-${id}`;
|
||||
if (!this.canvasPeers[id]) {
|
||||
this.canvasPeers[id] = new RTCPeerConnection(this.options.config);
|
||||
}
|
||||
const pc = this.canvasPeers[id];
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
// Добавить отправку ICE-кандидата через socket
|
||||
}
|
||||
};
|
||||
Object.values(this.agents).forEach(agent => {
|
||||
if (agent.agentInfo) {
|
||||
// реализовать сигналинг для canvas чтобы агент создал свой RTCPeerConnection для canvas
|
||||
stream.getTracks().forEach(track => {
|
||||
pc.addTrack(track, stream);
|
||||
const startCanvasStream = async (stream: MediaStream, id: number) => {
|
||||
// const canvasPID = `${app.getProjectKey()}-${sessionId}-${id}`;
|
||||
// const target = `${agent.agentInfo.peerId}-${agent.agentInfo.id}-canvas`;
|
||||
for (const agent of Object.values(this.agents)) {
|
||||
if (!agent.agentInfo) return;
|
||||
|
||||
const uniqueId = `${agent.agentInfo.peerId}-${agent.agentInfo.id}-canvas-${id}`;
|
||||
|
||||
if (!this.canvasPeers[uniqueId]) {
|
||||
this.canvasPeers[uniqueId] = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
|
||||
} else {
|
||||
app.debug.error('Assist: cant establish canvas peer to agent, no agent info')
|
||||
this.setupPeerListeners(uniqueId);
|
||||
|
||||
stream.getTracks().forEach((track) => {
|
||||
this.canvasPeers[uniqueId]?.addTrack(track, stream);
|
||||
});
|
||||
|
||||
// Создаем SDP offer
|
||||
const offer = await this.canvasPeers[uniqueId].createOffer();
|
||||
await this.canvasPeers[uniqueId].setLocalDescription(offer);
|
||||
|
||||
// Отправляем offer через сервер сигналинга
|
||||
socket.emit('webrtc_canvas_offer', { offer, id: uniqueId });
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
app.nodes.attachNodeCallback((node) => {
|
||||
const id = app.nodes.getID(node)
|
||||
if (id && hasTag(node, 'canvas')) {
|
||||
app.debug.log(`Creating stream for canvas ${id}`)
|
||||
// app.debug.log(`Creating stream for canvas ${id}`)
|
||||
const canvasHandler = new Canvas(
|
||||
node as unknown as HTMLCanvasElement,
|
||||
id,
|
||||
|
|
@ -705,6 +755,20 @@ export default class Assist {
|
|||
});
|
||||
}
|
||||
|
||||
private setupPeerListeners(id: string) {
|
||||
const peer = this.canvasPeers[id];
|
||||
if (!peer) return;
|
||||
// ICE-кандидаты
|
||||
peer.onicecandidate = (event) => {
|
||||
if (event.candidate && this.socket) {
|
||||
this.socket.emit('webrtc_canvas_ice_candidate', {
|
||||
candidate: event.candidate,
|
||||
id,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private playNotificationSound() {
|
||||
if ('Audio' in window) {
|
||||
new Audio('https://static.openreplay.com/tracker-assist/notification.mp3')
|
||||
|
|
@ -715,6 +779,7 @@ export default class Assist {
|
|||
}
|
||||
}
|
||||
|
||||
// очищаем все данные
|
||||
private clean() {
|
||||
// sometimes means new agent connected, so we keep id for control
|
||||
this.remoteControl?.releaseControl(false, true);
|
||||
|
|
@ -722,6 +787,7 @@ export default class Assist {
|
|||
clearTimeout(this.peerReconnectTimeout)
|
||||
this.peerReconnectTimeout = null
|
||||
}
|
||||
this.cleanCanvasConnections();
|
||||
Object.values(this.calls).forEach(pc => pc.close())
|
||||
this.calls = {}
|
||||
if (this.socket) {
|
||||
|
|
@ -733,6 +799,12 @@ export default class Assist {
|
|||
this.canvasNodeCheckers.forEach((int) => clearInterval(int))
|
||||
this.canvasNodeCheckers.clear()
|
||||
}
|
||||
|
||||
private cleanCanvasConnections() {
|
||||
Object.values(this.canvasPeers).forEach(pc => pc?.close())
|
||||
this.canvasPeers = {}
|
||||
this.socket?.emit('webrtc_canvas_restart')
|
||||
}
|
||||
}
|
||||
|
||||
/** simple peers impl
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue