react-native-nitro-player 1.7.0-alpha.3 → 1.7.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- 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/HybridPlayerQueue.kt +45 -7
- 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/TrackPlayerAudioFocus.kt +65 -0
- 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 +92 -15
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +12 -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 +39 -13
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +25 -4
- 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 +22 -5
- 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/AudioFocusController.kt +87 -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/ExoPlayerBuilder.kt +3 -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 +51 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +82 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +75 -0
- package/ios/cast/CastQueueDiff.swift +58 -0
- package/ios/cast/CastQueueReconciler.swift +228 -0
- package/ios/core/CastSessionManager.swift +215 -31
- package/ios/core/TrackPlayerCast.swift +96 -65
- package/ios/core/TrackPlayerCore.swift +31 -0
- package/ios/core/TrackPlayerListener.swift +19 -3
- package/ios/core/TrackPlayerPlayback.swift +12 -10
- package/ios/core/TrackPlayerQueue.swift +10 -2
- package/ios/core/TrackPlayerQueueBuild.swift +21 -9
- package/ios/core/TrackPlayerTempQueue.swift +27 -15
- package/ios/core/TrackPlayerUrlLoader.swift +6 -2
- package/ios/playlist/PlaylistManager.swift +60 -1
- package/ios/queue/HybridPlayerQueue.swift +40 -13
- package/lib/hooks/callbackManager.d.ts +2 -0
- package/lib/hooks/callbackManager.js +23 -8
- package/lib/index.js +5 -1
- package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
- package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
- package/lib/types/PlayerQueue.d.ts +14 -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++/JAndroidAudioFocusMode.hpp +61 -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/c++/JHybridPlayerQueueSpec.cpp +43 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
- package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -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/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
- package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
- package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
- package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
- package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +25 -10
- package/src/index.ts +9 -1
- package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
- package/src/specs/TrackPlayer.nitro.ts +11 -0
- package/src/types/AndroidAutoMediaLibrary.ts +24 -0
- package/src/types/PlayerQueue.ts +17 -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
|
}
|
|
@@ -79,10 +112,21 @@ class MediaSessionManager(
|
|
|
79
112
|
|
|
80
113
|
private fun updateMediaButtonPreferences() {
|
|
81
114
|
val session = mediaSession ?: return
|
|
82
|
-
// Preferences replace Media3's default layout — prev/next must be declared or they vanish
|
|
83
|
-
|
|
115
|
+
// Preferences replace Media3's default layout — prev/next must be declared or they vanish.
|
|
116
|
+
// Compact only shows BACK / play-pause / FORWARD, so the ±N skips go to OVERFLOW; left in
|
|
117
|
+
// the forward slot they push Next out of the compact notification entirely.
|
|
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
|
+
}
|
|
84
128
|
session.setMediaButtonPreferences(
|
|
85
|
-
listOf(previousTrackButton(),
|
|
129
|
+
listOf(previousTrackButton(), nextTrackButton()) + custom + listOf(skipBackButton(), skipForwardButton()) + preserved,
|
|
86
130
|
)
|
|
87
131
|
}
|
|
88
132
|
|
|
@@ -90,6 +134,7 @@ class MediaSessionManager(
|
|
|
90
134
|
CommandButton
|
|
91
135
|
.Builder(CommandButton.ICON_PREVIOUS)
|
|
92
136
|
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
|
|
137
|
+
.setSlots(CommandButton.SLOT_BACK)
|
|
93
138
|
.setDisplayName(context.getString(R.string.nitro_player_previous_track))
|
|
94
139
|
.build()
|
|
95
140
|
|
|
@@ -97,6 +142,7 @@ class MediaSessionManager(
|
|
|
97
142
|
CommandButton
|
|
98
143
|
.Builder(CommandButton.ICON_NEXT)
|
|
99
144
|
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
|
|
145
|
+
.setSlots(CommandButton.SLOT_FORWARD)
|
|
100
146
|
.setDisplayName(context.getString(R.string.nitro_player_next_track))
|
|
101
147
|
.build()
|
|
102
148
|
|
|
@@ -104,6 +150,7 @@ class MediaSessionManager(
|
|
|
104
150
|
CommandButton
|
|
105
151
|
.Builder(skipBackIcon(remoteSkipBackwardIntervalMs))
|
|
106
152
|
.setPlayerCommand(Player.COMMAND_SEEK_BACK)
|
|
153
|
+
.setSlots(CommandButton.SLOT_OVERFLOW)
|
|
107
154
|
.setDisplayName(
|
|
108
155
|
context.getString(
|
|
109
156
|
R.string.nitro_player_skip_back_seconds,
|
|
@@ -115,6 +162,7 @@ class MediaSessionManager(
|
|
|
115
162
|
CommandButton
|
|
116
163
|
.Builder(skipForwardIcon(remoteSkipForwardIntervalMs))
|
|
117
164
|
.setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
|
|
165
|
+
.setSlots(CommandButton.SLOT_OVERFLOW)
|
|
118
166
|
.setDisplayName(
|
|
119
167
|
context.getString(
|
|
120
168
|
R.string.nitro_player_skip_forward_seconds,
|
|
@@ -2,11 +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
|
|
12
|
+
import androidx.media3.common.ForwardingPlayer
|
|
13
|
+
import androidx.media3.common.MediaItem
|
|
14
|
+
import androidx.media3.common.Player
|
|
10
15
|
import androidx.media3.common.util.UnstableApi
|
|
11
16
|
import androidx.media3.exoplayer.ExoPlayer
|
|
12
17
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
|
@@ -14,7 +19,12 @@ import androidx.media3.session.MediaSession
|
|
|
14
19
|
import androidx.media3.session.MediaSessionService
|
|
15
20
|
import com.google.android.gms.cast.framework.CastContext
|
|
16
21
|
import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
|
|
22
|
+
import com.margelo.nitro.nitroplayer.core.checkUpcomingTracksForUrls
|
|
17
23
|
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
24
|
+
import com.margelo.nitro.nitroplayer.core.playFromMediaId
|
|
25
|
+
import com.margelo.nitro.nitroplayer.core.playOnQueue
|
|
26
|
+
import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
|
|
27
|
+
import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
|
|
18
28
|
import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
|
|
19
29
|
|
|
20
30
|
/**
|
|
@@ -51,6 +61,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
51
61
|
// ── Created in onCreate ────────────────────────────────────────────────
|
|
52
62
|
private lateinit var player: ExoPlayer
|
|
53
63
|
private var mediaSession: MediaSession? = null
|
|
64
|
+
private lateinit var sessionPlayer: Player
|
|
54
65
|
private var notificationProvider: DefaultMediaNotificationProvider? = null
|
|
55
66
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
56
67
|
|
|
@@ -81,10 +92,79 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
81
92
|
// Build ExoPlayer on main looper (default)
|
|
82
93
|
player = ExoPlayerBuilder.build(this)
|
|
83
94
|
|
|
95
|
+
// The timeline is a window over the logical queue, so ExoPlayer's own prev/next would
|
|
96
|
+
// only ever move inside it — remote Previous seeks to 0 instead of the previous track.
|
|
97
|
+
// Route the remote controls through the same logical skips JS uses.
|
|
98
|
+
sessionPlayer =
|
|
99
|
+
object : ForwardingPlayer(player) {
|
|
100
|
+
override fun seekToPrevious() {
|
|
101
|
+
trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPrevious()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override fun seekToPreviousMediaItem() {
|
|
105
|
+
trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPreviousMediaItem()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override fun seekToNext() {
|
|
109
|
+
trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNext()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override fun seekToNextMediaItem() {
|
|
113
|
+
trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNextMediaItem()
|
|
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
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
84
164
|
// Build MediaSession
|
|
85
165
|
val playlistManager = PlaylistManager.getInstance(this)
|
|
86
166
|
mediaSession = MediaSession
|
|
87
|
-
.Builder(this,
|
|
167
|
+
.Builder(this, sessionPlayer)
|
|
88
168
|
.setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
|
|
89
169
|
.apply { buildSessionActivity()?.let { setSessionActivity(it) } }
|
|
90
170
|
.build()
|
|
@@ -119,6 +199,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
|
|
|
119
199
|
castContext = castContext,
|
|
120
200
|
castPlayer = castPlayer,
|
|
121
201
|
localPlayer = player,
|
|
202
|
+
localSessionPlayer = sessionPlayer,
|
|
122
203
|
mediaSession = mediaSession!!,
|
|
123
204
|
mainHandler = mainHandler,
|
|
124
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
|
*/
|
|
@@ -249,6 +264,61 @@ class PlaylistManager private constructor(
|
|
|
249
264
|
return true
|
|
250
265
|
}
|
|
251
266
|
|
|
267
|
+
/** Removed count; null when the playlist does not exist. Unknown ids are ignored. */
|
|
268
|
+
fun removeTracksFromPlaylist(
|
|
269
|
+
playlistId: String,
|
|
270
|
+
trackIds: List<String>,
|
|
271
|
+
): Int? {
|
|
272
|
+
val ids = trackIds.toHashSet()
|
|
273
|
+
var removed = 0
|
|
274
|
+
val found =
|
|
275
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
276
|
+
val tracks = playlist.tracks.toMutableList()
|
|
277
|
+
val before = tracks.size
|
|
278
|
+
if (tracks.removeAll { it.id in ids }) {
|
|
279
|
+
removed = before - tracks.size
|
|
280
|
+
playlist.copy(tracks = tracks)
|
|
281
|
+
} else {
|
|
282
|
+
playlist
|
|
283
|
+
}
|
|
284
|
+
} != null
|
|
285
|
+
if (!found) return null
|
|
286
|
+
if (removed > 0) {
|
|
287
|
+
scheduleSave()
|
|
288
|
+
notifyPlaylistChanged(playlistId, QueueOperation.REMOVE)
|
|
289
|
+
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
|
|
290
|
+
}
|
|
291
|
+
return removed
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Fisher-Yates; [firstTrackId] is pinned at index 0. Null when the playlist does not exist. */
|
|
295
|
+
fun shufflePlaylist(
|
|
296
|
+
playlistId: String,
|
|
297
|
+
firstTrackId: String?,
|
|
298
|
+
): Boolean? {
|
|
299
|
+
var changed = false
|
|
300
|
+
val found =
|
|
301
|
+
playlists.computeIfPresent(playlistId) { _, playlist ->
|
|
302
|
+
val tracks = playlist.tracks.toMutableList()
|
|
303
|
+
val pinned = tracks.indexOfFirst { it.id == firstTrackId }.takeIf { it >= 0 }?.let { tracks.removeAt(it) }
|
|
304
|
+
tracks.shuffle()
|
|
305
|
+
pinned?.let { tracks.add(0, it) }
|
|
306
|
+
if (tracks.map { it.id } == playlist.tracks.map { it.id }) {
|
|
307
|
+
playlist
|
|
308
|
+
} else {
|
|
309
|
+
changed = true
|
|
310
|
+
playlist.copy(tracks = tracks)
|
|
311
|
+
}
|
|
312
|
+
} != null
|
|
313
|
+
if (!found) return null
|
|
314
|
+
if (changed) {
|
|
315
|
+
scheduleSave()
|
|
316
|
+
notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
|
|
317
|
+
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
|
|
318
|
+
}
|
|
319
|
+
return changed
|
|
320
|
+
}
|
|
321
|
+
|
|
252
322
|
/**
|
|
253
323
|
* Update entire track objects across all playlists
|
|
254
324
|
* Matches by track.id and replaces the entire track object
|
|
@@ -317,6 +387,11 @@ class PlaylistManager private constructor(
|
|
|
317
387
|
return true
|
|
318
388
|
}
|
|
319
389
|
|
|
390
|
+
/** Mirrors the core's current playlist (playSong bypasses loadPlaylist); Android Auto and persistence read this. */
|
|
391
|
+
fun setCurrentPlaylistId(playlistId: String?) {
|
|
392
|
+
currentPlaylistId = playlistId
|
|
393
|
+
}
|
|
394
|
+
|
|
320
395
|
/**
|
|
321
396
|
* Get the current playlist ID
|
|
322
397
|
*/
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pure receiver-queue diff: no Cast SDK types, no operation ever contains the current item.
|
|
2
|
+
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
enum CastQueueOp: Equatable {
|
|
6
|
+
case remove([String])
|
|
7
|
+
case insert([String])
|
|
8
|
+
case moveToEnd([String])
|
|
9
|
+
case moveBefore([String], anchor: String)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
enum CastQueueDiff {
|
|
13
|
+
/// Converges in at most 4 ops. Caller guarantees `currentId` occurs exactly once in both lists.
|
|
14
|
+
static func nextOp(existing: [String], desired: [String], currentId: String) -> CastQueueOp? {
|
|
15
|
+
let desiredSet = Set(desired)
|
|
16
|
+
let existingSet = Set(existing)
|
|
17
|
+
|
|
18
|
+
let toRemove = existing.filter { !desiredSet.contains($0) }
|
|
19
|
+
if !toRemove.isEmpty { return .remove(toRemove) }
|
|
20
|
+
|
|
21
|
+
let toInsert = desired.filter { !existingSet.contains($0) }
|
|
22
|
+
if !toInsert.isEmpty { return .insert(toInsert) }
|
|
23
|
+
|
|
24
|
+
if existing == desired { return nil }
|
|
25
|
+
guard let cur = desired.firstIndex(of: currentId) else { return nil }
|
|
26
|
+
|
|
27
|
+
let after = Array(desired[(cur + 1)...])
|
|
28
|
+
if !after.isEmpty, Array(existing.suffix(after.count)) != after { return .moveToEnd(after) }
|
|
29
|
+
|
|
30
|
+
let before = Array(desired[..<cur])
|
|
31
|
+
if !before.isEmpty, Array(existing.prefix(before.count)) != before {
|
|
32
|
+
return .moveBefore(before, anchor: currentId)
|
|
33
|
+
}
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static func apply(_ op: CastQueueOp, to existing: [String]) -> [String] {
|
|
38
|
+
switch op {
|
|
39
|
+
case .remove(let ids):
|
|
40
|
+
let dropped = Set(ids)
|
|
41
|
+
return existing.filter { !dropped.contains($0) }
|
|
42
|
+
case .insert(let ids):
|
|
43
|
+
return existing + ids
|
|
44
|
+
case .moveToEnd(let ids):
|
|
45
|
+
let moved = Set(ids)
|
|
46
|
+
return existing.filter { !moved.contains($0) } + ids
|
|
47
|
+
case .moveBefore(let ids, let anchor):
|
|
48
|
+
let moved = Set(ids)
|
|
49
|
+
var out: [String] = []
|
|
50
|
+
out.reserveCapacity(existing.count)
|
|
51
|
+
for id in existing where !moved.contains(id) {
|
|
52
|
+
if id == anchor { out.append(contentsOf: ids) }
|
|
53
|
+
out.append(id)
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|