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
package/README.md CHANGED
@@ -82,6 +82,8 @@ Mutations return **`Promise<void>`** (or **`Promise<string>`** for `createPlayli
82
82
  | `addTracksToPlaylist(pid, tracks)` | Both | **Async**. Adds multiple tracks to a playlist. |
83
83
  | `removeTrackFromPlaylist(pid, tid)` | Both | **Async**. Removes a track from a playlist. |
84
84
  | `reorderTrackInPlaylist(pid, tid, idx)` | Both | **Async**. Moves a track to a new position. |
85
+ | `removeTracksFromPlaylist(pid, tids)` | Both | **Async**. Removes several tracks in one call. |
86
+ | `shufflePlaylist(pid, options?)` | Both | **Async**. Shuffles a playlist without stopping playback.|
85
87
 
86
88
  ### Platform-Specific APIs
87
89
 
@@ -940,6 +942,13 @@ PlayerQueue.removeTrackFromPlaylist(playlistId, trackId)
940
942
  // Reorder tracks
941
943
  PlayerQueue.reorderTrackInPlaylist(playlistId, trackId, newIndex)
942
944
 
945
+ // Remove several tracks in one native call
946
+ PlayerQueue.removeTracksFromPlaylist(playlistId, [trackId1, trackId2])
947
+
948
+ // Shuffle in place — the playing track keeps playing, no shuffle mode to turn off
949
+ PlayerQueue.shufflePlaylist(playlistId)
950
+ PlayerQueue.shufflePlaylist(playlistId, { keepCurrentTrackFirst: false })
951
+
943
952
  // Delete a playlist
944
953
  PlayerQueue.deletePlaylist(playlistId)
945
954
  ```
@@ -18,6 +18,14 @@
18
18
  android:name="android.hardware.audio.output"
19
19
  android:required="false" />
20
20
 
21
+ <!-- Visibility of Android Auto's connection-state provider; without it the car never reads as connected -->
22
+ <queries>
23
+ <provider
24
+ android:name="androidx.car.app.connection.provider"
25
+ android:authorities="androidx.car.app.connection"
26
+ tools:ignore="MissingClass" />
27
+ </queries>
28
+
21
29
  <application>
22
30
  <!-- Google Cast: supplies CastOptions (receiver app ID) to the Cast SDK.
23
31
  Apps shipping their own OptionsProvider must override with tools:replace. -->
@@ -1,15 +1,21 @@
1
1
  package com.margelo.nitro.nitroplayer
2
2
 
3
3
  import androidx.annotation.Keep
4
+ import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.session.CommandButton
4
6
  import com.facebook.proguard.annotations.DoNotStrip
5
7
  import com.margelo.nitro.NitroModules
6
8
  import com.margelo.nitro.core.Promise
7
9
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
8
10
  import com.margelo.nitro.nitroplayer.core.clearAndroidAutoMediaLibrary
9
11
  import com.margelo.nitro.nitroplayer.core.setAndroidAutoMediaLibrary
12
+ import com.margelo.nitro.nitroplayer.media.MediaSessionManager
13
+ import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
14
+ import org.json.JSONArray
10
15
 
11
16
  @DoNotStrip
12
17
  @Keep
18
+ @UnstableApi
13
19
  class HybridAndroidAutoMediaLibrary : HybridAndroidAutoMediaLibrarySpec() {
14
20
  private val core: TrackPlayerCore
15
21
 
@@ -23,4 +29,49 @@ class HybridAndroidAutoMediaLibrary : HybridAndroidAutoMediaLibrarySpec() {
23
29
  override fun setMediaLibrary(libraryJson: String): Promise<Unit> = Promise.async { core.setAndroidAutoMediaLibrary(libraryJson) }
24
30
 
25
31
  override fun clearMediaLibrary(): Promise<Unit> = Promise.async { core.clearAndroidAutoMediaLibrary() }
32
+
33
+ override fun onLoadChildren(callback: (requestId: String, parentId: String) -> Unit) {
34
+ core.mediaLibraryManager.childrenRequester = callback
35
+ }
36
+
37
+ override fun onSearch(callback: (requestId: String, query: String) -> Unit) {
38
+ core.mediaLibraryManager.searchRequester = callback
39
+ NitroPlayerMediaBrowserService.setSearchSupported(core.context, true)
40
+ }
41
+
42
+ override fun resolveRequest(
43
+ requestId: String,
44
+ itemsJson: String,
45
+ ) = core.mediaLibraryManager.resolve(requestId, itemsJson)
46
+
47
+ override fun setSessionButtons(buttonsJson: String) {
48
+ val array = JSONArray(buttonsJson)
49
+ val buttons =
50
+ (0 until array.length()).mapNotNull { i ->
51
+ val button = array.getJSONObject(i)
52
+ val icon = ICONS[button.optString("icon")] ?: return@mapNotNull null
53
+ MediaSessionManager.SessionButton(button.getString("action"), button.optString("title"), icon)
54
+ }
55
+ core.enqueue { core.mediaSessionManager?.setSessionButtons(buttons) }
56
+ }
57
+
58
+ override fun onSessionButtonPress(callback: (action: String) -> Unit) {
59
+ core.enqueue { core.mediaSessionManager?.onSessionButtonPress = callback }
60
+ }
61
+
62
+ override fun onPlaybackResumption(callback: () -> Unit) = core.mediaLibraryManager.setResumptionRequester(callback)
63
+
64
+ private companion object {
65
+ val ICONS =
66
+ mapOf(
67
+ "heart" to CommandButton.ICON_HEART_UNFILLED,
68
+ "heart_filled" to CommandButton.ICON_HEART_FILLED,
69
+ "star" to CommandButton.ICON_STAR_UNFILLED,
70
+ "star_filled" to CommandButton.ICON_STAR_FILLED,
71
+ "thumb_up" to CommandButton.ICON_THUMB_UP_UNFILLED,
72
+ "thumb_up_filled" to CommandButton.ICON_THUMB_UP_FILLED,
73
+ "plus" to CommandButton.ICON_PLUS,
74
+ "check" to CommandButton.ICON_CHECK_CIRCLE_FILLED,
75
+ )
76
+ }
26
77
  }
@@ -94,16 +94,11 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
94
94
  core.updatePlaylist(playlistId)
95
95
  }
96
96
 
97
+ /** Same semantics as the batch form: unknown ids are skipped, only a missing playlist rejects. */
97
98
  override fun removeTrackFromPlaylist(
98
99
  playlistId: String,
99
100
  trackId: String,
100
- ): Promise<Unit> =
101
- Promise.async {
102
- if (!playlistManager.removeTrackFromPlaylist(playlistId, trackId)) {
103
- throw IllegalArgumentException("Track $trackId not found in playlist $playlistId")
104
- }
105
- core.updatePlaylist(playlistId)
106
- }
101
+ ): Promise<Unit> = removeTracksFromPlaylist(playlistId, arrayOf(trackId))
107
102
 
