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/ diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index 099f6ed91..b5a741fc3 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,104 @@ 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', + speed: 1.0, + 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 4e8a757cf..91ef3695b 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -1,4 +1,4 @@ -import { AudioContext, AudioManager } from 'react-native-audio-api'; +import { AudioContext, PlaybackNotificationManager } from 'react-native-audio-api'; import type { AudioBufferSourceNode, AudioBuffer, @@ -58,8 +58,9 @@ class AudioPlayer { this.sourceNode.start(this.audioContext.currentTime, this.offset); - AudioManager.setLockScreenInfo({ - state: 'state_playing', + PlaybackNotificationManager.update({ + state: 'playing', + elapsedTime: this.offset, }); }; @@ -71,8 +72,9 @@ class AudioPlayer { this.sourceNode?.stop(this.audioContext.currentTime); - AudioManager.setLockScreenInfo({ - state: 'state_paused', + PlaybackNotificationManager.update({ + state: 'paused', + elapsedTime: this.offset, }); await this.audioContext.suspend(); @@ -133,6 +135,10 @@ class AudioPlayer { ) => { this.onPositionChanged = callback; }; + + getDuration = (): number => { + return this.audioBuffer?.duration ?? 0; + }; } export default new AudioPlayer(); 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..54bc7412f --- /dev/null +++ b/apps/common-app/src/examples/PlaybackNotification/PlaybackNotification.tsx @@ -0,0 +1,299 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +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'; + +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); + 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 + 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(); + + // Cleanup AudioContext + if (audioContextRef.current) { + audioContextRef.current.close(); + audioContextRef.current = null; + } + }; + }, []); + + 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 { + // 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: 'playing', + duration: 180, + elapsedTime: 0, + }); + setIsShown(true); + setIsPlaying(true); + 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(); + + // 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'); + } 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 ac4da5d1f..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; @@ -104,4 +106,10 @@ export const Examples: Example[] = [ 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/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 @@ + | 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 the interruption was temporary } 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..587d6d254 --- /dev/null +++ b/packages/audiodocs/docs/system/playback-notification-manager.mdx @@ -0,0 +1,193 @@ +--- +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. + +:::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 +// TO DO +``` + +## 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. + +:::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 | + +#### 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. + +:::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 | + +#### Returns `Promise`. + +### `hide` + +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` + +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; + + // IOS: elapsed time does not update automatically, must be set manually on each state change + elapsedTime?: number; + speed?: number; + state?: 'playing' | 'paused'; +} +``` +
+ +### `PlaybackControlName` + +
+ Type definitions + ```typescript + type PlaybackControlName = + | 'play' + | 'pause' + | 'next' + | 'previous' + | 'skipForward' + | 'skipBackward'; + ``` +
+ +### `PlaybackNotificationEventName` + +
+ Type definitions +```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/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; +``` +
+ + + + 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 4ffa63356..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 @@ -1,6 +1,7 @@ package com.swmansion.audioapi import com.facebook.jni.HybridData +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -112,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) } @@ -148,7 +134,167 @@ class AudioAPIModule( promise.resolve(MediaSessionManager.checkRecordingPermissions()) } + override fun requestNotificationPermissions(promise: Promise) { + val permissionRequestListener = PermissionRequestListener(promise) + MediaSessionManager.requestNotificationPermissions(permissionRequestListener) + } + + override fun checkNotificationPermissions(promise: Promise) { + promise.resolve(MediaSessionManager.checkNotificationPermissions()) + } + override fun getDevicesInfo(promise: Promise) { promise.resolve(MediaSessionManager.getDevicesInfo()) } + + // New notification system methods + override fun registerNotification( + type: String?, + key: String?, + promise: Promise?, + ) { + try { + if (type == null || key == null) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", "Type and key are required") + promise?.resolve(result) + return + } + + MediaSessionManager.registerNotification(type, key) + + val result = Arguments.createMap() + result.putBoolean("success", true) + promise?.resolve(result) + } catch (e: Exception) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", e.message ?: "Unknown error") + promise?.resolve(result) + } + } + + override fun showNotification( + key: String?, + options: ReadableMap?, + promise: Promise?, + ) { + try { + if (key == null) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", "Key is required") + promise?.resolve(result) + return + } + + MediaSessionManager.showNotification(key, options) + + val result = Arguments.createMap() + result.putBoolean("success", true) + promise?.resolve(result) + } catch (e: Exception) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", e.message ?: "Unknown error") + promise?.resolve(result) + } + } + + override fun updateNotification( + key: String?, + options: ReadableMap?, + promise: Promise?, + ) { + try { + if (key == null) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", "Key is required") + promise?.resolve(result) + return + } + + MediaSessionManager.updateNotification(key, options) + + val result = Arguments.createMap() + result.putBoolean("success", true) + promise?.resolve(result) + } catch (e: Exception) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", e.message ?: "Unknown error") + promise?.resolve(result) + } + } + + override fun hideNotification( + key: String?, + promise: Promise?, + ) { + try { + if (key == null) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", "Key is required") + promise?.resolve(result) + return + } + + MediaSessionManager.hideNotification(key) + + val result = Arguments.createMap() + result.putBoolean("success", true) + promise?.resolve(result) + } catch (e: Exception) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", e.message ?: "Unknown error") + promise?.resolve(result) + } + } + + override fun unregisterNotification( + key: String?, + promise: Promise?, + ) { + try { + if (key == null) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", "Key is required") + promise?.resolve(result) + return + } + + MediaSessionManager.unregisterNotification(key) + + val result = Arguments.createMap() + result.putBoolean("success", true) + promise?.resolve(result) + } catch (e: Exception) { + val result = Arguments.createMap() + result.putBoolean("success", false) + result.putString("error", e.message ?: "Unknown error") + promise?.resolve(result) + } + } + + override fun isNotificationActive( + key: String?, + promise: Promise?, + ) { + try { + if (key == null) { + promise?.resolve(false) + return + } + + val isActive = MediaSessionManager.isNotificationActive(key) + promise?.resolve(isActive) + } catch (e: Exception) { + promise?.resolve(false) + } + } } 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 f0ef68b6f..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 @@ -23,6 +23,12 @@ import com.swmansion.audioapi.AudioAPIModule 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.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 @@ -33,15 +39,14 @@ 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 + private lateinit var recordingNotificationReceiver: RecordingNotificationReceiver + + // New notification system + private lateinit var notificationRegistry: NotificationRegistry - private var isServiceRunning = false - private val serviceStateLock = Any() private val nativeAudioPlayers = mutableMapOf() private val nativeAudioRecorders = mutableMapOf() @@ -52,38 +57,51 @@ 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() + + // 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) + } else { + ContextCompat.registerReceiver( + this.reactContext.get()!!, + playbackNotificationReceiver, + playbackFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } - 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) + // 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(mediaReceiver, filter, Context.RECEIVER_EXPORTED) + this.reactContext.get()!!.registerReceiver(recordingNotificationReceiver, recordingFilter, Context.RECEIVER_NOT_EXPORTED) } else { ContextCompat.registerReceiver( this.reactContext.get()!!, - mediaReceiver, - filter, + recordingNotificationReceiver, + recordingFilter, ContextCompat.RECEIVER_NOT_EXPORTED, ) } 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 + this.notificationRegistry = NotificationRegistry(this.reactContext) } fun attachAudioPlayer(player: NativeAudioPlayer): String { @@ -121,49 +139,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 { @@ -211,6 +212,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 +303,44 @@ 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) + "playback" -> PlaybackNotification(reactContext, audioAPIModule, 100, "audio_playback") + "recording" -> RecordingNotification(reactContext, audioAPIModule, 101, "audio_recording22") + 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..136443f43 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/BaseNotification.kt @@ -0,0 +1,47 @@ +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 + + /** + * 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. + */ + 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..3cc6698e6 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ForegroundNotificationService.kt @@ -0,0 +1,117 @@ +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 { + // 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( + 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) + throw 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() + } +} 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..23ab51010 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -0,0 +1,229 @@ +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. + */ +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? = builtNotifications[notificationId] + } + + 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 + } + + 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) + } + } + + /** + * 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() + builtNotifications.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 { + // 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) { + Log.e(TAG, "Error posting notification: ${e.message}", e) + } + } + + private fun cancelNotification(id: Int) { + val context = reactContext.get() ?: return + NotificationManagerCompat.from(context).cancel(id) + // Clean up stored notification + builtNotifications.remove(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/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..752af26dd --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt @@ -0,0 +1,578 @@ +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 playbackState: PlaybackStateCompat = playbackStateBuilder.build() + private var playbackPlayingState: Int = PlaybackStateCompat.STATE_PAUSED + + 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) + playbackState = playbackStateBuilder.build() + mediaSession?.setPlaybackState(playbackState) + 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() + } 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" -> { + playbackPlayingState = PlaybackStateCompat.STATE_PLAYING + } + "paused" -> { + playbackPlayingState = 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() + } + } + + updatePlaybackState(playbackPlayingState) + mediaSession?.setMetadata(metadataBuilder.build()) + mediaSession?.isActive = true + + return buildNotification() + } + + 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 + } + + 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) + playbackState = playbackStateBuilder.build() + mediaSession?.setPlaybackState(playbackState) + } + + 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) + 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) + } + + 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..69d4d1e43 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotificationReceiver.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 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/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..2b0846482 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt @@ -0,0 +1,301 @@ +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.content.IntentFilter +import android.graphics.Color +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.swmansion.audioapi.AudioAPIModule +import java.lang.ref.WeakReference + +/** + * RecordingNotification + * + * 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 + */ +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 ACTION_START = "com.swmansion.audioapi.RECORDING_START" + const val ACTION_STOP = "com.swmansion.audioapi.RECORDING_STOP" + } + + 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") + + // Register broadcast receiver + registerReceiver() + + // Create notification channel first + createNotificationChannel() + + // 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(false) + .setAutoCancel(false) + + // 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) + + // Apply initial params if provided + if (params != null) { + update(params) + } + + return buildNotification() + } + + override fun reset() { + // Unregister receiver + unregisterReceiver() + + // Reset state + title = "Audio Recording" + isRecording = false + notificationBuilder = 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") + 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")) { + "recording" -> isRecording = true + "stopped" -> isRecording = false + } + } + + // Update notification content + val statusText = + description.ifEmpty { + if (isRecording) "Recording..." else "Ready to record" + } + notificationBuilder?.setContentTitle(title) + notificationBuilder?.setContentText(statusText) + + // Update ongoing state - only persistent when recording + notificationBuilder?.setOngoing(isRecording) + + // Set red color when recording + if (isRecording) { + notificationBuilder?.setColor(Color.RED) + notificationBuilder?.setColorized(true) + } else { + notificationBuilder?.setColorized(false) + } + + // Update action button + updateActions() + + return buildNotification() + } + + private fun buildNotification(): Notification = + notificationBuilder?.build() + ?: throw IllegalStateException("Notification not initialized. Call init() first.") + + private fun updateActions() { + val context = reactContext.get() ?: return + + // Clear existing actions + notificationBuilder?.clearActions() + + // 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 && stopEnabled) { + // 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 if (!isRecording && startEnabled) { + // 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 = + NotificationCompat.Action + .Builder( + android.R.drawable.ic_btn_speak_now, + "Record", + startPendingIntent, + ).build() + notificationBuilder?.addAction(startAction) + } + + // Use BigTextStyle to ensure actions are visible + val statusText = + description.ifEmpty { + if (isRecording) "Recording in progress..." else "Ready to record" + } + notificationBuilder?.setStyle( + NotificationCompat + .BigTextStyle() + .bigText(statusText), + ) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val context = reactContext.get() ?: 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) + } + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + + Log.d(TAG, "Notification channel created: $channelId") + } + } + + private fun registerReceiver() { + val context = reactContext.get() ?: return + + if (receiver == null) { + receiver = RecordingNotificationReceiver() + RecordingNotificationReceiver.setAudioAPIModule(audioAPIModule.get()) + + val filter = IntentFilter() + filter.addAction(ACTION_START) + filter.addAction(ACTION_STOP) + filter.addAction(RecordingNotificationReceiver.ACTION_NOTIFICATION_DISMISSED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } + + Log.d(TAG, "RecordingNotificationReceiver registered") + } + } + + private fun unregisterReceiver() { + val context = reactContext.get() ?: return + + 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 new file mode 100644 index 000000000..1952521c2 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt @@ -0,0 +1,43 @@ +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 actions and 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?, + ) { + 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()) + } + } + } +} 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..da65e80ef --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/SimpleNotification.kt @@ -0,0 +1,120 @@ +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 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" + 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/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 6b6f4ca4f..4218e6214 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); @@ -144,21 +147,6 @@ - (void)invalidate 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]; @@ -192,6 +180,25 @@ - (void)invalidate }); } +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) @@ -206,6 +213,80 @@ - (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..8ba80faaa --- /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..8252c8828 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm @@ -0,0 +1,433 @@ +#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 diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 9a575300d..d21b85c5c 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -79,6 +79,12 @@ export { default as useSystemVolume } from './hooks/useSystemVolume'; export { decodeAudioData, decodePCMInBase64 } from './core/AudioDecoder'; export { default as changePlaybackSpeed } from './core/AudioStretcher'; +// Notification System +export { + PlaybackNotificationManager, + RecordingNotificationManager, +} from './system/notification'; + export { OscillatorType, BiquadFilterType, @@ -95,11 +101,20 @@ export { IOSMode, IOSOption, SessionOptions, - MediaState, - LockScreenInfo, PermissionStatus, } from './system/types'; +export { + NotificationManager, + PlaybackNotificationInfo, + PlaybackControlName, + PlaybackNotificationEventName, + RecordingNotificationInfo, + RecordingControlName, + RecordingNotificationEventName, + SimpleNotificationOptions, +} from './system/notification'; + export { IndexSizeError, InvalidAccessError, diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index 292f86993..22571c557 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -32,11 +32,16 @@ export { IOSMode, IOSOption, SessionOptions, - MediaState, - LockScreenInfo, 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/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index 93556c61f..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 {} @@ -8,7 +9,7 @@ export interface EventTypeWithValue { interface OnInterruptionEventType { type: 'ended' | 'began'; - shouldResume: boolean; + isTransient: boolean; } interface OnRouteChangeEventType { @@ -23,22 +24,7 @@ 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; -} - -type SystemEvents = RemoteCommandEvents & { +type SystemEvents = { volumeChange: EventTypeWithValue; interruption: OnInterruptionEventType; routeChange: OnRouteChangeEventType; @@ -64,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/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts index 21fa17a88..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; @@ -32,9 +25,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..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 @@ -101,6 +80,14 @@ class AudioManager { return NativeAudioAPIModule!.checkRecordingPermissions(); } + async requestNotificationPermissions(): Promise { + return NativeAudioAPIModule!.requestNotificationPermissions(); + } + + async checkNotificationPermissions(): Promise { + return NativeAudioAPIModule!.checkNotificationPermissions(); + } + async getDevicesInfo(): Promise { return NativeAudioAPIModule!.getDevicesInfo(); } 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..cf1f961c2 --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/PlaybackNotificationManager.ts @@ -0,0 +1,193 @@ +import { NativeAudioAPIModule } from '../../specs'; +import { AudioEventEmitter, AudioEventSubscription } from '../../events'; +import type { + NotificationManager, + PlaybackNotificationInfo, + PlaybackControlName, + PlaybackNotificationEventName, + NotificationEvents, +} from './types'; + +/// Manager for media playback notifications with controls and MediaSession integration. +class PlaybackNotificationManager + implements + NotificationManager< + PlaybackNotificationInfo, + PlaybackNotificationInfo, + PlaybackNotificationEventName + > +{ + 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: (event: NotificationEvents[T]) => void + ): 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/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/SimpleNotificationManager.ts b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts new file mode 100644 index 000000000..71f9a0392 --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/SimpleNotificationManager.ts @@ -0,0 +1,170 @@ +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. +// It is only a showcase +class SimpleNotificationManager + implements + NotificationManager< + SimpleNotificationOptions, + SimpleNotificationOptions, + never + > +{ + 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: 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( + // Using a valid event name for the no-op subscription + 'playbackNotificationPlay', + () => {} + ); + } + + /// 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..3ea837c3e --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/index.ts @@ -0,0 +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 new file mode 100644 index 000000000..94d9f994c --- /dev/null +++ b/packages/react-native-audio-api/src/system/notification/types.ts @@ -0,0 +1,107 @@ +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 NotificationEventName, +> { + /// 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: NotificationCallback + ): 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. +interface PlaybackNotificationEvent { + playbackNotificationPlay: EventEmptyType; + playbackNotificationPause: EventEmptyType; + playbackNotificationNext: EventEmptyType; + playbackNotificationPrevious: EventEmptyType; + playbackNotificationSkipForward: EventTypeWithValue; + playbackNotificationSkipBackward: EventTypeWithValue; + playbackNotificationDismissed: EventEmptyType; +} + +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. +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 = ( + event: NotificationEvents[Name] +) => void; + +/// Options for a simple notification with title and text. +export interface SimpleNotificationOptions { + title?: string; + text?: string; +} 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 { 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';