SHA256
Compare commits
7
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f9dd245481 | ||
|
|
20677a9090 | ||
|
|
d54cb7507a | ||
|
|
d6b313b582 | ||
|
|
f31cdb413b | ||
|
|
69faf55b2b | ||
|
|
4d9e42205f |
@@ -45,6 +45,11 @@
|
|||||||
- `docs/Solana_Architecture/README.md`
|
- `docs/Solana_Architecture/README.md`
|
||||||
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
||||||
|
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
||||||
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
|
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
||||||
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
|
- `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md`
|
||||||
|
|
||||||
## Документация блокчейна
|
## Документация блокчейна
|
||||||
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.347
|
client.version=1.2.354
|
||||||
server.version=1.2.319
|
server.version=1.2.319
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ sudo apt install -y coturn caddy
|
|||||||
sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET
|
sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
|
По умолчанию этот скрипт настраивает relay-диапазон `49000-53999`.
|
||||||
|
Это стандарт SHiNE: 5000 UDP-портов под TURN relay.
|
||||||
|
|
||||||
## 3. coturn
|
## 3. coturn
|
||||||
|
|
||||||
Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override.
|
Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override.
|
||||||
@@ -44,6 +47,14 @@ no-multicast-peers
|
|||||||
no-cli
|
no-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для SHiNE держать relay-range большим сразу, чтобы не упираться в `create_relay_ioa_sockets: no available ports`.
|
||||||
|
Стандартный диапазон:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
min-port=49000
|
||||||
|
max-port=53999
|
||||||
|
```
|
||||||
|
|
||||||
Включить сервис:
|
Включить сервис:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -83,3 +94,4 @@ sudo ss -lntup | grep -E ':(3478|5349)'
|
|||||||
- TURN credentials и shared secret не коммитить.
|
- TURN credentials и shared secret не коммитить.
|
||||||
- Если TURN домен переносится на другой VPS, менять DNS, а не код приложения.
|
- Если TURN домен переносится на другой VPS, менять DNS, а не код приложения.
|
||||||
- После изменения TURN-адресов обновить серверные/UI настройки, где они используются.
|
- После изменения TURN-адресов обновить серверные/UI настройки, где они используются.
|
||||||
|
- Не сужать relay-range ниже 5000 UDP-портов без явной причины: для SHiNE это уже приводило к отказам звонков на проде `24 июля 2026`.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ TURN нужен для WebRTC-звонков, когда прямое peer-to-pe
|
|||||||
- DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN.
|
- DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN.
|
||||||
- TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки.
|
- TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки.
|
||||||
- `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`.
|
- `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`.
|
||||||
|
- Стандартный relay-range SHiNE: `49000-53999` (`5000` UDP-портов).
|
||||||
- Секреты TURN не хранить в git.
|
- Секреты TURN не хранить в git.
|
||||||
|
|
||||||
## Что проверять
|
## Что проверять
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
SECRET=""
|
SECRET=""
|
||||||
REALM="turn1.shineup.me"
|
REALM="turn1.shineup.me"
|
||||||
MIN_PORT="49160"
|
MIN_PORT="49000"
|
||||||
MAX_PORT="49200"
|
MAX_PORT="53999"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -23,11 +23,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--min-port)
|
--min-port)
|
||||||
MIN_PORT="${2:-49160}"
|
MIN_PORT="${2:-49000}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--max-port)
|
--max-port)
|
||||||
MAX_PORT="${2:-49200}"
|
MAX_PORT="${2:-53999}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -87,6 +87,8 @@ systemctl --no-pager --full status coturn
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "coturn настроен."
|
echo "coturn настроен."
|
||||||
|
echo "Стандартный relay-range SHiNE: ${MIN_PORT}-${MAX_PORT}."
|
||||||
|
echo "Рекомендуемый размер диапазона: не меньше 5000 UDP-портов."
|
||||||
echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp"
|
echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp"
|
||||||
echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:"
|
echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:"
|
||||||
echo " -Dcall.ice.turn.sharedSecret=${SECRET}"
|
echo " -Dcall.ice.turn.sharedSecret=${SECRET}"
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ function nowMs() {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobileBrowser() {
|
||||||
|
const uaDataMobile = navigator?.userAgentData?.mobile;
|
||||||
|
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
|
||||||
|
const ua = String(navigator?.userAgent || '');
|
||||||
|
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRouteAudioOutputInBrowser() {
|
||||||
|
const mediaProto = globalThis?.HTMLMediaElement?.prototype;
|
||||||
|
return Boolean(
|
||||||
|
window.isSecureContext
|
||||||
|
&& isMobileBrowser()
|
||||||
|
&& mediaProto
|
||||||
|
&& typeof mediaProto.setSinkId === 'function'
|
||||||
|
&& navigator?.mediaDevices
|
||||||
|
&& typeof navigator.mediaDevices.selectAudioOutput === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeTimelineText(value) {
|
function sanitizeTimelineText(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
@@ -548,13 +567,23 @@ function getCallStateSnapshot() {
|
|||||||
if (!call) return null;
|
if (!call) return null;
|
||||||
if (call.localUiDismissed) return null;
|
if (call.localUiDismissed) return null;
|
||||||
const callPhase = String(call.phase || '').trim();
|
const callPhase = String(call.phase || '').trim();
|
||||||
|
const canToggleSpeaker = Boolean(
|
||||||
|
callPhase !== 'incoming'
|
||||||
|
&& callPhase !== 'ended'
|
||||||
|
&& canRouteAudioOutputInBrowser()
|
||||||
|
&& call.remoteAudio
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
callId: call.callId,
|
callId: call.callId,
|
||||||
peerLogin: call.peerLogin || '',
|
peerLogin: call.peerLogin || '',
|
||||||
direction: call.direction || 'out',
|
direction: call.direction || 'out',
|
||||||
phase: callPhase,
|
phase: callPhase,
|
||||||
statusText: call.statusText || '',
|
statusText: call.statusText || '',
|
||||||
|
startedAtMs: Number(call.startedAtMs || 0),
|
||||||
|
connectedAtMs: Number(call.connectedAtMs || 0),
|
||||||
muted: Boolean(call.muted),
|
muted: Boolean(call.muted),
|
||||||
|
speakerEnabled: Boolean(call.speakerEnabled),
|
||||||
|
canToggleSpeaker,
|
||||||
canAnswer: callPhase === 'incoming',
|
canAnswer: callPhase === 'incoming',
|
||||||
canDecline: callPhase === 'incoming',
|
canDecline: callPhase === 'incoming',
|
||||||
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
|
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
|
||||||
@@ -658,6 +687,7 @@ function buildCallFactsLine(call, extra = {}) {
|
|||||||
pcSignalingState: pc?.signalingState || '',
|
pcSignalingState: pc?.signalingState || '',
|
||||||
hasLocalStream: Boolean(call?.localStream),
|
hasLocalStream: Boolean(call?.localStream),
|
||||||
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
||||||
|
localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)),
|
||||||
timeline: buildTimelineSummary(call),
|
timeline: buildTimelineSummary(call),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@@ -666,6 +696,60 @@ function buildCallFactsLine(call, extra = {}) {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMediaSnapshot(call) {
|
||||||
|
const localStream = call?.localStream || null;
|
||||||
|
const localTracks = localStream?.getTracks?.() || [];
|
||||||
|
const remoteAudio = call?.remoteAudio || null;
|
||||||
|
const remoteStream = remoteAudio?.srcObject || null;
|
||||||
|
const remoteTracks = remoteStream?.getTracks?.() || [];
|
||||||
|
return {
|
||||||
|
hasLocalStream: Boolean(localStream),
|
||||||
|
localTrackCount: localTracks.length,
|
||||||
|
localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0,
|
||||||
|
localTrackStates: localTracks.map((track) => ({
|
||||||
|
kind: String(track?.kind || ''),
|
||||||
|
enabled: Boolean(track?.enabled),
|
||||||
|
muted: Boolean(track?.muted),
|
||||||
|
readyState: String(track?.readyState || ''),
|
||||||
|
label: String(track?.label || ''),
|
||||||
|
})),
|
||||||
|
hasRemoteAudio: Boolean(remoteAudio),
|
||||||
|
hasRemoteAudioSrcObject: Boolean(remoteStream),
|
||||||
|
remoteTrackCount: remoteTracks.length,
|
||||||
|
remoteTrackStates: remoteTracks.map((track) => ({
|
||||||
|
kind: String(track?.kind || ''),
|
||||||
|
enabled: Boolean(track?.enabled),
|
||||||
|
muted: Boolean(track?.muted),
|
||||||
|
readyState: String(track?.readyState || ''),
|
||||||
|
label: String(track?.label || ''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMediaSnapshot(snapshot) {
|
||||||
|
if (!snapshot) return '';
|
||||||
|
const localSummary = Array.isArray(snapshot.localTrackStates)
|
||||||
|
? snapshot.localTrackStates.map((track) => (
|
||||||
|
`${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}`
|
||||||
|
)).join('|')
|
||||||
|
: '';
|
||||||
|
const remoteSummary = Array.isArray(snapshot.remoteTrackStates)
|
||||||
|
? snapshot.remoteTrackStates.map((track) => (
|
||||||
|
`${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}`
|
||||||
|
)).join('|')
|
||||||
|
: '';
|
||||||
|
return [
|
||||||
|
`localStream=${snapshot.hasLocalStream ? 1 : 0}`,
|
||||||
|
`localTracks=${Number(snapshot.localTrackCount || 0)}`,
|
||||||
|
`localAudio=${Number(snapshot.localAudioTracksCount || 0)}`,
|
||||||
|
`localStates=${localSummary || '-'}`,
|
||||||
|
`remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`,
|
||||||
|
`remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`,
|
||||||
|
`remoteTracks=${Number(snapshot.remoteTrackCount || 0)}`,
|
||||||
|
`remoteStates=${remoteSummary || '-'}`,
|
||||||
|
].join(';');
|
||||||
|
}
|
||||||
|
|
||||||
function getCallDiagnosticsContext(call) {
|
function getCallDiagnosticsContext(call) {
|
||||||
const pc = call?.pc || null;
|
const pc = call?.pc || null;
|
||||||
const nav = typeof navigator !== 'undefined' ? navigator : null;
|
const nav = typeof navigator !== 'undefined' ? navigator : null;
|
||||||
@@ -686,6 +770,7 @@ function getCallDiagnosticsContext(call) {
|
|||||||
const iceGatheringState = pc?.iceGatheringState || '';
|
const iceGatheringState = pc?.iceGatheringState || '';
|
||||||
const currentLocalDescType = pc?.localDescription?.type || '';
|
const currentLocalDescType = pc?.localDescription?.type || '';
|
||||||
const currentRemoteDescType = pc?.remoteDescription?.type || '';
|
const currentRemoteDescType = pc?.remoteDescription?.type || '';
|
||||||
|
const mediaSnapshot = buildMediaSnapshot(call);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
remoteSessionIdPresent: Boolean(call?.remoteSessionId),
|
remoteSessionIdPresent: Boolean(call?.remoteSessionId),
|
||||||
@@ -707,6 +792,7 @@ function getCallDiagnosticsContext(call) {
|
|||||||
localAudioTracksCount: localAudioTracks.length,
|
localAudioTracksCount: localAudioTracks.length,
|
||||||
localAudioTracksEnabledCount: enabledLocalAudioTracks,
|
localAudioTracksEnabledCount: enabledLocalAudioTracks,
|
||||||
localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'),
|
localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'),
|
||||||
|
localTrackStates: summarizeMediaSnapshot(mediaSnapshot),
|
||||||
hasPeerConnection: Boolean(pc),
|
hasPeerConnection: Boolean(pc),
|
||||||
pcConnectionState: pc?.connectionState || '',
|
pcConnectionState: pc?.connectionState || '',
|
||||||
pcIceConnectionState: pc?.iceConnectionState || '',
|
pcIceConnectionState: pc?.iceConnectionState || '',
|
||||||
@@ -808,6 +894,8 @@ async function flushPendingIceCandidates(call) {
|
|||||||
|
|
||||||
async function closeMedia(call) {
|
async function closeMedia(call) {
|
||||||
const pc = call?.pc || null;
|
const pc = call?.pc || null;
|
||||||
|
const beforeSnapshot = buildMediaSnapshot(call);
|
||||||
|
recordCallTimeline(call, 'media_close_begin', summarizeMediaSnapshot(beforeSnapshot));
|
||||||
try {
|
try {
|
||||||
const senders = pc?.getSenders?.() || [];
|
const senders = pc?.getSenders?.() || [];
|
||||||
senders.forEach((sender) => {
|
senders.forEach((sender) => {
|
||||||
@@ -822,6 +910,16 @@ async function closeMedia(call) {
|
|||||||
try { tr?.stop?.(); } catch {}
|
try { tr?.stop?.(); } catch {}
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
if (pc) {
|
||||||
|
pc.ontrack = null;
|
||||||
|
pc.onicecandidate = null;
|
||||||
|
pc.onconnectionstatechange = null;
|
||||||
|
pc.oniceconnectionstatechange = null;
|
||||||
|
pc.onsignalingstatechange = null;
|
||||||
|
pc.onicegatheringstatechange = null;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
try {
|
try {
|
||||||
call.localStream?.getTracks?.()?.forEach((track) => {
|
call.localStream?.getTracks?.()?.forEach((track) => {
|
||||||
try { track.enabled = false; } catch {}
|
try { track.enabled = false; } catch {}
|
||||||
@@ -830,8 +928,15 @@ async function closeMedia(call) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
if (call.remoteAudio) {
|
if (call.remoteAudio) {
|
||||||
|
const remoteTracks = call.remoteAudio.srcObject?.getTracks?.() || [];
|
||||||
|
remoteTracks.forEach((track) => {
|
||||||
|
try { track.enabled = false; } catch {}
|
||||||
|
try { track.stop(); } catch {}
|
||||||
|
});
|
||||||
try { call.remoteAudio.pause?.(); } catch {}
|
try { call.remoteAudio.pause?.(); } catch {}
|
||||||
try { call.remoteAudio.srcObject = null; } catch {}
|
try { call.remoteAudio.srcObject = null; } catch {}
|
||||||
|
try { call.remoteAudio.removeAttribute?.('src'); } catch {}
|
||||||
|
try { call.remoteAudio.load?.(); } catch {}
|
||||||
call.remoteAudio = null;
|
call.remoteAudio = null;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -842,6 +947,14 @@ async function closeMedia(call) {
|
|||||||
call.connectionRouteLabel = '';
|
call.connectionRouteLabel = '';
|
||||||
call.connectionRouteDetails = '';
|
call.connectionRouteDetails = '';
|
||||||
call.pendingRemoteIceCandidates = [];
|
call.pendingRemoteIceCandidates = [];
|
||||||
|
const afterSnapshot = buildMediaSnapshot(call);
|
||||||
|
recordCallTimeline(call, 'media_close_end', summarizeMediaSnapshot(afterSnapshot));
|
||||||
|
await emitDebug(
|
||||||
|
call,
|
||||||
|
'info',
|
||||||
|
'media_closed',
|
||||||
|
`before=${summarizeMediaSnapshot(beforeSnapshot)} | after=${summarizeMediaSnapshot(afterSnapshot)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopReconnectFlow(call) {
|
function stopReconnectFlow(call) {
|
||||||
@@ -1314,8 +1427,10 @@ async function ensurePeerConnection(call) {
|
|||||||
recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`);
|
recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`);
|
||||||
const audio = new Audio();
|
const audio = new Audio();
|
||||||
audio.autoplay = true;
|
audio.autoplay = true;
|
||||||
|
audio.playsInline = true;
|
||||||
audio.srcObject = evt.streams[0];
|
audio.srcObject = evt.streams[0];
|
||||||
call.remoteAudio = audio;
|
call.remoteAudio = audio;
|
||||||
|
notifyCallState();
|
||||||
};
|
};
|
||||||
|
|
||||||
call.pc = pc;
|
call.pc = pc;
|
||||||
@@ -1516,6 +1631,36 @@ export async function toggleMicMuted() {
|
|||||||
await setMicMuted(!call.muted);
|
await setMicMuted(!call.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleSpeakerMode() {
|
||||||
|
const call = getActiveCall();
|
||||||
|
if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false;
|
||||||
|
const audio = call.remoteAudio;
|
||||||
|
if (Boolean(call.speakerEnabled)) {
|
||||||
|
try {
|
||||||
|
await audio.setSinkId('');
|
||||||
|
call.speakerEnabled = false;
|
||||||
|
notifyCallState();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = call.speakerSinkId ? { deviceId: call.speakerSinkId } : undefined;
|
||||||
|
const selected = await navigator.mediaDevices.selectAudioOutput(options);
|
||||||
|
const deviceId = String(selected?.deviceId || '').trim();
|
||||||
|
if (!deviceId) return false;
|
||||||
|
await audio.setSinkId(deviceId);
|
||||||
|
call.speakerSinkId = deviceId;
|
||||||
|
call.speakerEnabled = true;
|
||||||
|
notifyCallState();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) {
|
export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) {
|
||||||
const cleanCallId = String(callId || '').trim();
|
const cleanCallId = String(callId || '').trim();
|
||||||
const cleanPeerLogin = String(peerLogin || '').trim();
|
const cleanPeerLogin = String(peerLogin || '').trim();
|
||||||
|
|||||||
@@ -4,45 +4,236 @@ import {
|
|||||||
hangupActiveCall,
|
hangupActiveCall,
|
||||||
setMicMuted,
|
setMicMuted,
|
||||||
subscribeCallState,
|
subscribeCallState,
|
||||||
|
toggleSpeakerMode,
|
||||||
} from './call-service.js';
|
} from './call-service.js';
|
||||||
|
|
||||||
let shellEl = null;
|
const MINIMIZED_BAR_HEIGHT_PX = 44;
|
||||||
|
|
||||||
|
let rootEl = null;
|
||||||
|
let overlayEl = null;
|
||||||
let panelEl = null;
|
let panelEl = null;
|
||||||
|
let headerEl = null;
|
||||||
let titleEl = null;
|
let titleEl = null;
|
||||||
let statusEl = null;
|
let statusEl = null;
|
||||||
|
let incomingActionsEl = null;
|
||||||
|
let activeActionsEl = null;
|
||||||
|
let speakerBtn = null;
|
||||||
let muteBtn = null;
|
let muteBtn = null;
|
||||||
let acceptBtn = null;
|
let acceptBtn = null;
|
||||||
let declineBtn = null;
|
let declineBtn = null;
|
||||||
let hangupBtn = null;
|
let hangupBtn = null;
|
||||||
|
let minimizeBtn = null;
|
||||||
|
let barEl = null;
|
||||||
|
let barTextEl = null;
|
||||||
|
let barActionsEl = null;
|
||||||
|
let barSpeakerBtn = null;
|
||||||
|
let barMuteBtn = null;
|
||||||
|
let barHangupBtn = null;
|
||||||
let unbind = null;
|
let unbind = null;
|
||||||
|
let tickerId = 0;
|
||||||
|
let currentSnapshot = null;
|
||||||
|
let minimized = false;
|
||||||
|
|
||||||
|
function resolveAppShell() {
|
||||||
|
return document.querySelector('.app-shell') || document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(Math.max(0, Number(value) || 0)).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedMs(ms) {
|
||||||
|
const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
||||||
|
const hours = Math.floor(totalSec / 3600);
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const seconds = totalSec % 60;
|
||||||
|
if (hours > 0) return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
|
||||||
|
return `${pad2(minutes)}:${pad2(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotElapsedMs(snapshot) {
|
||||||
|
const baseMs = Number(snapshot?.connectedAtMs || snapshot?.startedAtMs || 0);
|
||||||
|
if (!baseMs || !Number.isFinite(baseMs)) return 0;
|
||||||
|
return Math.max(0, Date.now() - baseMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBarText(snapshot) {
|
||||||
|
const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь';
|
||||||
|
const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot));
|
||||||
|
return `Звонок с ${peer} · ${elapsed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopEvent(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconSvg(name) {
|
||||||
|
if (name === 'mic-on') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3.5" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 11.5a6 6 0 0 0 12 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M12 17.5v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8.5 20.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||||
|
}
|
||||||
|
if (name === 'mic-off') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3.5" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 11.5a6 6 0 0 0 7.2 5.88" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M12 17.5v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8.5 20.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||||
|
}
|
||||||
|
if (name === 'hangup') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>';
|
||||||
|
}
|
||||||
|
if (name === 'minimize') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 6v12M7.5 10.5 12 6l4.5 4.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
}
|
||||||
|
if (name === 'speaker-on') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 14h4l5 4V6L8 10H4z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 9.5a4.5 4.5 0 0 1 0 5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M18.8 7a8 8 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||||
|
}
|
||||||
|
if (name === 'speaker-off') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonIcon(button, iconName, label) {
|
||||||
|
if (!button) return;
|
||||||
|
button.innerHTML = getIconSvg(iconName);
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.title = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMuteButtons(muted) {
|
||||||
|
const iconName = muted ? 'mic-off' : 'mic-on';
|
||||||
|
const label = muted ? 'Включить микрофон' : 'Выключить микрофон';
|
||||||
|
setButtonIcon(muteBtn, iconName, label);
|
||||||
|
setButtonIcon(barMuteBtn, iconName, label);
|
||||||
|
if (muteBtn) muteBtn.dataset.muted = muted ? '1' : '0';
|
||||||
|
if (barMuteBtn) barMuteBtn.dataset.muted = muted ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSpeakerButtons(enabled) {
|
||||||
|
const iconName = enabled ? 'speaker-on' : 'speaker-off';
|
||||||
|
const label = enabled ? 'Выключить громкую связь' : 'Включить громкую связь';
|
||||||
|
setButtonIcon(speakerBtn, iconName, label);
|
||||||
|
setButtonIcon(barSpeakerBtn, iconName, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMuteClick(event) {
|
||||||
|
stopEvent(event);
|
||||||
|
const nowMuted = event.currentTarget?.dataset?.muted === '1';
|
||||||
|
await setMicMuted(!nowMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEndCallClick(event) {
|
||||||
|
stopEvent(event);
|
||||||
|
if (!currentSnapshot) return;
|
||||||
|
if (currentSnapshot.canDecline && !currentSnapshot.canHangup) {
|
||||||
|
await declineIncomingCall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await hangupActiveCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSpeakerClick(event) {
|
||||||
|
stopEvent(event);
|
||||||
|
await toggleSpeakerMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMinimized(nextValue) {
|
||||||
|
minimized = Boolean(nextValue);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTicker() {
|
||||||
|
const shouldTick = Boolean(currentSnapshot);
|
||||||
|
if (shouldTick && !tickerId) {
|
||||||
|
tickerId = window.setInterval(() => {
|
||||||
|
if (!currentSnapshot) return;
|
||||||
|
render();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
if (!shouldTick && tickerId) {
|
||||||
|
window.clearInterval(tickerId);
|
||||||
|
tickerId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUi() {
|
function ensureUi() {
|
||||||
if (shellEl) return;
|
if (rootEl) return;
|
||||||
|
|
||||||
shellEl = document.createElement('section');
|
rootEl = document.createElement('section');
|
||||||
shellEl.className = 'call-overlay';
|
rootEl.className = 'call-ui-root';
|
||||||
shellEl.hidden = true;
|
rootEl.hidden = true;
|
||||||
|
|
||||||
|
barEl = document.createElement('div');
|
||||||
|
barEl.className = 'call-minimized-bar';
|
||||||
|
barEl.hidden = true;
|
||||||
|
|
||||||
|
barTextEl = document.createElement('button');
|
||||||
|
barTextEl.type = 'button';
|
||||||
|
barTextEl.className = 'call-minimized-bar-text';
|
||||||
|
barTextEl.addEventListener('click', () => {
|
||||||
|
if (!currentSnapshot) return;
|
||||||
|
setMinimized(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
barActionsEl = document.createElement('div');
|
||||||
|
barActionsEl.className = 'call-minimized-bar-actions';
|
||||||
|
|
||||||
|
barSpeakerBtn = document.createElement('button');
|
||||||
|
barSpeakerBtn.type = 'button';
|
||||||
|
barSpeakerBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
||||||
|
barSpeakerBtn.addEventListener('click', handleSpeakerClick);
|
||||||
|
|
||||||
|
barMuteBtn = document.createElement('button');
|
||||||
|
barMuteBtn.type = 'button';
|
||||||
|
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
||||||
|
barMuteBtn.addEventListener('click', handleMuteClick);
|
||||||
|
|
||||||
|
barHangupBtn = document.createElement('button');
|
||||||
|
barHangupBtn.type = 'button';
|
||||||
|
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
|
||||||
|
barHangupBtn.addEventListener('click', handleEndCallClick);
|
||||||
|
|
||||||
|
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn);
|
||||||
|
barEl.append(barTextEl, barActionsEl);
|
||||||
|
|
||||||
|
overlayEl = document.createElement('div');
|
||||||
|
overlayEl.className = 'call-overlay';
|
||||||
|
overlayEl.hidden = true;
|
||||||
|
|
||||||
panelEl = document.createElement('div');
|
panelEl = document.createElement('div');
|
||||||
panelEl.className = 'call-overlay-panel';
|
panelEl.className = 'call-overlay-panel';
|
||||||
|
|
||||||
|
headerEl = document.createElement('div');
|
||||||
|
headerEl.className = 'call-overlay-head';
|
||||||
|
|
||||||
titleEl = document.createElement('h2');
|
titleEl = document.createElement('h2');
|
||||||
titleEl.className = 'call-overlay-title';
|
titleEl.className = 'call-overlay-title';
|
||||||
|
|
||||||
|
minimizeBtn = document.createElement('button');
|
||||||
|
minimizeBtn.type = 'button';
|
||||||
|
minimizeBtn.className = 'call-icon-btn call-icon-btn--secondary call-overlay-minimize-btn';
|
||||||
|
setButtonIcon(minimizeBtn, 'minimize', 'Свернуть звонок');
|
||||||
|
minimizeBtn.addEventListener('click', (event) => {
|
||||||
|
stopEvent(event);
|
||||||
|
if (!currentSnapshot) return;
|
||||||
|
setMinimized(true);
|
||||||
|
});
|
||||||
|
|
||||||
statusEl = document.createElement('div');
|
statusEl = document.createElement('div');
|
||||||
statusEl.className = 'call-overlay-status';
|
statusEl.className = 'call-overlay-status';
|
||||||
|
|
||||||
const controls = document.createElement('div');
|
incomingActionsEl = document.createElement('div');
|
||||||
controls.className = 'call-overlay-controls';
|
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming';
|
||||||
|
|
||||||
|
activeActionsEl = document.createElement('div');
|
||||||
|
activeActionsEl.className = 'call-overlay-controls call-overlay-controls--active';
|
||||||
|
|
||||||
|
speakerBtn = document.createElement('button');
|
||||||
|
speakerBtn.type = 'button';
|
||||||
|
speakerBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
||||||
|
speakerBtn.addEventListener('click', handleSpeakerClick);
|
||||||
|
|
||||||
muteBtn = document.createElement('button');
|
muteBtn = document.createElement('button');
|
||||||
muteBtn.type = 'button';
|
muteBtn.type = 'button';
|
||||||
muteBtn.className = 'secondary-btn';
|
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
||||||
muteBtn.textContent = 'Микрофон';
|
muteBtn.addEventListener('click', handleMuteClick);
|
||||||
muteBtn.addEventListener('click', async () => {
|
|
||||||
const nowMuted = muteBtn.dataset.muted === '1';
|
|
||||||
await setMicMuted(!nowMuted);
|
|
||||||
});
|
|
||||||
|
|
||||||
acceptBtn = document.createElement('button');
|
acceptBtn = document.createElement('button');
|
||||||
acceptBtn.type = 'button';
|
acceptBtn.type = 'button';
|
||||||
@@ -54,51 +245,122 @@ function ensureUi() {
|
|||||||
|
|
||||||
declineBtn = document.createElement('button');
|
declineBtn = document.createElement('button');
|
||||||
declineBtn.type = 'button';
|
declineBtn.type = 'button';
|
||||||
declineBtn.className = 'destructive-btn';
|
declineBtn.className = 'destructive-btn call-decline-btn';
|
||||||
declineBtn.textContent = 'Сбросить';
|
declineBtn.textContent = 'Отклонить';
|
||||||
declineBtn.addEventListener('click', async () => {
|
declineBtn.addEventListener('click', async () => {
|
||||||
await declineIncomingCall();
|
await declineIncomingCall();
|
||||||
});
|
});
|
||||||
|
|
||||||
hangupBtn = document.createElement('button');
|
hangupBtn = document.createElement('button');
|
||||||
hangupBtn.type = 'button';
|
hangupBtn.type = 'button';
|
||||||
hangupBtn.className = 'destructive-btn';
|
hangupBtn.className = 'call-icon-btn call-icon-btn--danger';
|
||||||
hangupBtn.textContent = 'Положить трубку';
|
setButtonIcon(hangupBtn, 'hangup', 'Положить трубку');
|
||||||
hangupBtn.addEventListener('click', async () => {
|
hangupBtn.addEventListener('click', handleEndCallClick);
|
||||||
await hangupActiveCall();
|
|
||||||
});
|
|
||||||
|
|
||||||
controls.append(muteBtn, acceptBtn, declineBtn, hangupBtn);
|
incomingActionsEl.append(acceptBtn, declineBtn);
|
||||||
panelEl.append(titleEl, statusEl, controls);
|
activeActionsEl.append(speakerBtn, muteBtn, hangupBtn);
|
||||||
shellEl.append(panelEl);
|
headerEl.append(titleEl, minimizeBtn);
|
||||||
document.body.append(shellEl);
|
panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl);
|
||||||
|
overlayEl.append(panelEl);
|
||||||
|
rootEl.append(barEl, overlayEl);
|
||||||
|
resolveAppShell().append(rootEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCallState(snapshot) {
|
function applyExpandedState(snapshot) {
|
||||||
ensureUi();
|
overlayEl.hidden = false;
|
||||||
|
barEl.hidden = true;
|
||||||
|
|
||||||
if (!snapshot) {
|
|
||||||
shellEl.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shellEl.hidden = false;
|
|
||||||
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
|
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
|
||||||
statusEl.textContent = snapshot.statusText || '';
|
statusEl.textContent = snapshot.statusText || '';
|
||||||
|
|
||||||
|
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
|
||||||
const muted = Boolean(snapshot.muted);
|
const muted = Boolean(snapshot.muted);
|
||||||
muteBtn.dataset.muted = muted ? '1' : '0';
|
updateMuteButtons(muted);
|
||||||
muteBtn.textContent = muted ? 'Включить микрофон' : 'Выключить микрофон';
|
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
||||||
|
|
||||||
|
minimizeBtn.hidden = incomingStage;
|
||||||
|
minimizeBtn.disabled = incomingStage;
|
||||||
|
|
||||||
|
incomingActionsEl.hidden = !incomingStage;
|
||||||
|
activeActionsEl.hidden = incomingStage;
|
||||||
|
|
||||||
|
speakerBtn.hidden = !snapshot.canToggleSpeaker;
|
||||||
|
speakerBtn.disabled = !snapshot.canToggleSpeaker;
|
||||||
|
|
||||||
muteBtn.hidden = !snapshot.canMute;
|
muteBtn.hidden = !snapshot.canMute;
|
||||||
muteBtn.disabled = !snapshot.canMute;
|
muteBtn.disabled = !snapshot.canMute;
|
||||||
|
|
||||||
acceptBtn.hidden = !snapshot.canAnswer;
|
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
|
||||||
declineBtn.hidden = !snapshot.canDecline;
|
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
|
||||||
|
|
||||||
hangupBtn.hidden = !snapshot.canHangup;
|
hangupBtn.hidden = !snapshot.canHangup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMinimizedState(snapshot) {
|
||||||
|
overlayEl.hidden = true;
|
||||||
|
barEl.hidden = false;
|
||||||
|
barTextEl.textContent = buildBarText(snapshot);
|
||||||
|
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
|
||||||
|
const muted = Boolean(snapshot.muted);
|
||||||
|
updateMuteButtons(muted);
|
||||||
|
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
||||||
|
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
|
||||||
|
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
|
||||||
|
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
|
||||||
|
barMuteBtn.disabled = !snapshot.canMute;
|
||||||
|
const canEnd = !incomingStage && snapshot.canHangup;
|
||||||
|
barHangupBtn.hidden = !canEnd;
|
||||||
|
barHangupBtn.disabled = !canEnd;
|
||||||
|
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
|
||||||
|
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncShellState() {
|
||||||
|
const appShell = resolveAppShell();
|
||||||
|
if (!appShell) return;
|
||||||
|
if (currentSnapshot && minimized) {
|
||||||
|
appShell.classList.add('has-minimized-call');
|
||||||
|
const measuredHeight = Math.max(
|
||||||
|
MINIMIZED_BAR_HEIGHT_PX,
|
||||||
|
Math.ceil(Number(barEl?.offsetHeight || 0)),
|
||||||
|
);
|
||||||
|
appShell.style.setProperty('--call-minimized-bar-height', `${measuredHeight}px`);
|
||||||
|
} else {
|
||||||
|
appShell.classList.remove('has-minimized-call');
|
||||||
|
appShell.style.removeProperty('--call-minimized-bar-height');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
ensureUi();
|
||||||
|
rootEl.hidden = !currentSnapshot;
|
||||||
|
ensureTicker();
|
||||||
|
|
||||||
|
if (!currentSnapshot) {
|
||||||
|
syncShellState();
|
||||||
|
overlayEl.hidden = true;
|
||||||
|
barEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minimized) {
|
||||||
|
applyMinimizedState(currentSnapshot);
|
||||||
|
syncShellState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyExpandedState(currentSnapshot);
|
||||||
|
syncShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCallState(snapshot) {
|
||||||
|
ensureUi();
|
||||||
|
currentSnapshot = snapshot || null;
|
||||||
|
if (!currentSnapshot) {
|
||||||
|
minimized = false;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
export function initCallUiOverlay() {
|
export function initCallUiOverlay() {
|
||||||
ensureUi();
|
ensureUi();
|
||||||
if (unbind) {
|
if (unbind) {
|
||||||
|
|||||||
@@ -1733,16 +1733,134 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-overlay {
|
.call-ui-root[hidden] {
|
||||||
position: fixed;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-ui-root {
|
||||||
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 80;
|
z-index: 80;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-minimized-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: calc(8px + env(safe-area-inset-top)) 14px 8px;
|
||||||
|
border-bottom: 1px solid rgba(159, 255, 196, 0.28);
|
||||||
|
background: linear-gradient(180deg, rgba(30, 138, 79, 0.98), rgba(18, 106, 60, 0.98));
|
||||||
|
box-shadow: 0 10px 24px rgba(5, 30, 16, 0.28);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-minimized-bar-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #f3fff8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-minimized-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(132, 162, 228, 0.42);
|
||||||
|
background: linear-gradient(180deg, rgba(19, 35, 63, 0.9), rgba(14, 26, 50, 0.95));
|
||||||
|
color: #e7efff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
color: #93a6cc;
|
||||||
|
background: linear-gradient(180deg, rgba(18, 30, 54, 0.8), rgba(11, 20, 39, 0.86));
|
||||||
|
border-color: rgba(124, 145, 189, 0.28);
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn--secondary {
|
||||||
|
border-color: rgba(132, 162, 228, 0.42);
|
||||||
|
background: linear-gradient(180deg, rgba(19, 35, 63, 0.9), rgba(14, 26, 50, 0.95));
|
||||||
|
color: #e7efff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn--secondary:hover {
|
||||||
|
border-color: rgba(167, 195, 255, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn--danger {
|
||||||
|
border-color: rgba(255, 129, 147, 0.52);
|
||||||
|
background: linear-gradient(120deg, rgba(188, 42, 65, 0.96), rgba(142, 28, 47, 0.98));
|
||||||
|
color: #fff2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn--danger:hover {
|
||||||
|
border-color: rgba(255, 163, 176, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn--bar {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-icon-btn--bar svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: rgba(6, 10, 16, 0.55);
|
background: rgba(6, 10, 16, 0.55);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-overlay-panel {
|
.call-overlay-panel {
|
||||||
@@ -1756,12 +1874,23 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-overlay-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.call-overlay-title {
|
.call-overlay-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #eff4ff;
|
color: #eff4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-overlay-minimize-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.call-overlay-status {
|
.call-overlay-status {
|
||||||
color: #c8d7f6;
|
color: #c8d7f6;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1771,6 +1900,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-overlay-controls[hidden],
|
||||||
|
.call-overlay-controls--incoming[hidden],
|
||||||
|
.call-overlay-controls--active[hidden],
|
||||||
|
.call-icon-btn[hidden],
|
||||||
|
.call-accept-btn[hidden],
|
||||||
|
.call-decline-btn[hidden],
|
||||||
|
.call-minimized-bar-actions[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-overlay-controls--incoming {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-overlay-controls--active {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-decline-btn {
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-accept-btn {
|
.call-accept-btn {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ body::before {
|
|||||||
width: min(100vw, 430px);
|
width: min(100vw, 430px);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
--call-minimized-bar-height: 0px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
border-left: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
border-right: 1px solid rgba(211, 170, 86, 0.2);
|
||||||
@@ -39,7 +40,7 @@ body::before {
|
|||||||
|
|
||||||
.screen-content {
|
.screen-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: var(--call-minimized-bar-height, 0px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 74px;
|
bottom: 74px;
|
||||||
@@ -52,6 +53,10 @@ body::before {
|
|||||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.has-minimized-call .screen-content {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.screen-content.network-scroll-lock {
|
.screen-content.network-scroll-lock {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user