react-native-nitro-player 1.7.0-alpha.4 → 1.7.0-alpha.6

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 (57) 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/ios/HybridTrackPlayer.swift +2 -1
  28. package/ios/core/TrackPlayerPlayback.swift +7 -3
  29. package/ios/media/MediaSessionManager.swift +8 -2
  30. package/lib/index.js +5 -1
  31. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
  32. package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
  33. package/lib/types/PlayerQueue.d.ts +4 -0
  34. package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
  35. package/lib/utils/androidAutoMediaLibrary.js +23 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
  37. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  38. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  39. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
  40. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
  41. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
  42. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +5 -2
  48. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  49. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
  50. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
  51. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  52. package/package.json +1 -1
  53. package/src/index.ts +9 -1
  54. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
  55. package/src/types/AndroidAutoMediaLibrary.ts +24 -0
  56. package/src/types/PlayerQueue.ts +4 -0
  57. package/src/utils/androidAutoMediaLibrary.ts +48 -1
@@ -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 {
@@ -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
  ) {
@@ -7,11 +7,13 @@ import android.net.Uri
7
7
  import android.os.Bundle
8
8
  import android.support.v4.media.MediaBrowserCompat
9
9
  import android.support.v4.media.MediaDescriptionCompat
10
+ import android.support.v4.media.session.MediaSessionCompat
10
11
  import androidx.media.MediaBrowserServiceCompat
11
12
  import androidx.media.utils.MediaConstants
12
13
  import com.margelo.nitro.nitroplayer.TrackItem
13
14
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
14
15
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
16
+ import com.margelo.nitro.nitroplayer.core.onAndroidAutoBrowserConnected
15
17
  import com.margelo.nitro.nitroplayer.playlist.Playlist
16
18
  import kotlinx.coroutines.CoroutineScope
17
19
  import kotlinx.coroutines.Dispatchers
@@ -22,45 +24,59 @@ import kotlinx.coroutines.launch
22
24
  class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
23
25
  companion object {
24
26
  private const val ROOT_ID = "root"
25
- private const val EMPTY_ROOT_ID = "empty_root"
26
27
  private const val PLAYLIST_PREFIX = "playlist_"
28
+ private const val ANDROID_AUTO_PACKAGE = "com.google.android.projection.gearhead"
29
+ private const val PREFS_NAME = "NitroPlayerAndroidAuto"
30
+ private const val KEY_ENABLED = "enabled"
31
+ private const val KEY_SEARCH = "search"
27
32
 
28
- var trackPlayerCore: TrackPlayerCore? = null
29
- var mediaSessionManager: MediaSessionManager? = null
30
- var isAndroidAutoEnabled: Boolean = false
31
33
  var isAndroidAutoConnected: Boolean = false
32
34
 
33
35
  @Volatile
34
36
  private var instance: NitroPlayerMediaBrowserService? = null
35
37
 
36
38
  fun getInstance(): NitroPlayerMediaBrowserService? = instance
39
+
40
+ // Persisted so a cold start from the car, before JS runs configure, still serves the library
41
+ fun setAndroidAutoEnabled(
42
+ context: Context,
43
+ enabled: Boolean,
44
+ ) {
45
+ prefs(context).edit().putBoolean(KEY_ENABLED, enabled).apply()
46
+ instance?.onPlaylistsUpdated()
47
+ }
48
+
49
+ // Persisted too: the car reads the root (and hides search for the whole connection) before JS registers a handler
50
+ fun setSearchSupported(
51
+ context: Context,
52
+ supported: Boolean,
53
+ ) {
54
+ prefs(context).edit().putBoolean(KEY_SEARCH, supported).apply()
55
+ }
56
+
57
+ private fun prefs(context: Context) = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
37
58
  }
38
59
 
39
60
  private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
40
61
  private lateinit var mediaLibraryManager: MediaLibraryManager
62
+ private lateinit var core: TrackPlayerCore
63
+
64
+ private val isAndroidAutoEnabled: Boolean
65
+ get() = prefs(this).getBoolean(KEY_ENABLED, false)
41
66
 
42
67
  override fun onCreate() {
43
68
  super.onCreate()
44
69
 
45
70
  instance = this
46
71
  mediaLibraryManager = MediaLibraryManager.getInstance(applicationContext)
47
-
48
- // Use the existing MediaSession from MediaSessionManager
49
- // This ensures the session is already connected to the ExoPlayer
50
- try {
51
- val session = mediaSessionManager?.mediaSession
52
- if (session != null) {
53
- // Convert Media3 MediaSession to MediaSessionCompat for MediaBrowserService
54
- sessionToken =
55
- android.support.v4.media.session.MediaSessionCompat.Token
56
- .fromToken(session.platformToken)
57
- NitroPlayerLogger.log("MediaBrowserService", "🎵 NitroPlayerMediaBrowserService: MediaSession token set successfully")
58
- } else {
59
- NitroPlayerLogger.log("MediaBrowserService", "⚠️ NitroPlayerMediaBrowserService: MediaSession not available yet")
60
- }
61
- } catch (e: Exception) {
62
- NitroPlayerLogger.log("MediaBrowserService", "❌ NitroPlayerMediaBrowserService: Error setting session token - ${e.message}")
63
- e.printStackTrace()
72
+ mediaLibraryManager.onLateChildren = { parentId -> notifyChildrenChanged(parentId) }
73
+
74
+ // A cold start has no session yet; the connection stays pending until the token is set
75
+ core = TrackPlayerCore.getInstance(applicationContext)
76
+ serviceScope.launch {
77
+ val token = core.withPlayerContext { core.mediaSessionManager?.mediaSession?.platformToken } ?: return@launch
78
+ if (sessionToken == null) sessionToken = MediaSessionCompat.Token.fromToken(token)
79
+ NitroPlayerLogger.log("MediaBrowserService", "🎵 NitroPlayerMediaBrowserService: MediaSession token set")
64
80
  }
65
81
 
66
82
  NitroPlayerLogger.log("MediaBrowserService", "🚀 NitroPlayerMediaBrowserService: Service created")
@@ -69,6 +85,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
69
85
  override fun onDestroy() {
70
86
  super.onDestroy()
71
87
  instance = null
88
+ mediaLibraryManager.onLateChildren = null
72
89
  serviceScope.cancel()
73
90
  NitroPlayerLogger.log("MediaBrowserService", "🛑 NitroPlayerMediaBrowserService: Service destroyed")
74
91
  }
@@ -77,25 +94,27 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
77
94
  clientPackageName: String,
78
95
  clientUid: Int,
79
96
  rootHints: Bundle?,
80
- ): BrowserRoot? {
97
+ ): BrowserRoot {
81
98
  NitroPlayerLogger.log("MediaBrowserService", "📂 NitroPlayerMediaBrowserService: onGetRoot called from $clientPackageName")
82
99
 
83
- // Check if Android Auto is enabled
84
- if (!isAndroidAutoEnabled) {
85
- NitroPlayerLogger.log("MediaBrowserService", "⚠️ NitroPlayerMediaBrowserService: Android Auto not enabled")
86
- return BrowserRoot(EMPTY_ROOT_ID, null)
87
- }
100
+ if (clientPackageName == ANDROID_AUTO_PACKAGE) core.onAndroidAutoBrowserConnected()
88
101
 
89
- // Allow Android Auto and other media browsers to connect
90
- // Enable grid layout for playlists at root level
102
+ // Always the real root: enabling later only has to refresh it, not reconnect the car
91
103
  val extras =
92
104
  Bundle().apply {
93
105
  putInt(
94
106
  MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
95
107
  MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
96
108
  )
109
+ putInt(
110
+ MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
111
+ MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
112
+ )
113
+ putBoolean(
114
+ MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED,
115
+ prefs(this@NitroPlayerMediaBrowserService).getBoolean(KEY_SEARCH, false),
116
+ )
97
117
  }
98
- NitroPlayerLogger.log("MediaBrowserService", "✅ NitroPlayerMediaBrowserService: Allowing connection from $clientPackageName with grid layout")
99
118
  return BrowserRoot(ROOT_ID, extras)
100
119
  }
101
120
 
@@ -104,6 +123,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
104
123
  result: Result<MutableList<MediaBrowserCompat.MediaItem>>,
105
124
  ) {
106
125
  NitroPlayerLogger.log("MediaBrowserService", "📂 NitroPlayerMediaBrowserService: onLoadChildren called for parentId: $parentId")
126
+ startJsOnDemand()
107
127
 
108
128
  if (!isAndroidAutoEnabled) {
109
129
  NitroPlayerLogger.log("MediaBrowserService", "⚠️ NitroPlayerMediaBrowserService: Android Auto not enabled, returning empty")
@@ -118,9 +138,11 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
118
138
 
119
139
  serviceScope.launch {
120
140
  try {
141
+ if (mediaLibraryManager.getMediaLibrary() == null) mediaLibraryManager.awaitFirstPublish(MediaLibraryManager.FIRST_PUBLISH_WAIT_MS)
121
142
  val library = mediaLibraryManager.getMediaLibrary()
143
+ val rootItems = library?.rootItems?.filter { it.hasContent() }.orEmpty()
122
144
 
123
- if (library == null) {
145
+ if (library == null || rootItems.isEmpty()) {
124
146
  // Fallback: show playlists if no media library is set
125
147
  NitroPlayerLogger.log("MediaBrowserService", "⚠️ NitroPlayerMediaBrowserService: No media library set, using fallback playlists")
126
148
  val mediaItems = loadFallbackPlaylists()
@@ -128,11 +150,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
128
150
  return@launch
129
151
  }
130
152
 
131
- val mediaItems = mutableListOf<MediaBrowserCompat.MediaItem>()
132
-
133
- library.rootItems.forEach { item ->
134
- mediaItems.add(convertToMediaBrowserItem(item, library.layoutType))
135
- }
153
+ val mediaItems = rootItems.map { convertToMediaBrowserItem(it, library.layoutType) }.toMutableList()
136
154
 
137
155
  NitroPlayerLogger.log("MediaBrowserService", "✅ NitroPlayerMediaBrowserService: Returning ${mediaItems.size} root items")
138
156
  result.sendResult(mediaItems)
@@ -152,7 +170,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
152
170
 
153
171
  serviceScope.launch {
154
172
  try {
155
- val playlist = trackPlayerCore?.getPlaylistManager()?.getPlaylist(playlistId)
173
+ val playlist = core.playlistManager.getPlaylist(playlistId)
156
174
 
157
175
  if (playlist == null) {
158
176
  NitroPlayerLogger.log("MediaBrowserService", "⚠️ NitroPlayerMediaBrowserService: Playlist '$playlistId' not found")
@@ -162,7 +180,10 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
162
180
 
163
181
  val mediaItems = mutableListOf<MediaBrowserCompat.MediaItem>()
164
182
 
183
+ val seen = mutableSetOf<String>()
165
184
  playlist.tracks.forEachIndexed { index, track ->
185
+ // A repeated track gets its own id so picking that row plays that occurrence
186
+ val trackRef = if (seen.add(track.id)) track.id else "${track.id}#$index"
166
187
  val extras =
167
188
  Bundle().apply {
168
189
  putString("playlistId", playlistId)
@@ -173,7 +194,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
173
194
  val description =
174
195
  MediaDescriptionCompat
175
196
  .Builder()
176
- .setMediaId("$playlistId:${track.id}")
197
+ .setMediaId("$playlistId:$trackRef")
177
198
  .setTitle(track.title)
178
199
  .setSubtitle(track.artist)
179
200
  .setDescription(track.album)
@@ -199,17 +220,14 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
199
220
  }
200
221
  }
201
222
 
202
- parentId == EMPTY_ROOT_ID -> {
203
- result.sendResult(mutableListOf())
204
- }
205
-
206
223
  else -> {
207
224
  // Handle custom folder IDs from media library
208
225
  result.detach()
209
226
 
210
227
  serviceScope.launch {
211
228
  try {
212
- val children = mediaLibraryManager.getChildrenById(parentId)
229
+ if (mediaLibraryManager.getMediaLibrary() == null) mediaLibraryManager.awaitFirstPublish(MediaLibraryManager.FIRST_PUBLISH_WAIT_MS)
230
+ val children = mediaLibraryManager.getChildrenById(parentId) ?: mediaLibraryManager.loadChildren(parentId)
213
231
 
214
232
  if (children == null) {
215
233
  NitroPlayerLogger.log("MediaBrowserService", "⚠️ NitroPlayerMediaBrowserService: No children found for parentId: $parentId")
@@ -221,6 +239,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
221
239
  val defaultLayout = library?.layoutType ?: LayoutType.LIST
222
240
  val mediaItems =
223
241
  children
242
+ .filter { it.hasContent() }
224
243
  .map { item ->
225
244
  convertToMediaBrowserItem(item, defaultLayout)
226
245
  }.toMutableList()
@@ -237,6 +256,31 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
237
256
  }
238
257
  }
239
258
 
259
+ override fun onSearch(
260
+ query: String,
261
+ extras: Bundle?,
262
+ result: Result<MutableList<MediaBrowserCompat.MediaItem>>,
263
+ ) {
264
+ startJsOnDemand()
265
+ result.detach()
266
+ serviceScope.launch {
267
+ val items = mediaLibraryManager.search(query).orEmpty().filter { it.hasContent() }
268
+ val layout = mediaLibraryManager.getMediaLibrary()?.layoutType ?: LayoutType.LIST
269
+ result.sendResult(items.map { convertToMediaBrowserItem(it, layout) }.toMutableList())
270
+ }
271
+ }
272
+
273
+ // The car binds every media app on connect; JS starts only once content is asked for, never for SystemUI's recent-media probe
274
+ private fun startJsOnDemand() {
275
+ if (browserRootHints?.getBoolean(BrowserRoot.EXTRA_RECENT) != true) core.startJsRuntimeIfNeeded()
276
+ }
277
+
278
+ // Folders a client has open keep their stale children unless refreshed too
279
+ fun onLibraryUpdated(folderIds: List<String>) {
280
+ onPlaylistsUpdated()
281
+ folderIds.distinct().forEach { notifyChildrenChanged(it) }
282
+ }
283
+
240
284
  fun onPlaylistsUpdated() {
241
285
  try {
242
286
  notifyChildrenChanged(ROOT_ID)
@@ -255,6 +299,12 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
255
299
  }
256
300
  }
257
301
 
302
+ // Apps may delete the playlists a published library points at; hide those, but keep empty ones so the user still finds them
303
+ private fun MediaItem.hasContent(): Boolean {
304
+ if (mediaType != MediaType.PLAYLIST || playlistId == null) return true
305
+ return core.playlistManager.getPlaylist(playlistId) != null
306
+ }
307
+
258
308
  /**
259
309
  * Convert MediaLibrary MediaItem to Android Auto MediaBrowserCompat.MediaItem
260
310
  */
@@ -275,6 +325,7 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
275
325
  MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
276
326
  contentStyle,
277
327
  )
328
+ item.groupTitle?.let { putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it) }
278
329
  }
279
330
 
280
331
  // Determine the media ID based on item type
@@ -328,8 +379,8 @@ class NitroPlayerMediaBrowserService : MediaBrowserServiceCompat() {
328
379
  // and prefer surfacing the currently-loaded playlist first so the user
329
380
  // always has access to "what's playing now" while the JS side hasn't
330
381
  // published a media library yet.
331
- val rawPlaylists = trackPlayerCore?.getAllPlaylists() ?: emptyList()
332
- val currentId = trackPlayerCore?.getCurrentPlaylistId()
382
+ val rawPlaylists = core.getAllPlaylists()
383
+ val currentId = core.getCurrentPlaylistId()
333
384
  val uuidRegex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
334
385
 
335
386
  val dedupedByName = linkedMapOf<String, com.margelo.nitro.nitroplayer.playlist.Playlist>()
@@ -41,6 +41,8 @@ data class MediaItem(
41
41
  val children: List<MediaItem>? = null,
42
42
  /** Layout type for folder items (overrides library default) */
43
43
  val layoutType: LayoutType? = null,
44
+ /** Group header; contiguous items sharing one are listed under it */
45
+ val groupTitle: String? = null,
44
46
  )
45
47
 
46
48
  /**
@@ -2,6 +2,12 @@ package com.margelo.nitro.nitroplayer.media
2
2
 
3
3
  import android.content.Context
4
4
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
5
+ import kotlinx.coroutines.CompletableDeferred
6
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
7
+ import kotlinx.coroutines.withTimeoutOrNull
8
+ import org.json.JSONException
9
+ import java.util.UUID
10
+ import java.util.concurrent.ConcurrentHashMap
5
11
 
6
12
  /**
7
13
  * Manages the Android Auto media library structure
@@ -12,11 +18,150 @@ class MediaLibraryManager private constructor(
12
18
  @Volatile
13
19
  private var mediaLibrary: MediaLibrary? = null
14
20
 
21
+ // The app answers these through resolve(): folders loaded on demand, and searches
22
+ @Volatile
23
+ var childrenRequester: ((requestId: String, parentId: String) -> Unit)? = null
24
+ set(value) {
25
+ field = value
26
+ if (value != null) loaderRegistered.complete(Unit)
27
+ }
28
+
29
+ private val loaderRegistered = CompletableDeferred<Unit>()
30
+
31
+ private var resumptionRequester: (() -> Unit)? = null
32
+ private var resumptionPending = false
33
+
34
+ // A resumption asked for before the app's JS registered its handler is delivered once it does
35
+ fun setResumptionRequester(requester: () -> Unit) {
36
+ val deliver =
37
+ synchronized(this) {
38
+ resumptionRequester = requester
39
+ resumptionPending.also { resumptionPending = false }
40
+ }
41
+ if (deliver) requester()
42
+ }
43
+
44
+ fun requestResumption() {
45
+ val requester = synchronized(this) { resumptionRequester.also { if (it == null) resumptionPending = true } }
46
+ requester?.invoke()
47
+ }
48
+
49
+ @Volatile
50
+ var searchRequester: ((requestId: String, query: String) -> Unit)? = null
51
+
52
+ private val pending = ConcurrentHashMap<String, CompletableDeferred<String>>()
53
+
54
+ private val firstPublish = CompletableDeferred<Unit>()
55
+
56
+ // A car-only start asks for the root before the app's JS has published; wait once instead of showing "No items"
57
+ suspend fun awaitFirstPublish(timeoutMs: Long) {
58
+ withTimeoutOrNull(timeoutMs) { firstPublish.await() }
59
+ firstPublish.complete(Unit)
60
+ }
61
+
62
+ /** Children of a folder the published library leaves empty, or null when the app has none to give. */
63
+ private val inFlightChildren = ConcurrentHashMap<String, CompletableDeferred<List<MediaItem>?>>()
64
+
65
+ // Folders loaded on demand are refreshed with the library, so the car never keeps a stale or failed answer
66
+ val loadedFolderIds: MutableSet<String> = ConcurrentHashMap.newKeySet()
67
+
68
+ // An answer that missed the timeout is kept briefly and the car told to reload that folder
69
+ private val lateChildren = ConcurrentHashMap<String, Pair<Long, List<MediaItem>>>()
70
+
71
+ @Volatile
72
+ var onLateChildren: ((parentId: String) -> Unit)? = null
73
+
74
+ suspend fun loadChildren(parentId: String): List<MediaItem>? {
75
+ // The car reopens remembered folders on connect, before a car-only start's JS has registered its loader
76
+ if (childrenRequester == null) withTimeoutOrNull(FIRST_PUBLISH_WAIT_MS) { loaderRegistered.await() }
77
+ loaderRegistered.complete(Unit)
78
+ loadedFolderIds.add(parentId)
79
+ lateChildren.remove(parentId)?.let { (at, items) -> if (System.currentTimeMillis() - at < LATE_CHILDREN_TTL_MS) return items }
80
+ // The car asks for the same folder several times at once; answer them all from one app call
81
+ val result = CompletableDeferred<List<MediaItem>?>()
82
+ inFlightChildren.putIfAbsent(parentId, result)?.let { return it.await() }
83
+ try {
84
+ result.complete(request(childrenRequester, parentId, LOAD_CHILDREN_TIMEOUT_MS, lateFolder = parentId))
85
+ } catch (e: Throwable) {
86
+ result.completeExceptionally(e)
87
+ throw e
88
+ } finally {
89
+ inFlightChildren.remove(parentId, result)
90
+ }
91
+ return result.await()
92
+ }
93
+
94
+ suspend fun search(query: String): List<MediaItem>? = request(searchRequester, query, SEARCH_TIMEOUT_MS)
95
+
96
+ fun resolve(
97
+ requestId: String,
98
+ itemsJson: String,
99
+ ) {
100
+ pending.remove(requestId)?.complete(itemsJson)
101
+ }
102
+
103
+ @OptIn(ExperimentalCoroutinesApi::class)
104
+ private suspend fun request(
105
+ requester: ((String, String) -> Unit)?,
106
+ argument: String,
107
+ timeoutMs: Long,
108
+ lateFolder: String? = null,
109
+ ): List<MediaItem>? {
110
+ val ask = requester ?: return null
111
+ val requestId = UUID.randomUUID().toString()
112
+ val answer = CompletableDeferred<String>()
113
+ pending[requestId] = answer
114
+ try {
115
+ ask(requestId, argument)
116
+ } catch (e: Throwable) {
117
+ pending.remove(requestId)
118
+ throw e
119
+ }
120
+ // A driver is waiting at the screen: past this the folder reads as empty
121
+ val askedAt = System.currentTimeMillis()
122
+ val json = withTimeoutOrNull(timeoutMs) { answer.await() }
123
+ NitroPlayerLogger.log("MediaLibraryManager") { "'$argument' ${if (json == null) "timed out" else "answered"} after ${System.currentTimeMillis() - askedAt}ms" }
124
+ if (json == null && lateFolder != null) {
125
+ if (pending.size > MAX_PENDING) pending.clear()
126
+ answer.invokeOnCompletion { error -> if (error == null) deliverLate(lateFolder, answer.getCompleted()) }
127
+ return null
128
+ }
129
+ pending.remove(requestId)
130
+ return json?.let { parseItems(it, argument) }
131
+ }
132
+
133
+ private fun deliverLate(
134
+ parentId: String,
135
+ json: String,
136
+ ) {
137
+ val items = parseItems(json, parentId) ?: return
138
+ NitroPlayerLogger.log("MediaLibraryManager") { "Late answer for '$parentId', asking the car to reload it" }
139
+ lateChildren[parentId] = System.currentTimeMillis() to items
140
+ onLateChildren?.invoke(parentId)
141
+ }
142
+
143
+ private fun parseItems(
144
+ json: String,
145
+ argument: String,
146
+ ): List<MediaItem>? =
147
+ try {
148
+ MediaLibraryParser.itemsFromJson(json)
149
+ } catch (e: JSONException) {
150
+ NitroPlayerLogger.log("MediaLibraryManager") { "Bad items JSON for '$argument': ${e.message}" }
151
+ null
152
+ }
153
+
15
154
  companion object {
16
155
  @Volatile
17
156
  @Suppress("ktlint:standard:property-naming")
18
157
  private var INSTANCE: MediaLibraryManager? = null
19
158
 
159
+ private const val LOAD_CHILDREN_TIMEOUT_MS = 10_000L
160
+ private const val LATE_CHILDREN_TTL_MS = 60_000L
161
+ private const val MAX_PENDING = 64
162
+ private const val SEARCH_TIMEOUT_MS = 7_000L
163
+ internal const val FIRST_PUBLISH_WAIT_MS = 3_000L
164
+
20
165
  fun getInstance(context: Context): MediaLibraryManager =
21
166
  INSTANCE ?: synchronized(this) {
22
167
  INSTANCE ?: MediaLibraryManager(context).also { INSTANCE = it }
@@ -28,6 +173,7 @@ class MediaLibraryManager private constructor(
28
173
  */
29
174
  fun setMediaLibrary(library: MediaLibrary) {
30
175
  mediaLibrary = library
176
+ firstPublish.complete(Unit)
31
177
  NitroPlayerLogger.log("MediaLibraryManager", "📚 MediaLibraryManager: Media library set with ${library.rootItems.size} root items")
32
178
  }
33
179
 
@@ -73,6 +219,7 @@ class MediaLibraryManager private constructor(
73
219
  */
74
220
  fun clear() {
75
221
  mediaLibrary = null
222
+ firstPublish.complete(Unit)
76
223
  NitroPlayerLogger.log("MediaLibraryManager", "📚 MediaLibraryManager: Media library cleared")
77
224
  }
78
225
  }
@@ -7,6 +7,8 @@ import org.json.JSONObject
7
7
  * Parser for MediaLibrary JSON structure
8
8
  */
9
9
  object MediaLibraryParser {
10
+ fun itemsFromJson(json: String): List<MediaItem> = parseMediaItems(JSONArray(json))
11
+
10
12
  fun fromJson(json: String): MediaLibrary {
11
13
  val jsonObject = JSONObject(json)
12
14
 
@@ -68,6 +70,7 @@ object MediaLibraryParser {
68
70
  playlistId = jsonObject.optString("playlistId").takeIf { it.isNotEmpty() },
69
71
  children = children.takeIf { it.isNotEmpty() },
70
72
  layoutType = layoutType,
73
+ groupTitle = jsonObject.optString("groupTitle").takeIf { it.isNotEmpty() },
71
74
  )
72
75
  }
73
76
  }