108
103
  override fun reorderTrackInPlaylist(
109
104
  playlistId: String,
@@ -117,6 +112,49 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
117
112
  core.updatePlaylist(playlistId)
118
113
  }
119
114
 
115
+ /** On the player looper: the live queue must reflect the removal before the promise resolves. */
116
+ override fun removeTracksFromPlaylist(
117
+ playlistId: String,
118
+ trackIds: Array<String>,
119
+ ): Promise<Unit> {
120
+ val promise = Promise<Unit>()
121
+ core.enqueue {
122
+ val removed = playlistManager.removeTracksFromPlaylist(playlistId, trackIds.toList())
123
+ if (removed == null) {
124
+ promise.reject(IllegalArgumentException("Playlist not found: $playlistId"))
125
+ return@enqueue
126
+ }
127
+ if (removed > 0 && core.getCurrentPlaylistId() == playlistId) core.syncCurrentPlaylistOnQueue()
128
+ promise.resolve(Unit)
129
+ }
130
+ return promise
131
+ }
132
+
133
+ /** On the player looper: the anchor id is player-thread state. */
134
+ override fun shufflePlaylist(
135
+ playlistId: String,
136
+ options: ShufflePlaylistOptions?,
137
+ ): Promise<Unit> {
138
+ val promise = Promise<Unit>()
139
+ core.enqueue {
140
+ val isCurrent = core.getCurrentPlaylistId() == playlistId
141
+ val pin =
142
+ if (isCurrent && options?.keepCurrentTrackFirst != false) {
143
+ core.currentTracks.getOrNull(core.currentTrackIndex)?.id
144
+ } else {
145
+ null
146
+ }
147
+ val changed = playlistManager.shufflePlaylist(playlistId, pin)
148
+ if (changed == null) {
149
+ promise.reject(IllegalArgumentException("Playlist not found: $playlistId"))
150
+ return@enqueue
151
+ }
152
+ if (changed && isCurrent) core.syncCurrentPlaylistOnQueue()
153
+ promise.resolve(Unit)
154
+ }
155
+ return promise
156
+ }
157
+
120
158
  // ── Playback control ──────────────────────────────────────────────────────
