react-native-nitro-player 0.0.1 → 0.3.0-alpha.10

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 (47) hide show
  1. package/README.md +282 -2
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +37 -29
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +24 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +408 -16
  5. package/ios/HybridAudioRoutePicker.swift +47 -46
  6. package/ios/HybridTrackPlayer.swift +22 -0
  7. package/ios/core/TrackPlayerCore.swift +538 -48
  8. package/lib/hooks/callbackManager.d.ts +28 -0
  9. package/lib/hooks/callbackManager.js +76 -0
  10. package/lib/hooks/index.d.ts +7 -0
  11. package/lib/hooks/index.js +3 -0
  12. package/lib/hooks/useActualQueue.d.ts +48 -0
  13. package/lib/hooks/useActualQueue.js +98 -0
  14. package/lib/hooks/useNowPlaying.d.ts +36 -0
  15. package/lib/hooks/useNowPlaying.js +87 -0
  16. package/lib/hooks/useOnChangeTrack.d.ts +33 -6
  17. package/lib/hooks/useOnChangeTrack.js +65 -9
  18. package/lib/hooks/useOnPlaybackStateChange.d.ts +32 -6
  19. package/lib/hooks/useOnPlaybackStateChange.js +65 -9
  20. package/lib/hooks/usePlaylist.d.ts +48 -0
  21. package/lib/hooks/usePlaylist.js +136 -0
  22. package/lib/index.d.ts +1 -0
  23. package/lib/specs/TrackPlayer.nitro.d.ts +6 -0
  24. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +46 -9
  25. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +5 -0
  26. package/nitrogen/generated/android/c++/JRepeatMode.hpp +62 -0
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +20 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/RepeatMode.kt +22 -0
  29. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +9 -0
  30. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  31. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +44 -4
  32. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +5 -0
  33. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +64 -0
  34. package/nitrogen/generated/ios/swift/RepeatMode.swift +44 -0
  35. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +5 -0
  36. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +12 -3
  37. package/nitrogen/generated/shared/c++/RepeatMode.hpp +80 -0
  38. package/package.json +13 -12
  39. package/src/hooks/callbackManager.ts +96 -0
  40. package/src/hooks/index.ts +7 -0
  41. package/src/hooks/useActualQueue.ts +116 -0
  42. package/src/hooks/useNowPlaying.ts +97 -0
  43. package/src/hooks/useOnChangeTrack.ts +77 -13
  44. package/src/hooks/useOnPlaybackStateChange.ts +83 -13
  45. package/src/hooks/usePlaylist.ts +161 -0
  46. package/src/index.ts +1 -1
  47. package/src/specs/TrackPlayer.nitro.ts +7 -0
@@ -4,6 +4,8 @@ package com.margelo.nitro.nitroplayer.core
4
4
 
5
5
  import android.content.Context
6
6
  import android.net.Uri
7
+ import androidx.media3.common.AudioAttributes
8
+ import androidx.media3.common.C
7
9
  import androidx.media3.common.MediaItem
8
10
  import androidx.media3.common.MediaMetadata
9
11
  import androidx.media3.common.Player
@@ -13,6 +15,7 @@ import com.margelo.nitro.core.NullType
13
15
  import com.margelo.nitro.nitroplayer.NitroPlayerPackage
14
16
  import com.margelo.nitro.nitroplayer.PlayerState
15
17
  import com.margelo.nitro.nitroplayer.Reason
18
+ import com.margelo.nitro.nitroplayer.RepeatMode
16
19
  import com.margelo.nitro.nitroplayer.TrackItem
17
20
  import com.margelo.nitro.nitroplayer.TrackPlayerState
18
21
  import com.margelo.nitro.nitroplayer.Variant_NullType_String
