Root cause: iOS CallKit auto-dismisses incoming-call UI after ~5s when the
app is in foreground (because AppDelegate.didReceiveIncomingPush MUST call
reportNewIncomingCall — Apple requirement). That CallKit dismiss fires an
endCall event which our useCallKeepEvents.onEnd translated to declineCall,
unmounting the in-app /call screen before the user could tap accept/decline.
Fixes:
- useCallKeepEvents.onEnd: ignore CallKit endCall when iOS app is foreground
AND status==='incoming' (in-app UI is authoritative there). Comment with
big warning not to remove this again.
- call.tsx closeScreen: replace('/') instead of router.back() to avoid
GO_BACK action errors when navigation stack is inconsistent after long
calls (manifested as wrap-jsx.js crash in react-native-css-interop).
- useIncomingCalls: log CANCEL receive events for future diagnostics.
- call.ts: clog hangup/declineCall/closeScreen with reason+status for trace.
Verified: foreground call screen stays up the full UNANSWERED_MS (35s) and
caller-side hangup('unanswered') correctly triggers iPhone closeScreen via
cancel-broadcast.
529 lines
21 KiB
TypeScript
529 lines
21 KiB
TypeScript
import { create } from 'zustand';
|
|
import { AppState, NativeModules, Platform } from 'react-native';
|
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
|
import { supabase } from '../lib/supabase';
|
|
import { apiFetch } from '../lib/api';
|
|
import { startRingback, stopRingback } from '../lib/ringback';
|
|
import * as callkit from '../lib/callkit';
|
|
|
|
// ─── Voice-Call-Engine (1:1, Audio-only, foreground-only / Phase 1) ──────────
|
|
//
|
|
// Signaling läuft über Supabase Realtime Broadcast — KEIN eigener Signaling-
|
|
// Server nötig:
|
|
// • Ring-Channel `call-ring:<userId>` → nur die initiale Einladung/Abbruch.
|
|
// • Call-Channel `call:<callId>` → ready/offer/answer/ice/decline/hangup.
|
|
//
|
|
// Handshake-Reihenfolge (verhindert "offer kommt bevor Callee subscribed"-Race):
|
|
// Caller: ring → join call-channel → WARTET auf `ready` → erst dann offer.
|
|
// Callee: accept → join call-channel → `ready` → wartet auf offer → answer.
|
|
//
|
|
// react-native-webrtc ist ein natives Modul → LAZY require + Guard. In einem
|
|
// Build ohne das Modul (z.B. der aktuelle Dev-Build) ist isWebRTCAvailable()
|
|
// false und der Call-Button meldet "Rebuild nötig" statt zu crashen.
|
|
|
|
export function isWebRTCAvailable(): boolean {
|
|
return !!(NativeModules as any).WebRTCModule;
|
|
}
|
|
|
|
export type CallStatus =
|
|
| 'idle'
|
|
| 'outgoing' // wir rufen an, warten auf Annahme
|
|
| 'incoming' // es klingelt bei uns
|
|
| 'connecting' // angenommen, ICE/DTLS baut auf
|
|
| 'connected' // Audio läuft
|
|
| 'ended';
|
|
|
|
export type CallPeer = { id: string; nickname: string; avatar: string | null };
|
|
export type CallEndReason = 'declined' | 'ended' | 'failed' | 'unanswered' | 'busy' | null;
|
|
|
|
const UNANSWERED_MS = 35_000;
|
|
// Callee-seitiges Auto-Cancel: Wenn ein eingehender Anruf in dieser Zeit
|
|
// weder angenommen noch durch Caller-cancel beendet wird (z.B. weil die App
|
|
// im Background war, der Caller schon aufgelegt hat und das ring-cancel-
|
|
// broadcast wegen fehlender Realtime-Subscription nicht ankam), räumen wir
|
|
// den stale 'incoming'-State auf — sonst sieht der User beim nächsten App-
|
|
// Open einen "Geist-Anruf".
|
|
const INCOMING_TIMEOUT_MS = 45_000;
|
|
|
|
// Nicht-reaktive Handles (gehören nicht in den Zustand-State).
|
|
let pc: any = null;
|
|
let localStream: any = null;
|
|
let callChan: RealtimeChannel | null = null;
|
|
let pendingRemoteIce: any[] = [];
|
|
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let incomingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let selfMe: CallPeer | null = null; // für Caller-Side DM-Logging
|
|
let currentRole: 'caller' | 'callee' | null = null;
|
|
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
|
|
|
|
type CallState = {
|
|
status: CallStatus;
|
|
peer: CallPeer | null;
|
|
callId: string | null;
|
|
muted: boolean;
|
|
speaker: boolean;
|
|
startedAt: number | null;
|
|
endReason: CallEndReason;
|
|
|
|
startCall: (peer: CallPeer, me: CallPeer) => Promise<void>;
|
|
receiveIncoming: (callId: string, from: CallPeer) => void;
|
|
acceptCall: () => Promise<void>;
|
|
declineCall: () => void;
|
|
hangup: (reason?: CallEndReason) => void;
|
|
toggleMute: () => void;
|
|
toggleSpeaker: () => void;
|
|
_clear: () => void;
|
|
};
|
|
|
|
function rtc() {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
return require('react-native-webrtc');
|
|
}
|
|
|
|
// Audio-Routing (Earpiece vs. Lautsprecher) über AVAudioSession (iOS) /
|
|
// AudioManager (Android). react-native-webrtc exposed das nicht direkt.
|
|
function inCall() {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
return require('react-native-incall-manager').default;
|
|
}
|
|
|
|
// Diagnose-Logging (taucht in Metro-Konsole + adb logcat ReactNativeJS auf).
|
|
function clog(...args: any[]) {
|
|
console.log('[CALL]', ...args);
|
|
}
|
|
|
|
// Fire-and-forget: einem noch klingelnden Callee (noch nicht im Call-Channel)
|
|
// den Abbruch auf seinem Ring-Channel signalisieren.
|
|
function fireRingCancel(peerId: string, callId: string) {
|
|
const chan = supabase.channel(`call-ring:${peerId}`);
|
|
chan.subscribe((s: string) => {
|
|
if (s === 'SUBSCRIBED') {
|
|
chan.send({ type: 'broadcast', event: 'cancel', payload: { callId } });
|
|
setTimeout(() => supabase.removeChannel(chan), 300);
|
|
}
|
|
});
|
|
}
|
|
|
|
function teardown() {
|
|
if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; }
|
|
if (incomingTimer) { clearTimeout(incomingTimer); incomingTimer = null; }
|
|
try { pc?.close?.(); } catch {}
|
|
try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {}
|
|
if (callChan) { supabase.removeChannel(callChan); callChan = null; }
|
|
// Audio-Session beenden → normales Audio-Routing wiederherstellen.
|
|
// stopRingtone/stopRingback sind no-ops wenn nichts läuft — safe.
|
|
try { inCall().stopRingtone(); } catch {}
|
|
void stopRingback();
|
|
try { inCall().stop(); } catch {}
|
|
pc = null;
|
|
localStream = null;
|
|
pendingRemoteIce = [];
|
|
}
|
|
|
|
// DM-Log nach Call-Ende. Nur der CALLER schreibt (verhindert Duplikate).
|
|
// Format: attachmentType='call', attachmentName='<kind>:<state>:<durSec>'.
|
|
async function logCallToChat(
|
|
peerId: string,
|
|
callId: string,
|
|
state: 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy',
|
|
startedAt: number | null,
|
|
) {
|
|
// Caller loggt jeden Call. Callee loggt NUR missed/declined/unanswered
|
|
// wenn der Caller dazu nicht kommt (z.B. Callee drückt 'decline' bevor der
|
|
// Caller-DM-Call durchgeht). Verbundene Calls werden vom Caller geloggt.
|
|
if (currentRole === 'callee' && state === 'ended') return;
|
|
if (loggedCallId === callId) return;
|
|
loggedCallId = callId;
|
|
const durSec = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
|
|
const meta = `audio:${state}:${durSec}`;
|
|
try {
|
|
await apiFetch('/api/chat/dm', {
|
|
method: 'POST',
|
|
body: {
|
|
receiverId: peerId,
|
|
content: '',
|
|
attachmentType: 'call',
|
|
attachmentName: meta,
|
|
},
|
|
});
|
|
clog('logged call DM →', peerId, meta);
|
|
} catch (e: any) {
|
|
clog('logCallToChat FAILED', e?.message ?? e);
|
|
}
|
|
}
|
|
|
|
export const useCallStore = create<CallState>((set, get) => {
|
|
// ─── WebRTC-Setup gemeinsam für beide Seiten ──────────────────────────────
|
|
async function buildPeer() {
|
|
const { RTCPeerConnection } = rtc();
|
|
clog('buildPeer: fetching ice-servers…');
|
|
let ice: { iceServers: any[]; iceTransportPolicy?: string };
|
|
try {
|
|
ice = await apiFetch('/api/calls/ice-servers');
|
|
} catch (e: any) {
|
|
clog('buildPeer: ICE-FETCH FAILED', e?.message ?? e, e?.statusCode ?? '');
|
|
throw e;
|
|
}
|
|
clog('buildPeer: ice ok', JSON.stringify({
|
|
servers: ice.iceServers?.length,
|
|
policy: ice.iceTransportPolicy,
|
|
urls: ice.iceServers?.[0]?.urls,
|
|
}));
|
|
pc = new RTCPeerConnection({
|
|
iceServers: ice.iceServers,
|
|
iceTransportPolicy: (ice.iceTransportPolicy as any) ?? 'relay',
|
|
});
|
|
|
|
pc.addEventListener('icecandidate', (e: any) => {
|
|
if (e.candidate && callChan) {
|
|
callChan.send({
|
|
type: 'broadcast',
|
|
event: 'ice',
|
|
payload: {
|
|
candidate: e.candidate.candidate,
|
|
sdpMid: e.candidate.sdpMid,
|
|
sdpMLineIndex: e.candidate.sdpMLineIndex,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
pc.addEventListener('icecandidateerror', (e: any) => {
|
|
clog('ICE-CANDIDATE-ERROR', e?.errorCode, e?.errorText, e?.url);
|
|
});
|
|
pc.addEventListener('iceconnectionstatechange', () => {
|
|
clog('iceConnectionState =', pc?.iceConnectionState);
|
|
});
|
|
pc.addEventListener('icegatheringstatechange', () => {
|
|
clog('iceGatheringState =', pc?.iceGatheringState);
|
|
});
|
|
|
|
pc.addEventListener('connectionstatechange', () => {
|
|
const st = pc?.connectionState;
|
|
clog('connectionState =', st);
|
|
if (st === 'connected') {
|
|
if (get().status !== 'connected') {
|
|
set({ status: 'connected', startedAt: Date.now() });
|
|
}
|
|
// Ringback aus — Verbindung steht.
|
|
void stopRingback();
|
|
// CallKit/ConnectionService: Call als aktiv markieren (Android needs this
|
|
// für Mute/Speaker-Controls).
|
|
try {
|
|
const cid = get().callId;
|
|
if (cid) callkit.reportConnected(cid);
|
|
} catch {}
|
|
// WebRTC hat seine eigene AVAudioSession aktiviert und unser früherer
|
|
// InCallManager-Call ist evtl. überschrieben worden — Speaker-Route
|
|
// jetzt erneut setzen, damit der User-Toggle wirklich greift.
|
|
try {
|
|
inCall().setForceSpeakerphoneOn(get().speaker);
|
|
clog('post-connect: speaker route applied =', get().speaker);
|
|
} catch (e: any) {
|
|
clog('post-connect setForceSpeakerphoneOn failed:', e?.message ?? e);
|
|
}
|
|
} else if (st === 'failed') {
|
|
get().hangup('failed');
|
|
}
|
|
});
|
|
|
|
// Mikrofon holen + Track anhängen.
|
|
clog('buildPeer: getUserMedia(audio)…');
|
|
const { mediaDevices } = rtc();
|
|
try {
|
|
localStream = await mediaDevices.getUserMedia({ audio: true, video: false });
|
|
} catch (e: any) {
|
|
clog('buildPeer: getUserMedia FAILED', e?.message ?? e);
|
|
throw e;
|
|
}
|
|
localStream.getTracks().forEach((t: any) => pc.addTrack(t, localStream));
|
|
// Audio-Session in den "In-Call"-Mode bringen (richtet Routing, Bluetooth,
|
|
// Audio-Focus etc. ein). Default = Earpiece, NICHT Speaker.
|
|
try {
|
|
inCall().start({ media: 'audio', auto: false });
|
|
inCall().setForceSpeakerphoneOn(get().speaker);
|
|
} catch (e: any) {
|
|
clog('InCallManager start failed:', e?.message ?? e);
|
|
}
|
|
clog('buildPeer: done (track added)');
|
|
}
|
|
|
|
function addRemoteIce(payload: any) {
|
|
const { RTCIceCandidate } = rtc();
|
|
const cand = new RTCIceCandidate(payload);
|
|
if (pc?.remoteDescription) {
|
|
pc.addIceCandidate(cand).catch(() => {});
|
|
} else {
|
|
pendingRemoteIce.push(payload);
|
|
}
|
|
}
|
|
|
|
function drainIce() {
|
|
const { RTCIceCandidate } = rtc();
|
|
pendingRemoteIce.forEach((p) => pc?.addIceCandidate(new RTCIceCandidate(p)).catch(() => {}));
|
|
pendingRemoteIce = [];
|
|
}
|
|
|
|
// Per-Call-Channel beitreten + Events verdrahten. role steuert offer/answer.
|
|
function joinCallChannel(callId: string, role: 'caller' | 'callee') {
|
|
const chan = supabase.channel(`call:${callId}`, {
|
|
config: { broadcast: { self: false } },
|
|
});
|
|
|
|
chan.on('broadcast', { event: 'ready' }, async () => {
|
|
clog('recv ready (role=' + role + ', pc=' + !!pc + ')');
|
|
if (role !== 'caller' || !pc) return;
|
|
const offer = await pc.createOffer({});
|
|
await pc.setLocalDescription(offer);
|
|
clog('caller: sending offer');
|
|
chan.send({ type: 'broadcast', event: 'offer', payload: { type: offer.type, sdp: offer.sdp } });
|
|
});
|
|
|
|
chan.on('broadcast', { event: 'offer' }, async (msg: any) => {
|
|
clog('recv offer (role=' + role + ', pc=' + !!pc + ')');
|
|
if (role !== 'callee' || !pc) return;
|
|
const { RTCSessionDescription } = rtc();
|
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload));
|
|
drainIce();
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
clog('callee: sending answer');
|
|
chan.send({ type: 'broadcast', event: 'answer', payload: { type: answer.type, sdp: answer.sdp } });
|
|
});
|
|
|
|
chan.on('broadcast', { event: 'answer' }, async (msg: any) => {
|
|
clog('recv answer (role=' + role + ')');
|
|
if (role !== 'caller' || !pc) return;
|
|
const { RTCSessionDescription } = rtc();
|
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload));
|
|
drainIce();
|
|
});
|
|
|
|
chan.on('broadcast', { event: 'ice' }, (msg: any) => addRemoteIce(msg.payload));
|
|
chan.on('broadcast', { event: 'decline' }, () => { clog('recv decline'); get().hangup('declined'); });
|
|
chan.on('broadcast', { event: 'hangup' }, () => { clog('recv hangup'); get().hangup('ended'); });
|
|
|
|
callChan = chan;
|
|
return chan;
|
|
}
|
|
|
|
return {
|
|
status: 'idle',
|
|
peer: null,
|
|
callId: null,
|
|
muted: false,
|
|
speaker: false,
|
|
startedAt: null,
|
|
endReason: null,
|
|
|
|
// ─── Caller ──────────────────────────────────────────────────────────
|
|
startCall: async (peer, me) => {
|
|
if (get().status !== 'idle') return;
|
|
if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable');
|
|
const callId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
clog('startCall → callee', peer.id, 'callId', callId);
|
|
selfMe = me;
|
|
currentRole = 'caller';
|
|
loggedCallId = null;
|
|
set({ status: 'outgoing', peer, callId, muted: false, speaker: false, startedAt: null, endReason: null });
|
|
|
|
// 1) Callee anklingeln (ephemerer Ring-Channel des Callees).
|
|
const ring = supabase.channel(`call-ring:${peer.id}`);
|
|
await new Promise<void>((res) => ring.subscribe((s: string) => s === 'SUBSCRIBED' && res()));
|
|
clog('ring channel subscribed → sending ring');
|
|
ring.send({
|
|
type: 'broadcast',
|
|
event: 'ring',
|
|
payload: { callId, from: { id: me.id, nickname: me.nickname, avatar: me.avatar } },
|
|
});
|
|
setTimeout(() => supabase.removeChannel(ring), 300);
|
|
|
|
// Background-Push an den Callee (deckt den Fall ab, dass die App nicht
|
|
// im Foreground ist — Realtime ist dann nicht subscribed). Fire-and-forget:
|
|
// ein fehlgeschlagener Push darf den Call nicht blocken.
|
|
apiFetch('/api/calls/ring', {
|
|
method: 'POST',
|
|
body: { peerId: peer.id, callId },
|
|
}).catch((e: any) => clog('ring-push failed', e?.message ?? e));
|
|
|
|
// CallKit: User-Seite in Recents/UI als ausgehender Call sichtbar machen.
|
|
try { callkit.startOutgoingCall(callId, peer.nickname || 'ReBreak'); } catch {}
|
|
|
|
// Ringback-Ton für den Anrufer — eigenes mp3-Asset (EU-Standard 425 Hz,
|
|
// 1s an / 4s aus). InCallManager.start() ohne `ringback`-Param läuft
|
|
// parallel für die AVAudioSession-Aktivierung, der eigentliche Ton kommt
|
|
// von expo-av.
|
|
try {
|
|
inCall().start({ media: 'audio', auto: false });
|
|
} catch (e: any) { clog('inCall start (caller) failed', e?.message ?? e); }
|
|
void startRingback();
|
|
|
|
// 2) Call-Channel join (Handler setzen) → PeerConnection bauen → ERST DANN
|
|
// subscriben. Sonst könnte ein schnelles `ready` den offer-Handler treffen,
|
|
// bevor pc existiert → kein offer → Deadlock.
|
|
const chan = joinCallChannel(callId, 'caller');
|
|
try {
|
|
await buildPeer();
|
|
} catch (e: any) {
|
|
clog('startCall: buildPeer FAILED →', e?.message ?? e);
|
|
get().hangup('failed');
|
|
throw e;
|
|
}
|
|
chan.subscribe((s: string) => clog('caller call-channel:', s));
|
|
|
|
// 3) Timeout: keine Annahme → auflegen (hangup signalisiert Ring-Cancel).
|
|
unansweredTimer = setTimeout(() => {
|
|
if (get().status === 'outgoing') get().hangup('unanswered');
|
|
}, UNANSWERED_MS);
|
|
},
|
|
|
|
// ─── Callee: Klingeln empfangen ───────────────────────────────────────
|
|
receiveIncoming: (callId, from) => {
|
|
// Schon im Gespräch / oder bereits am Klingeln mit derselben callId?
|
|
// → ignorieren (dedup: Realtime + VoIP-Push können beide feuern).
|
|
const cur = get();
|
|
if (cur.status !== 'idle') {
|
|
if (cur.status === 'incoming' && cur.callId === callId) {
|
|
clog('receiveIncoming dedup (already incoming for', callId, ')');
|
|
}
|
|
return;
|
|
}
|
|
clog('receiveIncoming from', from.id, 'callId', callId);
|
|
currentRole = 'callee';
|
|
loggedCallId = null;
|
|
set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null });
|
|
// CallKit-/ConnectionService-UI hochziehen — ABER nur wenn App NICHT im
|
|
// Vordergrund. Im Foreground kümmert sich der In-App /call-Screen darum,
|
|
// sonst gibt es Doppel-UI (System-Banner + Fullscreen).
|
|
//
|
|
// iOS-Hinweis: Bei VoIP-Push hat AppDelegate.swift bereits
|
|
// `RNCallKeep.reportNewIncomingCall` aufgerufen — wir dürfen das hier
|
|
// NICHT erneut tun (gleiche UUID → CallKit zeigt Banner doppelt /
|
|
// verhält sich kaputt). Auf iOS gibt es keinen sinnvollen JS-Pfad
|
|
// für Background-Ring, da Supabase-Realtime im Background nicht läuft;
|
|
// jeder bg-Ring kommt zwingend via PushKit → AppDelegate.
|
|
if (Platform.OS !== 'ios' && AppState.currentState !== 'active') {
|
|
try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
|
|
} else {
|
|
clog('receiveIncoming: skipping JS-side CallKit show (foreground OR iOS-AppDelegate already handled)');
|
|
}
|
|
// Auto-Cancel-Timer: Wenn nach 45s noch immer 'incoming' (kein Accept,
|
|
// kein Caller-Cancel angekommen), als 'unanswered' aufräumen damit nicht
|
|
// beim nächsten App-Open ein toter Call-Screen sichtbar wird.
|
|
if (incomingTimer) clearTimeout(incomingTimer);
|
|
incomingTimer = setTimeout(() => {
|
|
const st = get();
|
|
if (st.status === 'incoming' && st.callId === callId) {
|
|
clog('receiveIncoming: stale incoming timeout → hangup(unanswered)');
|
|
st.hangup('unanswered');
|
|
}
|
|
}, INCOMING_TIMEOUT_MS);
|
|
// CallKit (iOS) + ConnectionService (Android) spielen ihren eigenen
|
|
// Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes
|
|
// Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der
|
|
// CallKit-Pfad aktiviert die AVAudioSession selbst.
|
|
},
|
|
|
|
acceptCall: async () => {
|
|
const { callId, status } = get();
|
|
if (status !== 'incoming' || !callId) return;
|
|
if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable');
|
|
clog('acceptCall callId', callId);
|
|
// Klingelton stoppen — wir nehmen ab.
|
|
try { inCall().stopRingtone(); } catch {}
|
|
set({ status: 'connecting' });
|
|
const chan = joinCallChannel(callId, 'callee');
|
|
await new Promise<void>((res) => chan.subscribe((s: string) => s === 'SUBSCRIBED' && res()));
|
|
clog('callee call-channel subscribed');
|
|
try {
|
|
await buildPeer();
|
|
} catch (e: any) {
|
|
clog('acceptCall: buildPeer FAILED →', e?.message ?? e);
|
|
get().hangup('failed');
|
|
throw e;
|
|
}
|
|
clog('callee: sending ready');
|
|
chan.send({ type: 'broadcast', event: 'ready', payload: {} });
|
|
},
|
|
|
|
declineCall: () => {
|
|
const { callId, peer, startedAt, status } = get();
|
|
clog('declineCall called — status=', status, 'callId=', callId);
|
|
if (callId) {
|
|
// CallKit/ConnectionService aus dem Lockscreen-UI entfernen.
|
|
try { callkit.endCall(callId); } catch {}
|
|
// Kurz joinen um decline zu senden (Callee war noch nicht im Channel).
|
|
const chan = supabase.channel(`call:${callId}`);
|
|
chan.subscribe((s: string) => {
|
|
if (s === 'SUBSCRIBED') {
|
|
chan.send({ type: 'broadcast', event: 'decline', payload: {} });
|
|
setTimeout(() => supabase.removeChannel(chan), 300);
|
|
}
|
|
});
|
|
}
|
|
// Callee logged abgelehnten Call in eigenen Chat-Verlauf.
|
|
if (peer && callId) {
|
|
logCallToChat(peer.id, callId, 'declined', startedAt);
|
|
}
|
|
set({ status: 'ended', endReason: 'declined' });
|
|
teardown();
|
|
},
|
|
|
|
hangup: (reason = 'ended') => {
|
|
const { status, peer, callId, startedAt } = get();
|
|
clog('hangup called — reason=', reason, 'status=', status, 'callId=', callId);
|
|
if (status === 'idle' || status === 'ended') {
|
|
teardown();
|
|
return;
|
|
}
|
|
// CallKit/ConnectionService schließen.
|
|
if (callId) {
|
|
try {
|
|
callkit.endCall(callId);
|
|
callkit.reportEnded(callId, reason as any);
|
|
} catch {}
|
|
}
|
|
// Peer im Call-Channel informieren (falls schon verbunden).
|
|
callChan?.send({ type: 'broadcast', event: 'hangup', payload: {} });
|
|
// Klingelt der Callee noch (noch nicht im Call-Channel)? → Ring abbrechen.
|
|
if (peer && callId && (status === 'outgoing' || status === 'connecting')) {
|
|
fireRingCancel(peer.id, callId);
|
|
}
|
|
// Call als DM ins Chat-Log schreiben (nur Caller, fire-and-forget).
|
|
if (peer && callId && reason) {
|
|
const state = (reason === null ? 'ended' : reason) as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy';
|
|
logCallToChat(peer.id, callId, state, startedAt);
|
|
}
|
|
set({ status: 'ended', endReason: reason });
|
|
teardown();
|
|
},
|
|
|
|
toggleMute: () => {
|
|
const next = !get().muted;
|
|
try {
|
|
localStream?.getAudioTracks?.().forEach((t: any) => { t.enabled = !next; });
|
|
} catch {}
|
|
set({ muted: next });
|
|
},
|
|
|
|
toggleSpeaker: () => {
|
|
const next = !get().speaker;
|
|
try {
|
|
// Sicherheitshalber start() erneut aufrufen — falls die AVAudioSession
|
|
// von WebRTC zwischendurch deaktiviert wurde, sonst greift
|
|
// setForceSpeakerphoneOn auf iOS nicht.
|
|
inCall().start({ media: 'audio', auto: false });
|
|
inCall().setForceSpeakerphoneOn(next);
|
|
clog('toggleSpeaker →', next);
|
|
} catch (e: any) {
|
|
clog('setForceSpeakerphoneOn failed:', e?.message ?? e);
|
|
}
|
|
set({ speaker: next });
|
|
},
|
|
|
|
_clear: () => {
|
|
teardown();
|
|
set({ status: 'idle', peer: null, callId: null, muted: false, speaker: false, startedAt: null, endReason: null });
|
|
},
|
|
};
|
|
});
|