121
159
 
122
160
  /**
@@ -59,9 +59,9 @@ class AndroidAutoConnectionDetector(
59
59
  try {
60
60
  val filter = IntentFilter(ACTION_CAR_CONNECTION_UPDATED)
61
61
 
62
- // For Android 14+ (API 34+), we need to specify the receiver flags
62
+ // Exported: the broadcast comes from the Android Auto app, a NOT_EXPORTED receiver never gets it
63
63
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
64
- context.registerReceiver(carConnectionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
64
+ context.registerReceiver(carConnectionReceiver, filter, Context.RECEIVER_EXPORTED)
65
65
  } else {
66
66
  context.registerReceiver(carConnectionReceiver, filter)
67
67
  }
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.nitroplayer.core
2
2
 
3
+ import android.net.Uri
3
4
  import androidx.media3.common.MediaItem
4
5
  import androidx.media3.common.Player
5
6
 
@@ -42,7 +43,18 @@ class ExoPlayerCore(
42
43
  fun getPlaybackSpeed(): Float = player.playbackParameters.speed
43
44
 
44
45
  // ── Queue mutations ────────────────────────────────────────────────────
45
- fun prepare() = player.prepare()
46
+ // A lazy track's empty URI fails as "Source error"; stay idle until updateTracks supplies it
47
+ fun prepare() {
48
+ if (isLazyCurrent()) return
49
+ player.prepare()
50
+ }
51
+
52
+ // A prepared player loads a new current item by itself, so idle it while that item still has no URI
53
+ fun holdLazyCurrent() {
54
+ if (isLazyCurrent() && player.playbackState != Player.STATE_IDLE) player.stop()
55
+ }
56
+
57
+ private fun isLazyCurrent(): Boolean = player.currentMediaItem?.localConfiguration?.uri == Uri.EMPTY
46
58
 
47
59
  fun seekToDefaultPosition(windowIndex: Int) = player.seekToDefaultPosition(windowIndex)
48
60
 
@@ -51,7 +63,10 @@ class ExoPlayerCore(
51
63
  fun setMediaItems(
52
64
  items: List<MediaItem>,
53
65
  resetPosition: Boolean = false,
54
- ) = player.setMediaItems(items, resetPosition)
66
+ ) {
67
+ player.setMediaItems(items, resetPosition)
68
+ holdLazyCurrent()
69
+ }
55
70
 
56
71
  fun addMediaItems(items: List<MediaItem>) = player.addMediaItems(items)
57
72
 
@@ -4,8 +4,8 @@ import android.util.Log
4
4
  import com.margelo.nitro.nitroplayer.BuildConfig
5
5
 
6
6
  object NitroPlayerLogger {
7
- // Determine if logging is enabled based on build config
8
- val isEnabled: Boolean = BuildConfig.DEBUG
7
+ // Release builds log after `adb shell setprop log.tag.NitroPlayer DEBUG` and an app restart
8
+ val isEnabled: Boolean = BuildConfig.DEBUG || Log.isLoggable("NitroPlayer", Log.DEBUG)
9
9
 
10
10
  /**
11
11
  * Preferred overload: message lambda is only evaluated when logging is enabled.
@@ -3,7 +3,9 @@
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
5
  import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
6
+ import com.margelo.nitro.nitroplayer.media.MediaItem
6
7
  import com.margelo.nitro.nitroplayer.media.MediaLibraryParser
8
+ import com.margelo.nitro.nitroplayer.media.MediaType
7
9
  import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
8
10
 
9
11
  /**
@@ -13,56 +15,64 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
13
15
 
14
16
  /** Called on the main thread from TrackPlayerCore.init via handler.post. */
