- AppDelegate: NSLog for didUpdate token, didInvalidate, didReceiveIncomingPush - backend/push: log [push-token] register, [call-ring] receiver token-counts + expo-push-fanout for android-fallback - app/call.tsx: 250ms grace window before closeScreen on initial idle (fixes 'foreground call flashes briefly then disappears' race when dm.tsx startCall set() hasn't propagated through useCallStore selector yet)
527 lines
21 KiB
TypeScript
527 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 } = get();
|
|
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();
|
|
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 });
|
|
},
|
|
};
|
|
});
|