@javascriptcommon/react-native-track-player 4.1.16 → 4.1.18

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.
@@ -242,6 +242,7 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec
242
242
  this["CAPABILITY_LIKE"] = -1
243
243
  this["CAPABILITY_DISLIKE"] = -1
244
244
  this["CAPABILITY_BOOKMARK"] = -1
245
+ this["CAPABILITY_SHUFFLE"] = Capability.SHUFFLE.ordinal
245
246
 
246
247
  }
247
248
  }
@@ -420,6 +421,18 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec
420
421
  val bundle = Arguments.toBundle(data)
421
422
  if (bundle is Bundle) {
422
423
  musicService.load(bundleToTrack(bundle))
424
+
425
+ // Update heart state based on rating (for notification icon)
426
+ try {
427
+ val rating = bundle.get("rating")
428
+ val heartState = when (rating) {
429
+ is Number -> rating.toDouble() > 0
430
+ is Boolean -> rating
431
+ else -> false
432
+ }
433
+ musicService.setHeartState(heartState)
434
+ } catch (_: Exception) { }
435
+
423
436
  callback.resolve(null)
424
437
  } else {
425
438
  callback.reject("invalid_track_object", "Track was not a dictionary type")
@@ -474,12 +487,27 @@ class MusicModule(reactContext: ReactApplicationContext) : NativeTrackPlayerSpec
474
487
  override fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = launchInScope {
475
488
  if (verifyServiceBoundOrReject(callback)) return@launchInScope
476
489
 
477
- if (musicService.tracks.isEmpty())
490
+ if (musicService.tracks.isEmpty()) {
478
491
  callback.reject("no_current_item", "There is no current item in the player")
492
+ return@launchInScope
493
+ }
479
494
 
480
495
  Arguments.toBundle(map)?.let {
481
496
  val track = bundleToTrack(it)
482
497
  musicService.updateNowPlayingMetadata(track)
498
+
499
+ // Update heart state based on rating (for notification icon)
500
+ try {
501
+ val rating = it.get("rating")
502
+ if (rating != null) {
503
+ val heartState = when (rating) {
504
+ is Number -> rating.toDouble() > 0
505
+ is Boolean -> rating
506
+ else -> false
507
+ }
508
+ musicService.setHeartState(heartState)
509
+ }
510
+ } catch (_: Exception) { }
483
511
  }
484
512
 
485
513
  callback.resolve(null)
@@ -135,6 +135,9 @@ class MusicService : HeadlessJsMediaService() {
135
135
  private var playerCommands: Player.Commands? = null
136
136
  private var customLayout: List<CommandButton> = listOf()
137
137
  private var lastWake: Long = 0
138
+ private var shuffleState: Boolean = false
139
+ private var heartState: Boolean = false
140
+ private var notificationCapabilities: List<Capability> = emptyList()
138
141
  var searchResults: List<MediaItem> = listOf()
139
142
  var searchBrowser: MediaSession.ControllerInfo? = null
140
143
  var searchQuery: String = ""
@@ -402,7 +405,14 @@ class MusicService : HeadlessJsMediaService() {
402
405
  }
403
406
 
404
407
  player.alwaysPauseOnInterruption = androidOptions?.getBoolean(PAUSE_ON_INTERRUPTION_KEY) ?: false
405
- player.shuffleMode = androidOptions?.getBoolean(SHUFFLE_KEY) ?: false
408
+ val newShuffleState = androidOptions?.getBoolean(SHUFFLE_KEY) ?: false
409
+ player.shuffleMode = newShuffleState
410
+ shuffleState = newShuffleState
411
+
412
+ // Update heart state if provided
413
+ if (androidOptions?.containsKey("heartState") == true) {
414
+ heartState = androidOptions.getBoolean("heartState")
415
+ }
406
416
 
407
417
  // setup progress update events if configured
408
418
  progressUpdateJob?.cancel()
@@ -414,7 +424,7 @@ class MusicService : HeadlessJsMediaService() {
414
424
  }
415
425
 
416
426
  val capabilities = options.getIntegerArrayList("capabilities")?.map { Capability.entries[it] } ?: emptyList()
417
- var notificationCapabilities = options.getIntegerArrayList("notificationCapabilities")?.map { Capability.entries[it] } ?: emptyList()
427
+ notificationCapabilities = options.getIntegerArrayList("notificationCapabilities")?.map { Capability.entries[it] } ?: emptyList()
418
428
  compactCapabilities = options.getIntegerArrayList("compactCapabilities")?.map { Capability.entries[it] } ?: emptyList()
419
429
  val customActions = options.getBundle(CUSTOM_ACTIONS_KEY)
420
430
  val customActionsList = customActions?.getStringArrayList(CUSTOM_ACTIONS_LIST_KEY)
@@ -464,7 +474,7 @@ class MusicService : HeadlessJsMediaService() {
464
474
  else -> { }
465
475
  }
466
476
  }
467
- customLayout = customActionsList?.map {
477
+ val customButtons = customActionsList?.map {
468
478
  v -> CustomButton(
469
479
  displayName = v,
470
480
  sessionCommand = v,
@@ -475,7 +485,29 @@ class MusicService : HeadlessJsMediaService() {
475
485
  TrackPlayerR.drawable.ifl_24px
476
486
  )
477
487
  ).commandButton
478
- } ?: ImmutableList.of()
488
+ }?.toMutableList() ?: mutableListOf()
489
+
490
+ // Add heart button if SetRating capability is present
491
+ if (notificationCapabilities.contains(Capability.SET_RATING)) {
492
+ val heartIcon = if (heartState) TrackPlayerR.drawable.heart_24px else TrackPlayerR.drawable.hearte_24px
493
+ customButtons.add(0, CustomButton(
494
+ displayName = "Heart",
495
+ sessionCommand = "heart",
496
+ iconRes = heartIcon
497
+ ).commandButton)
498
+ }
499
+
500
+ // Add shuffle button if capability is present
501
+ if (notificationCapabilities.contains(Capability.SHUFFLE)) {
502
+ val shuffleIcon = if (shuffleState) TrackPlayerR.drawable.shuffle_on_24px else TrackPlayerR.drawable.shuffle_24px
503
+ customButtons.add(0, CustomButton(
504
+ displayName = "Shuffle",
505
+ sessionCommand = "shuffle",
506
+ iconRes = shuffleIcon
507
+ ).commandButton)
508
+ }
509
+
510
+ customLayout = customButtons
479
511
 
480
512
  val sessionCommandsBuilder = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
481
513
  customLayout.forEach {
@@ -486,17 +518,15 @@ class MusicService : HeadlessJsMediaService() {
486
518
  sessionCommands = sessionCommandsBuilder.build()
487
519
  playerCommands = playerCommandsBuilder.build()
488
520
 
489
- if (mediaSession.mediaNotificationControllerInfo != null) {
521
+ // Use safe call to avoid race condition
522
+ mediaSession.mediaNotificationControllerInfo?.let { controllerInfo ->
490
523
  // https://github.com/androidx/media/blob/c35a9d62baec57118ea898e271ac66819399649b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt#L107
491
- mediaSession.setCustomLayout(
492
- mediaSession.mediaNotificationControllerInfo!!,
493
- customLayout
494
- )
495
- mediaSession.setAvailableCommands(
496
- mediaSession.mediaNotificationControllerInfo!!,
497
- sessionCommands!!,
498
- playerCommands!!
499
- )
524
+ mediaSession.setCustomLayout(controllerInfo, customLayout)
525
+ sessionCommands?.let { sc ->
526
+ playerCommands?.let { pc ->
527
+ mediaSession.setAvailableCommands(controllerInfo, sc, pc)
528
+ }
529
+ }
500
530
  }
501
531
  }
502
532
 
@@ -648,6 +678,61 @@ class MusicService : HeadlessJsMediaService() {
648
678
  player.repeatMode = value
649
679
  }
650
680
 
681
+ @MainThread
682
+ fun setShuffleState(enabled: Boolean) {
683
+ if (shuffleState != enabled) {
684
+ shuffleState = enabled
685
+ updateCustomLayout()
686
+ }
687
+ }
688
+
689
+ @MainThread
690
+ fun setHeartState(saved: Boolean) {
691
+ if (heartState != saved) {
692
+ heartState = saved
693
+ updateCustomLayout()
694
+ }
695
+ }
696
+
697
+ @MainThread
698
+ private fun updateCustomLayout() {
699
+ // Check if mediaSession is initialized before accessing it
700
+ if (!::mediaSession.isInitialized) return
701
+
702
+ try {
703
+ val customButtons = mutableListOf<CommandButton>()
704
+
705
+ // Add heart button if SetRating capability is present
706
+ if (notificationCapabilities.contains(Capability.SET_RATING)) {
707
+ val heartIcon = if (heartState) TrackPlayerR.drawable.heart_24px else TrackPlayerR.drawable.hearte_24px
708
+ customButtons.add(CustomButton(
709
+ displayName = "Heart",
710
+ sessionCommand = "heart",
711
+ iconRes = heartIcon
712
+ ).commandButton)
713
+ }
714
+
715
+ // Add shuffle button if capability is present
716
+ if (notificationCapabilities.contains(Capability.SHUFFLE)) {
717
+ val shuffleIcon = if (shuffleState) TrackPlayerR.drawable.shuffle_on_24px else TrackPlayerR.drawable.shuffle_24px
718
+ customButtons.add(0, CustomButton(
719
+ displayName = "Shuffle",
720
+ sessionCommand = "shuffle",
721
+ iconRes = shuffleIcon
722
+ ).commandButton)
723
+ }
724
+
725
+ customLayout = customButtons
726
+
727
+ // Use safe call to avoid race condition where mediaNotificationControllerInfo becomes null
728
+ mediaSession.mediaNotificationControllerInfo?.let { controllerInfo ->
729
+ mediaSession.setCustomLayout(controllerInfo, customLayout)
730
+ }
731
+ } catch (e: Exception) {
732
+ // Ignore errors in custom layout update - notification is non-critical
733
+ }
734
+ }
735
+
651
736
  @MainThread
652
737
  fun getVolume(): Float = player.volume
653
738
 
@@ -869,9 +954,13 @@ class MusicService : HeadlessJsMediaService() {
869
954
  }
870
955
 
871
956
  is MediaSessionCallback.CUSTOMACTION -> {
872
- Bundle().apply {
873
- putString("customAction", it.customAction)
874
- emit(MusicEvents.BUTTON_CUSTOM_ACTION, this)
957
+ when (it.customAction) {
958
+ "shuffle" -> emit(MusicEvents.BUTTON_SHUFFLE)
959
+ "heart" -> emit(MusicEvents.BUTTON_SET_RATING, Bundle())
960
+ else -> Bundle().apply {
961
+ putString("customAction", it.customAction)
962
+ emit(MusicEvents.BUTTON_CUSTOM_ACTION, this)
963
+ }
875
964
  }
876
965
  }
877
966
  }
@@ -1224,7 +1313,11 @@ class MusicService : HeadlessJsMediaService() {
1224
1313
  customCommand: SessionCommand,
1225
1314
  args: Bundle
1226
1315
  ): ListenableFuture<SessionResult> {
1227
- emit(MusicEvents.BUTTON_CUSTOM_ACTION, Bundle().apply { putString("customAction", customCommand.customAction) })
1316
+ when (customCommand.customAction) {
1317
+ "shuffle" -> emit(MusicEvents.BUTTON_SHUFFLE)
1318
+ "heart" -> emit(MusicEvents.BUTTON_SET_RATING, Bundle())
1319
+ else -> emit(MusicEvents.BUTTON_CUSTOM_ACTION, Bundle().apply { putString("customAction", customCommand.customAction) })
1320
+ }
1228
1321
  return super.onCustomCommand(session, controller, customCommand, args)
1229
1322
  }
1230
1323
 
@@ -86,6 +86,10 @@ class Track: AudioItem, TimePitching, AssetOptionsProviding {
86
86
  func getAlbumTitle() -> String? {
87
87
  return album
88
88
  }
89
+
90
+ func getDuration() -> Double? {
91
+ return duration
92
+ }
89
93
 
90
94
  func getSourceType() -> SourceType {
91
95
  return url.isLocal ? .file : .stream
@@ -25,6 +25,7 @@ public protocol AudioItem {
25
25
  func getSourceUrl() -> String
26
26
  func getArtist() -> String?
27
27
  func getTitle() -> String?
28
+ func getDuration() -> Double?
28
29
  func getAlbumTitle() -> String?
29
30
  func getSourceType() -> SourceType
30
31
  func getArtwork(_ handler: @escaping (AudioItemImage?) -> Void)
@@ -57,6 +58,8 @@ public class DefaultAudioItem: AudioItem, Identifiable {
57
58
 
58
59
  public var albumTitle: String?
59
60
 
61
+ public var duration: Double?
62
+
60
63
  public var sourceType: SourceType
61
64
 
62
65
  public var artwork: AudioItemImage?
@@ -86,6 +89,10 @@ public class DefaultAudioItem: AudioItem, Identifiable {
86
89
  albumTitle
87
90
  }
88
91
 
92
+ public func getDuration() -> Double? {
93
+ duration
94
+ }
95
+
89
96
  public func getSourceType() -> SourceType {
90
97
  sourceType
91
98
  }
@@ -104,9 +104,12 @@ class AVPlayerItemObserver: NSObject {
104
104
  }
105
105
 
106
106
  case AVPlayerItemKeyPath.loadedTimeRanges:
107
- // Note: loadedTimeRanges represents buffered content, not total duration.
108
- // Do NOT call didUpdateDuration here - it would report buffer size instead of actual duration.
109
- break
107
+ if let item = observingItem, item.duration.isIndefinite || item.duration.seconds.isNaN,
108
+ let ranges = change?[.newKey] as? [NSValue],
109
+ let bufferDuration = ranges.first?.timeRangeValue.duration,
110
+ !bufferDuration.seconds.isNaN {
111
+ delegate?.item(didUpdateDuration: bufferDuration.seconds)
112
+ }
110
113
 
111
114
  case AVPlayerItemKeyPath.playbackLikelyToKeepUp:
112
115
  if let playbackLikelyToKeepUp = change?[.newKey] as? Bool {
@@ -74,6 +74,7 @@ export interface Spec extends TurboModule {
74
74
  CAPABILITY_LIKE: number;
75
75
  CAPABILITY_DISLIKE: number;
76
76
  CAPABILITY_BOOKMARK: number;
77
+ CAPABILITY_SHUFFLE: number;
77
78
  STATE_NONE: string;
78
79
  STATE_READY: string;
79
80
  STATE_PLAYING: string;
@@ -121,6 +122,7 @@ export declare const Constants: {
121
122
  CAPABILITY_LIKE: number;
122
123
  CAPABILITY_DISLIKE: number;
123
124
  CAPABILITY_BOOKMARK: number;
125
+ CAPABILITY_SHUFFLE: number;
124
126
  STATE_NONE: string;
125
127
  STATE_READY: string;
126
128
  STATE_PLAYING: string;
@@ -13,5 +13,6 @@ export declare enum Capability {
13
13
  SetRating,
14
14
  Like,
15
15
  Dislike,
16
- Bookmark
16
+ Bookmark,
17
+ Shuffle
17
18
  }
@@ -16,4 +16,5 @@ export var Capability;
16
16
  Capability[Capability["Like"] = TrackPlayer.CAPABILITY_LIKE] = "Like";
17
17
  Capability[Capability["Dislike"] = TrackPlayer.CAPABILITY_DISLIKE] = "Dislike";
18
18
  Capability[Capability["Bookmark"] = TrackPlayer.CAPABILITY_BOOKMARK] = "Bookmark";
19
+ Capability[Capability["Shuffle"] = TrackPlayer.CAPABILITY_SHUFFLE] = "Shuffle";
19
20
  })(Capability || (Capability = {}));
@@ -141,6 +141,10 @@ export declare enum Event {
141
141
  * See https://rntp.dev/docs/api/events#remoteCustomAction
142
142
  **/
143
143
  RemoteCustomAction = "remote-custom-action",
144
+ /**
145
+ * (Android only) Fired when the user presses the shuffle button.
146
+ **/
147
+ RemoteShuffle = "remote-shuffle",
144
148
  /** (iOS only) Fired when chapter metadata is received.
145
149
  * See https://rntp.dev/docs/api/events#chaptermetadatareceived
146
150
  **/
@@ -142,6 +142,10 @@ export var Event;
142
142
  * See https://rntp.dev/docs/api/events#remoteCustomAction
143
143
  **/
144
144
  Event["RemoteCustomAction"] = "remote-custom-action";
145
+ /**
146
+ * (Android only) Fired when the user presses the shuffle button.
147
+ **/
148
+ Event["RemoteShuffle"] = "remote-shuffle";
145
149
  /** (iOS only) Fired when chapter metadata is received.
146
150
  * See https://rntp.dev/docs/api/events#chaptermetadatareceived
147
151
  **/
@@ -57,6 +57,7 @@ export type EventPayloadByEvent = {
57
57
  [Event.RemoteBrowse]: RemoteBrowseEvent;
58
58
  [Event.PlaybackResume]: PlaybackResumeEvent;
59
59
  [Event.RemoteCustomAction]: RemoteCustomActionEvent;
60
+ [Event.RemoteShuffle]: never;
60
61
  [Event.MetadataChapterReceived]: AudioMetadataReceivedEvent;
61
62
  [Event.MetadataTimedReceived]: AudioMetadataReceivedEvent;
62
63
  [Event.MetadataCommonReceived]: AudioCommonMetadataReceivedEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@javascriptcommon/react-native-track-player",
3
- "version": "4.1.16",
3
+ "version": "4.1.18",
4
4
  "description": "A fully fledged audio module created for music apps",
5
5
  "main": "lib/src/index.js",
6
6
  "types": "lib/src/index.d.ts",
@@ -101,6 +101,7 @@ export interface Spec extends TurboModule {
101
101
  CAPABILITY_LIKE: number;
102
102
  CAPABILITY_DISLIKE: number;
103
103
  CAPABILITY_BOOKMARK: number;
104
+ CAPABILITY_SHUFFLE: number;
104
105
 
105
106
  // States
106
107
  STATE_NONE: string;
@@ -16,4 +16,5 @@ export enum Capability {
16
16
  Like = TrackPlayer.CAPABILITY_LIKE,
17
17
  Dislike = TrackPlayer.CAPABILITY_DISLIKE,
18
18
  Bookmark = TrackPlayer.CAPABILITY_BOOKMARK,
19
+ Shuffle = TrackPlayer.CAPABILITY_SHUFFLE,
19
20
  }
@@ -142,6 +142,10 @@ export enum Event {
142
142
  * See https://rntp.dev/docs/api/events#remoteCustomAction
143
143
  **/
144
144
  RemoteCustomAction = 'remote-custom-action',
145
+ /**
146
+ * (Android only) Fired when the user presses the shuffle button.
147
+ **/
148
+ RemoteShuffle = 'remote-shuffle',
145
149
  /** (iOS only) Fired when chapter metadata is received.
146
150
  * See https://rntp.dev/docs/api/events#chaptermetadatareceived
147
151
  **/
@@ -62,6 +62,7 @@ export type EventPayloadByEvent = {
62
62
  [Event.RemoteBrowse]: RemoteBrowseEvent;
63
63
  [Event.PlaybackResume]: PlaybackResumeEvent;
64
64
  [Event.RemoteCustomAction]: RemoteCustomActionEvent;
65
+ [Event.RemoteShuffle]: never;
65
66
  [Event.MetadataChapterReceived]: AudioMetadataReceivedEvent;
66
67
  [Event.MetadataTimedReceived]: AudioMetadataReceivedEvent;
67
68
  [Event.MetadataCommonReceived]: AudioCommonMetadataReceivedEvent;