@polymorfa/react 0.1.0-dev.20260922094209
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +5 -0
- package/README.md +303 -0
- package/dist/call-button.d.ts +29 -0
- package/dist/call-button.d.ts.map +1 -0
- package/dist/call-button.js +54 -0
- package/dist/calls.d.ts +201 -0
- package/dist/calls.d.ts.map +1 -0
- package/dist/calls.js +944 -0
- package/dist/components.d.ts +109 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +843 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +82 -0
- package/dist/dropin.d.ts +100 -0
- package/dist/dropin.d.ts.map +1 -0
- package/dist/dropin.js +298 -0
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +71 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/package.json +45 -0
package/dist/calls.js
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Polymorfa Calls UI — React components over `CallsController`.
|
|
3
|
+
//
|
|
4
|
+
// Shaped after the official WhatsApp desktop call windows in a monochrome
|
|
5
|
+
// Material-3 voice: black identity, tonal fills, hairline outlines, 8px
|
|
6
|
+
// radius; the verbs carry the only color (success green answers, danger red
|
|
7
|
+
// declines/hangs up). Cards follow the appearance theme; the call stage — and
|
|
8
|
+
// the incoming card once it carries a camera preview — is always dark, like an
|
|
9
|
+
// OS call screen. Widgets are fluid and never resize when a panel opens;
|
|
10
|
+
// device panels overlay them. Every affordance gates on the snapshot's
|
|
11
|
+
// reported `capabilities`.
|
|
12
|
+
import {} from "@polymorfa/browser";
|
|
13
|
+
import { appearanceToCssVariables } from "@polymorfa/ui";
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
import { usePolymorfa } from "./context.js";
|
|
17
|
+
import { useController, useResolvedController } from "./hooks.js";
|
|
18
|
+
/**
|
|
19
|
+
* Run a synchronous controller call from a DOM handler.
|
|
20
|
+
*
|
|
21
|
+
* The controller's mutators assert it is live and throw once it is disposed,
|
|
22
|
+
* and a disposed controller keeps its last snapshot — so a surface stays
|
|
23
|
+
* mounted and its buttons stay clickable. React does not catch throws from
|
|
24
|
+
* event handlers, so without this the error reaches window.onerror. There is
|
|
25
|
+
* nothing to report: the call these controls belonged to is gone.
|
|
26
|
+
*/
|
|
27
|
+
function ignoreDisposed(run) {
|
|
28
|
+
try {
|
|
29
|
+
run();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Disposed controller — no snapshot left to change.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** A call this client answered or joined that has not ended. */
|
|
36
|
+
const ANSWERED_STATUSES = new Set([
|
|
37
|
+
"accepted",
|
|
38
|
+
"connecting",
|
|
39
|
+
"connected",
|
|
40
|
+
"reconnecting",
|
|
41
|
+
]);
|
|
42
|
+
const ACTIVE_STATUSES = new Set([
|
|
43
|
+
"ringing",
|
|
44
|
+
"accepted",
|
|
45
|
+
"connecting",
|
|
46
|
+
"connected",
|
|
47
|
+
"reconnecting",
|
|
48
|
+
]);
|
|
49
|
+
function Icon({ children, ...props }) {
|
|
50
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", ...props, children: children }));
|
|
51
|
+
}
|
|
52
|
+
const PhoneIcon = (p) => (_jsx(Icon, { ...p, children: _jsx("path", { 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-6A19.79 19.79 0 0 1 2.08 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" }) }));
|
|
53
|
+
/** The end-call handset: the phone glyph rotated to point down. */
|
|
54
|
+
const HangupIcon = ({ style, ...p }) => (_jsx(PhoneIcon, { ...p, style: { transform: "rotate(135deg)", ...style } }));
|
|
55
|
+
const MicIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", y1: "19", x2: "12", y2: "23" }), _jsx("line", { x1: "8", y1: "23", x2: "16", y2: "23" })] }));
|
|
56
|
+
const MicOffIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("line", { x1: "1", y1: "1", x2: "23", y2: "23" }), _jsx("path", { d: "M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" }), _jsx("path", { d: "M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" }), _jsx("line", { x1: "12", y1: "19", x2: "12", y2: "23" }), _jsx("line", { x1: "8", y1: "23", x2: "16", y2: "23" })] }));
|
|
57
|
+
const VideoIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M23 7l-7 5 7 5V7z" }), _jsx("rect", { x: "1", y: "5", width: "15", height: "14", rx: "2", ry: "2" })] }));
|
|
58
|
+
const VideoOffIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" }), _jsx("line", { x1: "1", y1: "1", x2: "23", y2: "23" })] }));
|
|
59
|
+
const EraseIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" }), _jsx("line", { x1: "18", y1: "9", x2: "12", y2: "15" }), _jsx("line", { x1: "12", y1: "9", x2: "18", y2: "15" })] }));
|
|
60
|
+
const UserIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }), _jsx("circle", { cx: "12", cy: "7", r: "4" })] }));
|
|
61
|
+
const ChevronDownIcon = (p) => (_jsx(Icon, { ...p, children: _jsx("polyline", { points: "6 9 12 15 18 9" }) }));
|
|
62
|
+
const CloseIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }));
|
|
63
|
+
const LeaveIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }), _jsx("polyline", { points: "16 17 21 12 16 7" }), _jsx("line", { x1: "21", y1: "12", x2: "9", y2: "12" })] }));
|
|
64
|
+
const MoreIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("circle", { cx: "12", cy: "12", r: "1" }), _jsx("circle", { cx: "19", cy: "12", r: "1" }), _jsx("circle", { cx: "5", cy: "12", r: "1" })] }));
|
|
65
|
+
const PopoutIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }), _jsx("polyline", { points: "15 3 21 3 21 9" }), _jsx("line", { x1: "10", y1: "14", x2: "21", y2: "3" })] }));
|
|
66
|
+
// ── Root / theming ────────────────────────────────────────────────────
|
|
67
|
+
const STYLE_TAG_ID = "pmfa-calls-styles";
|
|
68
|
+
/**
|
|
69
|
+
* Inject the calls stylesheet once per document. SSR-safe (no-op without a
|
|
70
|
+
* `document`) and idempotent.
|
|
71
|
+
*/
|
|
72
|
+
export function injectCallsStyles(target = globalThis.document) {
|
|
73
|
+
if (target === undefined)
|
|
74
|
+
return;
|
|
75
|
+
if (target.getElementById(STYLE_TAG_ID))
|
|
76
|
+
return;
|
|
77
|
+
const tag = target.createElement("style");
|
|
78
|
+
tag.id = STYLE_TAG_ID;
|
|
79
|
+
tag.textContent = CALLS_STYLES;
|
|
80
|
+
target.head.appendChild(tag);
|
|
81
|
+
}
|
|
82
|
+
function useCallsRoot() {
|
|
83
|
+
const configuration = usePolymorfa();
|
|
84
|
+
const unstyled = configuration.appearance.unstyled === true;
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!unstyled)
|
|
87
|
+
injectCallsStyles();
|
|
88
|
+
}, [unstyled]);
|
|
89
|
+
const call = configuration.appearance.elements["call"];
|
|
90
|
+
const theme = configuration.appearance.theme;
|
|
91
|
+
const className = [
|
|
92
|
+
"pmfa-calls",
|
|
93
|
+
theme === "dark"
|
|
94
|
+
? "pmfa-calls-dark"
|
|
95
|
+
: theme === "system"
|
|
96
|
+
? "pmfa-calls-auto"
|
|
97
|
+
: "",
|
|
98
|
+
call?.className ?? "",
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join(" ");
|
|
102
|
+
const style = useMemo(() => ({
|
|
103
|
+
...appearanceToCssVariables(configuration.appearance),
|
|
104
|
+
...call?.styles,
|
|
105
|
+
}), [configuration.appearance, call]);
|
|
106
|
+
return {
|
|
107
|
+
className,
|
|
108
|
+
style,
|
|
109
|
+
dir: configuration.locale.direction,
|
|
110
|
+
locale: configuration.locale,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function t(locale, key, values = {}) {
|
|
114
|
+
return locale.messages[key].replace(/\{(\w+)\}/g, (_, name) => values[name] ?? "");
|
|
115
|
+
}
|
|
116
|
+
// ── Building blocks ───────────────────────────────────────────────────
|
|
117
|
+
function initialsOf(name) {
|
|
118
|
+
if (name === undefined)
|
|
119
|
+
return null;
|
|
120
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
121
|
+
const first = words[0];
|
|
122
|
+
if (first === undefined || /^[+\d(]/.test(first))
|
|
123
|
+
return null;
|
|
124
|
+
const last = words.length > 1 ? words[words.length - 1] : undefined;
|
|
125
|
+
const initials = `${first[0] ?? ""}${last?.[0] ?? ""}`.toUpperCase();
|
|
126
|
+
return initials.length > 0 ? initials : null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Deterministic per-caller avatar color: a stable hash of the peer id picks
|
|
130
|
+
* one of 8 palette tones, so the same caller always renders the same color.
|
|
131
|
+
* A resolved profile picture wins.
|
|
132
|
+
*/
|
|
133
|
+
function avatarColorClass(peer) {
|
|
134
|
+
if (peer === undefined)
|
|
135
|
+
return "";
|
|
136
|
+
let h = 5381;
|
|
137
|
+
for (let i = 0; i < peer.length; i += 1)
|
|
138
|
+
h = ((h * 33) ^ peer.charCodeAt(i)) >>> 0;
|
|
139
|
+
return ` pmfa-calls-avatar-c${(h % 8) + 1}`;
|
|
140
|
+
}
|
|
141
|
+
function CallAvatar({ displayName, avatarUrl, peer, }) {
|
|
142
|
+
const initials = initialsOf(displayName);
|
|
143
|
+
return (_jsx("div", { className: "pmfa-calls-avatar-wrap", children: _jsx("div", { className: `pmfa-calls-avatar${avatarUrl === undefined ? avatarColorClass(peer) : ""}`, children: avatarUrl !== undefined ? (_jsx("img", { src: avatarUrl, alt: "" })) : initials !== null ? (initials) : (_jsx(UserIcon, { style: { width: 30, height: 30, opacity: 0.9 } })) }) }));
|
|
144
|
+
}
|
|
145
|
+
function usePeerAvatar(peer, resolveAvatar, explicit) {
|
|
146
|
+
const [resolved, setResolved] = useState({});
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (peer === undefined || resolveAvatar === undefined || peer in resolved)
|
|
149
|
+
return;
|
|
150
|
+
let cancelled = false;
|
|
151
|
+
void Promise.resolve()
|
|
152
|
+
.then(() => resolveAvatar(peer))
|
|
153
|
+
.catch(() => undefined)
|
|
154
|
+
.then((url) => {
|
|
155
|
+
if (!cancelled)
|
|
156
|
+
setResolved((previous) => ({ ...previous, [peer]: url }));
|
|
157
|
+
});
|
|
158
|
+
return () => {
|
|
159
|
+
cancelled = true;
|
|
160
|
+
};
|
|
161
|
+
}, [peer, resolveAvatar, resolved]);
|
|
162
|
+
return explicit ?? (peer === undefined ? undefined : resolved[peer]);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A muted, mirrored self-view for the pre-answer preview on video offers.
|
|
166
|
+
* Null while the camera warms up or when access was denied — callers fall
|
|
167
|
+
* back to the dark block.
|
|
168
|
+
*/
|
|
169
|
+
function useSelfPreview(enabled, videoInput) {
|
|
170
|
+
const [stream, setStream] = useState(null);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const devices = globalThis.navigator?.mediaDevices;
|
|
173
|
+
if (!enabled ||
|
|
174
|
+
devices === undefined ||
|
|
175
|
+
typeof devices.getUserMedia !== "function")
|
|
176
|
+
return;
|
|
177
|
+
let cancelled = false;
|
|
178
|
+
let acquired = null;
|
|
179
|
+
void devices
|
|
180
|
+
.getUserMedia({
|
|
181
|
+
video: videoInput === undefined ? true : { deviceId: { ideal: videoInput } },
|
|
182
|
+
audio: false,
|
|
183
|
+
})
|
|
184
|
+
.then((s) => {
|
|
185
|
+
acquired = s;
|
|
186
|
+
if (cancelled)
|
|
187
|
+
for (const track of s.getTracks())
|
|
188
|
+
track.stop();
|
|
189
|
+
else
|
|
190
|
+
setStream(s);
|
|
191
|
+
})
|
|
192
|
+
.catch(() => undefined);
|
|
193
|
+
return () => {
|
|
194
|
+
cancelled = true;
|
|
195
|
+
if (acquired !== null)
|
|
196
|
+
for (const track of acquired.getTracks())
|
|
197
|
+
track.stop();
|
|
198
|
+
setStream(null);
|
|
199
|
+
};
|
|
200
|
+
}, [enabled, videoInput]);
|
|
201
|
+
return stream;
|
|
202
|
+
}
|
|
203
|
+
function attach(element, stream) {
|
|
204
|
+
if (element !== null && stream != null && element.srcObject !== stream)
|
|
205
|
+
element.srcObject = stream;
|
|
206
|
+
}
|
|
207
|
+
// ── Device panels ─────────────────────────────────────────────────────
|
|
208
|
+
const canSetSink = typeof HTMLMediaElement !== "undefined" &&
|
|
209
|
+
"setSinkId" in HTMLMediaElement.prototype;
|
|
210
|
+
const DEVICE_KIND = {
|
|
211
|
+
audioInput: "audioinput",
|
|
212
|
+
videoInput: "videoinput",
|
|
213
|
+
audioOutput: "audiooutput",
|
|
214
|
+
};
|
|
215
|
+
const DEVICE_LABEL = {
|
|
216
|
+
audioInput: "calls.microphone",
|
|
217
|
+
videoInput: "calls.camera",
|
|
218
|
+
audioOutput: "calls.speaker",
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* One-panel-at-a-time open state plus the label unlock: device labels are
|
|
222
|
+
* empty until the page holds a media permission, so opening a panel takes
|
|
223
|
+
* (and immediately releases) a throwaway audio capture so real names show.
|
|
224
|
+
*/
|
|
225
|
+
function useDevicePanels(controller, snapshot) {
|
|
226
|
+
const [open, setOpen] = useState(null);
|
|
227
|
+
const toggle = (panel) => {
|
|
228
|
+
const next = open === panel ? null : panel;
|
|
229
|
+
setOpen(next);
|
|
230
|
+
if (next === null)
|
|
231
|
+
return;
|
|
232
|
+
void (async () => {
|
|
233
|
+
const devices = globalThis.navigator?.mediaDevices;
|
|
234
|
+
if (snapshot.devices.every((d) => d.label.length === 0) &&
|
|
235
|
+
devices !== undefined &&
|
|
236
|
+
typeof devices.getUserMedia === "function") {
|
|
237
|
+
try {
|
|
238
|
+
const s = await devices.getUserMedia({ audio: true });
|
|
239
|
+
for (const track of s.getTracks())
|
|
240
|
+
track.stop();
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Denied — enumerate anyway; generic labels render.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
await controller.refreshDevices().catch(() => undefined);
|
|
247
|
+
})();
|
|
248
|
+
};
|
|
249
|
+
return { open, toggle };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Device selects, scoped per panel: the mic dropdown carries microphone +
|
|
253
|
+
* speaker, the camera dropdown carries the camera only (the official
|
|
254
|
+
* split-button layout). Selections apply live mid-call.
|
|
255
|
+
*/
|
|
256
|
+
function DeviceFields({ controller, snapshot, fields, locale, }) {
|
|
257
|
+
const select = (kind, deviceId) => {
|
|
258
|
+
ignoreDisposed(() => {
|
|
259
|
+
if (kind === "audioOutput")
|
|
260
|
+
controller.setPreferredDevices({ audioOutput: deviceId });
|
|
261
|
+
else if (deviceId.length > 0)
|
|
262
|
+
void controller.switchDevice(kind, deviceId);
|
|
263
|
+
else
|
|
264
|
+
controller.setPreferredDevices({ [kind]: undefined });
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
return (_jsx(_Fragment, { children: fields.map((kind) => {
|
|
268
|
+
if (kind === "audioOutput" && !canSetSink)
|
|
269
|
+
return null;
|
|
270
|
+
const group = snapshot.devices.filter((d) => d.kind === DEVICE_KIND[kind]);
|
|
271
|
+
return (_jsxs("label", { className: "pmfa-calls-field", children: [t(locale, DEVICE_LABEL[kind]), _jsxs("select", { className: "pmfa-calls-select", value: snapshot.selectedDevices[kind] ?? "", onChange: (event) => select(kind, event.currentTarget.value), children: [_jsx("option", { value: "", children: t(locale, "calls.defaultDevice") }), group.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label.length > 0
|
|
272
|
+
? d.label
|
|
273
|
+
: `${t(locale, DEVICE_LABEL[kind])} ${i + 1}` }, d.deviceId.length > 0 ? d.deviceId : i)))] })] }, kind));
|
|
274
|
+
}) }));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The incoming-call window: name, "WhatsApp audio/video call" subtitle, the
|
|
278
|
+
* media middle (avatar for audio; a mirrored self-preview with camera/mic
|
|
279
|
+
* toggles and a ⋯ device menu for video), then Reject / Answer. Answering a
|
|
280
|
+
* video offer with the camera toggled off still acquires video muted, so the
|
|
281
|
+
* in-call camera toggle can enable it later.
|
|
282
|
+
*
|
|
283
|
+
* A call another participant answered without claiming shows Join instead of
|
|
284
|
+
* Answer. A call another participant claimed shows only Dismiss: declining it
|
|
285
|
+
* would end it for the participant who answered. Other waiting calls are
|
|
286
|
+
* listed below; the card never declines them.
|
|
287
|
+
*/
|
|
288
|
+
export function IncomingCallCard({ controller, createController, displayName, avatarUrl, resolveAvatar, className, exclusive = false, resolveName, }) {
|
|
289
|
+
const resolved = useResolvedController(controller, createController);
|
|
290
|
+
const snapshot = useController(resolved);
|
|
291
|
+
const root = useCallsRoot();
|
|
292
|
+
const { locale } = root;
|
|
293
|
+
const incoming = snapshot.status === "incoming";
|
|
294
|
+
// A claimed call is not answered here, so it opens no camera preview.
|
|
295
|
+
const offersVideo = incoming &&
|
|
296
|
+
!snapshot.claimedByOther &&
|
|
297
|
+
snapshot.video &&
|
|
298
|
+
snapshot.capabilities.video;
|
|
299
|
+
const [cameraOn, setCameraOn] = useState(true);
|
|
300
|
+
const [preMuted, setPreMuted] = useState(false);
|
|
301
|
+
// The card returns null between calls instead of unmounting, so these
|
|
302
|
+
// toggles would otherwise carry into the next call and mute it with nothing
|
|
303
|
+
// on screen to explain why. Reset during render, before the preview reads
|
|
304
|
+
// them, so the camera is never acquired against a stale choice.
|
|
305
|
+
const [toggledFor, setToggledFor] = useState(snapshot.callId);
|
|
306
|
+
if (snapshot.callId !== toggledFor) {
|
|
307
|
+
setToggledFor(snapshot.callId);
|
|
308
|
+
setCameraOn(true);
|
|
309
|
+
setPreMuted(false);
|
|
310
|
+
}
|
|
311
|
+
const preview = useSelfPreview(offersVideo && cameraOn, snapshot.selectedDevices.videoInput);
|
|
312
|
+
const previewRef = useRef(null);
|
|
313
|
+
const peerAvatar = usePeerAvatar(snapshot.peer, resolveAvatar, avatarUrl);
|
|
314
|
+
const { open, toggle } = useDevicePanels(resolved, snapshot);
|
|
315
|
+
useEffect(() => attach(previewRef.current, preview), [preview]);
|
|
316
|
+
if (!incoming)
|
|
317
|
+
return null;
|
|
318
|
+
const peer = snapshot.peer ?? "";
|
|
319
|
+
const name = displayName ?? peer;
|
|
320
|
+
// A remote hang-up between the click and the call reaching the controller
|
|
321
|
+
// makes both of these reject: the call is no longer incoming. The snapshot
|
|
322
|
+
// already shows the call ended, so the rejection only needs absorbing.
|
|
323
|
+
const claimed = snapshot.claimedByOther;
|
|
324
|
+
const joinable = !claimed && snapshot.canJoin;
|
|
325
|
+
// An answer or join is in flight (possibly for another listed call): the
|
|
326
|
+
// controller refuses these until it settles.
|
|
327
|
+
const busy = snapshot.answering === true;
|
|
328
|
+
// A failed answer: this call's own (still ringing), or a previous call's
|
|
329
|
+
// that gave way to this one.
|
|
330
|
+
const failure = snapshot.error;
|
|
331
|
+
const failureKey = failure === undefined ||
|
|
332
|
+
failure.code === "call_control_failed" ||
|
|
333
|
+
failure.code === "call_claimed"
|
|
334
|
+
? undefined
|
|
335
|
+
: failure.callId === undefined || failure.callId === snapshot.callId
|
|
336
|
+
? "calls.answerFailed"
|
|
337
|
+
: "calls.previousFailed";
|
|
338
|
+
const answeredId = snapshot.callId;
|
|
339
|
+
const applyPreToggles = () => {
|
|
340
|
+
// The answer can settle without this call becoming active: it ended, was
|
|
341
|
+
// dismissed, or another call is displayed now. Those choices belong to
|
|
342
|
+
// this call only.
|
|
343
|
+
const now = resolved.getSnapshot();
|
|
344
|
+
if (now.callId !== answeredId || !ANSWERED_STATUSES.has(now.status))
|
|
345
|
+
return;
|
|
346
|
+
if (preMuted || (offersVideo && !cameraOn))
|
|
347
|
+
resolved.setMuted({
|
|
348
|
+
...(preMuted ? { audio: true } : {}),
|
|
349
|
+
...(offersVideo && !cameraOn ? { video: true } : {}),
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
const join = () => void resolved
|
|
353
|
+
.join({ video: offersVideo })
|
|
354
|
+
.then(applyPreToggles)
|
|
355
|
+
.catch(() => undefined);
|
|
356
|
+
const others = (snapshot.invitations ?? []).filter((invitation) => invitation.callId !== snapshot.callId);
|
|
357
|
+
const accept = () => void resolved
|
|
358
|
+
.answer({ video: offersVideo, exclusive })
|
|
359
|
+
.then(applyPreToggles)
|
|
360
|
+
.catch(() => undefined);
|
|
361
|
+
const micToggle = (_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-mini${preMuted ? " pmfa-calls-on" : ""}`, onClick: () => setPreMuted((v) => !v),
|
|
362
|
+
// The pending answer applies the value from when it was pressed.
|
|
363
|
+
disabled: busy, "aria-label": t(locale, preMuted ? "calls.answerUnmuted" : "calls.answerMuted"), "aria-pressed": preMuted, children: preMuted ? _jsx(MicOffIcon, {}) : _jsx(MicIcon, {}) }));
|
|
364
|
+
return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: `pmfa-calls-card pmfa-calls-incoming${className === undefined ? "" : ` ${className}`}`, role: "alertdialog", "aria-label": t(locale, "calls.incomingFrom", { peer: name }), children: [_jsx("div", { className: "pmfa-calls-name", children: name }), _jsx("div", { className: "pmfa-calls-subtitle", role: snapshot.error?.code === "call_control_failed" ||
|
|
365
|
+
failureKey !== undefined ||
|
|
366
|
+
claimed ||
|
|
367
|
+
joinable
|
|
368
|
+
? "status"
|
|
369
|
+
: undefined, children: t(locale, snapshot.error?.code === "call_control_failed"
|
|
370
|
+
? "calls.controlFailed"
|
|
371
|
+
: failureKey !== undefined
|
|
372
|
+
? failureKey
|
|
373
|
+
: claimed
|
|
374
|
+
? "calls.answeredElsewhere"
|
|
375
|
+
: joinable
|
|
376
|
+
? "calls.joinable"
|
|
377
|
+
: offersVideo
|
|
378
|
+
? "calls.videoCall"
|
|
379
|
+
: "calls.audioCall") }), offersVideo ? (_jsxs("div", { className: "pmfa-calls-media", children: [cameraOn && preview !== null ? (_jsx("video", { ref: previewRef, className: "pmfa-calls-preview", autoPlay: true, playsInline: true, muted: true })) : null, _jsx("div", { className: "pmfa-calls-more", children: _jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-mini${open === "more" ? " pmfa-calls-on" : ""}`, onClick: () => toggle("more"), "aria-label": t(locale, "calls.deviceSettings"), "aria-expanded": open === "more", children: _jsx(MoreIcon, {}) }) }), _jsxs("div", { className: "pmfa-calls-media-ctrls", children: [_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-mini${cameraOn ? "" : " pmfa-calls-on"}`, onClick: () => setCameraOn((v) => !v), disabled: busy, "aria-label": t(locale, cameraOn
|
|
380
|
+
? "calls.answerWithoutCamera"
|
|
381
|
+
: "calls.answerWithCamera"), "aria-pressed": !cameraOn, children: cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}) }), micToggle] })] })) : (_jsx("div", { className: "pmfa-calls-media pmfa-calls-media-audio", children: _jsx(CallAvatar, { displayName: displayName, avatarUrl: peerAvatar, peer: peer }) })), open === "more" && (_jsx("div", { className: "pmfa-calls-pop pmfa-calls-pop-float", role: "group", "aria-label": t(locale, "calls.deviceSettings"), children: _jsx(DeviceFields, { controller: resolved, snapshot: snapshot, fields: ["audioInput", "videoInput", "audioOutput"], locale: locale }) })), _jsxs("div", { className: "pmfa-calls-actions", children: [claimed || joinable ? (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-dismiss", onClick: () => ignoreDisposed(() => resolved.dismiss()), "aria-label": t(locale, "calls.dismiss"), children: _jsx(CloseIcon, {}) }), t(locale, "calls.dismiss")] })) : (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-decline", onClick: () => void resolved.reject().catch(() => undefined), disabled: busy, "aria-label": t(locale, "calls.reject"), children: _jsx(HangupIcon, {}) }), t(locale, "calls.reject")] })), joinable ? (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: join, disabled: busy, "aria-label": t(locale, "calls.join"), children: offersVideo && cameraOn ? _jsx(VideoIcon, {}) : _jsx(PhoneIcon, {}) }), t(locale, "calls.join")] })) : claimed ? null : (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: accept, disabled: busy, "aria-label": t(locale, "calls.answer"), children: offersVideo && cameraOn ? _jsx(VideoIcon, {}) : _jsx(PhoneIcon, {}) }), t(locale, "calls.answer")] }))] }), others.length > 0 && (_jsxs("div", { className: "pmfa-calls-others", children: [_jsx("div", { className: "pmfa-calls-others-title", children: t(locale, "calls.otherIncoming") }), _jsx("ul", { "aria-label": t(locale, "calls.otherIncoming"), children: others.map((invitation) => (_jsxs("li", { children: [_jsx("span", { children: resolveName?.(invitation.from) ?? invitation.from }), _jsx("span", { className: "pmfa-calls-others-state", children: invitation.claimedByOther
|
|
382
|
+
? t(locale, "calls.answeredElsewhere")
|
|
383
|
+
: invitation.canJoin
|
|
384
|
+
? t(locale, "calls.join")
|
|
385
|
+
: t(locale, invitation.video
|
|
386
|
+
? "calls.videoCall"
|
|
387
|
+
: "calls.audioCall") }), _jsx("button", { type: "button", className: "pmfa-calls-chip", onClick: () => ignoreDisposed(() => resolved.select(invitation.callId)), children: t(locale, "calls.show") })] }, invitation.callId))) })] }))] }) }));
|
|
388
|
+
}
|
|
389
|
+
// ── Duration ──────────────────────────────────────────────────────────
|
|
390
|
+
/**
|
|
391
|
+
* Seconds since the call connected, ticking once a second (0 before that).
|
|
392
|
+
* A call that drops to `reconnecting` keeps counting: the controller holds
|
|
393
|
+
* `connectedAt` across the flap, and the call it measures is the same one.
|
|
394
|
+
*/
|
|
395
|
+
export function useCallDuration(snapshot) {
|
|
396
|
+
const connectedAt = snapshot.status === "connected" || snapshot.status === "reconnecting"
|
|
397
|
+
? snapshot.connectedAt
|
|
398
|
+
: undefined;
|
|
399
|
+
const [seconds, setSeconds] = useState(0);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (connectedAt === undefined) {
|
|
402
|
+
setSeconds(0);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const tick = () => setSeconds(Math.max(0, Math.floor((Date.now() - connectedAt) / 1000)));
|
|
406
|
+
tick();
|
|
407
|
+
const timer = setInterval(tick, 1000);
|
|
408
|
+
return () => clearInterval(timer);
|
|
409
|
+
}, [connectedAt]);
|
|
410
|
+
return seconds;
|
|
411
|
+
}
|
|
412
|
+
/** Format seconds as `m:ss` (or `h:mm:ss` past an hour). */
|
|
413
|
+
export function formatDuration(totalSeconds) {
|
|
414
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
415
|
+
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
416
|
+
const s = totalSeconds % 60;
|
|
417
|
+
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
|
|
418
|
+
const ss = String(s).padStart(2, "0");
|
|
419
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Name with the line under it: "Ringing +number…" while an outgoing call
|
|
423
|
+
* rings, the peer number until an incoming call connects, then alternating
|
|
424
|
+
* number ⇄ duration on the 5-second cadence (crossfaded via keyed remount).
|
|
425
|
+
*/
|
|
426
|
+
function CallIdentity({ name, peer, live, ringingLine, errorLine, flashPeer, seconds, }) {
|
|
427
|
+
const line = errorLine ??
|
|
428
|
+
(live
|
|
429
|
+
? peer !== null && flashPeer
|
|
430
|
+
? peer
|
|
431
|
+
: formatDuration(seconds)
|
|
432
|
+
: (ringingLine ?? peer));
|
|
433
|
+
const lineKey = errorLine !== null
|
|
434
|
+
? "error"
|
|
435
|
+
: live && !(peer !== null && flashPeer)
|
|
436
|
+
? "duration"
|
|
437
|
+
: "peer";
|
|
438
|
+
return (_jsxs("div", { children: [_jsx("div", { className: "pmfa-calls-name", children: name }), line !== null && (_jsx("div", { className: "pmfa-calls-peer pmfa-calls-flash", role: errorLine === null ? undefined : "status", children: line }, lineKey))] }));
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The in-call stage: one tile per remote participant video when anyone sends
|
|
442
|
+
* video, the centered avatar hero otherwise, the local preview as a
|
|
443
|
+
* picture-in-picture tile, and the identity (name + flashing number/duration
|
|
444
|
+
* line). The merged call audio plays through a hidden element. Always dark by
|
|
445
|
+
* design.
|
|
446
|
+
*/
|
|
447
|
+
export function CallStage({ controller, createController, displayName, avatarUrl, resolveAvatar, className, labelVideo, renderMedia, }) {
|
|
448
|
+
const resolved = useResolvedController(controller, createController);
|
|
449
|
+
const snapshot = useController(resolved);
|
|
450
|
+
const root = useCallsRoot();
|
|
451
|
+
const { locale } = root;
|
|
452
|
+
const remoteRef = useRef(null);
|
|
453
|
+
const localRef = useRef(null);
|
|
454
|
+
// Snapshots from custom controllers may omit the newer arrays.
|
|
455
|
+
const remoteVideos = snapshot.remoteVideos ?? [];
|
|
456
|
+
const remoteHasVideo = remoteVideos.length > 0;
|
|
457
|
+
const seconds = useCallDuration(snapshot);
|
|
458
|
+
const peerAvatar = usePeerAvatar(snapshot.peer, resolveAvatar, avatarUrl);
|
|
459
|
+
const live = snapshot.status === "connected";
|
|
460
|
+
const localStream = resolved.localStream;
|
|
461
|
+
const remoteStream = resolved.remoteStream;
|
|
462
|
+
const [flashPeer, setFlashPeer] = useState(false);
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
if (!live) {
|
|
465
|
+
setFlashPeer(false);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const timer = setInterval(() => setFlashPeer((v) => !v), 5000);
|
|
469
|
+
return () => clearInterval(timer);
|
|
470
|
+
}, [live]);
|
|
471
|
+
useEffect(() => attach(remoteRef.current, remoteStream), [remoteStream, snapshot.revision]);
|
|
472
|
+
useEffect(() => attach(localRef.current, localStream), [localStream, snapshot.revision]);
|
|
473
|
+
const sink = snapshot.selectedDevices.audioOutput;
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
const element = remoteRef.current;
|
|
476
|
+
if (element?.setSinkId !== undefined && sink !== undefined)
|
|
477
|
+
void element.setSinkId(sink).catch(() => undefined);
|
|
478
|
+
}, [sink, snapshot.revision]);
|
|
479
|
+
if (!ACTIVE_STATUSES.has(snapshot.status))
|
|
480
|
+
return null;
|
|
481
|
+
const peer = snapshot.peer ?? "";
|
|
482
|
+
const showLocalVideo = snapshot.video && !snapshot.videoMuted;
|
|
483
|
+
// `live` is false while reconnecting, but the call is established and its
|
|
484
|
+
// layout should stay put — the pre-answer hero preview belongs to a call
|
|
485
|
+
// that has not connected yet.
|
|
486
|
+
const preAccept = showLocalVideo && !live && snapshot.status !== "reconnecting";
|
|
487
|
+
const ringingLine = snapshot.status === "reconnecting"
|
|
488
|
+
? `${t(locale, "calls.reconnecting")}…`
|
|
489
|
+
: // A placed call rings until the controller reports the answer.
|
|
490
|
+
snapshot.status === "ringing" && snapshot.direction === "outgoing"
|
|
491
|
+
? t(locale, "calls.ringingTo", { peer })
|
|
492
|
+
: null;
|
|
493
|
+
const identity = (_jsx(CallIdentity, { name: displayName ?? peer, peer: displayName === undefined ? null : peer, live: live, ringingLine: ringingLine, errorLine: snapshot.error?.code === "call_control_failed"
|
|
494
|
+
? t(locale, "calls.controlFailed")
|
|
495
|
+
: null, flashPeer: flashPeer, seconds: seconds }));
|
|
496
|
+
return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsx("div", { className: `pmfa-calls-stage${className === undefined ? "" : ` ${className}`}`, children: renderMedia !== undefined ? (renderMedia({
|
|
497
|
+
...(localStream === undefined ? {} : { local: localStream }),
|
|
498
|
+
...(remoteStream === undefined ? {} : { remote: remoteStream }),
|
|
499
|
+
videos: remoteVideos.flatMap((info) => {
|
|
500
|
+
const stream = resolved.remoteVideos.find((video) => video.key === info.key)?.stream;
|
|
501
|
+
return stream === undefined
|
|
502
|
+
? []
|
|
503
|
+
: [{ key: info.key, info, stream }];
|
|
504
|
+
}),
|
|
505
|
+
})) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: remoteRef, className: "pmfa-calls-audio-sink", autoPlay: true, playsInline: true, "aria-hidden": "true" }), remoteHasVideo && (_jsx(ParticipantVideoGrid, { controller: resolved, ...(labelVideo === undefined ? {} : { labelVideo }) })), !remoteHasVideo ? (preAccept ? (_jsxs("div", { className: "pmfa-calls-hero-preview", children: [identity, _jsx("div", { className: "pmfa-calls-inset", children: _jsx("video", { ref: localRef, className: "pmfa-calls-preview", autoPlay: true, playsInline: true, muted: true }) })] })) : (_jsxs("div", { className: "pmfa-calls-audio-hero", children: [_jsx(CallAvatar, { displayName: displayName, avatarUrl: peerAvatar, peer: peer }), identity] }))) : (_jsx("div", { className: "pmfa-calls-stage-id", children: identity })), showLocalVideo && !preAccept && (_jsx("div", { className: "pmfa-calls-pip", children: _jsx("video", { ref: localRef, autoPlay: true, playsInline: true, muted: true }) }))] })) }) }));
|
|
506
|
+
}
|
|
507
|
+
function defaultVideoLabel(video, locale) {
|
|
508
|
+
return (video.participant?.phoneNumber ??
|
|
509
|
+
video.participant?.id ??
|
|
510
|
+
video.connectionParticipant ??
|
|
511
|
+
t(locale, "calls.participant"));
|
|
512
|
+
}
|
|
513
|
+
function VideoTile({ stream, label, locale, }) {
|
|
514
|
+
const ref = useRef(null);
|
|
515
|
+
useEffect(() => attach(ref.current, stream), [stream]);
|
|
516
|
+
return (_jsxs("li", { className: "pmfa-calls-tile", children: [_jsx("video", { ref: ref, autoPlay: true, playsInline: true,
|
|
517
|
+
// Audio is the merged call stream; tiles carry video only.
|
|
518
|
+
muted: true, "aria-label": t(locale, "calls.videoFrom", { name: label }) }), _jsx("span", { className: "pmfa-calls-tile-label", children: label })] }));
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* One tile per remote participant video, keyed by participant or
|
|
522
|
+
* connection so a tile survives source changes. Videos are separate
|
|
523
|
+
* streams; nothing is composed. Renders nothing without remote video.
|
|
524
|
+
*/
|
|
525
|
+
export function ParticipantVideoGrid({ controller, createController, className, labelVideo, }) {
|
|
526
|
+
const resolved = useResolvedController(controller, createController);
|
|
527
|
+
const snapshot = useController(resolved);
|
|
528
|
+
const root = useCallsRoot();
|
|
529
|
+
const { locale } = root;
|
|
530
|
+
const videos = snapshot.remoteVideos ?? [];
|
|
531
|
+
if (videos.length === 0)
|
|
532
|
+
return null;
|
|
533
|
+
const count = videos.length;
|
|
534
|
+
return (_jsx("ul", { className: `pmfa-calls-grid${className === undefined ? "" : ` ${className}`}`, "data-count": count > 4 ? "many" : String(count), "aria-label": t(locale, "calls.participantVideos"), children: videos.map((video) => (_jsx(VideoTile, { stream: resolved.remoteVideos.find((remote) => remote.key === video.key)
|
|
535
|
+
?.stream, label: labelVideo?.(video) ?? defaultVideoLabel(video, locale), locale: locale }, video.key))) }));
|
|
536
|
+
}
|
|
537
|
+
const PARTICIPANT_STATE = {
|
|
538
|
+
invited: "calls.participantInvited",
|
|
539
|
+
ringing: "calls.participantRinging",
|
|
540
|
+
connected: "calls.participantConnected",
|
|
541
|
+
left: undefined,
|
|
542
|
+
};
|
|
543
|
+
/** The displayed call's WhatsApp participants and their state. */
|
|
544
|
+
export function ParticipantList({ controller, createController, className, resolveName, }) {
|
|
545
|
+
const resolved = useResolvedController(controller, createController);
|
|
546
|
+
const snapshot = useController(resolved);
|
|
547
|
+
const root = useCallsRoot();
|
|
548
|
+
const { locale } = root;
|
|
549
|
+
const participants = (snapshot.participants ?? []).filter((p) => p.state !== "left");
|
|
550
|
+
if (participants.length === 0)
|
|
551
|
+
return null;
|
|
552
|
+
return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: `pmfa-calls-card pmfa-calls-people${className === undefined ? "" : ` ${className}`}`, children: [_jsx("div", { className: "pmfa-calls-others-title", id: `pmfa-people-${snapshot.callId ?? ""}`, children: t(locale, "calls.participants") }), _jsx("ul", { "aria-labelledby": `pmfa-people-${snapshot.callId ?? ""}`, children: participants.map((participant) => {
|
|
553
|
+
const state = PARTICIPANT_STATE[participant.state];
|
|
554
|
+
return (_jsxs("li", { children: [_jsx("span", { children: resolveName?.(participant) ??
|
|
555
|
+
participant.phoneNumber ??
|
|
556
|
+
participant.id }), _jsx("span", { className: "pmfa-calls-others-state", children: [
|
|
557
|
+
state === undefined ? undefined : t(locale, state),
|
|
558
|
+
participant.audioMuted
|
|
559
|
+
? t(locale, "calls.participantMuted")
|
|
560
|
+
: undefined,
|
|
561
|
+
]
|
|
562
|
+
.filter(Boolean)
|
|
563
|
+
.join(" · ") })] }, participant.id));
|
|
564
|
+
}) })] }) }));
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* The in-call dock, after the official window: the camera split group
|
|
568
|
+
* ([toggle|⌄] — the dropdown picks the camera) and the mic split group
|
|
569
|
+
* ([toggle|⌄] — the dropdown picks microphone + speaker) on the left, the
|
|
570
|
+
* red hang-up pill on the right. The camera group renders whenever the line
|
|
571
|
+
* can carry video (unless `disableVideo`) and is disabled until the call
|
|
572
|
+
* connects. Video is per direction: the camera controls only the outgoing
|
|
573
|
+
* stream.
|
|
574
|
+
*/
|
|
575
|
+
export function CallControls({ controller, createController, className, disableVideo, showLeave, }) {
|
|
576
|
+
const resolved = useResolvedController(controller, createController);
|
|
577
|
+
const snapshot = useController(resolved);
|
|
578
|
+
const root = useCallsRoot();
|
|
579
|
+
const { locale } = root;
|
|
580
|
+
const { open, toggle } = useDevicePanels(resolved, snapshot);
|
|
581
|
+
if (!ACTIVE_STATUSES.has(snapshot.status))
|
|
582
|
+
return null;
|
|
583
|
+
const live = snapshot.status === "connected";
|
|
584
|
+
// Leaving a call this client placed before it connected would keep the
|
|
585
|
+
// callee ringing; only ending it is offered.
|
|
586
|
+
const placing = snapshot.direction === "outgoing" &&
|
|
587
|
+
!live &&
|
|
588
|
+
snapshot.connectedAt === undefined;
|
|
589
|
+
const leaveShown = !placing && (showLeave ?? !snapshot.exclusive);
|
|
590
|
+
const showCamera = snapshot.capabilities.video && disableVideo !== true;
|
|
591
|
+
// The affordance stays visible on an audio call (the settled design) and is
|
|
592
|
+
// disabled until it can act: connected, and either already a video call or
|
|
593
|
+
// an upgrade the media session can actually perform. A session that cannot
|
|
594
|
+
// renegotiate therefore shows the same disabled button as a call that has
|
|
595
|
+
// not connected yet, instead of a live one that does nothing.
|
|
596
|
+
const cameraReady = live && (snapshot.video || resolved.canEnableVideo);
|
|
597
|
+
const cameraOff = !snapshot.video || snapshot.videoMuted;
|
|
598
|
+
const onCamera = () => {
|
|
599
|
+
// On a video call the button mutes/unmutes the outgoing track; on an
|
|
600
|
+
// audio call it upgrades to video (camera + re-offer on the same
|
|
601
|
+
// connection) through the controller.
|
|
602
|
+
if (snapshot.video)
|
|
603
|
+
ignoreDisposed(() => resolved.setMuted({ video: !snapshot.videoMuted }));
|
|
604
|
+
// A denied camera, a failed re-offer, or a call that ended mid-upgrade all
|
|
605
|
+
// reject here. The upgrade rolls itself back, so the audio call carries on
|
|
606
|
+
// and the button stays live for another try; swallowing the rejection just
|
|
607
|
+
// keeps a declined camera from surfacing as an unhandled error.
|
|
608
|
+
else
|
|
609
|
+
void resolved.enableVideo().catch(() => undefined);
|
|
610
|
+
};
|
|
611
|
+
return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: "pmfa-calls-dock-col", children: [open === "mic" && (_jsx("div", { className: "pmfa-calls-pop", role: "group", "aria-label": t(locale, "calls.microphoneSettings"), children: _jsx(DeviceFields, { controller: resolved, snapshot: snapshot, fields: ["audioInput", "audioOutput"], locale: locale }) })), open === "cam" && (_jsx("div", { className: "pmfa-calls-pop", role: "group", "aria-label": t(locale, "calls.cameraSettings"), children: _jsx(DeviceFields, { controller: resolved, snapshot: snapshot, fields: ["videoInput"], locale: locale }) })), _jsxs("div", { className: `pmfa-calls-dock${className === undefined ? "" : ` ${className}`}`, children: [showCamera && (_jsxs("div", { className: `pmfa-calls-group${cameraReady ? "" : " pmfa-calls-disabled"}`, children: [_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-ctrl${cameraOff ? " pmfa-calls-on" : ""}`, onClick: onCamera, disabled: !cameraReady, "aria-label": t(locale, cameraOff ? "calls.cameraOn" : "calls.cameraOff"), "aria-pressed": cameraOff, children: cameraOff ? _jsx(VideoOffIcon, {}) : _jsx(VideoIcon, {}) }), _jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-acc${open === "cam" ? " pmfa-calls-open" : ""}`, onClick: () => toggle("cam"), disabled: !live, "aria-label": t(locale, "calls.cameraSettings"), "aria-expanded": open === "cam", children: _jsx(ChevronDownIcon, {}) })] })), snapshot.capabilities.mute && (_jsxs("div", { className: "pmfa-calls-group", children: [_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-ctrl${snapshot.audioMuted ? " pmfa-calls-on" : ""}`, onClick: () => ignoreDisposed(() => resolved.setMuted({ audio: !snapshot.audioMuted })), "aria-label": t(locale, snapshot.audioMuted ? "calls.unmute" : "calls.mute"), "aria-pressed": snapshot.audioMuted, children: snapshot.audioMuted ? _jsx(MicOffIcon, {}) : _jsx(MicIcon, {}) }), _jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-acc${open === "mic" ? " pmfa-calls-open" : ""}`, onClick: () => toggle("mic"), "aria-label": t(locale, "calls.microphoneSettings"), "aria-expanded": open === "mic", children: _jsx(ChevronDownIcon, {}) })] })), leaveShown && (_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-ctrl pmfa-calls-btn-leave", onClick: () => void resolved.leave().catch(() => undefined), "aria-label": t(locale, "calls.leave"), title: t(locale, "calls.leave"), children: _jsx(LeaveIcon, {}) })), _jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-hangup",
|
|
612
|
+
// Rejects on a disposed controller, which a mounted surface
|
|
613
|
+
// holding an application-supplied one can still be clicked
|
|
614
|
+
// against; the call is over either way.
|
|
615
|
+
onClick: () => void resolved.hangup().catch(() => undefined), "aria-label": t(locale, leaveShown ? "calls.end" : "calls.hangup"), title: t(locale, leaveShown ? "calls.end" : "calls.hangup"), children: _jsx(HangupIcon, {}) })] })] }) }));
|
|
616
|
+
}
|
|
617
|
+
// ── DialPad ───────────────────────────────────────────────────────────
|
|
618
|
+
const KEYS = [
|
|
619
|
+
"1",
|
|
620
|
+
"2",
|
|
621
|
+
"3",
|
|
622
|
+
"4",
|
|
623
|
+
"5",
|
|
624
|
+
"6",
|
|
625
|
+
"7",
|
|
626
|
+
"8",
|
|
627
|
+
"9",
|
|
628
|
+
"+",
|
|
629
|
+
"0",
|
|
630
|
+
"#",
|
|
631
|
+
];
|
|
632
|
+
/**
|
|
633
|
+
* A dial pad card: E.164 entry plus place-call buttons — audio always, and a
|
|
634
|
+
* video-call button unless `allowVideo` is false.
|
|
635
|
+
*/
|
|
636
|
+
export function DialPad({ controller, createController, defaultValue = "", allowVideo = true, onPlaced, className, }) {
|
|
637
|
+
const resolved = useResolvedController(controller, createController);
|
|
638
|
+
const snapshot = useController(resolved);
|
|
639
|
+
const root = useCallsRoot();
|
|
640
|
+
const { locale } = root;
|
|
641
|
+
const [value, setValue] = useState(defaultValue);
|
|
642
|
+
const busy = snapshot.status === "incoming" || ACTIVE_STATUSES.has(snapshot.status);
|
|
643
|
+
const canVideo = allowVideo;
|
|
644
|
+
const dial = (video) => {
|
|
645
|
+
const to = value.trim();
|
|
646
|
+
// The buttons are disabled while a call is up, but the input keeps focus
|
|
647
|
+
// and its value, so Enter would otherwise place a second call — and
|
|
648
|
+
// `place()` aborts the current operation, tearing down the live call.
|
|
649
|
+
if (busy || to.length === 0)
|
|
650
|
+
return;
|
|
651
|
+
// `place()` resolves even when the placement failed — the controller
|
|
652
|
+
// reports that through the snapshot — so the snapshot is what says
|
|
653
|
+
// whether there is now a call to announce.
|
|
654
|
+
void resolved
|
|
655
|
+
.place(to, { video })
|
|
656
|
+
.then(() => {
|
|
657
|
+
if (ACTIVE_STATUSES.has(resolved.getSnapshot().status))
|
|
658
|
+
onPlaced?.(to);
|
|
659
|
+
})
|
|
660
|
+
// A placement failure lands in the snapshot, not here, but `place()`
|
|
661
|
+
// does reject on a disposed controller — which a mounted pad holding a
|
|
662
|
+
// shared one can still be dialled against.
|
|
663
|
+
.catch(() => undefined);
|
|
664
|
+
};
|
|
665
|
+
return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: `pmfa-calls-card pmfa-calls-dialpad${className === undefined ? "" : ` ${className}`}`, children: [_jsx("input", { className: "pmfa-calls-input", type: "tel", inputMode: "tel", placeholder: "+1 555 000 0000", value: value, onChange: (event) => setValue(event.currentTarget.value), onKeyDown: (event) => {
|
|
666
|
+
if (event.key === "Enter")
|
|
667
|
+
dial(false);
|
|
668
|
+
}, "aria-label": t(locale, "calls.dialPlaceholder") }), _jsx("div", { className: "pmfa-calls-keys", children: KEYS.map((digit) => (_jsx("button", { type: "button", className: "pmfa-calls-key", onClick: () => setValue((v) => v + digit), "aria-label": digit, children: _jsx("span", { className: "pmfa-calls-key-digit", children: digit }) }, digit))) }), _jsxs("div", { className: "pmfa-calls-dial-actions", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: () => dial(false), disabled: busy || value.trim().length === 0, "aria-label": t(locale, "calls.placeAudioCall"), children: _jsx(PhoneIcon, {}) }), canVideo && (_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: () => dial(true), disabled: busy || value.trim().length === 0, "aria-label": t(locale, "calls.placeVideoCall"), children: _jsx(VideoIcon, {}) })), _jsx("button", { type: "button", className: "pmfa-calls-key pmfa-calls-key-erase", onClick: () => setValue((v) => v.slice(0, -1)), disabled: value.length === 0, "aria-label": t(locale, "calls.deleteDigit"), children: _jsx(EraseIcon, {}) })] })] }) }));
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Owns a separate, freely resizable call window (like the desktop client's)
|
|
672
|
+
* and hands back a container to portal the stage + controls into. Media
|
|
673
|
+
* streams keep playing across same-origin windows, so portal'd `<video>`
|
|
674
|
+
* elements just work.
|
|
675
|
+
*/
|
|
676
|
+
/**
|
|
677
|
+
* @param title Window title. `CallSurface` passes the localized
|
|
678
|
+
* `calls.popoutTitle`; the default keeps the hook usable on its own.
|
|
679
|
+
*/
|
|
680
|
+
export function useCallPopout(title = "Call") {
|
|
681
|
+
const windowRef = useRef(null);
|
|
682
|
+
const [container, setContainer] = useState(null);
|
|
683
|
+
const close = useCallback(() => {
|
|
684
|
+
windowRef.current?.close();
|
|
685
|
+
windowRef.current = null;
|
|
686
|
+
setContainer(null);
|
|
687
|
+
}, []);
|
|
688
|
+
const open = useCallback(() => {
|
|
689
|
+
if (typeof window === "undefined" || windowRef.current !== null)
|
|
690
|
+
return;
|
|
691
|
+
const popup = window.open("", "pmfa-call", "popup=yes,width=420,height=600");
|
|
692
|
+
if (popup === null)
|
|
693
|
+
return; // popup blocked — the call stays inline
|
|
694
|
+
const doc = popup.document;
|
|
695
|
+
doc.title = title;
|
|
696
|
+
const style = doc.createElement("style");
|
|
697
|
+
style.textContent = `html,body{margin:0;height:100%;background:oklch(0.15 0 0);}${CALLS_STYLES}`;
|
|
698
|
+
doc.head.appendChild(style);
|
|
699
|
+
const root = doc.createElement("div");
|
|
700
|
+
root.style.height = "100%";
|
|
701
|
+
doc.body.appendChild(root);
|
|
702
|
+
windowRef.current = popup;
|
|
703
|
+
setContainer(root);
|
|
704
|
+
}, [title]);
|
|
705
|
+
useEffect(() => {
|
|
706
|
+
if (container === null)
|
|
707
|
+
return;
|
|
708
|
+
const timer = setInterval(() => {
|
|
709
|
+
if (windowRef.current?.closed === true) {
|
|
710
|
+
windowRef.current = null;
|
|
711
|
+
setContainer(null);
|
|
712
|
+
}
|
|
713
|
+
}, 500);
|
|
714
|
+
return () => clearInterval(timer);
|
|
715
|
+
}, [container]);
|
|
716
|
+
useEffect(() => () => windowRef.current?.close(), []);
|
|
717
|
+
return { popped: container !== null, open, close, container };
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* The whole call experience in one component: a fixed overlay that shows the
|
|
721
|
+
* incoming-call card while ringing and the stage + control dock during a
|
|
722
|
+
* call. The ↗ button pops the call into its own resizable browser window;
|
|
723
|
+
* closing that window (or the call ending) pops it back inline.
|
|
724
|
+
*/
|
|
725
|
+
export function CallSurface({ controller, createController, resolveName, resolveAvatar, disableVideo, popout: allowPopout = true, renderMedia, exclusive = false, showParticipants = true, showLeave, labelVideo, }) {
|
|
726
|
+
const resolved = useResolvedController(controller, createController);
|
|
727
|
+
const snapshot = useController(resolved);
|
|
728
|
+
const root = useCallsRoot();
|
|
729
|
+
const { locale } = root;
|
|
730
|
+
const popout = useCallPopout(t(locale, "calls.popoutTitle"));
|
|
731
|
+
const peer = snapshot.peer;
|
|
732
|
+
const displayName = peer === undefined ? undefined : resolveName?.(peer);
|
|
733
|
+
const active = ACTIVE_STATUSES.has(snapshot.status);
|
|
734
|
+
const incoming = snapshot.status === "incoming";
|
|
735
|
+
const { popped, close } = popout;
|
|
736
|
+
useEffect(() => {
|
|
737
|
+
if (!active && popped)
|
|
738
|
+
close();
|
|
739
|
+
}, [active, popped, close]);
|
|
740
|
+
if (!incoming && !active)
|
|
741
|
+
return null;
|
|
742
|
+
const overlay = {
|
|
743
|
+
position: "fixed",
|
|
744
|
+
right: 20,
|
|
745
|
+
bottom: 20,
|
|
746
|
+
zIndex: 2147483000,
|
|
747
|
+
display: "flex",
|
|
748
|
+
flexDirection: "column",
|
|
749
|
+
alignItems: "center",
|
|
750
|
+
gap: 12,
|
|
751
|
+
width: 340,
|
|
752
|
+
maxWidth: "calc(100vw - 40px)",
|
|
753
|
+
};
|
|
754
|
+
const stageProps = {
|
|
755
|
+
controller: resolved,
|
|
756
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
757
|
+
...(resolveAvatar === undefined ? {} : { resolveAvatar }),
|
|
758
|
+
...(renderMedia === undefined ? {} : { renderMedia }),
|
|
759
|
+
...(labelVideo === undefined ? {} : { labelVideo }),
|
|
760
|
+
};
|
|
761
|
+
const callUi = (_jsxs(_Fragment, { children: [_jsx(CallStage, { ...stageProps }), _jsx(CallControls, { controller: resolved, ...(disableVideo === undefined ? {} : { disableVideo }), ...(showLeave === undefined ? {} : { showLeave }) }), showParticipants && _jsx(ParticipantList, { controller: resolved })] }));
|
|
762
|
+
return (_jsxs("div", { style: overlay, "data-pmfa": "call-surface", children: [_jsx(IncomingCallCard, { controller: resolved, exclusive: exclusive, ...(displayName === undefined ? {} : { displayName }), ...(resolveAvatar === undefined ? {} : { resolveAvatar }), ...(resolveName === undefined ? {} : { resolveName }) }), active &&
|
|
763
|
+
(popout.popped && popout.container !== null ? (_jsxs(_Fragment, { children: [createPortal(_jsx("div", { className: `${root.className} pmfa-calls-popout`, style: root.style, dir: root.dir, children: callUi }), popout.container), _jsx("div", { className: root.className, style: root.style, dir: root.dir, children: _jsxs("div", { className: "pmfa-calls-popbar", children: [t(locale, "calls.popOut"), _jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-mini", onClick: popout.close, "aria-label": t(locale, "calls.popIn"), children: _jsx(PopoutIcon, { style: { transform: "scaleX(-1)" } }) })] }) })] })) : (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [callUi, allowPopout && (_jsx("div", { className: root.className, style: root.style, dir: root.dir, children: _jsx("div", { className: "pmfa-calls-more", children: _jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-mini", onClick: popout.open, "aria-label": t(locale, "calls.popOut"), children: _jsx(PopoutIcon, {}) }) }) }))] })))] }));
|
|
764
|
+
}
|
|
765
|
+
// ── Stylesheet ────────────────────────────────────────────────────────
|
|
766
|
+
/**
|
|
767
|
+
* One injected `<style>` tag, scoped under `.pmfa-calls` so nothing leaks into
|
|
768
|
+
* the host app, inside the `polymorfa` cascade layer so unlayered host CSS
|
|
769
|
+
* wins. Colors derive from the shared `--pmfa-*` appearance variables
|
|
770
|
+
* with the monochrome Material-3 palette as fallbacks; the verbs use
|
|
771
|
+
* `--pmfa-color-success` / `--pmfa-color-danger`. All motion is disabled under
|
|
772
|
+
* `prefers-reduced-motion: reduce`.
|
|
773
|
+
*/
|
|
774
|
+
export const CALLS_STYLES = `
|
|
775
|
+
@layer polymorfa {
|
|
776
|
+
.pmfa-calls {
|
|
777
|
+
--pmfa-calls-accent: var(--pmfa-color-foreground, oklch(0.15 0 0));
|
|
778
|
+
--pmfa-calls-accent-soft: oklch(0.96 0 0);
|
|
779
|
+
--pmfa-calls-answer: var(--pmfa-color-success, oklch(0.76 0.19 150));
|
|
780
|
+
--pmfa-calls-answer-strong: oklch(0.68 0.18 150);
|
|
781
|
+
--pmfa-calls-danger: var(--pmfa-color-danger, oklch(0.5 0.19 28));
|
|
782
|
+
--pmfa-calls-danger-strong: oklch(0.44 0.18 28);
|
|
783
|
+
--pmfa-calls-card: var(--pmfa-color-background, oklch(1 0 0));
|
|
784
|
+
--pmfa-calls-fg: var(--pmfa-color-foreground, oklch(0.17 0 0));
|
|
785
|
+
--pmfa-calls-muted: var(--pmfa-color-muted, oklch(0.49 0.008 250));
|
|
786
|
+
--pmfa-calls-border: var(--pmfa-color-border, oklch(0.91 0 0));
|
|
787
|
+
--pmfa-calls-stage: oklch(0.15 0 0);
|
|
788
|
+
--pmfa-calls-stage-fg: oklch(0.985 0 0);
|
|
789
|
+
--pmfa-calls-stage-muted: oklch(0.75 0 0);
|
|
790
|
+
--pmfa-calls-stage-line: oklch(1 0 0 / 0.12);
|
|
791
|
+
--pmfa-calls-avatar-1: oklch(0.55 0.09 200);
|
|
792
|
+
--pmfa-calls-avatar-2: oklch(0.55 0.12 255);
|
|
793
|
+
--pmfa-calls-avatar-3: oklch(0.55 0.14 292);
|
|
794
|
+
--pmfa-calls-avatar-4: oklch(0.58 0.14 10);
|
|
795
|
+
--pmfa-calls-avatar-5: oklch(0.6 0.11 70);
|
|
796
|
+
--pmfa-calls-avatar-6: oklch(0.55 0.11 150);
|
|
797
|
+
--pmfa-calls-avatar-7: oklch(0.5 0.13 275);
|
|
798
|
+
--pmfa-calls-avatar-8: oklch(0.55 0.14 330);
|
|
799
|
+
--pmfa-calls-radius: var(--pmfa-radius-medium, 0.5rem);
|
|
800
|
+
--pmfa-calls-font: var(--pmfa-font-family, "Roboto", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
801
|
+
font-family: var(--pmfa-calls-font);
|
|
802
|
+
color: var(--pmfa-calls-fg);
|
|
803
|
+
font-size: 14px;
|
|
804
|
+
line-height: 1.45;
|
|
805
|
+
-webkit-font-smoothing: antialiased;
|
|
806
|
+
}
|
|
807
|
+
.pmfa-calls *, .pmfa-calls *::before, .pmfa-calls *::after { box-sizing: border-box; }
|
|
808
|
+
.pmfa-calls :focus-visible { outline: 2px solid var(--pmfa-calls-accent); outline-offset: 2px; }
|
|
809
|
+
.pmfa-calls.pmfa-calls-dark {
|
|
810
|
+
--pmfa-calls-accent: oklch(0.92 0 0);
|
|
811
|
+
--pmfa-calls-accent-soft: oklch(1 0 0 / 0.08);
|
|
812
|
+
--pmfa-calls-card: oklch(0.21 0 0);
|
|
813
|
+
--pmfa-calls-fg: oklch(0.985 0 0);
|
|
814
|
+
--pmfa-calls-muted: oklch(0.74 0 0);
|
|
815
|
+
--pmfa-calls-border: oklch(1 0 0 / 0.12);
|
|
816
|
+
}
|
|
817
|
+
@media (prefers-color-scheme: dark) {
|
|
818
|
+
.pmfa-calls.pmfa-calls-auto {
|
|
819
|
+
--pmfa-calls-accent: oklch(0.92 0 0);
|
|
820
|
+
--pmfa-calls-accent-soft: oklch(1 0 0 / 0.08);
|
|
821
|
+
--pmfa-calls-card: oklch(0.21 0 0);
|
|
822
|
+
--pmfa-calls-fg: oklch(0.985 0 0);
|
|
823
|
+
--pmfa-calls-muted: oklch(0.74 0 0);
|
|
824
|
+
--pmfa-calls-border: oklch(1 0 0 / 0.12);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
.pmfa-calls-card { background: var(--pmfa-calls-card); border: 1px solid var(--pmfa-calls-border); border-radius: var(--pmfa-calls-radius); }
|
|
828
|
+
.pmfa-calls-incoming { position: relative; width: 100%; padding: 22px 16px 18px; display: flex; flex-direction: column; text-align: center; background: var(--pmfa-calls-stage); color: var(--pmfa-calls-stage-fg); border-color: var(--pmfa-calls-stage-line); animation: pmfa-calls-rise 0.28s cubic-bezier(0.16, 1, 0.3, 1); }
|
|
829
|
+
.pmfa-calls-incoming .pmfa-calls-name { font-size: 19px; font-weight: 600; }
|
|
830
|
+
.pmfa-calls-subtitle { margin-top: 3px; font-size: 13px; color: var(--pmfa-calls-stage-muted); }
|
|
831
|
+
.pmfa-calls-media { position: relative; width: 100%; margin-top: 16px; border-radius: var(--pmfa-calls-radius); overflow: hidden; aspect-ratio: 16 / 10; background: oklch(0.08 0 0); }
|
|
832
|
+
.pmfa-calls-preview { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); }
|
|
833
|
+
.pmfa-calls-media-audio { overflow: visible; background: transparent; display: flex; align-items: center; justify-content: center; }
|
|
834
|
+
.pmfa-calls-media-audio .pmfa-calls-avatar-wrap { margin-bottom: 0; }
|
|
835
|
+
.pmfa-calls-mini { width: 36px; height: 36px; background: oklch(1 0 0 / 0.14); color: oklch(0.985 0 0); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
|
836
|
+
.pmfa-calls-mini:hover { background: oklch(1 0 0 / 0.22); }
|
|
837
|
+
.pmfa-calls-mini svg { width: 16px; height: 16px; }
|
|
838
|
+
.pmfa-calls-mini.pmfa-calls-on { background: oklch(0.985 0 0); color: oklch(0.2 0 0); }
|
|
839
|
+
.pmfa-calls-more { position: absolute; top: 10px; right: 10px; z-index: 2; }
|
|
840
|
+
.pmfa-calls-media-ctrls { position: absolute; bottom: 10px; left: 0; right: 0; display: flex; justify-content: center; gap: 10px; z-index: 2; }
|
|
841
|
+
.pmfa-calls-pop-float { position: absolute; left: 50%; transform: translateX(-50%); top: 96px; z-index: 4; }
|
|
842
|
+
.pmfa-calls-avatar-wrap { position: relative; width: 72px; height: 72px; margin-bottom: 14px; }
|
|
843
|
+
.pmfa-calls-avatar { position: relative; z-index: 1; width: 72px; height: 72px; border-radius: 50%; display: flex; align-items: center; justify-content: center; overflow: hidden; color: oklch(0.985 0 0); font-size: 24px; font-weight: 500; letter-spacing: 0.01em; background: oklch(0.22 0 0); }
|
|
844
|
+
.pmfa-calls-audio-hero .pmfa-calls-avatar { border: 1.5px solid var(--pmfa-calls-stage-line); }
|
|
845
|
+
.pmfa-calls-avatar-c1 { background: var(--pmfa-calls-avatar-1); }
|
|
846
|
+
.pmfa-calls-avatar-c2 { background: var(--pmfa-calls-avatar-2); }
|
|
847
|
+
.pmfa-calls-avatar-c3 { background: var(--pmfa-calls-avatar-3); }
|
|
848
|
+
.pmfa-calls-avatar-c4 { background: var(--pmfa-calls-avatar-4); }
|
|
849
|
+
.pmfa-calls-avatar-c5 { background: var(--pmfa-calls-avatar-5); }
|
|
850
|
+
.pmfa-calls-avatar-c6 { background: var(--pmfa-calls-avatar-6); }
|
|
851
|
+
.pmfa-calls-avatar-c7 { background: var(--pmfa-calls-avatar-7); }
|
|
852
|
+
.pmfa-calls-avatar-c8 { background: var(--pmfa-calls-avatar-8); }
|
|
853
|
+
.pmfa-calls-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|
854
|
+
.pmfa-calls-name { font-size: 16px; font-weight: 500; letter-spacing: 0.01em; text-wrap: balance; }
|
|
855
|
+
.pmfa-calls-peer { font-size: 12.5px; font-variant-numeric: tabular-nums; color: var(--pmfa-calls-muted); letter-spacing: 0.01em; }
|
|
856
|
+
.pmfa-calls-flash { animation: pmfa-calls-swap 0.35s ease; }
|
|
857
|
+
@keyframes pmfa-calls-swap { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
|
|
858
|
+
.pmfa-calls-btn { appearance: none; border: none; margin: 0; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 50%; color: oklch(0.985 0 0); transition: background-color 0.14s ease; }
|
|
859
|
+
.pmfa-calls-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
860
|
+
.pmfa-calls-btn svg { width: 22px; height: 22px; }
|
|
861
|
+
.pmfa-calls-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-top: 22px; width: 100%; }
|
|
862
|
+
.pmfa-calls-action { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 11.5px; color: var(--pmfa-calls-stage-muted); }
|
|
863
|
+
.pmfa-calls-btn-answer { width: 56px; height: 56px; background: var(--pmfa-calls-answer); animation: pmfa-calls-beckon 2s ease-in-out infinite; }
|
|
864
|
+
.pmfa-calls-btn-answer:hover { background: var(--pmfa-calls-answer-strong); }
|
|
865
|
+
.pmfa-calls-btn-decline { width: 56px; height: 56px; background: var(--pmfa-calls-danger); }
|
|
866
|
+
.pmfa-calls-btn-decline:hover { background: var(--pmfa-calls-danger-strong); }
|
|
867
|
+
@keyframes pmfa-calls-beckon { 0%, 100% { box-shadow: 0 0 0 0 oklch(0.76 0.19 150 / 0.4); } 50% { box-shadow: 0 0 0 10px oklch(0.76 0.19 150 / 0); } }
|
|
868
|
+
@keyframes pmfa-calls-fade { from { opacity: 0; } to { opacity: 1; } }
|
|
869
|
+
.pmfa-calls-pop { width: 236px; padding: 12px; display: flex; flex-direction: column; gap: 10px; text-align: left; background: var(--pmfa-calls-card); color: var(--pmfa-calls-fg); border: 1px solid var(--pmfa-calls-border); border-radius: calc(var(--pmfa-calls-radius) + 4px); animation: pmfa-calls-fade 0.16s ease; }
|
|
870
|
+
.pmfa-calls-field { display: flex; flex-direction: column; gap: 4px; font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; color: var(--pmfa-calls-muted); }
|
|
871
|
+
.pmfa-calls-select { width: 100%; padding: 7px 8px; font: inherit; font-family: var(--pmfa-calls-font); font-size: 12.5px; font-weight: 400; text-transform: none; letter-spacing: normal; color: var(--pmfa-calls-fg); background: transparent; border: 1px solid var(--pmfa-calls-border); border-radius: var(--pmfa-calls-radius); }
|
|
872
|
+
.pmfa-calls-select:focus { outline: none; border-color: var(--pmfa-calls-accent); }
|
|
873
|
+
.pmfa-calls-select option { color: initial; background: initial; }
|
|
874
|
+
.pmfa-calls-stage { position: relative; width: 100%; aspect-ratio: 4 / 3; border-radius: var(--pmfa-calls-radius); overflow: hidden; background: radial-gradient(130% 90% at 50% 0%, oklch(1 0 0 / 0.06) 0%, transparent 62%), var(--pmfa-calls-stage); color: var(--pmfa-calls-stage-fg); }
|
|
875
|
+
.pmfa-calls-audio-sink { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
876
|
+
.pmfa-calls-grid { position: absolute; inset: 0; margin: 0; padding: 4px; list-style: none; display: grid; gap: 4px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 120px), 1fr)); grid-auto-rows: 1fr; background: var(--pmfa-calls-stage); }
|
|
877
|
+
.pmfa-calls-grid[data-count="1"] { grid-template-columns: 1fr; padding: 0; }
|
|
878
|
+
.pmfa-calls-tile { position: relative; min-height: 0; overflow: hidden; border-radius: var(--pmfa-calls-radius); background: oklch(0.08 0 0); }
|
|
879
|
+
.pmfa-calls-grid[data-count="1"] .pmfa-calls-tile { border-radius: 0; }
|
|
880
|
+
.pmfa-calls-tile video { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
881
|
+
.pmfa-calls-tile-label { position: absolute; left: 6px; bottom: 6px; max-width: calc(100% - 12px); padding: 2px 6px; border-radius: 4px; font-size: 11.5px; color: var(--pmfa-calls-stage-fg); background: oklch(0.1 0 0 / 0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
882
|
+
.pmfa-calls-btn-dismiss { width: 56px; height: 56px; background: oklch(1 0 0 / 0.14); }
|
|
883
|
+
.pmfa-calls-btn-dismiss:hover { background: oklch(1 0 0 / 0.22); }
|
|
884
|
+
.pmfa-calls-btn-leave { width: 46px; }
|
|
885
|
+
.pmfa-calls-others, .pmfa-calls-people { width: 100%; margin-top: 14px; text-align: left; }
|
|
886
|
+
.pmfa-calls-people { margin-top: 0; padding: 12px 14px; }
|
|
887
|
+
.pmfa-calls-others ul, .pmfa-calls-people ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
888
|
+
.pmfa-calls-others li, .pmfa-calls-people li { display: flex; align-items: center; gap: 8px; font-size: 13px; min-width: 0; }
|
|
889
|
+
.pmfa-calls-others li > span:first-child, .pmfa-calls-people li > span:first-child { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
890
|
+
.pmfa-calls-others-title { font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; color: var(--pmfa-calls-muted); }
|
|
891
|
+
.pmfa-calls-incoming .pmfa-calls-others-title, .pmfa-calls-others-state { color: var(--pmfa-calls-stage-muted); }
|
|
892
|
+
.pmfa-calls-people .pmfa-calls-others-state { color: var(--pmfa-calls-muted); }
|
|
893
|
+
.pmfa-calls-others-state { font-size: 12px; white-space: nowrap; }
|
|
894
|
+
.pmfa-calls-chip { appearance: none; border: 1px solid var(--pmfa-calls-stage-line); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 999px; cursor: pointer; }
|
|
895
|
+
.pmfa-calls-chip:hover { background: oklch(1 0 0 / 0.12); }
|
|
896
|
+
.pmfa-calls-audio-hero { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; }
|
|
897
|
+
.pmfa-calls-audio-hero .pmfa-calls-name { font-size: 19px; }
|
|
898
|
+
.pmfa-calls-audio-hero .pmfa-calls-peer { color: var(--pmfa-calls-stage-muted); }
|
|
899
|
+
.pmfa-calls-hero-preview { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 0 24px; text-align: center; }
|
|
900
|
+
.pmfa-calls-hero-preview .pmfa-calls-peer { color: var(--pmfa-calls-stage-muted); }
|
|
901
|
+
.pmfa-calls-inset { position: relative; width: 82%; aspect-ratio: 16 / 10; border-radius: var(--pmfa-calls-radius); overflow: hidden; background: oklch(0.08 0 0); }
|
|
902
|
+
.pmfa-calls-pip { position: absolute; right: 14px; bottom: 14px; width: 96px; aspect-ratio: 3 / 4; border-radius: var(--pmfa-calls-radius); overflow: hidden; border: 1.5px solid var(--pmfa-calls-stage-line); background: oklch(0.24 0 0); }
|
|
903
|
+
.pmfa-calls-pip video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); }
|
|
904
|
+
.pmfa-calls-stage-id { position: absolute; top: 0; left: 0; right: 0; padding: 16px 18px 30px; background: linear-gradient(oklch(0.1 0 0 / 0.6), transparent); color: var(--pmfa-calls-stage-fg); }
|
|
905
|
+
.pmfa-calls-stage-id .pmfa-calls-peer { color: var(--pmfa-calls-stage-muted); }
|
|
906
|
+
.pmfa-calls-dock-col { position: relative; display: inline-flex; flex-direction: column; align-items: center; }
|
|
907
|
+
.pmfa-calls-dock-col > .pmfa-calls-pop { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%); z-index: 5; }
|
|
908
|
+
.pmfa-calls-dock { display: inline-flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--pmfa-calls-stage-line); background: oklch(0.17 0 0 / 0.72); backdrop-filter: blur(18px) saturate(1.4); -webkit-backdrop-filter: blur(18px) saturate(1.4); animation: pmfa-calls-rise 0.32s cubic-bezier(0.16, 1, 0.3, 1); }
|
|
909
|
+
.pmfa-calls-btn-ctrl { width: 46px; height: 46px; background: oklch(1 0 0 / 0.1); color: oklch(0.985 0 0); }
|
|
910
|
+
.pmfa-calls-btn-ctrl:hover { background: oklch(1 0 0 / 0.18); }
|
|
911
|
+
.pmfa-calls-group { display: inline-flex; align-items: stretch; border-radius: 999px; background: oklch(1 0 0 / 0.1); overflow: hidden; }
|
|
912
|
+
.pmfa-calls-group.pmfa-calls-disabled { opacity: 0.45; }
|
|
913
|
+
.pmfa-calls-group .pmfa-calls-btn-ctrl { background: transparent; border-radius: 0; }
|
|
914
|
+
.pmfa-calls-group .pmfa-calls-btn-ctrl:hover { background: oklch(1 0 0 / 0.12); }
|
|
915
|
+
.pmfa-calls-group .pmfa-calls-btn-ctrl.pmfa-calls-on { background: oklch(0.985 0 0); }
|
|
916
|
+
.pmfa-calls-btn-acc { width: 30px; height: 46px; border-radius: 0; background: transparent; color: oklch(0.985 0 0 / 0.8); border-left: 1px solid oklch(1 0 0 / 0.12); }
|
|
917
|
+
.pmfa-calls-btn-acc:hover { background: oklch(1 0 0 / 0.12); transform: none; }
|
|
918
|
+
.pmfa-calls-btn-acc svg { width: 14px; height: 14px; transition: transform 0.18s ease; }
|
|
919
|
+
.pmfa-calls-btn-acc.pmfa-calls-open svg { transform: rotate(180deg); }
|
|
920
|
+
.pmfa-calls-btn-ctrl.pmfa-calls-on { background: oklch(0.985 0 0); color: oklch(0.2 0 0); }
|
|
921
|
+
.pmfa-calls-btn-hangup { width: 64px; height: 46px; border-radius: 999px; background: var(--pmfa-calls-danger); }
|
|
922
|
+
.pmfa-calls-btn-hangup:hover { background: var(--pmfa-calls-danger-strong); }
|
|
923
|
+
.pmfa-calls-dialpad { width: 100%; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
|
924
|
+
.pmfa-calls-input { width: 100%; border: none; background: transparent; color: var(--pmfa-calls-fg); font-variant-numeric: tabular-nums; font-size: 22px; text-align: center; letter-spacing: 0.03em; padding: 6px 2px 10px; border-bottom: 1px solid var(--pmfa-calls-border); }
|
|
925
|
+
.pmfa-calls-input:focus { outline: none; border-bottom-color: var(--pmfa-calls-accent); }
|
|
926
|
+
.pmfa-calls-input::placeholder { color: var(--pmfa-calls-muted); opacity: 0.6; }
|
|
927
|
+
.pmfa-calls-keys { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; justify-items: center; }
|
|
928
|
+
.pmfa-calls-key { appearance: none; border: none; margin: 0; width: 62px; height: 48px; border-radius: var(--pmfa-calls-radius); background: transparent; color: var(--pmfa-calls-fg); font-family: var(--pmfa-calls-font); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.12s ease; }
|
|
929
|
+
.pmfa-calls-key:hover { background: var(--pmfa-calls-accent-soft); }
|
|
930
|
+
.pmfa-calls-key-digit { font-size: 21px; font-weight: 500; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
|
931
|
+
.pmfa-calls-dial-actions { display: flex; align-items: center; justify-content: center; gap: 18px; }
|
|
932
|
+
.pmfa-calls-dialpad .pmfa-calls-btn-answer { animation: none; }
|
|
933
|
+
.pmfa-calls-key-erase { color: var(--pmfa-calls-muted); }
|
|
934
|
+
.pmfa-calls-key-erase:hover { color: var(--pmfa-calls-fg); background: var(--pmfa-calls-accent-soft); }
|
|
935
|
+
.pmfa-calls-key-erase svg { width: 20px; height: 20px; }
|
|
936
|
+
.pmfa-calls-popout { display: flex; flex-direction: column; align-items: center; gap: 12px; height: 100%; padding: 12px; background: var(--pmfa-calls-stage); }
|
|
937
|
+
.pmfa-calls-popout .pmfa-calls-stage { flex: 1; min-height: 0; aspect-ratio: auto; }
|
|
938
|
+
.pmfa-calls-popbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 8px 8px 8px 14px; border-radius: var(--pmfa-calls-radius); border: 1px solid var(--pmfa-calls-stage-line); background: var(--pmfa-calls-stage); color: var(--pmfa-calls-stage-fg); font-size: 13px; }
|
|
939
|
+
@keyframes pmfa-calls-rise { from { opacity: 0; transform: translateY(8px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
|
940
|
+
@media (prefers-reduced-motion: reduce) {
|
|
941
|
+
.pmfa-calls *, .pmfa-calls *::before, .pmfa-calls *::after { animation: none !important; transition: none !important; }
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
`;
|