react-native-nitro-player 1.7.0-alpha.4 → 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 (48) hide show
  1. package/android/src/main/AndroidManifest.xml +8 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +51 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +2 -2
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +17 -2
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +2 -2
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +41 -31
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +1 -1
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +47 -2
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -17
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +3 -1
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +16 -1
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerRecovery.kt +97 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +0 -5
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +15 -2
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +6 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +2 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +96 -45
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +2 -0
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +147 -0
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +3 -0
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +102 -24
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +44 -2
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +57 -1
  26. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +15 -0
  27. package/lib/index.js +5 -1
  28. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
  29. package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
  30. package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
  31. package/lib/utils/androidAutoMediaLibrary.js +23 -0
  32. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
  33. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  34. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  35. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
  36. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
  37. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
  42. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
  43. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
  44. package/package.json +1 -1
  45. package/src/index.ts +9 -1
  46. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
  47. package/src/types/AndroidAutoMediaLibrary.ts +24 -0
  48. package/src/utils/androidAutoMediaLibrary.ts +48 -1
@@ -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
  }
@@ -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
  }
@@ -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
@@ -83,6 +88,9 @@ class TrackPlayerCore private constructor(
83
88
  internal var remoteSkipForwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
84
89
  internal var remoteSkipBackwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
85
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
86
94
 
87
95
  // ── Temporary queue ────────────────────────────────────────────────────
88
96
  internal var playNextStack: MutableList<TrackItem> = mutableListOf()
@@ -161,7 +169,10 @@ class TrackPlayerCore private constructor(
161
169
  val anchor = old.getOrNull(currentTrackIndex)
162
170
  currentTracks = next
163
171
  if (anchor == null) return AnchorOutcome.NONE
164
- val newIndex = next.indexOfFirst { it.id == anchor.id }
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 }
165
176
  if (newIndex >= 0) {
166
177
  currentTrackIndex = newIndex
167
178
  return AnchorOutcome.KEPT
@@ -251,6 +262,7 @@ class TrackPlayerCore private constructor(
251
262
  // ── Singleton ──────────────────────────────────────────────────────────
252
263
  companion object {
253
264
  internal const val PROGRESS_INTERVAL_MS = 1000L
265
+ private const val KEEP_ALIVE_TASK = "NitroPlayerKeepAlive"
254
266
 
255
267
  @Volatile
256
268
  @Suppress("ktlint:standard:property-naming")
@@ -393,6 +405,34 @@ class TrackPlayerCore private constructor(
393
405
  }
394
406
  }
395
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
+
396
436
  // ── Simple read-only accessors ─────────────────────────────────────────
397
437
 
398
438
  fun isAndroidAutoConnected(): Boolean = isAndroidAutoConnectedField
@@ -421,7 +461,12 @@ class TrackPlayerCore private constructor(
421
461
 
422
462
  fun removeOnPlaybackProgressChangeListener(id: Long): Boolean = onProgressListeners.remove(id)
423
463
 
424
- 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
+ }
425
470
 
426
471
  fun removeOnTracksNeedUpdateListener(id: Long): Boolean = onTracksNeedUpdateListeners.remove(id)
427
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,7 +62,9 @@ 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
  }
67
70
  // A transient play-out ends here: restore the user's repeat mode.
@@ -137,6 +140,7 @@ internal class TrackPlayerEventListener(
137
140
  reason: Int,
138
141
  ) {
139
142
  val r = if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) Reason.USER_ACTION else null
143
+ core.updateJsKeepAlive()
140
144
  core.emitStateChange(r)
141
145
  }
142
146
 
@@ -156,7 +160,10 @@ internal class TrackPlayerEventListener(
156
160
  }
157
161
 
158
162
  override fun onIsPlayingChanged(isPlaying: Boolean) {
159
- if (isPlaying) core.startProgressTicks()
163
+ if (isPlaying) {
164
+ core.startProgressTicks()
165
+ core.onPlaybackHealthy()
166
+ }
160
167
  core.emitStateChange()
161
168
  }
162
169
 
@@ -173,21 +180,7 @@ internal class TrackPlayerEventListener(
173
180
  }
174
181
  }
