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