🐛 fix(calls): окно аудиозвонка держит пропорцию и переживает обрыв

Вёрстка:
- мобильная сцена брала высоту как `height:100%` от родителя, у которого задан
  только `min-height` — процент схлопывался в высоту по контенту, и окно выходило
  412x427 вместо 412x915. Высота берётся от вьюпорта (vh/dvh), как у видеозвонка;
- на низком десктопном окне карточка 9:16 не влезала и кнопки приёма уезжали за
  фолд — ширина зажата по высоте вьюпорта, пропорция остаётся ровно 9:16;
- операторской модалке нужен `height:auto`, иначе мобильная ветка растягивала
  сцену на весь экран внутри antd Modal;
- пустая «пилюля» канала больше не рисуется без метки.

Жизненный цикл:
- токен из хеша сохраняется в sessionStorage: хеш срезается replaceState, а в
  пути лежит callId, поэтому после F5 звонок было не восстановить;
- обрабатывается CONNECTED от собеседника — UI выходит из «Переподключения»,
  когда обрыв был на его стороне;
- у режима reconnecting появился нижний бар: экран был без единой кнопки;
- клиенту не показывается «Позвонить снова», которого он не может сделать; на
  DECLINED/MISSED/EXPIRED это была единственная кнопка, и уйти было некуда;
- «Повторить» на упавшем соединении рестартует RTC, а не перепроверяет микрофон.

Чистка:
- TERMINAL_CALL_STATUSES стал единственным источником вместо трёх копий набора;
- волна в тишине спокойная: амплитуда растёт от idle к speak по громкости;
- сигналинг и track.enabled вынесены из updater'а setState.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 03:35:20 +03:00
1 parent ee9cbf0995
commit 3461556446
15 files changed
+131 -46

No files matched your search

