react-native-nitro-player 1.5.0 → 1.6.0-alpha.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 (63) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  13. package/ios/HybridTrackPlayer.swift +58 -29
  14. package/ios/core/TrackPlayerCore.swift +22 -1
  15. package/ios/core/TrackPlayerListener.swift +84 -5
  16. package/ios/core/TrackPlayerNotify.swift +13 -7
  17. package/ios/core/TrackPlayerPlayback.swift +75 -39
  18. package/ios/core/TrackPlayerQueue.swift +28 -4
  19. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  20. package/ios/core/TrackPlayerRecovery.swift +1 -1
  21. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  22. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  23. package/ios/download/DownloadDatabase.swift +41 -13
  24. package/ios/media/MediaSessionManager.swift +19 -12
  25. package/ios/queue/HybridPlayerQueue.swift +10 -5
  26. package/lib/hooks/callbackManager.d.ts +27 -1
  27. package/lib/hooks/callbackManager.js +100 -1
  28. package/lib/hooks/index.d.ts +1 -0
  29. package/lib/hooks/index.js +1 -0
  30. package/lib/hooks/useActualQueue.js +18 -12
  31. package/lib/hooks/useNowPlaying.js +10 -2
  32. package/lib/hooks/useTimedMetadata.d.ts +16 -0
  33. package/lib/hooks/useTimedMetadata.js +21 -0
  34. package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
  35. package/lib/types/PlayerQueue.d.ts +7 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  37. package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
  38. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
  39. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  40. package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
  44. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  45. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  47. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
  48. package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
  49. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  50. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  51. package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
  52. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  53. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  54. package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
  55. package/package.json +1 -1
  56. package/src/components/CastButton.tsx +14 -2
  57. package/src/hooks/callbackManager.ts +134 -2
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/useActualQueue.ts +18 -13
  60. package/src/hooks/useNowPlaying.ts +12 -2
  61. package/src/hooks/useTimedMetadata.ts +25 -0
  62. package/src/specs/TrackPlayer.nitro.ts +6 -0
  63. package/src/types/PlayerQueue.ts +8 -0
@@ -8,7 +8,7 @@ import com.margelo.nitro.NitroModules
8
8
  import com.margelo.nitro.core.NullType
9
9
  import com.margelo.nitro.core.Promise
10
10
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
11
- import com.margelo.nitro.nitroplayer.core.loadPlaylist
11
+ import com.margelo.nitro.nitroplayer.core.loadPlaylistOnQueue
12
12
  import com.margelo.nitro.nitroplayer.core.updatePlaylist
13
13
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
14
14
  import java.util.UUID
@@ -109,13 +109,24 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
109
109
 
110
110
  // ── Playback control ──────────────────────────────────────────────────────
111
111
 
