mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
1 parent
1a66659c30
commit
e78eb9f17d
43 files changed
+9497
-167
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user