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,188 @@
|
|
|
1
|
+
package com.mediacontrols
|
|
2
|
+
|
|
3
|
+
import android.content.ComponentName
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.content.ServiceConnection
|
|
7
|
+
import android.os.IBinder
|
|
8
|
+
import androidx.media3.common.util.UnstableApi
|
|
9
|
+
import com.facebook.react.bridge.Arguments
|
|
10
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
11
|
+
import com.facebook.react.bridge.Promise
|
|
12
|
+
import com.facebook.react.bridge.ReadableMap
|
|
13
|
+
import com.facebook.react.bridge.ReactMethod
|
|
14
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
15
|
+
|
|
16
|
+
@ReactModule(name = MediaControlsModule.NAME)
|
|
17
|
+
@UnstableApi
|
|
18
|
+
class MediaControlsModule(reactContext: ReactApplicationContext) :
|
|
19
|
+
NativeMediaControlsSpec(reactContext) {
|
|
20
|
+
|
|
21
|
+
private var mediaService: MediaControlsService? = null
|
|
22
|
+
private var serviceBound = false
|
|
23
|
+
private var serviceInitialized = false // Flag to track if service has been initialized
|
|
24
|
+
|
|
25
|
+
private val serviceConnection = object : ServiceConnection {
|
|
26
|
+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
27
|
+
val binder = service as? MediaControlsService.LocalBinder
|
|
28
|
+
mediaService = binder?.getService()
|
|
29
|
+
serviceBound = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override fun onServiceDisconnected(name: ComponentName?) {
|
|
33
|
+
mediaService = null
|
|
34
|
+
serviceBound = false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
init {
|
|
39
|
+
// Set react context for the service, but don't start it yet
|
|
40
|
+
MediaControlsService.reactContext = reactContext
|
|
41
|
+
MediaControlsService.player = MediaControlsPlayer(reactContext, this)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override fun getName(): String {
|
|
45
|
+
return NAME
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@ReactMethod
|
|
49
|
+
override fun setControlEnabled(name: String, enabled: Boolean) {
|
|
50
|
+
val control = Controls.fromString(name) ?: throw IllegalArgumentException("Invalid control name: $name")
|
|
51
|
+
MediaControlsService.player?.setControlEnabled(control, enabled)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@ReactMethod
|
|
55
|
+
override fun updateMetadata(metadata: ReadableMap, promise: Promise) {
|
|
56
|
+
try {
|
|
57
|
+
// Start service on first updateMetadata call
|
|
58
|
+
ensureServiceStarted()
|
|
59
|
+
|
|
60
|
+
val trackMetadata = MediaTrackMetadata(
|
|
61
|
+
title = metadata.getString("title") ?: "",
|
|
62
|
+
artist = metadata.getString("artist") ?: "",
|
|
63
|
+
album = metadata.getString("album"),
|
|
64
|
+
duration = if (metadata.hasKey("duration")) metadata.getDouble("duration") else null,
|
|
65
|
+
artwork = metadata.getString("artwork"),
|
|
66
|
+
position = if (metadata.hasKey("position")) metadata.getDouble("position") else null,
|
|
67
|
+
isPlaying = if (metadata.hasKey("isPlaying")) metadata.getBoolean("isPlaying") else null,
|
|
68
|
+
shuffleMode = if (metadata.hasKey("shuffle")) metadata.getBoolean("shuffle") else null,
|
|
69
|
+
repeatMode = if (metadata.hasKey("repeatMode")) metadata.getString("repeatMode") else null,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
MediaControlsService.player?.updateMetadata(trackMetadata)
|
|
73
|
+
promise.resolve(null)
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
promise.reject("UPDATE_METADATA_ERROR", "Failed to update metadata: ${e.message}", e)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@ReactMethod
|
|
80
|
+
override fun stopMediaNotification(promise: Promise) {
|
|
81
|
+
try {
|
|
82
|
+
stopMediaService()
|
|
83
|
+
promise.resolve(null)
|
|
84
|
+
} catch (e: Exception) {
|
|
85
|
+
promise.reject("STOP_NOTIFICATION_ERROR", "Failed to stop notification: ${e.message}", e)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@ReactMethod
|
|
90
|
+
override fun enableAudioInterruption(enabled: Boolean, promise: Promise) {
|
|
91
|
+
try {
|
|
92
|
+
MediaControlsService.player?.setAudioInterruptionEnabled(enabled)
|
|
93
|
+
promise.resolve(null)
|
|
94
|
+
} catch (e: Exception) {
|
|
95
|
+
promise.reject("AUDIO_INTERRUPTION_ERROR", "Failed to enable audio interruption: ${e.message}", e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override fun enableBackgroundMode(enabled: Boolean) {
|
|
100
|
+
// NOOP on android
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@ReactMethod
|
|
104
|
+
fun getControlsEnabled(promise: Promise) {
|
|
105
|
+
try {
|
|
106
|
+
ensureServiceStarted()
|
|
107
|
+
val player = MediaControlsService.player
|
|
108
|
+
val controls = Controls.entries.associate{ Pair(it.code, player?.isControlEnabled(it) ?: false) }
|
|
109
|
+
|
|
110
|
+
val result = Arguments.createMap()
|
|
111
|
+
controls.forEach { (key, value) ->
|
|
112
|
+
result.putBoolean(key, value)
|
|
113
|
+
}
|
|
114
|
+
promise.resolve(result)
|
|
115
|
+
} catch (e: Exception) {
|
|
116
|
+
promise.reject("GET_CONTROLS_ERROR", "Failed to get controls state: ${e.message}", e)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@ReactMethod
|
|
121
|
+
fun getCurrentPlayerState(promise: Promise) {
|
|
122
|
+
try {
|
|
123
|
+
ensureServiceStarted()
|
|
124
|
+
val player = MediaControlsService.player
|
|
125
|
+
val result = Arguments.createMap()
|
|
126
|
+
|
|
127
|
+
if (player != null) {
|
|
128
|
+
result.putBoolean("isPlaying", player.isPlaying)
|
|
129
|
+
result.putBoolean("playWhenReady", player.playWhenReady)
|
|
130
|
+
result.putInt("playbackState", player.playbackState)
|
|
131
|
+
result.putString("currentTitle", player.mediaMetadata.title?.toString() ?: "Unknown")
|
|
132
|
+
} else {
|
|
133
|
+
result.putBoolean("isPlaying", false)
|
|
134
|
+
result.putString("error", "Player not available")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
promise.resolve(result)
|
|
138
|
+
} catch (e: Exception) {
|
|
139
|
+
promise.reject("GET_PLAYER_STATE_ERROR", "Failed to get player state: ${e.message}", e)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun ensureServiceStarted() {
|
|
144
|
+
if (!serviceInitialized) {
|
|
145
|
+
startMediaService()
|
|
146
|
+
serviceInitialized = true
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fun sendEvent(eventName: Controls, position: Int?) {
|
|
151
|
+
val eventData = Arguments.createMap().apply {
|
|
152
|
+
putString("command", eventName.code)
|
|
153
|
+
position?.let {
|
|
154
|
+
putInt("seekPosition", it)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
emitOnEvent(eventData)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun startMediaService() {
|
|
161
|
+
val intent = Intent(reactApplicationContext, MediaControlsService::class.java)
|
|
162
|
+
reactApplicationContext.startService(intent)
|
|
163
|
+
reactApplicationContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private fun stopMediaService() {
|
|
167
|
+
mediaService?.stopNotificationAndService()
|
|
168
|
+
|
|
169
|
+
if (serviceBound) {
|
|
170
|
+
reactApplicationContext.unbindService(serviceConnection)
|
|
171
|
+
serviceBound = false
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
val intent = Intent(reactApplicationContext, MediaControlsService::class.java)
|
|
175
|
+
reactApplicationContext.stopService(intent)
|
|
176
|
+
serviceInitialized = false
|
|
177
|
+
mediaService = null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
override fun invalidate() {
|
|
181
|
+
super.invalidate()
|
|
182
|
+
stopMediaService()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
companion object {
|
|
186
|
+
const val NAME = "MediaControls"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package com.mediacontrols
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.util.UnstableApi
|
|
4
|
+
import com.facebook.react.BaseReactPackage
|
|
5
|
+
import com.facebook.react.bridge.NativeModule
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
8
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
9
|
+
import java.util.HashMap
|
|
10
|
+
|
|
11
|
+
class MediaControlsPackage : BaseReactPackage() {
|
|
12
|
+
@UnstableApi
|
|
13
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
14
|
+
return if (name == MediaControlsModule.NAME) {
|
|
15
|
+
MediaControlsModule(reactContext)
|
|
16
|
+
} else {
|
|
17
|
+
null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@UnstableApi
|
|
22
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
23
|
+
return ReactModuleInfoProvider {
|
|
24
|
+
val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
|
|
25
|
+
moduleInfos[MediaControlsModule.NAME] = ReactModuleInfo(
|
|
26
|
+
MediaControlsModule.NAME,
|
|
27
|
+
MediaControlsModule.NAME,
|
|
28
|
+
false, // canOverrideExistingModule
|
|
29
|
+
false, // needsEagerInit
|
|
30
|
+
false, // isCxxModule
|
|
31
|
+
true // isTurboModule
|
|
32
|
+
)
|
|
33
|
+
moduleInfos
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
package com.mediacontrols
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import androidx.media3.common.MediaItem
|
|
6
|
+
import androidx.media3.common.MediaMetadata
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.common.SimpleBasePlayer
|
|
9
|
+
import androidx.media3.common.util.UnstableApi
|
|
10
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
11
|
+
import com.google.common.util.concurrent.Futures
|
|
12
|
+
import com.google.common.util.concurrent.ListenableFuture
|
|
13
|
+
import kotlinx.coroutines.CoroutineScope
|
|
14
|
+
import kotlinx.coroutines.Dispatchers
|
|
15
|
+
import kotlinx.coroutines.SupervisorJob
|
|
16
|
+
import kotlinx.coroutines.cancel
|
|
17
|
+
import androidx.core.net.toUri
|
|
18
|
+
import androidx.media3.session.CommandButton
|
|
19
|
+
|
|
20
|
+
@UnstableApi
|
|
21
|
+
class MediaControlsPlayer(
|
|
22
|
+
reactContext: ReactApplicationContext,
|
|
23
|
+
private val module: MediaControlsModule,
|
|
24
|
+
) : SimpleBasePlayer(Looper.getMainLooper()) {
|
|
25
|
+
|
|
26
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
27
|
+
private var currentState = State.Builder().build()
|
|
28
|
+
|
|
29
|
+
// Track metadata
|
|
30
|
+
private var currentMetadata: MediaTrackMetadata? = null
|
|
31
|
+
|
|
32
|
+
// Audio interruption
|
|
33
|
+
private var audioInterruptionEnabled = false
|
|
34
|
+
|
|
35
|
+
private var audioFocusListener = AudioFocusListener(reactContext, module, this)
|
|
36
|
+
|
|
37
|
+
// Control states
|
|
38
|
+
private val enabledControls = mutableMapOf<Controls, Boolean>()
|
|
39
|
+
|
|
40
|
+
override fun getState(): State = currentState
|
|
41
|
+
|
|
42
|
+
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
|
43
|
+
if (playWhenReady && audioInterruptionEnabled) {
|
|
44
|
+
audioFocusListener.requestAudioFocus()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
updateState { builder ->
|
|
48
|
+
builder
|
|
49
|
+
.setPlayWhenReady(
|
|
50
|
+
playWhenReady,
|
|
51
|
+
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST
|
|
52
|
+
)
|
|
53
|
+
.setContentPositionMs(currentState.contentPositionMsSupplier.get())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Emit event to React Native
|
|
57
|
+
emitPlaybackStateChanged(playWhenReady)
|
|
58
|
+
|
|
59
|
+
return Futures.immediateFuture(null)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun handlePrepare(): ListenableFuture<*> {
|
|
63
|
+
updateState { builder ->
|
|
64
|
+
builder.setPlaybackState(Player.STATE_READY)
|
|
65
|
+
}
|
|
66
|
+
return Futures.immediateFuture(null)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override fun handleStop(): ListenableFuture<*> {
|
|
70
|
+
updateState { builder ->
|
|
71
|
+
builder.setPlayWhenReady(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
|
72
|
+
.setPlaybackState(Player.STATE_IDLE)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.sendEvent(Controls.STOP, null)
|
|
76
|
+
return Futures.immediateFuture(null)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override fun handleSeek(
|
|
80
|
+
mediaItemIndex: Int,
|
|
81
|
+
positionMs: Long,
|
|
82
|
+
seekCommand: Int
|
|
83
|
+
): ListenableFuture<*> {
|
|
84
|
+
updateState { builder ->
|
|
85
|
+
builder.setCurrentMediaItemIndex(mediaItemIndex)
|
|
86
|
+
.setContentPositionMs(positionMs)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle different seek commands
|
|
90
|
+
when (seekCommand) {
|
|
91
|
+
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
|
|
92
|
+
module.sendEvent(Controls.NEXT, null)
|
|
93
|
+
}
|
|
94
|
+
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
|
|
95
|
+
module.sendEvent(Controls.PREVIOUS, null)
|
|
96
|
+
}
|
|
97
|
+
Player.COMMAND_SEEK_FORWARD -> {
|
|
98
|
+
val positionSeconds = (positionMs / 1000).toInt()
|
|
99
|
+
module.sendEvent(Controls.SEEK_FORWARD, positionSeconds)
|
|
100
|
+
}
|
|
101
|
+
Player.COMMAND_SEEK_BACK -> {
|
|
102
|
+
val positionSeconds = (positionMs / 1000).toInt()
|
|
103
|
+
module.sendEvent(Controls.SEEK_BACKWARD, positionSeconds)
|
|
104
|
+
}
|
|
105
|
+
else -> {
|
|
106
|
+
emitSeekEvent(positionMs)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Futures.immediateFuture(null)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
override fun handleSetMediaItems(
|
|
114
|
+
mediaItems: List<MediaItem>,
|
|
115
|
+
startIndex: Int,
|
|
116
|
+
startPositionMs: Long
|
|
117
|
+
): ListenableFuture<*> {
|
|
118
|
+
val mediaItemDataList = mediaItems.map { mediaItem ->
|
|
119
|
+
MediaItemData.Builder(mediaItem.mediaId)
|
|
120
|
+
.setMediaItem(mediaItem)
|
|
121
|
+
.build()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
updateState { builder ->
|
|
125
|
+
builder.setPlaylist(mediaItemDataList)
|
|
126
|
+
.setCurrentMediaItemIndex(startIndex)
|
|
127
|
+
.setContentPositionMs(startPositionMs)
|
|
128
|
+
}
|
|
129
|
+
return Futures.immediateFuture(null)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fun emitShuffleClicked() {
|
|
133
|
+
module.sendEvent(Controls.SHUFFLE, null)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fun emitRepeatClicked() {
|
|
137
|
+
module.sendEvent(Controls.REPEAT_MODE, null)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Custom methods for React Native integration
|
|
141
|
+
fun updateMetadata(metadata: MediaTrackMetadata) {
|
|
142
|
+
if (metadata.isPlaying == true && audioInterruptionEnabled) {
|
|
143
|
+
audioFocusListener.requestAudioFocus()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.currentMetadata = metadata
|
|
147
|
+
|
|
148
|
+
val mediaMetadata = MediaMetadata.Builder()
|
|
149
|
+
.setTitle(metadata.title)
|
|
150
|
+
.setArtist(metadata.artist)
|
|
151
|
+
.setAlbumTitle(metadata.album)
|
|
152
|
+
.setDurationMs(metadata.duration?.times(1000)?.toLong())
|
|
153
|
+
.setIsPlayable(true)
|
|
154
|
+
.setIsBrowsable(false)
|
|
155
|
+
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
|
156
|
+
.apply {
|
|
157
|
+
metadata.artwork?.let { artworkUrl ->
|
|
158
|
+
setArtworkUri(artworkUrl.toUri())
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
.build()
|
|
162
|
+
|
|
163
|
+
// Create unique media ID for Android Auto
|
|
164
|
+
val mediaId = "${metadata.title}_${metadata.artist}".replace(" ", "_")
|
|
165
|
+
|
|
166
|
+
val mediaItem = MediaItem.Builder()
|
|
167
|
+
.setMediaId(mediaId)
|
|
168
|
+
.setUri("content://media/external/audio/media/1") // Placeholder URI for Android Auto
|
|
169
|
+
.setMediaMetadata(mediaMetadata)
|
|
170
|
+
.build()
|
|
171
|
+
|
|
172
|
+
val mediaItemData = MediaItemData.Builder(mediaId)
|
|
173
|
+
.setMediaItem(mediaItem)
|
|
174
|
+
.setDefaultPositionUs(metadata.position?.times(1_000_000)?.toLong() ?: 0)
|
|
175
|
+
.setDurationUs(metadata.duration?.times(1_000_000)?.toLong() ?: androidx.media3.common.C.TIME_UNSET)
|
|
176
|
+
.setIsSeekable(true)
|
|
177
|
+
.build()
|
|
178
|
+
|
|
179
|
+
updateState { builder ->
|
|
180
|
+
builder.setPlaylist(listOf(mediaItemData))
|
|
181
|
+
.setCurrentMediaItemIndex(0)
|
|
182
|
+
.setContentPositionMs(metadata.position?.times(1000)?.toLong() ?: 0)
|
|
183
|
+
.setPlayWhenReady(
|
|
184
|
+
metadata.isPlaying ?: false,
|
|
185
|
+
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST
|
|
186
|
+
)
|
|
187
|
+
.setPlaybackState(Player.STATE_READY)
|
|
188
|
+
.setAvailableCommands(state.availableCommands)
|
|
189
|
+
.setRepeatMode(metadata.repeatMode)
|
|
190
|
+
.setShuffleModeEnabled(metadata.shuffleMode)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun State.Builder.setRepeatMode(mode: String?): State.Builder {
|
|
195
|
+
val repeatMode = when (mode) {
|
|
196
|
+
"off" -> Player.REPEAT_MODE_OFF
|
|
197
|
+
"one" -> Player.REPEAT_MODE_ONE
|
|
198
|
+
"all" -> Player.REPEAT_MODE_ALL
|
|
199
|
+
else -> null
|
|
200
|
+
}
|
|
201
|
+
if (repeatMode == null) {
|
|
202
|
+
return this
|
|
203
|
+
}
|
|
204
|
+
return this.setRepeatMode(repeatMode)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private fun State.Builder.setShuffleModeEnabled(enabled: Boolean?): State.Builder {
|
|
208
|
+
if (enabled == null) {
|
|
209
|
+
return this
|
|
210
|
+
}
|
|
211
|
+
return this.setShuffleModeEnabled(enabled)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
fun setControlEnabled(controlName: Controls, enabled: Boolean) {
|
|
215
|
+
enabledControls[controlName] = enabled
|
|
216
|
+
|
|
217
|
+
// Update available commands based on enabled controls
|
|
218
|
+
val availableCommands = mutableSetOf<Int>().apply {
|
|
219
|
+
if (enabledControls[Controls.PLAY] == true || enabledControls[Controls.PAUSE] == true) add(Player.COMMAND_PLAY_PAUSE)
|
|
220
|
+
if (enabledControls[Controls.STOP] == true) add(Player.COMMAND_STOP)
|
|
221
|
+
if (enabledControls[Controls.NEXT] == true) add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
|
222
|
+
if (enabledControls[Controls.PREVIOUS] == true) add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
|
223
|
+
if (enabledControls[Controls.SEEK] == true) add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
|
|
224
|
+
if (enabledControls[Controls.SEEK_FORWARD] == true) add(Player.COMMAND_SEEK_FORWARD)
|
|
225
|
+
if (enabledControls[Controls.SEEK_BACKWARD] == true) add(Player.COMMAND_SEEK_BACK)
|
|
226
|
+
if (enabledControls[Controls.SHUFFLE] == true) add(Player.COMMAND_SET_SHUFFLE_MODE)
|
|
227
|
+
if (enabledControls[Controls.REPEAT_MODE] == true) add(Player.COMMAND_SET_REPEAT_MODE)
|
|
228
|
+
|
|
229
|
+
add(Player.COMMAND_PREPARE)
|
|
230
|
+
add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
|
|
231
|
+
add(Player.COMMAND_GET_METADATA)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
updateState { builder ->
|
|
235
|
+
builder.setAvailableCommands(Player.Commands.Builder().addAll(*availableCommands.toIntArray()).build())
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
fun getAvailableCustomCommands(): Set<CommandButton> {
|
|
240
|
+
return mutableSetOf<CommandButton>().apply {
|
|
241
|
+
if(isControlEnabled(Controls.SHUFFLE)) {
|
|
242
|
+
if (state.shuffleModeEnabled) {
|
|
243
|
+
add(CustomCommandButton.SHUFFLE_ON.commandButton)
|
|
244
|
+
} else {
|
|
245
|
+
add(CustomCommandButton.SHUFFLE_OFF.commandButton)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (isControlEnabled(Controls.REPEAT_MODE)) {
|
|
249
|
+
when (state.repeatMode) {
|
|
250
|
+
REPEAT_MODE_OFF -> add(CustomCommandButton.REPEAT_OFF.commandButton)
|
|
251
|
+
REPEAT_MODE_ONE -> add(CustomCommandButton.REPEAT_ONE.commandButton)
|
|
252
|
+
REPEAT_MODE_ALL -> add(CustomCommandButton.REPEAT_ALL.commandButton)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (isControlEnabled(Controls.SEEK_BACKWARD)) add(CustomCommandButton.REWIND.commandButton)
|
|
256
|
+
if (isControlEnabled(Controls.SEEK_FORWARD)) add(CustomCommandButton.FORWARD.commandButton)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fun isControlEnabled(controlName: Controls): Boolean {
|
|
261
|
+
return enabledControls[controlName] ?: false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fun setAudioInterruptionEnabled(enabled: Boolean) {
|
|
265
|
+
if (audioInterruptionEnabled == enabled) return
|
|
266
|
+
audioInterruptionEnabled = enabled
|
|
267
|
+
|
|
268
|
+
if (enabled) {
|
|
269
|
+
audioFocusListener.requestAudioFocus()
|
|
270
|
+
} else {
|
|
271
|
+
audioFocusListener.abandonAudioFocus()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
fun releaseFocus() {
|
|
276
|
+
audioFocusListener.abandonAudioFocus()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fun isAudioInterruptionEnabled(): Boolean = audioInterruptionEnabled
|
|
280
|
+
|
|
281
|
+
private fun updateState(updater: (State.Builder) -> State.Builder) {
|
|
282
|
+
val newState = updater(currentState.buildUpon()).build()
|
|
283
|
+
currentState = newState
|
|
284
|
+
|
|
285
|
+
// Ensure invalidateState is called on the correct thread
|
|
286
|
+
if (Looper.myLooper() == applicationLooper) {
|
|
287
|
+
invalidateState()
|
|
288
|
+
} else {
|
|
289
|
+
Handler(applicationLooper).post {
|
|
290
|
+
invalidateState()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private fun emitPlaybackStateChanged(isPlaying: Boolean) {
|
|
296
|
+
val command = if (isPlaying) Controls.PLAY else Controls.PAUSE
|
|
297
|
+
module.sendEvent(command, null)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private fun emitSeekEvent(positionMs: Long) {
|
|
301
|
+
val positionSeconds = (positionMs / 1000).toInt()
|
|
302
|
+
module.sendEvent(Controls.SEEK, positionSeconds)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fun cleanup() {
|
|
306
|
+
scope.cancel()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Data class for metadata
|
|
311
|
+
data class MediaTrackMetadata(
|
|
312
|
+
val title: String,
|
|
313
|
+
val artist: String,
|
|
314
|
+
val album: String? = null,
|
|
315
|
+
val duration: Double? = null,
|
|
316
|
+
val artwork: String? = null,
|
|
317
|
+
val position: Double? = null,
|
|
318
|
+
val isPlaying: Boolean? = null,
|
|
319
|
+
val repeatMode: String? = null,
|
|
320
|
+
val shuffleMode: Boolean? = null
|
|
321
|
+
)
|