✨ feat(calls): add audio calls alongside video calls

Introduce a call kind (AUDIO default, VIDEO) so dialogs support both audio
and video calls. Video path is unchanged; audio is a separate full-screen
component built 1:1 on the «Аудиозвонок.dc.html» baseline.

Backend
- CallSession.kind (CallKind AUDIO|VIDEO, default AUDIO) + migration 0005
- create_call_request(kind), CallCreateView validates kind from request body
- serializers expose kind in call_payload, public_invite_payload,
  public_call_state_payload (drop stale hardcoded capabilities)
- timeline system message reflects kind («аудио-/видеозвонок»)
- tests updated for the kind field and new message wording

@edevs/ui
- useCallRtcSession gains videoEnabled (default true; video unchanged);
  audio requests audio-only getUserMedia, no camera fallback
- AudioCallView, AudioCallWaveform (live AnalyserNode oscilloscope),
  AudioCallIcons (mic/speaker/phone/status), audioCallStates, audio-call.css
- exported via index.ts

internal-ui
- two call buttons in the conversation header (audio + video)
- CallOverlay becomes a kind dispatcher: VideoCallOverlay (unchanged) /
  AudioCallOverlay; kind threaded through model/hook/workspace

web-chat
- CallInfo.kind; invite banner shows audio vs video title and icon
- CallApp dispatches to AudioCallStage for audio; audio resolve/build in model

Infra
- compose.yaml: widen Coturn relay UDP range to 49160-49999 (~420 relay
  calls) with explanatory comment
