package com.ariacockpit import android.content.Context import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Build import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.modules.core.DeviceEventManagerModule /** * Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events * an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert * zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern * requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen. * * - requestDuck() → andere Apps werden leiser (ARIA spricht TTS) * - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme) * - release() → Focus abgeben, andere Apps duerfen wieder * * Events: * - "AudioFocusChanged" mit type: * "loss" — endgueltiger Verlust (Anruf, andere App permanent) * "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung) * "gain" — Fokus zurueck */ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { override fun getName() = "AudioFocus" companion object { private const val TAG = "AudioFocus" } private var currentRequest: AudioFocusRequest? = null private fun audioManager(): AudioManager? = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager private fun emitFocusChange(type: String) { try { val params = Arguments.createMap().apply { putString("type", type) } reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit("AudioFocusChanged", params) } catch (e: Exception) { Log.w(TAG, "emit failed: ${e.message}") } } private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)") emitFocusChange("loss") } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)") emitFocusChange("loss_transient") } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { // Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert") } AudioManager.AUDIOFOCUS_GAIN -> { Log.i(TAG, "AUDIOFOCUS_GAIN") emitFocusChange("gain") } } } private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) { val am = audioManager() if (am == null) { promise.reject("NO_AUDIO_MANAGER", "AudioManager nicht verfuegbar") return } release() val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val attrs = AudioAttributes.Builder() .setUsage(usage) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() val req = AudioFocusRequest.Builder(durationHint) .setAudioAttributes(attrs) .setOnAudioFocusChangeListener(focusListener) .build() currentRequest = req am.requestAudioFocus(req) } else { @Suppress("DEPRECATION") am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint) } promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) } /** Andere Apps werden pausiert (TTS spricht). * * TRANSIENT (statt TRANSIENT_MAY_DUCK): Spotify/YouTube pausieren komplett * statt nur leiser zu werden. Verhindert auch das "kommt-wieder-hoch"- * Problem mit MAY_DUCK, wo das System nach kurzer Zeit den Duck-Effekt * wieder aufgehoben hat obwohl wir den Fokus noch hielten. */ @ReactMethod fun requestDuck(promise: Promise) { requestFocus( AudioManager.AUDIOFOCUS_GAIN_TRANSIENT, AudioAttributes.USAGE_ASSISTANT, promise, ) } /** Andere Apps werden pausiert (Mikrofon-Aufnahme / Gespraech). */ @ReactMethod fun requestExclusive(promise: Promise) { requestFocus( AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, AudioAttributes.USAGE_VOICE_COMMUNICATION, promise, ) } /** Focus abgeben — andere Apps duerfen wieder volle Lautstaerke. */ @ReactMethod fun release(promise: Promise) { release() promise.resolve(true) } /** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube * resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus * nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA * GAIN beanspruchen — das System invalidiert dabei den haengenden Stack- * Eintrag des anderen Players — und sofort wieder abandonen. Spotify * bekommt den Focus-Gain und resumed. * * Workaround fuer das react-native-sound-Bug: Sound.stop()/release() * laesst den AudioFocusRequest haengen. */ @ReactMethod fun kickReleaseMedia(promise: Promise) { val am = audioManager() if (am == null) { promise.resolve(false) return } // Async laufen lassen — wir wollen einen request, Pause, dann abandon. // Ohne Pause merkt das System (und damit Spotify) die kurze Owner- // Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den // Focus-Stack-Refresh. Thread { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val attrs = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ } val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes(attrs) .setOnAudioFocusChangeListener(kickListener) .build() am.requestAudioFocus(kickReq) Thread.sleep(250) am.abandonAudioFocusRequest(kickReq) } else { val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ } @Suppress("DEPRECATION") am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) Thread.sleep(250) @Suppress("DEPRECATION") am.abandonAudioFocus(kickListener) } Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)") } catch (e: Exception) { Log.w(TAG, "kickReleaseMedia failed: ${e.message}") } }.start() promise.resolve(true) } private fun release() { val am = audioManager() ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { currentRequest?.let { am.abandonAudioFocusRequest(it) } } else { @Suppress("DEPRECATION") am.abandonAudioFocus(focusListener) } currentRequest = null } /** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4. * IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) — * kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */ @ReactMethod fun getMode(promise: Promise) { val am = audioManager() if (am == null) { promise.resolve(0) return } promise.resolve(am.mode) } @ReactMethod fun addListener(eventName: String) {} @ReactMethod fun removeListeners(count: Int) {} }