diff --git a/apps/common-app/src/examples/Record/Record.tsx b/apps/common-app/src/examples/Record/Record.tsx index cdb09addb..470e61e8f 100644 --- a/apps/common-app/src/examples/Record/Record.tsx +++ b/apps/common-app/src/examples/Record/Record.tsx @@ -6,6 +6,7 @@ import { RecorderAdapterNode, AudioBufferSourceNode, AudioBuffer, + UiMode, } from 'react-native-audio-api'; import { Container, Button } from '../../components'; @@ -20,6 +21,8 @@ const Record: FC = () => { const recorderAdapterRef = useRef(null); const audioBuffersRef = useRef([]); const sourcesRef = useRef([]); + const isRecordingRef = useRef(false); + const isPausedRef = useRef(false); useEffect(() => { const setup = async () => { @@ -49,13 +52,52 @@ const Record: FC = () => { iosMode: 'spokenAudio', iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], }); + + AudioManager.setUiMode(UiMode.RECORDING); + + const pauseResumeSubscription = + AudioManager.addRecordingNotificationButtonListener( + 'pause_resume', + (buttonId: string) => { + console.log('Pause/Resume button clicked:', buttonId); + // dummy + } + ); + + const stopSubscription = + AudioManager.addRecordingNotificationButtonListener( + 'stop', + (buttonId: string) => { + console.log('Stop button clicked:', buttonId); + stopRecorder(); + } + ); + + AudioManager.setRecordingLockScreenInfo({ + title: 'Recording Audio', + description: 'Recording in progress...', + notificationColor: '#FF6B35', + }); + + setupRecording.subscriptions = [pauseResumeSubscription, stopSubscription]; }; const stopRecorder = () => { if (recorderRef.current) { recorderRef.current.stop(); console.log('Recording stopped'); - // advised, but not required + isRecordingRef.current = false; + isPausedRef.current = false; + + const subscriptions = setupRecording.subscriptions || []; + subscriptions.forEach((subscriptionId: string) => { + AudioManager.removeRecordingNotificationButtonListener(subscriptionId); + }); + + AudioManager.resetRecordingLockScreenInfo(); + + AudioManager.setUiMode(UiMode.PLAYBACK); + AudioManager.setAudioSessionOptions({ iosCategory: 'playback', iosMode: 'default', @@ -78,6 +120,7 @@ const Record: FC = () => { recorderRef.current.connect(recorderAdapterRef.current); recorderRef.current.start(); + isRecordingRef.current = true; console.log('Recording started'); console.log('Audio context state:', aCtxRef.current.state); if (aCtxRef.current.state === 'suspended') { @@ -91,6 +134,7 @@ const Record: FC = () => { stopRecorder(); aCtxRef.current = null; recorderAdapterRef.current = null; + isRecordingRef.current = false; }; const startRecordReplay = () => { @@ -109,6 +153,7 @@ const Record: FC = () => { }); recorderRef.current.start(); + isRecordingRef.current = true; setTimeout(() => { stopRecorder(); diff --git a/apps/fabric-example/android/app/src/main/AndroidManifest.xml b/apps/fabric-example/android/app/src/main/AndroidManifest.xml index 54bdc2af3..1951a0a0f 100644 --- a/apps/fabric-example/android/app/src/main/AndroidManifest.xml +++ b/apps/fabric-example/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + , private val audioAPIModule: WeakReference, - private val lockScreenManager: WeakReference, ) : AudioManager.OnAudioFocusChangeListener { - private var playOnAudioFocus: Boolean = false private var focusRequest: AudioFocusRequest? = null override fun onAudioFocusChange(focusChange: Int) { Log.d("AudioFocusListener", "onAudioFocusChange: $focusChange") when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { - playOnAudioFocus = false val body = HashMap().apply { put("type", "began") @@ -28,33 +25,23 @@ class AudioFocusListener( } audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("interruption", body) } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - playOnAudioFocus = lockScreenManager.get()?.isPlaying == true val body = HashMap().apply { put("type", "began") - put("shouldResume", playOnAudioFocus) + put("shouldResume", false) } audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("interruption", body) } - AudioManager.AUDIOFOCUS_GAIN -> { - if (playOnAudioFocus) { - val body = - HashMap().apply { - put("type", "ended") - put("shouldResume", true) - } - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("interruption", body) - } else { - val body = - HashMap().apply { - put("type", "ended") - put("shouldResume", false) - } - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("interruption", body) - } - playOnAudioFocus = false + AudioManager.AUDIOFOCUS_GAIN -> { + val body = + HashMap().apply { + put("type", "ended") + put("shouldResume", true) + } + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("interruption", body) } } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/LockScreenManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/LockScreenManager.kt index ed92c7a5e..be1b20629 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/LockScreenManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/LockScreenManager.kt @@ -42,11 +42,13 @@ class LockScreenManager( private var playbackState: Int = PlaybackStateCompat.STATE_PAUSED init { - pb.setActions(controls) - nb.setPriority(NotificationCompat.PRIORITY_HIGH) - nb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + this.pb.setActions(controls) + + this.nb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + this.nb.setPriority(NotificationCompat.PRIORITY_HIGH) updateNotificationMediaStyle() + mediaNotificationManager.get()?.updateActions(controls) } @@ -172,31 +174,8 @@ class LockScreenManager( if (artworkThread != null && artworkThread!!.isAlive) artworkThread!!.interrupt() artworkThread = null - title = null - artist = null - album = null - description = null - duration = 0L - speed = 1.0F - elapsedTime = 0L - artwork = null - playbackState = PlaybackStateCompat.STATE_PAUSED - isPlaying = false - - val emptyMetadata = MediaMetadataCompat.Builder().build() - mediaSession.get()?.setMetadata(emptyMetadata) - - pb.setState(PlaybackStateCompat.STATE_NONE, 0, 0f) - pb.setActions(controls) - state = pb.build() - mediaSession.get()?.setPlaybackState(state) + mediaNotificationManager.get()?.cancelNotification() mediaSession.get()?.setActive(false) - - nb.setContentTitle("") - nb.setContentText("") - nb.setContentInfo("") - - mediaNotificationManager.get()?.updateNotification(nb, isPlaying) } fun enableRemoteCommand( diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaNotificationManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaNotificationManager.kt index 9f28a4910..93ee936a7 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaNotificationManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaNotificationManager.kt @@ -24,6 +24,8 @@ import java.lang.ref.WeakReference class MediaNotificationManager( private val reactContext: WeakReference, ) { + private var currentNotification: Notification? = null + private var isRecordingStyle: Boolean = true private var smallIcon: Int = R.drawable.logo private var customIcon: Int = 0 @@ -39,6 +41,9 @@ class MediaNotificationManager( const val REMOVE_NOTIFICATION: String = "audio_manager_remove_notification" const val PACKAGE_NAME: String = "com.swmansion.audioapi.system" const val MEDIA_BUTTON: String = "audio_manager_media_button" + + const val CUSTOM_ACTION: String = "audio_manager_custom_action" + const val EXTRA_CUSTOM_ACTION_ID: String = "extra_custom_action_id" } enum class ForegroundAction { @@ -57,37 +62,39 @@ class MediaNotificationManager( builder: NotificationCompat.Builder, isPlaying: Boolean, ): Notification { - builder.mActions.clear() + if (!isRecordingStyle) { + builder.mActions.clear() - if (previous != null) { - builder.addAction(previous) - } + if (previous != null) { + builder.addAction(previous) + } - if (skipBackward != null) { - builder.addAction(skipBackward) - } + if (skipBackward != null) { + builder.addAction(skipBackward) + } - if (play != null && !isPlaying) { - builder.addAction(play) - } + if (play != null && !isPlaying) { + builder.addAction(play) + } - if (pause != null && isPlaying) { - builder.addAction(pause) - } + if (pause != null && isPlaying) { + builder.addAction(pause) + } - if (stop != null) { - builder.addAction(stop) - } + if (stop != null) { + builder.addAction(stop) + } - if (next != null) { - builder.addAction(next) - } + if (next != null) { + builder.addAction(next) + } - if (skipForward != null) { - builder.addAction(skipForward) - } + if (skipForward != null) { + builder.addAction(skipForward) + } - builder.setSmallIcon(if (customIcon != 0) customIcon else smallIcon) + builder.setSmallIcon(if (customIcon != 0) customIcon else smallIcon) + } val packageName: String? = reactContext.get()?.packageName val openApp: Intent? = reactContext.get()?.packageManager?.getLaunchIntentForPackage(packageName!!) @@ -114,8 +121,13 @@ class MediaNotificationManager( PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ), ) + currentNotification = builder.build() + return currentNotification!! + } - return builder.build() + @Synchronized + fun setRecordingStyle(isRecording: Boolean) { + this.isRecordingStyle = isRecording } @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) @@ -132,6 +144,7 @@ class MediaNotificationManager( fun cancelNotification() { NotificationManagerCompat.from(reactContext.get()!!).cancel(MediaSessionManager.NOTIFICATION_ID) + currentNotification = null } @Synchronized @@ -145,6 +158,19 @@ class MediaNotificationManager( skipBackward = createAction("skip_backward_15", "Skip Backward", mask, PlaybackStateCompat.ACTION_REWIND, skipBackward) } + fun pendingIntentForAction(actionId: String): PendingIntent { + val intent = Intent(CUSTOM_ACTION) + intent.putExtra(EXTRA_CUSTOM_ACTION_ID, actionId) + intent.putExtra(ContactsContract.Directory.PACKAGE_NAME, reactContext.get()?.packageName) + val requestCode = actionId.hashCode() + return PendingIntent.getBroadcast( + reactContext.get(), + requestCode, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + private fun createAction( iconName: String, title: String, @@ -190,23 +216,28 @@ class MediaNotificationManager( synchronized(serviceLock) { if (!isServiceStarted) { try { - notification = - MediaSessionManager.mediaNotificationManager - .prepareNotification( - NotificationCompat.Builder(this, MediaSessionManager.CHANNEL_ID), - false, - ) + val notificationToStartWith = MediaSessionManager.mediaNotificationManager.currentNotification + + val finalNotification = + notificationToStartWith ?: run { + val fallbackBuilder = + NotificationCompat + .Builder(this, MediaSessionManager.CHANNEL_ID) + .setSmallIcon(MediaSessionManager.mediaNotificationManager.smallIcon) + .setContentTitle("Audio Service") + MediaSessionManager.mediaNotificationManager.prepareNotification(fallbackBuilder, false) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( MediaSessionManager.NOTIFICATION_ID, - notification!!, + finalNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST, ) } else { startForeground( MediaSessionManager.NOTIFICATION_ID, - notification, + finalNotification, ) } isServiceStarted = true @@ -253,21 +284,10 @@ class MediaNotificationManager( override fun onDestroy() { synchronized(serviceLock) { - notification = null + // notification = null isServiceStarted = false } super.onDestroy() } - - override fun onTimeout(startId: Int) { - stopForegroundService() - } - - override fun onTimeout( - startId: Int, - fgsType: Int, - ) { - stopForegroundService() - } } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaReceiver.kt index 5832c56aa..9d9130f46 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaReceiver.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaReceiver.kt @@ -42,6 +42,13 @@ class MediaReceiver( ?.controller ?.transportControls ?.pause() + } else if (action == "com.swmansion.audioapi.RECORDING_BUTTON_CLICK") { + if (!checkApp(intent)) return + + val buttonId = intent.getStringExtra("buttonId") + if (buttonId != null) { + MediaSessionManager.getRecordingLockScreenManager().onButtonClicked(buttonId) + } } } 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 f0ef68b6f..201d2aae1 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 @@ -26,7 +26,20 @@ import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDI import java.lang.ref.WeakReference import java.util.UUID +enum class UiMode { PLAYBACK, RECORDING } + object MediaSessionManager { + private interface SessionUiManager { + fun setInfo(info: ReadableMap?) + + fun resetInfo() + + fun enableRemoteCommand( + name: String, + enabled: Boolean, + ) + } + private lateinit var audioAPIModule: WeakReference private lateinit var reactContext: WeakReference const val NOTIFICATION_ID = 100 @@ -36,6 +49,8 @@ object MediaSessionManager { private lateinit var mediaSession: MediaSessionCompat lateinit var mediaNotificationManager: MediaNotificationManager private lateinit var lockScreenManager: LockScreenManager + + private lateinit var recordingLockScreenManager: RecordingLockScreenManager private lateinit var audioFocusListener: AudioFocusListener private lateinit var volumeChangeListener: VolumeChangeListener private lateinit var mediaReceiver: MediaReceiver @@ -44,6 +59,10 @@ object MediaSessionManager { private val serviceStateLock = Any() private val nativeAudioPlayers = mutableMapOf() private val nativeAudioRecorders = mutableMapOf() + private val buttonListeners = mutableMapOf() + + private lateinit var currentUi: SessionUiManager + private var uiMode: UiMode = UiMode.PLAYBACK fun initialize( audioAPIModule: WeakReference, @@ -54,21 +73,26 @@ object MediaSessionManager { this.audioManager = reactContext.get()?.getSystemService(Context.AUDIO_SERVICE) as AudioManager this.mediaSession = MediaSessionCompat(reactContext.get()!!, "MediaSessionManager") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createChannel() - } + createChannel() this.mediaNotificationManager = MediaNotificationManager(this.reactContext) this.lockScreenManager = LockScreenManager(this.reactContext, WeakReference(this.mediaSession), WeakReference(mediaNotificationManager)) + this.recordingLockScreenManager = + RecordingLockScreenManager(this.reactContext, WeakReference(this.mediaSession), WeakReference(this.mediaNotificationManager)) this.mediaReceiver = MediaReceiver(this.reactContext, WeakReference(this.mediaSession), WeakReference(this.mediaNotificationManager), this.audioAPIModule) this.mediaSession.setCallback(MediaSessionCallback(this.audioAPIModule, WeakReference(this.mediaNotificationManager))) + this.currentUi = lockScreenUi() + this.uiMode = UiMode.PLAYBACK + mediaNotificationManager.setRecordingStyle(false) + val filter = IntentFilter() filter.addAction(MediaNotificationManager.REMOVE_NOTIFICATION) filter.addAction(MediaNotificationManager.MEDIA_BUTTON) filter.addAction(Intent.ACTION_MEDIA_BUTTON) filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + filter.addAction("com.swmansion.audioapi.RECORDING_BUTTON_CLICK") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.reactContext.get()!!.registerReceiver(mediaReceiver, filter, Context.RECEIVER_EXPORTED) @@ -81,11 +105,46 @@ object MediaSessionManager { ) } - this.audioFocusListener = - AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule, WeakReference(this.lockScreenManager)) + this.audioFocusListener = AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule) this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule) } + private fun lockScreenUi(): SessionUiManager = + object : SessionUiManager { + override fun setInfo(info: ReadableMap?) { + lockScreenManager.setLockScreenInfo(info) + } + + override fun resetInfo() { + lockScreenManager.resetLockScreenInfo() + } + + override fun enableRemoteCommand( + name: String, + enabled: Boolean, + ) { + lockScreenManager.enableRemoteCommand(name, enabled) + } + } + + private fun recordingUi(): SessionUiManager = + object : SessionUiManager { + override fun setInfo(info: ReadableMap?) { + recordingLockScreenManager.setRecordingInfo(info) + } + + override fun resetInfo() { + recordingLockScreenManager.resetRecordingInfo() + } + + override fun enableRemoteCommand( + name: String, + enabled: Boolean, + ) { + recordingLockScreenManager.enableRemoteCommand(name, enabled) + } + } + fun attachAudioPlayer(player: NativeAudioPlayer): String { val uuid = UUID.randomUUID().toString() nativeAudioPlayers[uuid] = player @@ -129,11 +188,7 @@ object MediaSessionManager { val intent = Intent(reactContext.get(), MediaNotificationManager.AudioForegroundService::class.java) intent.action = MediaNotificationManager.ForegroundAction.START_FOREGROUND.name - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ContextCompat.startForegroundService(reactContext.get()!!, intent) - } else { - reactContext.get()!!.startService(intent) - } + ContextCompat.startForegroundService(reactContext.get()!!, intent) isServiceRunning = true } } @@ -152,18 +207,41 @@ object MediaSessionManager { } fun setLockScreenInfo(info: ReadableMap?) { - lockScreenManager.setLockScreenInfo(info) + currentUi.setInfo(info) } fun resetLockScreenInfo() { - lockScreenManager.resetLockScreenInfo() + currentUi.resetInfo() + } + + fun setRecordingLockScreenInfo(info: ReadableMap?) { + recordingLockScreenManager.setRecordingInfo(info) + } + + fun resetRecordingLockScreenInfo() { + recordingLockScreenManager.resetRecordingInfo() + } + + fun setUiMode(mode: String) { + when (mode) { + "PLAYBACK" -> { + currentUi = lockScreenUi() + uiMode = UiMode.PLAYBACK + mediaNotificationManager.setRecordingStyle(false) + } + "RECORDING" -> { + currentUi = recordingUi() + uiMode = UiMode.RECORDING + mediaNotificationManager.setRecordingStyle(true) + } + } } fun enableRemoteCommand( name: String, enabled: Boolean, ) { - lockScreenManager.enableRemoteCommand(name, enabled) + currentUi.enableRemoteCommand(name, enabled) } fun getDevicePreferredSampleRate(): Double { @@ -211,20 +289,19 @@ object MediaSessionManager { "Denied" } - @RequiresApi(Build.VERSION_CODES.O) private fun createChannel() { val notificationManager = reactContext.get()?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val mChannel = - NotificationChannel(CHANNEL_ID, "Audio manager", NotificationManager.IMPORTANCE_LOW) - mChannel.description = "Audio manager" + NotificationChannel(CHANNEL_ID, "Recording", NotificationManager.IMPORTANCE_HIGH) + mChannel.description = "Shows notifications about ongoing recording" mChannel.setShowBadge(false) + mChannel.enableVibration(true) mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC notificationManager.createNotificationChannel(mChannel) } - @RequiresApi(Build.VERSION_CODES.O) fun getDevicesInfo(): ReadableMap { val availableInputs = Arguments.createArray() val availableOutputs = Arguments.createArray() @@ -255,7 +332,6 @@ object MediaSessionManager { return devicesInfo } - @RequiresApi(Build.VERSION_CODES.O) fun parseDeviceType(device: AudioDeviceInfo): String = when (device.type) { AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Built-in Mic" @@ -267,4 +343,22 @@ object MediaSessionManager { AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth SCO" else -> "Other (${device.type})" } + + fun addRecordingNotificationButtonListener( + buttonId: String, + callback: com.facebook.react.bridge.Callback, + ): String { + val subscriptionId = UUID.randomUUID().toString() + buttonListeners[subscriptionId] = callback + recordingLockScreenManager.addButtonListener(buttonId) { clickedButtonId -> + callback.invoke(clickedButtonId) + } + return subscriptionId + } + + fun removeRecordingNotificationButtonListener(subscriptionId: String) { + buttonListeners.remove(subscriptionId) + } + + fun getRecordingLockScreenManager(): RecordingLockScreenManager = recordingLockScreenManager } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/RecordingLockScreenManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/RecordingLockScreenManager.kt new file mode 100644 index 000000000..8165ef949 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/RecordingLockScreenManager.kt @@ -0,0 +1,205 @@ +package com.swmansion.audioapi.system + +import android.app.Notification +import android.graphics.Color +import android.support.v4.media.session.MediaSessionCompat +import android.util.Log +import androidx.core.app.NotificationCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import java.lang.ref.WeakReference + +class RecordingLockScreenManager( + private val reactContext: WeakReference, + private val mediaSession: WeakReference, + private val mediaNotificationManager: WeakReference, +) { + private var isRecording: Boolean = false + private var isPaused: Boolean = false + private var title: String? = null + private var description: String? = null + private val iconName: String = "logo" + private val iconSource: String = "drawable" + private val buttonListeners = mutableMapOf Unit>() + + companion object { + private const val PAUSE_RESUME_REQUEST_CODE = 1001 + private const val STOP_REQUEST_CODE = 1002 + } + + private var nb: NotificationCompat.Builder = + NotificationCompat.Builder(reactContext.get()!!, MediaSessionManager.CHANNEL_ID) + + init { + val resId = reactContext.get()!!.resources.getIdentifier(iconName, iconSource, reactContext.get()!!.packageName) + val color = parseColor(null) // Default red + + nb.setSmallIcon(resId) + nb.setColorized(true) + nb.setColor(color) + nb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + nb.setPriority(NotificationCompat.PRIORITY_HIGH) + nb.setCategory(Notification.CATEGORY_SERVICE) + nb.setOnlyAlertOnce(true) + nb.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + nb.setOngoing(true) + } + + fun setRecordingInfo(info: ReadableMap?) { + if (info == null) { + return + } + + if (info.hasKey("title")) { + title = info.getString("title") + } + + if (info.hasKey("description")) { + description = info.getString("description") + } + + if (!isRecording) { + isRecording = true + isPaused = false + } + + val resId = reactContext.get()!!.resources.getIdentifier(iconName, iconSource, reactContext.get()!!.packageName) + val color = parseColor(info.getString("notificationColor")) + + nb.setSmallIcon(resId) + nb.setColorized(true) + nb.setColor(color) + nb.setUsesChronometer(isRecording && !isPaused) + nb.setContentTitle(title ?: "Recording") + nb.setContentText( + when { + isPaused -> "Recording paused" + isRecording -> "Recording in progress" + else -> "Ready to record" + }, + ) + + nb.clearActions() + + val pauseResumeAction = + androidx.core.app.NotificationCompat.Action + .Builder( + android.R.drawable.ic_media_pause, + if (isPaused) "Resume" else "Pause", + android.app.PendingIntent.getBroadcast( + reactContext.get(), + PAUSE_RESUME_REQUEST_CODE, + android.content.Intent("com.swmansion.audioapi.RECORDING_BUTTON_CLICK").apply { + putExtra("buttonId", "pause_resume") + }, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE, + ), + ).build() + nb.addAction(pauseResumeAction) + + val stopAction = + androidx.core.app.NotificationCompat.Action + .Builder( + android.R.drawable.ic_media_stop, + "Stop", + android.app.PendingIntent.getBroadcast( + reactContext.get(), + STOP_REQUEST_CODE, + android.content.Intent("com.swmansion.audioapi.RECORDING_BUTTON_CLICK").apply { + putExtra("buttonId", "stop") + }, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE, + ), + ).build() + nb.addAction(stopAction) + + mediaSession.get()?.setActive(true) + mediaNotificationManager.get()?.updateNotification(nb, isRecording) + } + + fun resetRecordingInfo() { + isRecording = false + isPaused = false + mediaNotificationManager.get()?.cancelNotification() + mediaSession.get()?.setActive(false) + } + + fun enableRemoteCommand( + name: String, + enabled: Boolean, + ) {} + + fun addButtonListener( + buttonId: String, + listener: (String) -> Unit, + ) { + buttonListeners[buttonId] = listener + } + + fun removeButtonListener(buttonId: String) { + buttonListeners.remove(buttonId) + } + + fun onButtonClicked(buttonId: String) { + when (buttonId) { + "pause_resume" -> { + if (isRecording) { + isPaused = !isPaused + setRecordingInfo(createCurrentInfoMap()) + } + } + "stop" -> { + isRecording = false + isPaused = false + buttonListeners[buttonId]?.invoke(buttonId) + } + else -> { + buttonListeners[buttonId]?.invoke(buttonId) + } + } + } + + fun pauseRecording() { + if (isRecording && !isPaused) { + isPaused = true + setRecordingInfo(createCurrentInfoMap()) + } + } + + fun resumeRecording() { + if (isRecording && isPaused) { + isPaused = false + setRecordingInfo(createCurrentInfoMap()) + } + } + + private fun createCurrentInfoMap(): ReadableMap? { + val arguments = + com.facebook.react.bridge.Arguments + .createMap() + title?.let { arguments.putString("title", it) } + description?.let { arguments.putString("description", it) } + return arguments + } + + private fun parseColor(colorString: String?): Int = + when { + colorString?.startsWith("#") == true -> { + try { + Color.parseColor(colorString) + } catch (e: IllegalArgumentException) { + Color.rgb(255, 0, 0) + } + } + colorString == "red" -> Color.RED + colorString == "blue" -> Color.BLUE + colorString == "green" -> Color.GREEN + colorString == "orange" -> Color.rgb(255, 165, 0) + colorString == "purple" -> Color.rgb(128, 0, 128) + colorString == "yellow" -> Color.YELLOW + colorString == "black" -> Color.BLACK + colorString == "white" -> Color.WHITE + colorString == "gray" -> Color.GRAY + else -> Color.rgb(255, 0, 0) + } +} diff --git a/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java b/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java index 3e0840996..8268cebc7 100644 --- a/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java +++ b/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java @@ -10,83 +10,95 @@ * @nolint */ - package com.swmansion.audioapi; - - import com.facebook.proguard.annotations.DoNotStrip; - 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.bridge.ReadableArray; - import com.facebook.react.bridge.ReadableMap; - import com.facebook.react.turbomodule.core.interfaces.TurboModule; - import javax.annotation.Nonnull; - - public abstract class NativeAudioAPIModuleSpec extends ReactContextBaseJavaModule implements TurboModule { - public static final String NAME = "AudioAPIModule"; - - public NativeAudioAPIModuleSpec(ReactApplicationContext reactContext) { - super(reactContext); - } - - @Override - public @Nonnull String getName() { - return NAME; - } - - @ReactMethod(isBlockingSynchronousMethod = true) - @DoNotStrip - public abstract boolean install(); - - @ReactMethod(isBlockingSynchronousMethod = true) - @DoNotStrip - public abstract double getDevicePreferredSampleRate(); - - @ReactMethod - @DoNotStrip - public abstract void setAudioSessionActivity(boolean enabled, Promise promise); - - @ReactMethod - @DoNotStrip - public abstract void setAudioSessionOptions(String category, String mode, ReadableArray options, boolean allowHaptics); - - @ReactMethod - @DoNotStrip - public abstract void disableSessionManagement(); - - @ReactMethod - @DoNotStrip - public abstract void setLockScreenInfo(ReadableMap info); - - @ReactMethod - @DoNotStrip - public abstract void resetLockScreenInfo(); - - @ReactMethod - @DoNotStrip - public abstract void enableRemoteCommand(String name, boolean enabled); - - @ReactMethod - @DoNotStrip - public abstract void observeAudioInterruptions(boolean enabled); - - @ReactMethod - @DoNotStrip - public abstract void activelyReclaimSession(boolean enabled); - - @ReactMethod - @DoNotStrip - public abstract void observeVolumeChanges(boolean enabled); - - @ReactMethod - @DoNotStrip - public abstract void requestRecordingPermissions(Promise promise); - - @ReactMethod - @DoNotStrip - public abstract void checkRecordingPermissions(Promise promise); - - @ReactMethod - @DoNotStrip - public abstract void getDevicesInfo(Promise promise); - } +package com.swmansion.audioapi; + +import com.facebook.proguard.annotations.DoNotStrip; +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.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; +import javax.annotation.Nonnull; + +public abstract class NativeAudioAPIModuleSpec extends ReactContextBaseJavaModule implements TurboModule { + public static final String NAME = "AudioAPIModule"; + + public NativeAudioAPIModuleSpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public @Nonnull String getName() { + return NAME; + } + + @ReactMethod(isBlockingSynchronousMethod = true) + @DoNotStrip + public abstract boolean install(); + + @ReactMethod(isBlockingSynchronousMethod = true) + @DoNotStrip + public abstract double getDevicePreferredSampleRate(); + + @ReactMethod + @DoNotStrip + public abstract void setAudioSessionActivity(boolean enabled, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void setAudioSessionOptions(String category, String mode, ReadableArray options, boolean allowHaptics); + + @ReactMethod + @DoNotStrip + public abstract void disableSessionManagement(); + + @ReactMethod + @DoNotStrip + public abstract void setLockScreenInfo(ReadableMap info); + + @ReactMethod + @DoNotStrip + public abstract void resetLockScreenInfo(); + + @ReactMethod + @DoNotStrip + public abstract void setRecordingLockScreenInfo(ReadableMap info); + + @ReactMethod + @DoNotStrip + public abstract void resetRecordingLockScreenInfo(); + + @ReactMethod + @DoNotStrip + public abstract void setUiMode(String mode); + + @ReactMethod + @DoNotStrip + public abstract void enableRemoteCommand(String name, boolean enabled); + + @ReactMethod + @DoNotStrip + public abstract void observeAudioInterruptions(boolean enabled); + + @ReactMethod + @DoNotStrip + public abstract void activelyReclaimSession(boolean enabled); + + @ReactMethod + @DoNotStrip + public abstract void observeVolumeChanges(boolean enabled); + + @ReactMethod + @DoNotStrip + public abstract void requestRecordingPermissions(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void checkRecordingPermissions(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void getDevicesInfo(Promise promise); +} diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 21586a4f2..8684fa1bb 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -98,6 +98,9 @@ export { SessionOptions, MediaState, LockScreenInfo, + RecordingLockScreenInfo, + UiMode, + ForegroundAction, PermissionStatus, } from './system/types'; diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts index 21fa17a88..c8dee6667 100644 --- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts +++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts @@ -1,7 +1,11 @@ 'use strict'; import { TurboModuleRegistry } from 'react-native'; import type { TurboModule } from 'react-native'; -import { PermissionStatus, AudioDevicesInfo } from '../system/types'; +import { + PermissionStatus, + AudioDevicesInfo, + RecordingLockScreenInfo, +} from '../system/types'; interface Spec extends TurboModule { install(): boolean; @@ -23,6 +27,22 @@ interface Spec extends TurboModule { }): void; resetLockScreenInfo(): void; + // Recording Lock Screen Info + setRecordingLockScreenInfo(info: { + title?: string; + description?: string; + notificationColor?: string; // Hex color like "#FF0000" or named color like "red" + }): void; + resetRecordingLockScreenInfo(): void; + setUiMode(mode: string): void; // 'PLAYBACK' | 'RECORDING' + + // Recording notification button callbacks + addRecordingNotificationButtonListener( + buttonId: string, + callback: (buttonId: string) => void + ): string; + removeRecordingNotificationButtonListener(subscriptionId: string): void; + // Remote commands, system events and interruptions enableRemoteCommand(name: string, enabled: boolean): void; observeAudioInterruptions(enabled: boolean): void; diff --git a/packages/react-native-audio-api/src/system/AudioManager.ts b/packages/react-native-audio-api/src/system/AudioManager.ts index 46a2a2b9f..7b7046569 100644 --- a/packages/react-native-audio-api/src/system/AudioManager.ts +++ b/packages/react-native-audio-api/src/system/AudioManager.ts @@ -1,8 +1,10 @@ import { SessionOptions, LockScreenInfo, + RecordingLockScreenInfo, PermissionStatus, AudioDevicesInfo, + UiMode, } from './types'; import { SystemEventName, @@ -57,10 +59,38 @@ class AudioManager { NativeAudioAPIModule!.resetLockScreenInfo(); } + setRecordingLockScreenInfo(info: RecordingLockScreenInfo) { + NativeAudioAPIModule!.setRecordingLockScreenInfo(info); + } + + resetRecordingLockScreenInfo() { + NativeAudioAPIModule!.resetRecordingLockScreenInfo(); + } + + setUiMode(mode: UiMode) { + NativeAudioAPIModule!.setUiMode(mode); + } + observeAudioInterruptions(enabled: boolean) { NativeAudioAPIModule!.observeAudioInterruptions(enabled); } + addRecordingNotificationButtonListener( + buttonId: string, + callback: (buttonId: string) => void + ): string { + return NativeAudioAPIModule!.addRecordingNotificationButtonListener( + buttonId, + callback + ); + } + + removeRecordingNotificationButtonListener(subscriptionId: string): void { + NativeAudioAPIModule!.removeRecordingNotificationButtonListener( + subscriptionId + ); + } + /** * @param enabled - Whether to actively reclaim the session or not * @experimental more aggressively try to reactivate the audio session during interruptions. diff --git a/packages/react-native-audio-api/src/system/types.ts b/packages/react-native-audio-api/src/system/types.ts index 8625eab25..44055e802 100644 --- a/packages/react-native-audio-api/src/system/types.ts +++ b/packages/react-native-audio-api/src/system/types.ts @@ -36,6 +36,12 @@ export interface SessionOptions { export type MediaState = 'state_playing' | 'state_paused'; +// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values +export enum UiMode { + PLAYBACK = 'PLAYBACK', + RECORDING = 'RECORDING', +} + interface BaseLockScreenInfo { [key: string]: string | boolean | number | undefined; } @@ -52,6 +58,17 @@ export interface LockScreenInfo extends BaseLockScreenInfo { elapsedTime?: number; } +export interface RecordingLockScreenInfo { + title?: string; + description?: string; + notificationColor?: string; // Hex color like "#FF0000" or named color like "red" +} + +export enum ForegroundAction { + START_FOREGROUND = 'START_FOREGROUND', + STOP_FOREGROUND = 'STOP_FOREGROUND', +} + export type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; export interface AudioDeviceInfo {