react-native-nitro-player 1.7.0-alpha.3 → 1.7.0-alpha.5

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 (98) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/AndroidManifest.xml +8 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +51 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +2 -2
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +17 -2
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +2 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +41 -31
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +1 -1
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +92 -15
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +12 -17
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +39 -13
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +25 -4
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerRecovery.kt +97 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +0 -5
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +22 -5
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -0
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +6 -0
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +2 -0
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +96 -45
  26. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +2 -0
  27. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +147 -0
  28. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +3 -0
  29. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +102 -24
  30. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +51 -3
  31. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +82 -1
  32. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +75 -0
  33. package/ios/cast/CastQueueDiff.swift +58 -0
  34. package/ios/cast/CastQueueReconciler.swift +228 -0
  35. package/ios/core/CastSessionManager.swift +215 -31
  36. package/ios/core/TrackPlayerCast.swift +96 -65
  37. package/ios/core/TrackPlayerCore.swift +31 -0
  38. package/ios/core/TrackPlayerListener.swift +19 -3
  39. package/ios/core/TrackPlayerPlayback.swift +12 -10
  40. package/ios/core/TrackPlayerQueue.swift +10 -2
  41. package/ios/core/TrackPlayerQueueBuild.swift +21 -9
  42. package/ios/core/TrackPlayerTempQueue.swift +27 -15
  43. package/ios/core/TrackPlayerUrlLoader.swift +6 -2
  44. package/ios/playlist/PlaylistManager.swift +60 -1
  45. package/ios/queue/HybridPlayerQueue.swift +40 -13
  46. package/lib/hooks/callbackManager.d.ts +2 -0
  47. package/lib/hooks/callbackManager.js +23 -8
  48. package/lib/index.js +5 -1
  49. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
  50. package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
  51. package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
  52. package/lib/types/PlayerQueue.d.ts +14 -0
  53. package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
  54. package/lib/utils/androidAutoMediaLibrary.js +23 -0
  55. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
  56. package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
  57. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  58. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  59. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
  60. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
  61. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
  63. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
  64. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
  65. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
  66. package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
  72. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
  73. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
  74. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
  75. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
  76. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
  77. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
  78. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
  79. package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
  80. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
  81. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
  82. package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
  83. package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
  84. package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
  85. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
  86. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
  87. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
  88. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
  89. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
  90. package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
  91. package/package.json +1 -1
  92. package/src/hooks/callbackManager.ts +25 -10
  93. package/src/index.ts +9 -1
  94. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
  95. package/src/specs/TrackPlayer.nitro.ts +11 -0
  96. package/src/types/AndroidAutoMediaLibrary.ts +24 -0
  97. package/src/types/PlayerQueue.ts +17 -0
  98. package/src/utils/androidAutoMediaLibrary.ts +48 -1
@@ -43,7 +43,12 @@ internal fun TrackPlayerCore.notifyTracksNeedUpdate(
43
43
  tracks: List<TrackItem>,
44
44
  lookahead: Int,
45
45
  ) {
46
+ NitroPlayerLogger.log("TrackPlayer") { "tracksNeedUpdate: ${tracks.size} track(s), listening=${!onTracksNeedUpdateListeners.isEmpty}" }
47
+ startJsRuntimeIfNeeded()
48
+ updateJsKeepAlive()
46
49
  onTracksNeedUpdateListeners.forEach { it(tracks, lookahead) }
50
+ // A lookup that hits a dead network usually fails in the app; ask again once it is back
51
+ awaitNetworkThenRetry(afterLoss = isNetworkValidated())
47
52
  }
48
53
 
