SHA256
Добавить режимы аудио и видеозвонка в чат
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.5.23
|
client.version=1.5.24
|
||||||
server.version=1.4.9
|
server.version=1.4.9
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ function openChatActionsMenu({
|
|||||||
anchorX = 0,
|
anchorX = 0,
|
||||||
anchorY = 0,
|
anchorY = 0,
|
||||||
onCall,
|
onCall,
|
||||||
|
onVideoCall,
|
||||||
|
onInstantVideoCall,
|
||||||
onClearHistory,
|
onClearHistory,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
}) {
|
}) {
|
||||||
@@ -255,7 +257,9 @@ function openChatActionsMenu({
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
|
||||||
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Видеозвонок</button>
|
||||||
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Сразу видео</button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
||||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,6 +307,14 @@ function openChatActionsMenu({
|
|||||||
close();
|
close();
|
||||||
if (typeof onCall === 'function') await onCall();
|
if (typeof onCall === 'function') await onCall();
|
||||||
});
|
});
|
||||||
|
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
|
||||||
|
close();
|
||||||
|
if (typeof onVideoCall === 'function') await onVideoCall();
|
||||||
|
});
|
||||||
|
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
|
||||||
|
close();
|
||||||
|
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
|
||||||
|
});
|
||||||
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
|
||||||
close();
|
close();
|
||||||
if (typeof onClearHistory === 'function') await onClearHistory();
|
if (typeof onClearHistory === 'function') await onClearHistory();
|
||||||
@@ -803,9 +815,9 @@ export function render({ navigate, route }) {
|
|||||||
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
|
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartCall = async () => {
|
const handleStartCall = async (mode = 'audio') => {
|
||||||
try {
|
try {
|
||||||
await startOutgoingCall(chatId);
|
await startOutgoingCall(chatId, { mode });
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||||
@@ -869,10 +881,10 @@ export function render({ navigate, route }) {
|
|||||||
ariaLabel: 'Позвонить',
|
ariaLabel: 'Позвонить',
|
||||||
className: 'chat-header-icon-btn chat-header-call-btn',
|
className: 'chat-header-icon-btn chat-header-call-btn',
|
||||||
iconNode: createHeaderPhoneHandsetIcon(),
|
iconNode: createHeaderPhoneHandsetIcon(),
|
||||||
onClick: handleStartCall,
|
onClick: () => handleStartCall('audio'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '⋯',
|
label: '⋮',
|
||||||
title: 'Действия чата',
|
title: 'Действия чата',
|
||||||
ariaLabel: 'Открыть меню действий чата',
|
ariaLabel: 'Открыть меню действий чата',
|
||||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
className: 'chat-header-icon-btn chat-header-menu-btn',
|
||||||
@@ -880,7 +892,9 @@ export function render({ navigate, route }) {
|
|||||||
openChatActionsMenu({
|
openChatActionsMenu({
|
||||||
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
|
||||||
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
|
||||||
onCall: handleStartCall,
|
onCall: () => handleStartCall('audio'),
|
||||||
|
onVideoCall: () => handleStartCall('video'),
|
||||||
|
onInstantVideoCall: () => handleStartCall('instant_video'),
|
||||||
onClearHistory: async () => {
|
onClearHistory: async () => {
|
||||||
openChatConfirmModal({
|
openChatConfirmModal({
|
||||||
title: 'Очистить историю?',
|
title: 'Очистить историю?',
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const CAMERA_CONSTRAINTS = Object.freeze({
|
|||||||
frameRate: { ideal: 24, max: 30 },
|
frameRate: { ideal: 24, max: 30 },
|
||||||
facingMode: 'user',
|
facingMode: 'user',
|
||||||
});
|
});
|
||||||
|
const CALL_MODE_AUDIO = 'audio';
|
||||||
|
const CALL_MODE_VIDEO = 'video';
|
||||||
|
const CALL_MODE_INSTANT_VIDEO = 'instant_video';
|
||||||
|
const VIDEO_TRANSPORT_NONE = 'none';
|
||||||
|
const VIDEO_TRANSPORT_SLOT = 'slot';
|
||||||
|
|
||||||
function nowMs() {
|
function nowMs() {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
@@ -103,6 +108,60 @@ function buildConnectStartData(winnerSessionId) {
|
|||||||
return `session=${String(winnerSessionId || '').trim()}`;
|
return `session=${String(winnerSessionId || '').trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCallMode(mode) {
|
||||||
|
const normalized = String(mode || '').trim().toLowerCase();
|
||||||
|
if (normalized === CALL_MODE_AUDIO) return CALL_MODE_AUDIO;
|
||||||
|
if (normalized === CALL_MODE_INSTANT_VIDEO) return CALL_MODE_INSTANT_VIDEO;
|
||||||
|
return CALL_MODE_VIDEO;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoTransportModeForCallMode(mode) {
|
||||||
|
return normalizeCallMode(mode) === CALL_MODE_AUDIO ? VIDEO_TRANSPORT_NONE : VIDEO_TRANSPORT_SLOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstantVideoMode(mode) {
|
||||||
|
return normalizeCallMode(mode) === CALL_MODE_INSTANT_VIDEO;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoCallMode(mode) {
|
||||||
|
const normalized = normalizeCallMode(mode);
|
||||||
|
return normalized === CALL_MODE_VIDEO || normalized === CALL_MODE_INSTANT_VIDEO;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInviteData({ mode } = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
mode: normalizeCallMode(mode),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInviteData(data = '') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(data || '{}'));
|
||||||
|
return {
|
||||||
|
mode: normalizeCallMode(parsed?.mode || CALL_MODE_VIDEO),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
mode: CALL_MODE_VIDEO,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCallTitleText(mode) {
|
||||||
|
return isVideoCallMode(mode) ? 'Видеозвонок' : 'Звонок';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIncomingCallStatusText(peerLogin, mode) {
|
||||||
|
const name = String(peerLogin || '').trim();
|
||||||
|
if (normalizeCallMode(mode) === CALL_MODE_INSTANT_VIDEO) {
|
||||||
|
return `Вам звонит ${name} (сразу видео)`;
|
||||||
|
}
|
||||||
|
if (isVideoCallMode(mode)) {
|
||||||
|
return `Вам звонит ${name} (видеозвонок)`;
|
||||||
|
}
|
||||||
|
return `Вам звонит ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseConnectStartWinnerSessionId(data = '') {
|
function parseConnectStartWinnerSessionId(data = '') {
|
||||||
const raw = String(data || '').trim();
|
const raw = String(data || '').trim();
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
@@ -647,6 +706,8 @@ function getCallStateSnapshot() {
|
|||||||
return {
|
return {
|
||||||
callId: call.callId,
|
callId: call.callId,
|
||||||
peerLogin: call.peerLogin || '',
|
peerLogin: call.peerLogin || '',
|
||||||
|
titleText: getCallTitleText(call.callMode),
|
||||||
|
callMode: normalizeCallMode(call.callMode),
|
||||||
direction: call.direction || 'out',
|
direction: call.direction || 'out',
|
||||||
phase: callPhase,
|
phase: callPhase,
|
||||||
statusText: call.statusText || '',
|
statusText: call.statusText || '',
|
||||||
@@ -720,6 +781,11 @@ function refreshVideoSlotReady(call) {
|
|||||||
return call.videoSlotReady;
|
return call.videoSlotReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVideoSectionInSdp(sdp = '') {
|
||||||
|
const text = String(sdp || '');
|
||||||
|
return text.includes('\nm=video') || text.startsWith('m=video');
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveStatus(call) {
|
function setActiveStatus(call) {
|
||||||
setStatus(call, buildActiveStatusText(call), 'active');
|
setStatus(call, buildActiveStatusText(call), 'active');
|
||||||
}
|
}
|
||||||
@@ -1653,24 +1719,13 @@ async function ensurePeerConnection(call) {
|
|||||||
call.audioSenders.push(sender);
|
call.audioSenders.push(sender);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const blackTrack = ensureBlackVideoTrack(call);
|
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT) {
|
||||||
if (blackTrack) {
|
const blackTrack = ensureBlackVideoTrack(call);
|
||||||
const existingVideoTracks = stream.getVideoTracks?.() || [];
|
if (blackTrack) {
|
||||||
existingVideoTracks.forEach((track) => {
|
await ensureVideoSenderSlot(call, { sourceTrack: blackTrack, profile: 'placeholder' });
|
||||||
if (track.id !== blackTrack.id) {
|
} else {
|
||||||
try { stream.removeTrack(track); } catch {}
|
ensureReservedVideoTransceiver(call);
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
|
|
||||||
stream.addTrack(blackTrack);
|
|
||||||
}
|
}
|
||||||
const sender = pc.addTrack(blackTrack, stream);
|
|
||||||
call.videoSender = sender;
|
|
||||||
call.videoTransceiver = (pc.getTransceivers?.() || []).find((tr) => tr?.sender === sender) || null;
|
|
||||||
bindVideoSenderStream(call, sender);
|
|
||||||
await applyVideoSenderEncodingProfile(sender, 'placeholder');
|
|
||||||
} else {
|
|
||||||
ensureReservedVideoTransceiver(call);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
|
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
|
||||||
@@ -1772,7 +1827,14 @@ async function onAccept(call) {
|
|||||||
try {
|
try {
|
||||||
await notifyPeerSessionsAboutConnectStart(call);
|
await notifyPeerSessionsAboutConnectStart(call);
|
||||||
const pc = await ensurePeerConnection(call);
|
const pc = await ensurePeerConnection(call);
|
||||||
ensureReservedVideoTransceiver(call);
|
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT) {
|
||||||
|
ensureReservedVideoTransceiver(call);
|
||||||
|
}
|
||||||
|
if (call.autoEnableCameraOnConnect && call.cameraEnabled) {
|
||||||
|
try {
|
||||||
|
await applyCameraState(call);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
call.initialOfferSent = true;
|
call.initialOfferSent = true;
|
||||||
@@ -1783,9 +1845,9 @@ async function onAccept(call) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureIncomingNotification(peerLogin) {
|
function ensureIncomingNotification(peerLogin, mode = CALL_MODE_AUDIO) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const text = `Вам звонит ${peerLogin}`;
|
const text = getIncomingCallStatusText(peerLogin, mode);
|
||||||
try {
|
try {
|
||||||
if ('Notification' in window && Notification.permission === 'granted') {
|
if ('Notification' in window && Notification.permission === 'granted') {
|
||||||
new Notification('SHiNE: входящий звонок', { body: text });
|
new Notification('SHiNE: входящий звонок', { body: text });
|
||||||
@@ -1818,6 +1880,8 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|||||||
const fromLogin = String(payload?.fromLogin || '').trim();
|
const fromLogin = String(payload?.fromLogin || '').trim();
|
||||||
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
||||||
const type = Number(payload?.type || TYPES.INVITE);
|
const type = Number(payload?.type || TYPES.INVITE);
|
||||||
|
const inviteMeta = parseInviteData(payload?.data || '');
|
||||||
|
const callMode = inviteMeta.mode;
|
||||||
if (type === TYPES.CONNECT_START) {
|
if (type === TYPES.CONNECT_START) {
|
||||||
const call = getCall(callId);
|
const call = getCall(callId);
|
||||||
if (!call || call.direction !== 'in' || call.phase === 'ended') return call;
|
if (!call || call.direction !== 'in' || call.phase === 'ended') return call;
|
||||||
@@ -1861,9 +1925,12 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|||||||
call = {
|
call = {
|
||||||
callId,
|
callId,
|
||||||
peerLogin: fromLogin,
|
peerLogin: fromLogin,
|
||||||
|
callMode,
|
||||||
|
videoTransportMode: getVideoTransportModeForCallMode(callMode),
|
||||||
|
autoEnableCameraOnConnect: isInstantVideoMode(callMode),
|
||||||
direction: 'in',
|
direction: 'in',
|
||||||
phase: 'incoming',
|
phase: 'incoming',
|
||||||
statusText: `Вам звонит ${fromLogin}`,
|
statusText: getIncomingCallStatusText(fromLogin, callMode),
|
||||||
remoteSessionId: fromSessionId,
|
remoteSessionId: fromSessionId,
|
||||||
timers: {},
|
timers: {},
|
||||||
startedAtMs: nowMs(),
|
startedAtMs: nowMs(),
|
||||||
@@ -1898,9 +1965,15 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|||||||
setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call');
|
setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!call.callMode) call.callMode = callMode;
|
||||||
|
if (!call.videoTransportMode) call.videoTransportMode = getVideoTransportModeForCallMode(call.callMode);
|
||||||
|
if (typeof call.autoEnableCameraOnConnect !== 'boolean') {
|
||||||
|
call.autoEnableCameraOnConnect = isInstantVideoMode(call.callMode);
|
||||||
|
}
|
||||||
|
|
||||||
activeCallId = callId;
|
activeCallId = callId;
|
||||||
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
setStatus(call, getIncomingCallStatusText(fromLogin, call.callMode), 'incoming');
|
||||||
ensureIncomingNotification(fromLogin);
|
ensureIncomingNotification(fromLogin, call.callMode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
|
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
|
||||||
@@ -2072,6 +2145,7 @@ function bindVideoSenderStream(call, sender) {
|
|||||||
async function applyCameraState(call) {
|
async function applyCameraState(call) {
|
||||||
if (!call?.pc) return false;
|
if (!call?.pc) return false;
|
||||||
const pc = call.pc;
|
const pc = call.pc;
|
||||||
|
const hadSlotBefore = call.videoTransportMode === VIDEO_TRANSPORT_SLOT;
|
||||||
let videoTransceiver = call.videoTransceiver || null;
|
let videoTransceiver = call.videoTransceiver || null;
|
||||||
if (!videoTransceiver) {
|
if (!videoTransceiver) {
|
||||||
const transceivers = pc.getTransceivers?.() || [];
|
const transceivers = pc.getTransceivers?.() || [];
|
||||||
@@ -2080,56 +2154,37 @@ async function applyCameraState(call) {
|
|||||||
call.videoSender = videoTransceiver?.sender || call.videoSender || null;
|
call.videoSender = videoTransceiver?.sender || call.videoSender || null;
|
||||||
}
|
}
|
||||||
if (call.cameraEnabled) {
|
if (call.cameraEnabled) {
|
||||||
const videoTrack = await ensureCameraTrack(call);
|
if (call.videoTransportMode !== VIDEO_TRANSPORT_SLOT) {
|
||||||
if (!videoTransceiver) {
|
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
|
||||||
videoTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
|
|
||||||
call.videoTransceiver = videoTransceiver;
|
|
||||||
call.videoSender = videoTransceiver?.sender || null;
|
|
||||||
}
|
}
|
||||||
|
const videoTrack = await ensureCameraTrack(call);
|
||||||
|
await ensureVideoSenderSlot(call, { sourceTrack: videoTrack, profile: 'camera' });
|
||||||
|
videoTransceiver = call.videoTransceiver || videoTransceiver;
|
||||||
try {
|
try {
|
||||||
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
||||||
videoTransceiver.direction = 'sendrecv';
|
videoTransceiver.direction = 'sendrecv';
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (videoTransceiver?.sender) {
|
|
||||||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
|
||||||
call.videoSender = videoTransceiver.sender;
|
|
||||||
bindVideoSenderStream(call, videoTransceiver.sender);
|
|
||||||
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'camera');
|
|
||||||
} else {
|
|
||||||
const sender = pc.addTrack(videoTrack, call.localStream);
|
|
||||||
call.videoSender = sender;
|
|
||||||
bindVideoSenderStream(call, sender);
|
|
||||||
await applyVideoSenderEncodingProfile(sender, 'camera');
|
|
||||||
}
|
|
||||||
videoTrack.enabled = true;
|
videoTrack.enabled = true;
|
||||||
} else {
|
} else {
|
||||||
const blackTrack = ensureBlackVideoTrack(call);
|
const blackTrack = ensureBlackVideoTrack(call);
|
||||||
if (call.localStream && blackTrack) {
|
if (call.localStream && blackTrack) {
|
||||||
const existingVideoTracks = call.localStream.getVideoTracks?.() || [];
|
syncLocalStreamVideoTrack(call, blackTrack, { stopRemoved: false });
|
||||||
existingVideoTracks.forEach((track) => {
|
|
||||||
if (track.id !== blackTrack.id) {
|
|
||||||
try { call.localStream.removeTrack(track); } catch {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
|
|
||||||
call.localStream.addTrack(blackTrack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
||||||
videoTransceiver.direction = 'sendrecv';
|
videoTransceiver.direction = 'sendrecv';
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (videoTransceiver?.sender) {
|
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT && blackTrack) {
|
||||||
try { await videoTransceiver.sender.replaceTrack(blackTrack || null); } catch {}
|
await ensureVideoSenderSlot(call, { sourceTrack: blackTrack, profile: 'placeholder' });
|
||||||
call.videoSender = videoTransceiver.sender;
|
|
||||||
bindVideoSenderStream(call, videoTransceiver.sender);
|
|
||||||
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'placeholder');
|
|
||||||
}
|
}
|
||||||
await disableCameraTrack(call);
|
await disableCameraTrack(call);
|
||||||
}
|
}
|
||||||
notifyCallState();
|
notifyCallState();
|
||||||
|
if (!hadSlotBefore && call.videoTransportMode === VIDEO_TRANSPORT_SLOT && !refreshVideoSlotReady(call)) {
|
||||||
|
await renegotiateCall(call, 'upgrade_to_video_slot');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2164,6 +2219,55 @@ export async function setCameraEnabled(enabled) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncLocalStreamVideoTrack(call, preferredTrack, { stopRemoved = false } = {}) {
|
||||||
|
if (!call?.localStream || !preferredTrack) return;
|
||||||
|
const currentVideoTracks = call.localStream.getVideoTracks?.() || [];
|
||||||
|
currentVideoTracks.forEach((track) => {
|
||||||
|
if (track.id !== preferredTrack.id) {
|
||||||
|
try { call.localStream.removeTrack(track); } catch {}
|
||||||
|
if (stopRemoved) {
|
||||||
|
try { track.stop(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!currentVideoTracks.some((track) => track.id === preferredTrack.id)) {
|
||||||
|
call.localStream.addTrack(preferredTrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVideoSenderSlot(call, { sourceTrack = null, profile = 'placeholder' } = {}) {
|
||||||
|
const pc = call?.pc || null;
|
||||||
|
const stream = call?.localStream || null;
|
||||||
|
if (!pc || !stream) return null;
|
||||||
|
|
||||||
|
const track = sourceTrack || ensureBlackVideoTrack(call);
|
||||||
|
if (!track) return null;
|
||||||
|
syncLocalStreamVideoTrack(call, track, { stopRemoved: false });
|
||||||
|
|
||||||
|
let transceiver = call.videoTransceiver || null;
|
||||||
|
if (!transceiver) {
|
||||||
|
const transceivers = pc.getTransceivers?.() || [];
|
||||||
|
transceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
|
||||||
|
call.videoTransceiver = transceiver;
|
||||||
|
call.videoSender = transceiver?.sender || call.videoSender || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transceiver?.sender) {
|
||||||
|
try { await transceiver.sender.replaceTrack(track); } catch {}
|
||||||
|
call.videoSender = transceiver.sender;
|
||||||
|
bindVideoSenderStream(call, transceiver.sender);
|
||||||
|
await applyVideoSenderEncodingProfile(transceiver.sender, profile);
|
||||||
|
return transceiver.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sender = pc.addTrack(track, stream);
|
||||||
|
call.videoSender = sender;
|
||||||
|
call.videoTransceiver = (pc.getTransceivers?.() || []).find((tr) => tr?.sender === sender) || null;
|
||||||
|
bindVideoSenderStream(call, sender);
|
||||||
|
await applyVideoSenderEncodingProfile(sender, profile);
|
||||||
|
return sender;
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleCameraEnabled() {
|
export async function toggleCameraEnabled() {
|
||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call) return false;
|
if (!call) return false;
|
||||||
@@ -2211,6 +2315,9 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
|
|||||||
call = {
|
call = {
|
||||||
callId: cleanCallId,
|
callId: cleanCallId,
|
||||||
peerLogin: cleanPeerLogin,
|
peerLogin: cleanPeerLogin,
|
||||||
|
callMode: CALL_MODE_AUDIO,
|
||||||
|
videoTransportMode: VIDEO_TRANSPORT_NONE,
|
||||||
|
autoEnableCameraOnConnect: false,
|
||||||
direction: 'in',
|
direction: 'in',
|
||||||
phase: 'incoming',
|
phase: 'incoming',
|
||||||
statusText: 'Debug: responder ждёт offer',
|
statusText: 'Debug: responder ждёт offer',
|
||||||
@@ -2257,6 +2364,9 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|||||||
const call = {
|
const call = {
|
||||||
callId: cleanCallId,
|
callId: cleanCallId,
|
||||||
peerLogin: cleanPeerLogin,
|
peerLogin: cleanPeerLogin,
|
||||||
|
callMode: CALL_MODE_AUDIO,
|
||||||
|
videoTransportMode: VIDEO_TRANSPORT_NONE,
|
||||||
|
autoEnableCameraOnConnect: false,
|
||||||
direction: 'out',
|
direction: 'out',
|
||||||
phase: 'connecting',
|
phase: 'connecting',
|
||||||
statusText: 'Debug: старт соединения',
|
statusText: 'Debug: старт соединения',
|
||||||
@@ -2300,7 +2410,7 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOutgoingCall(peerLogin) {
|
export async function startOutgoingCall(peerLogin, options = {}) {
|
||||||
const cleanPeer = String(peerLogin || '').trim();
|
const cleanPeer = String(peerLogin || '').trim();
|
||||||
if (!cleanPeer) return;
|
if (!cleanPeer) return;
|
||||||
|
|
||||||
@@ -2310,6 +2420,7 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const callId = makeCallId();
|
const callId = makeCallId();
|
||||||
|
const callMode = normalizeCallMode(options?.mode || CALL_MODE_AUDIO);
|
||||||
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
|
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
|
||||||
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
|
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
|
||||||
if (!preflightOk) {
|
if (!preflightOk) {
|
||||||
@@ -2319,6 +2430,9 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
const call = {
|
const call = {
|
||||||
callId,
|
callId,
|
||||||
peerLogin: cleanPeer,
|
peerLogin: cleanPeer,
|
||||||
|
callMode,
|
||||||
|
videoTransportMode: getVideoTransportModeForCallMode(callMode),
|
||||||
|
autoEnableCameraOnConnect: isInstantVideoMode(callMode),
|
||||||
direction: 'out',
|
direction: 'out',
|
||||||
phase: 'searching',
|
phase: 'searching',
|
||||||
statusText: 'Ищем пользователя…',
|
statusText: 'Ищем пользователя…',
|
||||||
@@ -2334,7 +2448,7 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
videoSender: null,
|
videoSender: null,
|
||||||
videoTransceiver: null,
|
videoTransceiver: null,
|
||||||
muted: false,
|
muted: false,
|
||||||
cameraEnabled: false,
|
cameraEnabled: isInstantVideoMode(callMode),
|
||||||
videoEverActivated: false,
|
videoEverActivated: false,
|
||||||
connectionRouteLabel: '',
|
connectionRouteLabel: '',
|
||||||
reconnectInProgress: false,
|
reconnectInProgress: false,
|
||||||
@@ -2364,7 +2478,7 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
}, 35000);
|
}, 35000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE, buildInviteData({ mode: callMode }));
|
||||||
recordCallTimeline(
|
recordCallTimeline(
|
||||||
call,
|
call,
|
||||||
'invite_broadcast_ok',
|
'invite_broadcast_ok',
|
||||||
@@ -2382,7 +2496,7 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
||||||
if (recovered) {
|
if (recovered) {
|
||||||
try {
|
try {
|
||||||
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE, buildInviteData({ mode: callMode }));
|
||||||
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
||||||
setStatus(call, 'Вызываем…', 'ringing');
|
setStatus(call, 'Вызываем…', 'ringing');
|
||||||
}
|
}
|
||||||
@@ -2433,6 +2547,9 @@ export async function acceptIncomingCall() {
|
|||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
||||||
recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`);
|
recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`);
|
||||||
|
if (call.autoEnableCameraOnConnect) {
|
||||||
|
call.cameraEnabled = true;
|
||||||
|
}
|
||||||
call.phase = 'connecting';
|
call.phase = 'connecting';
|
||||||
setStatus(call, 'Соединяем…', 'connecting');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
cleanupTimers(call);
|
cleanupTimers(call);
|
||||||
@@ -2587,7 +2704,16 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
await pc.setLocalDescription({ type: 'rollback' });
|
await pc.setLocalDescription({ type: 'rollback' });
|
||||||
}
|
}
|
||||||
await pc.setRemoteDescription(incomingOffer);
|
await pc.setRemoteDescription(incomingOffer);
|
||||||
ensureReservedVideoTransceiver(call);
|
if (hasVideoSectionInSdp(incomingOffer?.sdp || '')) {
|
||||||
|
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
|
||||||
|
ensureReservedVideoTransceiver(call);
|
||||||
|
await ensureVideoSenderSlot(call, { sourceTrack: ensureBlackVideoTrack(call), profile: 'placeholder' });
|
||||||
|
if (call.autoEnableCameraOnConnect && call.cameraEnabled) {
|
||||||
|
try {
|
||||||
|
await applyCameraState(call);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
syncRemoteReceiverVideoTracks(call);
|
syncRemoteReceiverVideoTracks(call);
|
||||||
await flushPendingIceCandidates(call);
|
await flushPendingIceCandidates(call);
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
@@ -2625,7 +2751,10 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
||||||
ensureReservedVideoTransceiver(call);
|
if (hasVideoSectionInSdp(pc.remoteDescription?.sdp || '')) {
|
||||||
|
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
|
||||||
|
ensureReservedVideoTransceiver(call);
|
||||||
|
}
|
||||||
syncRemoteReceiverVideoTracks(call);
|
syncRemoteReceiverVideoTracks(call);
|
||||||
await flushPendingIceCandidates(call);
|
await flushPendingIceCandidates(call);
|
||||||
refreshVideoSlotReady(call);
|
refreshVideoSlotReady(call);
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ function getSnapshotElapsedMs(snapshot) {
|
|||||||
function buildBarText(snapshot) {
|
function buildBarText(snapshot) {
|
||||||
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
|
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
|
||||||
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot));
|
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot));
|
||||||
return `Звонок с ${peer} · ${elapsed}`;
|
const kind = String(snapshot?.titleText || 'Звонок').trim() || 'Звонок';
|
||||||
|
return `${kind} с ${peer} · ${elapsed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopEvent(event) {
|
function stopEvent(event) {
|
||||||
@@ -336,7 +337,7 @@ function applyExpandedState(snapshot) {
|
|||||||
overlayEl.hidden = false;
|
overlayEl.hidden = false;
|
||||||
barEl.hidden = true;
|
barEl.hidden = true;
|
||||||
|
|
||||||
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
|
titleEl.textContent = `${snapshot.titleText || 'Звонок'}: ${snapshot.peerLogin || 'пользователь'}`;
|
||||||
statusEl.textContent = snapshot.statusText || '';
|
statusEl.textContent = snapshot.statusText || '';
|
||||||
|
|
||||||
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
|
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
|
||||||
|
|||||||
Reference in New Issue
Block a user