diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt index 791d5360f..319d715be 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt @@ -1,5 +1,7 @@ package com.swmansion.audioapi +import android.os.Build +import androidx.annotation.RequiresApi import com.facebook.jni.HybridData import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -78,6 +80,7 @@ class AudioAPIModule( override fun getDevicePreferredSampleRate(): Double = MediaSessionManager.getDevicePreferredSampleRate() + @RequiresApi(Build.VERSION_CODES.O) override fun observeAudioInterruptions(enabled: Boolean) { MediaSessionManager.observeAudioInterruptions(enabled) } @@ -95,4 +98,16 @@ class AudioAPIModule( val res = MediaSessionManager.checkRecordingPermissions() promise!!.resolve(res) } + + @RequiresApi(Build.VERSION_CODES.O) + override fun requestAudioFocus( + observeAudioInterruptions: Boolean, + options: ReadableMap?, + ) { + MediaSessionManager.requestAudioFocus(observeAudioInterruptions, options) + } + + override fun abandonAudioFocus() { + MediaSessionManager.abandonAudioFocus() + } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt index 3c1d2f468..62834981f 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt @@ -59,19 +59,17 @@ class AudioFocusListener( } } - fun requestAudioFocus() { + fun requestAudioFocus( + focusRequest: AudioFocusRequest.Builder, + observeAudioInterruptions: Boolean, + ): Int? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - this.focusRequest = - AudioFocusRequest - .Builder(AudioManager.AUDIOFOCUS_GAIN) - .setOnAudioFocusChangeListener(this) - .build() - - audioManager.get()?.requestAudioFocus(focusRequest!!) + if (observeAudioInterruptions) focusRequest.setOnAudioFocusChangeListener(this) + this.focusRequest = focusRequest.build() + audioManager.get()?.requestAudioFocus(this.focusRequest!!) } else { audioManager.get()?.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) } - } fun abandonAudioFocus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this.focusRequest != null) { diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt index 1fcde1ebc..9e98b264f 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt @@ -10,6 +10,8 @@ import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Build import android.os.IBinder @@ -137,9 +139,10 @@ object MediaSessionManager { return sampleRate.toDouble() } + @RequiresApi(Build.VERSION_CODES.O) fun observeAudioInterruptions(observe: Boolean) { if (observe) { - audioFocusListener.requestAudioFocus() + audioFocusListener.requestAudioFocus(AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN), true) } else { audioFocusListener.abandonAudioFocus() } @@ -159,10 +162,185 @@ object MediaSessionManager { } fun requestRecordingPermissions(currentActivity: Activity?): String { - ActivityCompat.requestPermissions(currentActivity!!, arrayOf(Manifest.permission.RECORD_AUDIO), 200) + ActivityCompat.requestPermissions( + currentActivity!!, + arrayOf(Manifest.permission.RECORD_AUDIO), + 200, + ) return checkRecordingPermissions() } + private fun parseAudioFocusOptionMap(request: ReadableMap?): Map { + val audioFocusOptions = HashMap() + if (request == null) { + return audioFocusOptions + } + if (request.hasKey("focusGain")) { + when (request.getString("focusGain")) { + "audiofocus_gain" -> audioFocusOptions["focusGain"] = AudioManager.AUDIOFOCUS_GAIN + "audiofocus_gain_transient" -> audioFocusOptions["focusGain"] = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + "audiofocus_gain_transient_exclusive" -> + audioFocusOptions["focusGain"] = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + "audiofocus_gain_transient_may_duck" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + } + } + if (request.hasKey("acceptsDelayedFocusGain")) { + when (request.getBoolean("acceptsDelayedFocusGain")) { + true -> audioFocusOptions["acceptsDelayedFocusGain"] = 1 + false -> audioFocusOptions["acceptsDelayedFocusGain"] = 0 + } + } + if (request.hasKey("pauseWhenDucked")) { + audioFocusOptions["pauseWhenDucked"] = if (request.getBoolean("pauseWhenDucked")) 1 else 0 + } + if (request.hasKey("audioAttributes")) { + val values: ReadableMap? = request.getMap("audioAttributes") + if (values?.hasKey("allowedCapturePolicy") == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + when (values.getString("allowedCapturePolicy")) { + "allow_capture_by_all" -> + audioFocusOptions["allowedCapturePolicy"] = + AudioAttributes.ALLOW_CAPTURE_BY_ALL + + "allow_capture_by_system" -> + audioFocusOptions["allowedCapturePolicy"] = + AudioAttributes.ALLOW_CAPTURE_BY_SYSTEM + + "allow_capture_by_none" -> + audioFocusOptions["allowedCapturePolicy"] = + AudioAttributes.ALLOW_CAPTURE_BY_NONE + } + } + if (values?.hasKey("contentType") == true) { + when (values.getString("contentType")) { + "content_type_movie" -> + audioFocusOptions["contentType"] = + AudioAttributes.CONTENT_TYPE_MOVIE + + "content_type_music" -> + audioFocusOptions["contentType"] = + AudioAttributes.CONTENT_TYPE_MOVIE + + "content_type_sonification" -> + audioFocusOptions["contentType"] = + AudioAttributes.CONTENT_TYPE_SONIFICATION + + "content_type_speech" -> + audioFocusOptions["contentType"] = + AudioAttributes.CONTENT_TYPE_SPEECH + + "content_type_unknown" -> + audioFocusOptions["contentType"] = + AudioAttributes.CONTENT_TYPE_UNKNOWN + } + } + if (values?.hasKey("flag") == true) { + when (values.getString("flag")) { + "flag_hw_av_sync" -> audioFocusOptions["flag"] = AudioAttributes.FLAG_HW_AV_SYNC + "flag_audibility_enforced" -> + audioFocusOptions["flag"] = + AudioAttributes.FLAG_AUDIBILITY_ENFORCED + } + } + if (values?.hasKey("hapticChannelsMuted") == true) { + audioFocusOptions["hapticChannelsMuted"] = if (values.getBoolean("hapticChannelsMuted")) 1 else 0 + } + if (values?.hasKey("isContentSpatialized") == true) { + audioFocusOptions["isContentSpatialized"] = if (values.getBoolean("isContentSpatialized")) 1 else 0 + } + if (values?.hasKey("spatializationBehavior") == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + when (values.getString("spatializationBehavior")) { + "spatialization_behavior_auto" -> + audioFocusOptions["spatializationBehavior"] = + AudioAttributes.SPATIALIZATION_BEHAVIOR_AUTO + + "spatialization_behavior_never" -> + audioFocusOptions["spatializationBehavior"] = + AudioAttributes.SPATIALIZATION_BEHAVIOR_NEVER + } + } + if (values?.hasKey("usage") == true) { + when (values.getString("usage")) { + "usage_alarm" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_ALARM + "usage_assistance_accessibility" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY + "usage_assistance_navigation_guidance" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE + "usage_assistance_sonification" -> + audioFocusOptions["usage"] = AudioAttributes.USAGE_ASSISTANCE_SONIFICATION + "usage_assistant" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) audioFocusOptions["usage"] = AudioAttributes.USAGE_ASSISTANT + } + "usage_game" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_GAME + "usage_media" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_MEDIA + "usage_notification" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_NOTIFICATION + "usage_notification_event" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_NOTIFICATION_EVENT + "usage_notification_ringtone" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_NOTIFICATION_RINGTONE + "usage_notification_communication_request" -> + audioFocusOptions["usage"] = + AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST + "usage_notification_communication_instant" -> + audioFocusOptions["usage"] = + AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT + "usage_notification_communication_delayed" -> + audioFocusOptions["usage"] = + AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_DELAYED + "usage_unknown" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_UNKNOWN + "usage_voice_communication" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_VOICE_COMMUNICATION + "usage_voice_communication_signalling" -> audioFocusOptions["usage"] = AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING + } + } + } + + return audioFocusOptions + } + + @RequiresApi(Build.VERSION_CODES.O) + fun requestAudioFocus( + observeAudioInterruptions: Boolean, + options: ReadableMap?, + ) { + val parsedRequest = parseAudioFocusOptionMap(options) + val afbd = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + val aabd = AudioAttributes.Builder() + var pauseWhenDucked = false + var acceptsDelayedFocusGain = false + if (parsedRequest.containsKey("pauseWhenDucked")) { + pauseWhenDucked = parsedRequest["pauseWhenDucked"] == 1 + afbd.setWillPauseWhenDucked(pauseWhenDucked) + } + parsedRequest["focusGain"]?.let { afbd.setFocusGain(it) } + if (parsedRequest.containsKey("acceptsDelayedFocusGain")) { + acceptsDelayedFocusGain = parsedRequest["acceptsDelayedFocusGain"] == 1 + afbd.setAcceptsDelayedFocusGain(acceptsDelayedFocusGain) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + parsedRequest["allowedCapturePolicy"]?.let { aabd.setAllowedCapturePolicy(it) } + parsedRequest["contentType"]?.let { aabd.setAllowedCapturePolicy(it) } + if (parsedRequest.containsKey("hapticChannelsMuted")) { + aabd.setHapticChannelsMuted(parsedRequest["hapticChannelsMuted"] == 1) + } + } + parsedRequest["flag"]?.let { aabd.setFlags(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + parsedRequest["spatializationBehavior"]?.let { aabd.setSpatializationBehavior(it) } + if (parsedRequest.containsKey("isContentSpatialized")) { + aabd.setIsContentSpatialized(parsedRequest["isContentSpatialized"] == 1) + } + } + parsedRequest["usage"]?.let { aabd.setUsage(it) } + afbd.setAudioAttributes(aabd.build()) + // according to docs: OnAudioFocusChangeListener is only required + // if you also specify willPauseWhenDucked(true) or setAcceptsDelayedFocusGain(true) in the request. + if ((pauseWhenDucked || acceptsDelayedFocusGain) && !observeAudioInterruptions) { + throw IllegalArgumentException( + "observeAudioInterruptions must be true when pauseWhenDucked or acceptsDelayedFocusGain is set to true", + ) + } + audioFocusListener.requestAudioFocus(afbd, observeAudioInterruptions) + } + + fun abandonAudioFocus() { + audioFocusListener.abandonAudioFocus() + } + fun checkRecordingPermissions(): String = if (ContextCompat.checkSelfPermission( reactContext.get()!!, @@ -174,6 +352,21 @@ object MediaSessionManager { "Denied" } + fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): String { + if (requestCode == 420) { + return if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + "Granted" + } else { + "Denied" + } + } + return "Undetermined" + } + @RequiresApi(Build.VERSION_CODES.O) private fun createChannel() { val notificationManager = diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm index 084ab18bf..fe6a867f0 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -105,14 +105,25 @@ - (void)invalidate return [self.audioSessionManager getDevicePreferredSampleRate]; } +RCT_EXPORT_METHOD(observeVolumeChanges : (BOOL)enabled) +{ + [self.notificationManager observeVolumeChanges:enabled]; +} + RCT_EXPORT_METHOD(observeAudioInterruptions : (BOOL)enabled) { [self.notificationManager observeAudioInterruptions:enabled]; } -RCT_EXPORT_METHOD(observeVolumeChanges : (BOOL)enabled) +// android-only support for options +RCT_EXPORT_METHOD(requestAudioFocus : (BOOL)observeAudioInterruptions : (NSDictionary *)options) +{ + [self.notificationManager observeAudioInterruptions:observeAudioInterruptions]; +} + +RCT_EXPORT_METHOD(abandonAudioFocus) { - [self.notificationManager observeVolumeChanges:(BOOL)enabled]; + [self.notificationManager observeAudioInterruptions:false]; } RCT_EXPORT_METHOD( diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts index 44ede514e..f5b8d54a9 100644 --- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts +++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts @@ -1,7 +1,7 @@ 'use strict'; import { TurboModuleRegistry } from 'react-native'; import type { TurboModule } from 'react-native'; -import { PermissionStatus } from '../system/types'; +import { PermissionStatus, AudioAttributeType } from '../system/types'; interface Spec extends TurboModule { install(): boolean; @@ -18,6 +18,13 @@ interface Spec extends TurboModule { ): void; getDevicePreferredSampleRate(): number; observeAudioInterruptions(enabled: boolean): void; + requestAudioFocus( + observeAudioInterruptions: boolean, + options?: { + [key: string]: string | boolean | number | AudioAttributeType | undefined; + } + ): void; + abandonAudioFocus(): void; observeVolumeChanges(enabled: boolean): void; requestRecordingPermissions(): Promise; checkRecordingPermissions(): Promise; diff --git a/packages/react-native-audio-api/src/system/AudioManager.ts b/packages/react-native-audio-api/src/system/AudioManager.ts index 224b5e868..69cfa29ec 100644 --- a/packages/react-native-audio-api/src/system/AudioManager.ts +++ b/packages/react-native-audio-api/src/system/AudioManager.ts @@ -1,4 +1,9 @@ -import { SessionOptions, LockScreenInfo, PermissionStatus } from './types'; +import { + SessionOptions, + LockScreenInfo, + PermissionStatus, + AudioFocusOptions, +} from './types'; import { SystemEventName, SystemEventCallback } from '../events/types'; import { NativeAudioAPIModule } from '../specs'; import { AudioEventEmitter, AudioEventSubscription } from '../events'; @@ -43,6 +48,17 @@ class AudioManager { NativeAudioAPIModule!.observeAudioInterruptions(enabled); } + requestAudioFocus( + observeAudioInterruption = true, + options?: AudioFocusOptions + ) { + NativeAudioAPIModule!.requestAudioFocus(observeAudioInterruption, options); + } + + abandonAudioFocus() { + NativeAudioAPIModule!.abandonAudioFocus(); + } + observeVolumeChanges(enabled: boolean) { NativeAudioAPIModule!.observeVolumeChanges(enabled); } diff --git a/packages/react-native-audio-api/src/system/types.ts b/packages/react-native-audio-api/src/system/types.ts index 0b681a380..bd713ce73 100644 --- a/packages/react-native-audio-api/src/system/types.ts +++ b/packages/react-native-audio-api/src/system/types.ts @@ -27,6 +27,52 @@ export type IOSOption = | 'overrideMutedMicrophoneInterruption' | 'interruptSpokenAudioAndMixWithOthers'; +export type audioAttributeUsageType = + | 'usage_alarm' + | 'usage_assistance_accessibility' + | 'usage_assistance_navigation_guidance' + | 'usage_assistance_sonification' + | 'usage_assistant' + | 'usage_game' + | 'usage_media' + | 'usage_notification' + | 'usage_notification_event' + | 'usage_notification_ringtone' + | 'usage_notification_communication_request' + | 'usage_notification_communication_instant' + | 'usage_notification_communication_delayed' + | 'usage_unknown' + | 'usage_voice_communication' + | 'usage_voice_communication_signalling'; + +export type audioAttributeContentType = + | 'content_type_movie' + | 'content_type_music' + | 'content_type_sonification' + | 'content_type_speech' + | 'content_type_unknown'; + +export type focusGainType = + | 'audiofocus_gain' + | 'audiofocus_gain_transient' + | 'audiofocus_gain_transient_exclusive' + | 'audiofocus_gain_transient_may_duck'; + +export type AudioAttributeType = { + allowedCapturePolicy?: + | 'allow_capture_by_all' + | 'allow_capture_by_system' + | 'allow_capture_by_none'; + contentType?: audioAttributeContentType; + flag?: 'flag_hw_av_sync' | 'flag_audibility_enforced'; + hapticChannelsMuted?: boolean; + isContentSpatialized?: boolean; + spatializationBehavior?: + | 'spatialization_behavior_auto' + | 'spatialization_behavior_never'; + usage?: audioAttributeUsageType; +}; + export interface SessionOptions { iosMode?: IOSMode; iosOptions?: IOSOption[]; @@ -52,3 +98,14 @@ export interface LockScreenInfo extends BaseLockScreenInfo { } export type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; + +interface BaseAudioFocusOptions { + [key: string]: string | boolean | number | AudioAttributeType | undefined; +} + +export interface AudioFocusOptions extends BaseAudioFocusOptions { + acceptsDelayedFocusGain?: boolean; + audioAttributes?: AudioAttributeType; + focusGain?: focusGainType; + pauseWhenDucked?: boolean; +}