49
54
  internal fun TrackPlayerCore.notifyTimedMetadata(metadata: TimedMetadata) {
@@ -20,11 +20,17 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
20
20
 
21
21
  suspend fun TrackPlayerCore.play() = withPlayerContext { playOnQueue() }
22
22
 
23
- internal fun TrackPlayerCore.playOnQueue() = exo.play()
23
+ internal fun TrackPlayerCore.playOnQueue() {
24
+ audioFocusController?.acquire()
25
+ exo.play()
26
+ }
24
27
 
25
28
  suspend fun TrackPlayerCore.pause() = withPlayerContext { pauseOnQueue() }
26
29
 
27
- internal fun TrackPlayerCore.pauseOnQueue() = exo.pause()
30
+ internal fun TrackPlayerCore.pauseOnQueue() {
31
+ exo.pause()
32
+ audioFocusController?.abandon()
33
+ }
28
34
 
29
35
  suspend fun TrackPlayerCore.seek(position: Double) = withPlayerContext { seekOnQueue(position) }
30
36
 
@@ -55,9 +61,8 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
55
61
  }
56
62
 
57
63
  currentTemporaryType != TrackPlayerCore.TemporaryType.NONE -> {
58
- // playFromIndexInternal clears both temp lists (matching iOS behavior),
59
- // so no per-track removal is needed here
60
- playFromIndexInternal(currentTrackIndex)
64
+ // playFromIndexInternal clears both temp lists; cursor may be -1 (anchor removed).
65
+ if (currentTracks.isEmpty()) exo.seekTo(0) else playFromIndexInternal(maxOf(0, currentTrackIndex))
61
66
  }
62
67
 
63
68
  currentTrackIndex > 0 -> {
@@ -76,12 +81,7 @@ suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext
76
81
  internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
77
82
  val previousMode = currentRepeatMode
78
83
  currentRepeatMode = mode
79
- exo.setRepeatMode(
80
- when (mode) {
81
- RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
82
- else -> Player.REPEAT_MODE_OFF
83
- },
84
- )
84
+ applyBackendRepeatMode()
85
85
  if (!isCastingField && previousMode != mode &&
86
86
  (previousMode == RepeatMode.PLAYLIST || mode == RepeatMode.PLAYLIST)
87
87
  ) {
@@ -89,19 +89,44 @@ internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
89
89
  }
90
90
  }
91
91
 
92
+ /** True while the playing item was removed from the playlist and is finishing. */
93
+ internal fun TrackPlayerCore.isTransientPlayOut(): Boolean {
94
+ if (!isExoInitialized || currentTemporaryType != TrackPlayerCore.TemporaryType.NONE) return false
95
+ val item = exo.currentMediaItem ?: return false
96
+ return findTrack(item) == null
97
+ }
98
+
99
+ /** Follows the user's mode except during a transient play-out, which must finish once. */
100
+ internal fun TrackPlayerCore.backendRepeatMode(): Int =
101
+ when {
102
+ isTransientPlayOut() -> Player.REPEAT_MODE_OFF
103
+ currentRepeatMode == RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
104
+ else -> Player.REPEAT_MODE_OFF
105
+ }
106
+
107
+ internal fun TrackPlayerCore.applyBackendRepeatMode() {
108
+ if (!isExoInitialized) return
109
+ val desired = backendRepeatMode()
110
+ if (exo.player.repeatMode != desired) exo.setRepeatMode(desired)
111
+ }
112
+
92
113
  fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
93
114
 
94
115
  suspend fun TrackPlayerCore.setVolume(volume: Double) = withPlayerContext { setVolumeOnQueue(volume) }
95
116
 
96
117
  internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
97
118
  val clamped = volume.coerceIn(0.0, 100.0)
98
- exo.setVolume((clamped / 100.0).toFloat())
119
+ userVolume = (clamped / 100.0).toFloat()
120
+ exo.setVolume(userVolume * if (isDucked) DUCK_VOLUME_FACTOR else 1f)
99
121
  }
100
122
 
101
123
  suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
102
124
 
103
125
  internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
104
- config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
126
+ // JS is calling, so the keep-alive task is registered: start it now if a headless start needs it
127
+ updateJsKeepAlive()
128
+ config.androidAudioFocus?.let { applyAudioFocusMode(it) }
129
+ config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.setAndroidAutoEnabled(context, it) }
105
130
  config.lookaheadCount?.let { lookaheadCount = it.toInt() }
