vojo/src/app/hooks/useTelecomConnectionSync.ts

94 lines
3.9 KiB
TypeScript

// Drive a self-managed Telecom call session alongside the active DM call
// (docs/plans/telecom_migration.md, Phase B). Mirror of
// useAndroidCallForegroundSync: keyed to the widget's JoinCall signal (via
// useCallJoined), not the mere presence of callEmbedAtom.
//
// What this gives us once TELECOM_ENABLED is flipped on:
// - the Vojo call is registered with the OS as a real call → cellular-call
// interop (an incoming GSM call holds/arbitrates against it), audio-focus
// arbitration, and a system "ongoing call" presence;
// - a remote/system Telecom disconnect (the user takes a GSM call, or a
// Bluetooth/Android-Auto "end call") tears the Vojo call down too;
// - Telecom owns the audio route, so its active endpoint is mirrored into the
// speaker-toggle atom (earpiece / speaker / Bluetooth all reflected).
//
// §4 (validated on Samsung One UI: the call gets system call-audio focus and
// the route endpoint flow emits cleanly): Telecom owns routing. The speaker
// toggle now drives Telecom endpoints (see useCallSpeaker) and AudioRoutePlugin
// is kept off the audio session while a Telecom call is live.
//
// Android-only, and additionally gated on TELECOM_ENABLED + API>=26 inside
// telecomCall — on web / iOS / old Android every call here is a no-op.
import { useEffect } from 'react';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { callEmbedAtom, callSpeakerAtom } from '../state/callEmbed';
import { telecomCall, type TelecomRoute } from '../plugins/call/telecomCall';
import { useCallJoined } from './useCallEmbed';
export const useTelecomConnectionSync = (): void => {
const callEmbed = useAtomValue(callEmbedAtom);
const joined = useCallJoined(callEmbed);
const store = useStore();
const setSpeaker = useSetAtom(callSpeakerAtom);
useEffect(() => {
if (!telecomCall.enabled()) return undefined;
if (!callEmbed || !joined) return undefined;
let cancelled = false;
const removers: Array<() => void> = [];
const track = (p: ReturnType<typeof telecomCall.addListener>) => {
p.then((handle) => {
if (cancelled) handle.remove();
else removers.push(() => handle.remove());
}).catch(() => {
/* listener registration best-effort */
});
};
telecomCall
.startCall({
roomId: callEmbed.roomId,
// For a DM the room name resolves to the peer's display name; good
// enough for the system call label. Refined per-direction later.
displayName: callEmbed.room.name || 'Vojo',
video: !callEmbed.voiceOnly,
incoming: false,
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[telecom] startCall failed', err);
});
// A Telecom-side disconnect we did NOT initiate (interrupting cellular
// call, BT/Auto "end", system) ends the Vojo call too. hangup() lets
// Element Call tear LiveKit down; the resulting Hangup event clears the
// atom, which unmounts this effect and fires endCall() below. Idempotent:
// our own hangup re-entering here is a no-op via the current-embed guard.
track(
telecomCall.addListener('telecomDisconnect', () => {
if (store.get(callEmbedAtom) !== callEmbed) return;
callEmbed.hangup().catch(() => {
/* widget already gone */
});
})
);
// Telecom owns the route; reflect the active endpoint in the speaker-toggle
// atom so the UI tracks reality (speaker vs earpiece/Bluetooth/wired).
track(
telecomCall.addListener('telecomEndpoint', (data: { route: TelecomRoute }) => {
setSpeaker(data.route === 'SPEAKER');
})
);
return () => {
cancelled = true;
removers.forEach((remove) => remove());
telecomCall.endCall().catch(() => {
/* best-effort teardown */
});
};
}, [callEmbed, joined, store, setSpeaker]);
};