@@ -40,6 +43,7 @@ class TrackPlayerCore private constructor(
40
43
  private var isAndroidAutoConnected: Boolean = false
41
44
  private var androidAutoConnectionDetector: AndroidAutoConnectionDetector? = null
42
45
  var onAndroidAutoConnectionChange: ((Boolean) -> Unit)? = null
46
+ private var previousMediaItem: MediaItem? = null
43
47
  private val progressUpdateRunnable =
44
48
  object : Runnable {
45
49
  override fun run() {
@@ -58,6 +62,19 @@ class TrackPlayerCore private constructor(
58
62
  var onSeek: ((Double, Double) -> Unit)? = null
59
63
  var onPlaybackProgressChange: ((Double, Double, Boolean?) -> Unit)? = null
60
64
 
65
+ // Temporary tracks for addToUpNext and playNext
66
+ private var playNextStack: MutableList<TrackItem> = mutableListOf() // LIFO - last added plays first
67
+ private var upNextQueue: MutableList<TrackItem> = mutableListOf() // FIFO - first added plays first
68
+ private var currentTemporaryType: TemporaryType = TemporaryType.NONE
69
+ private var currentTracks: List<TrackItem> = emptyList()
70
+
71
+ // Enum to track what type of track is currently playing
72
+ private enum class TemporaryType {
73
+ NONE, // Playing from original playlist
74
+ PLAY_NEXT, // Currently in playNextStack
75
+ UP_NEXT, // Currently in upNextQueue
76
+ }
77
+
61
78
  companion object {
62
79
  @Volatile
63
80
  @Suppress("ktlint:standard:property-naming")
@@ -71,25 +88,43 @@ class TrackPlayerCore private constructor(
71
88
 
72
89
  init {
73
90
  handler.post {
74
- // Configure LoadControl for gapless playback
75
- // This enables pre-buffering of the next track for seamless transitions
91
+ // ============================================================
92
+ // GAPLESS PLAYBACK CONFIGURATION
93
+ // ============================================================
94
+ // Configure LoadControl for maximum gapless playback
95
+ // Large buffers ensure next track is fully ready before current ends
76
96
  val loadControl =
77
97
  DefaultLoadControl
78
98
  .Builder()
79
99
  .setBufferDurationsMs(
80
- DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, // Minimum buffer: 1.5s
81
- DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, // Maximum buffer: 5s
82
- DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, // Buffer for playback: 2.5s
83
- DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, // Buffer after rebuffer: 5s
84
- ).setBackBuffer(DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS, true) // Keep back buffer for seamless transitions
100
+ 30_000, // MIN_BUFFER_MS: 30 seconds minimum buffer
101
+ 120_000, // MAX_BUFFER_MS: 2 minutes maximum buffer (enables preloading next tracks)
102
+ 2_500, // BUFFER_FOR_PLAYBACK_MS: 2.5s before playback starts
103
+ 5_000, // BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS: 5s after rebuffer
104
+ ).setBackBuffer(30_000, true) // Keep 30s back buffer for seamless seek-back
105
+ .setTargetBufferBytes(C.LENGTH_UNSET) // No size limit - prioritize time
85
106
  .setPrioritizeTimeOverSizeThresholds(true) // Prioritize time-based buffering
86
107
  .build()
87
108
 
109
+ // Configure audio attributes for optimal music playback
110
+ // This enables gapless audio processing in the audio pipeline
111
+ val audioAttributes =
112
+ AudioAttributes
113
+ .Builder()
114
+ .setUsage(C.USAGE_MEDIA)
115
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
116
+ .build()
117
+
88
118
  player =
89
119
  ExoPlayer
90
120
  .Builder(context)
91
121
  .setLoadControl(loadControl)
122
+ .setAudioAttributes(audioAttributes, true) // handleAudioFocus = true for gapless
123
+ .setHandleAudioBecomingNoisy(true) // Pause when headphones disconnected
124
+ .setPauseAtEndOfMediaItems(false) // Don't pause between items - key for gapless!
92
125
  .build()
126
+
127
+ println("🎵 TrackPlayerCore: Gapless playback configured - 120s buffer, audio focus handling enabled")
93
128
  mediaSessionManager =
94
129
  MediaSessionManager(context, player, playlistManager).apply {
95
130
  setTrackPlayerCore(this@TrackPlayerCore)
@@ -122,6 +157,60 @@ class TrackPlayerCore private constructor(
122
157
  mediaItem: MediaItem?,
123
158
  reason: Int,
124
159
  ) {
160
+ println("\n🔄 onMediaItemTransition called")
161
+ println(
162
+ " reason: ${when (reason) {
163
+ Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "AUTO (track ended)"
164
+ Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "SEEK"
165
+ Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "PLAYLIST_CHANGED"
166
+ Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "REPEAT"
167
+ else -> "UNKNOWN($reason)"
168
+ }}",
169
+ )
170
+ println(" previousMediaItem: ${previousMediaItem?.mediaId}")
171
+ println(" new mediaItem: ${mediaItem?.mediaId}")
172
+ println(" playNextStack: ${playNextStack.map { it.id }}")
173
+ println(" upNextQueue: ${upNextQueue.map { it.id }}")
174
+
175
+ // Remove finished track from temporary lists
176
+ // Handle AUTO (natural end) and SEEK (skip next) transitions
177
+ if ((
178
+ reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
179
+ reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
180
+ ) &&
181
+ previousMediaItem != null
182
+ ) {
183
+ previousMediaItem?.mediaId?.let { mediaId ->
184
+ val trackId = extractTrackId(mediaId)
185
+ println("🏁 Track finished/skipped, checking for removal: $trackId")
186
+
187
+ // Find and remove from playNext stack (like iOS does)
188
+ val playNextIndex = playNextStack.indexOfFirst { it.id == trackId }
189
+ if (playNextIndex >= 0) {
190
+ val track = playNextStack.removeAt(playNextIndex)
191
+ println(" ✅ Removed from playNext stack: ${track.title}")
192
+ } else {
193
+ // Find and remove from upNext queue
194
+ val upNextIndex = upNextQueue.indexOfFirst { it.id == trackId }
195
+ if (upNextIndex >= 0) {
196
+ val track = upNextQueue.removeAt(upNextIndex)
197
+ println(" ✅ Removed from upNext queue: ${track.title}")
198
+ } else {
199
+ println(" ℹ️ Was an original playlist track")
200
+ }
201
+ }
202
+ }
203
+ } else {
204
+ println(" ⏭️ Skipping removal (reason=$reason, prev=${previousMediaItem != null})")
205
+ }
206
+
207
+ // Store current item as previous for next transition
208
+ previousMediaItem = mediaItem
209
+
210
+ // Update temporary type for current track
211
+ currentTemporaryType = determineCurrentTemporaryType()
212
+ println(" Updated currentTemporaryType: $currentTemporaryType")
213
+
125
214
  // Handle playlist switching if needed
126
215
  mediaItem?.mediaId?.let { mediaId ->
127
216
  if (mediaId.contains(':')) {
@@ -141,7 +230,8 @@ class TrackPlayerCore private constructor(
141
230
  }
142
231
  }
143
232
 
144
- val track = findTrack(mediaItem)
233
+ // Use getCurrentTrack() which handles temporary tracks properly
234
+ val track = getCurrentTrack()
145
235
  if (track != null) {
146
236
  val r =
147
237
  when (reason) {
@@ -209,6 +299,12 @@ class TrackPlayerCore private constructor(
209
299
  */
210
300
  fun loadPlaylist(playlistId: String) {
211
301
  handler.post {
302
+ // Clear temporary tracks when loading new playlist
303
+ playNextStack.clear()
304
+ upNextQueue.clear()
305
+ currentTemporaryType = TemporaryType.NONE
306
+ println(" 🧹 Cleared temporary tracks")
307
+
212
308
  val playlist = playlistManager.getPlaylist(playlistId)
213
309
  if (playlist != null) {
214
310
  currentPlaylistId = playlistId
@@ -295,6 +391,9 @@ class TrackPlayerCore private constructor(
295
391
  }
296
392
 
297
393
  private fun updatePlayerQueue(tracks: List<TrackItem>) {
394
+ // Store the original tracks
395
+ currentTracks = tracks
396
+
298
397
  // Create MediaItems with playlist info in mediaId for Android Auto
299
398
  val mediaItems =
300
399
  tracks.mapIndexed { index, track ->
@@ -365,6 +464,12 @@ class TrackPlayerCore private constructor(
365
464
  println("🎵 TrackPlayerCore: playSong() called - songId: $songId, fromPlaylist: $fromPlaylist")
366
465
 
367
466
  handler.post {
467
+ // Clear temporary tracks when directly playing a song
468
+ playNextStack.clear()
469
+ upNextQueue.clear()
470
+ currentTemporaryType = TemporaryType.NONE
471
+ println(" 🧹 Cleared temporary tracks")
472
+
368
473
  var targetPlaylistId: String? = null
369
474
  var songIndex: Int = -1
370
475
 
@@ -463,8 +568,15 @@ class TrackPlayerCore private constructor(
463
568
 
464
569
  fun skipToPrevious() {
465
570
  handler.post {
466
- if (player.hasPreviousMediaItem()) {
571
+ // If playing temporary track, just seek to beginning (temps not navigable backwards)
572
+ if (currentTemporaryType != TemporaryType.NONE) {
573
+ println("🔄 TrackPlayerCore: Playing temporary track - seeking to beginning")
574
+ player.seekTo(0)
575
+ } else if (player.hasPreviousMediaItem()) {
467
576
  player.seekToPreviousMediaItem()
577
+ } else {
578
+ // Already at first track, seek to beginning
579
+ player.seekTo(0)
468
580
  }
469
581
  }
470
582
  }
@@ -476,6 +588,21 @@ class TrackPlayerCore private constructor(
476
588
  }
477
589
  }
478
590
 
591
+ fun setRepeatMode(mode: RepeatMode): Boolean {
592
+ println("🔁 TrackPlayerCore: setRepeatMode called with mode: $mode")
593
+ handler.post {
594
+ val exoRepeatMode =
595
+ when (mode) {
596
+ RepeatMode.OFF -> Player.REPEAT_MODE_OFF
597
+ RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
598
+ RepeatMode.PLAYLIST -> Player.REPEAT_MODE_ALL
599
+ }
600
+ player.repeatMode = exoRepeatMode
601
+ println("🔁 TrackPlayerCore: ExoPlayer repeat mode set to: $exoRepeatMode")
602
+ }
603
+ return true
604
+ }
605
+
479
606
  fun getState(): PlayerState {
480
607
  // Check if we're already on the main thread
481
608
  if (android.os.Looper.myLooper() == handler.looper) {
@@ -506,13 +633,8 @@ class TrackPlayerCore private constructor(
506
633
 
507
634
  private fun getStateInternal(): PlayerState =
508
635
  if (::player.isInitialized) {
509
- val currentMediaItem = player.currentMediaItem
510
- val track =
511
- if (currentMediaItem != null) {
512
- findTrack(currentMediaItem)
513
- } else {
514
- null
515
- }
636
+ // Use getCurrentTrack() which handles temporary tracks properly
637
+ val track = getCurrentTrack()
516
638
 
517
639
  // Convert nullable TrackItem to Variant_NullType_TrackItem
518
640
  val currentTrack: Variant_NullType_TrackItem? =
@@ -589,12 +711,45 @@ class TrackPlayerCore private constructor(
589
711
  fun getCurrentTrack(): TrackItem? {
590
712
  if (!::player.isInitialized) return null
591
713
  val currentMediaItem = player.currentMediaItem ?: return null
714
+
715
+ // If playing a temporary track, return that
716
+ if (currentTemporaryType != TemporaryType.NONE) {
717
+ val trackId = extractTrackId(currentMediaItem.mediaId)
718
+
719
+ when (currentTemporaryType) {
720
+ TemporaryType.PLAY_NEXT -> {
721
+ return playNextStack.firstOrNull { it.id == trackId }
722
+ }
723
+
724
+ TemporaryType.UP_NEXT -> {
725
+ return upNextQueue.firstOrNull { it.id == trackId }
726
+ }
727
+
728
+ else -> {}
729
+ }
730
+ }
731
+
732
+ // Otherwise return from original playlist
592
733
  return findTrack(currentMediaItem)
593
734
  }
594
735
 
736
+ private fun extractTrackId(mediaId: String): String =
737
+ if (mediaId.contains(':')) {
738
+ // Format: "playlistId:trackId"
739
+ mediaId.substring(mediaId.indexOf(':') + 1)
740
+ } else {
741
+ mediaId
742
+ }
743
+
595
744
  // Public method to play from a specific index (for Android Auto)
596
745
  fun playFromIndex(index: Int) {
597
746
  handler.post {
747
+ // Clear temporary tracks when jumping to specific index
748
+ playNextStack.clear()
749
+ upNextQueue.clear()
750
+ currentTemporaryType = TemporaryType.NONE
751
+ println(" 🧹 Cleared temporary tracks")
752
+
598
753
  if (::player.isInitialized && index >= 0 && index < player.mediaItemCount) {
599
754
  player.seekToDefaultPosition(index)
600
755
  player.playWhenReady = true
@@ -602,6 +757,163 @@ class TrackPlayerCore private constructor(
602
757
  }
603
758
  }
604
759
 
760
+ // MARK: - Temporary Track Management
761
+
762
+ /**
763
+ * Add a track to the up-next queue (FIFO - first added plays first)
764
+ * Track will be inserted after currently playing track and any playNext tracks
765
+ */
766
+ fun addToUpNext(trackId: String) {
767
+ handler.post {
768
+ println("📋 TrackPlayerCore: addToUpNext($trackId)")
769
+
770
+ // Find the track from current playlist or all playlists
771
+ val track = findTrackById(trackId)
772
+ if (track == null) {
773
+ println("❌ TrackPlayerCore: Track $trackId not found")
774
+ return@post
775
+ }
776
+
777
+ // Add to end of upNext queue (FIFO)
778
+ upNextQueue.add(track)
779
+ println(" ✅ Added '${track.title}' to upNext queue (position: ${upNextQueue.size})")
780
+
781
+ // Rebuild the player queue if actively playing
782
+ if (::player.isInitialized && player.currentMediaItem != null) {
783
+ rebuildQueueFromCurrentPosition()
784
+ }
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Add a track to play next (LIFO - last added plays first)
790
+ * Track will be inserted immediately after currently playing track
791
+ */
792
+ fun playNext(trackId: String) {
793
+ handler.post {
794
+ println("⏭️ TrackPlayerCore: playNext($trackId)")
795
+
796
+ // Find the track from current playlist or all playlists
797
+ val track = findTrackById(trackId)
798
+ if (track == null) {
799
+ println("❌ TrackPlayerCore: Track $trackId not found")
800
+ return@post
801
+ }
802
+
803
+ // Insert at beginning of playNext stack (LIFO)
804
+ playNextStack.add(0, track)
805
+ println(" ✅ Added '${track.title}' to playNext stack (position: 1)")
806
+
807
+ // Rebuild the player queue if actively playing
808
+ if (::player.isInitialized && player.currentMediaItem != null) {
809
+ rebuildQueueFromCurrentPosition()
810
+ }
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Rebuild the ExoPlayer queue from current position with temporary tracks
816
+ * Order: [current] + [playNext stack] + [upNext queue] + [remaining original]
817
+ */
818
+ private fun rebuildQueueFromCurrentPosition() {
819
+ if (!::player.isInitialized) return
820
+
821
+ println("\n🔄 TrackPlayerCore: REBUILDING QUEUE FROM CURRENT POSITION")
822
+ println(" currentIndex: ${player.currentMediaItemIndex}")
823
+ println(" currentMediaItem: ${player.currentMediaItem?.mediaId}")
824
+ println(" playNextStack (${playNextStack.size}): ${playNextStack.map { "${it.id}:${it.title}" }}")
825
+ println(" upNextQueue (${upNextQueue.size}): ${upNextQueue.map { "${it.id}:${it.title}" }}")
826
+
827
+ val currentIndex = player.currentMediaItemIndex
828
+ if (currentIndex < 0) return
829
+
830
+ // Build new queue order:
831
+ // [playNext stack] + [upNext queue] + [remaining original tracks]
832
+ val newQueueTracks = mutableListOf<TrackItem>()
833
+
834
+ // Add playNext stack (LIFO - most recently added plays first)
835
+ // Stack is already in correct order since we insert at position 0
836
+ newQueueTracks.addAll(playNextStack)
837
+
838
+ // Add upNext queue (in order, FIFO)
839
+ newQueueTracks.addAll(upNextQueue)
840
+
841
+ // Add remaining original tracks
842
+ if (currentIndex + 1 < currentTracks.size) {
843
+ val remaining = currentTracks.subList(currentIndex + 1, currentTracks.size)
844
+ println(" remaining original (${remaining.size}): ${remaining.map { it.id }}")
845
+ newQueueTracks.addAll(remaining)
846
+ }
847
+
848
+ println(" New queue total: ${newQueueTracks.size} tracks")
849
+ println(" Queue order: ${newQueueTracks.map { it.id }}")
850
+
851
+ // Create MediaItems for new tracks
852
+ val playlistId = currentPlaylistId ?: ""
853
+ val newMediaItems =
854
+ newQueueTracks.map { track ->
855
+ val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
856
+ println(" Creating MediaItem: mediaId=$mediaId, title=${track.title}")
857
+ track.toMediaItem(mediaId)
858
+ }
859
+
860
+ // Remove all items after current
861
+ val removedCount = player.mediaItemCount - currentIndex - 1
862
+ println(" Removing $removedCount items after current")
863
+ while (player.mediaItemCount > currentIndex + 1) {
864
+ player.removeMediaItem(currentIndex + 1)
865
+ }
866
+
867
+ // Add new items
868
+ player.addMediaItems(newMediaItems)
869
+
870
+ println(" ✅ Queue rebuilt. Player now has ${player.mediaItemCount} items")
871
+ for (i in 0 until player.mediaItemCount) {
872
+ println(" [$i]: ${player.getMediaItemAt(i).mediaId}")
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Find a track by ID from current playlist or all playlists
878
+ */
879
+ private fun findTrackById(trackId: String): TrackItem? {
880
+ // First check current playlist
881
+ currentTracks.find { it.id == trackId }?.let { return it }
882
+
883
+ // Then check all playlists
884
+ val allPlaylists = playlistManager.getAllPlaylists()
885
+ for (playlist in allPlaylists) {
886
+ playlist.tracks.find { it.id == trackId }?.let { return it }
887
+ }
888
+
889
+ return null
890
+ }
891
+
892
+ /**
893
+ * Determine what type of track is currently playing
894
+ */
895
+ private fun determineCurrentTemporaryType(): TemporaryType {
896
+ val currentItem = player.currentMediaItem ?: return TemporaryType.NONE
897
+ val trackId =
898
+ if (currentItem.mediaId.contains(':')) {
899
+ currentItem.mediaId.substring(currentItem.mediaId.indexOf(':') + 1)
900
+ } else {
901
+ currentItem.mediaId
902
+ }
903
+
904
+ // Check if in playNext stack
905
+ if (playNextStack.any { it.id == trackId }) {
906
+ return TemporaryType.PLAY_NEXT
907
+ }
908
+
909
+ // Check if in upNext queue
910
+ if (upNextQueue.any { it.id == trackId }) {
911
+ return TemporaryType.UP_NEXT
912
+ }
913
+
914
+ return TemporaryType.NONE
915
+ }
916
+
605
917
  // Clean up resources
606
918
  fun destroy() {
607
919
  handler.post {
@@ -636,4 +948,84 @@ class TrackPlayerCore private constructor(
636
948
  NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
637
949
  }
638
950
  }
951
+
952
+ // Set volume (0-100 range, converted to 0.0-1.0 for ExoPlayer)
953
+ fun setVolume(volume: Double): Boolean =
954
+ if (::player.isInitialized) {
955
+ handler.post {
956
+ // Clamp volume to 0-100 range
957
+ val clampedVolume = volume.coerceIn(0.0, 100.0)
958
+ // Convert to 0.0-1.0 range for ExoPlayer
959
+ val normalizedVolume = (clampedVolume / 100.0).toFloat()
960
+ player.volume = normalizedVolume
961
+ println("🔊 TrackPlayerCore: Volume set to $clampedVolume% (normalized: $normalizedVolume)")
962
+ }
963
+ true
964
+ } else {
965
+ println("⚠️ TrackPlayerCore: Cannot set volume - player not initialized")
966
+ false
967
+ }
968
+
969
+ /**
970
+ * Get the actual queue with temporary tracks
971
+ * Returns: [original_before_current] + [current] + [playNext_stack] + [upNext_queue] + [original_after_current]
972
+ */
973
+ fun getActualQueue(): List<TrackItem> {
974
+ // Check if we're already on the main thread
975
+ if (android.os.Looper.myLooper() == handler.looper) {
976
+ return getActualQueueInternal()
977
+ }
978
+
979
+ // Use CountDownLatch to wait for the result on the main thread
980
+ val latch = CountDownLatch(1)
981
+ var result: List<TrackItem>? = null
982
+
983
+ handler.post {
984
+ try {
985
+ result = getActualQueueInternal()
986
+ } finally {
987
+ latch.countDown()
988
+ }
989
+ }
990
+
991
+ try {
992
+ // Wait up to 5 seconds for the result
993
+ latch.await(5, TimeUnit.SECONDS)
994
+ } catch (e: InterruptedException) {
995
+ println("⚠️ TrackPlayerCore: Interrupted while waiting for actual queue")
996
+ }
997
+
998
+ return result ?: emptyList()
999
+ }
1000
+
1001
+ private fun getActualQueueInternal(): List<TrackItem> {
1002
+ val queue = mutableListOf<TrackItem>()
1003
+
1004
+ if (!::player.isInitialized) return emptyList()
1005
+
1006
+ val currentIndex = player.currentMediaItemIndex
1007
+ if (currentIndex < 0) return emptyList()
1008
+
1009
+ // Add tracks before current (original playlist)
1010
+ if (currentIndex > 0 && currentIndex <= currentTracks.size) {
1011
+ queue.addAll(currentTracks.subList(0, currentIndex))
1012
+ }
1013
+
1014
+ // Add current track
1015
+ getCurrentTrack()?.let { queue.add(it) }
1016
+
1017
+ // Add playNext stack (LIFO - most recently added plays first)
1018
+ // Stack is already in correct order since we insert at position 0
1019
+ queue.addAll(playNextStack)
1020
+
1021
+ // Add upNext queue (in order, FIFO)
1022
+ queue.addAll(upNextQueue)
1023
+
1024
+ // Add remaining original tracks
1025
+ if (currentIndex + 1 < currentTracks.size) {
1026
+ queue.addAll(currentTracks.subList(currentIndex + 1, currentTracks.size))
1027
+ }
1028
+
1029
+ return queue
1030
+ }
639
1031
  }
@@ -1,53 +1,54 @@
1
- import Foundation
2
- import UIKit
3
1
  import AVKit
2
+ import Foundation
4
3
  import NitroModules
4
+ import UIKit
5
5
 
6
6
  class HybridAudioRoutePicker: HybridAudioRoutePickerSpec {
7
-
8
- // Approximate memory footprint of this instance's reference
9
- private func getSizeOf(_ object: AnyObject) -> Int {
10
- // For class instances, MemoryLayout reports the size of the reference (pointer)
11
- return MemoryLayout.size(ofValue: object)
12
- }
13
-
14
- var memorySize: Int {
15
- return getSizeOf(self)
16
- }
17
-
18
- func showRoutePicker() throws -> Void {
19
- DispatchQueue.main.async {
20
- // Create AVRoutePickerView
21
- let routePickerView = AVRoutePickerView()
22
- routePickerView.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
23
- routePickerView.tintColor = .systemBlue
24
- routePickerView.activeTintColor = .systemBlue
25
-
26
- // Get the key window
27
- guard let window = UIApplication.shared.connectedScenes
28
- .compactMap({ $0 as? UIWindowScene })
29
- .flatMap({ $0.windows })
30
- .first(where: { $0.isKeyWindow }) else {
31
- print("HybridAudioRoutePicker: Could not find key window")
32
- return
33
- }
34
-
35
- // Add the route picker to the window temporarily
36
- window.addSubview(routePickerView)
37
-
38
- // Trigger the route picker button programmatically
39
- for view in routePickerView.subviews {
40
- if let button = view as? UIButton {
41
- button.sendActions(for: .touchUpInside)
42
- break
43
- }
44
- }
45
-
46
- // Remove the view after a short delay
47
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
48
- routePickerView.removeFromSuperview()
49
- }
7
+
8
+ // Approximate memory footprint of this instance's reference
9
+ private func getSizeOf(_ object: AnyObject) -> Int {
10
+ // For class instances, MemoryLayout reports the size of the reference (pointer)
11
+ return MemoryLayout.size(ofValue: object)
12
+ }
13
+
14
+ var memorySize: Int {
15
+ return getSizeOf(self)
16
+ }
17
+
18
+ func showRoutePicker() throws {
19
+ DispatchQueue.main.async {
20
+ // Create AVRoutePickerView
21
+ let routePickerView = AVRoutePickerView()
22
+ routePickerView.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
23
+ routePickerView.tintColor = .systemBlue
24
+ routePickerView.activeTintColor = .systemBlue
25
+
26
+ // Get the key window
27
+ guard
28
+ let window = UIApplication.shared.connectedScenes
29
+ .compactMap({ $0 as? UIWindowScene })
30
+ .flatMap({ $0.windows })
31
+ .first(where: { $0.isKeyWindow })
32
+ else {
33
+ print("HybridAudioRoutePicker: Could not find key window")
34
+ return
35
+ }
36
+
37
+ // Add the route picker to the window temporarily
38
+ window.addSubview(routePickerView)
39
+
40
+ // Trigger the route picker button programmatically
41
+ for view in routePickerView.subviews {
42
+ if let button = view as? UIButton {
43
+ button.sendActions(for: .touchUpInside)
44
+ break
50
45
  }
46
+ }
47
+
48
+ // Remove the view after a short delay
49
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
50
+ routePickerView.removeFromSuperview()
51
+ }
51
52
  }
53
+ }
52
54
  }
53
-
@@ -48,10 +48,26 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
48
48
  core.seek(position: position)
49
49
  }
50
50
 
51
+ func addToUpNext(trackId: String) throws {
52
+ core.addToUpNext(trackId: trackId)
53
+ }
54
+
55
+ func playNext(trackId: String) throws {
56
+ core.playNext(trackId: trackId)
57
+ }
58
+
59
+ func getActualQueue() throws -> [TrackItem] {
60
+ return core.getActualQueue()
61
+ }
62
+
51
63
  func getState() throws -> PlayerState {
52
64
  return core.getState()
53
65
  }
54
66
 
67
+ func setRepeatMode(mode: RepeatMode) throws -> Bool {
68
+ return core.setRepeatMode(mode: mode)
69
+ }
70
+
55
71
  // MARK: - Configuration
56
72
 
57
73
  func configure(config: PlayerConfig) throws {
@@ -97,4 +113,10 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
97
113
  func isAndroidAutoConnected() throws -> Bool {
98
114
  return false
99
115
  }
116
+
117
+ // MARK: - Volume Control
118
+
119
+ func setVolume(volume: Double) throws -> Bool {
120
+ return core.setVolume(volume: volume)
121
+ }
100
122
  }