106
131
  config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
107
132
  remoteSkipIntervalMs(config.remoteSkipForwardInterval)?.let { remoteSkipForwardIntervalMs = it }
@@ -185,6 +210,7 @@ internal fun TrackPlayerCore.playSongInternal(
185
210
 
186
211
  if (targetPlaylistId == null || songIndex < 0) return
187
212
 
213
+ playlistManager.setCurrentPlaylistId(targetPlaylistId)
188
214
  if (currentPlaylistId != targetPlaylistId) {
189
215
  val playlist = playlistManager.getPlaylist(targetPlaylistId) ?: return
190
216
  currentPlaylistId = targetPlaylistId
@@ -72,8 +72,8 @@ suspend fun TrackPlayerCore.getActualQueue(): List<TrackItem> = withPlayerContex
72
72
 
73
73
  internal fun TrackPlayerCore.getActualQueueInternal(): List<TrackItem> {
74
74
  if (!isExoInitialized) return emptyList()
75
+ // May be -1 (no anchor while a temp plays); the arithmetic below handles it.
75
76
  val currentIndex = currentTrackIndex
76
- if (currentIndex < 0) return emptyList()
77
77
 
78
78
  val queue = ArrayList<TrackItem>(currentTracks.size + playNextStack.size + upNextQueue.size)
79
79
 
@@ -46,6 +46,20 @@ internal fun TrackPlayerCore.castableUpcoming(tracks: List<TrackItem>): List<Tra
46
46
  */
47
47
  internal const val QUEUE_WINDOW_SIZE = 4
48
48
 
49
+ /** Index of [trackId] in currentTracks; a repeated track resolves to its first occurrence at or after [from], wrapping. */
50
+ internal fun TrackPlayerCore.currentTrackIndexOf(
51
+ trackId: String,
52
+ from: Int,
53
+ ): Int {
54
+ val size = currentTracks.size
55
+ val start = if (from in 0 until size) from else 0
56
+ for (k in 0 until size) {
57
+ val i = (start + k) % size
58
+ if (currentTracks[i].id == trackId) return i
59
+ }
60
+ return -1
61
+ }
62
+
49
63
  internal fun TrackPlayerCore.mediaIdFor(track: TrackItem): String {
50
64
  val playlistId = currentPlaylistId ?: ""
51
65
  return if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
@@ -88,6 +102,7 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
88
102
  currentTrackIndex = index
89
103
  exo.setMediaItems(mediaItems, true)
90
104
  exo.prepare()
105
+ applyBackendRepeatMode()
91
106
  }
92
107
 
93
108
  // ── Surgical rebuild (preserve current item) ──────────────────────────────
@@ -105,15 +120,19 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
105
120
  currentTracks.none { it.id == currentTrackId } &&
106
121
  currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
107
122
  ) {
108
- if (currentTracks.isEmpty()) return
109
- playFromIndexInternal(minOf(currentTrackIndex, currentTracks.size - 1))
110
- return
123
+ if (currentTracks.isNotEmpty()) {
124
+ // currentTrackIndex + 1 is the resume slot set by assignCurrentTracks.
125
+ playFromIndexInternal(minOf(currentTrackIndex + 1, currentTracks.size - 1))
126
+ return
127
+ }
128
+ // Transient play-out: the removed item must finish once, not loop.
129
+ applyBackendRepeatMode()
111
130
  }
112
131
 
113
132
  // Keep the logical playlist pointer in sync after playlist mutations.
114
133
  // Without this, getActualQueue/getState can report a stale index until the next track transition.
115
134
  if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && currentTrackId != null) {
116
- val resolvedIndex = currentTracks.indexOfFirst { it.id == currentTrackId }
135
+ val resolvedIndex = currentTrackIndexOf(currentTrackId, currentTrackIndex)
117
136
  if (resolvedIndex >= 0) {
118
137
  currentTrackIndex = resolvedIndex
119
138
  }
@@ -248,6 +267,7 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
248
267
  if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
249
268
  exo.prepare()
250
269
  }
270
+ applyBackendRepeatMode()
251
271
  }
