🐛 fix(calls): устройства отпускаются после отбоя, экран звонка на токенах

Утечка камеры и микрофона у оператора:
- терминальный статус приходит и поллингом состояния, а не только по RTC-сокету
  (до «Присоединиться» сокет ещё не открыт), поэтому `onCallState` → `stop()` не
  срабатывал. Экран уходил в `status`, а оба выхода с него RTC не глушили:
  `endAndClose` для этого режима пропускает `finish()`, а «Закрыть» зовёт
  `close()`, который только прячет модалку. Оверлей при этом остаётся
  смонтированным, так что и cleanup хука не отрабатывал — камера оператора
  горела до переключения диалога. Оба оверлея явно освобождают устройства,
  когда оверлей закрыт или звонок завершён.

call-view.css переведён на токены дизайн-системы:
- радиус карточки 24px → `--radius-4xl` (14px), тень → `--shadow-lg`,
  рамки → `--border-default`, второстепенный текст → `--text-secondary`,
  круглые кнопки → `--n-7`/`--n-9`/`--n-3`, выключенные → `--error-*`,
  таймер → `--error-bg`/`--error-text` + 12.5px и моноширинный шрифт,
  таймер переподключения → `--warning-text` и точка `--warning`,
  кнопки precall → `--radius-lg` (9px), 14px и `--shadow-primary`;
- радиус контейнера модалки взят из того же токена: снаружи виден именно он;
- хардкод оставлен там, где он есть и в макете — сцена видео и слои поверх неё
  от темы не зависят.

Побочный эффект: карточка звонка у оператора теперь следует тёмной теме
internal-ui, как остальные поверхности. Проверено в обеих темах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 03:57:32 +03:00
1 parent 3461556446
commit 48003f9bab
4 files changed
+50 -22

No files matched your search