112
- override fun loadPlaylist(playlistId: String, index: Double?): Promise<Unit> =
113
- Promise.async {
114
- val startIndex = index?.toInt()
115
- val loaded = playlistManager.loadPlaylist(playlistId, startIndex)
116
- if (!loaded) return@async
117
- core.loadPlaylist(playlistId, startIndex)
112
+ /**
113
+ * Enqueued synchronously on the player looper so a `loadPlaylist()` followed by
114
+ * `play()` from JS cannot be reordered — see HybridTrackPlayer.enqueue.
115
+ */
116
+ override fun loadPlaylist(
117
+ playlistId: String,
118
+ index: Double?,
119
+ ): Promise<Unit> {
120
+ val startIndex = index?.toInt()
121
+ val promise = Promise<Unit>()
122
+ core.enqueue {
123
+ if (playlistManager.loadPlaylist(playlistId, startIndex)) {
124
+ core.loadPlaylistOnQueue(playlistId, startIndex)
125
+ }
126
+ promise.resolve(Unit)
118
127
  }
128
+ return promise
129
+ }
119
130
 
120
131
  override fun getCurrentPlaylistId(): Variant_NullType_String {
121
132
  val id = core.getCurrentPlaylistId()
@@ -7,35 +7,30 @@ import com.facebook.proguard.annotations.DoNotStrip
7
7
  import com.margelo.nitro.NitroModules
8
8
  import com.margelo.nitro.core.Promise
9
9
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
10
- import com.margelo.nitro.nitroplayer.core.addToUpNext
11
- import com.margelo.nitro.nitroplayer.core.clearPlayNext
12
- import com.margelo.nitro.nitroplayer.core.clearUpNext
13
- import com.margelo.nitro.nitroplayer.core.configure
14
- import com.margelo.nitro.nitroplayer.core.getActualQueue
15
- import com.margelo.nitro.nitroplayer.core.getCurrentTrackIndex
16
- import com.margelo.nitro.nitroplayer.core.getNextTracks
17
- import com.margelo.nitro.nitroplayer.core.getPlayBackSpeed
18
- import com.margelo.nitro.nitroplayer.core.getPlayNextQueue
10
+ import com.margelo.nitro.nitroplayer.core.addToUpNextInternal
11
+ import com.margelo.nitro.nitroplayer.core.clearPlayNextOnQueue
12
+ import com.margelo.nitro.nitroplayer.core.clearUpNextOnQueue
13
+ import com.margelo.nitro.nitroplayer.core.configureOnQueue
14
+ import com.margelo.nitro.nitroplayer.core.getActualQueueInternal
15
+ import com.margelo.nitro.nitroplayer.core.getNextTracksInternal
19
16
  import com.margelo.nitro.nitroplayer.core.getRepeatMode
20
- import com.margelo.nitro.nitroplayer.core.getState
21
- import com.margelo.nitro.nitroplayer.core.getTracksById
22
- import com.margelo.nitro.nitroplayer.core.getTracksNeedingUrls
23
- import com.margelo.nitro.nitroplayer.core.getUpNextQueue
24
- import com.margelo.nitro.nitroplayer.core.pause
25
- import com.margelo.nitro.nitroplayer.core.play
26
- import com.margelo.nitro.nitroplayer.core.playNext
27
- import com.margelo.nitro.nitroplayer.core.playSong
28
- import com.margelo.nitro.nitroplayer.core.removeFromPlayNext
29
- import com.margelo.nitro.nitroplayer.core.removeFromUpNext
30
- import com.margelo.nitro.nitroplayer.core.reorderTemporaryTrack
31
- import com.margelo.nitro.nitroplayer.core.seek
32
- import com.margelo.nitro.nitroplayer.core.setPlayBackSpeed
33
- import com.margelo.nitro.nitroplayer.core.setRepeatMode
34
- import com.margelo.nitro.nitroplayer.core.setVolume
35
- import com.margelo.nitro.nitroplayer.core.skipToIndex
36
- import com.margelo.nitro.nitroplayer.core.skipToNext
37
- import com.margelo.nitro.nitroplayer.core.skipToPrevious
38
- import com.margelo.nitro.nitroplayer.core.updateTracks
17
+ import com.margelo.nitro.nitroplayer.core.getStateInternal
18
+ import com.margelo.nitro.nitroplayer.core.getTracksNeedingUrlsInternal
19
+ import com.margelo.nitro.nitroplayer.core.pauseOnQueue
20
+ import com.margelo.nitro.nitroplayer.core.playNextInternal
21
+ import com.margelo.nitro.nitroplayer.core.playOnQueue
22
+ import com.margelo.nitro.nitroplayer.core.playSongInternal
23
+ import com.margelo.nitro.nitroplayer.core.removeFromPlayNextOnQueue
24
+ import com.margelo.nitro.nitroplayer.core.removeFromUpNextOnQueue
25
+ import com.margelo.nitro.nitroplayer.core.reorderTemporaryTrackOnQueue
26
+ import com.margelo.nitro.nitroplayer.core.seekOnQueue
27
+ import com.margelo.nitro.nitroplayer.core.setPlayBackSpeedOnQueue
28
+ import com.margelo.nitro.nitroplayer.core.setRepeatModeOnQueue
29
+ import com.margelo.nitro.nitroplayer.core.setVolumeOnQueue
30
+ import com.margelo.nitro.nitroplayer.core.skipToIndexInternal
31
+ import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
32
+ import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
33
+ import com.margelo.nitro.nitroplayer.core.updateTracksOnQueue
39
34
 
40
35
  @DoNotStrip
41
36
  @Keep
@@ -52,79 +47,99 @@ class HybridTrackPlayer : HybridTrackPlayerSpec() {
52
47
  core = TrackPlayerCore.getInstance(context)
53
48
  }
54
49
 
50
+ // ── Ordered dispatch ──────────────────────────────────────────────────────
51
+ //
52
+ // `Promise.async { … }` launches an unordered coroutine, so the hop onto the player
53
+ // looper happened at an arbitrary later point: two calls made in order from JS could
54
+ // reach the player in reverse order (play-then-pause landing as pause-then-play).
55
+ // `core.enqueue` appends synchronously, on the JS thread at call time, so execution
56
+ // order equals JS call order.
57
+
58
+ private fun <T> enqueue(block: () -> T): Promise<T> {
59
+ val promise = Promise<T>()
60
+ core.enqueue {
61
+ try {
62
+ promise.resolve(block())
63
+ } catch (e: Throwable) {
64
+ promise.reject(e)
65
+ }
66
+ }
67
+ return promise
68
+ }
69
+
55
70
  // ── Playback ─────────────────────────────────────────────────────────────
56
71
 
57
- override fun play(): Promise<Unit> = Promise.async { core.play() }
72
+ override fun play(): Promise<Unit> = enqueue { core.playOnQueue() }
58
73
 
59
- override fun pause(): Promise<Unit> = Promise.async { core.pause() }
74
+ override fun pause(): Promise<Unit> = enqueue { core.pauseOnQueue() }
60
75
 
61
- override fun seek(position: Double): Promise<Unit> = Promise.async { core.seek(position) }
76
+ override fun seek(position: Double): Promise<Unit> = enqueue { core.seekOnQueue(position) }
62
77
 
63
- override fun skipToNext(): Promise<Unit> = Promise.async { core.skipToNext() }
78
+ override fun skipToNext(): Promise<Unit> = enqueue { core.skipToNextOnQueue() }
64
79
 
65
- override fun skipToPrevious(): Promise<Unit> = Promise.async { core.skipToPrevious() }
80
+ override fun skipToPrevious(): Promise<Unit> = enqueue { core.skipToPreviousOnQueue() }
66
81
 
67
82
  override fun playSong(
68
83
  songId: String,
69
84
  fromPlaylist: String?,
70
- ): Promise<Unit> = Promise.async { core.playSong(songId, fromPlaylist) }
85
+ ): Promise<Unit> = enqueue { core.playSongInternal(songId, fromPlaylist) }
71
86
 
72
- override fun skipToIndex(index: Double): Promise<Boolean> = Promise.async { core.skipToIndex(index.toInt()) }
87
+ override fun skipToIndex(index: Double): Promise<Boolean> = enqueue { core.skipToIndexInternal(index.toInt()) }
73
88
 
74
- override fun setRepeatMode(mode: RepeatMode): Promise<Unit> = Promise.async { core.setRepeatMode(mode) }
89
+ override fun setRepeatMode(mode: RepeatMode): Promise<Unit> = enqueue { core.setRepeatModeOnQueue(mode) }
75
90
 
76
91
  override fun getRepeatMode(): RepeatMode = core.getRepeatMode()
77
92
 
78
- override fun setVolume(volume: Double): Promise<Unit> = Promise.async { core.setVolume(volume) }
93
+ override fun setVolume(volume: Double): Promise<Unit> = enqueue { core.setVolumeOnQueue(volume) }
79
94
 
80
- override fun configure(config: PlayerConfig): Promise<Unit> = Promise.async { core.configure(config) }
95
+ override fun configure(config: PlayerConfig): Promise<Unit> = enqueue { core.configureOnQueue(config) }
81
96
 
82
97
  // ── Queue / state reads ───────────────────────────────────────────────────
83
98
 
84
- override fun getActualQueue(): Promise<Array<TrackItem>> = Promise.async { core.getActualQueue().toTypedArray() }
99
+ override fun getActualQueue(): Promise<Array<TrackItem>> = enqueue { core.getActualQueueInternal().toTypedArray() }
85
100
 
86
- override fun getState(): Promise<PlayerState> = Promise.async { core.getState() }
101
+ override fun getState(): Promise<PlayerState> = enqueue { core.getStateInternal() }
87
102
 
88
- override fun getTracksById(trackIds: Array<String>): Promise<Array<TrackItem>> = Promise.async { core.getTracksById(trackIds.toList()).toTypedArray() }
103
+ override fun getTracksById(trackIds: Array<String>): Promise<Array<TrackItem>> = enqueue { core.getPlaylistManager().getTracksById(trackIds.toList()).toTypedArray() }
89
104
 
90
- override fun getTracksNeedingUrls(): Promise<Array<TrackItem>> = Promise.async { core.getTracksNeedingUrls().toTypedArray() }
105
+ override fun getTracksNeedingUrls(): Promise<Array<TrackItem>> = enqueue { core.getTracksNeedingUrlsInternal().toTypedArray() }
91
106
 
92
- override fun getNextTracks(count: Double): Promise<Array<TrackItem>> = Promise.async { core.getNextTracks(count.toInt()).toTypedArray() }
107
+ override fun getNextTracks(count: Double): Promise<Array<TrackItem>> = enqueue { core.getNextTracksInternal(count.toInt()).toTypedArray() }
93
108
 
94
- override fun getCurrentTrackIndex(): Promise<Double> = Promise.async { core.getCurrentTrackIndex().toDouble() }
109
+ override fun getCurrentTrackIndex(): Promise<Double> = enqueue { core.currentTrackIndex.toDouble() }
95
110
 
96
111
  // ── URL updates ───────────────────────────────────────────────────────────
97
112
 
98
- override fun updateTracks(tracks: Array<TrackItem>): Promise<Unit> = Promise.async { core.updateTracks(tracks.toList()) }
113
+ override fun updateTracks(tracks: Array<TrackItem>): Promise<Unit> = enqueue { core.updateTracksOnQueue(tracks.toList()) }
99
114
 
100
115
  // ── Temporary queue ───────────────────────────────────────────────────────
101
116
 
102
- override fun addToUpNext(trackId: String): Promise<Unit> = Promise.async { core.addToUpNext(trackId) }
117
+ override fun addToUpNext(trackId: String): Promise<Unit> = enqueue { core.addToUpNextInternal(trackId) }
103
118
 
104
- override fun playNext(trackId: String): Promise<Unit> = Promise.async { core.playNext(trackId) }
119
+ override fun playNext(trackId: String): Promise<Unit> = enqueue { core.playNextInternal(trackId) }
105
120
 
106
- override fun removeFromPlayNext(trackId: String): Promise<Boolean> = Promise.async { core.removeFromPlayNext(trackId) }
121
+ override fun removeFromPlayNext(trackId: String): Promise<Boolean> = enqueue { core.removeFromPlayNextOnQueue(trackId) }
107
122
 
108
- override fun removeFromUpNext(trackId: String): Promise<Boolean> = Promise.async { core.removeFromUpNext(trackId) }
123
+ override fun removeFromUpNext(trackId: String): Promise<Boolean> = enqueue { core.removeFromUpNextOnQueue(trackId) }
109
124
 
110
- override fun clearPlayNext(): Promise<Unit> = Promise.async { core.clearPlayNext() }
125
+ override fun clearPlayNext(): Promise<Unit> = enqueue { core.clearPlayNextOnQueue() }
111
126
 
112
- override fun clearUpNext(): Promise<Unit> = Promise.async { core.clearUpNext() }
127
+ override fun clearUpNext(): Promise<Unit> = enqueue { core.clearUpNextOnQueue() }
113
128
 
114
129
  override fun reorderTemporaryTrack(
115
130
  trackId: String,
116
131
  newIndex: Double,
117
- ): Promise<Boolean> = Promise.async { core.reorderTemporaryTrack(trackId, newIndex.toInt()) }
132
+ ): Promise<Boolean> = enqueue { core.reorderTemporaryTrackOnQueue(trackId, newIndex.toInt()) }
118
133
 
