react-native-media-notification 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/MediaControls.podspec +30 -0
- package/README.md +237 -0
- package/android/build.gradle +89 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +29 -0
- package/android/src/main/java/com/mediacontrols/AudioFocusListener.kt +79 -0
- package/android/src/main/java/com/mediacontrols/Controls.kt +22 -0
- package/android/src/main/java/com/mediacontrols/CustomCommandButton.kt +72 -0
- package/android/src/main/java/com/mediacontrols/MediaControlsModule.kt +188 -0
- package/android/src/main/java/com/mediacontrols/MediaControlsPackage.kt +36 -0
- package/android/src/main/java/com/mediacontrols/MediaControlsPlayer.kt +321 -0
- package/android/src/main/java/com/mediacontrols/MediaControlsService.kt +233 -0
- package/android/src/main/java/com/mediacontrols/MediaNotificationProvider.kt +74 -0
- package/ios/MediaControls.h +5 -0
- package/ios/MediaControls.mm +300 -0
- package/lib/module/NativeMediaControls.js +7 -0
- package/lib/module/NativeMediaControls.js.map +1 -0
- package/lib/module/index.js +75 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeMediaControls.d.ts +31 -0
- package/lib/typescript/src/NativeMediaControls.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +34 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +169 -0
- package/src/NativeMediaControls.ts +54 -0
- package/src/index.tsx +87 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
package com.mediacontrols
|
|
2
|
+
|
|
3
|
+
import android.app.NotificationChannel
|
|
4
|
+
import android.app.NotificationManager
|
|
5
|
+
import android.app.Service
|
|
6
|
+
import android.content.ComponentName
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.os.Binder
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.Bundle
|
|
11
|
+
import android.os.IBinder
|
|
12
|
+
import android.view.KeyEvent
|
|
13
|
+
import androidx.annotation.RequiresApi
|
|
14
|
+
import androidx.media3.common.MediaItem
|
|
15
|
+
import androidx.media3.common.Player
|
|
16
|
+
import androidx.media3.common.util.UnstableApi
|
|
17
|
+
import androidx.media3.session.MediaController
|
|
18
|
+
import androidx.media3.session.MediaSession
|
|
19
|
+
import androidx.media3.session.MediaSessionService
|
|
20
|
+
import androidx.media3.session.SessionCommand
|
|
21
|
+
import androidx.media3.session.SessionError
|
|
22
|
+
import androidx.media3.session.SessionResult
|
|
23
|
+
import androidx.media3.session.SessionToken
|
|
24
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
25
|
+
import com.google.common.util.concurrent.Futures
|
|
26
|
+
import com.google.common.util.concurrent.ListenableFuture
|
|
27
|
+
|
|
28
|
+
@UnstableApi
|
|
29
|
+
class MediaControlsService : MediaSessionService() {
|
|
30
|
+
|
|
31
|
+
private var mediaSession: MediaSession? = null
|
|
32
|
+
private val binder = LocalBinder()
|
|
33
|
+
private var mediaController: MediaController? = null
|
|
34
|
+
|
|
35
|
+
companion object {
|
|
36
|
+
private const val CHANNEL_ID = "media_controls_channel"
|
|
37
|
+
var reactContext: ReactApplicationContext? = null
|
|
38
|
+
var player: MediaControlsPlayer? = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
inner class LocalBinder : Binder() {
|
|
42
|
+
fun getService(): MediaControlsService = this@MediaControlsService
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override fun onBind(intent: Intent?): IBinder {
|
|
46
|
+
return if (intent?.action == "androidx.media3.session.MediaSessionService" ||
|
|
47
|
+
intent?.action == "android.media.browse.MediaBrowserService") {
|
|
48
|
+
super.onBind(intent)!!
|
|
49
|
+
} else {
|
|
50
|
+
binder
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override fun onCreate() {
|
|
55
|
+
super.onCreate()
|
|
56
|
+
|
|
57
|
+
// TODO: handle headless service with HeadlessJsTask
|
|
58
|
+
if (player == null || reactContext == null) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create notification channel for Android O and above
|
|
63
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
64
|
+
createNotificationChannel()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Create media session
|
|
68
|
+
mediaSession = MediaSession.Builder(this, player!!)
|
|
69
|
+
.setCallback(MediaSessionCallback())
|
|
70
|
+
.setId("MediaControlsSession")
|
|
71
|
+
.build()
|
|
72
|
+
|
|
73
|
+
updateCustomLayout()
|
|
74
|
+
|
|
75
|
+
player?.addListener(object : Player.Listener {
|
|
76
|
+
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
|
77
|
+
updateCustomLayout()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override fun onRepeatModeChanged(repeatMode: Int) {
|
|
81
|
+
updateCustomLayout()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
setMediaNotificationProvider(MediaNotificationProvider(this))
|
|
86
|
+
|
|
87
|
+
// Create MediaController for media controls
|
|
88
|
+
setupMediaController()
|
|
89
|
+
|
|
90
|
+
android.util.Log.d("MediaControlsService", "Service created with new player instance")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fun updateCustomLayout() {
|
|
94
|
+
mediaSession?.setMediaButtonPreferences(player!!.getAvailableCustomCommands().toList())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private fun setupMediaController() {
|
|
98
|
+
mediaSession?.let { session ->
|
|
99
|
+
val sessionToken = SessionToken(this, ComponentName(this, MediaControlsService::class.java))
|
|
100
|
+
|
|
101
|
+
val controllerFuture = MediaController.Builder(this, sessionToken)
|
|
102
|
+
.buildAsync()
|
|
103
|
+
|
|
104
|
+
controllerFuture.addListener({
|
|
105
|
+
try {
|
|
106
|
+
mediaController = controllerFuture.get()
|
|
107
|
+
} catch (e: Exception) {
|
|
108
|
+
android.util.Log.e("MediaControlsService", "Failed to create MediaController", e)
|
|
109
|
+
}
|
|
110
|
+
}, androidx.core.content.ContextCompat.getMainExecutor(this))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@RequiresApi(Build.VERSION_CODES.O)
|
|
115
|
+
private fun createNotificationChannel() {
|
|
116
|
+
val channel = NotificationChannel(
|
|
117
|
+
CHANNEL_ID,
|
|
118
|
+
"Media Controls",
|
|
119
|
+
NotificationManager.IMPORTANCE_LOW
|
|
120
|
+
).apply {
|
|
121
|
+
description = "Media playback controls"
|
|
122
|
+
setShowBadge(false)
|
|
123
|
+
setSound(null, null)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
127
|
+
notificationManager.createNotificationChannel(channel)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
|
131
|
+
return mediaSession
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
override fun onDestroy() {
|
|
135
|
+
this.stopNotificationAndService()
|
|
136
|
+
super.onDestroy()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fun stopNotificationAndService() {
|
|
140
|
+
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
|
141
|
+
stopSelf()
|
|
142
|
+
player?.releaseFocus()
|
|
143
|
+
mediaSession?.release()
|
|
144
|
+
mediaSession = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fun getPlayer(): MediaControlsPlayer? = player
|
|
148
|
+
|
|
149
|
+
private inner class MediaSessionCallback : MediaSession.Callback {
|
|
150
|
+
|
|
151
|
+
override fun onConnect(
|
|
152
|
+
session: MediaSession,
|
|
153
|
+
controller: MediaSession.ControllerInfo
|
|
154
|
+
): MediaSession.ConnectionResult {
|
|
155
|
+
// Accept all connections and provide full access to player commands
|
|
156
|
+
val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
|
|
157
|
+
.addSessionCommands(CustomCommandButton.entries.map { c -> c.commandButton.sessionCommand!! })
|
|
158
|
+
.build()
|
|
159
|
+
|
|
160
|
+
val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
|
161
|
+
.build()
|
|
162
|
+
|
|
163
|
+
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
|
164
|
+
.setAvailableSessionCommands(sessionCommands)
|
|
165
|
+
.setAvailablePlayerCommands(playerCommands)
|
|
166
|
+
.build()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
|
|
170
|
+
super.onPostConnect(session, controller)
|
|
171
|
+
//mediaSession?.setCustomLayout(CustomCommandButton.entries.map { c -> c.commandButton })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override fun onCustomCommand(
|
|
175
|
+
session: MediaSession,
|
|
176
|
+
controller: MediaSession.ControllerInfo,
|
|
177
|
+
customCommand: SessionCommand,
|
|
178
|
+
args: Bundle
|
|
179
|
+
): ListenableFuture<SessionResult> {
|
|
180
|
+
// Handle custom commands if needed
|
|
181
|
+
return when (customCommand.customAction) {
|
|
182
|
+
"TOGGLE_PLAY_PAUSE" -> {
|
|
183
|
+
player?.let { p ->
|
|
184
|
+
if (p.isPlaying) {
|
|
185
|
+
p.pause()
|
|
186
|
+
} else {
|
|
187
|
+
p.play()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
191
|
+
}
|
|
192
|
+
CustomCommandButton.FORWARD.customAction -> {
|
|
193
|
+
player?.seekForward()
|
|
194
|
+
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
195
|
+
}
|
|
196
|
+
CustomCommandButton.REWIND.customAction -> {
|
|
197
|
+
player?.seekBack()
|
|
198
|
+
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
199
|
+
}
|
|
200
|
+
CustomCommandButton.SHUFFLE_ON.customAction, CustomCommandButton.SHUFFLE_OFF.customAction -> {
|
|
201
|
+
player?.emitShuffleClicked()
|
|
202
|
+
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
203
|
+
}
|
|
204
|
+
CustomCommandButton.REPEAT_ONE.customAction, CustomCommandButton.REPEAT_OFF.customAction, CustomCommandButton.REPEAT_ALL.customAction -> {
|
|
205
|
+
player?.emitRepeatClicked()
|
|
206
|
+
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
207
|
+
}
|
|
208
|
+
else -> Futures.immediateFuture(SessionResult(SessionError.ERROR_UNKNOWN))
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
override fun onAddMediaItems(
|
|
213
|
+
mediaSession: MediaSession,
|
|
214
|
+
controller: MediaSession.ControllerInfo,
|
|
215
|
+
mediaItems: List<MediaItem>
|
|
216
|
+
): ListenableFuture<List<MediaItem>> {
|
|
217
|
+
// Return the media items as-is since we're handling metadata updates separately
|
|
218
|
+
return Futures.immediateFuture(mediaItems)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
override fun onSetMediaItems(
|
|
222
|
+
mediaSession: MediaSession,
|
|
223
|
+
controller: MediaSession.ControllerInfo,
|
|
224
|
+
mediaItems: List<MediaItem>,
|
|
225
|
+
startIndex: Int,
|
|
226
|
+
startPositionMs: Long
|
|
227
|
+
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
|
228
|
+
return Futures.immediateFuture(
|
|
229
|
+
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package com.mediacontrols
|
|
2
|
+
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.content.pm.PackageManager
|
|
7
|
+
import android.os.Bundle
|
|
8
|
+
import androidx.media3.session.CommandButton
|
|
9
|
+
import androidx.media3.session.MediaNotification
|
|
10
|
+
import androidx.media3.session.MediaSession
|
|
11
|
+
import androidx.media3.ui.PlayerNotificationManager
|
|
12
|
+
import androidx.media3.common.util.UnstableApi
|
|
13
|
+
import com.google.common.collect.ImmutableList
|
|
14
|
+
|
|
15
|
+
@UnstableApi
|
|
16
|
+
class MediaNotificationProvider(private val context: Context) : MediaNotification.Provider {
|
|
17
|
+
|
|
18
|
+
override fun createNotification(
|
|
19
|
+
mediaSession: MediaSession,
|
|
20
|
+
customLayout: ImmutableList<CommandButton>,
|
|
21
|
+
actionFactory: MediaNotification.ActionFactory,
|
|
22
|
+
onNotificationChangedCallback: MediaNotification.Provider.Callback
|
|
23
|
+
): MediaNotification {
|
|
24
|
+
|
|
25
|
+
// Create PendingIntent to open the app when notification is clicked
|
|
26
|
+
val contentIntent = createContentIntent()
|
|
27
|
+
|
|
28
|
+
// Get the default notification from the session
|
|
29
|
+
val defaultProvider = androidx.media3.session.DefaultMediaNotificationProvider(context)
|
|
30
|
+
val notification = defaultProvider.createNotification(
|
|
31
|
+
mediaSession,
|
|
32
|
+
customLayout,
|
|
33
|
+
actionFactory,
|
|
34
|
+
onNotificationChangedCallback
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Modify the notification to add the content intent
|
|
38
|
+
val modifiedNotification = notification.notification.apply {
|
|
39
|
+
contentIntent?.let { pendingIntent ->
|
|
40
|
+
this.contentIntent = pendingIntent
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return MediaNotification(notification.notificationId, modifiedNotification)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun createContentIntent(): PendingIntent? {
|
|
48
|
+
return try {
|
|
49
|
+
// Get the launcher activity from the host app
|
|
50
|
+
val packageManager = context.packageManager
|
|
51
|
+
val intent = packageManager.getLaunchIntentForPackage(context.packageName)
|
|
52
|
+
intent?.let {
|
|
53
|
+
PendingIntent.getActivity(
|
|
54
|
+
context,
|
|
55
|
+
0,
|
|
56
|
+
it,
|
|
57
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
} catch (e: Exception) {
|
|
61
|
+
android.util.Log.e("MediaNotificationProvider", "Failed to create content intent", e)
|
|
62
|
+
null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override fun handleCustomCommand(
|
|
67
|
+
session: MediaSession,
|
|
68
|
+
action: String,
|
|
69
|
+
extras: Bundle
|
|
70
|
+
): Boolean {
|
|
71
|
+
// Handle custom commands if needed
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#import "MediaControls.h"
|
|
2
|
+
#import <MediaPlayer/MediaPlayer.h>
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
|
|
5
|
+
@interface MediaControls ()
|
|
6
|
+
@property (nonatomic, assign) BOOL audioInterruptionEnabled;
|
|
7
|
+
@property (nonatomic, assign) BOOL hasListeners;
|
|
8
|
+
@property (nonatomic, assign) BOOL audioInterrupted;
|
|
9
|
+
@property (nonatomic, assign) BOOL explictlyPaused;
|
|
10
|
+
@end
|
|
11
|
+
|
|
12
|
+
@implementation MediaControls
|
|
13
|
+
|
|
14
|
+
RCT_EXPORT_MODULE()
|
|
15
|
+
|
|
16
|
+
- (instancetype)init {
|
|
17
|
+
self = [super init];
|
|
18
|
+
if (self) {
|
|
19
|
+
_audioInterruptionEnabled = NO;
|
|
20
|
+
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(audioHardwareRouteChanged:) name:AVAudioSessionRouteChangeNotification object:nil];
|
|
21
|
+
[[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
|
|
22
|
+
}
|
|
23
|
+
return self;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
- (void)stopObserving {
|
|
27
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
- (NSArray<NSString *> *)supportedEvents {
|
|
31
|
+
return @[@"play", @"pause", @"stop", @"skipToNext", @"skipToPrevious", @"seekForward", @"seekBackward", @"seek"];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#pragma mark - React Native Methods
|
|
35
|
+
|
|
36
|
+
RCT_EXPORT_METHOD(setControlEnabled:(NSString*)name enabled:(BOOL)enabled) {
|
|
37
|
+
MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
|
|
38
|
+
if ([name isEqual: @"play"]) {
|
|
39
|
+
[commandCenter.playCommand addTarget:self action:@selector(handlePlayCommand:)];
|
|
40
|
+
commandCenter.playCommand.enabled = enabled;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if ([name isEqual: @"pause"]) {
|
|
44
|
+
[commandCenter.pauseCommand addTarget:self action:@selector(handlePauseCommand:)];
|
|
45
|
+
commandCenter.pauseCommand.enabled = enabled;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ([name isEqual: @"stop"]) {
|
|
49
|
+
[commandCenter.stopCommand addTarget:self action:@selector(handleStopCommand:)];
|
|
50
|
+
commandCenter.stopCommand.enabled = enabled;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ([name isEqual: @"skipToNext"]) {
|
|
54
|
+
[commandCenter.nextTrackCommand addTarget:self action:@selector(handleNextTrackCommand:)];
|
|
55
|
+
commandCenter.nextTrackCommand.enabled = enabled;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ([name isEqual: @"skipToPrevious"]) {
|
|
59
|
+
[commandCenter.previousTrackCommand addTarget:self action:@selector(handlePreviousTrackCommand:)];
|
|
60
|
+
commandCenter.previousTrackCommand.enabled = enabled;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ([name isEqual: @"seekForward"]) {
|
|
64
|
+
[commandCenter.seekForwardCommand addTarget:self action:@selector(handleSeekForwardCommand:)];
|
|
65
|
+
commandCenter.seekForwardCommand.enabled = enabled;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if ([name isEqual: @"seekBackward"]) {
|
|
69
|
+
[commandCenter.seekBackwardCommand addTarget:self action:@selector(handleSeekBackwardCommand:)];
|
|
70
|
+
commandCenter.seekBackwardCommand.enabled = enabled;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if ([name isEqual: @"seek"]) {
|
|
74
|
+
[commandCenter.changePlaybackPositionCommand addTarget:self action:@selector(handleChangePlaybackPositionCommand:)];
|
|
75
|
+
commandCenter.changePlaybackPositionCommand.enabled = enabled;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
RCT_EXPORT_METHOD(updateMetadata:(JS::NativeMediaControls::MediaTrackMetadata &)metadata
|
|
80
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
81
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
82
|
+
|
|
83
|
+
MPNowPlayingInfoCenter *_nowPlayingCenter = [MPNowPlayingInfoCenter defaultCenter];
|
|
84
|
+
|
|
85
|
+
@try {
|
|
86
|
+
NSMutableDictionary *nowPlayingInfo = [NSMutableDictionary dictionary];
|
|
87
|
+
|
|
88
|
+
if (metadata.title().length > 0) {
|
|
89
|
+
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (metadata.artist().length > 0) {
|
|
93
|
+
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata.artist();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (metadata.album().length > 0) {
|
|
97
|
+
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata.album();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (metadata.duration().has_value()) {
|
|
101
|
+
double duration = metadata.duration().value();
|
|
102
|
+
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = [NSNumber numberWithDouble:duration];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (metadata.position().has_value()) {
|
|
106
|
+
double position = metadata.position().value();
|
|
107
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = [NSNumber numberWithDouble:position];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = metadata.isPlaying() ? [NSNumber numberWithDouble:1] : [NSNumber numberWithDouble:0];
|
|
111
|
+
|
|
112
|
+
_nowPlayingCenter.nowPlayingInfo = nowPlayingInfo;
|
|
113
|
+
|
|
114
|
+
if (@available(iOS 11.0, *)) {
|
|
115
|
+
if (!self.audioInterrupted) {
|
|
116
|
+
self.explictlyPaused = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (metadata.isPlaying()) {
|
|
120
|
+
_nowPlayingCenter.playbackState = MPNowPlayingPlaybackStatePlaying;
|
|
121
|
+
} else {
|
|
122
|
+
_nowPlayingCenter.playbackState = MPNowPlayingPlaybackStatePaused;
|
|
123
|
+
|
|
124
|
+
if (!self.audioInterrupted) {
|
|
125
|
+
self.explictlyPaused = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
// Load artwork if provided
|
|
132
|
+
if (metadata.artwork().length > 0) {
|
|
133
|
+
NSString *artworkURL = metadata.artwork();
|
|
134
|
+
[self loadArtworkFromURL:artworkURL completion:^(UIImage *image) {
|
|
135
|
+
if (!image) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
139
|
+
MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter];
|
|
140
|
+
MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] initWithBoundsSize:image.size requestHandler:^UIImage * _Nonnull(CGSize size) {
|
|
141
|
+
return image;
|
|
142
|
+
}];
|
|
143
|
+
NSMutableDictionary *mediaDict = (center.nowPlayingInfo != nil) ? [[NSMutableDictionary alloc] initWithDictionary: center.nowPlayingInfo] : [NSMutableDictionary dictionary];
|
|
144
|
+
[mediaDict setValue:artwork forKey:MPMediaItemPropertyArtwork];
|
|
145
|
+
center.nowPlayingInfo = mediaDict;
|
|
146
|
+
});
|
|
147
|
+
}];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
resolve(nil);
|
|
151
|
+
}
|
|
152
|
+
@catch (NSException *exception) {
|
|
153
|
+
reject(@"UPDATE_METADATA_ERROR", exception.reason, nil);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
RCT_EXPORT_METHOD(stopMediaNotification:(RCTPromiseResolveBlock)resolve
|
|
158
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
159
|
+
@try {
|
|
160
|
+
[MPNowPlayingInfoCenter defaultCenter].nowPlayingInfo = nil;
|
|
161
|
+
resolve(nil);
|
|
162
|
+
}
|
|
163
|
+
@catch (NSException *exception) {
|
|
164
|
+
reject(@"STOP_NOTIFICATION_ERROR", exception.reason, nil);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
RCT_EXPORT_METHOD(enableAudioInterruption:(BOOL)enabled
|
|
169
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
170
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
171
|
+
@try {
|
|
172
|
+
_audioInterruptionEnabled = enabled;
|
|
173
|
+
|
|
174
|
+
if (enabled) {
|
|
175
|
+
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
176
|
+
selector:@selector(audioSessionInterrupted:)
|
|
177
|
+
name:AVAudioSessionInterruptionNotification
|
|
178
|
+
object:nil];
|
|
179
|
+
} else {
|
|
180
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self
|
|
181
|
+
name:AVAudioSessionInterruptionNotification
|
|
182
|
+
object:nil];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
resolve(nil);
|
|
186
|
+
}
|
|
187
|
+
@catch (NSException *exception) {
|
|
188
|
+
reject(@"AUDIO_INTERRUPTION_ERROR", exception.reason, nil);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
RCT_EXPORT_METHOD(enableBackgroundMode:(BOOL) enabled){
|
|
193
|
+
AVAudioSession *session = [AVAudioSession sharedInstance];
|
|
194
|
+
[session setCategory: AVAudioSessionCategoryPlayback error: nil];
|
|
195
|
+
[session setActive: enabled error: nil];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
- (MPRemoteCommandHandlerStatus)handlePlayCommand:(MPRemoteCommandEvent *)event {
|
|
199
|
+
[self emitEvent:@"play" position:nil];
|
|
200
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
- (MPRemoteCommandHandlerStatus)handlePauseCommand:(MPRemoteCommandEvent *)event {
|
|
204
|
+
[self emitEvent:@"pause" position:nil];
|
|
205
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
- (MPRemoteCommandHandlerStatus)handleStopCommand:(MPRemoteCommandEvent *)event {
|
|
209
|
+
[self emitEvent:@"stop" position:nil];
|
|
210
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
- (MPRemoteCommandHandlerStatus)handleNextTrackCommand:(MPRemoteCommandEvent *)event {
|
|
214
|
+
[self emitEvent:@"skipToNext" position:nil];
|
|
215
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
- (MPRemoteCommandHandlerStatus)handlePreviousTrackCommand:(MPRemoteCommandEvent *)event {
|
|
219
|
+
[self emitEvent:@"skipToPrevious" position:nil];
|
|
220
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
- (MPRemoteCommandHandlerStatus)handleSeekForwardCommand:(MPRemoteCommandEvent *)event {
|
|
224
|
+
[self emitEvent:@"seekForward" position:nil];
|
|
225
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
- (MPRemoteCommandHandlerStatus)handleSeekBackwardCommand:(MPRemoteCommandEvent *)event {
|
|
229
|
+
[self emitEvent:@"seekBackward" position:nil];
|
|
230
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
- (MPRemoteCommandHandlerStatus)handleChangePlaybackPositionCommand:(MPChangePlaybackPositionCommandEvent *)event {
|
|
234
|
+
[self emitEvent:@"seek" position:[NSNumber numberWithDouble:event.positionTime]];
|
|
235
|
+
return MPRemoteCommandHandlerStatusSuccess;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
- (void)emitEvent:(NSString*) name position:(nullable NSNumber*) position {
|
|
239
|
+
NSMutableDictionary *params = [NSMutableDictionary dictionary];
|
|
240
|
+
params[@"command"] = name;
|
|
241
|
+
if (position) {
|
|
242
|
+
params[@"seekPosition"] = position;
|
|
243
|
+
}
|
|
244
|
+
[self emitOnEvent:params];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#pragma mark - Audio Interruption Handler
|
|
248
|
+
|
|
249
|
+
- (void)audioSessionInterrupted:(NSNotification *)notification {
|
|
250
|
+
if (!_audioInterruptionEnabled) return;
|
|
251
|
+
|
|
252
|
+
NSInteger interruptionType = [notification.userInfo[AVAudioSessionInterruptionTypeKey] integerValue];
|
|
253
|
+
|
|
254
|
+
if (interruptionType == AVAudioSessionInterruptionTypeBegan) {
|
|
255
|
+
self.audioInterrupted = true;
|
|
256
|
+
if (!self.explictlyPaused) {
|
|
257
|
+
[self emitEvent:@"pause" position:nil];
|
|
258
|
+
}
|
|
259
|
+
} else if (interruptionType == AVAudioSessionInterruptionTypeEnded) {
|
|
260
|
+
self.audioInterrupted = false;
|
|
261
|
+
if (!self.explictlyPaused) {
|
|
262
|
+
[self emitEvent:@"play" position:nil];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
- (void)audioHardwareRouteChanged:(NSNotification *)notification {
|
|
268
|
+
NSInteger routeChangeReason = [notification.userInfo[AVAudioSessionRouteChangeReasonKey] integerValue];
|
|
269
|
+
if (routeChangeReason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
|
|
270
|
+
//headphones unplugged or bluetooth device disconnected, iOS will pause audio
|
|
271
|
+
[self emitEvent:@"pause" position:nil];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#pragma mark - Helper Methods
|
|
276
|
+
|
|
277
|
+
- (void)loadArtworkFromURL:(NSString *)urlString completion:(void (^)(UIImage *))completion {
|
|
278
|
+
NSURL *url = [NSURL URLWithString:urlString];
|
|
279
|
+
if (!url) {
|
|
280
|
+
completion(nil);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
|
285
|
+
NSData *imageData = [NSData dataWithContentsOfURL:url];
|
|
286
|
+
UIImage *image = imageData ? [UIImage imageWithData:imageData] : nil;
|
|
287
|
+
|
|
288
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
289
|
+
completion(image);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
295
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
296
|
+
{
|
|
297
|
+
return std::make_shared<facebook::react::NativeMediaControlsSpecJSI>(params);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { TurboModuleRegistry } from 'react-native';
|
|
4
|
+
// Event types
|
|
5
|
+
export const ALL_MEDIA_EVENTS = ['play', 'pause', 'stop', 'skipToNext', 'skipToPrevious', 'seekForward', 'seekBackward', 'seek', 'shuffle', 'repeatMode'];
|
|
6
|
+
export default TurboModuleRegistry.getEnforcing('MediaControls');
|
|
7
|
+
//# sourceMappingURL=NativeMediaControls.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["TurboModuleRegistry","ALL_MEDIA_EVENTS","getEnforcing"],"sourceRoot":"..\\..\\src","sources":["NativeMediaControls.ts"],"mappings":";;AACA,SAASA,mBAAmB,QAAQ,cAAc;AAGlD;AACA,OAAO,MAAMC,gBAAgB,GAAG,CAC9B,MAAM,EACN,OAAO,EACP,MAAM,EACN,YAAY,EACZ,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,MAAM,EACN,SAAS,EACT,YAAY,CACJ;AAqCV,eAAeD,mBAAmB,CAACE,YAAY,CAAO,eAAe,CAAC","ignoreList":[]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import MediaControls, { ALL_MEDIA_EVENTS } from "./NativeMediaControls.js";
|
|
4
|
+
import { EventEmitter } from 'fbemitter';
|
|
5
|
+
const eventEmitter = new EventEmitter();
|
|
6
|
+
let unsubscribe = null;
|
|
7
|
+
const setUpNativeEventListener = () => {
|
|
8
|
+
if (unsubscribe) return;
|
|
9
|
+
unsubscribe = MediaControls.onEvent(event => {
|
|
10
|
+
const {
|
|
11
|
+
command,
|
|
12
|
+
seekPosition
|
|
13
|
+
} = event;
|
|
14
|
+
eventEmitter.emit(command, {
|
|
15
|
+
position: seekPosition
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Updates the metadata for the current media track.
|
|
22
|
+
*/
|
|
23
|
+
export async function updateMetadata(metadata) {
|
|
24
|
+
return MediaControls.updateMetadata(metadata);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stops the media notification and clears any ongoing playback state.
|
|
29
|
+
*/
|
|
30
|
+
export async function stopMediaNotification() {
|
|
31
|
+
return MediaControls.stopMediaNotification();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Activates or deactivates audio interruption handling.
|
|
36
|
+
*/
|
|
37
|
+
export async function enableAudioInterruption(enabled) {
|
|
38
|
+
return MediaControls.enableAudioInterruption(enabled);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Activates the audio session for media playback.
|
|
43
|
+
* Call this method before starting media playback to ensure proper Control Center integration.
|
|
44
|
+
*/
|
|
45
|
+
export function enableBackgroundMode(enabled) {
|
|
46
|
+
MediaControls.enableBackgroundMode(enabled);
|
|
47
|
+
}
|
|
48
|
+
export function setControlEnabled(name, enabled) {
|
|
49
|
+
if (!ALL_MEDIA_EVENTS.includes(name)) {
|
|
50
|
+
throw new Error(`Unknown media control event: ${name}`);
|
|
51
|
+
}
|
|
52
|
+
MediaControls.setControlEnabled(name, enabled);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register an event listener for media control events.
|
|
57
|
+
*/
|
|
58
|
+
export function addEventListener(event, handler) {
|
|
59
|
+
setUpNativeEventListener();
|
|
60
|
+
return eventEmitter.addListener(event, handler);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove a specific event listener for media control events.
|
|
65
|
+
*/
|
|
66
|
+
export function removeAllListeners(event) {
|
|
67
|
+
if (event) {
|
|
68
|
+
eventEmitter.removeAllListeners(event);
|
|
69
|
+
} else {
|
|
70
|
+
ALL_MEDIA_EVENTS.forEach(e => eventEmitter.removeAllListeners(e));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Export types
|
|
75
|
+
//# sourceMappingURL=index.js.map
|