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.
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +51 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +2 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +17 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +2 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +41 -31
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +47 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -17
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +3 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +16 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerRecovery.kt +97 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +0 -5
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +15 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +96 -45
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +147 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +102 -24
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +44 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +57 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +15 -0
- package/lib/index.js +5 -1
- package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
- package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
- package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
- package/lib/utils/androidAutoMediaLibrary.js +23 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
- package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
- package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
- package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
- package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
- package/src/types/AndroidAutoMediaLibrary.ts +24 -0
- 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
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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 =
|
|
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:$
|
|
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
|
-
|
|
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 =
|
|
332
|
-
val currentId =
|
|
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
|
}
|