Compare commits

...
7 Commits
9 changed files with 619 additions and 72 deletions
@@ -214,8 +214,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
first.setCreatedAtMs(base.createdAtMs);
versions.add(first);
if (row.msgSubType == MsgSubType.TEXT_REPLY || ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
if (row.msgSubType == MsgSubType.TEXT_REPLY
|| row.msgSubType == MsgSubType.TEXT_RATING
|| ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) {
short editType = (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_RATING)
? MsgSubType.TEXT_EDIT_REPLY
: MsgSubType.TEXT_EDIT_POST;
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
@@ -128,17 +128,26 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setAuthorLogin(rs.getString("login"));
item.setAuthorBlockchainName(rs.getString("bch_name"));
item.setCreatedAtMs(entry.timestamp * 1000L);
item.setText(statusBody.message == null ? "" : statusBody.message);
item.setLikesCount(0);
item.setLikedByMe(false);
item.setRepliesCount(0);
item.setRatingsCount(0);
item.setVersionsTotal(1);
item.setVersions(new ArrayList<>());
item.setTargetBlockchainName(statusBody.toBchName());
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c,
rs.getString("bch_name"),
rs.getInt("block_number"),
rs.getBytes("block_hash"),
statusBody.message == null ? "" : statusBody.message,
entry.timestamp * 1000L
);
item.setVersions(versions);
item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
out.add(item);
}
@@ -147,6 +156,56 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
return out;
}
private List<Net_GetChannelMessages_Response.VersionItem> loadVersionsForDiaryItem(Connection c,
String ownerBch,
int originalBlockNumber,
byte[] originalBlockHash,
String originalText,
long originalCreatedAtMs) throws Exception {
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
first.setVersionIndex(1);
first.setBlockNumber(originalBlockNumber);
first.setBlockHash(ChannelsReadSupport.toHex(originalBlockHash));
first.setText(originalText == null ? "" : originalText);
first.setCreatedAtMs(originalCreatedAtMs);
versions.add(first);
try (PreparedStatement ps = c.prepareStatement("""
SELECT block_number, block_hash, block_bytes
FROM blocks
WHERE bch_name = ?
AND msg_type = ?
AND msg_sub_type = ?
AND to_block_number = ?
AND to_block_hash = ?
AND (to_bch_name = ? OR to_bch_name IS NULL OR to_bch_name = '')
ORDER BY block_number ASC
""")) {
ps.setString(1, ownerBch);
ps.setInt(2, ChannelsReadSupport.MSG_TYPE_TEXT);
ps.setInt(3, shine.db.MsgSubType.TEXT_EDIT_REPLY);
ps.setInt(4, originalBlockNumber);
ps.setBytes(5, originalBlockHash);
ps.setString(6, ownerBch);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
Net_GetChannelMessages_Response.VersionItem item = new Net_GetChannelMessages_Response.VersionItem();
item.setVersionIndex(versions.size() + 1);
item.setBlockNumber(rs.getInt("block_number"));
item.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash")));
item.setText(editText.text);
item.setCreatedAtMs(editText.createdAtMs);
versions.add(item);
}
}
}
return versions;
}
private void fillTargetDetails(Connection c,
Net_GetChannelMessages_Response.MessageItem item,
String targetBch,
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.5.13
server.version=1.4.9
client.version=1.5.27
server.version=1.4.10
+10 -2
View File
@@ -24,6 +24,7 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110;
@@ -247,6 +248,13 @@ function getChannelMessageTypeMeta(msgSubType) {
}
}
function isEditableAsChannelPostSubType(msgSubType) {
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_POST
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_EXERCISE
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_SERVICE
|| Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_COURSE;
}
function extractChannelContextFromThreadPayload(payload) {
const focusInfo = payload?.focus?.channelInfo;
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
@@ -455,10 +463,10 @@ function openBlockchainDetailsModal(details) {
function resolveNodeText(node) {
return firstNonEmptyText(
latestVersionText(node?.versions),
node?.text,
node?.message,
node?.body,
latestVersionText(node?.versions),
);
}
@@ -751,8 +759,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const replies = Number(node?.repliesCount || 0);
const ratings = Number(node?.ratingsCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
const msgSubType = Number(node?.msgSubType || 0);
const isChannelPost = isEditableAsChannelPostSubType(msgSubType);
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
const parsedText = parseMessageAttachments(text);
+4 -3
View File
@@ -2246,7 +2246,8 @@ export function render({ navigate, route }) {
const onEditPost = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
const isDiaryEdit = isDiarySelector(activeSelector);
if (!isDiaryEdit && (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null)) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockEditMessage({
@@ -2254,8 +2255,8 @@ export function render({ navigate, route }) {
storagePwd,
message: messageRef,
text,
isChannelPost: true,
channel: activeSelector,
isChannelPost: !isDiaryEdit,
channel: isDiaryEdit ? null : activeSelector,
});
softHaptic(12);
showToast('Сообщение обновлено');
+20 -6
View File
@@ -245,6 +245,8 @@ function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onVideoCall,
onInstantVideoCall,
onClearHistory,
onDeleteChat,
}) {
@@ -255,7 +257,9 @@ function openChatActionsMenu({
root.innerHTML = `
<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}">
<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 dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
@@ -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: 'Очистить историю?',
+497 -41
View File
@@ -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,66 @@ 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) {
if (normalizeCallMode(mode) === CALL_MODE_INSTANT_VIDEO) {
return 'Видеозвонок';
}
if (normalizeCallMode(mode) === CALL_MODE_VIDEO) {
return 'Звонок с поддержкой видео';
}
return 'Звонок';
}
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 +712,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 || '',
@@ -661,9 +728,14 @@ function getCallStateSnapshot() {
canDecline: callPhase === 'incoming',
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting',
hasLocalVideo: localVideoTracks.some((track) => track?.readyState === 'live' && track?.enabled !== false),
hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live'),
localPreviewStream: call.localStream || null,
hasLocalVideo: Boolean(
call.cameraEnabled
&& call.localVideoTrack
&& call.localVideoTrack.readyState === 'live'
&& call.localVideoTrack.enabled !== false
),
hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live' && !track?.muted),
localPreviewStream: call.cameraEnabled ? (call.localStream || null) : null,
remoteMediaStream: call.remoteMediaStream || null,
};
}
@@ -701,7 +773,23 @@ function setStatus(call, statusText, phase = '') {
function buildActiveStatusText(call) {
const route = String(call?.connectionRouteLabel || '').trim();
return route ? `Разговор идёт (${route})` : 'Разговор идёт';
const videoSlotMarker = call?.videoSlotReady ? ' +' : '';
return route ? `Разговор идёт (${route}${videoSlotMarker})` : 'Разговор идёт';
}
function refreshVideoSlotReady(call) {
if (!call?.pc) return false;
const localSdp = String(call.pc.localDescription?.sdp || '');
const remoteSdp = String(call.pc.remoteDescription?.sdp || '');
const hasVideoInLocal = localSdp.includes('\nm=video') || localSdp.startsWith('m=video');
const hasVideoInRemote = remoteSdp.includes('\nm=video') || remoteSdp.startsWith('m=video');
call.videoSlotReady = Boolean(hasVideoInLocal && hasVideoInRemote && call.videoTransceiver);
return call.videoSlotReady;
}
function hasVideoSectionInSdp(sdp = '') {
const text = String(sdp || '');
return text.includes('\nm=video') || text.startsWith('m=video');
}
function setActiveStatus(call) {
@@ -1251,6 +1339,12 @@ async function finalizeCall(call, {
&& Boolean(call.remoteSessionId)
&& String(localReasonCode || '') !== 'completed'
&& String(debugReason || '') !== 'remote_hangup';
const shouldBroadcastPreAcceptHangup =
!suppressRemoteSignal
&& notifyRemoteHangup
&& !call.remoteSessionId
&& call.direction === 'out'
&& !call.connectedAtMs;
if ((notifyRemoteHangup || shouldNotifyRemoteFailure) && call.remoteSessionId) {
try {
@@ -1260,6 +1354,11 @@ async function finalizeCall(call, {
await sendSignal(call, TYPES.HANGUP, dataValue);
} catch {}
}
if (shouldBroadcastPreAcceptHangup) {
try {
await broadcastSignal(call, TYPES.HANGUP, 'caller_cancelled_before_accept');
} catch {}
}
await closeMedia(call);
if (String(localReasonCode || '') === 'completed') {
@@ -1416,6 +1515,75 @@ async function notifyPeerSessionsAboutConnectStart(call) {
}
}
function ensureReservedVideoTransceiver(call) {
const pc = call?.pc || null;
if (!pc) return null;
if (call.videoTransceiver) return call.videoTransceiver;
const transceivers = pc.getTransceivers?.() || [];
const existing = transceivers.find((tr) => tr?.mid !== null && tr?.mid !== undefined && (tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video'))
|| transceivers.find((tr) => tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video')
|| null;
if (existing) {
call.videoTransceiver = existing;
call.videoSender = existing?.sender || call.videoSender || null;
return existing;
}
const created = pc.addTransceiver('video', { direction: 'sendrecv' });
call.videoTransceiver = created;
call.videoSender = created?.sender || null;
return created;
}
function ensureRemoteTrackObservers(call, track) {
if (!call || !track || typeof track !== 'object') return;
if (!call.remoteTrackObserverIds) {
call.remoteTrackObserverIds = new Set();
}
if (call.remoteTrackObserverIds.has(track.id)) return;
call.remoteTrackObserverIds.add(track.id);
track.onended = () => {
if (track.kind === 'video' && call.remoteMediaStream) {
try { call.remoteMediaStream.removeTrack(track); } catch {}
}
notifyCallState();
};
track.onmute = () => notifyCallState();
track.onunmute = () => notifyCallState();
}
function ensureRemoteMediaStream(call) {
if (call?.remoteMediaStream) return call.remoteMediaStream;
const stream = new MediaStream();
if (call) {
call.remoteMediaStream = stream;
}
return stream;
}
function syncRemoteReceiverVideoTracks(call) {
const pc = call?.pc || null;
if (!pc) return false;
const remoteStream = ensureRemoteMediaStream(call);
let changed = false;
const receivers = pc.getReceivers?.() || [];
receivers.forEach((receiver) => {
const track = receiver?.track || null;
if (!track || track.kind !== 'video' || track.readyState === 'ended') return;
const hasTrack = remoteStream.getVideoTracks().some((existing) => existing.id === track.id);
if (!hasTrack) {
try {
remoteStream.addTrack(track);
changed = true;
} catch {}
}
ensureRemoteTrackObservers(call, track);
});
if (call.remoteAudio) {
call.remoteAudio.srcObject = remoteStream;
}
return changed;
}
async function ensurePeerConnection(call) {
if (call.pc) return call.pc;
@@ -1479,6 +1647,7 @@ async function ensurePeerConnection(call) {
if (!call.connectedAtMs) {
call.connectedAtMs = nowMs();
}
refreshVideoSlotReady(call);
setActiveStatus(call);
startTransportProbe(call);
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
@@ -1538,11 +1707,25 @@ async function ensurePeerConnection(call) {
}
};
pc.onsignalingstatechange = () => {
const state = String(pc.signalingState || '');
recordCallTimeline(call, 'pc_signaling_state', `state=${state || ''}`);
if (state === 'stable') {
if (syncRemoteReceiverVideoTracks(call)) {
notifyCallState();
}
}
if (state === 'stable' && call.pendingNegotiationReason) {
void renegotiateCall(call, `signaling_stable:${call.pendingNegotiationReason}`);
}
};
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
call.localStream = stream;
call.audioSenders = [];
call.videoSender = null;
call.videoTransceiver = null;
call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
@@ -1553,6 +1736,27 @@ async function ensurePeerConnection(call) {
call.audioSenders.push(sender);
}
});
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT) {
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);
}
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');
recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn');
@@ -1573,18 +1777,18 @@ async function ensurePeerConnection(call) {
}
}
call.remoteMediaStream = remoteStream;
if (trackKind === 'video') {
call.videoEverActivated = true;
}
if (!call.remoteAudio) {
const audio = new Audio();
audio.autoplay = true;
audio.playsInline = true;
call.remoteAudio = audio;
}
if (evt?.track) {
evt.track.onended = () => notifyCallState();
evt.track.onmute = () => notifyCallState();
evt.track.onunmute = () => notifyCallState();
}
if (evt?.track) ensureRemoteTrackObservers(call, evt.track);
call.remoteAudio.srcObject = remoteStream;
syncRemoteReceiverVideoTracks(call);
void call.remoteAudio.play?.().catch?.(() => {});
notifyCallState();
};
@@ -1596,27 +1800,47 @@ async function ensurePeerConnection(call) {
return pc;
}
function queueNegotiationReason(call, reason = '') {
if (!call) return '';
const normalized = String(reason || '').trim() || 'pending';
call.pendingNegotiationReason = normalized;
return normalized;
}
async function renegotiateCall(call, reason = '') {
if (!call?.pc || !call.remoteSessionId) return false;
const queuedReason = queueNegotiationReason(call, reason);
if (call.negotiationInProgress) {
recordCallTimeline(call, 'renegotiate_skipped_busy', `reason=${reason || '-'}`);
recordCallTimeline(call, 'renegotiate_queued_busy', `reason=${queuedReason || '-'}`);
return false;
}
const pc = call.pc;
if (pc.signalingState !== 'stable') {
recordCallTimeline(call, 'renegotiate_queued_unstable', `state=${pc.signalingState}; reason=${queuedReason || '-'}`);
return false;
}
const activeReason = String(call.pendingNegotiationReason || queuedReason || 'pending').trim() || 'pending';
call.pendingNegotiationReason = '';
call.negotiationInProgress = true;
try {
const pc = call.pc;
if (pc.signalingState !== 'stable' && pc.localDescription?.type === 'offer') {
recordCallTimeline(call, 'renegotiate_wait_unstable', `state=${pc.signalingState}; reason=${reason || '-'}`);
return false;
}
recordCallTimeline(call, 'renegotiate_start', `reason=${reason || '-'}`);
recordCallTimeline(call, 'renegotiate_start', `reason=${activeReason || '-'}`);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${reason || '-'}`);
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${activeReason || '-'}`);
return true;
} catch (error) {
if (!call.pendingNegotiationReason) {
call.pendingNegotiationReason = activeReason;
}
throw error;
} finally {
call.negotiationInProgress = false;
if (call.pendingNegotiationReason && call.pc?.signalingState === 'stable') {
queueMicrotask(() => {
void renegotiateCall(call, `queued:${call.pendingNegotiationReason}`);
});
}
}
}
@@ -1633,6 +1857,14 @@ async function onAccept(call) {
try {
await notifyPeerSessionsAboutConnectStart(call);
const pc = await ensurePeerConnection(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;
@@ -1643,9 +1875,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 });
@@ -1678,6 +1910,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;
@@ -1721,9 +1955,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(),
@@ -1734,8 +1971,10 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
@@ -1743,6 +1982,7 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
debugRunId: '',
debugRole: '',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
@@ -1755,9 +1995,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}`);
@@ -1855,9 +2101,60 @@ async function ensureCameraTrack(call) {
});
call.localStream.addTrack(videoTrack);
call.localVideoTrack = videoTrack;
call.videoEverActivated = true;
return videoTrack;
}
function ensureBlackVideoTrack(call) {
const existingTrack = call?.placeholderBlackVideoTrack || null;
if (existingTrack && existingTrack.readyState === 'live') {
existingTrack.enabled = true;
return existingTrack;
}
const canvas = document.createElement('canvas');
canvas.width = 160;
canvas.height = 90;
const ctx = canvas.getContext('2d');
if (!ctx || typeof canvas.captureStream !== 'function') {
return null;
}
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const stream = canvas.captureStream(1);
const track = stream.getVideoTracks?.()[0] || null;
if (!track) return null;
call.placeholderBlackCanvas = canvas;
call.placeholderBlackStream = stream;
call.placeholderBlackVideoTrack = track;
return track;
}
async function applyVideoSenderEncodingProfile(sender, mode = 'camera') {
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
return;
}
try {
const params = sender.getParameters() || {};
const encodings = Array.isArray(params.encodings) && params.encodings.length > 0
? [...params.encodings]
: [{}];
const nextEncoding = { ...encodings[0] };
if (mode === 'placeholder') {
nextEncoding.maxBitrate = 12000;
nextEncoding.maxFramerate = 1;
nextEncoding.scaleResolutionDownBy = 1;
nextEncoding.active = true;
} else {
delete nextEncoding.maxBitrate;
delete nextEncoding.maxFramerate;
delete nextEncoding.scaleResolutionDownBy;
nextEncoding.active = true;
}
params.encodings = [nextEncoding];
await sender.setParameters(params);
} catch {}
}
async function disableCameraTrack(call) {
const videoTrack = call?.localVideoTrack || null;
if (videoTrack) {
@@ -1868,36 +2165,104 @@ async function disableCameraTrack(call) {
call.localVideoTrack = null;
}
async function applyCameraState(call, { renegotiate = true, reason = '' } = {}) {
function bindVideoSenderStream(call, sender) {
if (!call?.localStream || !sender || typeof sender.setStreams !== 'function') return;
try {
sender.setStreams(call.localStream);
} catch {}
}
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?.() || [];
let videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
call.videoTransceiver = videoTransceiver;
call.videoSender = videoTransceiver?.sender || call.videoSender || null;
}
if (call.cameraEnabled) {
if (hadSlotBefore) {
const videoTrack = await ensureCameraTrack(call);
if (!videoTransceiver) {
const sender = pc.addTrack(videoTrack, call.localStream);
call.videoSender = sender;
videoTransceiver = transceivers.find((tr) => tr?.sender === sender) || null;
} else {
videoTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
call.videoTransceiver = videoTransceiver;
call.videoSender = videoTransceiver?.sender || null;
}
try {
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
videoTransceiver.direction = 'sendrecv';
}
} catch {}
if (videoTransceiver?.sender) {
await videoTransceiver.sender.replaceTrack(videoTrack);
call.videoSender = videoTransceiver.sender;
}
if (videoTransceiver) {
try { videoTransceiver.direction = 'sendrecv'; } catch {}
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 {
if (videoTransceiver) {
try { await videoTransceiver.sender.replaceTrack(null); } catch {}
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 {}
videoTrack.enabled = true;
}
} else {
const blackTrack = ensureBlackVideoTrack(call);
if (hadSlotBefore) {
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);
}
}
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;
try { videoTransceiver.direction = 'recvonly'; } catch {}
bindVideoSenderStream(call, videoTransceiver.sender);
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'placeholder');
}
} else {
if (call.localStream && blackTrack) {
syncLocalStreamVideoTrack(call, blackTrack, { stopRemoved: false });
}
try {
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
videoTransceiver.direction = 'sendrecv';
}
} catch {}
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT && blackTrack) {
await ensureVideoSenderSlot(call, { sourceTrack: blackTrack, profile: 'placeholder' });
}
}
await disableCameraTrack(call);
}
notifyCallState();
if (renegotiate) {
await renegotiateCall(call, reason || (call.cameraEnabled ? 'camera_on' : 'camera_off'));
if (!hadSlotBefore && call.videoTransportMode === VIDEO_TRANSPORT_SLOT && !refreshVideoSlotReady(call)) {
await renegotiateCall(call, 'upgrade_to_video_slot');
}
return true;
}
@@ -1923,7 +2288,7 @@ export async function setCameraEnabled(enabled) {
if (Boolean(call.cameraEnabled) === nextEnabled) return true;
call.cameraEnabled = nextEnabled;
try {
await applyCameraState(call, { renegotiate: true, reason: nextEnabled ? 'camera_on' : 'camera_off' });
await applyCameraState(call);
notifyCallState();
return true;
} catch (error) {
@@ -1933,6 +2298,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;
@@ -1980,6 +2394,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',
@@ -1993,13 +2410,16 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
debugMode: true,
debugRunId: String(runId || '').trim(),
debugRole: 'responder',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
};
@@ -2023,6 +2443,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: старт соединения',
@@ -2036,13 +2459,16 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
debugMode: true,
debugRunId: String(runId || '').trim(),
debugRole: 'initiator',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
@@ -2063,7 +2489,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;
@@ -2073,6 +2499,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) {
@@ -2082,6 +2509,9 @@ export async function startOutgoingCall(peerLogin) {
const call = {
callId,
peerLogin: cleanPeer,
callMode,
videoTransportMode: getVideoTransportModeForCallMode(callMode),
autoEnableCameraOnConnect: isInstantVideoMode(callMode),
direction: 'out',
phase: 'searching',
statusText: 'Ищем пользователя…',
@@ -2095,8 +2525,10 @@ export async function startOutgoingCall(peerLogin) {
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
cameraEnabled: isInstantVideoMode(callMode),
videoEverActivated: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
@@ -2104,6 +2536,7 @@ export async function startOutgoingCall(peerLogin) {
debugRunId: '',
debugRole: '',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
@@ -2124,7 +2557,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',
@@ -2142,7 +2575,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');
}
@@ -2193,6 +2626,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);
@@ -2347,10 +2783,24 @@ export async function handleIncomingCallSignal(evt) {
await pc.setLocalDescription({ type: 'rollback' });
}
await pc.setRemoteDescription(incomingOffer);
if (hasVideoSectionInSdp(incomingOffer?.sdp || '')) {
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
ensureReservedVideoTransceiver(call);
if (!call.videoSender) {
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();
await pc.setLocalDescription(answer);
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
refreshVideoSlotReady(call);
setStatus(call, 'Соединяем…', 'connecting');
notifyCallState();
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
@@ -2382,7 +2832,13 @@ export async function handleIncomingCallSignal(evt) {
return;
}
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
if (hasVideoSectionInSdp(pc.remoteDescription?.sdp || '')) {
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
ensureReservedVideoTransceiver(call);
}
syncRemoteReceiverVideoTracks(call);
await flushPendingIceCandidates(call);
refreshVideoSlotReady(call);
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
setStatus(call, 'Соединяем…', 'connecting');
notifyCallState();
+5 -3
View File
@@ -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);
@@ -367,7 +368,8 @@ function applyExpandedState(snapshot) {
mediaStageEl.hidden = incomingStage;
const remoteStream = snapshot.remoteMediaStream || null;
const localStream = snapshot.localPreviewStream || null;
if (remoteVideoEl.srcObject !== remoteStream) remoteVideoEl.srcObject = remoteStream;
const remoteVideoSource = snapshot.hasRemoteVideo ? remoteStream : null;
if (remoteVideoEl.srcObject !== remoteVideoSource) remoteVideoEl.srcObject = remoteVideoSource;
if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream;
remoteVideoEl.hidden = !snapshot.hasRemoteVideo;
remoteVideoPlaceholderEl.hidden = snapshot.hasRemoteVideo;
+4 -1
View File
@@ -118,7 +118,10 @@ export function parseDmTechBlocks(rawText = '') {
const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec };
callSummary = {
status: 'completed',
durationSec,
};
} else {
callSummary = {
status: 'failed',