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
package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
package com.margelo.nitro.nitroplayer.media
|
|
4
4
|
|
|
5
5
|
import android.net.Uri
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
6
8
|
import android.provider.MediaStore
|
|
7
9
|
import androidx.media3.common.MediaItem
|
|
8
10
|
import androidx.media3.common.MediaMetadata
|
|
11
|
+
import androidx.media3.common.Player
|
|
12
|
+
import androidx.media3.common.Timeline
|
|
9
13
|
import androidx.media3.exoplayer.ExoPlayer
|
|
10
14
|
import androidx.media3.session.MediaSession
|
|
11
15
|
import androidx.media3.session.SessionCommand
|
|
12
16
|
import androidx.media3.session.SessionResult
|
|
13
17
|
import com.google.common.util.concurrent.Futures
|
|
14
18
|
import com.google.common.util.concurrent.ListenableFuture
|
|
19
|
+
import com.google.common.util.concurrent.SettableFuture
|
|
15
20
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
16
21
|
import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
|
|
17
|
-
import com.margelo.nitro.nitroplayer.core.loadPlaylist
|
|
18
22
|
import com.margelo.nitro.nitroplayer.playlist.Playlist
|
|
19
23
|
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
20
24
|
import kotlinx.coroutines.CoroutineScope
|
|
@@ -43,7 +47,8 @@ object MediaSessionCallbackFactory {
|
|
|
43
47
|
MediaSession.ConnectionResult
|
|
44
48
|
.AcceptedResultBuilder(session)
|
|
45
49
|
.setAvailableSessionCommands(
|
|
46
|
-
|
|
50
|
+
service.trackPlayerCore?.mediaSessionManager?.sessionCommands()
|
|
51
|
+
?: MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS,
|
|
47
52
|
).setAvailablePlayerCommands(
|
|
48
53
|
MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS,
|
|
49
54
|
).build()
|
|
@@ -74,7 +79,7 @@ object MediaSessionCallbackFactory {
|
|
|
74
79
|
val playlistId = mediaId.substring(0, colonIdx)
|
|
75
80
|
val trackId = mediaId.substring(colonIdx + 1)
|
|
76
81
|
val playlist = playlistManager.getPlaylist(playlistId)
|
|
77
|
-
val track = playlist?.tracks?.
|
|
82
|
+
val track = playlist?.tracks?.getOrNull(playlistManager.indexOfTrackRef(playlistId, trackId))
|
|
78
83
|
if (track != null) {
|
|
79
84
|
updated.add(createMediaItem(track, mediaId))
|
|
80
85
|
} else {
|
|
@@ -105,10 +110,7 @@ object MediaSessionCallbackFactory {
|
|
|
105
110
|
// it against loaded playlists before falling through.
|
|
106
111
|
val voiceMatch = resolveVoiceSearch(mediaItems, playlistManager)
|
|
107
112
|
if (voiceMatch != null) {
|
|
108
|
-
val (
|
|
109
|
-
service.trackPlayerCore?.let { core ->
|
|
110
|
-
scope.launch { core.loadPlaylist(playlistId, trackIndex) }
|
|
111
|
-
}
|
|
113
|
+
val (_, items, trackIndex) = voiceMatch
|
|
112
114
|
return Futures.immediateFuture(
|
|
113
115
|
MediaSession.MediaItemsWithStartPosition(items, trackIndex, startPositionMs),
|
|
114
116
|
)
|
|
@@ -127,15 +129,15 @@ object MediaSessionCallbackFactory {
|
|
|
127
129
|
val trackId = firstMediaId.substring(colonIdx + 1)
|
|
128
130
|
val playlist = playlistManager.getPlaylist(playlistId)
|
|
129
131
|
if (playlist != null) {
|
|
130
|
-
val trackIndex =
|
|
132
|
+
val trackIndex = playlistManager.indexOfTrackRef(playlistId, trackId)
|
|
131
133
|
if (trackIndex >= 0) {
|
|
132
|
-
|
|
133
|
-
scope.launch { core.loadPlaylist(playlistId, trackIndex) }
|
|
134
|
-
}
|
|
134
|
+
val seen = mutableSetOf<String>()
|
|
135
135
|
val playlistMediaItems =
|
|
136
136
|
playlist.tracks
|
|
137
|
-
.
|
|
138
|
-
|
|
137
|
+
.mapIndexed { i, track ->
|
|
138
|
+
val ref = if (seen.add(track.id)) track.id else "${track.id}#$i"
|
|
139
|
+
createMediaItem(track, "$playlistId:$ref")
|
|
140
|
+
}.toMutableList()
|
|
139
141
|
return Futures.immediateFuture(
|
|
140
142
|
MediaSession.MediaItemsWithStartPosition(
|
|
141
143
|
playlistMediaItems,
|
|
@@ -150,31 +152,107 @@ object MediaSessionCallbackFactory {
|
|
|
150
152
|
NitroPlayerLogger.log("MediaSessionCallback") { "Error in onSetMediaItems: ${e.message}" }
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Auto QA bot reports "no music played or error message shown".
|
|
157
|
-
if (mediaItems.isNotEmpty() && mediaItems[0].requestMetadata.searchQuery != null) {
|
|
158
|
-
return Futures.immediateFailedFuture(
|
|
159
|
-
UnsupportedOperationException("No matching tracks for voice search"),
|
|
160
|
-
)
|
|
161
|
-
}
|
|
155
|
+
// No match in loaded playlists: try the app's search, else fail so Assistant speaks an error
|
|
156
|
+
val voiceQuery = mediaItems.firstOrNull()?.requestMetadata?.searchQuery
|
|
157
|
+
if (voiceQuery != null) return searchAndPlay(service, playlistManager, scope, voiceQuery, startPositionMs)
|
|
162
158
|
|
|
163
159
|
return Futures.immediateFuture(
|
|
164
160
|
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs),
|
|
165
161
|
)
|
|
166
162
|
}
|
|
167
163
|
|
|
164
|
+
// Car "start music automatically" on a cold start: play what the app restores once its JS is up
|
|
165
|
+
override fun onPlaybackResumption(
|
|
166
|
+
mediaSession: MediaSession,
|
|
167
|
+
controller: MediaSession.ControllerInfo,
|
|
168
|
+
isForPlayback: Boolean,
|
|
169
|
+
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
|
170
|
+
service.trackPlayerCore?.startJsRuntimeIfNeeded()
|
|
171
|
+
MediaLibraryManager.getInstance(service).requestResumption()
|
|
172
|
+
val future = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
|
173
|
+
val player = mediaSession.player
|
|
174
|
+
val handler = Handler(Looper.getMainLooper())
|
|
175
|
+
lateinit var listener: Player.Listener
|
|
176
|
+
lateinit var timeout: Runnable
|
|
177
|
+
val finish = {
|
|
178
|
+
player.removeListener(listener)
|
|
179
|
+
handler.removeCallbacks(timeout)
|
|
180
|
+
val item = player.currentMediaItem
|
|
181
|
+
if (item != null) {
|
|
182
|
+
future.set(MediaSession.MediaItemsWithStartPosition(listOf(item), 0, player.currentPosition))
|
|
183
|
+
} else {
|
|
184
|
+
future.setException(UnsupportedOperationException("Nothing to resume"))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
listener =
|
|
188
|
+
object : Player.Listener {
|
|
189
|
+
override fun onTimelineChanged(
|
|
190
|
+
timeline: Timeline,
|
|
191
|
+
reason: Int,
|
|
192
|
+
) {
|
|
193
|
+
if (player.currentMediaItem != null) finish()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
timeout = Runnable { finish() }
|
|
197
|
+
player.addListener(listener)
|
|
198
|
+
handler.postDelayed(timeout, RESUMPTION_TIMEOUT_MS)
|
|
199
|
+
return future
|
|
200
|
+
}
|
|
201
|
+
|
|
168
202
|
override fun onCustomCommand(
|
|
169
203
|
session: MediaSession,
|
|
170
204
|
controller: MediaSession.ControllerInfo,
|
|
171
205
|
customCommand: SessionCommand,
|
|
172
206
|
args: android.os.Bundle,
|
|
173
|
-
): ListenableFuture<SessionResult>
|
|
174
|
-
|
|
207
|
+
): ListenableFuture<SessionResult> {
|
|
208
|
+
val manager = service.trackPlayerCore?.mediaSessionManager
|
|
209
|
+
if (manager?.handlesSessionButton(customCommand.customAction) == true) {
|
|
210
|
+
manager.onSessionButtonPress?.invoke(customCommand.customAction)
|
|
211
|
+
}
|
|
212
|
+
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
213
|
+
}
|
|
175
214
|
}
|
|
176
215
|
}
|
|
177
216
|
|
|
217
|
+
// Asks the app's search, which can look past the loaded playlists; plays the first result naming a playlist track
|
|
218
|
+
private fun searchAndPlay(
|
|
219
|
+
service: NitroPlayerPlaybackService,
|
|
220
|
+
playlistManager: PlaylistManager,
|
|
221
|
+
scope: CoroutineScope,
|
|
222
|
+
query: String,
|
|
223
|
+
startPositionMs: Long,
|
|
224
|
+
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
|
225
|
+
val future = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
|
226
|
+
scope.launch {
|
|
227
|
+
val match =
|
|
228
|
+
MediaLibraryManager
|
|
229
|
+
.getInstance(service)
|
|
230
|
+
.search(query)
|
|
231
|
+
?.firstNotNullOfOrNull { resolveTrack(it.id, playlistManager) }
|
|
232
|
+
if (match == null) {
|
|
233
|
+
future.setException(UnsupportedOperationException("No matching tracks for voice search"))
|
|
234
|
+
return@launch
|
|
235
|
+
}
|
|
236
|
+
val (playlist, index) = match
|
|
237
|
+
val items = playlist.tracks.map { createMediaItem(it, "${playlist.id}:${it.id}") }
|
|
238
|
+
future.set(MediaSession.MediaItemsWithStartPosition(items, index, startPositionMs))
|
|
239
|
+
}
|
|
240
|
+
return future
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private fun resolveTrack(
|
|
244
|
+
mediaId: String,
|
|
245
|
+
playlistManager: PlaylistManager,
|
|
246
|
+
): Pair<Playlist, Int>? {
|
|
247
|
+
val colonIndex = mediaId.indexOf(':')
|
|
248
|
+
if (colonIndex <= 0) return null
|
|
249
|
+
val playlist = playlistManager.getPlaylist(mediaId.substring(0, colonIndex)) ?: return null
|
|
250
|
+
val index = playlistManager.indexOfTrackRef(playlist.id, mediaId.substring(colonIndex + 1))
|
|
251
|
+
return if (index >= 0) playlist to index else null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private const val RESUMPTION_TIMEOUT_MS = 10_000L
|
|
255
|
+
|
|
178
256
|
private data class VoiceMatch(
|
|
179
257
|
val playlistId: String,
|
|
180
258
|
val items: List<MediaItem>,
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.media
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.os.Bundle
|
|
4
5
|
import androidx.media3.common.Player
|
|
5
6
|
import androidx.media3.common.util.UnstableApi
|
|
6
7
|
import androidx.media3.session.CommandButton
|
|
7
8
|
import androidx.media3.session.MediaSession
|
|
9
|
+
import androidx.media3.session.SessionCommand
|
|
10
|
+
import androidx.media3.session.SessionCommands
|
|
8
11
|
import com.margelo.nitro.nitroplayer.R
|
|
9
12
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
10
13
|
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
@@ -45,6 +48,36 @@ class MediaSessionManager(
|
|
|
45
48
|
private var remoteSkipForwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
46
49
|
private var remoteSkipBackwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
47
50
|
|
|
51
|
+
/** App-defined button; [icon] is a CommandButton.ICON_* constant. */
|
|
52
|
+
data class SessionButton(
|
|
53
|
+
val action: String,
|
|
54
|
+
val title: String,
|
|
55
|
+
val icon: Int,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
private var sessionButtons: List<SessionButton> = emptyList()
|
|
59
|
+
|
|
60
|
+
@Volatile var onSessionButtonPress: ((action: String) -> Unit)? = null
|
|
61
|
+
|
|
62
|
+
fun sessionCommands(): SessionCommands =
|
|
63
|
+
MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
|
|
64
|
+
.buildUpon()
|
|
65
|
+
.apply { sessionButtons.forEach { add(SessionCommand(it.action, Bundle.EMPTY)) } }
|
|
66
|
+
.build()
|
|
67
|
+
|
|
68
|
+
fun handlesSessionButton(action: String): Boolean = sessionButtons.any { it.action == action }
|
|
69
|
+
|
|
70
|
+
fun setSessionButtons(buttons: List<SessionButton>) {
|
|
71
|
+
sessionButtons = buttons
|
|
72
|
+
val session = mediaSession ?: return
|
|
73
|
+
// A controller only shows or presses a button whose command it was granted; the notification one feeds Android Auto
|
|
74
|
+
val commands = sessionCommands()
|
|
75
|
+
(session.connectedControllers + listOfNotNull(session.mediaNotificationControllerInfo)).distinct().forEach {
|
|
76
|
+
session.setAvailableCommands(it, commands, MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
|
|
77
|
+
}
|
|
78
|
+
updateMediaButtonPreferences()
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
init {
|
|
49
82
|
updateMediaButtonPreferences()
|
|
50
83
|
}
|
|
@@ -82,9 +115,18 @@ class MediaSessionManager(
|
|
|
82
115
|
// Preferences replace Media3's default layout — prev/next must be declared or they vanish.
|
|
83
116
|
// Compact only shows BACK / play-pause / FORWARD, so the ±N skips go to OVERFLOW; left in
|
|
84
117
|
// the forward slot they push Next out of the compact notification entirely.
|
|
85
|
-
val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
|
|
118
|
+
val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() || it.sessionCommand != null }
|
|
119
|
+
val custom =
|
|
120
|
+
sessionButtons.map {
|
|
121
|
+
CommandButton
|
|
122
|
+
.Builder(it.icon)
|
|
123
|
+
.setDisplayName(it.title)
|
|
124
|
+
.setSessionCommand(SessionCommand(it.action, Bundle.EMPTY))
|
|
125
|
+
.setSlots(CommandButton.SLOT_OVERFLOW)
|
|
126
|
+
.build()
|
|
127
|
+
}
|
|
86
128
|
session.setMediaButtonPreferences(
|
|
87
|
-
listOf(previousTrackButton(), nextTrackButton()
|
|
129
|
+
listOf(previousTrackButton(), nextTrackButton()) + custom + listOf(skipBackButton(), skipForwardButton()) + preserved,
|
|
88
130
|
)
|
|
89
131
|
}
|
|
90
132
|
|
|
@@ -2,12 +2,16 @@ package com.margelo.nitro.nitroplayer.media
|
|
|
2
2
|
|
|
3
3
|
import android.app.PendingIntent
|
|
4
4
|
import android.content.Intent
|
|
5
|
+
import android.net.Uri
|
|
5
6
|
import android.os.Binder
|
|
6
7
|
import android.os.Handler
|
|
7
8
|
import android.os.IBinder
|
|
8
9
|
import android.os.Looper
|
|
9
10
|
import androidx.media3.cast.CastPlayer
|
|
11
|
+
import androidx.media3.common.C
|
|
10
12
|
import androidx.media3.common.ForwardingPlayer
|
|
13
|
+
import androidx.media3.common.MediaItem
|
|
14
|
+
import androidx.media3.common.Player
|
|
11
15
|
import androidx.media3.common.util.UnstableApi
|
|
12
16
|
import androidx.media3.exoplayer.ExoPlayer
|
|
13
17
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
|
@@ -15,7 +19,10 @@ import androidx.media3.session.MediaSession
|
|
|
15
19
|
import androidx.media3.session.MediaSessionService
|
|
16
20
|
import com.google.android.gms.cast.framework.CastContext
|
|
17
21
|
import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
|
|
22
|
+
import com.margelo.nitro.nitroplayer.core.checkUpcomingTracksForUrls
|
|
18
23
|
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
24
|
+
import com.margelo.nitro.nitroplayer.core.playFromMediaId
|
|
25
|
+
import com.margelo.nitro.nitroplayer.core.playOnQueue
|
|
19
26
|
import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
|
|
20
27
|
import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
|
|
21
28
|
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
@@ -54,6 +61,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
54
61
|
// ── Created in onCreate ────────────────────────────────────────────────
|
|
55
62
|
private lateinit var player: ExoPlayer
|
|
56
63
|
private var mediaSession: MediaSession? = null
|
|
64
|
+
private lateinit var sessionPlayer: Player
|
|
57
65
|
private var notificationProvider: DefaultMediaNotificationProvider? = null
|
|
58
66
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
59
67
|
|
|
@@ -87,7 +95,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
87
95
|
// The timeline is a window over the logical queue, so ExoPlayer's own prev/next would
|
|
88
96
|
// only ever move inside it — remote Previous seeks to 0 instead of the previous track.
|
|
89
97
|
// Route the remote controls through the same logical skips JS uses.
|
|
90
|
-
|
|
98
|
+
sessionPlayer =
|
|
91
99
|
object : ForwardingPlayer(player) {
|
|
92
100
|
override fun seekToPrevious() {
|
|
93
101
|
trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPrevious()
|
|
@@ -104,6 +112,53 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
104
112
|
override fun seekToNextMediaItem() {
|
|
105
113
|
trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNextMediaItem()
|
|
106
114
|
}
|
|
115
|
+
|
|
116
|
+
// A controller's pick would otherwise load every track at once, bypassing the window and lazy URLs
|
|
117
|
+
override fun setMediaItems(
|
|
118
|
+
mediaItems: MutableList<MediaItem>,
|
|
119
|
+
resetPosition: Boolean,
|
|
120
|
+
) {
|
|
121
|
+
if (!playFromController(mediaItems, 0)) super.setMediaItems(mediaItems, resetPosition)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
override fun setMediaItems(
|
|
125
|
+
mediaItems: MutableList<MediaItem>,
|
|
126
|
+
startIndex: Int,
|
|
127
|
+
startPositionMs: Long,
|
|
128
|
+
) {
|
|
129
|
+
if (!playFromController(mediaItems, startIndex)) super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Queued behind the load above, in the order the controller sent them
|
|
133
|
+
override fun prepare() {
|
|
134
|
+
trackPlayerCore?.let { core -> core.enqueue { core.exo.prepare() } } ?: super.prepare()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override fun play() {
|
|
138
|
+
trackPlayerCore?.let { core -> core.enqueue { core.playOnQueue() } } ?: super.play()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Transcoded streams lack a length header, so ExoPlayer reads them as live: no bar or position in the car
|
|
142
|
+
override fun isCurrentMediaItemLive(): Boolean = super.isCurrentMediaItemLive() && knownDurationMs() == null
|
|
143
|
+
|
|
144
|
+
override fun getDuration(): Long = super.getDuration().takeIf { it != C.TIME_UNSET } ?: knownDurationMs() ?: C.TIME_UNSET
|
|
145
|
+
|
|
146
|
+
private fun knownDurationMs(): Long? = currentMediaItem?.mediaMetadata?.durationMs?.takeIf { it > 0 }
|
|
147
|
+
|
|
148
|
+
private fun playFromController(
|
|
149
|
+
mediaItems: List<MediaItem>,
|
|
150
|
+
startIndex: Int,
|
|
151
|
+
): Boolean {
|
|
152
|
+
val mediaId = mediaItems.getOrNull(maxOf(startIndex, 0))?.mediaId ?: return false
|
|
153
|
+
if (mediaId == currentMediaItem?.mediaId && playerError == null) {
|
|
154
|
+
// Re-picking a song still waiting for its URL asks the app for it again
|
|
155
|
+
if (currentMediaItem?.localConfiguration?.uri == Uri.EMPTY) {
|
|
156
|
+
trackPlayerCore?.let { core -> core.enqueue { core.checkUpcomingTracksForUrls(core.lookaheadCount, force = true) } }
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
return trackPlayerCore?.playFromMediaId(mediaId) == true
|
|
161
|
+
}
|
|
107
162
|
}
|
|
108
163
|
|
|
109
164
|
// Build MediaSession
|
|
@@ -144,6 +199,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
144
199
|
castContext = castContext,
|
|
145
200
|
castPlayer = castPlayer,
|
|
146
201
|
localPlayer = player,
|
|
202
|
+
localSessionPlayer = sessionPlayer,
|
|
147
203
|
mediaSession = mediaSession!!,
|
|
148
204
|
mainHandler = mainHandler,
|
|
149
205
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.playlist
|
|
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.nitroplayer.QueueOperation
|
|
6
7
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
@@ -59,6 +60,8 @@ class PlaylistManager private constructor(
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
init {
|
|
63
|
+
// Persisted extraPayloads are AnyMaps; a process started without JS has not loaded Nitro's JNI yet
|
|
64
|
+
JNIOnLoad.initializeNativeNitro()
|
|
62
65
|
// Synchronous so no mutation can race a late async load and be clobbered
|
|
63
66
|
loadPlaylistsFromFile()
|
|
64
67
|
}
|
|
@@ -136,6 +139,18 @@ class PlaylistManager private constructor(
|
|
|
136
139
|
*/
|
|
137
140
|
fun getPlaylist(playlistId: String): Playlist? = playlists[playlistId]
|
|
138
141
|
|
|
142
|
+
/** "trackId" or, for a repeated track, "trackId#index" as the car browser lists it; -1 when it is not in the playlist. */
|
|
143
|
+
fun indexOfTrackRef(
|
|
144
|
+
playlistId: String,
|
|
145
|
+
trackRef: String,
|
|
146
|
+
): Int {
|
|
147
|
+
val tracks = playlists[playlistId]?.tracks ?: return -1
|
|
148
|
+
val exact = tracks.indexOfFirst { it.id == trackRef }
|
|
149
|
+
if (exact >= 0) return exact
|
|
150
|
+
val index = trackRef.substringAfterLast('#', "").toIntOrNull() ?: return -1
|
|
151
|
+
return index.takeIf { tracks.getOrNull(it)?.id == trackRef.substringBeforeLast('#') } ?: -1
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
/**
|
|
140
155
|
* Get all playlists
|
|
141
156
|
*/
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// TODO: Export all HybridObjects here for the user
|
|
2
2
|
import { NitroModules } from 'react-native-nitro-modules';
|
|
3
|
-
import { Platform } from 'react-native';
|
|
3
|
+
import { AppRegistry, Platform } from 'react-native';
|
|
4
|
+
// Native runs this while it needs JS without a resumed activity (car, screen off): it keeps JS timers ticking
|
|
5
|
+
if (Platform.OS === 'android') {
|
|
6
|
+
AppRegistry.registerHeadlessTask('NitroPlayerKeepAlive', () => () => new Promise(() => { }));
|
|
7
|
+
}
|
|
4
8
|
export const PlayerQueue = NitroModules.createHybridObject('PlayerQueue');
|
|
5
9
|
export const TrackPlayer = NitroModules.createHybridObject('TrackPlayer');
|
|
6
10
|
// Android-only: Android Auto Media Library
|
|
@@ -18,4 +18,16 @@ export interface AndroidAutoMediaLibrary extends HybridObject<{
|
|
|
18
18
|
* Falls back to showing all playlists
|
|
19
19
|
*/
|
|
20
20
|
clearMediaLibrary(): Promise<void>;
|
|
21
|
+
/** Android Auto opened a folder published with `children: []`; answer with {@link resolveRequest}. */
|
|
22
|
+
onLoadChildren(callback: (requestId: string, parentId: string) => void): void;
|
|
23
|
+
/** A car search, or a voice request the loaded playlists cannot match; answer with {@link resolveRequest}. */
|
|
24
|
+
onSearch(callback: (requestId: string, query: string) => void): void;
|
|
25
|
+
/** Answers {@link onLoadChildren} / {@link onSearch} with a JSON array of MediaItem. */
|
|
26
|
+
resolveRequest(requestId: string, itemsJson: string): void;
|
|
27
|
+
/** Replaces the session buttons with a JSON array of SessionButton. */
|
|
28
|
+
setSessionButtons(buttonsJson: string): void;
|
|
29
|
+
/** Called with the button's `action` when a session button is pressed. */
|
|
30
|
+
onSessionButtonPress(callback: (action: string) => void): void;
|
|
31
|
+
/** A controller asked to play with nothing loaded (car auto-play, play on connect); load the last queue now. */
|
|
32
|
+
onPlaybackResumption(callback: () => void): void;
|
|
21
33
|
}
|
|
@@ -28,6 +28,8 @@ export interface MediaItem {
|
|
|
28
28
|
children?: MediaItem[];
|
|
29
29
|
/** Layout type for folder items (overrides library default) */
|
|
30
30
|
layoutType?: LayoutType;
|
|
31
|
+
/** Group header; contiguous items sharing one are listed under it */
|
|
32
|
+
groupTitle?: string;
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
33
35
|
* Media library structure for Android Auto
|
|
@@ -42,3 +44,12 @@ export interface MediaLibrary {
|
|
|
42
44
|
/** Optional app icon URL */
|
|
43
45
|
appIconUrl?: string;
|
|
44
46
|
}
|
|
47
|
+
export type SessionButtonIcon = 'heart' | 'heart_filled' | 'star' | 'star_filled' | 'thumb_up' | 'thumb_up_filled' | 'plus' | 'check';
|
|
48
|
+
/** App-defined button beside the transport controls in Android Auto and the notification */
|
|
49
|
+
export interface SessionButton {
|
|
50
|
+
/** Passed to the press callback */
|
|
51
|
+
action: string;
|
|
52
|
+
/** Accessibility label */
|
|
53
|
+
title: string;
|
|
54
|
+
icon: SessionButtonIcon;
|
|
55
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { MediaLibrary } from '../types/AndroidAutoMediaLibrary';
|
|
1
|
+
import type { MediaItem, MediaLibrary, SessionButton } from '../types/AndroidAutoMediaLibrary';
|
|
2
|
+
type ItemsProvider = (argument: string) => Promise<MediaItem[] | null | undefined>;
|
|
2
3
|
/**
|
|
3
4
|
* Helper utilities for Android Auto Media Library
|
|
4
5
|
* Android-only functionality
|
|
@@ -40,8 +41,19 @@ export declare const AndroidAutoMediaLibraryHelper: {
|
|
|
40
41
|
* Falls back to showing all playlists
|
|
41
42
|
*/
|
|
42
43
|
clear: () => void;
|
|
44
|
+
/** Children of folders published with `children: []`; playable ids are `${playlistId}:${trackId}`, empty after 10s. Register at module scope: the car can start the app without UI. */
|
|
45
|
+
setChildrenLoader: (loader: ItemsProvider) => void;
|
|
46
|
+
/** Answers car searches and unmatched voice requests (first playable result plays); search shows from the next connection. */
|
|
47
|
+
setSearchHandler: (handler: ItemsProvider) => void;
|
|
48
|
+
/** Buttons beside the transport controls in Android Auto and the notification; call again to change their state. */
|
|
49
|
+
setSessionButtons: (buttons: SessionButton[]) => void;
|
|
50
|
+
/** Called with the pressed button's `action`. */
|
|
51
|
+
onSessionButtonPress: (callback: (action: string) => void) => void;
|
|
52
|
+
/** Car auto-play or play-on-connect found nothing loaded: restore your last queue (playback starts once it is loaded). */
|
|
53
|
+
onPlaybackResumption: (callback: () => void) => void;
|
|
43
54
|
/**
|
|
44
55
|
* Check if Android Auto Media Library is available (Android only)
|
|
45
56
|
*/
|
|
46
57
|
isAvailable: () => boolean;
|
|
47
58
|
};
|
|
59
|
+
export {};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { AndroidAutoMediaLibrary as AndroidAutoMediaLibraryModule } from '../index';
|
|
2
|
+
const answer = (requestId, provide) => provide()
|
|
3
|
+
.catch(() => null)
|
|
4
|
+
.then((items) => AndroidAutoMediaLibraryModule?.resolveRequest(requestId, JSON.stringify(items ?? [])));
|
|
2
5
|
/**
|
|
3
6
|
* Helper utilities for Android Auto Media Library
|
|
4
7
|
* Android-only functionality
|
|
@@ -53,6 +56,26 @@ export const AndroidAutoMediaLibraryHelper = {
|
|
|
53
56
|
}
|
|
54
57
|
AndroidAutoMediaLibraryModule.clearMediaLibrary();
|
|
55
58
|
},
|
|
59
|
+
/** Children of folders published with `children: []`; playable ids are `${playlistId}:${trackId}`, empty after 10s. Register at module scope: the car can start the app without UI. */
|
|
60
|
+
setChildrenLoader: (loader) => {
|
|
61
|
+
AndroidAutoMediaLibraryModule?.onLoadChildren((requestId, parentId) => answer(requestId, () => loader(parentId)));
|
|
62
|
+
},
|
|
63
|
+
/** Answers car searches and unmatched voice requests (first playable result plays); search shows from the next connection. */
|
|
64
|
+
setSearchHandler: (handler) => {
|
|
65
|
+
AndroidAutoMediaLibraryModule?.onSearch((requestId, query) => answer(requestId, () => handler(query)));
|
|
66
|
+
},
|
|
67
|
+
/** Buttons beside the transport controls in Android Auto and the notification; call again to change their state. */
|
|
68
|
+
setSessionButtons: (buttons) => {
|
|
69
|
+
AndroidAutoMediaLibraryModule?.setSessionButtons(JSON.stringify(buttons));
|
|
70
|
+
},
|
|
71
|
+
/** Called with the pressed button's `action`. */
|
|
72
|
+
onSessionButtonPress: (callback) => {
|
|
73
|
+
AndroidAutoMediaLibraryModule?.onSessionButtonPress(callback);
|
|
74
|
+
},
|
|
75
|
+
/** Car auto-play or play-on-connect found nothing loaded: restore your last queue (playback starts once it is loaded). */
|
|
76
|
+
onPlaybackResumption: (callback) => {
|
|
77
|
+
AndroidAutoMediaLibraryModule?.onPlaybackResumption(callback);
|
|
78
|
+
},
|
|
56
79
|
/**
|
|
57
80
|
* Check if Android Auto Media Library is available (Android only)
|
|
58
81
|
*/
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
#include <NitroModules/HybridObjectRegistry.hpp>
|
|
17
17
|
|
|
18
18
|
#include "JHybridAndroidAutoMediaLibrarySpec.hpp"
|
|
19
|
+
#include "JFunc_void_std__string_std__string.hpp"
|
|
20
|
+
#include "JFunc_void_std__string.hpp"
|
|
21
|
+
#include "JFunc_void.hpp"
|
|
19
22
|
#include "JHybridAudioDevicesSpec.hpp"
|
|
20
23
|
#include "JHybridCastSpec.hpp"
|
|
21
24
|
#include "JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp"
|
|
@@ -112,6 +115,9 @@ void registerAllNatives() {
|
|
|
112
115
|
|
|
113
116
|
// Register native JNI methods
|
|
114
117
|
margelo::nitro::nitroplayer::JHybridAndroidAutoMediaLibrarySpec::CxxPart::registerNatives();
|
|
118
|
+
margelo::nitro::nitroplayer::JFunc_void_std__string_std__string_cxx::registerNatives();
|
|
119
|
+
margelo::nitro::nitroplayer::JFunc_void_std__string_cxx::registerNatives();
|
|
120
|
+
margelo::nitro::nitroplayer::JFunc_void_cxx::registerNatives();
|
|
115
121
|
margelo::nitro::nitroplayer::JHybridAudioDevicesSpec::CxxPart::registerNatives();
|
|
116
122
|
margelo::nitro::nitroplayer::JHybridCastSpec::CxxPart::registerNatives();
|
|
117
123
|
margelo::nitro::nitroplayer::JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string___cxx::registerNatives();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
///
|
|
2
|
+
/// JFunc_void.hpp
|
|
3
|
+
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
|
4
|
+
/// https://github.com/mrousavy/nitro
|
|
5
|
+
/// Copyright © Marc Rousavy @ Margelo
|
|
6
|
+
///
|
|
7
|
+
|
|
8
|
+
#pragma once
|
|
9
|
+
|
|
10
|
+
#include <fbjni/fbjni.h>
|
|
11
|
+
#include <functional>
|
|
12
|
+
|
|
13
|
+
#include <functional>
|
|
14
|
+
#include <NitroModules/JNICallable.hpp>
|
|
15
|
+
|
|
16
|
+
namespace margelo::nitro::nitroplayer {
|
|
17
|
+
|
|
18
|
+
using namespace facebook;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Represents the Java/Kotlin callback `() -> Unit`.
|
|
22
|
+
* This can be passed around between C++ and Java/Kotlin.
|
|
23
|
+
*/
|
|
24
|
+
struct JFunc_void: public jni::JavaClass<JFunc_void> {
|
|
25
|
+
public:
|
|
26
|
+
static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/nitroplayer/Func_void;";
|
|
27
|
+
|
|
28
|
+
public:
|
|
29
|
+
/**
|
|
30
|
+
* Invokes the function this `JFunc_void` instance holds through JNI.
|
|
31
|
+
*/
|
|
32
|
+
void invoke() const {
|
|
33
|
+
static const auto method = javaClassStatic()->getMethod<void()>("invoke");
|
|
34
|
+
method(self());
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An implementation of Func_void that is backed by a C++ implementation (using `std::function<...>`)
|
|
40
|
+
*/
|
|
41
|
+
class JFunc_void_cxx final: public jni::HybridClass<JFunc_void_cxx, JFunc_void> {
|
|
42
|
+
public:
|
|
43
|
+
static jni::local_ref<JFunc_void::javaobject> fromCpp(const std::function<void()>& func) {
|
|
44
|
+
return JFunc_void_cxx::newObjectCxxArgs(func);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public:
|
|
48
|
+
/**
|
|
49
|
+
* Invokes the C++ `std::function<...>` this `JFunc_void_cxx` instance holds.
|
|
50
|
+
*/
|
|
51
|
+
void invoke_cxx() {
|
|
52
|
+
_func();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public:
|
|
56
|
+
[[nodiscard]]
|
|
57
|
+
inline const std::function<void()>& getFunction() const {
|
|
58
|
+
return _func;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public:
|
|
62
|
+
static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/nitroplayer/Func_void_cxx;";
|
|
63
|
+
static void registerNatives() {
|
|
64
|
+
registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_cxx::invoke_cxx)});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private:
|
|
68
|
+
explicit JFunc_void_cxx(const std::function<void()>& func): _func(func) { }
|
|
69
|
+
|
|
70
|
+
private:
|
|
71
|
+
friend HybridBase;
|
|
72
|
+
std::function<void()> _func;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
} // namespace margelo::nitro::nitroplayer
|