@@ -47,6 +47,12 @@ export function AudioCallOverlay(props: Props) {
if (call.status === "ACCEPTED" || call.status === "CONNECTING") void rtc.start();
}, [props.open, props.access, call?.status, rtc.connectionPhase, rtc.start]);
// Микрофон держим только пока оверлей открыт и звонок не завершён: терминал
// приходит и поллингом состояния, а не только по RTC-сокету (VideoCallOverlay).
useEffect(() => {
if (!props.open || isTerminalCallStatus(call?.status)) rtc.stop();
}, [props.open, call?.status, rtc.stop]);
if (!props.dialog) return null;
const channel = providerMeta[props.dialog.channel];
const subCaption = mode === "active" ? (rtc.micOn ? "Говорите" : "Ваш микрофон выключен") : undefined;
@@ -2,7 +2,7 @@
// из CallOverlay при добавлении аудиозвонка, чтобы CallOverlay остался тонким
// диспетчером по call.kind (NO GOD / separation of concerns).
import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
import { CallView, type CallViewMode, type CallViewStatus, isTerminalCallStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
import { Modal } from "antd";
import { useEffect, useMemo, useState } from "react";
@@ -47,6 +47,14 @@ export function VideoCallOverlay(props: Props) {
});
const elapsed = useElapsed(call?.connectedAt ?? null, call?.status === "ACTIVE");
const mode = resolveMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue);
// Камеру и микрофон держим только пока оверлей открыт и звонок не завершён.
// Терминал приходит и поллингом состояния, а не только по RTC-сокету: до
// «Присоединиться» сокета ещё нет, и без явной остановки камера оператора
// продолжала гореть после отбоя клиента.
useEffect(() => {
if (!props.open || isTerminalCallStatus(call?.status)) rtc.stop();
}, [props.open, call?.status, rtc.stop]);
useLoopingAudio("/audio/ringtone.mp3", props.open && mode === "ringing", 0.5);
const status = useMemo(
() => buildStatus(props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start),
@@ -1,11 +1,15 @@
/* Контейнер модалки — та же карточка звонка, поэтому радиус берётся из того же
токена, что и у `.hub-call-view`: иначе снаружи виден радиус модалки, а не
карточки, и перевод call-view.css на токены оператору не заметен. */
.call-modal .ant-modal-content {
padding: 0;
border-radius: 24px;
border-radius: var(--radius-4xl);
overflow: hidden;
}
.call-modal .hub-call-view {
border: 0;
border-radius: 0;
box-shadow: none;
}
@@ -14,7 +18,7 @@
.audio-call-modal .ant-modal-content {
padding: 0;
background: var(--n-1);
border-radius: 24px;
border-radius: var(--radius-4xl);
overflow: hidden;
}
+29 -19
View File
@@ -1,16 +1,23 @@
.hub-call-view { width:100%; max-width:428px; background:#fff; border:1px solid #e8e8e8; border-radius:24px; box-shadow:0 12px 40px rgba(0,0,0,.12); overflow:hidden; color:#262626; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; }
.hub-call-head { display:flex; align-items:center; gap:11px; padding:12px 15px; border-bottom:1px solid #e8e8e8; }
/* Видеозвонок (baseline «Экран звонка.dc.html»). Значения — токены дизайн-системы
с запасным значением из макета: internal-ui отдаёт свой набор (включая тёмную
тему), web-chat — сокращённый в styles.css, остальное берётся из fallback.
Хардкод остаётся только там, где он есть и в макете: сцена видео и слои поверх
неё не зависят от темы. */
.hub-call-view { width:100%; max-width:428px; background:var(--surface-card,#fff); border:1px solid var(--border-default,#f0f0f0); border-radius:var(--radius-4xl,14px); box-shadow:var(--shadow-lg,0 6px 20px rgba(0,0,0,.12)); overflow:hidden; color:var(--text-body,#262626); font-family:var(--font-sans,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif); }
.hub-call-head { display:flex; align-items:center; gap:11px; padding:12px 15px; border-bottom:1px solid var(--border-default,#f0f0f0); }
.hub-call-avatar { display:grid; place-items:center; box-sizing:border-box; padding:0; border-radius:50%; color:#fff; font-weight:600; line-height:1; text-align:center; flex:none; }
.hub-call-avatar.small { width:38px; height:38px; font-size:14px; }
.hub-call-avatar.big { width:88px; height:88px; font-size:30px; position:relative; z-index:2; box-shadow:0 4px 18px rgba(0,0,0,.35); }
.hub-call-avatar.pip { width:34px; height:34px; font-size:12px; background:#595959; position:absolute; inset:0; margin:auto; }
.hub-call-avatar.pip { width:34px; height:34px; font-size:12px; background:var(--n-3,#595959); position:absolute; inset:0; margin:auto; }
.hub-call-head-info { display:flex; flex:1; min-width:0; flex-direction:column; gap:2px; }
.hub-call-head-info > span { font-size:12px; color:#8c8c8c; }
.hub-call-peer { display:flex; align-items:center; gap:7px; min-width:0; font-size:14px; font-weight:600; color:#1f1f1f; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.hub-call-close { width:34px; height:34px; border-radius:8px; border:1px solid #e8e8e8; background:#fff; color:#8c8c8c; display:flex; align-items:center; justify-content:center; cursor:pointer; flex:none; }
.hub-call-timer { display:flex; align-items:center; gap:6px; padding:4px 9px; border-radius:999px; background:#fff1f0; color:#cf1322; font-size:11.5px; font-weight:600; }
.hub-call-timer.warn { background:#fffbe6; color:#ad6800; }
.hub-call-timer i { width:6px; height:6px; border-radius:50%; background:#ff4d4f; }
.hub-call-head-info > span { font-size:12px; color:var(--text-secondary,#595959); }
.hub-call-peer { display:flex; align-items:center; gap:7px; min-width:0; font-size:14px; font-weight:600; color:var(--text-heading,#1f1f1f); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.hub-call-close { width:34px; height:34px; border-radius:var(--radius-md,8px); border:1px solid var(--border-default,#f0f0f0); background:var(--surface-card,#fff); color:var(--text-secondary,#595959); display:flex; align-items:center; justify-content:center; cursor:pointer; flex:none; }
.hub-call-timer { display:flex; align-items:center; gap:6px; padding:4px 9px; border-radius:var(--radius-pill,999px); background:var(--error-bg,#fff2f0); color:var(--error-text,#cf1322); font-size:12.5px; font-weight:600; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.hub-call-timer.warn { background:var(--warning-bg,#fffbe6); color:var(--warning-text,#d48806); }
.hub-call-timer i { width:6px; height:6px; border-radius:50%; background:var(--error,#ff4d4f); }
.hub-call-timer.warn i { background:var(--warning,#faad14); }
.hub-call-media { position:relative; aspect-ratio:16/9; background:#141414; overflow:hidden; }
.hub-call-remote-video,.hub-call-preview-video,.hub-call-local-video { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.hub-call-preview-video.mirrored,.hub-call-local-video.mirrored { transform:scaleX(-1); }
@@ -23,7 +30,7 @@
.hub-call-avatar-wrap.rings > i { display:block; }
.hub-call-avatar-wrap.rings > i:nth-child(2) { animation-delay:.95s; }
.hub-call-reconnect { position:absolute; top:0; left:0; right:0; z-index:6; display:flex; align-items:center; justify-content:center; gap:9px; padding:9px 12px; background:rgba(20,20,20,.82); color:#fff; font-size:12.5px; font-weight:500; }
.hub-call-name-label { position:absolute; left:12px; bottom:12px; z-index:5; display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:8px; background:rgba(0,0,0,.5); color:#fff; font-size:12px; font-weight:500; }
.hub-call-name-label { position:absolute; left:12px; bottom:12px; z-index:5; display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--radius-md,8px); background:rgba(0,0,0,.5); color:#fff; font-size:12px; font-weight:500; }
.hub-call-name-label svg { width:14px; height:14px; color:#ff7875; }
.hub-call-pip { position:absolute; right:12px; bottom:12px; width:100px; height:66px; z-index:4; overflow:hidden; border:2px solid rgba(255,255,255,.9); border-radius:10px; background:#1f1f1f; box-shadow:0 4px 14px rgba(0,0,0,.4); }
.hub-call-pip b { position:absolute; left:5px; bottom:4px; color:#fff; font-size:10px; text-shadow:0 1px 2px rgba(0,0,0,.6); }
@@ -35,22 +42,25 @@
.hub-call-status strong { color:#fff; font-size:15.5px; line-height:1.3; }
.hub-call-status p { max-width:280px; margin:6px 0 0; color:rgba(255,255,255,.6); font-size:12.5px; line-height:1.5; }
.hub-call-status-actions { display:flex; gap:9px; margin-top:2px; }
.hub-call-status-actions button,.hub-call-join-row button { height:38px; padding:0 16px; border-radius:10px; font:600 13.5px inherit; cursor:pointer; }
.hub-call-status-actions .primary,.hub-call-join-row .primary { border:0; background:#1677ff; color:#fff; }
.hub-call-status-actions button,.hub-call-join-row button { height:38px; padding:0 16px; border-radius:var(--radius-lg,9px); font:600 13.5px inherit; cursor:pointer; }
/* Статус-бар лежит поверх тёмной сцены — его кнопки всегда светлые, как в макете. */
.hub-call-status-actions .primary,.hub-call-join-row .primary { border:0; background:var(--primary,#1677ff); color:#fff; }
.hub-call-status-actions .secondary { border:1px solid rgba(255,255,255,.28); background:transparent; color:#fff; }
.hub-call-ringing-bar { display:flex; flex-direction:column; align-items:center; gap:9px; padding:18px 16px 22px; }
.hub-call-ringing-bar span,.hub-call-device-control span { color:#8c8c8c; font-size:12px; }
.hub-call-ringing-bar span { color:var(--text-secondary,#595959); font-size:12.5px; }
.hub-call-device-control span { color:var(--text-secondary,#595959); font-size:12px; }
.hub-call-precall-bar { display:flex; flex-direction:column; gap:15px; padding:16px 16px 18px; }
.hub-call-device-row { display:flex; justify-content:center; gap:32px; }
.hub-call-device-control { display:flex; flex-direction:column; align-items:center; gap:8px; }
.hub-call-device,.hub-call-round { width:54px; height:54px; display:flex; align-items:center; justify-content:center; border:1px solid #d9d9d9; border-radius:50%; background:#f5f5f5; color:#434343; cursor:pointer; }
.hub-call-device.off { border-color:#ffccc7; background:#fff2f0; color:#cf1322; }
.hub-call-device,.hub-call-round { width:54px; height:54px; display:flex; align-items:center; justify-content:center; border:1px solid var(--n-7,#e8e8e8); border-radius:50%; background:var(--n-9,#f5f5f5); color:var(--n-3,#595959); cursor:pointer; }
.hub-call-device.off { border-color:var(--error-border,#ffccc7); background:var(--error-bg,#fff2f0); color:var(--error-text,#cf1322); }
.hub-call-join-row { display:flex; gap:10px; }
.hub-call-join-row button { flex:1; height:44px; }
.hub-call-join-row .secondary { border:1px solid #d9d9d9; background:#fff; color:#262626; }
.hub-call-join-row button { flex:1; height:44px; font-size:14px; }
.hub-call-join-row .primary { box-shadow:var(--shadow-primary,0 1px 2px rgba(22,119,255,.3)); }
.hub-call-join-row .secondary { border:1px solid var(--n-6,#d9d9d9); background:var(--surface-card,#fff); color:var(--text-body,#262626); }
.hub-call-join-row button:disabled { cursor:default; opacity:.6; }
.hub-call-active-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:16px; }
.hub-call-end { width:60px; height:54px; border:0; border-radius:27px; display:flex; align-items:center; justify-content:center; background:#ff4d4f; color:#fff; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35); }
.hub-call-end { width:60px; height:54px; border:0; border-radius:27px; display:flex; align-items:center; justify-content:center; background:var(--error,#ff4d4f); color:#fff; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35); }
.hub-call-end.round { width:58px; height:58px; border-radius:50%; }
@keyframes hub-call-ring { from { transform:scale(1); opacity:.55; } to { transform:scale(2.3); opacity:0; } }
@media (max-width:480px) {
@@ -62,7 +72,7 @@
/* Полноэкранный режим: карточка на весь экран колонкой, чтобы медиа тянулось,
а шапка и панель кнопок не выходили за нижнюю границу экрана. */
.hub-call-view:fullscreen { max-width:none; width:100vw; height:100vh; border-radius:0; border:0; display:flex; flex-direction:column; background:#fff; }
.hub-call-view:fullscreen { max-width:none; width:100vw; height:100vh; border-radius:0; border:0; display:flex; flex-direction:column; background:var(--surface-card,#fff); }
.hub-call-view:fullscreen .hub-call-head,
.hub-call-view:fullscreen .hub-call-active-bar { flex:none; }
.hub-call-view:fullscreen .hub-call-media { flex:1 1 auto; aspect-ratio:auto; min-height:0; }