119
- override fun getPlayNextQueue(): Promise<Array<TrackItem>> = Promise.async { core.getPlayNextQueue().toTypedArray() }
134
+ override fun getPlayNextQueue(): Promise<Array<TrackItem>> = enqueue { core.playNextStack.toTypedArray() }
120
135
 
121
- override fun getUpNextQueue(): Promise<Array<TrackItem>> = Promise.async { core.getUpNextQueue().toTypedArray() }
136
+ override fun getUpNextQueue(): Promise<Array<TrackItem>> = enqueue { core.upNextQueue.toTypedArray() }
122
137
 
123
138
  // ── Playback speed ────────────────────────────────────────────────────────
124
139
 
125
- override fun setPlaybackSpeed(speed: Double): Promise<Unit> = Promise.async { core.setPlayBackSpeed(speed) }
140
+ override fun setPlaybackSpeed(speed: Double): Promise<Unit> = enqueue { core.setPlayBackSpeedOnQueue(speed) }
126
141
 
127
- override fun getPlaybackSpeed(): Promise<Double> = Promise.async { core.getPlayBackSpeed() }
142
+ override fun getPlaybackSpeed(): Promise<Double> = enqueue { if (core.isExoInitialized) core.exo.getPlaybackSpeed().toDouble() else 1.0 }
128
143
 
