+ );
+}
+
+// ── CallControls ──────────────────────────────────────────────────────
+
+export interface CallControlsProps extends ControllerProps {
+ readonly className?: string;
+ /** Force-hide the camera control even when the line could carry video. */
+ readonly disableVideo?: boolean;
+}
+
+/**
+ * The in-call dock, after the official window: the camera split group
+ * ([toggle|⌄] — the dropdown picks the camera) and the mic split group
+ * ([toggle|⌄] — the dropdown picks microphone + speaker) on the left, the
+ * red hang-up pill on the right. The camera group renders whenever the line
+ * can carry video (unless `disableVideo`) and is disabled until the call
+ * connects. Video is per direction: the camera controls only the outgoing
+ * stream.
+ */
+export function CallControls({
+ controller,
+ createController,
+ className,
+ disableVideo,
+}: CallControlsProps) {
+ const resolved = useResolvedController(controller, createController);
+ const snapshot = useController(resolved);
+ const root = useCallsRoot();
+ const { locale } = root;
+ const { open, toggle } = useDevicePanels(resolved, snapshot);
+ if (!ACTIVE_STATUSES.has(snapshot.status)) return null;
+
+ const live = snapshot.status === "connected";
+ const showCamera = snapshot.capabilities.video && disableVideo !== true;
+ // The affordance stays visible on an audio call (the settled design) and is
+ // disabled until it can act: connected, and either already a video call or
+ // an upgrade the media session can actually perform. A session that cannot
+ // renegotiate therefore shows the same disabled button as a call that has
+ // not connected yet, instead of a live one that does nothing.
+ const cameraReady = live && (snapshot.video || resolved.canEnableVideo);
+ const cameraOff = !snapshot.video || snapshot.videoMuted;
+ const onCamera = () => {
+ // On a video call the button mutes/unmutes the outgoing track; on an
+ // audio call it upgrades to video (camera + re-offer on the same
+ // connection) through the controller.
+ if (snapshot.video)
+ ignoreDisposed(() => resolved.setMuted({ video: !snapshot.videoMuted }));
+ // A denied camera, a failed re-offer, or a call that ended mid-upgrade all
+ // reject here. The upgrade rolls itself back, so the audio call carries on
+ // and the button stays live for another try; swallowing the rejection just
+ // keeps a declined camera from surfacing as an unhandled error.
+ else void resolved.enableVideo().catch(() => undefined);
+ };
+
+ return (
+
+
+ {open === "mic" && (
+
+
+
+ )}
+ {open === "cam" && (
+
+
+
+ )}
+
+ {showCamera && (
+
+
+
+
+ )}
+ {snapshot.capabilities.mute && (
+
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+// ── DialPad ───────────────────────────────────────────────────────────
+
+const KEYS = [
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "+",
+ "0",
+ "#",
+] as const;
+
+export interface DialPadProps extends ControllerProps {
+ /** Prefill the number field. */
+ readonly defaultValue?: string;
+ /** Calling line the call is placed over. Defaults to `linkedDevice`. */
+ readonly line?: CallLine;
+ /** Called once the call is actually placed, not when a placement fails. */
+ readonly onPlaced?: (to: string) => void;
+ readonly className?: string;
+}
+
+/**
+ * A dial pad card: E.164 entry plus place-call buttons — audio always, and a
+ * video-call button when the line can carry video (linked device; the
+ * Business Calling API line is audio-only, so the button never renders there).
+ */
+export function DialPad({
+ controller,
+ createController,
+ defaultValue = "",
+ line = "linkedDevice",
+ onPlaced,
+ className,
+}: DialPadProps) {
+ const resolved = useResolvedController(controller, createController);
+ const snapshot = useController(resolved);
+ const root = useCallsRoot();
+ const { locale } = root;
+ const [value, setValue] = useState(defaultValue);
+ const busy =
+ snapshot.status === "incoming" || ACTIVE_STATUSES.has(snapshot.status);
+ const canVideo = capabilitiesFor(line).video;
+ const dial = (video: boolean) => {
+ const to = value.trim();
+ // The buttons are disabled while a call is up, but the input keeps focus
+ // and its value, so Enter would otherwise place a second call — and
+ // `place()` aborts the current operation, tearing down the live call.
+ if (busy || to.length === 0) return;
+ // `place()` resolves even when the placement failed — the controller
+ // reports that through the snapshot — so the snapshot is what says
+ // whether there is now a call to announce.
+ void resolved
+ .place(to, { video, line })
+ .then(() => {
+ if (ACTIVE_STATUSES.has(resolved.getSnapshot().status)) onPlaced?.(to);
+ })
+ // A placement failure lands in the snapshot, not here, but `place()`
+ // does reject on a disposed controller — which a mounted pad holding a
+ // shared one can still be dialled against.
+ .catch(() => undefined);
+ };
+
+ return (
+
+ );
+}
+
+// ── Pop-out call window ───────────────────────────────────────────────
+
+export interface CallPopoutHandle {
+ /** True while the separate call window is open. */
+ readonly popped: boolean;
+ /** Open the call window (no-op if already open or the popup was blocked). */
+ readonly open: () => void;
+ /** Close the call window (popping the call back inline). */
+ readonly close: () => void;
+ /** Portal target inside the call window; null while not popped. */
+ readonly container: HTMLElement | null;
+}
+
+/**
+ * Owns a separate, freely resizable call window (like the desktop client's)
+ * and hands back a container to portal the stage + controls into. Media
+ * streams keep playing across same-origin windows, so portal'd `