mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🐛 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:
1 parent
ee9cbf0995
commit
3461556446
15 files changed
+131
-46
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
Reference in new issue
Block a user