252
272
 
253
273
  // ── MediaItem construction (member extension to access downloadManager) ────
@@ -262,6 +282,7 @@ internal fun TrackPlayerCore.makeMediaItem(
262
282
  .setTitle(track.title)
263
283
  .setArtist(track.artist)
264
284
  .setAlbumTitle(track.album)
285
+ if (track.duration > 0) metaBuilder.setDurationMs((track.duration * 1000).toLong())
265
286
 
266
287
  track.artwork?.asSecondOrNull()?.let { artworkUrl ->
267
288
  try {
@@ -0,0 +1,97 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.core
4
+
5
+ import android.content.Context
6
+ import android.net.ConnectivityManager
7
+ import android.net.Network
8
+ import android.net.NetworkCapabilities
9
+ import androidx.media3.common.PlaybackException
10
+ import androidx.media3.common.Player
11
+ import com.margelo.nitro.nitroplayer.Reason
12
+ import com.margelo.nitro.nitroplayer.TrackPlayerState
13
+
14
+ // Lost network waits for connectivity and retries the same item; bad sources skip, capped so an unplayable queue stops
15
+
16
+ internal const val MAX_CONSECUTIVE_SOURCE_ERRORS = 3
17
+
18
+ internal fun TrackPlayerCore.handlePlayerError(error: PlaybackException) {
19
+ NitroPlayerLogger.log("TrackPlayer") { "Player error: ${error.errorCodeName} - ${error.message}" }
20
+ // Still waiting for its URL: updateTracks re-prepares it, skipping would cascade through the window
21
+ if (getCurrentTrack()?.url?.isEmpty() == true) {
22
+ checkUpcomingTracksForUrls(lookaheadCount, force = true)
23
+ return
24
+ }
25
+ notifyPlaybackStateChange(TrackPlayerState.STOPPED, Reason.ERROR)
26
+ if (error.isConnectivityError() && !isNetworkValidated()) {
27
+ awaitNetworkThenRetry()
28
+ return
29
+ }
30
+ consecutiveSourceErrors++
31
+ if (consecutiveSourceErrors >= MAX_CONSECUTIVE_SOURCE_ERRORS) return
32
+ // A source error leaves ExoPlayer in IDLE; skip the dead item and re-prepare so the rest of the queue keeps playing
33
+ if (!exo.hasNextMediaItem()) rebuildQueueFromCurrentPosition()
34
+ if (exo.hasNextMediaItem()) {
35
+ exo.seekToNext()
36
+ exo.prepare()
37
+ }
38
+ }
39
+
40
+ /** Called when playback actually starts, so earlier failures stop counting against the queue. */
41
+ internal fun TrackPlayerCore.onPlaybackHealthy() {
42
+ consecutiveSourceErrors = 0
43
+ }
44
+
45
+ private fun PlaybackException.isConnectivityError(): Boolean =
46
+ errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
47
+ errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT
48
+
49
+ private fun TrackPlayerCore.connectivity(): ConnectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
50
+
51
+ internal fun TrackPlayerCore.isNetworkValidated(): Boolean {
52
+ val manager = connectivity()
53
+ val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
54
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
55
+ }
56
+
57
+ /** Retries once the network is back; [afterLoss] first waits for it to drop, for work started while it was up. */
58
+ internal fun TrackPlayerCore.awaitNetworkThenRetry(afterLoss: Boolean = false) {
59
+ if (networkRetryCallback != null) return
60
+ val callback =
61
+ object : ConnectivityManager.NetworkCallback() {
62
+ private var lost = !afterLoss
63
+ private var seen: Network? = null
64
+
65
+ override fun onLost(network: Network) {
66
+ lost = true
67
+ }
68
+
69
+ override fun onCapabilitiesChanged(
70
+ network: Network,
71
+ capabilities: NetworkCapabilities,
72
+ ) {
73
+ // Switching networks (Wi-Fi to mobile) breaks requests in flight just like a drop
74
+ if (seen != null && seen != network) lost = true
75
+ seen = network
76
+ if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
77
+ lost = true
78
+ } else if (lost) {
79
+ enqueue { retryAfterNetwork(this) }
80
+ }
81
+ }
82
+ }
83
+ networkRetryCallback = callback
84
+ connectivity().registerDefaultNetworkCallback(callback)
85
+ }
86
+
87
+ private fun TrackPlayerCore.retryAfterNetwork(callback: ConnectivityManager.NetworkCallback) {
88
+ if (networkRetryCallback !== callback) return
89
+ networkRetryCallback = null
90
+ try {
91
+ connectivity().unregisterNetworkCallback(callback)
92
+ } catch (_: IllegalArgumentException) {
93
+ }
94
+ // URLs the app could not fetch offline are asked for again; a failed item is retried only if nothing replaced it
95
+ checkUpcomingTracksForUrls(lookaheadCount, force = true)
96
+ if (exo.playbackState == Player.STATE_IDLE && exo.player.playerError != null) exo.prepare()
97
+ }
@@ -2,7 +2,6 @@ package com.margelo.nitro.nitroplayer.core
2
2
 
3
3
  import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
4
4
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
5
- import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
6
5
  import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
7
6
 
8
7
  /**
@@ -19,10 +18,6 @@ internal fun TrackPlayerCore.initFromService(binder: NitroPlayerPlaybackService.
19
18
  setTrackPlayerCore(this@initFromService)
20
19
  }
21
20
 
22
- // Give MediaBrowserService access to this core and media session
23
- NitroPlayerMediaBrowserService.trackPlayerCore = this
24
- NitroPlayerMediaBrowserService.mediaSessionManager = mediaSessionManager
25
-
26
21
  // Attach player listener
27
22
  val listener = TrackPlayerEventListener(this)
28
23
  playerListener = listener
@@ -2,6 +2,7 @@
2
2
 
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
+ import android.os.SystemClock
5
6
  import androidx.media3.common.Player
6
7
  import com.margelo.nitro.nitroplayer.TrackItem
7
8
 
@@ -24,7 +25,9 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
24
25
  val safeTracks =
25
26
  tracks.filter { track ->
26
27
  when {
27
- track.id == currentTrackId && !currentTrackIsEmpty -> false
28
+ // Same URL is a metadata-only edit (a title or artwork refresh): it reaches
29
+ // the session without touching the media, so gapless is unaffected.
30
+ track.id == currentTrackId && !currentTrackIsEmpty -> track.url == currentTrack?.url
28
31
 
29
32
  // preserve gapless
30
33
  track.id == currentTrackId && currentTrackIsEmpty -> track.url.isNotEmpty()
@@ -35,12 +38,15 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
35
38
  }
36
39
  }
37
40
  if (safeTracks.isEmpty()) return@run
41
+ safeTracks.forEach { urlRequestedAt.remove(it.id) }
38
42
 
39
43
  val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
40
44
 
41
45
  // Replace the current MediaItem when its URL just resolved (local only — the cast branch below reloads instead).
42
46
  val currentTrackResolvedNow = currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()
43
- if (!isCastingField && currentTrackResolvedNow) {
47
+ val currentMetadataChanged =
48
+ currentTrackUpdate != null && !currentTrackIsEmpty && currentTrackUpdate.url == currentTrack?.url
49
+ if (!isCastingField && (currentTrackResolvedNow || currentMetadataChanged)) {
44
50
  val exoIndex = exo.currentMediaItemIndex
45
51
  if (exoIndex >= 0) {
46
52
  val playlistId = currentPlaylistId ?: ""
@@ -53,7 +59,7 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
53
59
  if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
54
60
  val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
55
61
  if (refreshedPlaylist != null) {
56
- currentTracks = refreshedPlaylist.tracks
62
+ assignCurrentTracks(refreshedPlaylist.tracks)
57
63
  val updatedById = currentTracks.associateBy { it.id }
58
64
  playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
59
65
  upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
@@ -129,7 +135,13 @@ suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { cu
129
135
 
130
136
  // ── URL lookahead ─────────────────────────────────────────────────────────
131
137
 
132
- internal fun TrackPlayerCore.checkUpcomingTracksForUrls(lookahead: Int = 5) {
138
+ // One pick fires several queue events within a few hundred ms; the app is asked once for that burst
139
+ private const val URL_REQUEST_COALESCE_MS = 500L
140
+
141
+ internal fun TrackPlayerCore.checkUpcomingTracksForUrls(
142
+ lookahead: Int = 5,
143
+ force: Boolean = false,
144
+ ) {
133
145
  val upcomingTracks =
134
146
  if (currentTrackIndex < 0) {
135
147
  currentTracks.take(lookahead)
@@ -140,5 +152,10 @@ internal fun TrackPlayerCore.checkUpcomingTracksForUrls(lookahead: Int = 5) {
140
152
  val currentNeedsUrl = currentTrack != null && currentTrack.url.isEmpty()
141
153
  val candidates = if (currentNeedsUrl) listOf(currentTrack!!) + upcomingTracks else upcomingTracks
142
154
  val needUrls = candidates.filter { it.url.isEmpty() }
143
- if (needUrls.isNotEmpty()) notifyTracksNeedUpdate(needUrls, lookahead)
155
+ val now = SystemClock.elapsedRealtime()
156
+ val ask = if (force) needUrls else needUrls.filter { track -> urlRequestedAt[track.id]?.let { now - it < URL_REQUEST_COALESCE_MS } != true }
157
+ if (ask.isEmpty()) return
158
+ if (urlRequestedAt.size > 1000) urlRequestedAt.clear()
159
+ ask.forEach { urlRequestedAt[it.id] = now }
160
+ notifyTracksNeedUpdate(ask, lookahead)
144
161
  }
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.nitroplayer.download
2
2
 
3
3
  import android.content.Context
4
+ import com.margelo.nitro.JNIOnLoad
4
5
  import com.margelo.nitro.core.AnyMap
5
6
  import com.margelo.nitro.core.NullType
6
7
  import com.margelo.nitro.nitroplayer.*
@@ -56,6 +57,8 @@ class DownloadDatabase private constructor(
56
57
  private val ioScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1) + SupervisorJob())
57
58
 
58
59
  init {
60
+ // Persisted extraPayloads are AnyMaps; a process started without JS has not loaded Nitro's JNI yet
61
+ JNIOnLoad.initializeNativeNitro()
59
62
  loadFromDisk()
60
63
  }
61
64
 
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.nitroplayer.download
2
2
 
3
+ import com.margelo.nitro.JNIOnLoad
3
4
  import com.margelo.nitro.core.AnyMap
4
5
  import com.margelo.nitro.nitroplayer.TrackItem
5
6
  import com.margelo.nitro.nitroplayer.Variant_NullType_String
@@ -8,6 +9,11 @@ import org.json.JSONObject
8
9
  // Survives process death inside the worker's inputData so a completed
9
10
  // download can be recorded even when the in-memory metadata maps are gone.
10
11
  internal object TrackItemJson {
12
+ // extraPayload is an AnyMap; downloads can run in a process JS never started
13
+ init {
14
+ JNIOnLoad.initializeNativeNitro()
15
+ }
16
+
11
17
  fun toJson(track: TrackItem): String =
12
18
  JSONObject()
13
19
  .apply {
@@ -0,0 +1,87 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import android.content.Context
4
+ import android.media.AudioAttributes
5
+ import android.media.AudioFocusRequest
6
+ import android.media.AudioManager
7
+ import android.os.Build
8
+
9
+ /**
10
+ * Owns audio focus for the 'duck' mode. ExoPlayer's own handling pauses on every transient
11
+ * loss, duckable or not, which is why a notification can stop playback outright.
12
+ */
13
+ class AudioFocusController(
14
+ context: Context,
15
+ private val onDuck: (Boolean) -> Unit,
16
+ private val onPause: () -> Unit,
17
+ ) {
18
+ private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
19
+ private var request: AudioFocusRequest? = null
20
+ private var ducked = false
21
+ private var held = false
22
+
23
+ private val listener =
24
+ AudioManager.OnAudioFocusChangeListener { change ->
25
+ when (change) {
26
+ // Another app took focus for good — stop and let it go.
27
+ AudioManager.AUDIOFOCUS_LOSS -> {
28
+ setDucked(false)
29
+ onPause()
30
+ abandon()
31
+ }
32
+
33
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
34
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
35
+ -> setDucked(true)
36
+
37
+ AudioManager.AUDIOFOCUS_GAIN -> setDucked(false)
38
+ }
39
+ }
40
+
41
+ private fun setDucked(next: Boolean) {
42
+ if (ducked == next) return
43
+ ducked = next
44
+ onDuck(next)
45
+ }
46
+
47
+ /** Returns false when the system refused focus, so the caller can leave playback alone. */
48
+ fun acquire(): Boolean {
49
+ if (held) return true
50
+ val attributes =
51
+ AudioAttributes
52
+ .Builder()
53
+ .setUsage(AudioAttributes.USAGE_MEDIA)
54
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
55
+ .build()
56
+ val result =
57
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
58
+ val built =
59
+ AudioFocusRequest
60
+ .Builder(AudioManager.AUDIOFOCUS_GAIN)
61
+ .setAudioAttributes(attributes)
62
+ .setWillPauseWhenDucked(false)
63
+ .setOnAudioFocusChangeListener(listener)
64
+ .build()
65
+ request = built
66
+ audioManager.requestAudioFocus(built)
67
+ } else {
68
+ @Suppress("DEPRECATION")
69
+ audioManager.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
70
+ }
71
+ held = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
72
+ return held
73
+ }
74
+
75
+ fun abandon() {
76
+ if (!held) return
77
+ held = false
78
+ setDucked(false)
79
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
80
+ request?.let { audioManager.abandonAudioFocusRequest(it) }
81
+ request = null
82
+ } else {
83
+ @Suppress("DEPRECATION")
84
+ audioManager.abandonAudioFocus(listener)
85
+ }
86
+ }
87
+ }
@@ -35,6 +35,8 @@ class CastSessionController(
35
35
  private val castContext: CastContext,
36
36
  val castPlayer: CastPlayer,
37
37
  val localPlayer: Player,
38
+ /** What the session drives locally — wraps [localPlayer] to route remote commands through the core. */
39
+ val localSessionPlayer: Player,
38
40
  val mediaSession: MediaSession,
39
41
  private val mainHandler: Handler,
40
42
  ) {
@@ -8,6 +8,7 @@ import androidx.media3.datasource.DefaultDataSource
8
8
  import androidx.media3.exoplayer.DefaultLoadControl
9
9
  import androidx.media3.exoplayer.ExoPlayer
10
10
  import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
11
+ import androidx.media3.exoplayer.source.ShuffleOrder
11
12
 
12
13
  /**
13
14
  * Builds an [ExoPlayer] with the standard NitroPlayer configuration.
@@ -59,5 +60,7 @@ object ExoPlayerBuilder {
59
60
  .setWakeMode(C.WAKE_MODE_NETWORK)
60
61
  .setPauseAtEndOfMediaItems(false)
61
62
  .build()
63
+ // Queue order is ours; a controller's shuffle toggle must not randomize the window.
64
+ .apply { setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) }
62
65
  }
63
66
  }