feat(audio): TTS pausiert bei Anruf + Conversation-Focus haelt Spotify durchgehend gepaust

Bug 1a — Anruf-Pause:
Neues PhoneCallModule.kt nutzt TelephonyCallback (API 31+) bzw.
PhoneStateListener (Pre-12) um auf RINGING/OFFHOOK/IDLE zu reagieren.
Bei Klingeln/Gespraech ruft phoneCall.ts → audioService.haltAllPlayback,
ARIA verstummt sofort. READ_PHONE_STATE Permission wird beim ersten
Start angefragt; ohne Permission failt der Listener leise.

Bug 1b — Spotify-Resume:
AudioFocus wird jetzt an den Conversation-Lifecycle gekoppelt statt an
einzelne Streams. Solange wakeWordState 'conversing' ist, blockt
acquireConversationFocus() jeden per-Stream-Release. Erst beim Wechsel
auf 'armed'/'off' darf der Focus tatsaechlich freigegeben werden.
Verhindert das "Spotify kommt nach 10s wieder hoch"-Phaenomen auch
ueber Render-Pausen + zwischen mehreren ARIA-Antworten hinweg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 21:44:58 +02:00
parent 20123de827
commit fec8aa977b
7 changed files with 308 additions and 1 deletions
+108
View File
@@ -0,0 +1,108 @@
/**
* PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
*
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen —
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
* (Audio kommt nicht zurueck), der User muesste die Antwort manuell
* nochmal anfordern (Play-Button auf der Nachricht).
*
* Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden —
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
*/
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
interface PhoneCallNative {
start(): Promise<boolean>;
stop(): Promise<boolean>;
}
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
async start(): Promise<boolean> {
if (this.started || !PhoneCall) return false;
if (Platform.OS !== 'android') return false;
// Runtime-Permission holen (nur einmal noetig)
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
{
title: 'ARIA Cockpit — Anruf-Erkennung',
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
return false;
}
} catch (err) {
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
}
try {
const ok = await PhoneCall.start();
if (!ok) {
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
return false;
}
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
this._onStateChanged(e.state);
});
this.started = true;
console.log('[PhoneCall] Listener aktiv');
return true;
} catch (err: any) {
console.warn('[PhoneCall] start gescheitert:', err?.message || err);
return false;
}
}
async stop(): Promise<void> {
if (!this.started || !PhoneCall) return;
try {
this.subscription?.remove();
this.subscription = null;
await PhoneCall.stop();
} catch {}
this.started = false;
this.lastState = 'idle';
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
console.log('[PhoneCall] State: %s → %s', this.lastState, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
audioService.haltAllPlayback(`Telefon-State: ${state}`);
ToastAndroid.show(
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
ToastAndroid.SHORT,
);
}
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;