175
182
 
176
- override fun onPlayerError(error: PlaybackException) {
177
- with(core) {
178
- NitroPlayerLogger.log(
179
- "TrackPlayer",
180
- "Player error: ${error.errorCodeName} - ${error.message}",
181
- )
182
- notifyPlaybackStateChange(TrackPlayerState.STOPPED, Reason.ERROR)
183
- // A source error leaves ExoPlayer in IDLE; skip the dead item and
184
- // re-prepare so the rest of the queue keeps playing.
185
- if (exo.hasNextMediaItem()) {
186
- exo.seekToNext()
187
- exo.prepare()
188
- }
189
- }
190
- }
183
+ override fun onPlayerError(error: PlaybackException) = core.handlePlayerError(error)
191
184
 
192
185
  override fun onAudioSessionIdChanged(audioSessionId: Int) {
193
186
  if (audioSessionId != 0) {
@@ -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) {
@@ -123,8 +123,10 @@ internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
123
123
  suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
124
124
 
125
125
  internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
126
+ // JS is calling, so the keep-alive task is registered: start it now if a headless start needs it
127
+ updateJsKeepAlive()
126
128
  config.androidAudioFocus?.let { applyAudioFocusMode(it) }
127
- config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
129
+ config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.setAndroidAutoEnabled(context, it) }
128
130
  config.lookaheadCount?.let { lookaheadCount = it.toInt() }
129
131
  config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
130
132
  remoteSkipIntervalMs(config.remoteSkipForwardInterval)?.let { remoteSkipForwardIntervalMs = it }
@@ -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
@@ -118,7 +132,7 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
118
132
  // Keep the logical playlist pointer in sync after playlist mutations.
119
133
  // Without this, getActualQueue/getState can report a stale index until the next track transition.
120
134
  if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && currentTrackId != null) {
121
- val resolvedIndex = currentTracks.indexOfFirst { it.id == currentTrackId }
135
+ val resolvedIndex = currentTrackIndexOf(currentTrackId, currentTrackIndex)
122
136
  if (resolvedIndex >= 0) {
123
137
  currentTrackIndex = resolvedIndex
124
138
  }
@@ -268,6 +282,7 @@ internal fun TrackPlayerCore.makeMediaItem(
268
282
  .setTitle(track.title)
269
283
  .setArtist(track.artist)
270
284
  .setAlbumTitle(track.album)
285
+ if (track.duration > 0) metaBuilder.setDurationMs((track.duration * 1000).toLong())
271
286
 
272
287
  track.artwork?.asSecondOrNull()?.let { artworkUrl ->
273
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
 
@@ -37,6 +38,7 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
37
38
  }
38
39
  }
39
40
  if (safeTracks.isEmpty()) return@run
41
+ safeTracks.forEach { urlRequestedAt.remove(it.id) }
40
42
 
41
43
  val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
42
44
 
@@ -133,7 +135,13 @@ suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { cu
133
135
 
134
136
  // ── URL lookahead ─────────────────────────────────────────────────────────
135
137
 
136
- 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
+ ) {
137
145
  val upcomingTracks =
138
146
  if (currentTrackIndex < 0) {
139
147
  currentTracks.take(lookahead)
@@ -144,5 +152,10 @@ internal fun TrackPlayerCore.checkUpcomingTracksForUrls(lookahead: Int = 5) {
144
152
  val currentNeedsUrl = currentTrack != null && currentTrack.url.isEmpty()
145
153
  val candidates = if (currentNeedsUrl) listOf(currentTrack!!) + upcomingTracks else upcomingTracks
146
154
  val needUrls = candidates.filter { it.url.isEmpty() }
147
- 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)
148
161
  }