129
144
  // ── Android Auto ──────────────────────────────────────────────────────────
130
145
 
@@ -171,6 +186,11 @@ class HybridTrackPlayer : HybridTrackPlayerSpec() {
171
186
  listenerIds += "onTracksNeedUpdate" to id
172
187
  }
173
188
 
189
+ override fun onTimedMetadata(callback: (metadata: TimedMetadata) -> Unit) {
190
+ val id = core.addOnTimedMetadataListener(callback)
191
+ listenerIds += "onTimedMetadata" to id
192
+ }
193
+
174
194
  override fun onTemporaryQueueChange(callback: (playNextQueue: Array<TrackItem>, upNextQueue: Array<TrackItem>) -> Unit) {
175
195
  val id =
176
196
  core.addOnTemporaryQueueChangeListener { pn, un ->
@@ -192,6 +212,7 @@ class HybridTrackPlayer : HybridTrackPlayerSpec() {
192
212
  "onAndroidAutoConnectionChange" -> core.removeOnAndroidAutoConnectionListener(id)
193
213
  "onTracksNeedUpdate" -> core.removeOnTracksNeedUpdateListener(id)
194
214
  "onTemporaryQueueChange" -> core.removeOnTemporaryQueueChangeListener(id)
215
+ "onTimedMetadata" -> core.removeOnTimedMetadataListener(id)
195
216
  }
196
217
  }
197
218
  listenerIds.clear()
@@ -11,6 +11,7 @@ import android.os.IBinder
11
11
  import android.os.Looper
12
12
  import com.margelo.nitro.nitroplayer.Reason
13
13
  import com.margelo.nitro.nitroplayer.RepeatMode
14
+ import com.margelo.nitro.nitroplayer.TimedMetadata
14
15
  import com.margelo.nitro.nitroplayer.TrackItem
15
16
  import com.margelo.nitro.nitroplayer.TrackPlayerState
16
17
  import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
@@ -90,10 +91,13 @@ class TrackPlayerCore private constructor(
90
91
  ListenerRegistry<(List<TrackItem>, List<TrackItem>) -> Unit>()
91
92
  internal val onAndroidAutoConnectionListeners =
92
93
  ListenerRegistry<(Boolean) -> Unit>()
94
+ internal val onTimedMetadataListeners =
95
+ ListenerRegistry<(TimedMetadata) -> Unit>()
93
96
  internal val onCastStateChangeListeners =
94
97
  ListenerRegistry<(com.margelo.nitro.nitroplayer.CastState, String?) -> Unit>()
95
98
 
96
99
  // ── Google Cast ──────────────────────────────────────────────────────────
100
+
97
101
  /** Set once the playback service has a usable CastContext; null when Cast is unavailable. */
98
102
  internal var castSessionController: com.margelo.nitro.nitroplayer.media.CastSessionController? = null
99
103
 
@@ -104,18 +108,23 @@ class TrackPlayerCore private constructor(
104
108
  internal var lastCastWaitTrackId: String? = null
105
109
 
106
110
  // ── Progress & playlist-update runnables ───────────────────────────────
111
+
112
+ /**
113
+ * Emits progress once per second while actually playing (matching iOS), instead of
114
+ * four times a second regardless of state. A paused player has nothing to report,
115
+ * and every tick is a JS callback that re-renders every `useNowPlaying` consumer.
116
+ * A seek still reports immediately via `onPositionDiscontinuity`.
117
+ */
107
118
  internal val progressUpdateRunnable =
108
119
  object : Runnable {
109
120
  override fun run() {
110
- if (::exo.isInitialized &&
111
- exo.playbackState != androidx.media3.common.Player.STATE_IDLE
112
- ) {
121
+ if (::exo.isInitialized && exo.isPlaying) {
113
122
  val pos = exo.currentPosition / 1000.0
114
123
  val dur = if (exo.duration > 0) exo.duration / 1000.0 else 0.0
115
124
  notifyPlaybackProgress(pos, dur, if (isManuallySeeked) true else null)
116
125
  isManuallySeeked = false
117
126
  }
118
- playerHandler.postDelayed(this, 250)
127
+ playerHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
119
128
  }
120
129
  }
121
130
 
@@ -155,7 +164,8 @@ class TrackPlayerCore private constructor(
155
164
  }
156
165
  try {
157
166
  context.unbindService(this)
158
- } catch (_: Exception) {}
167
+ } catch (_: Exception) {
168
+ }
159
169
  serviceBound = false
160
170
  if (rebindAttempts < 3) {
161
171
  rebindAttempts++
@@ -192,6 +202,8 @@ class TrackPlayerCore private constructor(
192
202
 
193
203
  // ── Singleton ──────────────────────────────────────────────────────────
194
204
  companion object {
205
+ internal const val PROGRESS_INTERVAL_MS = 1000L
206
+
195
207
  @Volatile
196
208
  @Suppress("ktlint:standard:property-naming")
197
209
  private var INSTANCE: TrackPlayerCore? = null
@@ -206,8 +218,18 @@ class TrackPlayerCore private constructor(
206
218
  // Defer service start/bind to the main thread so it doesn't run
207
219
  // synchronously on the JNI thread during HybridObject creation.
208
220
  handler.post {
209
- val intent = Intent(context, NitroPlayerPlaybackService::class.java)
210
- context.startService(intent)
221
+ // startService throws BackgroundServiceStartNotAllowedException on Android 12+
222
+ // when the process is in the background — which is exactly what happens when JS
223
+ // loads the module from a headless/backgrounded start. It is only here to keep
224
+ // the service alive across unbind; BIND_AUTO_CREATE below already creates it,
225
+ // and the service promotes itself to foreground once playback starts.
226
+ try {
227
+ context.startService(Intent(context, NitroPlayerPlaybackService::class.java))
228
+ } catch (e: Exception) {
229
+ NitroPlayerLogger.log("TrackPlayerCore") {
230
+ "startService skipped (app in background): ${e.message}"
231
+ }
232
+ }
211
233
 
212
234
  val bindIntent =
213
235
  Intent(context, NitroPlayerPlaybackService::class.java).apply {
@@ -219,6 +241,58 @@ class TrackPlayerCore private constructor(
219
241
 
220
242
  // ── Coroutine bridge to player looper (main thread) ────────────────────
221
243
 
244
+ // ── Ordered command dispatch ───────────────────────────────────────────
245
+ //
246
+ // `Promise.async { … }` launches an unordered coroutine, so the hop onto the
247
+ // player looper used to happen at an arbitrary later point: two calls made in
248
+ // order from JS could reach the player in reverse order (play-then-pause landing
249
+ // as pause-then-play). `enqueue` appends to a queue synchronously, on the JS
250
+ // thread at call time, so execution order equals JS call order — including for
251
+ // commands issued before the playback service has bound.
252
+
253
+ private val pendingCommands = ArrayDeque<() -> Unit>()
254
+ private var commandsDraining = false
255
+
256
+ /**
257
+ * Runs [block] on the player looper, preserving the order in which callers
258
+ * enqueued. Safe to call before the service binds — commands buffer until then.
259
+ */
260
+ internal fun enqueue(block: () -> Unit) {
261
+ synchronized(pendingCommands) {
262
+ pendingCommands.addLast(block)
263
+ if (commandsDraining) return
264
+ if (!::playerHandler.isInitialized) return
265
+ commandsDraining = true
266
+ }
267
+ playerHandler.post { drainCommands() }
268
+ }
269
+
270
+ private fun drainCommands() {
271
+ while (true) {
272
+ val next =
273
+ synchronized(pendingCommands) {
274
+ val head = pendingCommands.removeFirstOrNull()
275
+ if (head == null) commandsDraining = false
276
+ head
277
+ } ?: return
278
+ next()
279
+ }
280
+ }
281
+
282
+ /** Called once the player looper exists — flushes anything queued before binding. */
283
+ internal fun startCommandDraining() {
284
+ val shouldPost =
285
+ synchronized(pendingCommands) {
286
+ if (commandsDraining || pendingCommands.isEmpty()) {
287
+ false
288
+ } else {
289
+ commandsDraining = true
290
+ true
291
+ }
292
+ }
293
+ if (shouldPost) playerHandler.post { drainCommands() }
294
+ }
295
+
222
296
  internal suspend fun <T> withPlayerContext(block: () -> T): T {
223
297
  // Wait until the service is bound and player is initialized
224
298
  serviceReady.await()
@@ -256,13 +330,15 @@ class TrackPlayerCore private constructor(
256
330
  }
257
331
  }
258
332
  scope.cancel()
259
- com.margelo.nitro.nitroplayer.media.AuthAwareHttpDataSourceFactory.clear()
333
+ com.margelo.nitro.nitroplayer.media.AuthAwareHttpDataSourceFactory
334
+ .clear()
260
335
  // Do NOT stop the service — it owns the player.
261
336
  // Unbind so Android can clean up if needed.
262
337
  if (serviceBound) {
263
338
  try {
264
339
  context.unbindService(serviceConnection)
265
- } catch (_: Exception) {}
340
+ } catch (_: Exception) {
341
+ }
266
342
  serviceBound = false
267
343
  }
268
344
  }
@@ -303,6 +379,10 @@ class TrackPlayerCore private constructor(
303
379
 
304
380
  fun removeOnTemporaryQueueChangeListener(id: Long): Boolean = onTemporaryQueueChangeListeners.remove(id)
305
381
 
382
+ fun addOnTimedMetadataListener(cb: (TimedMetadata) -> Unit): Long = onTimedMetadataListeners.add(cb)
383
+
384
+ fun removeOnTimedMetadataListener(id: Long): Boolean = onTimedMetadataListeners.remove(id)
385
+
306
386
  fun addOnAndroidAutoConnectionListener(cb: (Boolean) -> Unit): Long = onAndroidAutoConnectionListeners.add(cb)
307
387
 
308
388
  fun removeOnAndroidAutoConnectionListener(id: Long): Boolean = onAndroidAutoConnectionListeners.remove(id)
@@ -3,9 +3,13 @@
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
5
  import androidx.media3.common.MediaItem
6
+ import androidx.media3.common.MediaMetadata
7
+ import androidx.media3.common.Metadata
6
8
  import androidx.media3.common.Player
9
+ import androidx.media3.common.util.UnstableApi
7
10
  import com.margelo.nitro.nitroplayer.Reason
8
11
  import com.margelo.nitro.nitroplayer.RepeatMode
12
+ import com.margelo.nitro.nitroplayer.TimedMetadata
9
13
  import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
10
14
  import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
11
15
 
@@ -57,6 +61,16 @@ internal class TrackPlayerEventListener(
57
61
  if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
58
62
  }
59
63
 
64
+ // Top up the windowed timeline — the item we just left freed a slot.
65
+ // Only when it is actually short: rebuildQueueFromCurrentPosition can fall
66
+ // into the substitute-jump path (setMediaItems), which re-enters this very
67
+ // callback. The length check makes any such re-entry a no-op and terminate.
68
+ if (mediaItem != null &&
69
+ exo.mediaItemCount - exo.currentMediaItemIndex - 1 < QUEUE_WINDOW_SIZE
70
+ ) {
71
+ rebuildQueueFromCurrentPosition()
72
+ }
73
+
60
74
  val track = getCurrentTrack() ?: return
61
75
  val r =
62
76
  when (reason) {
@@ -72,6 +86,22 @@ internal class TrackPlayerEventListener(
72
86
  }
73
87
  }
74
88
 
89
+ /** In-stream metadata (ICY StreamTitle, ID3 frames) arriving mid-playback. */
90
+ @UnstableApi
91
+ override fun onMetadata(metadata: Metadata) {
92
+ val builder = MediaMetadata.Builder()
93
+ for (i in 0 until metadata.length()) {
94
+ metadata.get(i).populateMediaMetadata(builder)
95
+ }
96
+ val merged = builder.build()
97
+ val title = merged.title?.toString()
98
+ val artist = merged.artist?.toString()
99
+ val album = merged.albumTitle?.toString()
100
+ val artworkUrl = merged.artworkUri?.toString()
101
+ if (title == null && artist == null && album == null && artworkUrl == null) return
102
+ core.notifyTimedMetadata(TimedMetadata(title, artist, album, artworkUrl))
103
+ }
104
+
75
105
  override fun onTimelineChanged(
76
106
  timeline: androidx.media3.common.Timeline,
77
107
  reason: Int,
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.nitroplayer.core
2
2
 
3
3
  import com.margelo.nitro.nitroplayer.Reason
4
+ import com.margelo.nitro.nitroplayer.TimedMetadata
4
5
  import com.margelo.nitro.nitroplayer.TrackItem
5
6
  import com.margelo.nitro.nitroplayer.TrackPlayerState
6
7
 
@@ -45,6 +46,10 @@ internal fun TrackPlayerCore.notifyTracksNeedUpdate(
45
46
  onTracksNeedUpdateListeners.forEach { it(tracks, lookahead) }
46
47
  }
47
48
 
49
+ internal fun TrackPlayerCore.notifyTimedMetadata(metadata: TimedMetadata) {
50
+ onTimedMetadataListeners.forEach { it(metadata) }
51
+ }
52
+
48
53
  internal fun TrackPlayerCore.notifyTemporaryQueueChange() {
49
54
  val pn = playNextStack.toList()
50
55
  val un = upNextQueue.toList()