This commit is contained in:
Andrey committed 2026-07-31 01:35:29 +03:00
1 parent 1a66659c30
commit e78eb9f17d
43 files changed
+9497 -167

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
// Иконки аудиозвонка (baseline «Аудиозвонок.dc.html»): stroke=currentColor,
// 1.85, round caps. Микрофон/динамик (аудио не имеет камеры), завершение и
// status-набор для процессов/ошибок/терминалов.
import type { ReactNode } from "react";
export type DeviceName = "mic" | "speaker";
const svg =
(width = 23) =>
(children: ReactNode) =>
(
<svg viewBox="0 0 24 24" width={width} height={width} fill="none" stroke="currentColor" strokeWidth={1.85} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
export function MicIcon({ on }: { on: boolean }) {
const s = svg(23);
return on
? s(<><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M19 10v1a7 7 0 0 1-14 0v-1" /><line x1="12" y1="18" x2="12" y2="22" /></>)
: s(<><line x1="3" y1="3" x2="21" y2="21" /><path d="M9 9v2a3 3 0 0 0 5.1 2.1" /><path d="M15 9.3V5a3 3 0 0 0-5.9-.7" /><path d="M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1" /><line x1="12" y1="18" x2="12" y2="22" /></>);
}
export function SpeakerIcon({ on }: { on: boolean }) {
const s = svg(23);
return on
? s(<><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M19 5a9 9 0 0 1 0 14" /></>)
: s(<><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" /><line x1="22" y1="9" x2="16" y2="15" /><line x1="16" y1="9" x2="22" y2="15" /></>);
}
// Трубка; rotated=true → повёрнута на 135° (отклонить/завершить/отменить).
export function PhoneIcon({ rotated = false, width = 26 }: { rotated?: boolean; width?: number }) {
const s = svg(width);
return s(
<path
style={rotated ? { transform: "rotate(135deg)", transformOrigin: "center" } : undefined}
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"
/>
);
}
export function SpinnerIcon({ width = 34 }: { width?: number }) {
const s = svg(width);
return s(<path style={{ animation: "hub-audio-spin .9s linear infinite" }} d="M21 12a9 9 0 1 1-6.219-8.56" strokeWidth={2} />);
}
export type AudioStatusIconName = "spinner" | "declined" | "missed" | "clock" | "alert" | "nodevice" | "unsupported";
export function AudioStatusIcon({ name }: { name: AudioStatusIconName }) {
const s = svg(34);
switch (name) {
case "spinner":
return <SpinnerIcon />;
case "declined":
return s(<path transform="rotate(135 12 12)" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" />);
case "missed":
return s(<><path d="M23 7l-8 8-4-4-9 9" /><polyline points="17 7 23 7 23 13" /></>);
case "clock":
return s(<><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>);
case "alert":
return s(<><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></>);
case "nodevice":
return s(<><line x1="2" y1="2" x2="22" y2="22" /><path d="M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2" /><path d="M10 5h4a2 2 0 0 1 2 2v3l4-3v9" /></>);
case "unsupported":
return s(<><rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /><line x1="9" y1="8" x2="15" y2="14" /><line x1="15" y1="8" x2="9" y2="14" /></>);
default:
return null;
}
}
+240
View File
@@ -0,0 +1,240 @@
// Аудиозвонок — полноэкранный портретный UI (baseline «Аудиозвонок.dc.html»).
// Тёмный градиент 150deg #0958d9→#722ed1→#c41d7f + два дрейфующих блоба,
// glass-аватар 126px с двумя волнами edv-ring2, живая осциллограмма собеседника.
// Никакой камеры/видео/PiP — это честный аудио-режим.
import { AudioCallWaveform } from "./AudioCallWaveform";
import { AudioStatusIcon } from "./AudioCallIcons";
import { MicIcon, PhoneIcon, SpeakerIcon } from "./AudioCallIcons";
import { type AudioCallMode, type AudioCallStatus, formatDuration } from "./audioCallStates";
import "./audio-call.css";
type Props = {
mode: AudioCallMode;
status?: AudioCallStatus;
peerName: string;
peerInitials: string;
channelLabel?: string;
showChannel?: boolean;
micOn?: boolean;
speakerOn?: boolean;
remoteStream?: MediaStream | null;
remoteMicOn?: boolean;
elapsedSeconds?: number;
durationSeconds?: number | null;
mediaIssue?: string;
statusLabel?: string;
subCaption?: string;
onToggleMic?: () => void;
onToggleSpeaker?: () => void;
onAccept?: () => void;
onDecline?: () => void;
onCancel?: () => void;
onEnd?: () => void;
onClose?: () => void;
onCallAgain?: () => void;
onRetry?: () => void;
};
export function AudioCallView(props: Props) {
const status = props.status;
const isStatus = props.mode === "status" && !!status;
const showAvatarCenter = !isStatus;
const ringsVisible = props.mode === "incoming" || props.mode === "ringing";
const isActive = props.mode === "active";
const timer = formatDuration(props.elapsedSeconds ?? 0);
return (
<div className="hub-audio-view" data-bg={bgState(props.mode)}>
<div className="hub-audio-bg" aria-hidden>
<div className="hub-audio-grad" />
<div className="hub-audio-blob a" />
<div className="hub-audio-blob b" />
<div className="hub-audio-veil" />
</div>
<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>}
<div className="hub-audio-top-spacer" />
{isActive && <span className="hub-audio-timer">{timer}</span>}
</div>
<div className="hub-audio-center">
{showAvatarCenter && (
<>
<div className="hub-audio-avatar-wrap">
{ringsVisible && (
<>
<i className="hub-audio-ring" />
<i className="hub-audio-ring delay" />
</>
)}
<div className="hub-audio-avatar">{props.peerInitials}</div>
</div>
<div className="hub-audio-name-block">
<div className="hub-audio-name">{props.peerName}</div>
<div className="hub-audio-sub">{props.subCaption ?? defaultSubCaption(props.mode, props.micOn)}</div>
</div>
<div className="hub-audio-wave-slot">
{isActive && <AudioCallWaveform stream={props.remoteStream} micOn={props.remoteMicOn !== false} active />}
{!isActive && durationPill(props.durationSeconds)}
</div>
</>
)}
{isStatus && status && (
<StatusCenter status={status} />
)}
</div>
<div className="hub-audio-bottom">
{props.mode === "incoming" && (
<PairBar left={{ label: "Отклонить", tone: "decline", icon: <PhoneIcon rotated /> }} right={{ label: "Принять", tone: "accept", icon: <PhoneIcon /> }} onLeft={props.onDecline} onRight={props.onAccept} />
)}
{props.mode === "ringing" && (
<SingleRoundBar label="Отменить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onCancel ?? props.onEnd} />
)}
{props.mode === "connecting" && (
<SingleRoundBar label="Отменить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onCancel ?? props.onEnd} />
)}
{isActive && (
<div className="hub-audio-active">
<div className="hub-audio-device-row">
<DeviceControl label="Микрофон" icon={<MicIcon on={props.micOn !== false} />} on={props.micOn !== false} onClick={props.onToggleMic} />
<DeviceControl label="Динамик" icon={<SpeakerIcon on={props.speakerOn !== false} />} on={props.speakerOn !== false} onClick={props.onToggleSpeaker} />
</div>
<SingleRoundBar label="Завершить" tone="decline" icon={<PhoneIcon rotated />} onClick={props.onEnd} />
</div>
)}
{isStatus && status && (
<StatusBar status={status} onClose={props.onClose} onCallAgain={props.onCallAgain} onRetry={props.onRetry} onEnd={props.onEnd} />
)}
</div>
</div>
</div>
);
}
function StatusCenter({ status }: { status: AudioCallStatus }) {
return (
<div className="hub-audio-status-center">
<span className={`hub-audio-status-tile ${status.tone}`}><AudioStatusIcon name={status.icon} /></span>
<div>
<div className="hub-audio-status-title">{status.title}</div>
<div className="hub-audio-status-caption">{status.caption}</div>
</div>
</div>
);
}
function DeviceControl({ label, icon, on, onClick }: { label: string; icon: React.ReactNode; on: boolean; onClick?: () => void }) {
return (
<div className="hub-audio-device-control">
<button className={`hub-audio-device${on ? "" : " off"}`} onClick={onClick} aria-label={label}>{icon}</button>
<span>{label}</span>
</div>
);
}
function SingleRoundBar({ label, tone, icon, onClick }: { label: string; tone: "decline"; icon: React.ReactNode; onClick?: () => void }) {
return (
<div className="hub-audio-round-stack">
<button className={`hub-audio-round ${tone}`} onClick={onClick} aria-label={label}>{icon}</button>
<span className="hub-audio-round-label">{label}</span>
</div>
);
}
function PairBar({ left, right, onLeft, onRight }: { left: BarEnd; right: BarEnd; onLeft?: () => void; onRight?: () => void }) {
return (
<div className="hub-audio-pair">
<EndStack end={left} onClick={onLeft} />
<EndStack end={right} onClick={onRight} />
</div>
);
}
type BarEnd = { label: string; tone: "decline" | "accept"; icon: React.ReactNode };
function EndStack({ end, onClick }: { end: BarEnd; onClick?: () => void }) {
return (
<div className="hub-audio-round-stack">
<button className={`hub-audio-round ${end.tone}`} onClick={onClick} aria-label={end.label}>{end.icon}</button>
<span className="hub-audio-round-label">{end.label}</span>
</div>
);
}
// Бар для терминальных/ошибочных статусов: кнопки 48px, как в дизайне ended.
function StatusBar({ status, onClose, onCallAgain, onRetry, onEnd }: { status: AudioCallStatus; onClose?: () => void; onCallAgain?: () => void; onRetry?: () => void; onEnd?: () => void }) {
const bar = status.bar;
if (bar === "reconnect") {
return <SingleRoundBar label="Завершить" tone="decline" icon={<PhoneIcon rotated />} onClick={onEnd} />;
}
if (bar === "retrySingle") {
return (
<div className="hub-audio-pill-bar">
<PillButton kind="solid" onClick={onCallAgain}>Позвонить снова</PillButton>
</div>
);
}
if (bar === "retryClose") {
return (
<div className="hub-audio-pill-bar two">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
<PillButton kind="solid" onClick={onRetry}>Повторить</PillButton>
</div>
);
}
if (bar === "retryCheck") {
return (
<div className="hub-audio-pill-bar two">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
<PillButton kind="solid" onClick={onRetry}>Повторить проверку</PillButton>
</div>
);
}
// ended / connecting
return (
<div className="hub-audio-pill-bar two">
<PillButton kind="ghost" onClick={onClose}>Закрыть</PillButton>
<PillButton kind="solid" onClick={onCallAgain}>Позвонить снова</PillButton>
</div>
);
}
function PillButton({ kind, onClick, children }: { kind: "solid" | "ghost"; onClick?: () => void; children: React.ReactNode }) {
return <button className={`hub-audio-pill ${kind}`} onClick={onClick}>{children}</button>;
}
function durationPill(duration?: number | null) {
if (duration == null || duration <= 0) return null;
return (
<div className="hub-audio-duration-pill">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
Длительность {formatDuration(duration)}
</div>
);
}
function bgState(mode: AudioCallMode): string {
if (mode === "active") return "active";
if (mode === "status" || mode === "reconnecting") return "dim";
return "full";
}
function defaultStatusLabel(mode: AudioCallMode, status?: AudioCallStatus): string {
if (status) return status.title;
if (mode === "incoming") return "Входящий звонок";
if (mode === "ringing") return "Исходящий звонок";
if (mode === "active") return "Аудиозвонок";
if (mode === "connecting") return "Соединение";
if (mode === "reconnecting") return "Переподключение";
return "Звонок завершён";
}
function defaultSubCaption(mode: AudioCallMode, micOn?: boolean): string {
if (mode === "active") return micOn ? "Говорите" : "Ваш микрофон выключен";
if (mode === "incoming" || mode === "ringing") return "";
return "";
}
@@ -0,0 +1,96 @@
// Живая осциллограмма аудиозвонка (baseline «Аудиозвонок.dc.html»).
// Белая линия rgba(255,255,255,.92), lineWidth 3, конусное затухание по краям.
// Амплитуда берётся из AnalyserNode удалённого потока — волна реагирует на
// голос собеседника. Нет потока/тишина → мягкая анимированная idle-волна.
import { useEffect, useRef } from "react";
type Props = {
stream?: MediaStream | null;
micOn?: boolean;
active: boolean;
};
const IDLE_AMP = 0.12;
const SPEAK_AMP = 0.5;
export function AudioCallWaveform({ stream, micOn = true, active }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(stream);
streamRef.current = stream;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const mid = H / 2;
// AnalyserNode на удалённом аудио, чтобы измерять громкость собеседника.
let audioCtx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let levels: Uint8Array<ArrayBuffer> | null = null;
try {
const target = streamRef.current;
if (target && target.getAudioTracks().length > 0) {
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
source = audioCtx.createMediaStreamSource(target);
source.connect(analyser);
levels = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount));
}
} catch {
// AudioContext недоступен (нет аудио/нет прав) — работаем idle-волной.
}
let raf = 0;
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
ctx.clearRect(0, 0, W, H);
// Уровень громкости собеседника 0..1 (RMS по bins).
let level = 0;
if (active && micOn && analyser && levels) {
analyser.getByteTimeDomainData(levels);
let sum = 0;
for (let i = 0; i < levels.length; i++) {
const v = (levels[i] - 128) / 128;
sum += v * v;
}
level = Math.min(1, Math.sqrt(sum / levels.length) * 2.4);
}
const speaking = active && micOn;
const t = ts / 1000;
const base = speaking ? SPEAK_AMP : IDLE_AMP;
const ampScale = speaking ? (0.35 + 0.65 * level) : 1;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.beginPath();
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 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);
}
ctx.stroke();
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
source?.disconnect();
analyser?.disconnect();
void audioCtx?.close();
};
}, [active, micOn]);
return <canvas ref={canvasRef} width={520} height={112} className="hub-audio-wave" />;
}
+149
View File
@@ -0,0 +1,149 @@
/* Аудиозвонок (baseline «Аудиозвонок.dc.html»). Тёмная портретная сцена с
живым градиентом и glass-элементами. Токены совпадают с макетом 1-в-1. */
@keyframes hub-audio-ring2 { 0% { transform: scale(1); opacity: .5; } 100% { transform: scale(1.9); opacity: 0; } }
@keyframes hub-audio-drift { 0% { transform: translate(0,0) scale(1); } 50% { transform: translate(4%,-3%) scale(1.08); } 100% { transform: translate(0,0) scale(1); } }
@keyframes hub-audio-drift2 { 0% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-5%,4%) scale(1); } 100% { transform: translate(0,0) scale(1.05); } }
@keyframes hub-audio-spin { to { transform: rotate(360deg); } }
.hub-audio-view {
position: relative;
width: 100%;
max-width: 400px;
aspect-ratio: 9 / 16;
max-height: 760px;
margin: 0 auto;
border-radius: var(--radius-4xl, 28px);
overflow: hidden;
background: #0d0d16;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.hub-audio-bg { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
.hub-audio-grad {
position: absolute; inset: 0;
background: linear-gradient(150deg, #0958d9 0%, #722ed1 52%, #c41d7f 100%);
opacity: 1; transition: opacity .6s;
}
[data-bg="active"] .hub-audio-grad { opacity: .85; }
[data-bg="dim"] .hub-audio-grad { opacity: .5; }
.hub-audio-blob { position: absolute; border-radius: 50%; }
.hub-audio-blob.a {
width: 75%; height: 55%; left: -12%; top: -10%;
background: radial-gradient(circle, #597ef7, transparent 68%);
filter: blur(18px); opacity: .75;
animation: hub-audio-drift 9s ease-in-out infinite;
}
.hub-audio-blob.b {
width: 70%; height: 52%; right: -14%; bottom: -8%;
background: radial-gradient(circle, #c41d7f, transparent 68%);
filter: blur(20px); opacity: .6;
animation: hub-audio-drift2 11s ease-in-out infinite;
}
.hub-audio-veil {
position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(11, 11, 18, .15) 0%, rgba(11, 11, 18, .05) 45%, rgba(11, 11, 18, .55) 100%);
}
.hub-audio-body {
position: relative; z-index: 2; height: 100%;
box-sizing: border-box; display: flex; flex-direction: column;
padding: 26px 24px 30px; color: #fff;
}
.hub-audio-top { display: flex; align-items: center; gap: 9px; }
.hub-audio-status-label { font-size: 13px; font-weight: 600; letter-spacing: .02em; color: rgba(255, 255, 255, .9); }
.hub-audio-channel {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px; border-radius: var(--radius-pill, 999px);
background: rgba(255, 255, 255, .16); backdrop-filter: blur(6px);
font-size: 11.5px; font-weight: 500; color: #fff;
}
.hub-audio-top-spacer { flex: 1; }
.hub-audio-timer {
font-size: 14px; font-weight: 600; letter-spacing: .04em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff;
}
.hub-audio-center {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 22px;
}
.hub-audio-avatar-wrap { position: relative; display: flex; align-items: center; justify-content: center; }
.hub-audio-ring {
position: absolute; width: 126px; height: 126px; border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .45);
animation: hub-audio-ring2 2s ease-out infinite;
}
.hub-audio-ring.delay { animation-delay: 1s; }
.hub-audio-avatar {
width: 126px; height: 126px; border-radius: 50%;
background: rgba(255, 255, 255, .14); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, .28);
display: flex; align-items: center; justify-content: center;
font-size: 42px; font-weight: 600; color: #fff;
box-shadow: 0 8px 30px rgba(0, 0, 0, .3);
}
.hub-audio-name-block { text-align: center; }
.hub-audio-name { font-size: 25px; font-weight: 600; letter-spacing: -0.01em; color: #fff; }
.hub-audio-sub { font-size: 14px; color: rgba(255, 255, 255, .72); margin-top: 6px; }
.hub-audio-wave-slot { height: 56px; width: 100%; max-width: 260px; display: flex; align-items: center; justify-content: center; }
.hub-audio-wave { width: 100%; height: 56px; }
.hub-audio-status-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; text-align: center; }
.hub-audio-status-tile {
width: 92px; height: 92px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255, 255, 255, .22); backdrop-filter: blur(10px);
box-shadow: 0 8px 30px rgba(0, 0, 0, .3);
}
.hub-audio-status-tile.neutral { background: rgba(255, 255, 255, .14); color: #fff; }
.hub-audio-status-tile.warn { background: rgba(250, 173, 20, .18); color: #ffc53d; }
.hub-audio-status-tile.error { background: rgba(255, 77, 79, .2); color: #ff7875; }
.hub-audio-status-title { font-size: 23px; font-weight: 600; letter-spacing: -0.01em; color: #fff; line-height: 1.25; }
.hub-audio-status-caption { font-size: 14px; color: rgba(255, 255, 255, .72); margin-top: 8px; line-height: 1.5; max-width: 280px; }
.hub-audio-duration-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: var(--radius-pill, 999px);
background: rgba(255, 255, 255, .12); font-size: 13.5px; color: #fff;
}
.hub-audio-bottom { display: flex; flex-direction: column; gap: 18px; }
.hub-audio-pair { display: flex; justify-content: center; gap: 64px; }
.hub-audio-round-stack { display: flex; flex-direction: column; align-items: center; gap: 9px; }
.hub-audio-round {
width: 64px; height: 64px; border-radius: 50%; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff;
}
.hub-audio-round.decline { background: var(--error, #ff4d4f); box-shadow: 0 6px 20px rgba(255, 77, 79, .45); }
.hub-audio-round.accept { background: var(--success, #52c41a); box-shadow: 0 6px 20px rgba(82, 196, 26, .45); }
.hub-audio-round-label { font-size: 12.5px; color: rgba(255, 255, 255, .75); }
.hub-audio-active { display: flex; flex-direction: column; gap: 18px; }
.hub-audio-device-row { display: flex; justify-content: center; gap: 40px; }
.hub-audio-device-control { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.hub-audio-device {
width: 60px; height: 60px; border-radius: 50%; cursor: pointer;
border: 1px solid rgba(255, 255, 255, .28); color: #fff;
background: rgba(255, 255, 255, .16); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s;
}
.hub-audio-device.off { background: #fff; color: #141414; }
.hub-audio-device-control span { font-size: 11.5px; color: rgba(255, 255, 255, .72); }
.hub-audio-pill-bar { display: flex; gap: 12px; }
.hub-audio-pill-bar.two > * { flex: 1; }
.hub-audio-pill {
height: 48px; border-radius: var(--radius-lg, 12px); cursor: pointer;
font-size: 14px; font-weight: 600; font-family: var(--font-sans, inherit);
}
.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; }
@media (max-width: 480px) {
.hub-audio-view { max-width: 100%; max-height: none; aspect-ratio: auto; height: 100%; border-radius: 0; }
}
+75
View File
@@ -0,0 +1,75 @@
// Состояния аудиозвонка и сопутствующие данные (baseline «Аудиозвонок.dc.html»).
// Тексты/иконки/тоны вынесены отдельно, чтобы AudioCallView оставался
// composition-only и не смешивал разметку со справочником состояний.
import type { AudioStatusIconName } from "./AudioCallIcons";
export type AudioCallMode = "incoming" | "ringing" | "connecting" | "active" | "reconnecting" | "status";
export type AudioCallActionKind = "close" | "callAgain" | "retry" | "retryCheck" | "end";
export type AudioCallAction = { label: string; kind: AudioCallActionKind };
export type AudioCallStatus = {
icon: AudioStatusIconName;
tone: "neutral" | "warn" | "error";
title: string;
caption: string;
bar: AudioBarKind;
};
// Какой нижний бар рисовать для текущего состояния (вне active/incoming/ringing/connecting).
export type AudioBarKind = "ended" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck";
export type AudioStatusEntry = AudioCallStatus;
// Терминальные состояния, приходящие из домена звонков (TERMINAL_CALL_STATUSES).
export const AUDIO_TERMINAL_STATUSES = new Set([
"DECLINED",
"CANCELLED",
"MISSED",
"ENDED",
"FAILED",
"EXPIRED",
]);
export const isAudioTerminal = (status?: string) => Boolean(status && AUDIO_TERMINAL_STATUSES.has(status));
function dur(prefix: string, duration?: number | null) {
return duration != null && duration > 0 ? `${prefix}Длительность ${formatDuration(duration)}. ` : prefix;
}
// Справочник статусов-центров: процессы + ошибки + терминалы.
// `duration` подставляется только там, где разговор реально состоялся.
export function buildAudioStatus(status: string, peerName: string, duration?: number | null): AudioStatusEntry | null {
const p = `${peerName} `;
switch (status) {
case "connecting":
case "CONNECTING":
return { icon: "spinner", tone: "neutral", title: "Соединяем", caption: "Устанавливаем защищённое соединение…", bar: "ended" };
case "reconnecting":
return { icon: "spinner", tone: "neutral", title: "Связь прервана", caption: "Восстанавливаем соединение…", bar: "reconnect" };
case "DECLINED":
return { icon: "declined", tone: "neutral", title: "Звонок отклонён", caption: `${p}отклонил(а) вызов.`, bar: "retrySingle" };
case "MISSED":
return { icon: "missed", tone: "warn", title: "Пропущенный звонок", caption: `${p}не ответил(а) на вызов.`, bar: "retrySingle" };
case "EXPIRED":
return { icon: "clock", tone: "warn", title: "Время ожидания истекло", caption: "Никто не ответил вовремя. Попробуйте позвонить снова.", bar: "retrySingle" };
case "CANCELLED":
return { icon: "declined", tone: "neutral", title: "Звонок отменён", caption: "Приглашение отменено.", bar: "ended" };
case "FAILED":
return { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: `${dur("", duration)}Проверьте интернет-соединение и попробуйте снова.`, bar: "retryClose" };
case "ENDED":
return { icon: "clock", tone: "neutral", title: "Звонок завершён", caption: `${dur("", duration)}Разговор завершён.`, bar: "ended" };
case "nodevice":
return { icon: "nodevice", tone: "error", title: "Нет доступа к микрофону", caption: "Разрешите доступ к микрофону в настройках браузера и повторите.", bar: "retryCheck" };
case "unsupported":
return { icon: "unsupported", tone: "error", title: "Аудиозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge.", bar: "retryCheck" };
default:
return null;
}
}
export function formatDuration(seconds: number) {
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
}
+18 -5
View File
@@ -9,6 +9,7 @@ type Options = {
accessToken: string;
side: CallSide;
iceServers: RTCIceServer[];
videoEnabled?: boolean;
onCallState: (call: PublicCallState) => void;
};
@@ -27,6 +28,8 @@ export function useCallRtcSession(options: Options) {
const localRef = useRef<MediaStream | null>(null);
const clientRef = useRef<CallRtcClient | null>(null);
const preparingRef = useRef(false);
const videoEnabledRef = useRef(options.videoEnabled ?? true);
videoEnabledRef.current = options.videoEnabled ?? true;
const onCallStateRef = useRef(options.onCallState);
onCallStateRef.current = options.onCallState;
@@ -47,14 +50,24 @@ export function useCallRtcSession(options: Options) {
preparingRef.current = true;
setPreparing(true);
try {
// Аудиозвонок: видео не запрашивается и не откатывается — это честный
// аудио-режим. Видеозвонок: video+audio, при отказе камеры — audio-only.
const constraints: MediaStreamConstraints = videoEnabledRef.current
? { audio: true, video: true }
: { audio: true, video: false };
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream = await navigator.mediaDevices.getUserMedia(constraints);
setMediaIssue("none");
if (!videoEnabledRef.current) setCamOn(false);
} catch {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setCamOn(false);
setMediaIssue("video");
if (videoEnabledRef.current) {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setCamOn(false);
setMediaIssue("video");
} else {
throw new Error("audio devices unavailable");
}
}
localRef.current = stream;
setLocalStream(stream);
@@ -71,7 +84,7 @@ export function useCallRtcSession(options: Options) {
useEffect(() => {
stop();
setMicOn(true);
setCamOn(true);
setCamOn(videoEnabledRef.current);
setRemoteMicOn(true);
setRemoteCamOn(true);
setMediaIssue("none");
+2
View File
@@ -1,4 +1,6 @@
export { edevsHubTheme } 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 { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";
export { useAudioCue, useLoopingAudio } from "./audio/useAudio";