15
17
  internal fun TrackPlayerCore.setupAndroidAutoDetector() {
18
+ // The car may have bound the browser before this core existed (cold start from the car)
19
+ if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) setAndroidAutoConnected(true)
16
20
  androidAutoConnectionDetector =
17
21
  AndroidAutoConnectionDetector(context).apply {
18
22
  onConnectionChanged = { connected, _ ->
19
- handler.post {
20
- isAndroidAutoConnectedField = connected
21
- NitroPlayerMediaBrowserService.isAndroidAutoConnected = connected
22
- notifyAndroidAutoConnection(connected)
23
- }
23
+ handler.post { setAndroidAutoConnected(connected) }
24
24
  }
25
25
  registerCarConnectionReceiver()
26
26
  }
27
27
  }
28
28
 
29
- /** Called by MediaBrowserService when the user picks a track in Android Auto. */
30
- suspend fun TrackPlayerCore.playFromPlaylistTrack(mediaId: String) =
31
- withPlayerContext {
32
- try {
33
- val colonIndex = mediaId.indexOf(':')
34
- if (colonIndex <= 0 || colonIndex >= mediaId.length - 1) return@withPlayerContext
35
- val playlistId = mediaId.substring(0, colonIndex)
36
- val trackId = mediaId.substring(colonIndex + 1)
37
- val playlist = playlistManager.getPlaylist(playlistId) ?: return@withPlayerContext
38
- val trackIndex = playlist.tracks.indexOfFirst { it.id == trackId }
39
- if (trackIndex < 0) return@withPlayerContext
40
- if (currentPlaylistId != playlistId) {
41
- loadPlaylistInternal(playlistId)
42
- }
43
- playFromIndexInternal(trackIndex)
44
- } catch (_: Exception) {
29
+ /** Android Auto binding the browser is proof of a connection, whatever the provider query said. */
30
+ internal fun TrackPlayerCore.onAndroidAutoBrowserConnected() {
31
+ handler.post { setAndroidAutoConnected(true) }
32
+ }
33
+
34
+ private fun TrackPlayerCore.setAndroidAutoConnected(connected: Boolean) {
35
+ NitroPlayerMediaBrowserService.isAndroidAutoConnected = connected
36
+ if (isAndroidAutoConnectedField == connected) return
37
+ isAndroidAutoConnectedField = connected
38
+ updateJsKeepAlive()
39
+ notifyAndroidAutoConnection(connected)
40
+ }
41
+
42
+ /** Routes a controller's "playlistId:trackId" pick through the logical queue (window + lazy URLs); false for unknown ids. */
43
+ internal fun TrackPlayerCore.playFromMediaId(mediaId: String): Boolean {
44
+ val colonIndex = mediaId.indexOf(':')
45
+ if (colonIndex <= 0) return false
46
+ val playlistId = mediaId.substring(0, colonIndex)
47
+ val trackRef = mediaId.substring(colonIndex + 1)
48
+ if (playlistManager.indexOfTrackRef(playlistId, trackRef) < 0) return false
49
+ startJsRuntimeIfNeeded()
50
+ enqueue {
51
+ val index = playlistManager.indexOfTrackRef(playlistId, trackRef)
52
+ // Resuming what is already loaded keeps its position; a failed item is reloaded instead
53
+ if (currentPlaylistId == playlistId && currentTrackIndex == index && exo.player.playerError == null) {
54
+ // Tapping a song still waiting for its URL asks the app for it again
55
+ if (getCurrentTrack()?.url.isNullOrEmpty()) checkUpcomingTracksForUrls(lookaheadCount, force = true)
56
+ return@enqueue
45
57
  }
58
+ if (index >= 0 && playlistManager.loadPlaylist(playlistId, index)) loadPlaylistOnQueue(playlistId, index)
46
59
  }
47
-
48
- private fun TrackPlayerCore.loadPlaylistInternal(playlistId: String) {
49
- playNextStack.clear()
50
- upNextQueue.clear()
51
- currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
52
- val playlist = playlistManager.getPlaylist(playlistId) ?: return
53
- currentPlaylistId = playlistId
54
- updatePlayerQueue(playlist.tracks)
60
+ return true
55
61
  }
56
62
 
63
+ private fun folderIds(items: List<MediaItem>?): List<String> = items.orEmpty().filter { it.mediaType == MediaType.FOLDER }.flatMap { listOf(it.id) + folderIds(it.children) }
64
+
57
65
  suspend fun TrackPlayerCore.setAndroidAutoMediaLibrary(libraryJson: String) =
58
66
  withPlayerContext {
59
67
  val library = MediaLibraryParser.fromJson(libraryJson)
68
+ val previous = folderIds(mediaLibraryManager.getMediaLibrary()?.rootItems)
60
69
  mediaLibraryManager.setMediaLibrary(library)
61
- NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
70
+ NitroPlayerMediaBrowserService.getInstance()?.onLibraryUpdated(previous + folderIds(library.rootItems) + mediaLibraryManager.loadedFolderIds)
62
71
  }
63
72
 
64
73
  suspend fun TrackPlayerCore.clearAndroidAutoMediaLibrary() =
65
74
  withPlayerContext {
75
+ val previous = folderIds(mediaLibraryManager.getMediaLibrary()?.rootItems)
66
76
  mediaLibraryManager.clear()
67
- NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
77
+ NitroPlayerMediaBrowserService.getInstance()?.onLibraryUpdated(previous + mediaLibraryManager.loadedFolderIds)
68
78
  }
@@ -0,0 +1,65 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.core
4
+
5
+ import androidx.annotation.OptIn
6
+ import androidx.media3.common.AudioAttributes
7
+ import androidx.media3.common.C
8
+ import androidx.media3.common.util.UnstableApi
9
+ import androidx.media3.exoplayer.ExoPlayer
10
+ import com.margelo.nitro.nitroplayer.AndroidAudioFocusMode
11
+ import com.margelo.nitro.nitroplayer.media.AudioFocusController
12
+
13
+ /**
14
+ * Audio focus policy. ExoPlayer's built-in handling pauses on every transient loss — even the
15
+ * duckable kind a notification asks for — so 'duck' and 'ignore' take focus into our own hands.
16
+ */
17
+
18
+ internal const val DUCK_VOLUME_FACTOR = 0.2f
19
+
20
+ @OptIn(UnstableApi::class)
21
+ internal fun TrackPlayerCore.applyAudioFocusMode(mode: AndroidAudioFocusMode) {
22
+ if (mode == audioFocusMode && (mode == AndroidAudioFocusMode.PAUSE) == (audioFocusController == null)) return
23
+ audioFocusMode = mode
24
+
25
+ audioFocusController?.abandon()
26
+ audioFocusController = null
27
+ isDucked = false
28
+
29
+ val local = exoOrNull() ?: return
30
+ // media3 only knows how to pause; it owns focus for the default mode and nothing else.
31
+ local.setAudioAttributes(mediaAudioAttributes(), mode == AndroidAudioFocusMode.PAUSE)
32
+ local.volume = userVolume
33
+
34
+ if (mode == AndroidAudioFocusMode.DUCK) {
35
+ audioFocusController =
36
+ AudioFocusController(
37
+ context,
38
+ onDuck = { ducked -> applyDuck(ducked) },
39
+ onPause = { playerHandler.post { pauseOnQueue() } },
40
+ )
41
+ if (isExoInitialized && exo.player.isPlaying) audioFocusController?.acquire()
42
+ }
43
+ }
44
+
45
+ @OptIn(UnstableApi::class)
46
+ internal fun TrackPlayerCore.applyDuck(ducked: Boolean) {
47
+ playerHandler.post {
48
+ isDucked = ducked
49
+ exoOrNull()?.volume = userVolume * if (ducked) DUCK_VOLUME_FACTOR else 1f
50
+ }
51
+ }
52
+
53
+ private fun mediaAudioAttributes(): AudioAttributes =
54
+ AudioAttributes
55
+ .Builder()
56
+ .setUsage(C.USAGE_MEDIA)
57
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
58
+ .build()
59
+
60
+ /** The local ExoPlayer, or null while casting (the CastPlayer has no audio focus of its own). */
61
+ @OptIn(UnstableApi::class)
62
+ internal fun TrackPlayerCore.exoOrNull(): ExoPlayer? {
63
+ if (!isExoInitialized) return null
64
+ return exo.player as? ExoPlayer
65
+ }
@@ -88,7 +88,7 @@ internal fun TrackPlayerCore.switchToLocalPlayer() {
88
88
  lastCastWaitTrackId = null
89
89
  exo = ExoPlayerCore(controller.localPlayer)
90
90
  playerListener?.let { controller.localPlayer.addListener(it) }
91
- setMediaSessionPlayer(controller.localPlayer)
91
+ setMediaSessionPlayer(controller.localSessionPlayer)
92
92
 
93
93
  val localList = ArrayList<TrackItem>()
94
94
  currentTrack?.let { localList.add(it) }
@@ -12,6 +12,11 @@ import android.os.Bundle
12
12
  import android.os.Handler
13
13
  import android.os.IBinder
14
14
  import android.os.Looper
15
+ import com.facebook.react.ReactApplication
16
+ import com.facebook.react.bridge.Arguments
17
+ import com.facebook.react.bridge.ReactContext
18
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig
19
+ import com.facebook.react.jstasks.HeadlessJsTaskContext
15
20
  import com.margelo.nitro.NitroModules
16
21
  import com.margelo.nitro.nitroplayer.Reason
17
22
  import com.margelo.nitro.nitroplayer.RepeatMode
@@ -22,6 +27,8 @@ import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
22
27
  import com.margelo.nitro.nitroplayer.download.DownloadManagerCore
23
28
  import com.margelo.nitro.nitroplayer.media.ExoPlayerBuilder
24
29
  import com.margelo.nitro.nitroplayer.media.MediaLibraryManager
30
+ import com.margelo.nitro.nitroplayer.AndroidAudioFocusMode
31
+ import com.margelo.nitro.nitroplayer.media.AudioFocusController
25
32
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
26
33
  import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
27
34
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
@@ -71,9 +78,19 @@ class TrackPlayerCore private constructor(
71
78
  @Volatile internal var currentRepeatMode: RepeatMode = RepeatMode.OFF
72
79
  internal var lookaheadCount: Int = 5
73
80
 
81
+ internal var audioFocusMode: AndroidAudioFocusMode = AndroidAudioFocusMode.PAUSE
82
+ internal var audioFocusController: AudioFocusController? = null
83
+
84
+ /** Volume the app asked for; ducking scales this instead of overwriting it. */
85
+ internal var userVolume: Float = 1f
86
+ internal var isDucked: Boolean = false
87
+
74
88
  internal var remoteSkipForwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
75
89
  internal var remoteSkipBackwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
76
90
  internal var playerListener: androidx.media3.common.Player.Listener? = null
91
+ internal var consecutiveSourceErrors = 0
92
+ internal val urlRequestedAt = HashMap<String, Long>()
93
+ internal var networkRetryCallback: android.net.ConnectivityManager.NetworkCallback? = null
77
94
 
78
95
  // ── Temporary queue ────────────────────────────────────────────────────
79
96
  internal var playNextStack: MutableList<TrackItem> = mutableListOf()
@@ -144,21 +161,47 @@ class TrackPlayerCore private constructor(
144
161
  playerHandler.postDelayed(progressUpdateRunnable, PROGRESS_INTERVAL_MS)
145
162
  }
146
163
 
147
- internal val updateCurrentPlaylistRunnable =
148
- Runnable {
149
- val id = currentPlaylistId ?: return@Runnable
150
- val playlist = playlistManager.getPlaylist(id) ?: return@Runnable
151
- currentTracks = playlist.tracks
152
- if (::exo.isInitialized &&
153
- exo.currentMediaItem != null &&
154
- exo.currentMediaItemIndex >= 0
155
- ) {
156
- rebuildQueueFromCurrentPosition()
157
- } else {
158
- updatePlayerQueue(playlist.tracks)
159
- }
160
- checkUpcomingTracksForUrls(lookaheadCount)
164
+ internal enum class AnchorOutcome { NONE, KEPT, REMOVED }
165
+
166
+ /** Re-resolves the anchor by id, or moves the cursor to the resume slot when it was removed. */
167
+ internal fun assignCurrentTracks(next: List<TrackItem>): AnchorOutcome {
168
+ val old = currentTracks
169
+ val anchor = old.getOrNull(currentTrackIndex)
170
+ currentTracks = next
171
+ if (anchor == null) return AnchorOutcome.NONE
172
+ // A repeated track keeps its rank among the occurrences, so editing elsewhere does not jump to another copy
173
+ val rank = old.subList(0, currentTrackIndex).count { it.id == anchor.id }
174
+ val occurrences = next.indices.filter { next[it].id == anchor.id }
175
+ val newIndex = occurrences.getOrElse(rank) { occurrences.lastOrNull() ?: -1 }
176
+ if (newIndex >= 0) {
177
+ currentTrackIndex = newIndex
178
+ return AnchorOutcome.KEPT
161
179
  }
180
+ val alive = next.mapTo(HashSet()) { it.id }
181
+ val followerId = old.drop(currentTrackIndex + 1).firstOrNull { it.id in alive }?.id
182
+ val resume = followerId?.let { id -> next.indexOfFirst { it.id == id } } ?: next.size
183
+ currentTrackIndex = resume - 1
184
+ return AnchorOutcome.REMOVED
185
+ }
186
+
187
+ /** Applies the stored playlist to the live queue without re-preparing the playing item. */
188
+ internal fun syncCurrentPlaylistOnQueue() {
189
+ playerHandler.removeCallbacks(updateCurrentPlaylistRunnable)
190
+ val id = currentPlaylistId ?: return
191
+ val playlist = playlistManager.getPlaylist(id) ?: return
192
+ assignCurrentTracks(playlist.tracks)
193
+ if (::exo.isInitialized &&
194
+ exo.currentMediaItem != null &&
195
+ exo.currentMediaItemIndex >= 0
196
+ ) {
197
+ rebuildQueueFromCurrentPosition()
198
+ } else {
199
+ updatePlayerQueue(playlist.tracks)
200
+ }
201
+ checkUpcomingTracksForUrls(lookaheadCount)
202
+ }
203
+
204
+ internal val updateCurrentPlaylistRunnable = Runnable { syncCurrentPlaylistOnQueue() }
162
205
 
163
206
  // ── Service binding ────────────────────────────────────────────────────
164
207
  private var serviceBound = false
@@ -219,6 +262,7 @@ class TrackPlayerCore private constructor(
219
262
  // ── Singleton ──────────────────────────────────────────────────────────
220
263
  companion object {
221
264
  internal const val PROGRESS_INTERVAL_MS = 1000L
265
+ private const val KEEP_ALIVE_TASK = "NitroPlayerKeepAlive"
222
266
 
223
267
  @Volatile
224
268
  @Suppress("ktlint:standard:property-naming")
@@ -361,6 +405,34 @@ class TrackPlayerCore private constructor(
361
405
  }
362
406
  }
363
407
 
408
+ /** Lazy URLs come from JS; a process started by the car or a controller has no runtime until started. */
409
+ internal fun startJsRuntimeIfNeeded() {
410
+ val host = (context as? ReactApplication)?.reactHost ?: return
411
+ if (host.currentReactContext == null) host.start()
412
+ }
413
+
414
+ private var keepAliveTask: Pair<ReactContext, Int>? = null
415
+
416
+ /** Without a resumed activity (car, screen off) RN pauses JS timers; a headless task keeps them running while JS may be needed. */
417
+ internal fun updateJsKeepAlive() {
418
+ val wanted = isAndroidAutoConnectedField || (isExoInitialized && exo.playWhenReady)
419
+ val reactContext = (context as? ReactApplication)?.reactHost?.currentReactContext
420
+ keepAliveTask?.let { (taskContext, taskId) ->
421
+ val tasks = HeadlessJsTaskContext.getInstance(taskContext)
422
+ if (wanted && taskContext === reactContext && tasks.isTaskRunning(taskId)) return
423
+ if (tasks.isTaskRunning(taskId)) tasks.finishTask(taskId)
424
+ keepAliveTask = null
425
+ }
426
+ if (!wanted || reactContext == null || !reactContext.hasActiveReactInstance()) return
427
+ try {
428
+ val config = HeadlessJsTaskConfig(KEEP_ALIVE_TASK, Arguments.createMap(), 0, true)
429
+ keepAliveTask = reactContext to HeadlessJsTaskContext.getInstance(reactContext).startTask(config)
430
+ NitroPlayerLogger.log("TrackPlayerCore") { "keep-alive task started: ${keepAliveTask?.second}" }
431
+ } catch (e: Exception) {
432
+ NitroPlayerLogger.log("TrackPlayerCore") { "keep-alive task not started: ${e.message}" }
433
+ }
434
+ }
435
+
364
436
  // ── Simple read-only accessors ─────────────────────────────────────────
365
437
 
366
438
  fun isAndroidAutoConnected(): Boolean = isAndroidAutoConnectedField
@@ -389,7 +461,12 @@ class TrackPlayerCore private constructor(
389
461
 
390
462
  fun removeOnPlaybackProgressChangeListener(id: Long): Boolean = onProgressListeners.remove(id)
391
463
 
392
- fun addOnTracksNeedUpdateListener(cb: (List<TrackItem>, Int) -> Unit): Long = onTracksNeedUpdateListeners.add(cb)
464
+ fun addOnTracksNeedUpdateListener(cb: (List<TrackItem>, Int) -> Unit): Long {
465
+ val id = onTracksNeedUpdateListeners.add(cb)
466
+ // Requests raised before JS was listening (headless start) are otherwise lost
467
+ enqueue { checkUpcomingTracksForUrls(lookaheadCount, force = true) }
468
+ return id
469
+ }
393
470
 
394
471
  fun removeOnTracksNeedUpdateListener(id: Long): Boolean = onTracksNeedUpdateListeners.remove(id)
395
472
 
@@ -31,6 +31,7 @@ internal class TrackPlayerEventListener(
31
31
  with(core) {
32
32
  // TRACK repeat: REPEAT_MODE_ONE fires this every loop — not a real track change
33
33
  if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) return
34
+ exo.holdLazyCurrent()
34
35
 
35
36
  // Remove the track that just finished/was skipped from temp lists
36
37
  if ((
@@ -61,9 +62,13 @@ internal class TrackPlayerEventListener(
61
62
  val previousTrackIndex = currentTrackIndex
62
63
  if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && mediaItem != null) {
63
64
  val trackId = extractTrackId(mediaItem.mediaId)
64
- val newIdx = currentTracks.indexOfFirst { it.id == trackId }
65
+ // Moving through the timeline lands on the next occurrence of a repeated track, not its first
66
+ val advancing = reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
67
+ val newIdx = currentTrackIndexOf(trackId, if (advancing) currentTrackIndex + 1 else currentTrackIndex)
65
68
  if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
66
69
  }
70
+ // A transient play-out ends here: restore the user's repeat mode.
71
+ core.applyBackendRepeatMode()
67
72
 
68
73
  val wrappedToStart = currentRepeatMode == RepeatMode.PLAYLIST &&
69
74
  currentTrackIndex == 0 && previousTrackIndex == currentTracks.size - 1
@@ -135,6 +140,7 @@ internal class TrackPlayerEventListener(
135
140
  reason: Int,
136
141
  ) {
137
142
  val r = if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) Reason.USER_ACTION else null
143
+ core.updateJsKeepAlive()
138
144
  core.emitStateChange(r)
139
145
  }
140
146
 
@@ -154,7 +160,10 @@ internal class TrackPlayerEventListener(
154
160
  }
155
161
 
156
162
  override fun onIsPlayingChanged(isPlaying: Boolean) {
157
- if (isPlaying) core.startProgressTicks()
163
+ if (isPlaying) {
164
+ core.startProgressTicks()
165
+ core.onPlaybackHealthy()
166
+ }
158
167
  core.emitStateChange()
159
168
  }
160
169
 
@@ -171,21 +180,7 @@ internal class TrackPlayerEventListener(
171
180
  }
172
181
  }
173
182
 
174
- override fun onPlayerError(error: PlaybackException) {
175
- with(core) {
176
- NitroPlayerLogger.log(
177
- "TrackPlayer",
178
- "Player error: ${error.errorCodeName} - ${error.message}",
179
- )
180
- notifyPlaybackStateChange(TrackPlayerState.STOPPED, Reason.ERROR)
181
- // A source error leaves ExoPlayer in IDLE; skip the dead item and
182
- // re-prepare so the rest of the queue keeps playing.
183
- if (exo.hasNextMediaItem()) {
184
- exo.seekToNext()
185
- exo.prepare()
186
- }
187
- }
188
- }
183
+ override fun onPlayerError(error: PlaybackException) = core.handlePlayerError(error)
189
184
 
190
185
  override fun onAudioSessionIdChanged(audioSessionId: Int) {
191
186
  if (audioSessionId != 0) {