Native AudioFocus: dispatchMediaPlay() (echter KEYCODE_MEDIA_PLAY an die aktive MediaSession, wie die Kopfhoerer-Play-Taste) + isMusicActive() zum Gaten. audio.ts merkt vor dem Focus-Grab ob Musik lief und resumt am Dialog-Ende nur dann — deterministisch statt des auf OnePlus flakigen nudgeMediaResume. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
315 lines
13 KiB
Kotlin
315 lines
13 KiB
Kotlin
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.os.SystemClock
|
|
import android.util.Log
|
|
import android.view.KeyEvent
|
|
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)
|
|
}
|
|
|
|
/** Sanfter Spotify-Resume-Nudge: kurz USAGE_MEDIA mit TRANSIENT
|
|
* requesten und sofort abandonen. Spotify bekommt das als
|
|
* Focus-Frei-Signal und resumed automatisch — aber weil TRANSIENT
|
|
* (nicht GAIN permanent), interpretiert Spotify das NICHT als
|
|
* "user stopped" was Auto-Resume verhindert haette.
|
|
*
|
|
* Hintergrund: ARIA spricht TTS via USAGE_ASSISTANT GAIN_TRANSIENT,
|
|
* Spotify pausiert. ARIA released. Spotify SOLLTE nach
|
|
* TRANSIENT-Loss + Abandon automatisch resumen, tut es aber bei
|
|
* manchen Versionen / Geraeten nicht zuverlaessig. Dieser Nudge
|
|
* triggert den Focus-Stack-Refresh ohne den Spotify-Auto-Stop-Bug
|
|
* der alten kickReleaseMedia mit GAIN permanent.
|
|
*/
|
|
@ReactMethod
|
|
fun nudgeMediaResume(promise: Promise) {
|
|
val am = audioManager()
|
|
if (am == null) {
|
|
promise.resolve(false)
|
|
return
|
|
}
|
|
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 nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
|
val nudgeReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
.setAudioAttributes(attrs)
|
|
.setOnAudioFocusChangeListener(nudgeListener)
|
|
.build()
|
|
am.requestAudioFocus(nudgeReq)
|
|
Thread.sleep(100)
|
|
am.abandonAudioFocusRequest(nudgeReq)
|
|
} else {
|
|
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
|
@Suppress("DEPRECATION")
|
|
am.requestAudioFocus(nudgeListener, AudioManager.STREAM_MUSIC,
|
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
Thread.sleep(100)
|
|
@Suppress("DEPRECATION")
|
|
am.abandonAudioFocus(nudgeListener)
|
|
}
|
|
Log.i(TAG, "nudgeMediaResume: USAGE_MEDIA TRANSIENT request+abandon (Spotify-Resume-Trigger)")
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "nudgeMediaResume failed: ${e.message}")
|
|
}
|
|
}.start()
|
|
promise.resolve(true)
|
|
}
|
|
|
|
/** Zuverlaessiger Spotify-Resume: einen echten MEDIA_PLAY-Tastendruck an die
|
|
* aktive MediaSession schicken — exakt das Signal der Play-Taste am
|
|
* Bluetooth-Kopfhoerer. Anders als nudgeMediaResume (Focus-Stack-Trick,
|
|
* auf manchen OEMs/Spotify-Versionen unzuverlaessig) spricht das Spotifys
|
|
* MediaSession DIREKT an und startet die Wiedergabe deterministisch wieder.
|
|
*
|
|
* Wir senden bewusst KEYCODE_MEDIA_PLAY (nicht PLAY_PAUSE) — das kann nur
|
|
* starten, nie pausieren. Aufrufer muss also selbst gaten (nur senden wenn
|
|
* vor dem Gespraech wirklich Musik lief, siehe isMusicActive()).
|
|
*/
|
|
@ReactMethod
|
|
fun dispatchMediaPlay(promise: Promise) {
|
|
val am = audioManager()
|
|
if (am == null) {
|
|
promise.resolve(false)
|
|
return
|
|
}
|
|
try {
|
|
val now = SystemClock.uptimeMillis()
|
|
val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
|
|
val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
|
|
am.dispatchMediaKeyEvent(down)
|
|
am.dispatchMediaKeyEvent(up)
|
|
Log.i(TAG, "dispatchMediaPlay: KEYCODE_MEDIA_PLAY an aktive MediaSession gesendet")
|
|
promise.resolve(true)
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "dispatchMediaPlay failed: ${e.message}")
|
|
promise.resolve(false)
|
|
}
|
|
}
|
|
|
|
/** Ob gerade Musik/Media aktiv abgespielt wird (AudioManager.isMusicActive).
|
|
* Der Aufrufer merkt sich das VOR dem Focus-Grab, um am Dialog-Ende zu
|
|
* entscheiden ob ein dispatchMediaPlay-Resume ueberhaupt gewuenscht ist. */
|
|
@ReactMethod
|
|
fun isMusicActive(promise: Promise) {
|
|
val am = audioManager()
|
|
if (am == null) {
|
|
promise.resolve(false)
|
|
return
|
|
}
|
|
promise.resolve(am.isMusicActive)
|
|
}
|
|
|
|
/** 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.
|
|
*
|
|
* ⚠️ ACHTUNG: nutzt AUDIOFOCUS_GAIN (permanent), Spotify kann das als
|
|
* "user-action stopp" interpretieren und Auto-Resume verhindern.
|
|
* Fuer Spotify-Resume nach TTS lieber nudgeMediaResume() nehmen (sanfter).
|
|
*/
|
|
@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) {}
|
|
}
|