From f4af0990ce2f0b88f23fb5e923b8b13dbaee5897 Mon Sep 17 00:00:00 2001 From: poneciak Date: Mon, 10 Nov 2025 11:51:20 +0100 Subject: [PATCH 01/19] feat: new android notification system --- .../android/app/src/main/AndroidManifest.xml | 1 + .../com/swmansion/audioapi/AudioAPIModule.kt | 161 ++++++++++++++ .../audioapi/system/MediaSessionManager.kt | 81 +++++++ .../system/PermissionRequestListener.kt | 3 +- .../system/notification/BaseNotification.kt | 38 ++++ .../ForegroundNotificationService.kt | 119 ++++++++++ .../notification/NotificationRegistry.kt | 207 ++++++++++++++++++ .../system/notification/SimpleNotification.kt | 115 ++++++++++ .../src/specs/NativeAudioAPIModule.ts | 21 ++ .../src/system/AudioManager.ts | 8 + 10 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt 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 @@ + () @@ -84,6 +89,9 @@ object MediaSessionManager { this.audioFocusListener = AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule, WeakReference(this.lockScreenManager)) this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule) + + // Initialize new notification system + this.notificationRegistry = NotificationRegistry(this.reactContext) } fun attachAudioPlayer(player: NativeAudioPlayer): String { @@ -211,6 +219,41 @@ object MediaSessionManager { "Denied" } + fun requestNotificationPermissions(permissionListener: PermissionListener) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionAwareActivity = reactContext.get()!!.currentActivity as PermissionAwareActivity + permissionAwareActivity.requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + PermissionRequestListener.NOTIFICATION_REQUEST_CODE, + permissionListener, + ) + } else { + // For Android < 13, permission is granted by default + val result = Arguments.createMap() + result.putString("status", "Granted") + permissionListener.onRequestPermissionsResult( + PermissionRequestListener.NOTIFICATION_REQUEST_CODE, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + intArrayOf(PackageManager.PERMISSION_GRANTED), + ) + } + } + + fun checkNotificationPermissions(): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return if (reactContext.get()!!.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) { + "Granted" + } else { + "Denied" + } + } + // For Android < 13, permission is granted by default + return "Granted" + } + @RequiresApi(Build.VERSION_CODES.O) private fun createChannel() { val notificationManager = @@ -267,4 +310,42 @@ object MediaSessionManager { AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth SCO" else -> "Other (${device.type})" } + + // New notification system methods + fun registerNotification( + type: String, + key: String, + ) { + val notification = + when (type) { + "simple" -> SimpleNotification(reactContext) + else -> throw IllegalArgumentException("Unknown notification type: $type") + } + + notificationRegistry.registerNotification(key, notification) + } + + fun showNotification( + key: String, + options: ReadableMap?, + ) { + notificationRegistry.showNotification(key, options) + } + + fun updateNotification( + key: String, + options: ReadableMap?, + ) { + notificationRegistry.updateNotification(key, options) + } + + fun hideNotification(key: String) { + notificationRegistry.hideNotification(key) + } + + fun unregisterNotification(key: String) { + notificationRegistry.unregisterNotification(key) + } + + fun isNotificationActive(key: String): Boolean = notificationRegistry.isNotificationActive(key) } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/PermissionRequestListener.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/PermissionRequestListener.kt index 6b4be7882..7195056da 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/PermissionRequestListener.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/PermissionRequestListener.kt @@ -9,6 +9,7 @@ class PermissionRequestListener( ) : PermissionListener { companion object { const val RECORDING_REQUEST_CODE = 1234 + const val NOTIFICATION_REQUEST_CODE = 1235 } override fun onRequestPermissionsResult( @@ -16,7 +17,7 @@ class PermissionRequestListener( permissions: Array, grantResults: IntArray, ): Boolean { - if (requestCode == RECORDING_REQUEST_CODE) { + if (requestCode == RECORDING_REQUEST_CODE || requestCode == NOTIFICATION_REQUEST_CODE) { if (grantResults.isEmpty()) { this.promise.resolve("Undetermined") } else { diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt new file mode 100644 index 000000000..d43a7389d --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt @@ -0,0 +1,38 @@ +package com.swmansion.audioapi.system.notification + +import android.app.Notification +import com.facebook.react.bridge.ReadableMap + +/** + * Base interface for all notification types. + * Implementations should handle their own notification channel creation, + * notification building, and lifecycle management. + */ +interface BaseNotification { + /** + * Initialize the notification with the provided options. + * This method should create the notification and prepare it for display. + * + * @param options Configuration options from JavaScript side + * @return The built Notification ready to be shown + */ + fun init(options: ReadableMap?): Notification + + /** + * Reset the notification to its initial state. + * This should clear any stored data and stop any ongoing processes. + */ + fun reset() + + /** + * Get the unique ID for this notification. + * Used by the NotificationManager to track and manage notifications. + */ + fun getNotificationId(): Int + + /** + * Get the channel ID for this notification. + * Required for Android O+ notification channels. + */ + fun getChannelId(): String +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt new file mode 100644 index 000000000..c83b8cb82 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt @@ -0,0 +1,119 @@ +package com.swmansion.audioapi.system.notification + +import android.app.Notification +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log + +/** + * Foreground service for displaying persistent notifications. + */ +class ForegroundNotificationService : Service() { + companion object { + private const val TAG = "ForegroundNotifService" + const val ACTION_START_FOREGROUND = "START_FOREGROUND" + const val ACTION_STOP_FOREGROUND = "STOP_FOREGROUND" + const val EXTRA_NOTIFICATION_ID = "notification_id" + const val EXTRA_NOTIFICATION_KEY = "notification_key" + } + + private var isServiceStarted = false + private val serviceLock = Any() + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + when (intent?.action) { + ACTION_START_FOREGROUND -> { + val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + val notificationKey = intent.getStringExtra(EXTRA_NOTIFICATION_KEY) + + if (notificationId != -1 && notificationKey != null) { + startForegroundService(notificationId, notificationKey) + } else { + Log.w(TAG, "Invalid notification data received") + } + } + ACTION_STOP_FOREGROUND -> { + stopForegroundService() + } + else -> { + Log.w(TAG, "Unknown action: ${intent?.action}") + } + } + + return START_NOT_STICKY + } + + private fun startForegroundService( + notificationId: Int, + notificationKey: String, + ) { + synchronized(serviceLock) { + if (!isServiceStarted) { + try { + // TODO retrieve actual notification from NotificationRegistry + val notification = createDummyNotification(notificationId) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + ) + } else { + startForeground(notificationId, notification) + } + + isServiceStarted = true + Log.d(TAG, "Foreground service started with notification: $notificationKey") + } catch (e: Exception) { + Log.e(TAG, "Error starting foreground service: ${e.message}", e) + } + } + } + } + + private fun stopForegroundService() { + synchronized(serviceLock) { + if (isServiceStarted) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + stopForeground(STOP_FOREGROUND_REMOVE) + } + isServiceStarted = false + stopSelf() + Log.d(TAG, "Foreground service stopped") + } + } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + stopForegroundService() + } + + override fun onDestroy() { + synchronized(serviceLock) { + isServiceStarted = false + } + super.onDestroy() + } + + private fun createDummyNotification(notificationId: Int): Notification { + // TODO This is a placeholder - in a real implementation, we'd need to pass + // the actual notification or retrieve it from a shared location + return Notification + .Builder(this, SimpleNotification.CHANNEL_ID) + .setContentTitle("Audio Service") + .setContentText("Running in background") + .setSmallIcon(android.R.drawable.ic_media_play) + .build() + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt new file mode 100644 index 000000000..f1704b993 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -0,0 +1,207 @@ +package com.swmansion.audioapi.system.notification + +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import java.lang.ref.WeakReference + +/** + * Central notification registry that manages multiple notification instances. + * Provides a clean API for creating, updating, and removing notifications. + */ +class NotificationRegistry( + private val reactContext: WeakReference, +) { + companion object { + private const val TAG = "NotificationRegistry" + } + + private val notifications = mutableMapOf() + private val activeNotifications = mutableMapOf() + + /** + * Register a new notification instance. + * + * @param key Unique identifier for this notification + * @param notification The notification instance to register + */ + fun registerNotification( + key: String, + notification: BaseNotification, + ) { + notifications[key] = notification + Log.d(TAG, "Registered notification: $key") + } + + /** + * Initialize and show a notification. + * + * @param key The unique identifier of the notification + * @param options Configuration options from JavaScript + */ + fun showNotification( + key: String, + options: ReadableMap?, + ) { + val notification = notifications[key] + if (notification == null) { + Log.w(TAG, "Notification not found: $key") + return + } + + try { + val builtNotification = notification.init(options) + displayNotification(notification.getNotificationId(), builtNotification) + activeNotifications[key] = true + Log.d(TAG, "Showing notification: $key") + } catch (e: Exception) { + Log.e(TAG, "Error showing notification $key: ${e.message}", e) + } + } + + /** + * Update an existing notification with new options. + * + * @param key The unique identifier of the notification + * @param options New configuration options from JavaScript + */ + fun updateNotification( + key: String, + options: ReadableMap?, + ) { + if (!activeNotifications.getOrDefault(key, false)) { + Log.w(TAG, "Cannot update inactive notification: $key") + return + } + + showNotification(key, options) + } + + /** + * Hide and reset a notification. + * + * @param key The unique identifier of the notification + */ + fun hideNotification(key: String) { + val notification = notifications[key] + if (notification == null) { + Log.w(TAG, "Notification not found: $key") + return + } + + try { + cancelNotification(notification.getNotificationId()) + notification.reset() + activeNotifications[key] = false + Log.d(TAG, "Hiding notification: $key") + } catch (e: Exception) { + Log.e(TAG, "Error hiding notification $key: ${e.message}", e) + } + } + + /** + * Unregister and cleanup a notification. + * + * @param key The unique identifier of the notification + */ + fun unregisterNotification(key: String) { + hideNotification(key) + notifications.remove(key) + activeNotifications.remove(key) + Log.d(TAG, "Unregistered notification: $key") + } + + /** + * Check if a notification is currently active. + */ + fun isNotificationActive(key: String): Boolean = activeNotifications.getOrDefault(key, false) + + /** + * Get all registered notification keys. + */ + fun getRegisteredKeys(): Set = notifications.keys.toSet() + + /** + * Cleanup all notifications. + */ + fun cleanup() { + notifications.keys.toList().forEach { key -> + hideNotification(key) + } + notifications.clear() + activeNotifications.clear() + Log.d(TAG, "Cleaned up all notifications") + } + + private fun displayNotification( + id: Int, + notification: Notification, + ) { + val context = reactContext.get() ?: throw IllegalStateException("React context is null") + Log.d(TAG, "Displaying notification with ID: $id") + try { + NotificationManagerCompat.from(context).notify(id, notification) + Log.d(TAG, "Notification posted successfully with ID: $id") + } catch (e: Exception) { + Log.e(TAG, "Error posting notification: ${e.message}", e) + } + } + + private fun cancelNotification(id: Int) { + val context = reactContext.get() ?: return + NotificationManagerCompat.from(context).cancel(id) + } + + /** + * Start foreground service with the given notification. + * This is required for Android O+ when app is in background. + * + * @param key The notification key to use for foreground service + */ + fun startForegroundService(key: String) { + val notification = notifications[key] + if (notification == null) { + Log.w(TAG, "Cannot start foreground service: notification $key not found") + return + } + + val context = reactContext.get() ?: return + val intent = Intent(context, ForegroundNotificationService::class.java) + intent.action = ForegroundNotificationService.ACTION_START_FOREGROUND + intent.putExtra(ForegroundNotificationService.EXTRA_NOTIFICATION_ID, notification.getNotificationId()) + intent.putExtra(ForegroundNotificationService.EXTRA_NOTIFICATION_KEY, key) + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + Log.d(TAG, "Started foreground service with notification: $key") + } catch (e: Exception) { + Log.e(TAG, "Error starting foreground service: ${e.message}", e) + } + } + + /** + * Stop the foreground service. + */ + fun stopForegroundService() { + val context = reactContext.get() ?: return + val intent = Intent(context, ForegroundNotificationService::class.java) + intent.action = ForegroundNotificationService.ACTION_STOP_FOREGROUND + + try { + context.startService(intent) + Log.d(TAG, "Stopped foreground service") + } catch (e: Exception) { + Log.e(TAG, "Error stopping foreground service: ${e.message}", e) + } + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt new file mode 100644 index 000000000..5b947ffed --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt @@ -0,0 +1,115 @@ +package com.swmansion.audioapi.system.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.swmansion.audioapi.R +import java.lang.ref.WeakReference + +/** + * This serves as a reference implementation and starting point for more complex notifications. + */ +class SimpleNotification( + private val reactContext: WeakReference, + private val notificationId: Int = DEFAULT_NOTIFICATION_ID, +) : BaseNotification { + companion object { + const val DEFAULT_NOTIFICATION_ID = 200 + const val CHANNEL_ID = "react-native-audio-api-simple" + const val CHANNEL_NAME = "Simple Notifications" + } + + private var title: String = "Audio Playing" + private var text: String = "" + + init { + createNotificationChannel() + } + + override fun init(options: ReadableMap?): Notification { + // Parse options from JavaScript + if (options != null) { + if (options.hasKey("title")) { + title = options.getString("title") ?: "Audio Playing" + } + if (options.hasKey("text")) { + text = options.getString("text") ?: "" + } + } + + return buildNotification() + } + + override fun reset() { + // Reset to default values + title = "Audio Playing" + text = "" + } + + override fun getNotificationId(): Int = notificationId + + override fun getChannelId(): String = CHANNEL_ID + + private fun buildNotification(): Notification { + val context = reactContext.get() ?: throw IllegalStateException("React context is null") + + // Use a system icon that's guaranteed to exist + val icon = android.R.drawable.ic_dialog_info + + val builder = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(icon) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(false) + .setAutoCancel(false) + + // Add content intent to open the app when notification is tapped + val packageName = context.packageName + val openAppIntent = context.packageManager?.getLaunchIntentForPackage(packageName) + if (openAppIntent != null) { + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + builder.setContentIntent(pendingIntent) + } + + return builder.build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val context = reactContext.get() ?: return + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val channel = + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Simple notification channel for audio playback" + setShowBadge(true) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + enableLights(true) + enableVibration(false) + } + + notificationManager.createNotificationChannel(channel) + } + } +} diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts index 21fa17a88..a07d1cb97 100644 --- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts +++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts @@ -32,9 +32,30 @@ interface Spec extends TurboModule { // Permissions requestRecordingPermissions(): Promise; checkRecordingPermissions(): Promise; + requestNotificationPermissions(): Promise; + checkNotificationPermissions(): Promise; // Audio devices getDevicesInfo(): Promise; + + // New notification system + registerNotification( + type: string, + key: string + ): Promise<{ success: boolean; error?: string }>; + showNotification( + key: string, + options: { [key: string]: string | boolean | number | undefined } + ): Promise<{ success: boolean; error?: string }>; + updateNotification( + key: string, + options: { [key: string]: string | boolean | number | undefined } + ): Promise<{ success: boolean; error?: string }>; + hideNotification(key: string): Promise<{ success: boolean; error?: string }>; + unregisterNotification( + key: string + ): Promise<{ success: boolean; error?: string }>; + isNotificationActive(key: string): Promise; } const NativeAudioAPIModule = TurboModuleRegistry.get('AudioAPIModule'); diff --git a/packages/react-native-audio-api/src/system/AudioManager.ts b/packages/react-native-audio-api/src/system/AudioManager.ts index 46a2a2b9f..22751ec09 100644 --- a/packages/react-native-audio-api/src/system/AudioManager.ts +++ b/packages/react-native-audio-api/src/system/AudioManager.ts @@ -101,6 +101,14 @@ class AudioManager { return NativeAudioAPIModule!.checkRecordingPermissions(); } + async requestNotificationPermissions(): Promise { + return NativeAudioAPIModule!.requestNotificationPermissions(); + } + + async checkNotificationPermissions(): Promise { + return NativeAudioAPIModule!.checkNotificationPermissions(); + } + async getDevicesInfo(): Promise { return NativeAudioAPIModule!.getDevicesInfo(); } From 3cbbb8ef939c8b16685dfd51658222e6c35495ca Mon Sep 17 00:00:00 2001 From: poneciak Date: Tue, 11 Nov 2025 20:22:48 +0100 Subject: [PATCH 02/19] feat: playback notification in new system --- .../PlaybackNotification.tsx | 277 +++++++++ apps/common-app/src/examples/index.ts | 8 + .../audioapi/system/MediaSessionManager.kt | 21 + .../system/notification/BaseNotification.kt | 9 + .../ForegroundNotificationService.kt | 23 +- .../notification/NotificationRegistry.kt | 28 +- .../notification/PlaybackNotification.kt | 527 ++++++++++++++++++ .../PlaybackNotificationReceiver.kt | 30 + .../system/notification/SimpleNotification.kt | 5 + packages/react-native-audio-api/src/api.ts | 13 + .../src/events/types.ts | 21 +- .../src/system/index.ts | 1 + .../PlaybackNotificationManager.ts | 186 +++++++ .../notification/SimpleNotificationManager.ts | 158 ++++++ .../src/system/notification/index.ts | 3 + .../src/system/notification/types.ts | 70 +++ 16 files changed, 1360 insertions(+), 20 deletions(-) create mode 100644 apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt create mode 100644 packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts create mode 100644 packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts create mode 100644 packages/react-native-audio-api/src/system/notification/index.ts create mode 100644 packages/react-native-audio-api/src/system/notification/types.ts diff --git a/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx b/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx new file mode 100644 index 000000000..d59830f99 --- /dev/null +++ b/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx @@ -0,0 +1,277 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +import { PlaybackNotificationManager, AudioManager } from 'react-native-audio-api'; +import type { PermissionStatus } from 'react-native-audio-api'; +import { Container } from '../../components'; +import { colors } from '../../styles'; + +export const PlaybackNotificationExample: React.FC = () => { + const [permissionStatus, setPermissionStatus] = useState('Undetermined'); + const [isRegistered, setIsRegistered] = useState(false); + const [isShown, setIsShown] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + + useEffect(() => { + checkPermissions(); + + // Add event listeners for notification actions + const playListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPlay', + async () => { + console.log('Notification: Play button pressed'); + setIsPlaying(true); + // Update notification to reflect playing state + await PlaybackNotificationManager.update({ state: 'playing' }); + } + ); + + const pauseListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPause', + async () => { + console.log('Notification: Pause button pressed'); + setIsPlaying(false); + // Update notification to reflect paused state + await PlaybackNotificationManager.update({ state: 'paused' }); + } + ); + + const nextListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationNext', + () => { + console.log('Notification: Next button pressed'); + } + ); + + const previousListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPrevious', + () => { + console.log('Notification: Previous button pressed'); + } + ); + + const skipForwardListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationSkipForward', + (event) => { + console.log(`Notification: Skip forward ${event.value}s`); + } + ); + + const skipBackwardListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationSkipBackward', + (event) => { + console.log(`Notification: Skip backward ${event.value}s`); + } + ); + + const dismissListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationDismissed', + () => { + console.log('Notification: Dismissed by user'); + setIsShown(false); + setIsPlaying(false); + } + ); + + return () => { + playListener.remove(); + pauseListener.remove(); + nextListener.remove(); + previousListener.remove(); + skipForwardListener.remove(); + skipBackwardListener.remove(); + dismissListener.remove(); + }; + }, []); + + const checkPermissions = async () => { + const status = await AudioManager.checkNotificationPermissions(); + setPermissionStatus(status); + }; + + const requestPermissions = async () => { + const status = await AudioManager.requestNotificationPermissions(); + setPermissionStatus(status); + }; + + const handleRegister = async () => { + try { + await PlaybackNotificationManager.register(); + setIsRegistered(true); + console.log('Playback notification registered'); + } catch (error) { + console.error('Failed to register:', error); + } + }; + + const handleShow = async () => { + try { + await PlaybackNotificationManager.show({ + title: 'My Audio Track', + artist: 'Artist Name', + album: 'Album Name', + state: 'paused', + duration: 180, + elapsedTime: 0, + }); + setIsShown(true); + setIsPlaying(false); + console.log('Playback notification shown'); + } catch (error) { + console.error('Failed to show:', error); + } + }; + + const handlePlay = async () => { + try { + await PlaybackNotificationManager.update({ + state: 'playing', + elapsedTime: 0, + }); + setIsPlaying(true); + console.log('Playing'); + } catch (error) { + console.error('Failed to play:', error); + } + }; + + const handlePause = async () => { + try { + await PlaybackNotificationManager.update({ + state: 'paused', + }); + setIsPlaying(false); + console.log('Paused'); + } catch (error) { + console.error('Failed to pause:', error); + } + }; + + const handleUpdateMetadata = async () => { + try { + await PlaybackNotificationManager.update({ + title: 'Updated Track', + artist: 'New Artist', + elapsedTime: 45, + }); + console.log('Metadata updated'); + } catch (error) { + console.error('Failed to update:', error); + } + }; + + const handleHide = async () => { + try { + await PlaybackNotificationManager.hide(); + setIsShown(false); + setIsPlaying(false); + console.log('Playback notification hidden'); + } catch (error) { + console.error('Failed to hide:', error); + } + }; + + const handleUnregister = async () => { + try { + await PlaybackNotificationManager.unregister(); + setIsRegistered(false); + setIsShown(false); + setIsPlaying(false); + console.log('Playback notification unregistered'); + } catch (error) { + console.error('Failed to unregister:', error); + } + }; + + const renderButton = ( + title: string, + onPress: () => void, + disabled: boolean = false + ) => ( + + {title} + + ); + + return ( + + + Playback Notification Test + + + Permission + Status: {permissionStatus} + {renderButton('Request Permission', requestPermissions, permissionStatus === 'Granted')} + + + + Lifecycle + {renderButton('Register', handleRegister, isRegistered)} + {renderButton('Show', handleShow, !isRegistered || isShown)} + {renderButton('Hide', handleHide, !isShown)} + {renderButton('Unregister', handleUnregister, !isRegistered)} + + + + Playback Controls + State: {isPlaying ? 'Playing' : 'Paused'} + {renderButton('Play', handlePlay, !isShown || isPlaying)} + {renderButton('Pause', handlePause, !isShown || !isPlaying)} + {renderButton('Update Metadata', handleUpdateMetadata, !isShown)} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + color: colors.white, + }, + section: { + marginBottom: 24, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + color: colors.white, + }, + status: { + fontSize: 14, + color: colors.gray, + marginBottom: 8, + }, + button: { + backgroundColor: colors.main, + padding: 12, + borderRadius: 8, + alignItems: 'center', + marginTop: 8, + }, + buttonDisabled: { + backgroundColor: colors.border, + }, + buttonText: { + color: colors.white, + fontSize: 16, + fontWeight: '500', + }, + info: { + fontSize: 12, + color: colors.gray, + fontStyle: 'italic', + }, +}); diff --git a/apps/common-app/src/examples/index.ts b/apps/common-app/src/examples/index.ts index 98eab9683..d0744d4c7 100644 --- a/apps/common-app/src/examples/index.ts +++ b/apps/common-app/src/examples/index.ts @@ -11,6 +11,7 @@ import Record from './Record/Record'; import PlaybackSpeed from './PlaybackSpeed/PlaybackSpeed'; import Worklets from './Worklets/Worklets'; import Streaming from './Streaming/Streaming'; +import { PlaybackNotificationExample } from './PlaybackNotification/PlaybackNotification'; type NavigationParamList = { Oscillator: undefined; @@ -25,6 +26,7 @@ type NavigationParamList = { Record: undefined; Worklets: undefined; Streamer: undefined; + PlaybackNotification: undefined; }; export type ExampleKey = keyof NavigationParamList; @@ -103,5 +105,11 @@ export const Examples: Example[] = [ title: 'Streamer', subtitle: 'Stream audio from a URL', screen: Streaming, + }, + { + key: 'PlaybackNotification', + title: 'Playback Notification', + subtitle: 'Media playback notification with controls', + screen: PlaybackNotificationExample, } ] as const; 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 3eece0162..286f86e13 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 @@ -25,6 +25,8 @@ import com.swmansion.audioapi.core.NativeAudioRecorder import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDING_REQUEST_CODE import com.swmansion.audioapi.system.notification.NotificationRegistry import com.swmansion.audioapi.system.notification.SimpleNotification +import com.swmansion.audioapi.system.notification.PlaybackNotification +import com.swmansion.audioapi.system.notification.PlaybackNotificationReceiver import java.lang.ref.WeakReference import java.util.UUID @@ -41,6 +43,7 @@ object MediaSessionManager { private lateinit var audioFocusListener: AudioFocusListener private lateinit var volumeChangeListener: VolumeChangeListener private lateinit var mediaReceiver: MediaReceiver + private lateinit var playbackNotificationReceiver: PlaybackNotificationReceiver // New notification system private lateinit var notificationRegistry: NotificationRegistry @@ -69,6 +72,10 @@ object MediaSessionManager { MediaReceiver(this.reactContext, WeakReference(this.mediaSession), WeakReference(this.mediaNotificationManager), this.audioAPIModule) this.mediaSession.setCallback(MediaSessionCallback(this.audioAPIModule, WeakReference(this.mediaNotificationManager))) + // Set up PlaybackNotificationReceiver + PlaybackNotificationReceiver.setAudioAPIModule(audioAPIModule.get()) + this.playbackNotificationReceiver = PlaybackNotificationReceiver() + val filter = IntentFilter() filter.addAction(MediaNotificationManager.REMOVE_NOTIFICATION) filter.addAction(MediaNotificationManager.MEDIA_BUTTON) @@ -86,6 +93,19 @@ object MediaSessionManager { ) } + // Register PlaybackNotificationReceiver separately + val playbackFilter = IntentFilter(PlaybackNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.reactContext.get()!!.registerReceiver(playbackNotificationReceiver, playbackFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + ContextCompat.registerReceiver( + this.reactContext.get()!!, + playbackNotificationReceiver, + playbackFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + this.audioFocusListener = AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule, WeakReference(this.lockScreenManager)) this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule) @@ -319,6 +339,7 @@ object MediaSessionManager { val notification = when (type) { "simple" -> SimpleNotification(reactContext) + "playback" -> PlaybackNotification(reactContext, audioAPIModule, 100, "audio_playback") else -> throw IllegalArgumentException("Unknown notification type: $type") } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt index d43a7389d..136443f43 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt @@ -18,6 +18,15 @@ interface BaseNotification { */ fun init(options: ReadableMap?): Notification + /** + * Update the notification with new options. + * This method should rebuild the notification with updated data. + * + * @param options New configuration options from JavaScript side + * @return The updated Notification ready to be shown + */ + fun update(options: ReadableMap?): Notification + /** * Reset the notification to its initial state. * This should clear any stored data and stop any ongoing processes. diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt index c83b8cb82..0dd28f852 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt @@ -59,8 +59,15 @@ class ForegroundNotificationService : Service() { synchronized(serviceLock) { if (!isServiceStarted) { try { - // TODO retrieve actual notification from NotificationRegistry - val notification = createDummyNotification(notificationId) + // Retrieve actual notification from NotificationRegistry + val notification = NotificationRegistry.getBuiltNotification(notificationId) + + if (notification == null) { + val errorMsg = "Notification with ID $notificationId not found in registry. " + + "Make sure to call showNotification() before starting foreground service." + Log.e(TAG, errorMsg) + throw IllegalStateException(errorMsg) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( @@ -76,6 +83,7 @@ class ForegroundNotificationService : Service() { Log.d(TAG, "Foreground service started with notification: $notificationKey") } catch (e: Exception) { Log.e(TAG, "Error starting foreground service: ${e.message}", e) + throw e } } } @@ -105,15 +113,4 @@ class ForegroundNotificationService : Service() { } super.onDestroy() } - - private fun createDummyNotification(notificationId: Int): Notification { - // TODO This is a placeholder - in a real implementation, we'd need to pass - // the actual notification or retrieve it from a shared location - return Notification - .Builder(this, SimpleNotification.CHANNEL_ID) - .setContentTitle("Audio Service") - .setContentText("Running in background") - .setSmallIcon(android.R.drawable.ic_media_play) - .build() - } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt index f1704b993..3b99075c9 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -13,13 +13,19 @@ import java.lang.ref.WeakReference /** * Central notification registry that manages multiple notification instances. - * Provides a clean API for creating, updating, and removing notifications. */ class NotificationRegistry( private val reactContext: WeakReference, ) { companion object { private const val TAG = "NotificationRegistry" + + // Store last built notifications for foreground service access + private val builtNotifications = mutableMapOf() + + fun getBuiltNotification(notificationId: Int): Notification? { + return builtNotifications[notificationId] + } } private val notifications = mutableMapOf() @@ -80,7 +86,19 @@ class NotificationRegistry( return } - showNotification(key, options) + val notification = notifications[key] + if (notification == null) { + Log.w(TAG, "Notification not found: $key") + return + } + + try { + val builtNotification = notification.update(options) + displayNotification(notification.getNotificationId(), builtNotification) + Log.d(TAG, "Updated notification: $key") + } catch (e: Exception) { + Log.e(TAG, "Error updating notification $key: ${e.message}", e) + } } /** @@ -136,6 +154,7 @@ class NotificationRegistry( } notifications.clear() activeNotifications.clear() + builtNotifications.clear() Log.d(TAG, "Cleaned up all notifications") } @@ -146,6 +165,9 @@ class NotificationRegistry( val context = reactContext.get() ?: throw IllegalStateException("React context is null") Log.d(TAG, "Displaying notification with ID: $id") try { + // Store notification for foreground service access + builtNotifications[id] = notification + NotificationManagerCompat.from(context).notify(id, notification) Log.d(TAG, "Notification posted successfully with ID: $id") } catch (e: Exception) { @@ -156,6 +178,8 @@ class NotificationRegistry( private fun cancelNotification(id: Int) { val context = reactContext.get() ?: return NotificationManagerCompat.from(context).cancel(id) + // Clean up stored notification + builtNotifications.remove(id) } /** diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt new file mode 100644 index 000000000..e6bdbb0eb --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt @@ -0,0 +1,527 @@ +package com.swmansion.audioapi.system.notification + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.provider.ContactsContract +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import android.view.KeyEvent +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.swmansion.audioapi.AudioAPIModule +import java.io.IOException +import java.lang.ref.WeakReference +import java.net.URL + +/** + * PlaybackNotification + * + * This notification: + * - Shows media metadata (title, artist, album, artwork) + * - Supports playback controls (play, pause, next, previous, skip) + * - Integrates with Android MediaSession for lock screen controls + * - Is persistent and cannot be swiped away when playing + * - Notifies its dismissal via PlaybackNotificationReceiver + */ +class PlaybackNotification( + private val reactContext: WeakReference, + private val audioAPIModule: WeakReference, + private val notificationId: Int, + private val channelId: String, +) : BaseNotification { + companion object { + private const val TAG = "PlaybackNotification" + const val MEDIA_BUTTON = "playback_notification_media_button" + const val PACKAGE_NAME = "com.swmansion.audioapi.playback" + } + + private var mediaSession: MediaSessionCompat? = null + private var notificationBuilder: NotificationCompat.Builder? = null + private var playbackStateBuilder: PlaybackStateCompat.Builder = PlaybackStateCompat.Builder() + + private var enabledControls: Long = 0 + private var isPlaying: Boolean = false + + // Metadata + private var title: String? = null + private var artist: String? = null + private var album: String? = null + private var artwork: Bitmap? = null + private var duration: Long = 0L + private var elapsedTime: Long = 0L + private var speed: Float = 1.0F + + // Actions + private var playAction: NotificationCompat.Action? = null + private var pauseAction: NotificationCompat.Action? = null + private var nextAction: NotificationCompat.Action? = null + private var previousAction: NotificationCompat.Action? = null + private var skipForwardAction: NotificationCompat.Action? = null + private var skipBackwardAction: NotificationCompat.Action? = null + + private var artworkThread: Thread? = null + + override fun init(params: ReadableMap?): Notification { + val context = reactContext.get() ?: throw IllegalStateException("React context is null") + + // Create notification channel first + createNotificationChannel() + + // Create MediaSession + mediaSession = MediaSessionCompat(context, "PlaybackNotification") + mediaSession?.isActive = true + + // Set up media session callbacks + mediaSession?.setCallback( + object : MediaSessionCompat.Callback() { + override fun onPlay() { + Log.d(TAG, "MediaSession: onPlay") + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("playbackNotificationPlay", mapOf()) + } + + override fun onPause() { + Log.d(TAG, "MediaSession: onPause") + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("playbackNotificationPause", mapOf()) + } + + override fun onSkipToNext() { + Log.d(TAG, "MediaSession: onSkipToNext") + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("playbackNotificationNext", mapOf()) + } + + override fun onSkipToPrevious() { + Log.d(TAG, "MediaSession: onSkipToPrevious") + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("playbackNotificationPrevious", mapOf()) + } + + override fun onFastForward() { + Log.d(TAG, "MediaSession: onFastForward") + val body = HashMap().apply { put("value", 15) } + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("playbackNotificationSkipForward", body) + } + + override fun onRewind() { + Log.d(TAG, "MediaSession: onRewind") + val body = HashMap().apply { put("value", 15) } + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("playbackNotificationSkipBackward", body) + } + }, + ) + + // Create notification builder + notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(android.R.drawable.ic_media_play) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(true) // Make it persistent (can't swipe away) + + // Set content intent to open app + val packageName = context.packageName + val openAppIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (openAppIntent != null) { + val pendingIntent = PendingIntent.getActivity( + context, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + notificationBuilder?.setContentIntent(pendingIntent) + } + + // Set delete intent to handle dismissal + val deleteIntent = Intent(PlaybackNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) + deleteIntent.setPackage(context.packageName) + val deletePendingIntent = PendingIntent.getBroadcast( + context, + notificationId, + deleteIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + notificationBuilder?.setDeleteIntent(deletePendingIntent) + + // Enable default controls + enableControl("play", true) + enableControl("pause", true) + enableControl("next", true) + enableControl("previous", true) + + updateMediaStyle() + updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) + + // Apply initial params if provided + if (params != null) { + update(params) + } + + return buildNotification() + } + + override fun reset() { + // Interrupt artwork loading if in progress + artworkThread?.interrupt() + artworkThread = null + + // Reset metadata + title = null + artist = null + album = null + artwork = null + duration = 0L + elapsedTime = 0L + speed = 1.0F + isPlaying = false + + // Reset media session + val emptyMetadata = MediaMetadataCompat.Builder().build() + mediaSession?.setMetadata(emptyMetadata) + + playbackStateBuilder.setState(PlaybackStateCompat.STATE_NONE, 0, 0f) + playbackStateBuilder.setActions(enabledControls) + mediaSession?.setPlaybackState(playbackStateBuilder.build()) + mediaSession?.isActive = false + mediaSession?.release() + mediaSession = null + } + + override fun getNotificationId(): Int = notificationId + + override fun getChannelId(): String = channelId + + override fun update(options: ReadableMap?): Notification { + if (options == null) { + return buildNotification() + } + + // Handle control enable/disable + if (options.hasKey("control") && options.hasKey("enabled")) { + val control = options.getString("control") + val enabled = options.getBoolean("enabled") + if (control != null) { + enableControl(control, enabled) + } + return buildNotification() + } + + // Update metadata + if (options.hasKey("title")) { + title = options.getString("title") + } + + if (options.hasKey("artist")) { + artist = options.getString("artist") + } + + if (options.hasKey("album")) { + album = options.getString("album") + } + + if (options.hasKey("duration")) { + duration = (options.getDouble("duration") * 1000).toLong() + } + + if (options.hasKey("elapsedTime")) { + elapsedTime = (options.getDouble("elapsedTime") * 1000).toLong() + } + + if (options.hasKey("speed")) { + speed = options.getDouble("speed").toFloat() + } + + // Update playback state + if (options.hasKey("state")) { + when (options.getString("state")) { + "playing" -> { + isPlaying = true + updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) + } + "paused" -> { + isPlaying = false + updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) + } + } + } + + // Build MediaMetadata + val metadataBuilder = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) + + // Update notification builder + notificationBuilder?.setContentTitle(title) + notificationBuilder?.setContentText(artist) + + // Handle artwork + if (options.hasKey("artwork")) { + artworkThread?.interrupt() + + val artworkUrl: String? + val isLocal: Boolean + + if (options.getType("artwork") == ReadableType.Map) { + artworkUrl = options.getMap("artwork")?.getString("uri") + isLocal = true + } else { + artworkUrl = options.getString("artwork") + isLocal = false + } + + if (artworkUrl != null) { + artworkThread = Thread { + try { + val bitmap = loadArtwork(artworkUrl, isLocal) + if (bitmap != null) { + artwork = bitmap + metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + notificationBuilder?.setLargeIcon(bitmap) + } + artworkThread = null + } catch (e: Exception) { + Log.e(TAG, "Error loading artwork: ${e.message}", e) + } + } + artworkThread?.start() + } + } + + mediaSession?.setMetadata(metadataBuilder.build()) + mediaSession?.isActive = true + + return buildNotification() + } + + private fun buildNotification(): Notification { + return notificationBuilder?.build() + ?: throw IllegalStateException("Notification not initialized. Call init() first.") + } + + /** + * Enable or disable a specific control action. + */ + private fun enableControl(name: String, enabled: Boolean) { + val controlValue = when (name) { + "play" -> PlaybackStateCompat.ACTION_PLAY + "pause" -> PlaybackStateCompat.ACTION_PAUSE + "next" -> PlaybackStateCompat.ACTION_SKIP_TO_NEXT + "previous" -> PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + "skipForward" -> PlaybackStateCompat.ACTION_FAST_FORWARD + "skipBackward" -> PlaybackStateCompat.ACTION_REWIND + else -> 0L + } + + if (controlValue == 0L) return + + enabledControls = if (enabled) { + enabledControls or controlValue + } else { + enabledControls and controlValue.inv() + } + + // Update actions + updateActions() + updateMediaStyle() + + // Update playback state with new controls + playbackStateBuilder.setActions(enabledControls) + mediaSession?.setPlaybackState(playbackStateBuilder.build()) + } + + private fun updateActions() { + val context = reactContext.get() ?: return + val packageName = context.packageName + + playAction = createAction( + "play", + "Play", + android.R.drawable.ic_media_play, + PlaybackStateCompat.ACTION_PLAY, + ) + + pauseAction = createAction( + "pause", + "Pause", + android.R.drawable.ic_media_pause, + PlaybackStateCompat.ACTION_PAUSE, + ) + + nextAction = createAction( + "next", + "Next", + android.R.drawable.ic_media_next, + PlaybackStateCompat.ACTION_SKIP_TO_NEXT, + ) + + previousAction = createAction( + "previous", + "Previous", + android.R.drawable.ic_media_previous, + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS, + ) + + skipForwardAction = createAction( + "skip_forward", + "Skip Forward", + android.R.drawable.ic_media_ff, + PlaybackStateCompat.ACTION_FAST_FORWARD, + ) + + skipBackwardAction = createAction( + "skip_backward", + "Skip Backward", + android.R.drawable.ic_media_rew, + PlaybackStateCompat.ACTION_REWIND, + ) + } + + private fun createAction( + name: String, + title: String, + icon: Int, + action: Long, + ): NotificationCompat.Action? { + val context = reactContext.get() ?: return null + + if ((enabledControls and action) == 0L) { + return null + } + + val keyCode = PlaybackStateCompat.toKeyCode(action) + val intent = Intent(MEDIA_BUTTON) + intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + intent.putExtra(ContactsContract.Directory.PACKAGE_NAME, context.packageName) + + val pendingIntent = PendingIntent.getBroadcast( + context, + keyCode, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + return NotificationCompat.Action(icon, title, pendingIntent) + } + + private fun updatePlaybackState(state: Int) { + isPlaying = state == PlaybackStateCompat.STATE_PLAYING + + playbackStateBuilder.setState(state, elapsedTime, speed) + playbackStateBuilder.setActions(enabledControls) + mediaSession?.setPlaybackState(playbackStateBuilder.build()) + + // Update ongoing state - only persistent when playing + notificationBuilder?.setOngoing(isPlaying) + } + + private fun updateMediaStyle() { + val style = MediaStyle() + style.setMediaSession(mediaSession?.sessionToken) + + // Clear existing actions + notificationBuilder?.clearActions() + + // Add actions in order based on enabled controls + val compactActions = mutableListOf() + var actionIndex = 0 + + if (previousAction != null) { + notificationBuilder?.addAction(previousAction) + actionIndex++ + } + + if (skipBackwardAction != null) { + notificationBuilder?.addAction(skipBackwardAction) + actionIndex++ + } + + if (playAction != null && !isPlaying) { + notificationBuilder?.addAction(playAction) + compactActions.add(actionIndex) + actionIndex++ + } + + if (pauseAction != null && isPlaying) { + notificationBuilder?.addAction(pauseAction) + compactActions.add(actionIndex) + actionIndex++ + } + + if (skipForwardAction != null) { + notificationBuilder?.addAction(skipForwardAction) + actionIndex++ + } + + if (nextAction != null) { + notificationBuilder?.addAction(nextAction) + actionIndex++ + } + + // Show up to 3 actions in compact view + style.setShowActionsInCompactView(*compactActions.take(3).toIntArray()) + notificationBuilder?.setStyle(style) + } + + private fun loadArtwork(url: String, isLocal: Boolean): Bitmap? { + val context = reactContext.get() ?: return null + + return try { + if (isLocal && !url.startsWith("http")) { + // Load local resource + val helper = com.facebook.react.views.imagehelper.ResourceDrawableIdHelper.getInstance() + val drawable = helper.getResourceDrawable(context, url) + + if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + BitmapFactory.decodeFile(url) + } + } else { + // Load from URL + val connection = URL(url).openConnection() + connection.connect() + val inputStream = connection.getInputStream() + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream.close() + bitmap + } + } catch (e: IOException) { + Log.e(TAG, "Failed to load artwork: ${e.message}", e) + null + } catch (e: Exception) { + Log.e(TAG, "Error loading artwork: ${e.message}", e) + null + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val context = reactContext.get() ?: return + + val channel = android.app.NotificationChannel( + channelId, + "Audio Playback", + android.app.NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Media playback controls and information" + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + notificationManager.createNotificationChannel(channel) + + Log.d(TAG, "Notification channel created: $channelId") + } + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt new file mode 100644 index 000000000..618d15472 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt @@ -0,0 +1,30 @@ +package com.swmansion.audioapi.system.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.swmansion.audioapi.AudioAPIModule + +/** + * Broadcast receiver for handling playback notification dismissal. + */ +class PlaybackNotificationReceiver : BroadcastReceiver() { + companion object { + const val ACTION_NOTIFICATION_DISMISSED = "com.swmansion.audioapi.PLAYBACK_NOTIFICATION_DISMISSED" + private const val TAG = "PlaybackNotificationReceiver" + + private var audioAPIModule: AudioAPIModule? = null + + fun setAudioAPIModule(module: AudioAPIModule?) { + audioAPIModule = module + } + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_NOTIFICATION_DISMISSED) { + Log.d(TAG, "Notification dismissed by user") + audioAPIModule?.invokeHandlerWithEventNameAndEventBody("playbackNotificationDismissed", mapOf()) + } + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt index 5b947ffed..da65e80ef 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt @@ -47,6 +47,11 @@ class SimpleNotification( return buildNotification() } + override fun update(options: ReadableMap?): Notification { + // Update works the same as init for simple notifications + return init(options) + } + override fun reset() { // Reset to default values title = "Audio Playing" diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 21586a4f2..71acdf06c 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -80,6 +80,11 @@ export { default as useSystemVolume } from './hooks/useSystemVolume'; export { decodeAudioData, decodePCMInBase64 } from './core/AudioDecoder'; export { default as changePlaybackSpeed } from './core/AudioStretcher'; +// Notification System +export { + PlaybackNotificationManager, +} from './system/notification'; + export { OscillatorType, BiquadFilterType, @@ -101,6 +106,14 @@ export { PermissionStatus, } from './system/types'; +export { + NotificationManager, + PlaybackNotificationInfo, + PlaybackControlName, + PlaybackNotificationEventName, + SimpleNotificationOptions, +} from './system/notification'; + export { IndexSizeError, InvalidAccessError, diff --git a/packages/react-native-audio-api/src/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index 93556c61f..67f713aec 100644 --- a/packages/react-native-audio-api/src/events/types.ts +++ b/packages/react-native-audio-api/src/events/types.ts @@ -38,11 +38,22 @@ interface RemoteCommandEvents { remoteChangePlaybackPosition: EventTypeWithValue; } -type SystemEvents = RemoteCommandEvents & { - volumeChange: EventTypeWithValue; - interruption: OnInterruptionEventType; - routeChange: OnRouteChangeEventType; -}; +interface PlaybackNotificationEvents { + playbackNotificationPlay: EventEmptyType; + playbackNotificationPause: EventEmptyType; + playbackNotificationNext: EventEmptyType; + playbackNotificationPrevious: EventEmptyType; + playbackNotificationSkipForward: EventTypeWithValue; + playbackNotificationSkipBackward: EventTypeWithValue; + playbackNotificationDismissed: EventEmptyType; +} + +type SystemEvents = RemoteCommandEvents & + PlaybackNotificationEvents & { + volumeChange: EventTypeWithValue; + interruption: OnInterruptionEventType; + routeChange: OnRouteChangeEventType; + }; export interface OnEndedEventType extends EventEmptyType { bufferId: string | undefined; diff --git a/packages/react-native-audio-api/src/system/index.ts b/packages/react-native-audio-api/src/system/index.ts index 91488dcdf..ea02d73f8 100644 --- a/packages/react-native-audio-api/src/system/index.ts +++ b/packages/react-native-audio-api/src/system/index.ts @@ -1 +1,2 @@ export { default } from './AudioManager'; +export * from './notification'; diff --git a/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts new file mode 100644 index 000000000..d6085cbce --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts @@ -0,0 +1,186 @@ +import { NativeAudioAPIModule } from '../../specs'; +import { AudioEventEmitter, AudioEventSubscription } from '../../events'; +import type { SystemEventCallback } from '../../events/types'; +import type { + NotificationManager, + PlaybackNotificationInfo, + PlaybackControlName, + PlaybackNotificationEventName, +} from './types'; + +/// Manager for media playback notifications with controls and MediaSession integration. +class PlaybackNotificationManager + implements NotificationManager +{ + private notificationKey = 'playback'; + private isRegistered = false; + private isShown = false; + private audioEventEmitter: AudioEventEmitter; + + constructor() { + this.audioEventEmitter = new AudioEventEmitter(global.AudioEventEmitter); + } + + /// Register the playback notification (must be called before showing). + async register(): Promise { + if (this.isRegistered) { + console.warn('PlaybackNotification is already registered'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.registerNotification( + 'playback', + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isRegistered = true; + } + + /// Show the notification with initial metadata. + async show(info: PlaybackNotificationInfo): Promise { + if (!this.isRegistered) { + throw new Error( + 'PlaybackNotification must be registered before showing. Call register() first.' + ); + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.showNotification( + this.notificationKey, + info as Record + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isShown = true; + } + + /// Update the notification with new metadata or state. + async update(info: PlaybackNotificationInfo): Promise { + if (!this.isShown) { + console.warn('PlaybackNotification is not shown. Call show() first.'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.updateNotification( + this.notificationKey, + info as Record + ); + + if (result.error) { + throw new Error(result.error); + } + } + + /// Hide the notification (can be shown again later). + async hide(): Promise { + if (!this.isShown) { + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.hideNotification( + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isShown = false; + } + + /// Unregister the notification (must register again to use). + async unregister(): Promise { + if (!this.isRegistered) { + return; + } + + if (this.isShown) { + await this.hide(); + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.unregisterNotification( + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isRegistered = false; + } + + /// Enable or disable a specific playback control. + async enableControl( + control: PlaybackControlName, + enabled: boolean + ): Promise { + if (!this.isRegistered) { + console.warn('PlaybackNotification is not registered'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const params = { control, enabled }; + const result = await NativeAudioAPIModule.updateNotification( + this.notificationKey, + params as Record + ); + + if (result.error) { + throw new Error(result.error); + } + } + + /// Check if the notification is currently active. + async isActive(): Promise { + if (!NativeAudioAPIModule) { + return false; + } + + return await NativeAudioAPIModule.isNotificationActive(this.notificationKey); + } + + /// Add an event listener for notification actions. + addEventListener( + eventName: T, + callback: SystemEventCallback + ): AudioEventSubscription { + return this.audioEventEmitter.addAudioEventListener(eventName, callback); + } + + /** Remove an event listener. */ + removeEventListener(subscription: AudioEventSubscription): void { + subscription.remove(); + } +} + +export default new PlaybackNotificationManager(); diff --git a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts new file mode 100644 index 000000000..5ca6745fc --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts @@ -0,0 +1,158 @@ +import { NativeAudioAPIModule } from '../../specs'; +import { AudioEventEmitter, AudioEventSubscription } from '../../events'; +import type { NotificationManager, SimpleNotificationOptions } from './types'; + +/// Simple notification manager for basic notifications with title and text. +/// Implements the generic NotificationManager interface. +class SimpleNotificationManager + implements NotificationManager +{ + private notificationKey = 'simple'; + private isRegistered = false; + private isShown = false; + private audioEventEmitter: AudioEventEmitter; + + constructor() { + this.audioEventEmitter = new AudioEventEmitter(global.AudioEventEmitter); + } + + /// Register the simple notification (must be called before showing). + async register(): Promise { + if (this.isRegistered) { + console.warn('SimpleNotification is already registered'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.registerNotification( + 'simple', + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isRegistered = true; + } + + /// Show the notification with initial options. + async show(options: SimpleNotificationOptions): Promise { + if (!this.isRegistered) { + throw new Error( + 'SimpleNotification must be registered before showing. Call register() first.' + ); + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.showNotification( + this.notificationKey, + options as Record + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isShown = true; + } + + /// Update the notification with new options. + async update(options: SimpleNotificationOptions): Promise { + if (!this.isShown) { + console.warn('SimpleNotification is not shown. Call show() first.'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.updateNotification( + this.notificationKey, + options as Record + ); + + if (result.error) { + throw new Error(result.error); + } + } + + /// Hide the notification (can be shown again later). + async hide(): Promise { + if (!this.isShown) { + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.hideNotification( + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isShown = false; + } + + /// Unregister the notification (must register again to use). + async unregister(): Promise { + if (!this.isRegistered) { + return; + } + + if (this.isShown) { + await this.hide(); + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.unregisterNotification( + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isRegistered = false; + } + + /// Check if the notification is currently active. + async isActive(): Promise { + if (!NativeAudioAPIModule) { + return false; + } + + return await NativeAudioAPIModule.isNotificationActive(this.notificationKey); + } + + /// Add an event listener (SimpleNotification doesn't emit events). + addEventListener( + _eventName: T, + _callback: (event: any) => void + ): AudioEventSubscription { + // SimpleNotification doesn't emit events, return a no-op subscription + console.warn('SimpleNotification does not support event listeners'); + return this.audioEventEmitter.addAudioEventListener('playbackNotificationPlay' as any, () => {}); + } + + /// Remove an event listener. + removeEventListener(subscription: AudioEventSubscription): void { + subscription.remove(); + } +} + +export default new SimpleNotificationManager(); diff --git a/packages/react-native-audio-api/src/system/notification/index.ts b/packages/react-native-audio-api/src/system/notification/index.ts new file mode 100644 index 000000000..4f937cc0d --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/index.ts @@ -0,0 +1,3 @@ +export { default as PlaybackNotificationManager } from './PlaybackNotificationManager'; +export { default as SimpleNotificationManager } from './SimpleNotificationManager'; +export * from './types'; diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts new file mode 100644 index 000000000..1a881fa30 --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -0,0 +1,70 @@ +import type { AudioEventSubscription } from '../../events'; + + +/// Generic notification manager interface that all notification managers should implement. +/// Provides a consistent API for managing notification lifecycle and events. +export interface NotificationManager { + /// Register the notification (must be called before showing). + register(): Promise; + + /// Show the notification with initial options. + show(options: TShowOptions): Promise; + + /// Update the notification with new options. + update(options: TUpdateOptions): Promise; + + /// Hide the notification (can be shown again later). + hide(): Promise; + + /// Unregister the notification (must register again to use). + unregister(): Promise; + + /// Check if the notification is currently active. + isActive(): Promise; + + /// Add an event listener for notification events. + addEventListener( + eventName: T, + callback: (event: any) => void + ): AudioEventSubscription; + + /// Remove an event listener. + removeEventListener(subscription: AudioEventSubscription): void; +} + +/// Metadata and state information for playback notifications. +export interface PlaybackNotificationInfo { + title?: string; + artist?: string; + album?: string; + artwork?: string | { uri: string }; + duration?: number; + elapsedTime?: number; + speed?: number; + state?: 'playing' | 'paused'; +} + +/// Available playback control actions. +export type PlaybackControlName = + | 'play' + | 'pause' + | 'next' + | 'previous' + | 'skipForward' + | 'skipBackward'; + +/// Event names for playback notification actions. +export type PlaybackNotificationEventName = + | 'playbackNotificationPlay' + | 'playbackNotificationPause' + | 'playbackNotificationNext' + | 'playbackNotificationPrevious' + | 'playbackNotificationSkipForward' + | 'playbackNotificationSkipBackward' + | 'playbackNotificationDismissed'; + +/// Options for a simple notification with title and text. +export interface SimpleNotificationOptions { + title?: string; + text?: string; +} From 11249a7d31e589ae3a5df5b031e43aafcbee46ae Mon Sep 17 00:00:00 2001 From: poneciak Date: Tue, 11 Nov 2025 20:25:46 +0100 Subject: [PATCH 03/19] chore: formatting --- .../audioapi/system/MediaSessionManager.kt | 2 +- .../ForegroundNotificationService.kt | 5 +- .../notification/NotificationRegistry.kt | 4 +- .../notification/PlaybackNotification.kt | 233 ++++++++++-------- .../PlaybackNotificationReceiver.kt | 5 +- .../ios/audioapi/ios/core/IOSAudioRecorder.h | 3 +- packages/react-native-audio-api/src/api.ts | 4 +- .../PlaybackNotificationManager.ts | 11 +- .../notification/SimpleNotificationManager.ts | 16 +- .../src/system/notification/types.ts | 7 +- 10 files changed, 168 insertions(+), 122 deletions(-) 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 286f86e13..1b9eaf4d7 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 @@ -24,9 +24,9 @@ import com.swmansion.audioapi.core.NativeAudioPlayer import com.swmansion.audioapi.core.NativeAudioRecorder import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDING_REQUEST_CODE import com.swmansion.audioapi.system.notification.NotificationRegistry -import com.swmansion.audioapi.system.notification.SimpleNotification import com.swmansion.audioapi.system.notification.PlaybackNotification import com.swmansion.audioapi.system.notification.PlaybackNotificationReceiver +import com.swmansion.audioapi.system.notification.SimpleNotification import java.lang.ref.WeakReference import java.util.UUID diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt index 0dd28f852..3cc6698e6 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt @@ -63,8 +63,9 @@ class ForegroundNotificationService : Service() { val notification = NotificationRegistry.getBuiltNotification(notificationId) if (notification == null) { - val errorMsg = "Notification with ID $notificationId not found in registry. " + - "Make sure to call showNotification() before starting foreground service." + val errorMsg = + "Notification with ID $notificationId not found in registry. " + + "Make sure to call showNotification() before starting foreground service." Log.e(TAG, errorMsg) throw IllegalStateException(errorMsg) } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt index 3b99075c9..23ab51010 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -23,9 +23,7 @@ class NotificationRegistry( // Store last built notifications for foreground service access private val builtNotifications = mutableMapOf() - fun getBuiltNotification(notificationId: Int): Notification? { - return builtNotifications[notificationId] - } + fun getBuiltNotification(notificationId: Int): Notification? = builtNotifications[notificationId] } private val notifications = mutableMapOf() diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt index e6bdbb0eb..f9c774106 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt @@ -120,34 +120,38 @@ class PlaybackNotification( ) // Create notification builder - notificationBuilder = NotificationCompat.Builder(context, channelId) - .setSmallIcon(android.R.drawable.ic_media_play) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOngoing(true) // Make it persistent (can't swipe away) + notificationBuilder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(android.R.drawable.ic_media_play) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(true) // Make it persistent (can't swipe away) // Set content intent to open app val packageName = context.packageName val openAppIntent = context.packageManager.getLaunchIntentForPackage(packageName) if (openAppIntent != null) { - val pendingIntent = PendingIntent.getActivity( - context, - 0, - openAppIntent, - PendingIntent.FLAG_IMMUTABLE, - ) + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE, + ) notificationBuilder?.setContentIntent(pendingIntent) } // Set delete intent to handle dismissal val deleteIntent = Intent(PlaybackNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) deleteIntent.setPackage(context.packageName) - val deletePendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - deleteIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) + val deletePendingIntent = + PendingIntent.getBroadcast( + context, + notificationId, + deleteIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) notificationBuilder?.setDeleteIntent(deletePendingIntent) // Enable default controls @@ -253,11 +257,13 @@ class PlaybackNotification( } // Build MediaMetadata - val metadataBuilder = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) + val metadataBuilder = + MediaMetadataCompat + .Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) // Update notification builder notificationBuilder?.setContentTitle(title) @@ -279,19 +285,20 @@ class PlaybackNotification( } if (artworkUrl != null) { - artworkThread = Thread { - try { - val bitmap = loadArtwork(artworkUrl, isLocal) - if (bitmap != null) { - artwork = bitmap - metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) - notificationBuilder?.setLargeIcon(bitmap) + artworkThread = + Thread { + try { + val bitmap = loadArtwork(artworkUrl, isLocal) + if (bitmap != null) { + artwork = bitmap + metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + notificationBuilder?.setLargeIcon(bitmap) + } + artworkThread = null + } catch (e: Exception) { + Log.e(TAG, "Error loading artwork: ${e.message}", e) } - artworkThread = null - } catch (e: Exception) { - Log.e(TAG, "Error loading artwork: ${e.message}", e) } - } artworkThread?.start() } } @@ -302,32 +309,36 @@ class PlaybackNotification( return buildNotification() } - private fun buildNotification(): Notification { - return notificationBuilder?.build() + private fun buildNotification(): Notification = + notificationBuilder?.build() ?: throw IllegalStateException("Notification not initialized. Call init() first.") - } /** * Enable or disable a specific control action. */ - private fun enableControl(name: String, enabled: Boolean) { - val controlValue = when (name) { - "play" -> PlaybackStateCompat.ACTION_PLAY - "pause" -> PlaybackStateCompat.ACTION_PAUSE - "next" -> PlaybackStateCompat.ACTION_SKIP_TO_NEXT - "previous" -> PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - "skipForward" -> PlaybackStateCompat.ACTION_FAST_FORWARD - "skipBackward" -> PlaybackStateCompat.ACTION_REWIND - else -> 0L - } + private fun enableControl( + name: String, + enabled: Boolean, + ) { + val controlValue = + when (name) { + "play" -> PlaybackStateCompat.ACTION_PLAY + "pause" -> PlaybackStateCompat.ACTION_PAUSE + "next" -> PlaybackStateCompat.ACTION_SKIP_TO_NEXT + "previous" -> PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + "skipForward" -> PlaybackStateCompat.ACTION_FAST_FORWARD + "skipBackward" -> PlaybackStateCompat.ACTION_REWIND + else -> 0L + } if (controlValue == 0L) return - enabledControls = if (enabled) { - enabledControls or controlValue - } else { - enabledControls and controlValue.inv() - } + enabledControls = + if (enabled) { + enabledControls or controlValue + } else { + enabledControls and controlValue.inv() + } // Update actions updateActions() @@ -342,47 +353,53 @@ class PlaybackNotification( val context = reactContext.get() ?: return val packageName = context.packageName - playAction = createAction( - "play", - "Play", - android.R.drawable.ic_media_play, - PlaybackStateCompat.ACTION_PLAY, - ) + playAction = + createAction( + "play", + "Play", + android.R.drawable.ic_media_play, + PlaybackStateCompat.ACTION_PLAY, + ) - pauseAction = createAction( - "pause", - "Pause", - android.R.drawable.ic_media_pause, - PlaybackStateCompat.ACTION_PAUSE, - ) + pauseAction = + createAction( + "pause", + "Pause", + android.R.drawable.ic_media_pause, + PlaybackStateCompat.ACTION_PAUSE, + ) - nextAction = createAction( - "next", - "Next", - android.R.drawable.ic_media_next, - PlaybackStateCompat.ACTION_SKIP_TO_NEXT, - ) + nextAction = + createAction( + "next", + "Next", + android.R.drawable.ic_media_next, + PlaybackStateCompat.ACTION_SKIP_TO_NEXT, + ) - previousAction = createAction( - "previous", - "Previous", - android.R.drawable.ic_media_previous, - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS, - ) + previousAction = + createAction( + "previous", + "Previous", + android.R.drawable.ic_media_previous, + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS, + ) - skipForwardAction = createAction( - "skip_forward", - "Skip Forward", - android.R.drawable.ic_media_ff, - PlaybackStateCompat.ACTION_FAST_FORWARD, - ) + skipForwardAction = + createAction( + "skip_forward", + "Skip Forward", + android.R.drawable.ic_media_ff, + PlaybackStateCompat.ACTION_FAST_FORWARD, + ) - skipBackwardAction = createAction( - "skip_backward", - "Skip Backward", - android.R.drawable.ic_media_rew, - PlaybackStateCompat.ACTION_REWIND, - ) + skipBackwardAction = + createAction( + "skip_backward", + "Skip Backward", + android.R.drawable.ic_media_rew, + PlaybackStateCompat.ACTION_REWIND, + ) } private fun createAction( @@ -402,12 +419,13 @@ class PlaybackNotification( intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) intent.putExtra(ContactsContract.Directory.PACKAGE_NAME, context.packageName) - val pendingIntent = PendingIntent.getBroadcast( - context, - keyCode, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) + val pendingIntent = + PendingIntent.getBroadcast( + context, + keyCode, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) return NotificationCompat.Action(icon, title, pendingIntent) } @@ -471,13 +489,18 @@ class PlaybackNotification( notificationBuilder?.setStyle(style) } - private fun loadArtwork(url: String, isLocal: Boolean): Bitmap? { + private fun loadArtwork( + url: String, + isLocal: Boolean, + ): Bitmap? { val context = reactContext.get() ?: return null return try { if (isLocal && !url.startsWith("http")) { // Load local resource - val helper = com.facebook.react.views.imagehelper.ResourceDrawableIdHelper.getInstance() + val helper = + com.facebook.react.views.imagehelper.ResourceDrawableIdHelper + .getInstance() val drawable = helper.getResourceDrawable(context, url) if (drawable is BitmapDrawable) { @@ -507,15 +530,17 @@ class PlaybackNotification( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val context = reactContext.get() ?: return - val channel = android.app.NotificationChannel( - channelId, - "Audio Playback", - android.app.NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Media playback controls and information" - setShowBadge(false) - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - } + val channel = + android.app + .NotificationChannel( + channelId, + "Audio Playback", + android.app.NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Media playback controls and information" + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt index 618d15472..69d4d1e43 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.kt @@ -21,7 +21,10 @@ class PlaybackNotificationReceiver : BroadcastReceiver() { } } - override fun onReceive(context: Context?, intent: Intent?) { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { if (intent?.action == ACTION_NOTIFICATION_DISMISSED) { Log.d(TAG, "Notification dismissed by user") audioAPIModule?.invokeHandlerWithEventNameAndEventBody("playbackNotificationDismissed", mapOf()) diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index 39a2e0b0c..5f5233774 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -18,7 +18,8 @@ class IOSAudioRecorder : public AudioRecorder { IOSAudioRecorder( float sampleRate, int bufferLength, - const std::shared_ptr &audioEventHandlerRegistry); + const std::shared_ptr + &audioEventHandlerRegistry); ~IOSAudioRecorder() override; diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 71acdf06c..ab8796baa 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -81,9 +81,7 @@ export { decodeAudioData, decodePCMInBase64 } from './core/AudioDecoder'; export { default as changePlaybackSpeed } from './core/AudioStretcher'; // Notification System -export { - PlaybackNotificationManager, -} from './system/notification'; +export { PlaybackNotificationManager } from './system/notification'; export { OscillatorType, diff --git a/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts index d6085cbce..1b66186f3 100644 --- a/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts +++ b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts @@ -10,7 +10,12 @@ import type { /// Manager for media playback notifications with controls and MediaSession integration. class PlaybackNotificationManager - implements NotificationManager + implements + NotificationManager< + PlaybackNotificationInfo, + PlaybackNotificationInfo, + PlaybackNotificationEventName + > { private notificationKey = 'playback'; private isRegistered = false; @@ -166,7 +171,9 @@ class PlaybackNotificationManager return false; } - return await NativeAudioAPIModule.isNotificationActive(this.notificationKey); + return await NativeAudioAPIModule.isNotificationActive( + this.notificationKey + ); } /// Add an event listener for notification actions. diff --git a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts index 5ca6745fc..a77665617 100644 --- a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts +++ b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts @@ -5,7 +5,12 @@ import type { NotificationManager, SimpleNotificationOptions } from './types'; /// Simple notification manager for basic notifications with title and text. /// Implements the generic NotificationManager interface. class SimpleNotificationManager - implements NotificationManager + implements + NotificationManager< + SimpleNotificationOptions, + SimpleNotificationOptions, + never + > { private notificationKey = 'simple'; private isRegistered = false; @@ -136,7 +141,9 @@ class SimpleNotificationManager return false; } - return await NativeAudioAPIModule.isNotificationActive(this.notificationKey); + return await NativeAudioAPIModule.isNotificationActive( + this.notificationKey + ); } /// Add an event listener (SimpleNotification doesn't emit events). @@ -146,7 +153,10 @@ class SimpleNotificationManager ): AudioEventSubscription { // SimpleNotification doesn't emit events, return a no-op subscription console.warn('SimpleNotification does not support event listeners'); - return this.audioEventEmitter.addAudioEventListener('playbackNotificationPlay' as any, () => {}); + return this.audioEventEmitter.addAudioEventListener( + 'playbackNotificationPlay' as any, + () => {} + ); } /// Remove an event listener. diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index 1a881fa30..5a71db979 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -1,9 +1,12 @@ import type { AudioEventSubscription } from '../../events'; - /// Generic notification manager interface that all notification managers should implement. /// Provides a consistent API for managing notification lifecycle and events. -export interface NotificationManager { +export interface NotificationManager< + TShowOptions, + TUpdateOptions, + TEventName extends string = string, +> { /// Register the notification (must be called before showing). register(): Promise; From 30c524f6a5b2d765b84c7400d206d4c2a7875ea4 Mon Sep 17 00:00:00 2001 From: poneciak Date: Wed, 12 Nov 2025 16:55:02 +0100 Subject: [PATCH 04/19] fix: fixed some issues and dropped old FS --- .../src/examples/AudioFile/AudioFile.tsx | 98 +++-- .../src/examples/AudioFile/AudioPlayer.ts | 17 +- .../com/swmansion/audioapi/AudioAPIModule.kt | 15 - .../audioapi/system/AudioFocusListener.kt | 31 +- .../audioapi/system/LockScreenManager.kt | 346 ------------------ .../system/MediaNotificationManager.kt | 273 -------------- .../audioapi/system/MediaReceiver.kt | 57 --- .../audioapi/system/MediaSessionCallback.kt | 61 --- .../audioapi/system/MediaSessionManager.kt | 73 +--- .../notification/PlaybackNotification.kt | 40 +- .../src/events/types.ts | 2 +- .../src/specs/NativeAudioAPIModule.ts | 7 - .../src/system/AudioManager.ts | 25 +- .../notification/SimpleNotificationManager.ts | 5 +- .../src/system/notification/types.ts | 2 +- 15 files changed, 136 insertions(+), 916 deletions(-) delete mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/LockScreenManager.kt delete mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaNotificationManager.kt delete mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaReceiver.kt delete mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionCallback.kt diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index 099f6ed91..b18c2d80a 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState, FC } from 'react'; import { ActivityIndicator, View, StyleSheet } from 'react-native'; -import { AudioManager } from 'react-native-audio-api'; +import { AudioManager, PlaybackNotificationManager } from 'react-native-audio-api'; import { Container, Button, Spacer } from '../../components'; import AudioPlayer from './AudioPlayer'; import { colors } from '../../styles'; @@ -13,6 +13,7 @@ const AudioFile: FC = () => { const [isPlaying, setIsPlaying] = useState(false); const [isLoading, setIsLoading] = useState(false); const [positionPercentage, setPositionPercentage] = useState(0); + const [shouldResume, setShouldResume] = useState(false); const togglePlayPause = async () => { if (isPlaying) { @@ -41,76 +42,103 @@ const AudioFile: FC = () => { }, []); useEffect(() => { - AudioManager.setLockScreenInfo({ - title: 'Audio file', - artist: 'Software Mansion', - album: 'Audio API', - duration: 10, - }); - - AudioManager.enableRemoteCommand('remotePlay', true); - AudioManager.enableRemoteCommand('remotePause', true); - AudioManager.enableRemoteCommand('remoteSkipForward', true); - AudioManager.enableRemoteCommand('remoteSkipBackward', true); + // Register notification first + const setupNotification = async () => { + try { + await PlaybackNotificationManager.register(); + + // Load audio buffer first + await fetchAudioBuffer(); + + // Show notification with correct duration after buffer is loaded + const duration = AudioPlayer.getDuration(); + await PlaybackNotificationManager.show({ + title: 'Audio file', + artist: 'Software Mansion', + album: 'Audio API', + duration: duration, + state: 'paused', + elapsedTime: 0, + }); + } catch (error) { + console.error('Failed to setup notification:', error); + } + }; + + setupNotification(); + AudioManager.observeAudioInterruptions(true); AudioManager.activelyReclaimSession(true); - const remotePlaySubscription = AudioManager.addSystemEventListener( - 'remotePlay', + // Listen to notification control events + const playListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPlay', () => { AudioPlayer.play(); setIsPlaying(true); } ); - const remotePauseSubscription = AudioManager.addSystemEventListener( - 'remotePause', + const pauseListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPause', () => { AudioPlayer.pause(); setIsPlaying(false); } ); - const remoteSkipForwardSubscription = AudioManager.addSystemEventListener( - 'remoteSkipForward', + const skipForwardListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationSkipForward', (event) => { AudioPlayer.seekBy(event.value); } ); - const remoteSkipBackwardSubscription = AudioManager.addSystemEventListener( - 'remoteSkipBackward', + const skipBackwardListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationSkipBackward', (event) => { AudioPlayer.seekBy(-event.value); } ); + // Keep interruption handling through AudioManager const interruptionSubscription = AudioManager.addSystemEventListener( 'interruption', async (event) => { if (event.type === 'began') { - await AudioPlayer.pause(); - setIsPlaying(false); - } else if (event.type === 'ended' && event.shouldResume) { - BackgroundTimer.setTimeout(async () => { - AudioManager.setAudioSessionActivity(true); - await AudioPlayer.play(); - setIsPlaying(true); - }, 1000); + // Store whether we were playing before interruption + setShouldResume(isPlaying && event.isTransient); + + if (isPlaying) { + await AudioPlayer.pause(); + setIsPlaying(false); + } + } else if (event.type === 'ended') { + + if (shouldResume) { + BackgroundTimer.setTimeout(async () => { + AudioManager.setAudioSessionActivity(true); + await AudioPlayer.play(); + setIsPlaying(true); + console.log('Auto-resumed after transient interruption'); + }, 500); + } + + // Reset the flag + setShouldResume(false); } } ); - fetchAudioBuffer(); - return () => { - remotePlaySubscription?.remove(); - remotePauseSubscription?.remove(); - remoteSkipForwardSubscription?.remove(); - remoteSkipBackwardSubscription?.remove(); + playListener.remove(); + pauseListener.remove(); + skipForwardListener.remove(); + skipBackwardListener.remove(); interruptionSubscription?.remove(); - AudioManager.resetLockScreenInfo(); + PlaybackNotificationManager.unregister(); AudioPlayer.reset(); + console.log('Cleanup AudioFile component'); }; }, [fetchAudioBuffer]); diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index bdeb84042..68eda3284 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -1,9 +1,12 @@ -import { AudioContext, AudioManager } from 'react-native-audio-api'; +import { AudioContext, PlaybackNotificationManager } from 'react-native-audio-api'; import type { AudioBufferSourceNode, AudioBuffer, } from 'react-native-audio-api'; + +// 1. Pasek sie nie updatuje podczas robienia pause play +// 2. Pierwszy play nie pokazuje widgeta dopiero stop go pokazuje class AudioPlayer { private readonly audioContext: AudioContext; private sourceNode: AudioBufferSourceNode | null = null; @@ -58,8 +61,8 @@ class AudioPlayer { this.sourceNode.start(this.audioContext.currentTime, this.offset); - AudioManager.setLockScreenInfo({ - state: 'state_playing', + PlaybackNotificationManager.update({ + state: 'playing' }); }; @@ -71,8 +74,8 @@ class AudioPlayer { this.sourceNode?.stop(this.audioContext.currentTime); - AudioManager.setLockScreenInfo({ - state: 'state_paused', + PlaybackNotificationManager.update({ + state: 'paused' }); await this.audioContext.suspend(); @@ -132,6 +135,10 @@ class AudioPlayer { ) => { this.onPositionChanged = callback; }; + + getDuration = (): number => { + return this.audioBuffer?.duration ?? 0; + }; } export default new AudioPlayer(); 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 45e1fb3a0..24c67b124 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 @@ -113,21 +113,6 @@ class AudioAPIModule( // nothing to do here } - override fun setLockScreenInfo(info: ReadableMap?) { - MediaSessionManager.setLockScreenInfo(info) - } - - override fun resetLockScreenInfo() { - MediaSessionManager.resetLockScreenInfo() - } - - override fun enableRemoteCommand( - name: String?, - enabled: Boolean, - ) { - MediaSessionManager.enableRemoteCommand(name!!, enabled) - } - override fun observeAudioInterruptions(enabled: Boolean) { MediaSessionManager.observeAudioInterruptions(enabled) } 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 e2cdd9727..c206ef19f 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 @@ -11,50 +11,35 @@ import java.util.HashMap class AudioFocusListener( private val audioManager: WeakReference, 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") - put("shouldResume", false) + put("isTransient", false) } 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("isTransient", true) } 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 + val body = + HashMap().apply { + put("type", "ended") + put("isTransient", false) + } + 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 deleted file mode 100644 index ed92c7a5e..000000000 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/LockScreenManager.kt +++ /dev/null @@ -1,346 +0,0 @@ -package com.swmansion.audioapi.system - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.media.app.NotificationCompat.MediaStyle -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.ReadableType -import com.swmansion.audioapi.R -import java.io.IOException -import java.lang.ref.WeakReference -import java.net.URL - -class LockScreenManager( - private val reactContext: WeakReference, - private val mediaSession: WeakReference, - private val mediaNotificationManager: WeakReference, -) { - private var pb: PlaybackStateCompat.Builder = PlaybackStateCompat.Builder() - private var state: PlaybackStateCompat = pb.build() - private var controls: Long = 0 - var isPlaying: Boolean = false - - private var nb: NotificationCompat.Builder = NotificationCompat.Builder(reactContext.get()!!, MediaSessionManager.CHANNEL_ID) - - private var artworkThread: Thread? = null - - private var title: String? = null - private var artist: String? = null - private var album: String? = null - private var description: String? = null - private var duration: Long = 0L - private var speed: Float = 1.0F - private var elapsedTime: Long = 0L - private var artwork: String? = null - private var playbackState: Int = PlaybackStateCompat.STATE_PAUSED - - init { - pb.setActions(controls) - nb.setPriority(NotificationCompat.PRIORITY_HIGH) - nb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - - updateNotificationMediaStyle() - mediaNotificationManager.get()?.updateActions(controls) - } - - fun setLockScreenInfo(info: ReadableMap?) { - if (artworkThread != null && artworkThread!!.isAlive) { - artworkThread!!.interrupt() - } - - artworkThread = null - - if (info == null) { - return - } - - val md = MediaMetadataCompat.Builder() - - if (info.hasKey("title")) { - title = info.getString("title") - } - - if (info.hasKey("artist")) { - artist = info.getString("artist") - } - - if (info.hasKey("album")) { - album = info.getString("album") - } - - if (info.hasKey("description")) { - description = info.getString("description") - } - - if (info.hasKey("duration")) { - duration = (info.getDouble("duration") * 1000).toLong() - } - - md.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) - md.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) - md.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album) - md.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, description) - md.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) - - nb.setContentTitle(title) - nb.setContentText(artist) - nb.setContentInfo(album) - - if (info.hasKey("artwork")) { - var localArtwork = false - - if (info.getType("artwork") == ReadableType.Map) { - artwork = info.getMap("artwork")?.getString("uri") - localArtwork = true - } else { - artwork = info.getString("artwork") - } - - val artworkLocal = localArtwork - - artworkThread = - Thread { - try { - val bitmap: Bitmap? = artwork?.let { loadArtwork(it, artworkLocal) } - - val currentMetadata = mediaSession.get()?.controller?.metadata - val newBuilder = - MediaMetadataCompat.Builder( - currentMetadata, - ) - mediaSession.get()?.setMetadata( - newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build(), - ) - - nb.setLargeIcon(bitmap) - mediaNotificationManager.get()?.updateNotification(nb, isPlaying) - - artworkThread = null - } catch (ex: Exception) { - ex.printStackTrace() - } - } - artworkThread!!.start() - } - - speed = - if (info.hasKey("speed")) { - info.getDouble("speed").toFloat() - } else { - state.playbackSpeed - } - - if (isPlaying && speed == 0F) { - speed = 1F - } - - elapsedTime = - if (info.hasKey("elapsedTime")) { - (info.getDouble("elapsedTime") * 1000).toLong() - } else { - state.position - } - - if (info.hasKey("state")) { - val state = info.getString("state") - - when (state) { - "state_playing" -> { - this.playbackState = PlaybackStateCompat.STATE_PLAYING - } - "state_paused" -> { - this.playbackState = PlaybackStateCompat.STATE_PAUSED - } - } - } - - updatePlaybackState(this.playbackState) - - mediaSession.get()?.setMetadata(md.build()) - mediaSession.get()?.setActive(true) - mediaNotificationManager.get()?.updateNotification(nb, isPlaying) - } - - fun resetLockScreenInfo() { - 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) - mediaSession.get()?.setActive(false) - - nb.setContentTitle("") - nb.setContentText("") - nb.setContentInfo("") - - mediaNotificationManager.get()?.updateNotification(nb, isPlaying) - } - - fun enableRemoteCommand( - name: String, - enabled: Boolean, - ) { - pb = PlaybackStateCompat.Builder() - var controlValue = 0L - when (name) { - "remotePlay" -> controlValue = PlaybackStateCompat.ACTION_PLAY - "remotePause" -> controlValue = PlaybackStateCompat.ACTION_PAUSE - "remoteStop" -> controlValue = PlaybackStateCompat.ACTION_STOP - "remoteTogglePlayPause" -> controlValue = PlaybackStateCompat.ACTION_PLAY_PAUSE - "remoteNextTrack" -> controlValue = PlaybackStateCompat.ACTION_SKIP_TO_NEXT - "remotePreviousTrack" -> controlValue = PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - "remoteSkipForward" -> controlValue = PlaybackStateCompat.ACTION_FAST_FORWARD - "remoteSkipBackward" -> controlValue = PlaybackStateCompat.ACTION_REWIND - "remoteChangePlaybackPosition" -> controlValue = PlaybackStateCompat.ACTION_SEEK_TO - } - - controls = - if (enabled) { - controls or controlValue - } else { - controls and controlValue.inv() - } - - mediaNotificationManager.get()?.updateActions(controls) - - if (hasControl(PlaybackStateCompat.ACTION_REWIND)) { - pb.addCustomAction( - PlaybackStateCompat.CustomAction - .Builder( - "SkipBackward", - "Skip Backward", - R.drawable.skip_backward_15, - ).build(), - ) - } - - pb.setActions(controls) - - if (hasControl(PlaybackStateCompat.ACTION_FAST_FORWARD)) { - pb.addCustomAction( - PlaybackStateCompat.CustomAction - .Builder( - "SkipForward", - "Skip Forward", - R.drawable.skip_forward_15, - ).build(), - ) - } - - state = pb.build() - mediaSession.get()?.setPlaybackState(state) - - updateNotificationMediaStyle() - - if (mediaSession.get()?.isActive == true) { - mediaNotificationManager.get()?.updateNotification(nb, isPlaying) - } - } - - private fun loadArtwork( - url: String, - local: Boolean, - ): Bitmap? { - var bitmap: Bitmap? = null - - try { - // If we are running the app in debug mode, the "local" image will be served from htt://localhost:8080, so we need to check for this case and load those images from URL - if (local && !url.startsWith("http")) { - // Gets the drawable from the RN's helper for local resources - val helper = com.facebook.react.views.imagehelper.ResourceDrawableIdHelper.instance - val image = helper.getResourceDrawable(reactContext.get()!!, url) - - bitmap = - if (image is BitmapDrawable) { - image.bitmap - } else { - BitmapFactory.decodeFile(url) - } - } else { - // Open connection to the URL and decodes the image - val con = URL(url).openConnection() - con.connect() - val input = con.getInputStream() - bitmap = BitmapFactory.decodeStream(input) - input.close() - } - } catch (ex: IOException) { - Log.w("MediaSessionManager", "Could not load the artwork", ex) - } catch (ex: IndexOutOfBoundsException) { - Log.w("MediaSessionManager", "Could not load the artwork", ex) - } - - return bitmap - } - - private fun updatePlaybackState(playbackState: Int) { - isPlaying = playbackState == PlaybackStateCompat.STATE_PLAYING - - pb.setState(playbackState, elapsedTime, speed) - pb.setActions(controls) - state = pb.build() - mediaSession.get()?.setPlaybackState(state) - } - - private fun hasControl(control: Long): Boolean = (controls and control) == control - - private fun updateNotificationMediaStyle() { - val style = MediaStyle() - style.setMediaSession(mediaSession.get()?.sessionToken) - var controlCount = 0 - if (hasControl(PlaybackStateCompat.ACTION_PLAY) || - hasControl(PlaybackStateCompat.ACTION_PAUSE) || - hasControl( - PlaybackStateCompat.ACTION_PLAY_PAUSE, - ) - ) { - controlCount += 1 - } - if (hasControl(PlaybackStateCompat.ACTION_SKIP_TO_NEXT)) { - controlCount += 1 - } - if (hasControl(PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)) { - controlCount += 1 - } - if (hasControl(PlaybackStateCompat.ACTION_FAST_FORWARD)) { - controlCount += 1 - } - if (hasControl(PlaybackStateCompat.ACTION_REWIND)) { - controlCount += 1 - } - - if (hasControl(PlaybackStateCompat.ACTION_SEEK_TO)) { - controlCount += 1 - } - - val actions = IntArray(controlCount) - for (i in actions.indices) { - actions[i] = i - } - style.setShowActionsInCompactView(*actions) - nb.setStyle(style) - } -} 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 deleted file mode 100644 index 9f28a4910..000000000 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaNotificationManager.kt +++ /dev/null @@ -1,273 +0,0 @@ -package com.swmansion.audioapi.system - -import android.Manifest -import android.annotation.SuppressLint -import android.app.Notification -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.content.pm.ServiceInfo -import android.content.res.Resources -import android.os.Build -import android.os.IBinder -import android.provider.ContactsContract -import android.support.v4.media.session.PlaybackStateCompat -import android.util.Log -import android.view.KeyEvent -import androidx.annotation.RequiresPermission -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.facebook.react.bridge.ReactApplicationContext -import com.swmansion.audioapi.R -import java.lang.ref.WeakReference - -class MediaNotificationManager( - private val reactContext: WeakReference, -) { - private var smallIcon: Int = R.drawable.logo - private var customIcon: Int = 0 - - private var play: NotificationCompat.Action? = null - private var pause: NotificationCompat.Action? = null - private var stop: NotificationCompat.Action? = null - private var next: NotificationCompat.Action? = null - private var previous: NotificationCompat.Action? = null - private var skipForward: NotificationCompat.Action? = null - private var skipBackward: NotificationCompat.Action? = null - - companion object { - 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" - } - - enum class ForegroundAction { - START_FOREGROUND, - STOP_FOREGROUND, - ; - - companion object { - fun fromAction(action: String?): ForegroundAction? = entries.firstOrNull { it.name == action } - } - } - - @SuppressLint("RestrictedApi") - @Synchronized - fun prepareNotification( - builder: NotificationCompat.Builder, - isPlaying: Boolean, - ): Notification { - builder.mActions.clear() - - if (previous != null) { - builder.addAction(previous) - } - - if (skipBackward != null) { - builder.addAction(skipBackward) - } - - if (play != null && !isPlaying) { - builder.addAction(play) - } - - if (pause != null && isPlaying) { - builder.addAction(pause) - } - - if (stop != null) { - builder.addAction(stop) - } - - if (next != null) { - builder.addAction(next) - } - - if (skipForward != null) { - builder.addAction(skipForward) - } - - builder.setSmallIcon(if (customIcon != 0) customIcon else smallIcon) - - val packageName: String? = reactContext.get()?.packageName - val openApp: Intent? = reactContext.get()?.packageManager?.getLaunchIntentForPackage(packageName!!) - try { - builder.setContentIntent( - PendingIntent.getActivity( - reactContext.get(), - 0, - openApp, - PendingIntent.FLAG_IMMUTABLE, - ), - ) - } catch (e: Exception) { - Log.w("AudioManagerModule", "Error creating content intent: ${e.message}") - } - - val remove = Intent(REMOVE_NOTIFICATION) - remove.putExtra(PACKAGE_NAME, reactContext.get()?.applicationInfo?.packageName) - builder.setDeleteIntent( - PendingIntent.getBroadcast( - reactContext.get(), - 0, - remove, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ), - ) - - return builder.build() - } - - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) - @Synchronized - fun updateNotification( - builder: NotificationCompat.Builder?, - isPlaying: Boolean, - ) { - NotificationManagerCompat.from(reactContext.get()!!).notify( - MediaSessionManager.NOTIFICATION_ID, - prepareNotification(builder!!, isPlaying), - ) - } - - fun cancelNotification() { - NotificationManagerCompat.from(reactContext.get()!!).cancel(MediaSessionManager.NOTIFICATION_ID) - } - - @Synchronized - fun updateActions(mask: Long) { - play = createAction("play", "Play", mask, PlaybackStateCompat.ACTION_PLAY, play) - pause = createAction("pause", "Pause", mask, PlaybackStateCompat.ACTION_PAUSE, pause) - stop = createAction("stop", "Stop", mask, PlaybackStateCompat.ACTION_STOP, stop) - next = createAction("next", "Next", mask, PlaybackStateCompat.ACTION_SKIP_TO_NEXT, next) - previous = createAction("previous", "Previous", mask, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS, previous) - skipForward = createAction("skip_forward_15", "Skip Forward", mask, PlaybackStateCompat.ACTION_FAST_FORWARD, skipForward) - skipBackward = createAction("skip_backward_15", "Skip Backward", mask, PlaybackStateCompat.ACTION_REWIND, skipBackward) - } - - private fun createAction( - iconName: String, - title: String, - mask: Long, - action: Long, - oldAction: NotificationCompat.Action?, - ): NotificationCompat.Action? { - if ((mask and action) == 0L) { - return null - } - - if (oldAction != null) { - return oldAction - } - - val r: Resources? = reactContext.get()?.resources - val packageName: String? = reactContext.get()?.packageName - val icon = r?.getIdentifier(iconName, "drawable", packageName) - - val keyCode = PlaybackStateCompat.toKeyCode(action) - val intent = Intent(MEDIA_BUTTON) - intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) - intent.putExtra(ContactsContract.Directory.PACKAGE_NAME, packageName) - val i = - PendingIntent.getBroadcast( - reactContext.get(), - keyCode, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - return NotificationCompat.Action(icon!!, title, i) - } - - class AudioForegroundService : Service() { - private var notification: Notification? = null - private var isServiceStarted = false - private val serviceLock = Any() - - override fun onBind(intent: Intent): IBinder? = null - - private fun startForegroundService() { - synchronized(serviceLock) { - if (!isServiceStarted) { - try { - notification = - MediaSessionManager.mediaNotificationManager - .prepareNotification( - NotificationCompat.Builder(this, MediaSessionManager.CHANNEL_ID), - false, - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - MediaSessionManager.NOTIFICATION_ID, - notification!!, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST, - ) - } else { - startForeground( - MediaSessionManager.NOTIFICATION_ID, - notification, - ) - } - isServiceStarted = true - } catch (ex: Exception) { - Log.e("AudioManagerModule", "Error starting foreground service: ${ex.message}") - stopSelf() - } - } - } - } - - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int, - ): Int { - val action = ForegroundAction.fromAction(intent?.action) - - when (action) { - ForegroundAction.START_FOREGROUND -> startForegroundService() - ForegroundAction.STOP_FOREGROUND -> stopForegroundService() - else -> startForegroundService() - } - - return START_NOT_STICKY - } - - private fun stopForegroundService() { - synchronized(serviceLock) { - if (isServiceStarted) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - stopForeground(STOP_FOREGROUND_REMOVE) - } - isServiceStarted = false - stopSelf() - } - } - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - stopForegroundService() - } - - override fun onDestroy() { - synchronized(serviceLock) { - 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 deleted file mode 100644 index 5832c56aa..000000000 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaReceiver.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.swmansion.audioapi.system - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.media.AudioManager -import android.support.v4.media.session.MediaSessionCompat -import android.view.KeyEvent -import com.facebook.react.bridge.ReactApplicationContext -import com.swmansion.audioapi.AudioAPIModule -import java.lang.ref.WeakReference - -class MediaReceiver( - private val reactContext: WeakReference, - private val mediaSession: WeakReference, - private val mediaNotificationManager: WeakReference, - private val audioAPIModule: WeakReference, -) : BroadcastReceiver() { - override fun onReceive( - context: Context?, - intent: Intent?, - ) { - val action = intent!!.action - - if (MediaNotificationManager.REMOVE_NOTIFICATION == action) { - if (!checkApp(intent)) return - - mediaNotificationManager.get()?.cancelNotification() - MediaSessionManager.stopForegroundServiceIfNecessary() - mediaSession.get()?.isActive = false - - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("closeNotification", mapOf()) // add to ts events - } else if (MediaNotificationManager.MEDIA_BUTTON == action || Intent.ACTION_MEDIA_BUTTON == action) { - if (!intent.hasExtra(Intent.EXTRA_KEY_EVENT)) return - if (!checkApp(intent)) return - - val keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) - mediaSession.get()?.controller?.dispatchMediaButtonEvent(keyEvent) - } else if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == action) { - mediaSession - .get() - ?.controller - ?.transportControls - ?.pause() - } - } - - private fun checkApp(intent: Intent): Boolean { - if (intent.hasExtra(MediaNotificationManager.PACKAGE_NAME)) { - val name = intent.getStringExtra(MediaNotificationManager.PACKAGE_NAME) - if (!reactContext.get()?.packageName.equals(name)) { - return false - } - } - return true - } -} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionCallback.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionCallback.kt deleted file mode 100644 index fc5a49466..000000000 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionCallback.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.swmansion.audioapi.system - -import android.os.Bundle -import android.support.v4.media.session.MediaSessionCompat -import android.util.Log -import com.swmansion.audioapi.AudioAPIModule -import java.lang.ref.WeakReference - -class MediaSessionCallback( - private val audioAPIModule: WeakReference, - private val mediaNotificationManager: WeakReference, -) : MediaSessionCompat.Callback() { - override fun onPlay() { - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remotePlay", mapOf()) - } - - override fun onPause() { - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remotePause", mapOf()) - } - - override fun onStop() { - mediaNotificationManager.get()?.cancelNotification() - MediaSessionManager.stopForegroundServiceIfNecessary() - - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remoteStop", mapOf()) - } - - override fun onSkipToNext() { - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remoteNextTrack", mapOf()) - } - - override fun onSkipToPrevious() { - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remotePreviousTrack", mapOf()) - } - - override fun onFastForward() { - val body = HashMap().apply { put("value", 15) } - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remoteSkipForward", body) - } - - override fun onRewind() { - val body = HashMap().apply { put("value", 15) } - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remoteSkipBackward", body) - } - - override fun onSeekTo(pos: Long) { - val body = HashMap().apply { put("value", (pos.toDouble() / 1000)) } - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("remoteChangePlaybackPosition", body) - } - - override fun onCustomAction( - action: String?, - extras: Bundle?, - ) { - when (action) { - "SkipForward" -> onFastForward() - "SkipBackward" -> onRewind() - else -> Log.w("MediaSessionCallback", "Unknown custom action: $action") - } - } -} 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 1b9eaf4d7..6d0833bf7 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 @@ -37,19 +37,13 @@ object MediaSessionManager { const val CHANNEL_ID = "react-native-audio-api" private lateinit var audioManager: AudioManager - private lateinit var mediaSession: MediaSessionCompat - lateinit var mediaNotificationManager: MediaNotificationManager - private lateinit var lockScreenManager: LockScreenManager private lateinit var audioFocusListener: AudioFocusListener private lateinit var volumeChangeListener: VolumeChangeListener - private lateinit var mediaReceiver: MediaReceiver private lateinit var playbackNotificationReceiver: PlaybackNotificationReceiver // New notification system private lateinit var notificationRegistry: NotificationRegistry - private var isServiceRunning = false - private val serviceStateLock = Any() private val nativeAudioPlayers = mutableMapOf() private val nativeAudioRecorders = mutableMapOf() @@ -60,40 +54,16 @@ object MediaSessionManager { this.audioAPIModule = audioAPIModule this.reactContext = reactContext 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() } - this.mediaNotificationManager = MediaNotificationManager(this.reactContext) - this.lockScreenManager = LockScreenManager(this.reactContext, WeakReference(this.mediaSession), WeakReference(mediaNotificationManager)) - this.mediaReceiver = - MediaReceiver(this.reactContext, WeakReference(this.mediaSession), WeakReference(this.mediaNotificationManager), this.audioAPIModule) - this.mediaSession.setCallback(MediaSessionCallback(this.audioAPIModule, WeakReference(this.mediaNotificationManager))) - // Set up PlaybackNotificationReceiver PlaybackNotificationReceiver.setAudioAPIModule(audioAPIModule.get()) this.playbackNotificationReceiver = PlaybackNotificationReceiver() - 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) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - this.reactContext.get()!!.registerReceiver(mediaReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - ContextCompat.registerReceiver( - this.reactContext.get()!!, - mediaReceiver, - filter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - } - - // Register PlaybackNotificationReceiver separately + // Register PlaybackNotificationReceiver val playbackFilter = IntentFilter(PlaybackNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.reactContext.get()!!.registerReceiver(playbackNotificationReceiver, playbackFilter, Context.RECEIVER_NOT_EXPORTED) @@ -107,7 +77,7 @@ object MediaSessionManager { } this.audioFocusListener = - AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule, WeakReference(this.lockScreenManager)) + AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule) this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule) // Initialize new notification system @@ -149,49 +119,32 @@ object MediaSessionManager { } private fun startForegroundService() { - synchronized(serviceStateLock) { - if (isServiceRunning || reactContext.get() == null) { - return - } - - 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) - } - isServiceRunning = true - } + // No longer needed with new notification system } private fun stopForegroundService() { - synchronized(serviceStateLock) { - if (!isServiceRunning || reactContext.get() == null) { - return - } - - val intent = Intent(reactContext.get(), MediaNotificationManager.AudioForegroundService::class.java) - intent.action = MediaNotificationManager.ForegroundAction.STOP_FOREGROUND.name - reactContext.get()!!.startService(intent) - isServiceRunning = false - } + // No longer needed with new notification system } + // Deprecated - kept for backward compatibility + @Deprecated("Use new PlaybackNotification system instead") fun setLockScreenInfo(info: ReadableMap?) { - lockScreenManager.setLockScreenInfo(info) + // No-op: Old system removed } + // Deprecated - kept for backward compatibility + @Deprecated("Use new PlaybackNotification system instead") fun resetLockScreenInfo() { - lockScreenManager.resetLockScreenInfo() + // No-op: Old system removed } + // Deprecated - kept for backward compatibility + @Deprecated("Use new PlaybackNotification system instead") fun enableRemoteCommand( name: String, enabled: Boolean, ) { - lockScreenManager.enableRemoteCommand(name, enabled) + // No-op: Old system removed } fun getDevicePreferredSampleRate(): Double { diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt index f9c774106..752af26dd 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt @@ -49,6 +49,8 @@ class PlaybackNotification( private var mediaSession: MediaSessionCompat? = null private var notificationBuilder: NotificationCompat.Builder? = null private var playbackStateBuilder: PlaybackStateCompat.Builder = PlaybackStateCompat.Builder() + private var playbackState: PlaybackStateCompat = playbackStateBuilder.build() + private var playbackPlayingState: Int = PlaybackStateCompat.STATE_PAUSED private var enabledControls: Long = 0 private var isPlaying: Boolean = false @@ -192,7 +194,8 @@ class PlaybackNotification( playbackStateBuilder.setState(PlaybackStateCompat.STATE_NONE, 0, 0f) playbackStateBuilder.setActions(enabledControls) - mediaSession?.setPlaybackState(playbackStateBuilder.build()) + playbackState = playbackStateBuilder.build() + mediaSession?.setPlaybackState(playbackState) mediaSession?.isActive = false mediaSession?.release() mediaSession = null @@ -236,22 +239,37 @@ class PlaybackNotification( if (options.hasKey("elapsedTime")) { elapsedTime = (options.getDouble("elapsedTime") * 1000).toLong() + } else { + // Use the current position from the media session controller (live calculated position) + val controllerPosition = mediaSession?.controller?.playbackState?.position + if (controllerPosition != null && controllerPosition > 0) { + elapsedTime = controllerPosition + } } if (options.hasKey("speed")) { speed = options.getDouble("speed").toFloat() + } else { + // Use the current speed from the media session controller + val controllerSpeed = mediaSession?.controller?.playbackState?.playbackSpeed + if (controllerSpeed != null && controllerSpeed > 0) { + speed = controllerSpeed + } + } + + // Ensure speed is at least 1.0 when playing + if (isPlaying && speed == 0f) { + speed = 1.0f } // Update playback state if (options.hasKey("state")) { when (options.getString("state")) { "playing" -> { - isPlaying = true - updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) + playbackPlayingState = PlaybackStateCompat.STATE_PLAYING } "paused" -> { - isPlaying = false - updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) + playbackPlayingState = PlaybackStateCompat.STATE_PAUSED } } } @@ -303,6 +321,7 @@ class PlaybackNotification( } } + updatePlaybackState(playbackPlayingState) mediaSession?.setMetadata(metadataBuilder.build()) mediaSession?.isActive = true @@ -346,7 +365,8 @@ class PlaybackNotification( // Update playback state with new controls playbackStateBuilder.setActions(enabledControls) - mediaSession?.setPlaybackState(playbackStateBuilder.build()) + playbackState = playbackStateBuilder.build() + mediaSession?.setPlaybackState(playbackState) } private fun updateActions() { @@ -435,7 +455,13 @@ class PlaybackNotification( playbackStateBuilder.setState(state, elapsedTime, speed) playbackStateBuilder.setActions(enabledControls) - mediaSession?.setPlaybackState(playbackStateBuilder.build()) + playbackState = playbackStateBuilder.build() + if (mediaSession != null) { + Log.d(TAG, "mediaSession is not null") + } else { + Log.d(TAG, "mediaSession is null") + } + mediaSession?.setPlaybackState(playbackState) // Update ongoing state - only persistent when playing notificationBuilder?.setOngoing(isPlaying) diff --git a/packages/react-native-audio-api/src/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index 67f713aec..aa7a0eb5f 100644 --- a/packages/react-native-audio-api/src/events/types.ts +++ b/packages/react-native-audio-api/src/events/types.ts @@ -8,7 +8,7 @@ export interface EventTypeWithValue { interface OnInterruptionEventType { type: 'ended' | 'began'; - shouldResume: boolean; + isTransient: boolean; } interface OnRouteChangeEventType { diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts index a07d1cb97..eff997025 100644 --- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts +++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts @@ -17,14 +17,7 @@ interface Spec extends TurboModule { ): void; disableSessionManagement(): void; - // Lock Screen Info - setLockScreenInfo(info: { - [key: string]: string | boolean | number | undefined; - }): void; - resetLockScreenInfo(): void; - // Remote commands, system events and interruptions - enableRemoteCommand(name: string, enabled: boolean): void; observeAudioInterruptions(enabled: boolean): void; activelyReclaimSession(enabled: boolean): void; observeVolumeChanges(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 22751ec09..f0a4a97bc 100644 --- a/packages/react-native-audio-api/src/system/AudioManager.ts +++ b/packages/react-native-audio-api/src/system/AudioManager.ts @@ -1,14 +1,5 @@ -import { - SessionOptions, - LockScreenInfo, - PermissionStatus, - AudioDevicesInfo, -} from './types'; -import { - SystemEventName, - SystemEventCallback, - RemoteCommandEventName, -} from '../events/types'; +import { SessionOptions, PermissionStatus, AudioDevicesInfo } from './types'; +import { SystemEventName, SystemEventCallback } from '../events/types'; import { NativeAudioAPIModule } from '../specs'; import { AudioEventEmitter, AudioEventSubscription } from '../events'; @@ -49,14 +40,6 @@ class AudioManager { NativeAudioAPIModule!.disableSessionManagement(); } - setLockScreenInfo(info: LockScreenInfo) { - NativeAudioAPIModule!.setLockScreenInfo(info); - } - - resetLockScreenInfo() { - NativeAudioAPIModule!.resetLockScreenInfo(); - } - observeAudioInterruptions(enabled: boolean) { NativeAudioAPIModule!.observeAudioInterruptions(enabled); } @@ -82,10 +65,6 @@ class AudioManager { NativeAudioAPIModule!.observeVolumeChanges(enabled); } - enableRemoteCommand(name: RemoteCommandEventName, enabled: boolean) { - NativeAudioAPIModule!.enableRemoteCommand(name, enabled); - } - addSystemEventListener( name: Name, callback: SystemEventCallback diff --git a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts index a77665617..46f2b84e2 100644 --- a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts +++ b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts @@ -149,12 +149,13 @@ class SimpleNotificationManager /// Add an event listener (SimpleNotification doesn't emit events). addEventListener( _eventName: T, - _callback: (event: any) => void + _callback: (event: never) => void ): AudioEventSubscription { // SimpleNotification doesn't emit events, return a no-op subscription console.warn('SimpleNotification does not support event listeners'); return this.audioEventEmitter.addAudioEventListener( - 'playbackNotificationPlay' as any, + // Using a valid event name for the no-op subscription + 'playbackNotificationPlay', () => {} ); } diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index 5a71db979..8e8f5f6b2 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -28,7 +28,7 @@ export interface NotificationManager< /// Add an event listener for notification events. addEventListener( eventName: T, - callback: (event: any) => void + callback: (event: unknown) => void ): AudioEventSubscription; /// Remove an event listener. From 5563fdf2cdb81bfe9ec693b37f1e555a76bb0e09 Mon Sep 17 00:00:00 2001 From: poneciak Date: Wed, 12 Nov 2025 18:50:20 +0100 Subject: [PATCH 05/19] chore: minor touches --- apps/common-app/src/examples/AudioFile/AudioPlayer.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index 68eda3284..cbda09f0e 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -4,9 +4,6 @@ import type { AudioBuffer, } from 'react-native-audio-api'; - -// 1. Pasek sie nie updatuje podczas robienia pause play -// 2. Pierwszy play nie pokazuje widgeta dopiero stop go pokazuje class AudioPlayer { private readonly audioContext: AudioContext; private sourceNode: AudioBufferSourceNode | null = null; From ea93261144fe68359b5829c238f1189dcd8c10fe Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 13 Nov 2025 11:12:35 +0100 Subject: [PATCH 06/19] feat: types update --- packages/react-native-audio-api/src/api.ts | 2 - .../react-native-audio-api/src/api.web.ts | 2 - .../src/events/types.ts | 41 ++++--------------- .../PlaybackNotificationManager.ts | 4 +- .../notification/SimpleNotificationManager.ts | 1 + .../src/system/notification/types.ts | 31 +++++++++----- .../src/system/types.ts | 18 -------- 7 files changed, 31 insertions(+), 68 deletions(-) diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index ab8796baa..844817928 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -99,8 +99,6 @@ export { IOSMode, IOSOption, SessionOptions, - MediaState, - LockScreenInfo, PermissionStatus, } from './system/types'; diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index 292f86993..f6ae34685 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -32,8 +32,6 @@ export { IOSMode, IOSOption, SessionOptions, - MediaState, - LockScreenInfo, PermissionStatus, } from './system/types'; diff --git a/packages/react-native-audio-api/src/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index aa7a0eb5f..bdf97216b 100644 --- a/packages/react-native-audio-api/src/events/types.ts +++ b/packages/react-native-audio-api/src/events/types.ts @@ -1,4 +1,5 @@ import AudioBuffer from '../core/AudioBuffer'; +import { NotificationEvents } from '../system'; export interface EventEmptyType {} @@ -23,37 +24,11 @@ interface OnRouteChangeEventType { | 'NoSuitableRouteForCategory'; } -interface RemoteCommandEvents { - remotePlay: EventEmptyType; - remotePause: EventEmptyType; - remoteStop: EventEmptyType; - remoteTogglePlayPause: EventEmptyType; - remoteChangePlaybackRate: EventTypeWithValue; - remoteNextTrack: EventEmptyType; - remotePreviousTrack: EventEmptyType; - remoteSkipForward: EventTypeWithValue; - remoteSkipBackward: EventTypeWithValue; - remoteSeekForward: EventEmptyType; - remoteSeekBackward: EventEmptyType; - remoteChangePlaybackPosition: EventTypeWithValue; -} - -interface PlaybackNotificationEvents { - playbackNotificationPlay: EventEmptyType; - playbackNotificationPause: EventEmptyType; - playbackNotificationNext: EventEmptyType; - playbackNotificationPrevious: EventEmptyType; - playbackNotificationSkipForward: EventTypeWithValue; - playbackNotificationSkipBackward: EventTypeWithValue; - playbackNotificationDismissed: EventEmptyType; -} - -type SystemEvents = RemoteCommandEvents & - PlaybackNotificationEvents & { - volumeChange: EventTypeWithValue; - interruption: OnInterruptionEventType; - routeChange: OnRouteChangeEventType; - }; +type SystemEvents = { + volumeChange: EventTypeWithValue; + interruption: OnInterruptionEventType; + routeChange: OnRouteChangeEventType; +}; export interface OnEndedEventType extends EventEmptyType { bufferId: string | undefined; @@ -75,9 +50,7 @@ interface AudioAPIEvents { systemStateChanged: EventEmptyType; // to change } -type AudioEvents = SystemEvents & AudioAPIEvents; - -export type RemoteCommandEventName = keyof RemoteCommandEvents; +type AudioEvents = SystemEvents & AudioAPIEvents & NotificationEvents; export type SystemEventName = keyof SystemEvents; export type SystemEventCallback = ( diff --git a/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts index 1b66186f3..cf1f961c2 100644 --- a/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts +++ b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts @@ -1,11 +1,11 @@ import { NativeAudioAPIModule } from '../../specs'; import { AudioEventEmitter, AudioEventSubscription } from '../../events'; -import type { SystemEventCallback } from '../../events/types'; import type { NotificationManager, PlaybackNotificationInfo, PlaybackControlName, PlaybackNotificationEventName, + NotificationEvents, } from './types'; /// Manager for media playback notifications with controls and MediaSession integration. @@ -179,7 +179,7 @@ class PlaybackNotificationManager /// Add an event listener for notification actions. addEventListener( eventName: T, - callback: SystemEventCallback + callback: (event: NotificationEvents[T]) => void ): AudioEventSubscription { return this.audioEventEmitter.addAudioEventListener(eventName, callback); } diff --git a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts index 46f2b84e2..71f9a0392 100644 --- a/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts +++ b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts @@ -4,6 +4,7 @@ import type { NotificationManager, SimpleNotificationOptions } from './types'; /// Simple notification manager for basic notifications with title and text. /// Implements the generic NotificationManager interface. +// It is only a showcase class SimpleNotificationManager implements NotificationManager< diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index 8e8f5f6b2..7df2b9572 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -1,11 +1,12 @@ import type { AudioEventSubscription } from '../../events'; +import { EventEmptyType, EventTypeWithValue } from '../../events/types'; /// Generic notification manager interface that all notification managers should implement. /// Provides a consistent API for managing notification lifecycle and events. export interface NotificationManager< TShowOptions, TUpdateOptions, - TEventName extends string = string, + TEventName extends NotificationEventName, > { /// Register the notification (must be called before showing). register(): Promise; @@ -28,7 +29,7 @@ export interface NotificationManager< /// Add an event listener for notification events. addEventListener( eventName: T, - callback: (event: unknown) => void + callback: NotificationCallback ): AudioEventSubscription; /// Remove an event listener. @@ -57,14 +58,24 @@ export type PlaybackControlName = | 'skipBackward'; /// Event names for playback notification actions. -export type PlaybackNotificationEventName = - | 'playbackNotificationPlay' - | 'playbackNotificationPause' - | 'playbackNotificationNext' - | 'playbackNotificationPrevious' - | 'playbackNotificationSkipForward' - | 'playbackNotificationSkipBackward' - | 'playbackNotificationDismissed'; +interface PlaybackNotificationEvent { + playbackNotificationPlay: EventEmptyType; + playbackNotificationPause: EventEmptyType; + playbackNotificationNext: EventEmptyType; + playbackNotificationPrevious: EventEmptyType; + playbackNotificationSkipForward: EventTypeWithValue; + playbackNotificationSkipBackward: EventTypeWithValue; + playbackNotificationDismissed: EventEmptyType; +} + +export type PlaybackNotificationEventName = keyof PlaybackNotificationEvent; + +export type NotificationEvents = PlaybackNotificationEvent; +export type NotificationEventName = keyof NotificationEvents; + +export type NotificationCallback = ( + event: NotificationEvents[Name] +) => void; /// Options for a simple notification with title and text. export interface SimpleNotificationOptions { diff --git a/packages/react-native-audio-api/src/system/types.ts b/packages/react-native-audio-api/src/system/types.ts index 8625eab25..1960b0f85 100644 --- a/packages/react-native-audio-api/src/system/types.ts +++ b/packages/react-native-audio-api/src/system/types.ts @@ -34,24 +34,6 @@ export interface SessionOptions { iosAllowHaptics?: boolean; } -export type MediaState = 'state_playing' | 'state_paused'; - -interface BaseLockScreenInfo { - [key: string]: string | boolean | number | undefined; -} - -export interface LockScreenInfo extends BaseLockScreenInfo { - title?: string; - artwork?: string; - artist?: string; - album?: string; - duration?: number; - description?: string; // android only - state?: MediaState; - speed?: number; - elapsedTime?: number; -} - export type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; export interface AudioDeviceInfo { From 59c02e60de3fe9d1fd3013a89d945e4cd37dd4da Mon Sep 17 00:00:00 2001 From: miloszwielgus Date: Thu, 13 Nov 2025 12:40:21 +0100 Subject: [PATCH 07/19] feat: docs draft --- .../audiodocs/docs/system/audio-manager.mdx | 86 +++----- .../system/playback-notification-manager.mdx | 205 ++++++++++++++++++ .../system/simple-notification-manager.mdx | 176 +++++++++++++++ 3 files changed, 407 insertions(+), 60 deletions(-) create mode 100644 packages/audiodocs/docs/system/playback-notification-manager.mdx create mode 100644 packages/audiodocs/docs/system/simple-notification-manager.mdx diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index 4f86c84ee..1d16fc452 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -74,20 +74,6 @@ function App() { ## Methods -### `setLockScreenInfo` - -| Parameter | Type | Description | -| :---: | :---: | :-----: | -| `info` | [`LockScreenInfo`](/docs/system/audio-manager#lockscreeninfo) | Information to be displayed on the lock screen | - -#### Returns `undefined` - -### `resetLockScreenInfo` - -Resets all of the lock screen data. - -#### Returns `undefined` - ### `setAudioSessionOptions` | Parameter | Type | Description | @@ -151,24 +137,6 @@ interval polling to check if other audio is playing. #### Returns `undefined` -### `enableRemoteCommand` - -Enables emition of some system events. - -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `name` | [`RemoteCommandEventName`](/docs/system/audio-manager#systemeventname--remotecommandeventname) | Name of an event | -| `enabled` | `boolean` | Indicates the start or the end of event emission | - -#### Returns `undefined` - -:::info - -If you want to add callback upon hearing event, remember to call [`addSystemEventListener`](/docs/system/audio-manager#addsystemeventlistener) -with proper parameters. - -::: - ### `addSystemEventListener` @@ -198,6 +166,19 @@ Checks if permissions were previously granted. #### Returns promise of [`PermissionStatus`](/docs/system/audio-manager#permissionstatus) type, which is resolved after receiving answer from the system. +### `requestNotificationPermissions` + +Brings up the system notification permissions pop-up on demand. The pop-up automatically shows if notification data +is directly requested, but sometimes it is better to ask beforehand. + +#### Returns promise of [`PermissionStatus`](/docs/system/audio-manager#permissionstatus) type, which is resolved after receiving answer from the system. + +### `checkRecordingPermissions` + +Checks if permissions were previously granted. + +#### Returns promise of [`PermissionStatus`](/docs/system/audio-manager#permissionstatus) type, which is resolved after receiving answer from the system. + ### `getDevicesInfo` Checks currently used and available devices. @@ -206,32 +187,6 @@ Checks currently used and available devices. ## Remarks -### `LockScreenInfo` - -
-Type definitions -```typescript -interface BaseLockScreenInfo { - [key: string]: string | boolean | number | undefined; -} - -//state_playing if track is played, state_paused otherwise -type MediaState = 'state_playing' | 'state_paused'; - -interface LockScreenInfo extends BaseLockScreenInfo { - title?: string; //title of the track - artwork?: string; //uri to the artwork - artist?: string; //name of the artist - album?: string; //name of the album - duration?: number; //duration in seconds - description?: string; // android only, description of the track - state?: MediaState; - speed?: number; //playback rate - elapsedTime?: number; //elapsed time of an audio in seconds -} -``` -
- ### `SessionOptions`
@@ -288,7 +243,7 @@ interface EventTypeWithValue { interface OnInterruptionEventType { type: 'ended' | 'began'; //if interruption event has started or ended - shouldResume: boolean; //if we should resume playing after interruption + isTransient: boolean; //if we should resume playing after interruption } interface OnRouteChangeEventType { @@ -320,7 +275,18 @@ interface RemoteCommandEvents { remoteChangePlaybackPosition: EventTypeWithValue; } -type SystemEvents = RemoteCommandEvents & { +interface PlaybackNotificationEvents { + playbackNotificationPlay: EventEmptyType; + playbackNotificationPause: EventEmptyType; + playbackNotificationNext: EventEmptyType; + playbackNotificationPrevious: EventEmptyType; + playbackNotificationSkipForward: EventTypeWithValue; + playbackNotificationSkipBackward: EventTypeWithValue; + playbackNotificationDismissed: EventEmptyType; +} + +type SystemEvents = RemoteCommandEvents & + PlaybackNotificationEvents & { volumeChange: EventTypeWithValue; //triggered when volume level is changed interruption: OnInterruptionEventType; //triggered when f.e. some app wants to play music when we are playing routeChange: OnRouteChangeEventType; //change of output f.e. from speaker to headphones, events are always emitted! diff --git a/packages/audiodocs/docs/system/playback-notification-manager.mdx b/packages/audiodocs/docs/system/playback-notification-manager.mdx new file mode 100644 index 000000000..5fc01cc2c --- /dev/null +++ b/packages/audiodocs/docs/system/playback-notification-manager.mdx @@ -0,0 +1,205 @@ +--- +sidebar_position: 2 +--- + +import { + Optional, + ReadOnly, + OnlyiOS, + Experimental, +} from '@site/src/components/Badges'; + +# PlaybackNotificationManager + +The `PlaybackNotificationManager` provides media session integration and playback controls for your audio application. It manages system-level media notifications with controls like play, pause, next, previous, and seek functionality. + +## Example + +```tsx +import { PlaybackNotificationManager } from 'react-native-audio-api'; +import { useEffect } from 'react'; + +function AudioPlayer() { + useEffect(() => { + // Register the notification + PlaybackNotificationManager.register(); + + // Show initial notification + PlaybackNotificationManager.show({ + title: 'My Song', + artist: 'Artist Name', + album: 'Album Name', + duration: 180, + }); + + // Add event listeners for controls + const playListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPlay', + () => { + // Handle play action + PlaybackNotificationManager.update({ state: 'playing' }); + } + ); + + const pauseListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPause', + () => { + // Handle pause action + PlaybackNotificationManager.update({ state: 'paused' }); + } + ); + + return () => { + playListener.remove(); + pauseListener.remove(); + PlaybackNotificationManager.unregister(); + }; + }, []); + + // Update playback position + const updateProgress = (elapsedTime: number) => { + PlaybackNotificationManager.update({ elapsedTime }); + }; +} +``` + +## Methods + +### `register` + +Register the playback notification with the system. Must be called before showing any notification. + +#### Returns `Promise`. + +#### Errors + +| Error type | Description | +| :--------: | :---------------------------------------------------------- | +| `Error` | NativeAudioAPIModule is not available or registration fails | + +### `show` + +Display the notification with initial metadata. + +| Parameter | Type | Description | +| :-------: | :-----------------------------------------------------------------------------------------------: | :---------------------------- | +| `info` | [`PlaybackNotificationInfo`](/docs/system/playback-notification-manager#playbacknotificationinfo) | Initial notification metadata | + +#### Returns `Promise`. + +#### Errors + +| Error type | Description | +| :--------: | :----------------------------------------------------------------- | +| `Error` | Notification must be registered first or native module unavailable | + +### `update` + +Update the notification with new metadata or state. + +| Parameter | Type | Description | +| :-------: | :-----------------------------------------------------------------------------------------------: | :---------------------------- | +| `info` | [`PlaybackNotificationInfo`](/docs/system/playback-notification-manager#playbacknotificationinfo) | Updated notification metadata | + +#### Returns `Promise`. + +### `hide` + +Hide the notification. Can be shown again later by calling `show()`. + +#### Returns `Promise`. + +### `unregister` + +Unregister the notification from the system. Must call `register()` again to use. + +#### Returns `Promise`. + +### `enableControl` + +Enable or disable specific playback controls. + +| Parameter | Type | Description | +| :-------: | :-------------------------------------------------------------------------------------: | :------------------------------------ | +| `control` | [`PlaybackControlName`](/docs/system/playback-notification-manager#playbackcontrolname) | The control to enable/disable | +| `enabled` | `boolean` | Whether the control should be enabled | + +#### Returns `Promise`. + +### `isActive` + +Check if the notification is currently active and visible. + +#### Returns `Promise`. + +### `addEventListener` + +Add an event listener for notification actions. + +| Parameter | Type | Description | +| :---------: | :---------------------------------------------------------------------------------------------------------: | :---------------------- | +| `eventName` | [`PlaybackNotificationEventName`](/docs/system/playback-notification-manager#playbacknotificationeventname) | The event to listen for | +| `callback` | [`SystemEventCallback`](/docs/system/audio-manager#systemeventname--remotecommandeventname) | Callback function | + +#### Returns [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription). + +### `removeEventListener` + +Remove an event listener. + +| Parameter | Type | Description | +| :------------: | :---------------------------------------------------------------------------: | :------------------------- | +| `subscription` | [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription) | The subscription to remove | + +## Remarks + +### `PlaybackNotificationInfo` + +
+Type definitions +```typescript +interface PlaybackNotificationInfo { + title?: string; + artist?: string; + album?: string; + artwork?: string | { uri: string }; + duration?: number; + elapsedTime?: number; + speed?: number; + state?: 'playing' | 'paused'; +} +``` +
+ +### `PlaybackControlName` + +
+ Type definitions + ```typescript + type PlaybackControlName = + | 'play' + | 'pause' + | 'next' + | 'previous' + | 'skipForward' + | 'skipBackward'; + ``` +
+ +### `PlaybackNotificationEventName` + +
+ Type definitions + ```typescript + type PlaybackNotificationEventName = + | 'playbackNotificationPlay' + | 'playbackNotificationPause' + | 'playbackNotificationNext' + | 'playbackNotificationPrevious' + | 'playbackNotificationSkipForward' + | 'playbackNotificationSkipBackward' + | 'playbackNotificationDismissed'; + ``` +
+ +``` diff --git a/packages/audiodocs/docs/system/simple-notification-manager.mdx b/packages/audiodocs/docs/system/simple-notification-manager.mdx new file mode 100644 index 000000000..da801f202 --- /dev/null +++ b/packages/audiodocs/docs/system/simple-notification-manager.mdx @@ -0,0 +1,176 @@ +--- +sidebar_position: 3 +--- + +import { + Optional, + ReadOnly, + OnlyiOS, + Experimental, +} from '@site/src/components/Badges'; + +# SimpleNotificationManager + +The `SimpleNotificationManager` provides basic notification functionality for displaying simple notifications with title and text. It's designed for use cases where you need to show basic status notifications without media controls. + +## Example + +```tsx +import { SimpleNotificationManager } from 'react-native-audio-api'; +import { useEffect } from 'react'; + +function AudioProcessor() { + const showProcessingNotification = async () => { + try { + // Register the notification + await SimpleNotificationManager.register(); + + // Show processing notification + await SimpleNotificationManager.show({ + title: 'Processing Audio', + text: 'Please wait while we process your audio file...', + }); + + // Update when complete + setTimeout(async () => { + await SimpleNotificationManager.update({ + title: 'Processing Complete', + text: 'Your audio has been processed successfully!', + }); + + // Hide after a delay + setTimeout(async () => { + await SimpleNotificationManager.hide(); + await SimpleNotificationManager.unregister(); + }, 3000); + }, 5000); + } catch (error) { + console.error('Notification error:', error); + } + }; + + return ; +} +``` + +## Methods + +### `register` + +Register the simple notification with the system. Must be called before showing any notification. + +#### Returns `Promise`. + +#### Errors + +| Error type | Description | +| :--------: | :---------------------------------------------------------- | +| `Error` | NativeAudioAPIModule is not available or registration fails | + +### `show` + +Display the notification with initial options. + +| Parameter | Type | Description | +| :-------: | :-----------------------------------------------------------------------------------------------: | :--------------------------- | +| `options` | [`SimpleNotificationOptions`](/docs/system/simple-notification-manager#simplenotificationoptions) | Initial notification options | + +#### Returns `Promise`. + +#### Errors + +| Error type | Description | +| :--------: | :----------------------------------------------------------------- | +| `Error` | Notification must be registered first or native module unavailable | + +### `update` + +Update the notification with new options. + +| Parameter | Type | Description | +| :-------: | :-----------------------------------------------------------------------------------------------: | :--------------------------- | +| `options` | [`SimpleNotificationOptions`](/docs/system/simple-notification-manager#simplenotificationoptions) | Updated notification options | + +#### Returns `Promise`. + +### `hide` + +Hide the notification. Can be shown again later by calling `show()`. + +#### Returns `Promise`. + +### `unregister` + +Unregister the notification from the system. Must call `register()` again to use. + +#### Returns `Promise`. + +### `isActive` + +Check if the notification is currently active and visible. + +#### Returns `Promise`. + +### `addEventListener` + +Add an event listener for notification actions. Note: SimpleNotificationManager does not emit events, so this method provides a no-op subscription. + +| Parameter | Type | Description | +| :---------: | :----------------------: | :-------------------- | +| `eventName` | `never` | No events are emitted | +| `callback` | `(event: never) => void` | Callback function | + +#### Returns [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription). + +#### Note + +This method will log a warning since SimpleNotificationManager does not support event listeners. + +### `removeEventListener` + +Remove an event listener. + +| Parameter | Type | Description | +| :------------: | :---------------------------------------------------------------------------: | :------------------------- | +| `subscription` | [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription) | The subscription to remove | + +## Remarks + +### `SimpleNotificationOptions` + +
+Type definitions +```typescript +interface SimpleNotificationOptions { + title?: string; + text?: string; +} +``` +
+ +## Use Cases + +SimpleNotificationManager is ideal for: + +- Status updates during audio processing +- Progress notifications +- Simple alerts and confirmations +- Background task status updates + +## Limitations + +- No interactive controls +- No event emission +- Basic styling only +- Limited to title and text content + +## Comparison with PlaybackNotificationManager + +| Feature | SimpleNotificationManager | PlaybackNotificationManager | +| :---------------: | :-----------------------: | :-------------------------: | +| Media Controls | ❌ | ✅ | +| Event Listeners | ❌ | ✅ | +| Artwork Support | ❌ | ✅ | +| Progress Tracking | ❌ | ✅ | +| Basic Text | ✅ | ✅ | +| Simplicity | ✅ | ❌ | From 12db627c4a29bfb6e75816031feaf9f1db61ac35 Mon Sep 17 00:00:00 2001 From: miloszwielgus Date: Thu, 13 Nov 2025 13:32:51 +0100 Subject: [PATCH 08/19] fix: minor doc imrovements --- .../audiodocs/docs/system/audio-manager.mdx | 2 +- .../system/playback-notification-manager.mdx | 69 ++----- .../system/simple-notification-manager.mdx | 176 ------------------ 3 files changed, 15 insertions(+), 232 deletions(-) delete mode 100644 packages/audiodocs/docs/system/simple-notification-manager.mdx diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index 1d16fc452..e28a24eaf 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -243,7 +243,7 @@ interface EventTypeWithValue { interface OnInterruptionEventType { type: 'ended' | 'began'; //if interruption event has started or ended - isTransient: boolean; //if we should resume playing after interruption + isTransient: boolean; //if the interruption was temporary } interface OnRouteChangeEventType { diff --git a/packages/audiodocs/docs/system/playback-notification-manager.mdx b/packages/audiodocs/docs/system/playback-notification-manager.mdx index 5fc01cc2c..d8042f01a 100644 --- a/packages/audiodocs/docs/system/playback-notification-manager.mdx +++ b/packages/audiodocs/docs/system/playback-notification-manager.mdx @@ -16,51 +16,7 @@ The `PlaybackNotificationManager` provides media session integration and playbac ## Example ```tsx -import { PlaybackNotificationManager } from 'react-native-audio-api'; -import { useEffect } from 'react'; - -function AudioPlayer() { - useEffect(() => { - // Register the notification - PlaybackNotificationManager.register(); - - // Show initial notification - PlaybackNotificationManager.show({ - title: 'My Song', - artist: 'Artist Name', - album: 'Album Name', - duration: 180, - }); - - // Add event listeners for controls - const playListener = PlaybackNotificationManager.addEventListener( - 'playbackNotificationPlay', - () => { - // Handle play action - PlaybackNotificationManager.update({ state: 'playing' }); - } - ); - - const pauseListener = PlaybackNotificationManager.addEventListener( - 'playbackNotificationPause', - () => { - // Handle pause action - PlaybackNotificationManager.update({ state: 'paused' }); - } - ); - - return () => { - playListener.remove(); - pauseListener.remove(); - PlaybackNotificationManager.unregister(); - }; - }, []); - - // Update playback position - const updateProgress = (elapsedTime: number) => { - PlaybackNotificationManager.update({ elapsedTime }); - }; -} +// TO DO ``` ## Methods @@ -190,16 +146,19 @@ interface PlaybackNotificationInfo {
Type definitions - ```typescript - type PlaybackNotificationEventName = - | 'playbackNotificationPlay' - | 'playbackNotificationPause' - | 'playbackNotificationNext' - | 'playbackNotificationPrevious' - | 'playbackNotificationSkipForward' - | 'playbackNotificationSkipBackward' - | 'playbackNotificationDismissed'; - ``` +```typescript + interface PlaybackNotificationEvent { + playbackNotificationPlay: EventEmptyType; + playbackNotificationPause: EventEmptyType; + playbackNotificationNext: EventEmptyType; + playbackNotificationPrevious: EventEmptyType; + playbackNotificationSkipForward: EventTypeWithValue; + playbackNotificationSkipBackward: EventTypeWithValue; + playbackNotificationDismissed: EventEmptyType; + } + + type PlaybackNotificationEventName = keyof PlaybackNotificationEvent; + ```
``` diff --git a/packages/audiodocs/docs/system/simple-notification-manager.mdx b/packages/audiodocs/docs/system/simple-notification-manager.mdx deleted file mode 100644 index da801f202..000000000 --- a/packages/audiodocs/docs/system/simple-notification-manager.mdx +++ /dev/null @@ -1,176 +0,0 @@ ---- -sidebar_position: 3 ---- - -import { - Optional, - ReadOnly, - OnlyiOS, - Experimental, -} from '@site/src/components/Badges'; - -# SimpleNotificationManager - -The `SimpleNotificationManager` provides basic notification functionality for displaying simple notifications with title and text. It's designed for use cases where you need to show basic status notifications without media controls. - -## Example - -```tsx -import { SimpleNotificationManager } from 'react-native-audio-api'; -import { useEffect } from 'react'; - -function AudioProcessor() { - const showProcessingNotification = async () => { - try { - // Register the notification - await SimpleNotificationManager.register(); - - // Show processing notification - await SimpleNotificationManager.show({ - title: 'Processing Audio', - text: 'Please wait while we process your audio file...', - }); - - // Update when complete - setTimeout(async () => { - await SimpleNotificationManager.update({ - title: 'Processing Complete', - text: 'Your audio has been processed successfully!', - }); - - // Hide after a delay - setTimeout(async () => { - await SimpleNotificationManager.hide(); - await SimpleNotificationManager.unregister(); - }, 3000); - }, 5000); - } catch (error) { - console.error('Notification error:', error); - } - }; - - return ; -} -``` - -## Methods - -### `register` - -Register the simple notification with the system. Must be called before showing any notification. - -#### Returns `Promise`. - -#### Errors - -| Error type | Description | -| :--------: | :---------------------------------------------------------- | -| `Error` | NativeAudioAPIModule is not available or registration fails | - -### `show` - -Display the notification with initial options. - -| Parameter | Type | Description | -| :-------: | :-----------------------------------------------------------------------------------------------: | :--------------------------- | -| `options` | [`SimpleNotificationOptions`](/docs/system/simple-notification-manager#simplenotificationoptions) | Initial notification options | - -#### Returns `Promise`. - -#### Errors - -| Error type | Description | -| :--------: | :----------------------------------------------------------------- | -| `Error` | Notification must be registered first or native module unavailable | - -### `update` - -Update the notification with new options. - -| Parameter | Type | Description | -| :-------: | :-----------------------------------------------------------------------------------------------: | :--------------------------- | -| `options` | [`SimpleNotificationOptions`](/docs/system/simple-notification-manager#simplenotificationoptions) | Updated notification options | - -#### Returns `Promise`. - -### `hide` - -Hide the notification. Can be shown again later by calling `show()`. - -#### Returns `Promise`. - -### `unregister` - -Unregister the notification from the system. Must call `register()` again to use. - -#### Returns `Promise`. - -### `isActive` - -Check if the notification is currently active and visible. - -#### Returns `Promise`. - -### `addEventListener` - -Add an event listener for notification actions. Note: SimpleNotificationManager does not emit events, so this method provides a no-op subscription. - -| Parameter | Type | Description | -| :---------: | :----------------------: | :-------------------- | -| `eventName` | `never` | No events are emitted | -| `callback` | `(event: never) => void` | Callback function | - -#### Returns [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription). - -#### Note - -This method will log a warning since SimpleNotificationManager does not support event listeners. - -### `removeEventListener` - -Remove an event listener. - -| Parameter | Type | Description | -| :------------: | :---------------------------------------------------------------------------: | :------------------------- | -| `subscription` | [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription) | The subscription to remove | - -## Remarks - -### `SimpleNotificationOptions` - -
-Type definitions -```typescript -interface SimpleNotificationOptions { - title?: string; - text?: string; -} -``` -
- -## Use Cases - -SimpleNotificationManager is ideal for: - -- Status updates during audio processing -- Progress notifications -- Simple alerts and confirmations -- Background task status updates - -## Limitations - -- No interactive controls -- No event emission -- Basic styling only -- Limited to title and text content - -## Comparison with PlaybackNotificationManager - -| Feature | SimpleNotificationManager | PlaybackNotificationManager | -| :---------------: | :-----------------------: | :-------------------------: | -| Media Controls | ❌ | ✅ | -| Event Listeners | ❌ | ✅ | -| Artwork Support | ❌ | ✅ | -| Progress Tracking | ❌ | ✅ | -| Basic Text | ✅ | ✅ | -| Simplicity | ✅ | ❌ | From 8b8a402e8d81d4fd87324678db57c7dab4367953 Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 13 Nov 2025 15:16:40 +0100 Subject: [PATCH 09/19] feat: recording notification --- .../audioapi/system/MediaSessionManager.kt | 21 + .../notification/RecordingNotification.kt | 435 ++++++++++++++++++ .../RecordingNotificationReceiver.kt | 33 ++ packages/react-native-audio-api/src/api.ts | 8 +- .../RecordingNotificationManager.ts | 193 ++++++++ .../src/system/notification/index.ts | 1 + .../src/system/notification/types.ts | 22 +- 7 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt create mode 100644 packages/react-native-audio-api/src/system/notification/RecordingNotificationManager.ts 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 6d0833bf7..cd9b6cda1 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,6 +26,8 @@ import com.swmansion.audioapi.system.PermissionRequestListener.Companion.RECORDI import com.swmansion.audioapi.system.notification.NotificationRegistry import com.swmansion.audioapi.system.notification.PlaybackNotification import com.swmansion.audioapi.system.notification.PlaybackNotificationReceiver +import com.swmansion.audioapi.system.notification.RecordingNotification +import com.swmansion.audioapi.system.notification.RecordingNotificationReceiver import com.swmansion.audioapi.system.notification.SimpleNotification import java.lang.ref.WeakReference import java.util.UUID @@ -40,6 +42,7 @@ object MediaSessionManager { private lateinit var audioFocusListener: AudioFocusListener private lateinit var volumeChangeListener: VolumeChangeListener private lateinit var playbackNotificationReceiver: PlaybackNotificationReceiver + private lateinit var recordingNotificationReceiver: RecordingNotificationReceiver // New notification system private lateinit var notificationRegistry: NotificationRegistry @@ -76,6 +79,23 @@ object MediaSessionManager { ) } + // Set up RecordingNotificationReceiver + RecordingNotificationReceiver.setAudioAPIModule(audioAPIModule.get()) + this.recordingNotificationReceiver = RecordingNotificationReceiver() + + // Register RecordingNotificationReceiver + val recordingFilter = IntentFilter(RecordingNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.reactContext.get()!!.registerReceiver(recordingNotificationReceiver, recordingFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + ContextCompat.registerReceiver( + this.reactContext.get()!!, + recordingNotificationReceiver, + recordingFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + this.audioFocusListener = AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule) this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule) @@ -293,6 +313,7 @@ object MediaSessionManager { when (type) { "simple" -> SimpleNotification(reactContext) "playback" -> PlaybackNotification(reactContext, audioAPIModule, 100, "audio_playback") + "recording" -> RecordingNotification(reactContext, audioAPIModule, 101, "audio_recording") else -> throw IllegalArgumentException("Unknown notification type: $type") } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt new file mode 100644 index 000000000..05bdb744f --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -0,0 +1,435 @@ +package com.swmansion.audioapi.system.notification + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.provider.ContactsContract +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import android.view.KeyEvent +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.swmansion.audioapi.AudioAPIModule +import java.io.IOException +import java.lang.ref.WeakReference +import java.net.URL + +/** + * RecordingNotification + * + * This notification: + * - Shows recording status and metadata + * - Supports recording controls (start, stop) + * - Is persistent and cannot be swiped away when recording + * - Notifies its dismissal via RecordingNotificationReceiver + */ +class RecordingNotification( + private val reactContext: WeakReference, + private val audioAPIModule: WeakReference, + private val notificationId: Int, + private val channelId: String, +) : BaseNotification { + companion object { + private const val TAG = "RecordingNotification" + const val MEDIA_BUTTON = "recording_notification_media_button" + const val PACKAGE_NAME = "com.swmansion.audioapi.recording" + } + + private var mediaSession: MediaSessionCompat? = null + private var notificationBuilder: NotificationCompat.Builder? = null + private var playbackStateBuilder: PlaybackStateCompat.Builder = PlaybackStateCompat.Builder() + + private var enabledControls: Long = 0 + private var isRecording: Boolean = false + + // Metadata + private var title: String? = null + private var artwork: Bitmap? = null + + // Actions + private var startAction: NotificationCompat.Action? = null + private var stopAction: NotificationCompat.Action? = null + + private var artworkThread: Thread? = null + + override fun init(params: ReadableMap?): Notification { + val context = reactContext.get() ?: throw IllegalStateException("React context is null") + + // Create notification channel first + createNotificationChannel() + + // Create MediaSession + mediaSession = MediaSessionCompat(context, "RecordingNotification") + mediaSession?.isActive = true + + // Set up media session callbacks + mediaSession?.setCallback( + object : MediaSessionCompat.Callback() { + override fun onCustomAction(action: String, extras: android.os.Bundle?) { + Log.d(TAG, "MediaSession: onCustomAction ($action)") + when (action) { + "START_RECORDING" -> { + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("recordingNotificationStart", mapOf()) + } + "STOP_RECORDING" -> { + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("recordingNotificationStop", mapOf()) + } + } + } + }, + ) + + // Create notification builder + notificationBuilder = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(true) // Make it persistent (can't swipe away) + + // Set content intent to open app + val packageName = context.packageName + val openAppIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (openAppIntent != null) { + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + notificationBuilder?.setContentIntent(pendingIntent) + } + + // Set delete intent to handle dismissal + val deleteIntent = Intent(RecordingNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) + deleteIntent.setPackage(context.packageName) + val deletePendingIntent = + PendingIntent.getBroadcast( + context, + notificationId, + deleteIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + notificationBuilder?.setDeleteIntent(deletePendingIntent) + + // Enable default controls + enableControl("start", true) + enableControl("stop", true) + + updateMediaStyle() + updatePlaybackState() + + // Apply initial params if provided + if (params != null) { + update(params) + } + + return buildNotification() + } + + override fun reset() { + // Interrupt artwork loading if in progress + artworkThread?.interrupt() + artworkThread = null + + // Reset metadata + title = null + artwork = null + isRecording = false + + // Reset media session + val emptyMetadata = MediaMetadataCompat.Builder().build() + mediaSession?.setMetadata(emptyMetadata) + + mediaSession?.isActive = false + mediaSession?.release() + mediaSession = null + } + + override fun getNotificationId(): Int = notificationId + + override fun getChannelId(): String = channelId + + override fun update(options: ReadableMap?): Notification { + if (options == null) { + return buildNotification() + } + + // Handle control enable/disable + if (options.hasKey("control") && options.hasKey("enabled")) { + val control = options.getString("control") + val enabled = options.getBoolean("enabled") + if (control != null) { + enableControl(control, enabled) + } + return buildNotification() + } + + // Update metadata + if (options.hasKey("title")) { + title = options.getString("title") + } + + // Update recording state + if (options.hasKey("state")) { + when (options.getString("state")) { + "recording" -> { + isRecording = true + } + "stopped" -> { + isRecording = false + } + } + } + + // Build MediaMetadata + val metadataBuilder = + MediaMetadataCompat + .Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + + // Update notification builder + notificationBuilder?.setContentTitle(title) + val statusText = if (isRecording) "Recording..." else "Ready to record" + notificationBuilder?.setContentText(statusText) + + // Handle artwork + if (options.hasKey("artwork")) { + artworkThread?.interrupt() + + val artworkUrl: String? + val isLocal: Boolean + + if (options.getType("artwork") == ReadableType.Map) { + artworkUrl = options.getMap("artwork")?.getString("uri") + isLocal = true + } else { + artworkUrl = options.getString("artwork") + isLocal = false + } + + if (artworkUrl != null) { + artworkThread = + Thread { + try { + val bitmap = loadArtwork(artworkUrl, isLocal) + if (bitmap != null) { + artwork = bitmap + metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + notificationBuilder?.setLargeIcon(bitmap) + } + artworkThread = null + } catch (e: Exception) { + Log.e(TAG, "Error loading artwork: ${e.message}", e) + } + } + artworkThread?.start() + } + } + + mediaSession?.setMetadata(metadataBuilder.build()) + mediaSession?.isActive = true + + // Update ongoing state - only persistent when recording + notificationBuilder?.setOngoing(isRecording) + + // Update media style to reflect current state + updatePlaybackState() + updateMediaStyle() + + return buildNotification() + } + + private fun buildNotification(): Notification = + notificationBuilder?.build() + ?: throw IllegalStateException("Notification not initialized. Call init() first.") + + private fun updatePlaybackState() { + // Set playback state with custom actions to preserve custom icons + val state = if (isRecording) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_STOPPED + + // Clear previous state and rebuild + playbackStateBuilder = PlaybackStateCompat.Builder() + playbackStateBuilder.setState(state, 0, 1.0f) + + // Add only the appropriate custom action based on current state + if (!isRecording && (enabledControls and PlaybackStateCompat.ACTION_PLAY) != 0L) { + // Show START button when not recording + val startAction = PlaybackStateCompat.CustomAction.Builder( + "START_RECORDING", + "Start Recording", + android.R.drawable.ic_btn_speak_now + ).build() + playbackStateBuilder.addCustomAction(startAction) + } else if (isRecording && (enabledControls and PlaybackStateCompat.ACTION_PAUSE) != 0L) { + // Show STOP button when recording + val stopAction = PlaybackStateCompat.CustomAction.Builder( + "STOP_RECORDING", + "Stop Recording", + android.R.drawable.ic_media_pause + ).build() + playbackStateBuilder.addCustomAction(stopAction) + } + + mediaSession?.setPlaybackState(playbackStateBuilder.build()) + } + + /** + * Enable or disable a specific control action. + */ + private fun enableControl( + name: String, + enabled: Boolean, + ) { + val controlValue = + when (name) { + "start" -> PlaybackStateCompat.ACTION_PLAY + // Use PAUSE action so the system shows a pause button (consistent with PlaybackNotification) + "stop" -> PlaybackStateCompat.ACTION_PAUSE + else -> 0L + } + + if (controlValue == 0L) return + + enabledControls = + if (enabled) { + enabledControls or controlValue + } else { + enabledControls and controlValue.inv() + } + + // Update actions + updateActions() + updateMediaStyle() + updatePlaybackState() + } + + private fun updateActions() { + val context = reactContext.get() ?: return + + startAction = + createAction( + "start", + "Start Recording", + android.R.drawable.ic_btn_speak_now, // Microphone icon + PlaybackStateCompat.ACTION_PLAY, + ) + + stopAction = + createAction( + "stop", + "Stop Recording", + android.R.drawable.ic_media_pause, + PlaybackStateCompat.ACTION_PAUSE, + ) + } + + private fun createAction( + name: String, + title: String, + icon: Int, + action: Long, + ): NotificationCompat.Action? { + val context = reactContext.get() ?: return null + + if ((enabledControls and action) == 0L) { + return null + } + + val keyCode = PlaybackStateCompat.toKeyCode(action) + val intent = Intent(MEDIA_BUTTON) + intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + intent.putExtra(ContactsContract.Directory.PACKAGE_NAME, context.packageName) + + val pendingIntent = + PendingIntent.getBroadcast( + context, + keyCode, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + return NotificationCompat.Action(icon, title, pendingIntent) + } + + private fun updateMediaStyle() { + val style = MediaStyle() + style.setMediaSession(mediaSession?.sessionToken) + notificationBuilder?.clearActions() + style.setShowActionsInCompactView(0, 1, 2) + notificationBuilder?.setStyle(style) + } + + private fun loadArtwork( + url: String, + isLocal: Boolean, + ): Bitmap? { + val context = reactContext.get() ?: return null + + return try { + if (isLocal && !url.startsWith("http")) { + // Load local resource + val helper = + com.facebook.react.views.imagehelper.ResourceDrawableIdHelper + .getInstance() + val drawable = helper.getResourceDrawable(context, url) + + if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + BitmapFactory.decodeFile(url) + } + } else { + // Load from URL + val connection = URL(url).openConnection() + connection.connect() + val inputStream = connection.getInputStream() + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream.close() + bitmap + } + } catch (e: IOException) { + Log.e(TAG, "Failed to load artwork: ${e.message}", e) + null + } catch (e: Exception) { + Log.e(TAG, "Error loading artwork: ${e.message}", e) + null + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val context = reactContext.get() ?: return + + val channel = + android.app + .NotificationChannel( + channelId, + "Audio Recording", + android.app.NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Recording controls and status" + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + notificationManager.createNotificationChannel(channel) + + Log.d(TAG, "Notification channel created: $channelId") + } + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt new file mode 100644 index 000000000..9dd5b19de --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt @@ -0,0 +1,33 @@ +package com.swmansion.audioapi.system.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.swmansion.audioapi.AudioAPIModule + +/** + * Broadcast receiver for handling recording notification dismissal. + */ +class RecordingNotificationReceiver : BroadcastReceiver() { + companion object { + const val ACTION_NOTIFICATION_DISMISSED = "com.swmansion.audioapi.RECORDING_NOTIFICATION_DISMISSED" + private const val TAG = "RecordingNotificationReceiver" + + private var audioAPIModule: AudioAPIModule? = null + + fun setAudioAPIModule(module: AudioAPIModule?) { + audioAPIModule = module + } + } + + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent?.action == ACTION_NOTIFICATION_DISMISSED) { + Log.d(TAG, "Recording notification dismissed by user") + audioAPIModule?.invokeHandlerWithEventNameAndEventBody("recordingNotificationDismissed", mapOf()) + } + } +} diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 844817928..077f4b203 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -81,7 +81,10 @@ export { decodeAudioData, decodePCMInBase64 } from './core/AudioDecoder'; export { default as changePlaybackSpeed } from './core/AudioStretcher'; // Notification System -export { PlaybackNotificationManager } from './system/notification'; +export { + PlaybackNotificationManager, + RecordingNotificationManager, +} from './system/notification'; export { OscillatorType, @@ -107,6 +110,9 @@ export { PlaybackNotificationInfo, PlaybackControlName, PlaybackNotificationEventName, + RecordingNotificationInfo, + RecordingControlName, + RecordingNotificationEventName, SimpleNotificationOptions, } from './system/notification'; diff --git a/packages/react-native-audio-api/src/system/notification/RecordingNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/RecordingNotificationManager.ts new file mode 100644 index 000000000..23e56a56a --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/RecordingNotificationManager.ts @@ -0,0 +1,193 @@ +import { NativeAudioAPIModule } from '../../specs'; +import { AudioEventEmitter, AudioEventSubscription } from '../../events'; +import type { + NotificationManager, + RecordingNotificationInfo, + RecordingControlName, + RecordingNotificationEventName, + NotificationEvents, +} from './types'; + +/// Manager for recording notifications with controls. +class RecordingNotificationManager + implements + NotificationManager< + RecordingNotificationInfo, + RecordingNotificationInfo, + RecordingNotificationEventName + > +{ + private notificationKey = 'recording'; + private isRegistered = false; + private isShown = false; + private audioEventEmitter: AudioEventEmitter; + + constructor() { + this.audioEventEmitter = new AudioEventEmitter(global.AudioEventEmitter); + } + + /// Register the recording notification (must be called before showing). + async register(): Promise { + if (this.isRegistered) { + console.warn('RecordingNotification is already registered'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.registerNotification( + 'recording', + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isRegistered = true; + } + + /// Show the notification with initial metadata. + async show(info: RecordingNotificationInfo): Promise { + if (!this.isRegistered) { + throw new Error( + 'RecordingNotification must be registered before showing. Call register() first.' + ); + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.showNotification( + this.notificationKey, + info as Record + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isShown = true; + } + + /// Update the notification with new metadata or state. + async update(info: RecordingNotificationInfo): Promise { + if (!this.isShown) { + console.warn('RecordingNotification is not shown. Call show() first.'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.updateNotification( + this.notificationKey, + info as Record + ); + + if (result.error) { + throw new Error(result.error); + } + } + + /// Hide the notification (can be shown again later). + async hide(): Promise { + if (!this.isShown) { + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.hideNotification( + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isShown = false; + } + + /// Unregister the notification (must register again to use). + async unregister(): Promise { + if (!this.isRegistered) { + return; + } + + if (this.isShown) { + await this.hide(); + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const result = await NativeAudioAPIModule.unregisterNotification( + this.notificationKey + ); + + if (result.error) { + throw new Error(result.error); + } + + this.isRegistered = false; + } + + /// Enable or disable a specific recording control. + async enableControl( + control: RecordingControlName, + enabled: boolean + ): Promise { + if (!this.isRegistered) { + console.warn('RecordingNotification is not registered'); + return; + } + + if (!NativeAudioAPIModule) { + throw new Error('NativeAudioAPIModule is not available'); + } + + const params = { control, enabled }; + const result = await NativeAudioAPIModule.updateNotification( + this.notificationKey, + params as Record + ); + + if (result.error) { + throw new Error(result.error); + } + } + + /// Check if the notification is currently active. + async isActive(): Promise { + if (!NativeAudioAPIModule) { + return false; + } + + return await NativeAudioAPIModule.isNotificationActive( + this.notificationKey + ); + } + + /// Add an event listener for notification actions. + addEventListener( + eventName: T, + callback: (event: NotificationEvents[T]) => void + ): AudioEventSubscription { + return this.audioEventEmitter.addAudioEventListener(eventName, callback); + } + + /** Remove an event listener. */ + removeEventListener(subscription: AudioEventSubscription): void { + subscription.remove(); + } +} + +export default new RecordingNotificationManager(); diff --git a/packages/react-native-audio-api/src/system/notification/index.ts b/packages/react-native-audio-api/src/system/notification/index.ts index 4f937cc0d..3ea837c3e 100644 --- a/packages/react-native-audio-api/src/system/notification/index.ts +++ b/packages/react-native-audio-api/src/system/notification/index.ts @@ -1,3 +1,4 @@ export { default as PlaybackNotificationManager } from './PlaybackNotificationManager'; +export { default as RecordingNotificationManager } from './RecordingNotificationManager'; export { default as SimpleNotificationManager } from './SimpleNotificationManager'; export * from './types'; diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index 7df2b9572..f57a51b2d 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -70,7 +70,27 @@ interface PlaybackNotificationEvent { export type PlaybackNotificationEventName = keyof PlaybackNotificationEvent; -export type NotificationEvents = PlaybackNotificationEvent; +/// Metadata and state information for recording notifications. +export interface RecordingNotificationInfo { + title?: string; + artwork?: string | { uri: string }; + state?: 'recording' | 'stopped'; +} + +/// Available recording control actions. +export type RecordingControlName = 'start' | 'stop'; + +/// Event names for recording notification actions. +interface RecordingNotificationEvent { + recordingNotificationStart: EventEmptyType; + recordingNotificationStop: EventEmptyType; + recordingNotificationDismissed: EventEmptyType; +} + +export type RecordingNotificationEventName = keyof RecordingNotificationEvent; + +export type NotificationEvents = PlaybackNotificationEvent & + RecordingNotificationEvent; export type NotificationEventName = keyof NotificationEvents; export type NotificationCallback = ( From 0812d1c09916df1fb018531d9f964f3551e9b868 Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 13 Nov 2025 15:18:43 +0100 Subject: [PATCH 10/19] chore: formatting --- .../notification/RecordingNotification.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index 05bdb744f..f2a9319cb 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -75,7 +75,10 @@ class RecordingNotification( // Set up media session callbacks mediaSession?.setCallback( object : MediaSessionCompat.Callback() { - override fun onCustomAction(action: String, extras: android.os.Bundle?) { + override fun onCustomAction( + action: String, + extras: android.os.Bundle?, + ) { Log.d(TAG, "MediaSession: onCustomAction ($action)") when (action) { "START_RECORDING" -> { @@ -267,19 +270,23 @@ class RecordingNotification( // Add only the appropriate custom action based on current state if (!isRecording && (enabledControls and PlaybackStateCompat.ACTION_PLAY) != 0L) { // Show START button when not recording - val startAction = PlaybackStateCompat.CustomAction.Builder( - "START_RECORDING", - "Start Recording", - android.R.drawable.ic_btn_speak_now - ).build() + val startAction = + PlaybackStateCompat.CustomAction + .Builder( + "START_RECORDING", + "Start Recording", + android.R.drawable.ic_btn_speak_now, + ).build() playbackStateBuilder.addCustomAction(startAction) } else if (isRecording && (enabledControls and PlaybackStateCompat.ACTION_PAUSE) != 0L) { // Show STOP button when recording - val stopAction = PlaybackStateCompat.CustomAction.Builder( - "STOP_RECORDING", - "Stop Recording", - android.R.drawable.ic_media_pause - ).build() + val stopAction = + PlaybackStateCompat.CustomAction + .Builder( + "STOP_RECORDING", + "Stop Recording", + android.R.drawable.ic_media_pause, + ).build() playbackStateBuilder.addCustomAction(stopAction) } From 61624a5e0d3dab42744cb62da3acc7b1a97a45eb Mon Sep 17 00:00:00 2001 From: miloszwielgus Date: Thu, 13 Nov 2025 15:41:57 +0100 Subject: [PATCH 11/19] feat: recording notification manager docs --- .../system/recording-notification-manager.mdx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/audiodocs/docs/system/recording-notification-manager.mdx diff --git a/packages/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx new file mode 100644 index 000000000..ee06f93f3 --- /dev/null +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -0,0 +1,167 @@ +--- +sidebar_position: 4 +--- + +import { + Optional, + ReadOnly, + OnlyiOS, + Experimental, +} from '@site/src/components/Badges'; + +# RecordingNotificationManager + +The `RecordingNotificationManager` provides recording session integration and recording controls for your audio application. It manages system-level recording notifications with controls like start and stop functionality. + +## Example + +```tsx +// TODO +``` + +## Methods + +### `register` + +Register the recording notification with the system. Must be called before showing any notification. + +#### Returns `Promise`. + +#### Errors + +| Error type | Description | +| :--------: | :---------------------------------------------------------- | +| `Error` | NativeAudioAPIModule is not available or registration fails | + +### `show` + +Display the notification with initial metadata. + +| Parameter | Type | Description | +| :-------: | :--------------------------------------------------------------------------------------------------: | :---------------------------- | +| `info` | [`RecordingNotificationInfo`](/docs/system/recording-notification-manager#recordingnotificationinfo) | Initial notification metadata | + +#### Returns `Promise`. + +#### Errors + +| Error type | Description | +| :--------: | :----------------------------------------------------------------- | +| `Error` | Notification must be registered first or native module unavailable | + +### `update` + +Update the notification with new metadata or state. + +| Parameter | Type | Description | +| :-------: | :--------------------------------------------------------------------------------------------------: | :---------------------------- | +| `info` | [`RecordingNotificationInfo`](/docs/system/recording-notification-manager#recordingnotificationinfo) | Updated notification metadata | + +#### Returns `Promise`. + +### `hide` + +Hide the notification. Can be shown again later by calling `show()`. + +#### Returns `Promise`. + +### `unregister` + +Unregister the notification from the system. Must call `register()` again to use. + +#### Returns `Promise`. + +### `enableControl` + +Enable or disable specific recording controls. + +| Parameter | Type | Description | +| :-------: | :----------------------------------------------------------------------------------------: | :------------------------------------ | +| `control` | [`RecordingControlName`](/docs/system/recording-notification-manager#recordingcontrolname) | The control to enable/disable | +| `enabled` | `boolean` | Whether the control should be enabled | + +#### Returns `Promise`. + +### `isActive` + +Check if the notification is currently active and visible. + +#### Returns `Promise`. + +### `addEventListener` + +Add an event listener for notification actions. + +| Parameter | Type | Description | +| :---------: | :------------------------------------------------------------------------------------------------------------: | :---------------------- | +| `eventName` | [`RecordingNotificationEventName`](/docs/system/recording-notification-manager#recordingnotificationeventname) | The event to listen for | +| `callback` | [`NotificationCallback`](/docs/system/recording-notification-manager#notificationcallback) | Callback function | + +#### Returns [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription). + +### `removeEventListener` + +Remove an event listener. + +| Parameter | Type | Description | +| :------------: | :---------------------------------------------------------------------------: | :------------------------- | +| `subscription` | [`AudioEventSubscription`](/docs/system/audio-manager#audioeventsubscription) | The subscription to remove | + +## Remarks + +### `RecordingNotificationInfo` + +
+Type definitions +```typescript +interface RecordingNotificationInfo { + title?: string; + artwork?: string | { uri: string }; + state?: 'recording' | 'stopped'; +} +``` +
+ +### `RecordingControlName` + +
+ Type definitions + ```typescript + type RecordingControlName = 'start' | 'stop'; + ``` +
+ +### `RecordingNotificationEventName` + +
+ Type definitions + ```typescript + interface RecordingNotificationEvent { + recordingNotificationStart: EventEmptyType; + recordingNotificationStop: EventEmptyType; + recordingNotificationDismissed: EventEmptyType; + } + + type RecordingNotificationEventName = keyof RecordingNotificationEvent; + + ``` +
+ +### `NotificationCallback` + +
+Type definitions +```typescript +type NotificationEvents = PlaybackNotificationEvent & RecordingNotificationEvent; + +type NotificationEventName = keyof NotificationEvents; + +type NotificationCallback = ( + event: NotificationEvents[Name] +) => void; +``` +
+ + + + From 1300350a89eaa2d8581eb3b4fc40d15e1b1b929a Mon Sep 17 00:00:00 2001 From: miloszwielgus Date: Fri, 14 Nov 2025 14:22:26 +0100 Subject: [PATCH 12/19] feat: mock web methods --- .../react-native-audio-api/src/api.web.ts | 7 +++ .../src/web-system/index.ts | 1 + .../PlaybackNotificationManager.ts | 60 +++++++++++++++++++ .../RecordingNotificationManager.ts | 60 +++++++++++++++++++ .../src/web-system/notification/index.ts | 2 + 5 files changed, 130 insertions(+) create mode 100644 packages/react-native-audio-api/src/web-system/index.ts create mode 100644 packages/react-native-audio-api/src/web-system/notification/PlaybackNotificationManager.ts create mode 100644 packages/react-native-audio-api/src/web-system/notification/RecordingNotificationManager.ts create mode 100644 packages/react-native-audio-api/src/web-system/notification/index.ts diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index f6ae34685..22571c557 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -35,6 +35,13 @@ export { PermissionStatus, } from './system/types'; +export { + PlaybackNotificationManager, + RecordingNotificationManager, +} from './web-system'; + +export * from './system/notification/types'; + export { IndexSizeError, InvalidAccessError, diff --git a/packages/react-native-audio-api/src/web-system/index.ts b/packages/react-native-audio-api/src/web-system/index.ts new file mode 100644 index 000000000..d9b217ce3 --- /dev/null +++ b/packages/react-native-audio-api/src/web-system/index.ts @@ -0,0 +1 @@ +export * from './notification'; diff --git a/packages/react-native-audio-api/src/web-system/notification/PlaybackNotificationManager.ts b/packages/react-native-audio-api/src/web-system/notification/PlaybackNotificationManager.ts new file mode 100644 index 000000000..61ff5b75e --- /dev/null +++ b/packages/react-native-audio-api/src/web-system/notification/PlaybackNotificationManager.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-useless-constructor */ +/* eslint-disable @typescript-eslint/require-await */ + +import type { AudioEventSubscription } from '../../events'; +import type { + NotificationManager, + PlaybackNotificationInfo, + PlaybackControlName, + PlaybackNotificationEventName, + NotificationEvents, +} from '../../system'; + +/// Mock Manager for playback notifications. Does nothing. +class PlaybackNotificationManager + implements + NotificationManager< + PlaybackNotificationInfo, + PlaybackNotificationInfo, + PlaybackNotificationEventName + > +{ + private isRegistered = false; + private isShown = false; + + constructor() {} + + async register(): Promise {} + + async show(info: PlaybackNotificationInfo): Promise {} + + async update(info: PlaybackNotificationInfo): Promise {} + + async hide(): Promise {} + + async unregister(): Promise {} + + async enableControl( + control: PlaybackControlName, + enabled: boolean + ): Promise {} + + async isActive(): Promise { + return this.isShown; + } + + addEventListener( + eventName: T, + callback: (event: NotificationEvents[T]) => void + ): AudioEventSubscription { + // dummy subscription object with a no-op remove method + return { + remove: () => {}, + } as any; + } + + removeEventListener(subscription: AudioEventSubscription): void {} +} + +export default new PlaybackNotificationManager(); diff --git a/packages/react-native-audio-api/src/web-system/notification/RecordingNotificationManager.ts b/packages/react-native-audio-api/src/web-system/notification/RecordingNotificationManager.ts new file mode 100644 index 000000000..1af0a0b6f --- /dev/null +++ b/packages/react-native-audio-api/src/web-system/notification/RecordingNotificationManager.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-useless-constructor */ +/* eslint-disable @typescript-eslint/require-await */ + +import type { AudioEventSubscription } from '../../events'; +import type { + NotificationManager, + RecordingNotificationInfo, + RecordingControlName, + RecordingNotificationEventName, + NotificationEvents, +} from '../../system'; + +/// Mock Manager for recording notifications. Does nothing. +class RecordingNotificationManager + implements + NotificationManager< + RecordingNotificationInfo, + RecordingNotificationInfo, + RecordingNotificationEventName + > +{ + private isRegistered = false; + private isShown = false; + + constructor() {} + + async register(): Promise {} + + async show(info: RecordingNotificationInfo): Promise {} + + async update(info: RecordingNotificationInfo): Promise {} + + async hide(): Promise {} + + async unregister(): Promise {} + + async enableControl( + control: RecordingControlName, + enabled: boolean + ): Promise {} + + async isActive(): Promise { + return this.isShown; + } + + addEventListener( + eventName: T, + callback: (event: NotificationEvents[T]) => void + ): AudioEventSubscription { + // dummy subscription object with a no-op remove method + return { + remove: () => {}, + } as any; + } + + removeEventListener(subscription: AudioEventSubscription): void {} +} + +export default new RecordingNotificationManager(); diff --git a/packages/react-native-audio-api/src/web-system/notification/index.ts b/packages/react-native-audio-api/src/web-system/notification/index.ts new file mode 100644 index 000000000..794ba1b90 --- /dev/null +++ b/packages/react-native-audio-api/src/web-system/notification/index.ts @@ -0,0 +1,2 @@ +export { default as PlaybackNotificationManager } from './PlaybackNotificationManager'; +export { default as RecordingNotificationManager } from './RecordingNotificationManager'; From af1ebd7261beb04eb942bc0fc380c30a49347c84 Mon Sep 17 00:00:00 2001 From: poneciak Date: Fri, 14 Nov 2025 20:47:11 +0100 Subject: [PATCH 13/19] feat: ios omfg why is it so bad --- .../src/examples/AudioFile/AudioFile.tsx | 1 + .../src/examples/AudioFile/AudioPlayer.ts | 6 +- .../PlaybackNotification.tsx | 30 +- .../system/playback-notification-manager.mdx | 29 ++ .../ios/audioapi/ios/AudioAPIModule.h | 2 + .../ios/audioapi/ios/AudioAPIModule.mm | 118 ++++- .../system/notification/BaseNotification.h | 58 +++ .../notification/NotificationRegistry.h | 70 +++ .../notification/NotificationRegistry.mm | 172 ++++++++ .../notification/PlaybackNotification.h | 27 ++ .../notification/PlaybackNotification.mm | 415 ++++++++++++++++++ 11 files changed, 907 insertions(+), 21 deletions(-) create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/BaseNotification.h create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index b18c2d80a..b5a741fc3 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -58,6 +58,7 @@ const AudioFile: FC = () => { album: 'Audio API', duration: duration, state: 'paused', + speed: 1.0, elapsedTime: 0, }); } catch (error) { diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index cbda09f0e..c02ffa3ab 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -59,7 +59,8 @@ class AudioPlayer { this.sourceNode.start(this.audioContext.currentTime, this.offset); PlaybackNotificationManager.update({ - state: 'playing' + state: 'playing', + elapsedTime: this.offset, }); }; @@ -72,7 +73,8 @@ class AudioPlayer { this.sourceNode?.stop(this.audioContext.currentTime); PlaybackNotificationManager.update({ - state: 'paused' + state: 'paused', + elapsedTime: this.offset, }); await this.audioContext.suspend(); diff --git a/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx b/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx index d59830f99..54bc7412f 100644 --- a/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx +++ b/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; -import { PlaybackNotificationManager, AudioManager } from 'react-native-audio-api'; +import { PlaybackNotificationManager, AudioManager, AudioContext } from 'react-native-audio-api'; import type { PermissionStatus } from 'react-native-audio-api'; import { Container } from '../../components'; import { colors } from '../../styles'; @@ -10,8 +10,13 @@ export const PlaybackNotificationExample: React.FC = () => { const [isRegistered, setIsRegistered] = useState(false); const [isShown, setIsShown] = useState(false); const [isPlaying, setIsPlaying] = useState(false); + const audioContextRef = useRef(null); useEffect(() => { + // Create AudioContext in suspended state + // Will be resumed when showing notification + audioContextRef.current = new AudioContext({ initSuspended: true }); + checkPermissions(); // Add event listeners for notification actions @@ -80,6 +85,12 @@ export const PlaybackNotificationExample: React.FC = () => { skipForwardListener.remove(); skipBackwardListener.remove(); dismissListener.remove(); + + // Cleanup AudioContext + if (audioContextRef.current) { + audioContextRef.current.close(); + audioContextRef.current = null; + } }; }, []); @@ -105,16 +116,21 @@ export const PlaybackNotificationExample: React.FC = () => { const handleShow = async () => { try { + // Resume audio context to activate audio session on iOS + if (audioContextRef.current?.state === 'suspended') { + await audioContextRef.current.resume(); + } + await PlaybackNotificationManager.show({ title: 'My Audio Track', artist: 'Artist Name', album: 'Album Name', - state: 'paused', + state: 'playing', duration: 180, elapsedTime: 0, }); setIsShown(true); - setIsPlaying(false); + setIsPlaying(true); console.log('Playback notification shown'); } catch (error) { console.error('Failed to show:', error); @@ -162,6 +178,12 @@ export const PlaybackNotificationExample: React.FC = () => { const handleHide = async () => { try { await PlaybackNotificationManager.hide(); + + // Suspend audio context to deactivate audio session on iOS + if (audioContextRef.current?.state === 'running') { + await audioContextRef.current.suspend(); + } + setIsShown(false); setIsPlaying(false); console.log('Playback notification hidden'); diff --git a/packages/audiodocs/docs/system/playback-notification-manager.mdx b/packages/audiodocs/docs/system/playback-notification-manager.mdx index d8042f01a..587d6d254 100644 --- a/packages/audiodocs/docs/system/playback-notification-manager.mdx +++ b/packages/audiodocs/docs/system/playback-notification-manager.mdx @@ -13,6 +13,21 @@ import { The `PlaybackNotificationManager` provides media session integration and playback controls for your audio application. It manages system-level media notifications with controls like play, pause, next, previous, and seek functionality. +:::info Platform Differences + +**iOS Requirements:** +- Notification controls only appear when an active `AudioContext` is running +- `show()`, `update()` or `hide()` only update metadata - they don't control notification visibility +- The notification automatically appears/disappears based on audio session state +- To show: create and resume an AudioContext +- To hide: suspend or close the AudioContext + +**Android:** +- Notification visibility is directly controlled by `show()` and `hide()` methods +- Works independently of AudioContext state + +::: + ## Example ```tsx @@ -37,6 +52,10 @@ Register the playback notification with the system. Must be called before showin Display the notification with initial metadata. +:::note iOS Behavior +On iOS, this method only sets the metadata. The notification controls will only appear when an `AudioContext` is actively running. Make sure to create and resume an AudioContext before calling `show()`. +::: + | Parameter | Type | Description | | :-------: | :-----------------------------------------------------------------------------------------------: | :---------------------------- | | `info` | [`PlaybackNotificationInfo`](/docs/system/playback-notification-manager#playbacknotificationinfo) | Initial notification metadata | @@ -53,6 +72,10 @@ Display the notification with initial metadata. Update the notification with new metadata or state. +:::note iOS Behavior +On iOS, this method only updates the metadata. It does not control notification visibility - controls remain visible as long as the AudioContext is running. +::: + | Parameter | Type | Description | | :-------: | :-----------------------------------------------------------------------------------------------: | :---------------------------- | | `info` | [`PlaybackNotificationInfo`](/docs/system/playback-notification-manager#playbacknotificationinfo) | Updated notification metadata | @@ -63,6 +86,10 @@ Update the notification with new metadata or state. Hide the notification. Can be shown again later by calling `show()`. +:::note iOS Behavior +On iOS, this method clears the metadata but does not hide the notification controls. To completely hide controls on iOS, you must suspend or close the AudioContext. +::: + #### Returns `Promise`. ### `unregister` @@ -120,6 +147,8 @@ interface PlaybackNotificationInfo { album?: string; artwork?: string | { uri: string }; duration?: number; + + // IOS: elapsed time does not update automatically, must be set manually on each state change elapsedTime?: number; speed?: number; state?: 'playing' | 'paused'; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.h b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.h index 3e2a4d9dc..44be17191 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.h @@ -12,6 +12,7 @@ @class NotificationManager; @class AudioSessionManager; @class LockScreenManager; +@class NotificationRegistry; @interface AudioAPIModule : RCTEventEmitter #ifdef RCT_NEW_ARCH_ENABLED @@ -24,6 +25,7 @@ @property (nonatomic, strong) NotificationManager *notificationManager; @property (nonatomic, strong) AudioSessionManager *audioSessionManager; @property (nonatomic, strong) LockScreenManager *lockScreenManager; +@property (nonatomic, strong) NotificationRegistry *notificationRegistry; - (void)invokeHandlerWithEventName:(NSString *)eventName eventBody:(NSDictionary *)eventBody; 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 baecd10f2..5f8d9aae7 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -13,6 +13,7 @@ #import #import #import +#import #import @@ -51,6 +52,7 @@ - (void)invalidate [self.notificationManager cleanup]; [self.audioSessionManager cleanup]; [self.lockScreenManager cleanup]; + [self.notificationRegistry cleanup]; _eventHandler = nullptr; @@ -65,6 +67,7 @@ - (void)invalidate self.audioEngine = [[AudioEngine alloc] initWithAudioSessionManager:self.audioSessionManager]; self.lockScreenManager = [[LockScreenManager alloc] initWithAudioAPIModule:self]; self.notificationManager = [[NotificationManager alloc] initWithAudioAPIModule:self]; + self.notificationRegistry = [[NotificationRegistry alloc] initWithAudioAPIModule:self]; auto jsiRuntime = reinterpret_cast(self.bridge.runtime); @@ -139,21 +142,6 @@ - (void)invalidate [self.audioSessionManager setAudioSessionOptions:category mode:mode options:options allowHaptics:allowHaptics]; } -RCT_EXPORT_METHOD(setLockScreenInfo : (NSDictionary *)info) -{ - [self.lockScreenManager setLockScreenInfo:info]; -} - -RCT_EXPORT_METHOD(resetLockScreenInfo) -{ - [self.lockScreenManager resetLockScreenInfo]; -} - -RCT_EXPORT_METHOD(enableRemoteCommand : (NSString *)name enabled : (BOOL)enabled) -{ - [self.lockScreenManager enableRemoteCommand:name enabled:enabled]; -} - RCT_EXPORT_METHOD(observeAudioInterruptions : (BOOL)enabled) { [self.notificationManager observeAudioInterruptions:enabled]; @@ -182,6 +170,23 @@ - (void)invalidate [self.audioSessionManager checkRecordingPermissions:resolve reject:reject]; } +RCT_EXPORT_METHOD( + requestNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock)reject) +{ + // iOS doesn't require explicit notification permissions for media controls + // MPNowPlayingInfoCenter and MPRemoteCommandCenter work without permissions + // Return 'granted' to match the spec interface + resolve(@"granted"); +} + +RCT_EXPORT_METHOD( + checkNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock)reject) +{ + // iOS doesn't require explicit notification permissions for media controls + // Return 'granted' to match the spec interface + resolve(@"granted"); +} + RCT_EXPORT_METHOD( getDevicesInfo : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock)reject) { @@ -193,6 +198,89 @@ - (void)invalidate [self.audioSessionManager disableSessionManagement]; } +// New notification system methods +RCT_EXPORT_METHOD( + registerNotification : (NSString *)type + key : (NSString *)key + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + BOOL success = [self.notificationRegistry registerNotificationType:type withKey:key]; + + if (success) { + resolve(@{@"success" : @YES}); + } else { + resolve(@{@"success" : @NO, @"error" : @"Failed to register notification"}); + } +} + +RCT_EXPORT_METHOD( + showNotification : (NSString *)key + options : (NSDictionary *)options + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + BOOL success = [self.notificationRegistry showNotificationWithKey:key options:options]; + + if (success) { + resolve(@{@"success" : @YES}); + } else { + resolve(@{@"success" : @NO, @"error" : @"Failed to show notification"}); + } +} + +RCT_EXPORT_METHOD( + updateNotification : (NSString *)key + options : (NSDictionary *)options + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + BOOL success = [self.notificationRegistry updateNotificationWithKey:key options:options]; + + if (success) { + resolve(@{@"success" : @YES}); + } else { + resolve(@{@"success" : @NO, @"error" : @"Failed to update notification"}); + } +} + +RCT_EXPORT_METHOD( + hideNotification : (NSString *)key + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + BOOL success = [self.notificationRegistry hideNotificationWithKey:key]; + + if (success) { + resolve(@{@"success" : @YES}); + } else { + resolve(@{@"success" : @NO, @"error" : @"Failed to hide notification"}); + } +} + +RCT_EXPORT_METHOD( + unregisterNotification : (NSString *)key + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + BOOL success = [self.notificationRegistry unregisterNotificationWithKey:key]; + + if (success) { + resolve(@{@"success" : @YES}); + } else { + resolve(@{@"success" : @NO, @"error" : @"Failed to unregister notification"}); + } +} + +RCT_EXPORT_METHOD( + isNotificationActive : (NSString *)key + resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + BOOL isActive = [self.notificationRegistry isNotificationActiveWithKey:key]; + resolve(@(isActive)); +} + #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/BaseNotification.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/BaseNotification.h new file mode 100644 index 000000000..f68bdbfd0 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/BaseNotification.h @@ -0,0 +1,58 @@ +#pragma once + +#import + +/** + * BaseNotification protocol + * + * Interface that all notification types must implement. + */ +@protocol BaseNotification + +@required + +/** + * Initialize the notification. + * @param options Initialization options (can be nil) + * @return YES if successful + */ +- (BOOL)initializeWithOptions:(NSDictionary *)options; + +/** + * Show the notification (sets metadata on iOS). + * @param options Notification options + * @return YES if successful + */ +- (BOOL)showWithOptions:(NSDictionary *)options; + +/** + * Update notification metadata. + * @param options Updated information + * @return YES if successful + */ +- (BOOL)updateWithOptions:(NSDictionary *)options; + +/** + * Hide the notification (clears metadata on iOS). + * @return YES if successful + */ +- (BOOL)hide; + +/** + * Clean up and release resources. + */ +- (void)cleanup; + +/** + * Check if notification is active. + * @return YES if active + */ +- (BOOL)isActive; + +/** + * Get notification type identifier. + * @return Type identifier (e.g., "playback", "recording") + */ +- (NSString *)getNotificationType; + +@end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h new file mode 100644 index 000000000..b0f629080 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h @@ -0,0 +1,70 @@ +#pragma once + +#import +#import + +@class AudioAPIModule; + +/** + * NotificationRegistry + * + * Central manager for all notification types. + * Manages registration, lifecycle, and routing of notification implementations. + */ +@interface NotificationRegistry : NSObject + +@property (nonatomic, weak) AudioAPIModule *audioAPIModule; + +- (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule; + +/** + * Register a new notification type. + * @param type The notification type identifier (e.g., "playback", "recording") + * @param key Unique key for this notification instance + * @return YES if registration succeeded, NO otherwise + */ +- (BOOL)registerNotificationType:(NSString *)type withKey:(NSString *)key; + +/** + * Show a registered notification. + * @param key The notification key + * @param options Options for showing the notification + * @return YES if successful, NO otherwise + */ +- (BOOL)showNotificationWithKey:(NSString *)key options:(NSDictionary *)options; + +/** + * Update a shown notification. + * @param key The notification key + * @param options Updated options + * @return YES if successful, NO otherwise + */ +- (BOOL)updateNotificationWithKey:(NSString *)key options:(NSDictionary *)options; + +/** + * Hide a notification. + * @param key The notification key + * @return YES if successful, NO otherwise + */ +- (BOOL)hideNotificationWithKey:(NSString *)key; + +/** + * Unregister and clean up a notification. + * @param key The notification key + * @return YES if successful, NO otherwise + */ +- (BOOL)unregisterNotificationWithKey:(NSString *)key; + +/** + * Check if a notification is active. + * @param key The notification key + * @return YES if active, NO otherwise + */ +- (BOOL)isNotificationActiveWithKey:(NSString *)key; + +/** + * Clean up all notifications. + */ +- (void)cleanup; + +@end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm new file mode 100644 index 000000000..031b7ece1 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm @@ -0,0 +1,172 @@ +#import +#import +#import + +@implementation NotificationRegistry { + NSMutableDictionary> *_notifications; +} + +- (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule +{ + if (self = [super init]) { + self.audioAPIModule = audioAPIModule; + _notifications = [[NSMutableDictionary alloc] init]; + + NSLog(@"[NotificationRegistry] Initialized"); + } + + return self; +} + +- (BOOL)registerNotificationType:(NSString *)type withKey:(NSString *)key +{ + if (!type || !key) { + NSLog(@"[NotificationRegistry] Invalid type or key"); + return NO; + } + + // Check if already registered + if (_notifications[key]) { + NSLog(@"[NotificationRegistry] Notification with key '%@' already registered", key); + return NO; + } + + // Create the appropriate notification type + id notification = [self createNotificationForType:type]; + + if (!notification) { + NSLog(@"[NotificationRegistry] Unknown notification type: %@", type); + return NO; + } + + // Store the notification + _notifications[key] = notification; + + NSLog(@"[NotificationRegistry] Registered notification type '%@' with key '%@'", type, key); + return YES; +} + +- (BOOL)showNotificationWithKey:(NSString *)key options:(NSDictionary *)options +{ + id notification = _notifications[key]; + + if (!notification) { + NSLog(@"[NotificationRegistry] No notification found with key: %@", key); + return NO; + } + + // Initialize if first time showing + if (![notification isActive]) { + if (![notification initializeWithOptions:options]) { + NSLog(@"[NotificationRegistry] Failed to initialize notification: %@", key); + return NO; + } + } + + BOOL success = [notification showWithOptions:options]; + + if (success) { + NSLog(@"[NotificationRegistry] Showed notification: %@", key); + } else { + NSLog(@"[NotificationRegistry] Failed to show notification: %@", key); + } + + return success; +} + +- (BOOL)updateNotificationWithKey:(NSString *)key options:(NSDictionary *)options +{ + id notification = _notifications[key]; + + if (!notification) { + NSLog(@"[NotificationRegistry] No notification found with key: %@", key); + return NO; + } + + BOOL success = [notification updateWithOptions:options]; + + if (success) { + NSLog(@"[NotificationRegistry] Updated notification: %@", key); + } else { + NSLog(@"[NotificationRegistry] Failed to update notification: %@", key); + } + + return success; +} + +- (BOOL)hideNotificationWithKey:(NSString *)key +{ + id notification = _notifications[key]; + + if (!notification) { + NSLog(@"[NotificationRegistry] No notification found with key: %@", key); + return NO; + } + + BOOL success = [notification hide]; + + if (success) { + NSLog(@"[NotificationRegistry] Hid notification: %@", key); + } else { + NSLog(@"[NotificationRegistry] Failed to hide notification: %@", key); + } + + return success; +} + +- (BOOL)unregisterNotificationWithKey:(NSString *)key +{ + id notification = _notifications[key]; + + if (!notification) { + NSLog(@"[NotificationRegistry] No notification found with key: %@", key); + return NO; + } + + // Clean up and remove + [notification cleanup]; + [_notifications removeObjectForKey:key]; + + NSLog(@"[NotificationRegistry] Unregistered notification: %@", key); + return YES; +} + +- (BOOL)isNotificationActiveWithKey:(NSString *)key +{ + id notification = _notifications[key]; + + if (!notification) { + return NO; + } + + return [notification isActive]; +} + +- (void)cleanup +{ + NSLog(@"[NotificationRegistry] Cleaning up all notifications"); + + // Clean up all notifications + for (id notification in [_notifications allValues]) { + [notification cleanup]; + } + + [_notifications removeAllObjects]; +} + +#pragma mark - Private Methods + +- (id)createNotificationForType:(NSString *)type +{ + if ([type isEqualToString:@"playback"]) { + return [[PlaybackNotification alloc] initWithAudioAPIModule:self.audioAPIModule]; + } + // Future: Add more notification types here + // else if ([type isEqualToString:@"recording"]) { + // return [[RecordingNotification alloc] initWithAudioAPIModule:self.audioAPIModule]; + // } + + return nil; +} + +@end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h new file mode 100644 index 000000000..efa049211 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h @@ -0,0 +1,27 @@ +#pragma once + +#import +#import +#import + +@class AudioAPIModule; + +/** + * PlaybackNotification + * + * iOS playback notification using MPNowPlayingInfoCenter and MPRemoteCommandCenter. + * Provides lock screen controls, Control Center integration, and Now Playing display. + * + * Note: On iOS, this only manages metadata. Notification visibility is controlled + * by the AudioContext state (active audio session shows controls). + */ +@interface PlaybackNotification : NSObject + +@property (nonatomic, weak) AudioAPIModule *audioAPIModule; +@property (nonatomic, weak) MPNowPlayingInfoCenter *playingInfoCenter; +@property (nonatomic, copy) NSString *artworkUrl; +@property (nonatomic, assign) BOOL isActive; + +- (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule; + +@end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm new file mode 100644 index 000000000..1841448a5 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm @@ -0,0 +1,415 @@ +#import +#import + +#define NOW_PLAYING_INFO_KEYS \ + @{ \ + @"title" : MPMediaItemPropertyTitle, \ + @"artist" : MPMediaItemPropertyArtist, \ + @"album" : MPMediaItemPropertyAlbumTitle, \ + @"duration" : MPMediaItemPropertyPlaybackDuration, \ + @"elapsedTime" : MPNowPlayingInfoPropertyElapsedPlaybackTime, \ + @"speed" : MPNowPlayingInfoPropertyPlaybackRate \ + } + +@implementation PlaybackNotification { + BOOL _isInitialized; + NSMutableDictionary *_currentInfo; +} + +- (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule +{ + if (self = [super init]) { + self.audioAPIModule = audioAPIModule; + self.playingInfoCenter = [MPNowPlayingInfoCenter defaultCenter]; + _isInitialized = NO; + _isActive = NO; + _currentInfo = [[NSMutableDictionary alloc] init]; + } + + return self; +} + +#pragma mark - BaseNotification Protocol + +- (BOOL)initializeWithOptions:(NSDictionary *)options +{ + if (_isInitialized) { + return YES; + } + + // Enable remote control events + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIApplication sharedApplication] beginReceivingRemoteControlEvents]; + }); + + // Enable default remote commands + [self enableRemoteCommand:@"play" enabled:YES]; + [self enableRemoteCommand:@"pause" enabled:YES]; + [self enableRemoteCommand:@"next" enabled:YES]; + [self enableRemoteCommand:@"previous" enabled:YES]; + [self enableRemoteCommand:@"skipForward" enabled:YES]; + [self enableRemoteCommand:@"skipBackward" enabled:YES]; + + _isInitialized = YES; + return YES; +} + +- (BOOL)showWithOptions:(NSDictionary *)options +{ + if (!_isInitialized) { + if (![self initializeWithOptions:options]) { + return NO; + } + } + + // Update the now playing info + [self updateNowPlayingInfo:options]; + + _isActive = YES; + + return YES; +} + +- (BOOL)updateWithOptions:(NSDictionary *)options +{ + if (!_isActive) { + return NO; + } + + // Handle control enable/disable + if (options[@"control"] && options[@"enabled"]) { + NSString *control = options[@"control"]; + BOOL enabled = [options[@"enabled"] boolValue]; + [self enableControl:control enabled:enabled]; + return YES; + } + + // Update the now playing info + [self updateNowPlayingInfo:options]; + + return YES; +} + +- (BOOL)hide +{ + if (!_isActive) { + return YES; + } + + // Clear now playing info + self.playingInfoCenter.nowPlayingInfo = nil; + self.artworkUrl = nil; + [_currentInfo removeAllObjects]; + + _isActive = NO; + + return YES; +} + +- (void)cleanup +{ + // Hide if active + if (_isActive) { + [self hide]; + } + + // Disable all remote commands + MPRemoteCommandCenter *remoteCenter = [MPRemoteCommandCenter sharedCommandCenter]; + [remoteCenter.playCommand removeTarget:self]; + [remoteCenter.pauseCommand removeTarget:self]; + [remoteCenter.stopCommand removeTarget:self]; + [remoteCenter.togglePlayPauseCommand removeTarget:self]; + [remoteCenter.nextTrackCommand removeTarget:self]; + [remoteCenter.previousTrackCommand removeTarget:self]; + [remoteCenter.skipForwardCommand removeTarget:self]; + [remoteCenter.skipBackwardCommand removeTarget:self]; + [remoteCenter.seekForwardCommand removeTarget:self]; + [remoteCenter.seekBackwardCommand removeTarget:self]; + [remoteCenter.changePlaybackPositionCommand removeTarget:self]; + + // Disable remote control events + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIApplication sharedApplication] endReceivingRemoteControlEvents]; + }); + + _isInitialized = NO; +} + +- (BOOL)isActive +{ + return _isActive; +} + +- (NSString *)getNotificationType +{ + return @"playback"; +} + +#pragma mark - Private Methods + +- (void)updateNowPlayingInfo:(NSDictionary *)info +{ + if (!info) { + return; + } + + // Get existing now playing info or create new one + NSMutableDictionary *nowPlayingInfo = [self.playingInfoCenter.nowPlayingInfo mutableCopy]; + if (!nowPlayingInfo) { + nowPlayingInfo = [[NSMutableDictionary alloc] init]; + } + + // Map keys from our API to MPNowPlayingInfoCenter keys + NSDictionary *keyMap = NOW_PLAYING_INFO_KEYS; + + // Only update the keys that are provided in this update + for (NSString *key in info) { + NSString *mpKey = keyMap[key]; + if (mpKey) { + nowPlayingInfo[mpKey] = info[key]; + _currentInfo[key] = info[key]; + } + } + + self.playingInfoCenter.nowPlayingInfo = nowPlayingInfo; + + // Handle playback state + NSString *state = _currentInfo[@"state"]; + MPNowPlayingPlaybackState playbackState = MPNowPlayingPlaybackStatePaused; + + if (state) { + if ([state isEqualToString:@"playing"]) { + playbackState = MPNowPlayingPlaybackStatePlaying; + } else if ([state isEqualToString:@"paused"]) { + playbackState = MPNowPlayingPlaybackStatePaused; + } else if ([state isEqualToString:@"stopped"]) { + playbackState = MPNowPlayingPlaybackStateStopped; + } else { + playbackState = MPNowPlayingPlaybackStatePaused; + } + } + + self.playingInfoCenter.playbackState = playbackState; + + // Handle artwork + NSString *artworkUrl = [self getArtworkUrl:_currentInfo[@"artwork"]]; + [self updateArtworkIfNeeded:artworkUrl]; +} + +- (NSString *)getArtworkUrl:(id)artwork +{ + if (!artwork) { + return nil; + } + + // Handle both string and dictionary formats + if ([artwork isKindOfClass:[NSString class]]) { + return artwork; + } else if ([artwork isKindOfClass:[NSDictionary class]]) { + return artwork[@"uri"]; + } + + return nil; +} + +- (void)updateArtworkIfNeeded:(NSString *)artworkUrl +{ + if (!artworkUrl) { + return; + } + + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; + if ([artworkUrl isEqualToString:self.artworkUrl] && + center.nowPlayingInfo[MPMediaItemPropertyArtwork] != nil) { + return; + } + + self.artworkUrl = artworkUrl; + + // Load artwork asynchronously + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + NSURL *url = nil; + NSData *imageData = nil; + UIImage *image = nil; + + @try { + if ([artworkUrl hasPrefix:@"http://"] || [artworkUrl hasPrefix:@"https://"]) { + // Remote URL + url = [NSURL URLWithString:artworkUrl]; + imageData = [NSData dataWithContentsOfURL:url]; + } else { + // Local file - try as resource or file path + NSString *imagePath = [[NSBundle mainBundle] pathForResource:artworkUrl ofType:nil]; + if (imagePath) { + imageData = [NSData dataWithContentsOfFile:imagePath]; + } else { + // Try as absolute path + imageData = [NSData dataWithContentsOfFile:artworkUrl]; + } + } + + if (imageData) { + image = [UIImage imageWithData:imageData]; + } + } + @catch (NSException *exception) { + // Failed to load artwork + } + + if (image) { + MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] + initWithBoundsSize:image.size + requestHandler:^UIImage * _Nonnull(CGSize size) { + return image; + }]; + + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableDictionary *nowPlayingInfo = [center.nowPlayingInfo mutableCopy]; + if (!nowPlayingInfo) { + nowPlayingInfo = [[NSMutableDictionary alloc] init]; + } + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork; + center.nowPlayingInfo = nowPlayingInfo; + }); + } + }); +} + +- (void)enableControl:(NSString *)control enabled:(BOOL)enabled +{ + // Map control names to remote commands + NSString *remoteCommandName = nil; + + if ([control isEqualToString:@"play"]) { + remoteCommandName = @"play"; + } else if ([control isEqualToString:@"pause"]) { + remoteCommandName = @"pause"; + } else if ([control isEqualToString:@"next"]) { + remoteCommandName = @"next"; + } else if ([control isEqualToString:@"previous"]) { + remoteCommandName = @"previous"; + } else if ([control isEqualToString:@"skipForward"]) { + remoteCommandName = @"skipForward"; + } else if ([control isEqualToString:@"skipBackward"]) { + remoteCommandName = @"skipBackward"; + } else if ([control isEqualToString:@"seek"]) { + remoteCommandName = @"seek"; + } + + if (remoteCommandName) { + [self enableRemoteCommand:remoteCommandName enabled:enabled]; + } +} + +- (void)enableRemoteCommand:(NSString *)name enabled:(BOOL)enabled +{ + MPRemoteCommandCenter *remoteCenter = [MPRemoteCommandCenter sharedCommandCenter]; + + if ([name isEqualToString:@"play"]) { + [self enableCommand:remoteCenter.playCommand withSelector:@selector(onPlay:) enabled:enabled]; + } else if ([name isEqualToString:@"pause"]) { + [self enableCommand:remoteCenter.pauseCommand withSelector:@selector(onPause:) enabled:enabled]; + } else if ([name isEqualToString:@"stop"]) { + [self enableCommand:remoteCenter.stopCommand withSelector:@selector(onStop:) enabled:enabled]; + } else if ([name isEqualToString:@"togglePlayPause"]) { + [self enableCommand:remoteCenter.togglePlayPauseCommand withSelector:@selector(onTogglePlayPause:) enabled:enabled]; + } else if ([name isEqualToString:@"next"]) { + [self enableCommand:remoteCenter.nextTrackCommand withSelector:@selector(onNextTrack:) enabled:enabled]; + } else if ([name isEqualToString:@"previous"]) { + [self enableCommand:remoteCenter.previousTrackCommand withSelector:@selector(onPreviousTrack:) enabled:enabled]; + } else if ([name isEqualToString:@"skipForward"]) { + remoteCenter.skipForwardCommand.preferredIntervals = @[@(15)]; + [self enableCommand:remoteCenter.skipForwardCommand withSelector:@selector(onSkipForward:) enabled:enabled]; + } else if ([name isEqualToString:@"skipBackward"]) { + remoteCenter.skipBackwardCommand.preferredIntervals = @[@(15)]; + [self enableCommand:remoteCenter.skipBackwardCommand withSelector:@selector(onSkipBackward:) enabled:enabled]; + } else if ([name isEqualToString:@"seekForward"]) { + [self enableCommand:remoteCenter.seekForwardCommand withSelector:@selector(onSeekForward:) enabled:enabled]; + } else if ([name isEqualToString:@"seekBackward"]) { + [self enableCommand:remoteCenter.seekBackwardCommand withSelector:@selector(onSeekBackward:) enabled:enabled]; + } else if ([name isEqualToString:@"seek"]) { + [self enableCommand:remoteCenter.changePlaybackPositionCommand withSelector:@selector(onChangePlaybackPosition:) enabled:enabled]; + } +} + +- (void)enableCommand:(MPRemoteCommand *)command withSelector:(SEL)selector enabled:(BOOL)enabled +{ + [command removeTarget:self]; + command.enabled = enabled; + if (enabled) { + [command addTarget:self action:selector]; + } +} + +#pragma mark - Remote Command Handlers + +- (MPRemoteCommandHandlerStatus)onPlay:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationPlay" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onPause:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationPause" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onStop:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationStop" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onTogglePlayPause:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationTogglePlayPause" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onNextTrack:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationNext" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onPreviousTrack:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationPrevious" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onSeekForward:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSeekForward" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onSeekBackward:(MPRemoteCommandEvent *)event +{ + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSeekBackward" eventBody:@{}]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onSkipForward:(MPSkipIntervalCommandEvent *)event +{ + NSDictionary *body = @{@"interval" : @(event.interval)}; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSkipForward" eventBody:body]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onSkipBackward:(MPSkipIntervalCommandEvent *)event +{ + NSDictionary *body = @{@"interval" : @(event.interval)}; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSkipBackward" eventBody:body]; + return MPRemoteCommandHandlerStatusSuccess; +} + +- (MPRemoteCommandHandlerStatus)onChangePlaybackPosition:(MPChangePlaybackPositionCommandEvent *)event +{ + NSDictionary *body = @{@"position" : @(event.positionTime)}; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSeek" eventBody:body]; + return MPRemoteCommandHandlerStatusSuccess; +} + +@end From 6ac246a287f136e30cb607b2292e59fb89bf2999 Mon Sep 17 00:00:00 2001 From: poneciak Date: Sun, 16 Nov 2025 17:39:00 +0100 Subject: [PATCH 14/19] chore: formatting --- .../ios/audioapi/ios/AudioAPIModule.mm | 38 ++++++++----------- .../notification/NotificationRegistry.mm | 2 +- .../notification/PlaybackNotification.mm | 25 ++++++------ 3 files changed, 28 insertions(+), 37 deletions(-) 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 5f8d9aae7..cf84cd5de 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -171,7 +171,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - requestNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock)reject) + requestNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock) + reject) { // iOS doesn't require explicit notification permissions for media controls // MPNowPlayingInfoCenter and MPRemoteCommandCenter work without permissions @@ -180,7 +181,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - checkNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock)reject) + checkNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock) + reject) { // iOS doesn't require explicit notification permissions for media controls // Return 'granted' to match the spec interface @@ -200,10 +202,8 @@ - (void)invalidate // New notification system methods RCT_EXPORT_METHOD( - registerNotification : (NSString *)type - key : (NSString *)key - resolve : (RCTPromiseResolveBlock)resolve - reject : (RCTPromiseRejectBlock)reject) + registerNotification : (NSString *)type key : (NSString *)key resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry registerNotificationType:type withKey:key]; @@ -215,10 +215,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - showNotification : (NSString *)key - options : (NSDictionary *)options - resolve : (RCTPromiseResolveBlock)resolve - reject : (RCTPromiseRejectBlock)reject) + showNotification : (NSString *)key options : (NSDictionary *)options resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry showNotificationWithKey:key options:options]; @@ -230,10 +228,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - updateNotification : (NSString *)key - options : (NSDictionary *)options - resolve : (RCTPromiseResolveBlock)resolve - reject : (RCTPromiseRejectBlock)reject) + updateNotification : (NSString *)key options : (NSDictionary *)options resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry updateNotificationWithKey:key options:options]; @@ -245,9 +241,7 @@ - (void)invalidate } RCT_EXPORT_METHOD( - hideNotification : (NSString *)key - resolve : (RCTPromiseResolveBlock)resolve - reject : (RCTPromiseRejectBlock)reject) + hideNotification : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry hideNotificationWithKey:key]; @@ -259,9 +253,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - unregisterNotification : (NSString *)key - resolve : (RCTPromiseResolveBlock)resolve - reject : (RCTPromiseRejectBlock)reject) + unregisterNotification : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock) + reject) { BOOL success = [self.notificationRegistry unregisterNotificationWithKey:key]; @@ -273,9 +266,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - isNotificationActive : (NSString *)key - resolve : (RCTPromiseResolveBlock)resolve - reject : (RCTPromiseRejectBlock)reject) + isNotificationActive : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock) + reject) { BOOL isActive = [self.notificationRegistry isNotificationActiveWithKey:key]; resolve(@(isActive)); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm index 031b7ece1..8ba80faaa 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm @@ -1,6 +1,6 @@ +#import #import #import -#import @implementation NotificationRegistry { NSMutableDictionary> *_notifications; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm index 1841448a5..99fae3f7a 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm @@ -1,5 +1,5 @@ -#import #import +#import #define NOW_PLAYING_INFO_KEYS \ @{ \ @@ -219,8 +219,7 @@ - (void)updateArtworkIfNeeded:(NSString *)artworkUrl } MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; - if ([artworkUrl isEqualToString:self.artworkUrl] && - center.nowPlayingInfo[MPMediaItemPropertyArtwork] != nil) { + if ([artworkUrl isEqualToString:self.artworkUrl] && center.nowPlayingInfo[MPMediaItemPropertyArtwork] != nil) { return; } @@ -251,17 +250,15 @@ - (void)updateArtworkIfNeeded:(NSString *)artworkUrl if (imageData) { image = [UIImage imageWithData:imageData]; } - } - @catch (NSException *exception) { + } @catch (NSException *exception) { // Failed to load artwork } if (image) { - MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] - initWithBoundsSize:image.size - requestHandler:^UIImage * _Nonnull(CGSize size) { - return image; - }]; + MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] initWithBoundsSize:image.size + requestHandler:^UIImage *_Nonnull(CGSize size) { + return image; + }]; dispatch_async(dispatch_get_main_queue(), ^{ NSMutableDictionary *nowPlayingInfo = [center.nowPlayingInfo mutableCopy]; @@ -318,17 +315,19 @@ - (void)enableRemoteCommand:(NSString *)name enabled:(BOOL)enabled } else if ([name isEqualToString:@"previous"]) { [self enableCommand:remoteCenter.previousTrackCommand withSelector:@selector(onPreviousTrack:) enabled:enabled]; } else if ([name isEqualToString:@"skipForward"]) { - remoteCenter.skipForwardCommand.preferredIntervals = @[@(15)]; + remoteCenter.skipForwardCommand.preferredIntervals = @[ @(15) ]; [self enableCommand:remoteCenter.skipForwardCommand withSelector:@selector(onSkipForward:) enabled:enabled]; } else if ([name isEqualToString:@"skipBackward"]) { - remoteCenter.skipBackwardCommand.preferredIntervals = @[@(15)]; + remoteCenter.skipBackwardCommand.preferredIntervals = @[ @(15) ]; [self enableCommand:remoteCenter.skipBackwardCommand withSelector:@selector(onSkipBackward:) enabled:enabled]; } else if ([name isEqualToString:@"seekForward"]) { [self enableCommand:remoteCenter.seekForwardCommand withSelector:@selector(onSeekForward:) enabled:enabled]; } else if ([name isEqualToString:@"seekBackward"]) { [self enableCommand:remoteCenter.seekBackwardCommand withSelector:@selector(onSeekBackward:) enabled:enabled]; } else if ([name isEqualToString:@"seek"]) { - [self enableCommand:remoteCenter.changePlaybackPositionCommand withSelector:@selector(onChangePlaybackPosition:) enabled:enabled]; + [self enableCommand:remoteCenter.changePlaybackPositionCommand + withSelector:@selector(onChangePlaybackPosition:) + enabled:enabled]; } } From e218cc87e60b45b37d0ff7ab80a1f180d6280809 Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 20 Nov 2025 14:51:08 +0100 Subject: [PATCH 15/19] feat: changed recording notification to be simpler --- .../audioapi/system/MediaSessionManager.kt | 2 +- .../notification/RecordingNotification.kt | 421 ++++++------------ .../RecordingNotificationReceiver.kt | 18 +- 3 files changed, 142 insertions(+), 299 deletions(-) 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 cd9b6cda1..eddbe4a24 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 @@ -313,7 +313,7 @@ object MediaSessionManager { when (type) { "simple" -> SimpleNotification(reactContext) "playback" -> PlaybackNotification(reactContext, audioAPIModule, 100, "audio_playback") - "recording" -> RecordingNotification(reactContext, audioAPIModule, 101, "audio_recording") + "recording" -> RecordingNotification(reactContext, audioAPIModule, 101, "audio_recording22") else -> throw IllegalArgumentException("Unknown notification type: $type") } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index f2a9319cb..aad09e996 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -1,35 +1,27 @@ package com.swmansion.audioapi.system.notification import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable +import android.content.IntentFilter +import android.graphics.Color import android.os.Build -import android.provider.ContactsContract -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat import android.util.Log -import android.view.KeyEvent import androidx.core.app.NotificationCompat -import androidx.media.app.NotificationCompat.MediaStyle import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.ReadableType import com.swmansion.audioapi.AudioAPIModule -import java.io.IOException import java.lang.ref.WeakReference -import java.net.URL /** * RecordingNotification * - * This notification: - * - Shows recording status and metadata - * - Supports recording controls (start, stop) + * Simple notification for audio recording: + * - Shows recording status with red background when recording + * - Simple start/stop button with microphone icon * - Is persistent and cannot be swiped away when recording * - Notifies its dismissal via RecordingNotificationReceiver */ @@ -41,65 +33,35 @@ class RecordingNotification( ) : BaseNotification { companion object { private const val TAG = "RecordingNotification" - const val MEDIA_BUTTON = "recording_notification_media_button" - const val PACKAGE_NAME = "com.swmansion.audioapi.recording" + const val ACTION_START = "com.swmansion.audioapi.RECORDING_START" + const val ACTION_STOP = "com.swmansion.audioapi.RECORDING_STOP" } - private var mediaSession: MediaSessionCompat? = null private var notificationBuilder: NotificationCompat.Builder? = null - private var playbackStateBuilder: PlaybackStateCompat.Builder = PlaybackStateCompat.Builder() - - private var enabledControls: Long = 0 private var isRecording: Boolean = false - - // Metadata - private var title: String? = null - private var artwork: Bitmap? = null - - // Actions - private var startAction: NotificationCompat.Action? = null - private var stopAction: NotificationCompat.Action? = null - - private var artworkThread: Thread? = null + private var title: String = "Audio Recording" + private var receiver: RecordingNotificationReceiver? = null override fun init(params: ReadableMap?): Notification { val context = reactContext.get() ?: throw IllegalStateException("React context is null") + // Register broadcast receiver + registerReceiver() + // Create notification channel first createNotificationChannel() - // Create MediaSession - mediaSession = MediaSessionCompat(context, "RecordingNotification") - mediaSession?.isActive = true - - // Set up media session callbacks - mediaSession?.setCallback( - object : MediaSessionCompat.Callback() { - override fun onCustomAction( - action: String, - extras: android.os.Bundle?, - ) { - Log.d(TAG, "MediaSession: onCustomAction ($action)") - when (action) { - "START_RECORDING" -> { - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("recordingNotificationStart", mapOf()) - } - "STOP_RECORDING" -> { - audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody("recordingNotificationStop", mapOf()) - } - } - } - }, - ) - // Create notification builder notificationBuilder = NotificationCompat .Builder(context, channelId) .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setContentTitle(title) + .setContentText("Ready to record") .setPriority(NotificationCompat.PRIORITY_HIGH) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOngoing(true) // Make it persistent (can't swipe away) + .setOngoing(false) + .setAutoCancel(false) // Set content intent to open app val packageName = context.packageName @@ -127,13 +89,6 @@ class RecordingNotification( ) notificationBuilder?.setDeleteIntent(deletePendingIntent) - // Enable default controls - enableControl("start", true) - enableControl("stop", true) - - updateMediaStyle() - updatePlaybackState() - // Apply initial params if provided if (params != null) { update(params) @@ -143,22 +98,13 @@ class RecordingNotification( } override fun reset() { - // Interrupt artwork loading if in progress - artworkThread?.interrupt() - artworkThread = null + // Unregister receiver + unregisterReceiver() - // Reset metadata - title = null - artwork = null + // Reset state + title = "Audio Recording" isRecording = false - - // Reset media session - val emptyMetadata = MediaMetadataCompat.Builder().build() - mediaSession?.setMetadata(emptyMetadata) - - mediaSession?.isActive = false - mediaSession?.release() - mediaSession = null + notificationBuilder = null } override fun getNotificationId(): Int = notificationId @@ -170,87 +116,37 @@ class RecordingNotification( return buildNotification() } - // Handle control enable/disable - if (options.hasKey("control") && options.hasKey("enabled")) { - val control = options.getString("control") - val enabled = options.getBoolean("enabled") - if (control != null) { - enableControl(control, enabled) - } - return buildNotification() - } - // Update metadata if (options.hasKey("title")) { - title = options.getString("title") + title = options.getString("title") ?: "Audio Recording" } // Update recording state if (options.hasKey("state")) { when (options.getString("state")) { - "recording" -> { - isRecording = true - } - "stopped" -> { - isRecording = false - } + "recording" -> isRecording = true + "stopped" -> isRecording = false } } - // Build MediaMetadata - val metadataBuilder = - MediaMetadataCompat - .Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - - // Update notification builder - notificationBuilder?.setContentTitle(title) + // Update notification content val statusText = if (isRecording) "Recording..." else "Ready to record" + notificationBuilder?.setContentTitle(title) notificationBuilder?.setContentText(statusText) - // Handle artwork - if (options.hasKey("artwork")) { - artworkThread?.interrupt() - - val artworkUrl: String? - val isLocal: Boolean - - if (options.getType("artwork") == ReadableType.Map) { - artworkUrl = options.getMap("artwork")?.getString("uri") - isLocal = true - } else { - artworkUrl = options.getString("artwork") - isLocal = false - } - - if (artworkUrl != null) { - artworkThread = - Thread { - try { - val bitmap = loadArtwork(artworkUrl, isLocal) - if (bitmap != null) { - artwork = bitmap - metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) - notificationBuilder?.setLargeIcon(bitmap) - } - artworkThread = null - } catch (e: Exception) { - Log.e(TAG, "Error loading artwork: ${e.message}", e) - } - } - artworkThread?.start() - } - } - - mediaSession?.setMetadata(metadataBuilder.build()) - mediaSession?.isActive = true - // Update ongoing state - only persistent when recording notificationBuilder?.setOngoing(isRecording) - // Update media style to reflect current state - updatePlaybackState() - updateMediaStyle() + // Set red color when recording + if (isRecording) { + notificationBuilder?.setColor(Color.RED) + notificationBuilder?.setColorized(true) + } else { + notificationBuilder?.setColorized(false) + } + + // Update action button + updateActions() return buildNotification() } @@ -259,184 +155,121 @@ class RecordingNotification( notificationBuilder?.build() ?: throw IllegalStateException("Notification not initialized. Call init() first.") - private fun updatePlaybackState() { - // Set playback state with custom actions to preserve custom icons - val state = if (isRecording) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_STOPPED + private fun updateActions() { + val context = reactContext.get() ?: return - // Clear previous state and rebuild - playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(state, 0, 1.0f) + // Clear existing actions + notificationBuilder?.clearActions() - // Add only the appropriate custom action based on current state - if (!isRecording && (enabledControls and PlaybackStateCompat.ACTION_PLAY) != 0L) { + // Add appropriate action based on recording state + // Note: Android shows text labels in collapsed view, icons only in expanded/Auto/Wear + if (isRecording) { + // Show STOP button when recording + val stopIntent = Intent(ACTION_STOP) + stopIntent.setPackage(context.packageName) + val stopPendingIntent = + PendingIntent.getBroadcast( + context, + 1001, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val stopAction = + NotificationCompat.Action + .Builder( + android.R.drawable.ic_delete, + "Stop", + stopPendingIntent, + ).build() + notificationBuilder?.addAction(stopAction) + } else { // Show START button when not recording + val startIntent = Intent(ACTION_START) + startIntent.setPackage(context.packageName) + val startPendingIntent = + PendingIntent.getBroadcast( + context, + 1000, + startIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) val startAction = - PlaybackStateCompat.CustomAction + NotificationCompat.Action .Builder( - "START_RECORDING", - "Start Recording", android.R.drawable.ic_btn_speak_now, + "Record", + startPendingIntent, ).build() - playbackStateBuilder.addCustomAction(startAction) - } else if (isRecording && (enabledControls and PlaybackStateCompat.ACTION_PAUSE) != 0L) { - // Show STOP button when recording - val stopAction = - PlaybackStateCompat.CustomAction - .Builder( - "STOP_RECORDING", - "Stop Recording", - android.R.drawable.ic_media_pause, - ).build() - playbackStateBuilder.addCustomAction(stopAction) + notificationBuilder?.addAction(startAction) } - mediaSession?.setPlaybackState(playbackStateBuilder.build()) + // Use BigTextStyle to ensure actions are visible + val statusText = if (isRecording) "Recording in progress..." else "Ready to record" + notificationBuilder?.setStyle( + NotificationCompat.BigTextStyle() + .bigText(statusText), + ) } - /** - * Enable or disable a specific control action. - */ - private fun enableControl( - name: String, - enabled: Boolean, - ) { - val controlValue = - when (name) { - "start" -> PlaybackStateCompat.ACTION_PLAY - // Use PAUSE action so the system shows a pause button (consistent with PlaybackNotification) - "stop" -> PlaybackStateCompat.ACTION_PAUSE - else -> 0L - } + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val context = reactContext.get() ?: return - if (controlValue == 0L) return + val channel = + NotificationChannel( + channelId, + "Audio Recording", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Recording controls and status" + setShowBadge(true) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + enableLights(true) + lightColor = Color.RED + enableVibration(false) + } - enabledControls = - if (enabled) { - enabledControls or controlValue - } else { - enabledControls and controlValue.inv() - } + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) - // Update actions - updateActions() - updateMediaStyle() - updatePlaybackState() + Log.d(TAG, "Notification channel created: $channelId") + } } - private fun updateActions() { + private fun registerReceiver() { val context = reactContext.get() ?: return - startAction = - createAction( - "start", - "Start Recording", - android.R.drawable.ic_btn_speak_now, // Microphone icon - PlaybackStateCompat.ACTION_PLAY, - ) - - stopAction = - createAction( - "stop", - "Stop Recording", - android.R.drawable.ic_media_pause, - PlaybackStateCompat.ACTION_PAUSE, - ) - } - - private fun createAction( - name: String, - title: String, - icon: Int, - action: Long, - ): NotificationCompat.Action? { - val context = reactContext.get() ?: return null - - if ((enabledControls and action) == 0L) { - return null - } - - val keyCode = PlaybackStateCompat.toKeyCode(action) - val intent = Intent(MEDIA_BUTTON) - intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) - intent.putExtra(ContactsContract.Directory.PACKAGE_NAME, context.packageName) - - val pendingIntent = - PendingIntent.getBroadcast( - context, - keyCode, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - return NotificationCompat.Action(icon, title, pendingIntent) - } + if (receiver == null) { + receiver = RecordingNotificationReceiver() + RecordingNotificationReceiver.setAudioAPIModule(audioAPIModule.get()) - private fun updateMediaStyle() { - val style = MediaStyle() - style.setMediaSession(mediaSession?.sessionToken) - notificationBuilder?.clearActions() - style.setShowActionsInCompactView(0, 1, 2) - notificationBuilder?.setStyle(style) - } + val filter = IntentFilter() + filter.addAction(ACTION_START) + filter.addAction(ACTION_STOP) + filter.addAction(RecordingNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) - private fun loadArtwork( - url: String, - isLocal: Boolean, - ): Bitmap? { - val context = reactContext.get() ?: return null - - return try { - if (isLocal && !url.startsWith("http")) { - // Load local resource - val helper = - com.facebook.react.views.imagehelper.ResourceDrawableIdHelper - .getInstance() - val drawable = helper.getResourceDrawable(context, url) - - if (drawable is BitmapDrawable) { - drawable.bitmap - } else { - BitmapFactory.decodeFile(url) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) } else { - // Load from URL - val connection = URL(url).openConnection() - connection.connect() - val inputStream = connection.getInputStream() - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream.close() - bitmap + context.registerReceiver(receiver, filter) } - } catch (e: IOException) { - Log.e(TAG, "Failed to load artwork: ${e.message}", e) - null - } catch (e: Exception) { - Log.e(TAG, "Error loading artwork: ${e.message}", e) - null + + Log.d(TAG, "RecordingNotificationReceiver registered") } } - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val context = reactContext.get() ?: return - - val channel = - android.app - .NotificationChannel( - channelId, - "Audio Recording", - android.app.NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Recording controls and status" - setShowBadge(false) - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - } - - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager - notificationManager.createNotificationChannel(channel) + private fun unregisterReceiver() { + val context = reactContext.get() ?: return - Log.d(TAG, "Notification channel created: $channelId") + receiver?.let { + try { + context.unregisterReceiver(it) + receiver = null + Log.d(TAG, "RecordingNotificationReceiver unregistered") + } catch (e: Exception) { + Log.e(TAG, "Error unregistering receiver: ${e.message}", e) + } } } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt index 9dd5b19de..1952521c2 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt @@ -7,7 +7,7 @@ import android.util.Log import com.swmansion.audioapi.AudioAPIModule /** - * Broadcast receiver for handling recording notification dismissal. + * Broadcast receiver for handling recording notification actions and dismissal. */ class RecordingNotificationReceiver : BroadcastReceiver() { companion object { @@ -25,9 +25,19 @@ class RecordingNotificationReceiver : BroadcastReceiver() { context: Context?, intent: Intent?, ) { - if (intent?.action == ACTION_NOTIFICATION_DISMISSED) { - Log.d(TAG, "Recording notification dismissed by user") - audioAPIModule?.invokeHandlerWithEventNameAndEventBody("recordingNotificationDismissed", mapOf()) + when (intent?.action) { + ACTION_NOTIFICATION_DISMISSED -> { + Log.d(TAG, "Recording notification dismissed by user") + audioAPIModule?.invokeHandlerWithEventNameAndEventBody("recordingNotificationDismissed", mapOf()) + } + RecordingNotification.ACTION_START -> { + Log.d(TAG, "Start recording action received") + audioAPIModule?.invokeHandlerWithEventNameAndEventBody("recordingNotificationStart", mapOf()) + } + RecordingNotification.ACTION_STOP -> { + Log.d(TAG, "Stop recording action received") + audioAPIModule?.invokeHandlerWithEventNameAndEventBody("recordingNotificationStop", mapOf()) + } } } } From d5c87e478ab369bedb60a86736512e630c28255f Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 20 Nov 2025 15:19:22 +0100 Subject: [PATCH 16/19] feat: added more options to recording notification --- .../notification/RecordingNotification.kt | 36 +++++++++++++++---- .../src/system/notification/types.ts | 3 ++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index aad09e996..d91ec27e4 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -40,7 +40,10 @@ class RecordingNotification( private var notificationBuilder: NotificationCompat.Builder? = null private var isRecording: Boolean = false private var title: String = "Audio Recording" + private var description: String = "Ready to record" private var receiver: RecordingNotificationReceiver? = null + private var startEnabled: Boolean = true + private var stopEnabled: Boolean = true override fun init(params: ReadableMap?): Notification { val context = reactContext.get() ?: throw IllegalStateException("React context is null") @@ -116,11 +119,27 @@ class RecordingNotification( return buildNotification() } + // Handle control enable/disable + if (options.hasKey("control") && options.hasKey("enabled")) { + val control = options.getString("control") + val enabled = options.getBoolean("enabled") + when (control) { + "start" -> startEnabled = enabled + "stop" -> stopEnabled = enabled + } + updateActions() + return buildNotification() + } + // Update metadata if (options.hasKey("title")) { title = options.getString("title") ?: "Audio Recording" } + if (options.hasKey("description")) { + description = options.getString("description") ?: "Ready to record" + } + // Update recording state if (options.hasKey("state")) { when (options.getString("state")) { @@ -130,7 +149,9 @@ class RecordingNotification( } // Update notification content - val statusText = if (isRecording) "Recording..." else "Ready to record" + val statusText = description.ifEmpty { + if (isRecording) "Recording..." else "Ready to record" + } notificationBuilder?.setContentTitle(title) notificationBuilder?.setContentText(statusText) @@ -161,9 +182,9 @@ class RecordingNotification( // Clear existing actions notificationBuilder?.clearActions() - // Add appropriate action based on recording state + // Add appropriate action based on recording state and enabled controls // Note: Android shows text labels in collapsed view, icons only in expanded/Auto/Wear - if (isRecording) { + if (isRecording && stopEnabled) { // Show STOP button when recording val stopIntent = Intent(ACTION_STOP) stopIntent.setPackage(context.packageName) @@ -182,7 +203,7 @@ class RecordingNotification( stopPendingIntent, ).build() notificationBuilder?.addAction(stopAction) - } else { + } else if (!isRecording && startEnabled) { // Show START button when not recording val startIntent = Intent(ACTION_START) startIntent.setPackage(context.packageName) @@ -204,9 +225,12 @@ class RecordingNotification( } // Use BigTextStyle to ensure actions are visible - val statusText = if (isRecording) "Recording in progress..." else "Ready to record" + val statusText = description.ifEmpty { + if (isRecording) "Recording in progress..." else "Ready to record" + } notificationBuilder?.setStyle( - NotificationCompat.BigTextStyle() + NotificationCompat + .BigTextStyle() .bigText(statusText), ) } diff --git a/packages/react-native-audio-api/src/system/notification/types.ts b/packages/react-native-audio-api/src/system/notification/types.ts index f57a51b2d..94d9f994c 100644 --- a/packages/react-native-audio-api/src/system/notification/types.ts +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -73,8 +73,11 @@ export type PlaybackNotificationEventName = keyof PlaybackNotificationEvent; /// Metadata and state information for recording notifications. export interface RecordingNotificationInfo { title?: string; + description?: string; artwork?: string | { uri: string }; state?: 'recording' | 'stopped'; + control?: RecordingControlName; + enabled?: boolean; } /// Available recording control actions. From 3a3b9f59c820b078feb127ecba4bdd4bab73bc91 Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 20 Nov 2025 15:19:51 +0100 Subject: [PATCH 17/19] chore: formatting --- .../system/notification/RecordingNotification.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt index d91ec27e4..2b0846482 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -149,9 +149,10 @@ class RecordingNotification( } // Update notification content - val statusText = description.ifEmpty { - if (isRecording) "Recording..." else "Ready to record" - } + val statusText = + description.ifEmpty { + if (isRecording) "Recording..." else "Ready to record" + } notificationBuilder?.setContentTitle(title) notificationBuilder?.setContentText(statusText) @@ -225,9 +226,10 @@ class RecordingNotification( } // Use BigTextStyle to ensure actions are visible - val statusText = description.ifEmpty { - if (isRecording) "Recording in progress..." else "Ready to record" - } + val statusText = + description.ifEmpty { + if (isRecording) "Recording in progress..." else "Ready to record" + } notificationBuilder?.setStyle( NotificationCompat .BigTextStyle() From b432ff7bb0df524f452cea6628ac941fc05f8e8b Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 20 Nov 2025 15:46:57 +0100 Subject: [PATCH 18/19] chore: formatting --- .../ios/audioapi/ios/AudioAPIModule.mm | 27 ++++---- .../ios/audioapi/ios/core/IOSAudioRecorder.h | 3 +- .../notification/PlaybackNotification.mm | 67 ++++++++++++------- 3 files changed, 58 insertions(+), 39 deletions(-) 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 98ac4a944..4218e6214 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -181,8 +181,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - requestNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock) - reject) + requestNotificationPermissions : (nonnull RCTPromiseResolveBlock) + resolve reject : (nonnull RCTPromiseRejectBlock)reject) { // iOS doesn't require explicit notification permissions for media controls // MPNowPlayingInfoCenter and MPRemoteCommandCenter work without permissions @@ -191,8 +191,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - checkNotificationPermissions : (nonnull RCTPromiseResolveBlock)resolve reject : (nonnull RCTPromiseRejectBlock) - reject) + checkNotificationPermissions : (nonnull RCTPromiseResolveBlock) + resolve reject : (nonnull RCTPromiseRejectBlock)reject) { // iOS doesn't require explicit notification permissions for media controls // Return 'granted' to match the spec interface @@ -228,8 +228,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - showNotification : (NSString *)key options : (NSDictionary *)options resolve : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) + showNotification : (NSString *)key options : (NSDictionary *) + options resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry showNotificationWithKey:key options:options]; @@ -241,8 +241,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - updateNotification : (NSString *)key options : (NSDictionary *)options resolve : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) + updateNotification : (NSString *)key options : (NSDictionary *) + options resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry updateNotificationWithKey:key options:options]; @@ -254,7 +254,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - hideNotification : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) + hideNotification : (NSString *)key resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry hideNotificationWithKey:key]; @@ -266,8 +267,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - unregisterNotification : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock) - reject) + unregisterNotification : (NSString *)key resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { BOOL success = [self.notificationRegistry unregisterNotificationWithKey:key]; @@ -279,8 +280,8 @@ - (void)invalidate } RCT_EXPORT_METHOD( - isNotificationActive : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock) - reject) + isNotificationActive : (NSString *)key resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { BOOL isActive = [self.notificationRegistry isNotificationActiveWithKey:key]; resolve(@(isActive)); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index a97d9b50f..6d1bb3b69 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -18,8 +18,7 @@ class IOSAudioRecorder : public AudioRecorder { IOSAudioRecorder( float sampleRate, int bufferLength, - const std::shared_ptr - &audioEventHandlerRegistry); + const std::shared_ptr &audioEventHandlerRegistry); ~IOSAudioRecorder() override; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm index 99fae3f7a..8252c8828 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm @@ -1,14 +1,14 @@ #import #import -#define NOW_PLAYING_INFO_KEYS \ - @{ \ - @"title" : MPMediaItemPropertyTitle, \ - @"artist" : MPMediaItemPropertyArtist, \ - @"album" : MPMediaItemPropertyAlbumTitle, \ - @"duration" : MPMediaItemPropertyPlaybackDuration, \ +#define NOW_PLAYING_INFO_KEYS \ + @{ \ + @"title" : MPMediaItemPropertyTitle, \ + @"artist" : MPMediaItemPropertyArtist, \ + @"album" : MPMediaItemPropertyAlbumTitle, \ + @"duration" : MPMediaItemPropertyPlaybackDuration, \ @"elapsedTime" : MPNowPlayingInfoPropertyElapsedPlaybackTime, \ - @"speed" : MPNowPlayingInfoPropertyPlaybackRate \ + @"speed" : MPNowPlayingInfoPropertyPlaybackRate \ } @implementation PlaybackNotification { @@ -219,7 +219,8 @@ - (void)updateArtworkIfNeeded:(NSString *)artworkUrl } MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; - if ([artworkUrl isEqualToString:self.artworkUrl] && center.nowPlayingInfo[MPMediaItemPropertyArtwork] != nil) { + if ([artworkUrl isEqualToString:self.artworkUrl] && + center.nowPlayingInfo[MPMediaItemPropertyArtwork] != nil) { return; } @@ -255,10 +256,9 @@ - (void)updateArtworkIfNeeded:(NSString *)artworkUrl } if (image) { - MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] initWithBoundsSize:image.size - requestHandler:^UIImage *_Nonnull(CGSize size) { - return image; - }]; + MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] + initWithBoundsSize:image.size + requestHandler:^UIImage *_Nonnull(CGSize size) { return image; }]; dispatch_async(dispatch_get_main_queue(), ^{ NSMutableDictionary *nowPlayingInfo = [center.nowPlayingInfo mutableCopy]; @@ -309,21 +309,35 @@ - (void)enableRemoteCommand:(NSString *)name enabled:(BOOL)enabled } else if ([name isEqualToString:@"stop"]) { [self enableCommand:remoteCenter.stopCommand withSelector:@selector(onStop:) enabled:enabled]; } else if ([name isEqualToString:@"togglePlayPause"]) { - [self enableCommand:remoteCenter.togglePlayPauseCommand withSelector:@selector(onTogglePlayPause:) enabled:enabled]; + [self enableCommand:remoteCenter.togglePlayPauseCommand + withSelector:@selector(onTogglePlayPause:) + enabled:enabled]; } else if ([name isEqualToString:@"next"]) { - [self enableCommand:remoteCenter.nextTrackCommand withSelector:@selector(onNextTrack:) enabled:enabled]; + [self enableCommand:remoteCenter.nextTrackCommand + withSelector:@selector(onNextTrack:) + enabled:enabled]; } else if ([name isEqualToString:@"previous"]) { - [self enableCommand:remoteCenter.previousTrackCommand withSelector:@selector(onPreviousTrack:) enabled:enabled]; + [self enableCommand:remoteCenter.previousTrackCommand + withSelector:@selector(onPreviousTrack:) + enabled:enabled]; } else if ([name isEqualToString:@"skipForward"]) { remoteCenter.skipForwardCommand.preferredIntervals = @[ @(15) ]; - [self enableCommand:remoteCenter.skipForwardCommand withSelector:@selector(onSkipForward:) enabled:enabled]; + [self enableCommand:remoteCenter.skipForwardCommand + withSelector:@selector(onSkipForward:) + enabled:enabled]; } else if ([name isEqualToString:@"skipBackward"]) { remoteCenter.skipBackwardCommand.preferredIntervals = @[ @(15) ]; - [self enableCommand:remoteCenter.skipBackwardCommand withSelector:@selector(onSkipBackward:) enabled:enabled]; + [self enableCommand:remoteCenter.skipBackwardCommand + withSelector:@selector(onSkipBackward:) + enabled:enabled]; } else if ([name isEqualToString:@"seekForward"]) { - [self enableCommand:remoteCenter.seekForwardCommand withSelector:@selector(onSeekForward:) enabled:enabled]; + [self enableCommand:remoteCenter.seekForwardCommand + withSelector:@selector(onSeekForward:) + enabled:enabled]; } else if ([name isEqualToString:@"seekBackward"]) { - [self enableCommand:remoteCenter.seekBackwardCommand withSelector:@selector(onSeekBackward:) enabled:enabled]; + [self enableCommand:remoteCenter.seekBackwardCommand + withSelector:@selector(onSeekBackward:) + enabled:enabled]; } else if ([name isEqualToString:@"seek"]) { [self enableCommand:remoteCenter.changePlaybackPositionCommand withSelector:@selector(onChangePlaybackPosition:) @@ -362,7 +376,8 @@ - (MPRemoteCommandHandlerStatus)onStop:(MPRemoteCommandEvent *)event - (MPRemoteCommandHandlerStatus)onTogglePlayPause:(MPRemoteCommandEvent *)event { - [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationTogglePlayPause" eventBody:@{}]; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationTogglePlayPause" + eventBody:@{}]; return MPRemoteCommandHandlerStatusSuccess; } @@ -386,25 +401,29 @@ - (MPRemoteCommandHandlerStatus)onSeekForward:(MPRemoteCommandEvent *)event - (MPRemoteCommandHandlerStatus)onSeekBackward:(MPRemoteCommandEvent *)event { - [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSeekBackward" eventBody:@{}]; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSeekBackward" + eventBody:@{}]; return MPRemoteCommandHandlerStatusSuccess; } - (MPRemoteCommandHandlerStatus)onSkipForward:(MPSkipIntervalCommandEvent *)event { NSDictionary *body = @{@"interval" : @(event.interval)}; - [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSkipForward" eventBody:body]; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSkipForward" + eventBody:body]; return MPRemoteCommandHandlerStatusSuccess; } - (MPRemoteCommandHandlerStatus)onSkipBackward:(MPSkipIntervalCommandEvent *)event { NSDictionary *body = @{@"interval" : @(event.interval)}; - [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSkipBackward" eventBody:body]; + [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSkipBackward" + eventBody:body]; return MPRemoteCommandHandlerStatusSuccess; } -- (MPRemoteCommandHandlerStatus)onChangePlaybackPosition:(MPChangePlaybackPositionCommandEvent *)event +- (MPRemoteCommandHandlerStatus)onChangePlaybackPosition: + (MPChangePlaybackPositionCommandEvent *)event { NSDictionary *body = @{@"position" : @(event.positionTime)}; [self.audioAPIModule invokeHandlerWithEventName:@"playbackNotificationSeek" eventBody:body]; From 313eaa6f9a3b9c6252bf60af6cff31cee567484a Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 20 Nov 2025 15:58:08 +0100 Subject: [PATCH 19/19] chore: bump ktlint to 1.7 --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index c9b544e19..eabf1687f 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -24,7 +24,7 @@ runs: - name: Install ktlint shell: bash run: | - curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.5.0/ktlint + curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.7.0/ktlint chmod a+x ktlint sudo mv ktlint /usr/local/bin/