+11 -1
View File
@@ -59,7 +59,7 @@ export function AudioCallView(props: Props) {
<div className="hub-audio-body">
<div className="hub-audio-top">
<span className="hub-audio-status-label">{props.statusLabel ?? defaultStatusLabel(props.mode, status)}</span>
{props.showChannel !== false && <span className="hub-audio-channel">{props.channelLabel ?? ""}</span>}
{props.showChannel !== false && props.channelLabel && <span className="hub-audio-channel">{props.channelLabel}</span>}
<div className="hub-audio-top-spacer" />
{isActive && <span className="hub-audio-timer">{timer}</span>}
</div>
@@ -101,6 +101,9 @@ export function AudioCallView(props: Props) {
{props.mode === "connecting" && (
<SingleRoundBar label="Отменить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onCancel ?? props.onEnd} />
)}
{props.mode === "reconnecting" && (
<SingleRoundBar label="Завершить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onEnd} />
)}
{isActive && (
<div className="hub-audio-active">
<div className="hub-audio-device-row">
@@ -185,6 +188,13 @@ function StatusBar({ status, onClose, onCallAgain, onRetry, onEnd }: { status: A
if (bar === "reconnect") {
return <SingleRoundBar label="Завершить" tone="decline" icon={<PhoneIcon rotated />} onClick={onEnd} />;
}
if (bar === "close") {
return (
<div className="hub-audio-pill-bar">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
</div>
);
}
if (bar === "retrySingle") {
return (
<div className="hub-audio-pill-bar">
+5 -3
View File
@@ -65,8 +65,10 @@ export function AudioCallWaveform({ stream, micOn = true, active }: Props) {
}
const speaking = active && micOn;
const t = ts / 1000;
const base = speaking ? SPEAK_AMP : IDLE_AMP;
const ampScale = speaking ? (0.35 + 0.65 * level) : 1;
// В тишине волна ровно такая же, как idle: амплитуда растёт от IDLE_AMP к
// SPEAK_AMP строго по громкости. Раньше базой всегда был SPEAK_AMP, и линия
// заметно колыхалась, даже когда собеседник молчал.
const base = speaking ? IDLE_AMP + (SPEAK_AMP - IDLE_AMP) * level : IDLE_AMP;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
@@ -75,7 +77,7 @@ export function AudioCallWaveform({ stream, micOn = true, active }: Props) {
for (let x = 0; x <= W; x += 4) {
const p = x / W;
const env = Math.sin(p * Math.PI); // taper ends
const amp = env * H * 0.36 * base * ampScale * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3));
const amp = env * H * 0.36 * base * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3));
const y = mid + Math.sin(p * 22 + t * 6) * amp + Math.sin(p * 9 - t * 3.3) * amp * 0.5;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
+12 -1
View File
@@ -146,6 +146,17 @@
.hub-audio-pill.solid { flex: 1; border: none; background: #fff; color: #141414; }
.hub-audio-pill.ghost { flex: 1; border: 1px solid rgba(255, 255, 255, .3); background: rgba(255, 255, 255, .08); color: #fff; }
/* На телефоне сцена занимает весь экран. Высота берётся от вьюпорта, а не `100%`:
родитель страницы звонка задаёт только `min-height`, его высота неопределённа,
и процент схлопывался в высоту по контенту — окно выходило ~412x427 вместо
412x915. Пара vh/dvh — как в call-view.css у видеозвонка. */
@media (max-width: 480px) {
.hub-audio-view { max-width: 100%; max-height: none; aspect-ratio: auto; height: 100%; border-radius: 0; }
.hub-audio-view {
max-width: 100%;
max-height: none;
aspect-ratio: auto;
height: 100vh;
height: 100dvh;
border-radius: 0;
}
}
+6 -3
View File
@@ -19,12 +19,15 @@ export type AudioCallStatus = {
};
// Какой нижний бар рисовать для текущего состояния (вне active/incoming/ringing/connecting).
export type AudioBarKind = "ended" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck";
// "close" — только «Закрыть»: сторона, которая не может инициировать звонок (клиент).
export type AudioBarKind = "ended" | "close" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck";
export type AudioStatusEntry = AudioCallStatus;
// Терминальные состояния, приходящие из домена звонков (TERMINAL_CALL_STATUSES).
export const AUDIO_TERMINAL_STATUSES = new Set([
// Единственный источник правды для фронтенда: и аудио-, и видеозвонок, и оператор,
// и RTC-сессия сверяются с этим набором — раньше он был скопирован в четыре места.
export const TERMINAL_CALL_STATUSES = new Set([
"DECLINED",
"CANCELLED",
"MISSED",
@@ -33,7 +36,7 @@ export const AUDIO_TERMINAL_STATUSES = new Set([
"EXPIRED",
]);
export const isAudioTerminal = (status?: string) => Boolean(status && AUDIO_TERMINAL_STATUSES.has(status));
export const isTerminalCallStatus = (status?: string) => Boolean(status && TERMINAL_CALL_STATUSES.has(status));
function dur(prefix: string, duration?: number | null) {
return duration != null && duration > 0 ? `${prefix}Длительность ${formatDuration(duration)}. ` : prefix;
+15 -17
View File
@@ -1,6 +1,8 @@
import { CallRtcClient, type CallSide, type PublicCallState, type RtcConnectionPhase } from "@chatballs/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import { isTerminalCallStatus } from "./audioCallStates";
export type CallMediaIssue = "none" | "video" | "devices" | "unsupported";
type Options = {
@@ -13,8 +15,6 @@ type Options = {
onCallState: (call: PublicCallState) => void;
};
const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]);
export function useCallRtcSession(options: Options) {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
@@ -135,7 +135,7 @@ export function useCallRtcSession(options: Options) {
handlers: {
onCallState: (call) => {
onCallStateRef.current(call);
if (TERMINAL.has(call.status)) queueMicrotask(stop);
if (isTerminalCallStatus(call.status)) queueMicrotask(stop);
},
onRemoteStream: setRemoteStream,
onRemoteMedia: ({ mic, cam }) => {
@@ -157,23 +157,21 @@ export function useCallRtcSession(options: Options) {
return pending;
}, [options.accessToken, options.iceServers, options.side, prepare, stop]);
// Побочные эффекты (сигналинг + track.enabled) держим снаружи updater'а: React в
// StrictMode вызывает updater дважды, а внутри него они бы отработали по два раза.
const toggleMic = useCallback(() => {
setMicOn((current) => {
const next = !current;
clientRef.current?.setMediaState(next, camOn);
localRef.current?.getAudioTracks().forEach((track) => { track.enabled = next; });
return next;
});
}, [camOn]);
const next = !micOn;
setMicOn(next);
clientRef.current?.setMediaState(next, camOn);
localRef.current?.getAudioTracks().forEach((track) => { track.enabled = next; });
}, [camOn, micOn]);
const toggleCam = useCallback(() => {
setCamOn((current) => {
const next = !current;
clientRef.current?.setMediaState(micOn, next);
localRef.current?.getVideoTracks().forEach((track) => { track.enabled = next; });
return next;
});
}, [micOn]);
const next = !camOn;
setCamOn(next);
clientRef.current?.setMediaState(micOn, next);
localRef.current?.getVideoTracks().forEach((track) => { track.enabled = next; });
}, [camOn, micOn]);
const end = useCallback(() => clientRef.current?.end(), []);
const restart = useCallback(async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
export { buildTheme, chatballsTheme } from "./theme";
export { CallView, type CallViewMode, type CallViewStatus } from "./call/CallView";
export { AudioCallView } from "./call/AudioCallView";
export { type AudioCallMode, type AudioCallStatus, buildAudioStatus, isAudioTerminal, formatDuration as formatAudioDuration } from "./call/audioCallStates";
export { type AudioBarKind, type AudioCallMode, type AudioCallStatus, buildAudioStatus, isTerminalCallStatus, TERMINAL_CALL_STATUSES, formatDuration as formatAudioDuration } from "./call/audioCallStates";
export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";
export { useAudioCue, useLoopingAudio } from "./audio/useAudio";
export { Loader } from "./loader/Loader";