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.
Files changed (29) hide show
  1. package/LICENSE +20 -0
  2. package/MediaControls.podspec +30 -0
  3. package/README.md +237 -0
  4. package/android/build.gradle +89 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +29 -0
  7. package/android/src/main/java/com/mediacontrols/AudioFocusListener.kt +79 -0
  8. package/android/src/main/java/com/mediacontrols/Controls.kt +22 -0
  9. package/android/src/main/java/com/mediacontrols/CustomCommandButton.kt +72 -0
  10. package/android/src/main/java/com/mediacontrols/MediaControlsModule.kt +188 -0
  11. package/android/src/main/java/com/mediacontrols/MediaControlsPackage.kt +36 -0
  12. package/android/src/main/java/com/mediacontrols/MediaControlsPlayer.kt +321 -0
  13. package/android/src/main/java/com/mediacontrols/MediaControlsService.kt +233 -0
  14. package/android/src/main/java/com/mediacontrols/MediaNotificationProvider.kt +74 -0
  15. package/ios/MediaControls.h +5 -0
  16. package/ios/MediaControls.mm +300 -0
  17. package/lib/module/NativeMediaControls.js +7 -0
  18. package/lib/module/NativeMediaControls.js.map +1 -0
  19. package/lib/module/index.js +75 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/package.json +1 -0
  22. package/lib/typescript/package.json +1 -0
  23. package/lib/typescript/src/NativeMediaControls.d.ts +31 -0
  24. package/lib/typescript/src/NativeMediaControls.d.ts.map +1 -0
  25. package/lib/typescript/src/index.d.ts +34 -0
  26. package/lib/typescript/src/index.d.ts.map +1 -0
  27. package/package.json +169 -0
  28. package/src/NativeMediaControls.ts +54 -0
  29. 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
+ )