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
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
android:name="android.hardware.audio.output"
|
|
19
19
|
android:required="false" />
|
|
20
20
|
|
|
21
|
+
<!-- Visibility of Android Auto's connection-state provider; without it the car never reads as connected -->
|
|
22
|
+
<queries>
|
|
23
|
+
<provider
|
|
24
|
+
android:name="androidx.car.app.connection.provider"
|
|
25
|
+
android:authorities="androidx.car.app.connection"
|
|
26
|
+
tools:ignore="MissingClass" />
|
|
27
|
+
</queries>
|
|
28
|
+
|
|
21
29
|
<application>
|
|
22
30
|
<!-- Google Cast: supplies CastOptions (receiver app ID) to the Cast SDK.
|
|
23
31
|
Apps shipping their own OptionsProvider must override with tools:replace. -->
|
package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer
|
|
2
2
|
|
|
3
3
|
import androidx.annotation.Keep
|
|
4
|
+
import androidx.media3.common.util.UnstableApi
|
|
5
|
+
import androidx.media3.session.CommandButton
|
|
4
6
|
import com.facebook.proguard.annotations.DoNotStrip
|
|
5
7
|
import com.margelo.nitro.NitroModules
|
|
6
8
|
import com.margelo.nitro.core.Promise
|
|
7
9
|
import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
|
|
8
10
|
import com.margelo.nitro.nitroplayer.core.clearAndroidAutoMediaLibrary
|
|
9
11
|
import com.margelo.nitro.nitroplayer.core.setAndroidAutoMediaLibrary
|
|
12
|
+
import com.margelo.nitro.nitroplayer.media.MediaSessionManager
|
|
13
|
+
import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
|
|
14
|
+
import org.json.JSONArray
|
|
10
15
|
|
|
11
16
|
@DoNotStrip
|
|
12
17
|
@Keep
|
|
18
|
+
@UnstableApi
|
|
13
19
|
class HybridAndroidAutoMediaLibrary : HybridAndroidAutoMediaLibrarySpec() {
|
|
14
20
|
private val core: TrackPlayerCore
|
|
15
21
|
|
|
@@ -23,4 +29,49 @@ class HybridAndroidAutoMediaLibrary : HybridAndroidAutoMediaLibrarySpec() {
|
|
|
23
29
|
override fun setMediaLibrary(libraryJson: String): Promise<Unit> = Promise.async { core.setAndroidAutoMediaLibrary(libraryJson) }
|
|
24
30
|
|
|
25
31
|
override fun clearMediaLibrary(): Promise<Unit> = Promise.async { core.clearAndroidAutoMediaLibrary() }
|
|
32
|
+
|
|
33
|
+
override fun onLoadChildren(callback: (requestId: String, parentId: String) -> Unit) {
|
|
34
|
+
core.mediaLibraryManager.childrenRequester = callback
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override fun onSearch(callback: (requestId: String, query: String) -> Unit) {
|
|
38
|
+
core.mediaLibraryManager.searchRequester = callback
|
|
39
|
+
NitroPlayerMediaBrowserService.setSearchSupported(core.context, true)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override fun resolveRequest(
|
|
43
|
+
requestId: String,
|
|
44
|
+
itemsJson: String,
|
|
45
|
+
) = core.mediaLibraryManager.resolve(requestId, itemsJson)
|
|
46
|
+
|
|
47
|
+
override fun setSessionButtons(buttonsJson: String) {
|
|
48
|
+
val array = JSONArray(buttonsJson)
|
|
49
|
+
val buttons =
|
|
50
|
+
(0 until array.length()).mapNotNull { i ->
|
|
51
|
+
val button = array.getJSONObject(i)
|
|
52
|
+
val icon = ICONS[button.optString("icon")] ?: return@mapNotNull null
|
|
53
|
+
MediaSessionManager.SessionButton(button.getString("action"), button.optString("title"), icon)
|
|
54
|
+
}
|
|
55
|
+
core.enqueue { core.mediaSessionManager?.setSessionButtons(buttons) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override fun onSessionButtonPress(callback: (action: String) -> Unit) {
|
|
59
|
+
core.enqueue { core.mediaSessionManager?.onSessionButtonPress = callback }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun onPlaybackResumption(callback: () -> Unit) = core.mediaLibraryManager.setResumptionRequester(callback)
|
|
63
|
+
|
|
64
|
+
private companion object {
|
|
65
|
+
val ICONS =
|
|
66
|
+
mapOf(
|
|
67
|
+
"heart" to CommandButton.ICON_HEART_UNFILLED,
|
|
68
|
+
"heart_filled" to CommandButton.ICON_HEART_FILLED,
|
|
69
|
+
"star" to CommandButton.ICON_STAR_UNFILLED,
|
|
70
|
+
"star_filled" to CommandButton.ICON_STAR_FILLED,
|
|
71
|
+
"thumb_up" to CommandButton.ICON_THUMB_UP_UNFILLED,
|
|
72
|
+
"thumb_up_filled" to CommandButton.ICON_THUMB_UP_FILLED,
|
|
73
|
+
"plus" to CommandButton.ICON_PLUS,
|
|
74
|
+
"check" to CommandButton.ICON_CHECK_CIRCLE_FILLED,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
26
77
|
}
|
|
@@ -59,9 +59,9 @@ class AndroidAutoConnectionDetector(
|
|
|
59
59
|
try {
|
|
60
60
|
val filter = IntentFilter(ACTION_CAR_CONNECTION_UPDATED)
|
|
61
61
|
|
|
62
|
-
//
|
|
62
|
+
// Exported: the broadcast comes from the Android Auto app, a NOT_EXPORTED receiver never gets it
|
|
63
63
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
64
|
-
context.registerReceiver(carConnectionReceiver, filter, Context.
|
|
64
|
+
context.registerReceiver(carConnectionReceiver, filter, Context.RECEIVER_EXPORTED)
|
|
65
65
|
} else {
|
|
66
66
|
context.registerReceiver(carConnectionReceiver, filter)
|
|
67
67
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.core
|
|
2
2
|
|
|
3
|
+
import android.net.Uri
|
|
3
4
|
import androidx.media3.common.MediaItem
|
|
4
5
|
import androidx.media3.common.Player
|
|
5
6
|
|
|
@@ -42,7 +43,18 @@ class ExoPlayerCore(
|
|
|
42
43
|
fun getPlaybackSpeed(): Float = player.playbackParameters.speed
|
|
43
44
|
|
|
44
45
|
// ── Queue mutations ────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
+
// A lazy track's empty URI fails as "Source error"; stay idle until updateTracks supplies it
|
|
47
|
+
fun prepare() {
|
|
48
|
+
if (isLazyCurrent()) return
|
|
49
|
+
player.prepare()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A prepared player loads a new current item by itself, so idle it while that item still has no URI
|
|
53
|
+
fun holdLazyCurrent() {
|
|
54
|
+
if (isLazyCurrent() && player.playbackState != Player.STATE_IDLE) player.stop()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private fun isLazyCurrent(): Boolean = player.currentMediaItem?.localConfiguration?.uri == Uri.EMPTY
|
|
46
58
|
|
|
47
59
|
fun seekToDefaultPosition(windowIndex: Int) = player.seekToDefaultPosition(windowIndex)
|
|
48
60
|
|
|
@@ -51,7 +63,10 @@ class ExoPlayerCore(
|
|
|
51
63
|
fun setMediaItems(
|
|
52
64
|
items: List<MediaItem>,
|
|
53
65
|
resetPosition: Boolean = false,
|
|
54
|
-
)
|
|
66
|
+
) {
|
|
67
|
+
player.setMediaItems(items, resetPosition)
|
|
68
|
+
holdLazyCurrent()
|
|
69
|
+
}
|
|
55
70
|
|
|
56
71
|
fun addMediaItems(items: List<MediaItem>) = player.addMediaItems(items)
|
|
57
72
|
|
|
@@ -4,8 +4,8 @@ import android.util.Log
|
|
|
4
4
|
import com.margelo.nitro.nitroplayer.BuildConfig
|
|
5
5
|
|
|
6
6
|
object NitroPlayerLogger {
|
|
7
|
-
//
|
|
8
|
-
val isEnabled: Boolean = BuildConfig.DEBUG
|
|
7
|
+
// Release builds log after `adb shell setprop log.tag.NitroPlayer DEBUG` and an app restart
|
|
8
|
+
val isEnabled: Boolean = BuildConfig.DEBUG || Log.isLoggable("NitroPlayer", Log.DEBUG)
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Preferred overload: message lambda is only evaluated when logging is enabled.
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
package com.margelo.nitro.nitroplayer.core
|
|
4
4
|
|
|
5
5
|
import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
|
|
6
|
+
import com.margelo.nitro.nitroplayer.media.MediaItem
|
|
6
7
|
import com.margelo.nitro.nitroplayer.media.MediaLibraryParser
|
|
8
|
+
import com.margelo.nitro.nitroplayer.media.MediaType
|
|
7
9
|
import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -13,56 +15,64 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
|
|
|
13
15
|
|
|
14
16
|
/** Called on the main thread from TrackPlayerCore.init via handler.post. */
|
|
15
17
|
internal fun TrackPlayerCore.setupAndroidAutoDetector() {
|
|
18
|
+
// The car may have bound the browser before this core existed (cold start from the car)
|
|
19
|
+
if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) setAndroidAutoConnected(true)
|
|
16
20
|
androidAutoConnectionDetector =
|
|
17
21
|
AndroidAutoConnectionDetector(context).apply {
|
|
18
22
|
onConnectionChanged = { connected, _ ->
|
|
19
|
-
handler.post {
|
|
20
|
-
isAndroidAutoConnectedField = connected
|
|
21
|
-
NitroPlayerMediaBrowserService.isAndroidAutoConnected = connected
|
|
22
|
-
notifyAndroidAutoConnection(connected)
|
|
23
|
-
}
|
|
23
|
+
handler.post { setAndroidAutoConnected(connected) }
|
|
24
24
|
}
|
|
25
25
|
registerCarConnectionReceiver()
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
/** Android Auto binding the browser is proof of a connection, whatever the provider query said. */
|
|
30
|
+
internal fun TrackPlayerCore.onAndroidAutoBrowserConnected() {
|
|
31
|
+
handler.post { setAndroidAutoConnected(true) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private fun TrackPlayerCore.setAndroidAutoConnected(connected: Boolean) {
|
|
35
|
+
NitroPlayerMediaBrowserService.isAndroidAutoConnected = connected
|
|
36
|
+
if (isAndroidAutoConnectedField == connected) return
|
|
37
|
+
isAndroidAutoConnectedField = connected
|
|
38
|
+
updateJsKeepAlive()
|
|
39
|
+
notifyAndroidAutoConnection(connected)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Routes a controller's "playlistId:trackId" pick through the logical queue (window + lazy URLs); false for unknown ids. */
|
|
43
|
+
internal fun TrackPlayerCore.playFromMediaId(mediaId: String): Boolean {
|
|
44
|
+
val colonIndex = mediaId.indexOf(':')
|
|
45
|
+
if (colonIndex <= 0) return false
|
|
46
|
+
val playlistId = mediaId.substring(0, colonIndex)
|
|
47
|
+
val trackRef = mediaId.substring(colonIndex + 1)
|
|
48
|
+
if (playlistManager.indexOfTrackRef(playlistId, trackRef) < 0) return false
|
|
49
|
+
startJsRuntimeIfNeeded()
|
|
50
|
+
enqueue {
|
|
51
|
+
val index = playlistManager.indexOfTrackRef(playlistId, trackRef)
|
|
52
|
+
// Resuming what is already loaded keeps its position; a failed item is reloaded instead
|
|
53
|
+
if (currentPlaylistId == playlistId && currentTrackIndex == index && exo.player.playerError == null) {
|
|
54
|
+
// Tapping a song still waiting for its URL asks the app for it again
|
|
55
|
+
if (getCurrentTrack()?.url.isNullOrEmpty()) checkUpcomingTracksForUrls(lookaheadCount, force = true)
|
|
56
|
+
return@enqueue
|
|
45
57
|
}
|
|
58
|
+
if (index >= 0 && playlistManager.loadPlaylist(playlistId, index)) loadPlaylistOnQueue(playlistId, index)
|
|
46
59
|
}
|
|
47
|
-
|
|
48
|
-
private fun TrackPlayerCore.loadPlaylistInternal(playlistId: String) {
|
|
49
|
-
playNextStack.clear()
|
|
50
|
-
upNextQueue.clear()
|
|
51
|
-
currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
|
|
52
|
-
val playlist = playlistManager.getPlaylist(playlistId) ?: return
|
|
53
|
-
currentPlaylistId = playlistId
|
|
54
|
-
updatePlayerQueue(playlist.tracks)
|
|
60
|
+
return true
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
private fun folderIds(items: List<MediaItem>?): List<String> = items.orEmpty().filter { it.mediaType == MediaType.FOLDER }.flatMap { listOf(it.id) + folderIds(it.children) }
|
|
64
|
+
|
|
57
65
|
suspend fun TrackPlayerCore.setAndroidAutoMediaLibrary(libraryJson: String) =
|
|
58
66
|
withPlayerContext {
|
|
59
67
|
val library = MediaLibraryParser.fromJson(libraryJson)
|
|
68
|
+
val previous = folderIds(mediaLibraryManager.getMediaLibrary()?.rootItems)
|
|
60
69
|
mediaLibraryManager.setMediaLibrary(library)
|
|
61
|
-
NitroPlayerMediaBrowserService.getInstance()?.
|
|
70
|
+
NitroPlayerMediaBrowserService.getInstance()?.onLibraryUpdated(previous + folderIds(library.rootItems) + mediaLibraryManager.loadedFolderIds)
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
suspend fun TrackPlayerCore.clearAndroidAutoMediaLibrary() =
|
|
65
74
|
withPlayerContext {
|
|
75
|
+
val previous = folderIds(mediaLibraryManager.getMediaLibrary()?.rootItems)
|
|
66
76
|
mediaLibraryManager.clear()
|
|
67
|
-
NitroPlayerMediaBrowserService.getInstance()?.
|
|
77
|
+
NitroPlayerMediaBrowserService.getInstance()?.onLibraryUpdated(previous + mediaLibraryManager.loadedFolderIds)
|
|
68
78
|
}
|
|
@@ -88,7 +88,7 @@ internal fun TrackPlayerCore.switchToLocalPlayer() {
|
|
|
88
88
|
lastCastWaitTrackId = null
|
|
89
89
|
exo = ExoPlayerCore(controller.localPlayer)
|
|
90
90
|
playerListener?.let { controller.localPlayer.addListener(it) }
|
|
91
|
-
setMediaSessionPlayer(controller.
|
|
91
|
+
setMediaSessionPlayer(controller.localSessionPlayer)
|
|
92
92
|
|
|
93
93
|
val localList = ArrayList<TrackItem>()
|
|
94
94
|
currentTrack?.let { localList.add(it) }
|
|
@@ -12,6 +12,11 @@ import android.os.Bundle
|
|
|
12
12
|
import android.os.Handler
|
|
13
13
|
import android.os.IBinder
|
|
14
14
|
import android.os.Looper
|
|
15
|
+
import com.facebook.react.ReactApplication
|
|
16
|
+
import com.facebook.react.bridge.Arguments
|
|
17
|
+
import com.facebook.react.bridge.ReactContext
|
|
18
|
+
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
|
19
|
+
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
|
15
20
|
import com.margelo.nitro.NitroModules
|
|
16
21
|
import com.margelo.nitro.nitroplayer.Reason
|
|
17
22
|
import com.margelo.nitro.nitroplayer.RepeatMode
|
|
@@ -83,6 +88,9 @@ class TrackPlayerCore private constructor(
|
|
|
83
88
|
internal var remoteSkipForwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
84
89
|
internal var remoteSkipBackwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
|
|
85
90
|
internal var playerListener: androidx.media3.common.Player.Listener? = null
|
|
91
|
+
internal var consecutiveSourceErrors = 0
|
|
92
|
+
internal val urlRequestedAt = HashMap<String, Long>()
|
|
93
|
+
internal var networkRetryCallback: android.net.ConnectivityManager.NetworkCallback? = null
|
|
86
94
|
|
|
87
95
|
// ── Temporary queue ────────────────────────────────────────────────────
|
|
88
96
|
internal var playNextStack: MutableList<TrackItem> = mutableListOf()
|
|
@@ -161,7 +169,10 @@ class TrackPlayerCore private constructor(
|
|
|
161
169
|
val anchor = old.getOrNull(currentTrackIndex)
|
|
162
170
|
currentTracks = next
|
|
163
171
|
if (anchor == null) return AnchorOutcome.NONE
|
|
164
|
-
|
|
172
|
+
// A repeated track keeps its rank among the occurrences, so editing elsewhere does not jump to another copy
|
|
173
|
+
val rank = old.subList(0, currentTrackIndex).count { it.id == anchor.id }
|
|
174
|
+
val occurrences = next.indices.filter { next[it].id == anchor.id }
|
|
175
|
+
val newIndex = occurrences.getOrElse(rank) { occurrences.lastOrNull() ?: -1 }
|
|
165
176
|
if (newIndex >= 0) {
|
|
166
177
|
currentTrackIndex = newIndex
|
|
167
178
|
return AnchorOutcome.KEPT
|
|
@@ -251,6 +262,7 @@ class TrackPlayerCore private constructor(
|
|
|
251
262
|
// ── Singleton ──────────────────────────────────────────────────────────
|
|
252
263
|
companion object {
|
|
253
264
|
internal const val PROGRESS_INTERVAL_MS = 1000L
|
|
265
|
+
private const val KEEP_ALIVE_TASK = "NitroPlayerKeepAlive"
|
|
254
266
|
|
|
255
267
|
@Volatile
|
|
256
268
|
@Suppress("ktlint:standard:property-naming")
|
|
@@ -393,6 +405,34 @@ class TrackPlayerCore private constructor(
|
|
|
393
405
|
}
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
/** Lazy URLs come from JS; a process started by the car or a controller has no runtime until started. */
|
|
409
|
+
internal fun startJsRuntimeIfNeeded() {
|
|
410
|
+
val host = (context as? ReactApplication)?.reactHost ?: return
|
|
411
|
+
if (host.currentReactContext == null) host.start()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private var keepAliveTask: Pair<ReactContext, Int>? = null
|
|
415
|
+
|
|
416
|
+
/** Without a resumed activity (car, screen off) RN pauses JS timers; a headless task keeps them running while JS may be needed. */
|
|
417
|
+
internal fun updateJsKeepAlive() {
|
|
418
|
+
val wanted = isAndroidAutoConnectedField || (isExoInitialized && exo.playWhenReady)
|
|
419
|
+
val reactContext = (context as? ReactApplication)?.reactHost?.currentReactContext
|
|
420
|
+
keepAliveTask?.let { (taskContext, taskId) ->
|
|
421
|
+
val tasks = HeadlessJsTaskContext.getInstance(taskContext)
|
|
422
|
+
if (wanted && taskContext === reactContext && tasks.isTaskRunning(taskId)) return
|
|
423
|
+
if (tasks.isTaskRunning(taskId)) tasks.finishTask(taskId)
|
|
424
|
+
keepAliveTask = null
|
|
425
|
+
}
|
|
426
|
+
if (!wanted || reactContext == null || !reactContext.hasActiveReactInstance()) return
|
|
427
|
+
try {
|
|
428
|
+
val config = HeadlessJsTaskConfig(KEEP_ALIVE_TASK, Arguments.createMap(), 0, true)
|
|
429
|
+
keepAliveTask = reactContext to HeadlessJsTaskContext.getInstance(reactContext).startTask(config)
|
|
430
|
+
NitroPlayerLogger.log("TrackPlayerCore") { "keep-alive task started: ${keepAliveTask?.second}" }
|
|
431
|
+
} catch (e: Exception) {
|
|
432
|
+
NitroPlayerLogger.log("TrackPlayerCore") { "keep-alive task not started: ${e.message}" }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
396
436
|
// ── Simple read-only accessors ─────────────────────────────────────────
|
|
397
437
|
|
|
398
438
|
fun isAndroidAutoConnected(): Boolean = isAndroidAutoConnectedField
|
|
@@ -421,7 +461,12 @@ class TrackPlayerCore private constructor(
|
|
|
421
461
|
|
|
422
462
|
fun removeOnPlaybackProgressChangeListener(id: Long): Boolean = onProgressListeners.remove(id)
|
|
423
463
|
|
|
424
|
-
fun addOnTracksNeedUpdateListener(cb: (List<TrackItem>, Int) -> Unit): Long
|
|
464
|
+
fun addOnTracksNeedUpdateListener(cb: (List<TrackItem>, Int) -> Unit): Long {
|
|
465
|
+
val id = onTracksNeedUpdateListeners.add(cb)
|
|
466
|
+
// Requests raised before JS was listening (headless start) are otherwise lost
|
|
467
|
+
enqueue { checkUpcomingTracksForUrls(lookaheadCount, force = true) }
|
|
468
|
+
return id
|
|
469
|
+
}
|
|
425
470
|
|
|
426
471
|
fun removeOnTracksNeedUpdateListener(id: Long): Boolean = onTracksNeedUpdateListeners.remove(id)
|
|
427
472
|
|
|
@@ -31,6 +31,7 @@ internal class TrackPlayerEventListener(
|
|
|
31
31
|
with(core) {
|
|
32
32
|
// TRACK repeat: REPEAT_MODE_ONE fires this every loop — not a real track change
|
|
33
33
|
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) return
|
|
34
|
+
exo.holdLazyCurrent()
|
|
34
35
|
|
|
35
36
|
// Remove the track that just finished/was skipped from temp lists
|
|
36
37
|
if ((
|
|
@@ -61,7 +62,9 @@ internal class TrackPlayerEventListener(
|
|
|
61
62
|
val previousTrackIndex = currentTrackIndex
|
|
62
63
|
if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && mediaItem != null) {
|
|
63
64
|
val trackId = extractTrackId(mediaItem.mediaId)
|
|
64
|
-
|
|
65
|
+
// Moving through the timeline lands on the next occurrence of a repeated track, not its first
|
|
66
|
+
val advancing = reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
|
|
67
|
+
val newIdx = currentTrackIndexOf(trackId, if (advancing) currentTrackIndex + 1 else currentTrackIndex)
|
|
65
68
|
if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
|
|
66
69
|
}
|
|
67
70
|
// A transient play-out ends here: restore the user's repeat mode.
|
|
@@ -137,6 +140,7 @@ internal class TrackPlayerEventListener(
|
|
|
137
140
|
reason: Int,
|
|
138
141
|
) {
|
|
139
142
|
val r = if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) Reason.USER_ACTION else null
|
|
143
|
+
core.updateJsKeepAlive()
|
|
140
144
|
core.emitStateChange(r)
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -156,7 +160,10 @@ internal class TrackPlayerEventListener(
|
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
159
|
-
if (isPlaying)
|
|
163
|
+
if (isPlaying) {
|
|
164
|
+
core.startProgressTicks()
|
|
165
|
+
core.onPlaybackHealthy()
|
|
166
|
+
}
|
|
160
167
|
core.emitStateChange()
|
|
161
168
|
}
|
|
162
169
|
|
|
@@ -173,21 +180,7 @@ internal class TrackPlayerEventListener(
|
|
|
173
180
|
}
|
|
174
181
|
}
|
|
175
182
|
|
|
176
|
-
override fun onPlayerError(error: PlaybackException)
|
|
177
|
-
with(core) {
|
|
178
|
-
NitroPlayerLogger.log(
|
|
179
|
-
"TrackPlayer",
|
|
180
|
-
"Player error: ${error.errorCodeName} - ${error.message}",
|
|
181
|
-
)
|
|
182
|
-
notifyPlaybackStateChange(TrackPlayerState.STOPPED, Reason.ERROR)
|
|
183
|
-
// A source error leaves ExoPlayer in IDLE; skip the dead item and
|
|
184
|
-
// re-prepare so the rest of the queue keeps playing.
|
|
185
|
-
if (exo.hasNextMediaItem()) {
|
|
186
|
-
exo.seekToNext()
|
|
187
|
-
exo.prepare()
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
183
|
+
override fun onPlayerError(error: PlaybackException) = core.handlePlayerError(error)
|
|
191
184
|
|
|
192
185
|
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
|
193
186
|
if (audioSessionId != 0) {
|
|
@@ -43,7 +43,12 @@ internal fun TrackPlayerCore.notifyTracksNeedUpdate(
|
|
|
43
43
|
tracks: List<TrackItem>,
|
|
44
44
|
lookahead: Int,
|
|
45
45
|
) {
|
|
46
|
+
NitroPlayerLogger.log("TrackPlayer") { "tracksNeedUpdate: ${tracks.size} track(s), listening=${!onTracksNeedUpdateListeners.isEmpty}" }
|
|
47
|
+
startJsRuntimeIfNeeded()
|
|
48
|
+
updateJsKeepAlive()
|
|
46
49
|
onTracksNeedUpdateListeners.forEach { it(tracks, lookahead) }
|
|
50
|
+
// A lookup that hits a dead network usually fails in the app; ask again once it is back
|
|
51
|
+
awaitNetworkThenRetry(afterLoss = isNetworkValidated())
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
internal fun TrackPlayerCore.notifyTimedMetadata(metadata: TimedMetadata) {
|
|
@@ -123,8 +123,10 @@ internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
|
|
|
123
123
|
suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
|
|
124
124
|
|
|
125
125
|
internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
|
|
126
|
+
// JS is calling, so the keep-alive task is registered: start it now if a headless start needs it
|
|
127
|
+
updateJsKeepAlive()
|
|
126
128
|
config.androidAudioFocus?.let { applyAudioFocusMode(it) }
|
|
127
|
-
config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.
|
|
129
|
+
config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.setAndroidAutoEnabled(context, it) }
|
|
128
130
|
config.lookaheadCount?.let { lookaheadCount = it.toInt() }
|
|
129
131
|
config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
|
|
130
132
|
remoteSkipIntervalMs(config.remoteSkipForwardInterval)?.let { remoteSkipForwardIntervalMs = it }
|
|
@@ -46,6 +46,20 @@ internal fun TrackPlayerCore.castableUpcoming(tracks: List<TrackItem>): List<Tra
|
|
|
46
46
|
*/
|
|
47
47
|
internal const val QUEUE_WINDOW_SIZE = 4
|
|
48
48
|
|
|
49
|
+
/** Index of [trackId] in currentTracks; a repeated track resolves to its first occurrence at or after [from], wrapping. */
|
|
50
|
+
internal fun TrackPlayerCore.currentTrackIndexOf(
|
|
51
|
+
trackId: String,
|
|
52
|
+
from: Int,
|
|
53
|
+
): Int {
|
|
54
|
+
val size = currentTracks.size
|
|
55
|
+
val start = if (from in 0 until size) from else 0
|
|
56
|
+
for (k in 0 until size) {
|
|
57
|
+
val i = (start + k) % size
|
|
58
|
+
if (currentTracks[i].id == trackId) return i
|
|
59
|
+
}
|
|
60
|
+
return -1
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
internal fun TrackPlayerCore.mediaIdFor(track: TrackItem): String {
|
|
50
64
|
val playlistId = currentPlaylistId ?: ""
|
|
51
65
|
return if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
|
|
@@ -118,7 +132,7 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
118
132
|
// Keep the logical playlist pointer in sync after playlist mutations.
|
|
119
133
|
// Without this, getActualQueue/getState can report a stale index until the next track transition.
|
|
120
134
|
if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && currentTrackId != null) {
|
|
121
|
-
val resolvedIndex =
|
|
135
|
+
val resolvedIndex = currentTrackIndexOf(currentTrackId, currentTrackIndex)
|
|
122
136
|
if (resolvedIndex >= 0) {
|
|
123
137
|
currentTrackIndex = resolvedIndex
|
|
124
138
|
}
|
|
@@ -268,6 +282,7 @@ internal fun TrackPlayerCore.makeMediaItem(
|
|
|
268
282
|
.setTitle(track.title)
|
|
269
283
|
.setArtist(track.artist)
|
|
270
284
|
.setAlbumTitle(track.album)
|
|
285
|
+
if (track.duration > 0) metaBuilder.setDurationMs((track.duration * 1000).toLong())
|
|
271
286
|
|
|
272
287
|
track.artwork?.asSecondOrNull()?.let { artworkUrl ->
|
|
273
288
|
try {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
@file:Suppress("ktlint:standard:max-line-length")
|
|
2
|
+
|
|
3
|
+
package com.margelo.nitro.nitroplayer.core
|
|
4
|
+
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.net.ConnectivityManager
|
|
7
|
+
import android.net.Network
|
|
8
|
+
import android.net.NetworkCapabilities
|
|
9
|
+
import androidx.media3.common.PlaybackException
|
|
10
|
+
import androidx.media3.common.Player
|
|
11
|
+
import com.margelo.nitro.nitroplayer.Reason
|
|
12
|
+
import com.margelo.nitro.nitroplayer.TrackPlayerState
|
|
13
|
+
|
|
14
|
+
// Lost network waits for connectivity and retries the same item; bad sources skip, capped so an unplayable queue stops
|
|
15
|
+
|
|
16
|
+
internal const val MAX_CONSECUTIVE_SOURCE_ERRORS = 3
|
|
17
|
+
|
|
18
|
+
internal fun TrackPlayerCore.handlePlayerError(error: PlaybackException) {
|
|
19
|
+
NitroPlayerLogger.log("TrackPlayer") { "Player error: ${error.errorCodeName} - ${error.message}" }
|
|
20
|
+
// Still waiting for its URL: updateTracks re-prepares it, skipping would cascade through the window
|
|
21
|
+
if (getCurrentTrack()?.url?.isEmpty() == true) {
|
|
22
|
+
checkUpcomingTracksForUrls(lookaheadCount, force = true)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
notifyPlaybackStateChange(TrackPlayerState.STOPPED, Reason.ERROR)
|
|
26
|
+
if (error.isConnectivityError() && !isNetworkValidated()) {
|
|
27
|
+
awaitNetworkThenRetry()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
consecutiveSourceErrors++
|
|
31
|
+
if (consecutiveSourceErrors >= MAX_CONSECUTIVE_SOURCE_ERRORS) return
|
|
32
|
+
// A source error leaves ExoPlayer in IDLE; skip the dead item and re-prepare so the rest of the queue keeps playing
|
|
33
|
+
if (!exo.hasNextMediaItem()) rebuildQueueFromCurrentPosition()
|
|
34
|
+
if (exo.hasNextMediaItem()) {
|
|
35
|
+
exo.seekToNext()
|
|
36
|
+
exo.prepare()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Called when playback actually starts, so earlier failures stop counting against the queue. */
|
|
41
|
+
internal fun TrackPlayerCore.onPlaybackHealthy() {
|
|
42
|
+
consecutiveSourceErrors = 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private fun PlaybackException.isConnectivityError(): Boolean =
|
|
46
|
+
errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
|
|
47
|
+
errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT
|
|
48
|
+
|
|
49
|
+
private fun TrackPlayerCore.connectivity(): ConnectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
50
|
+
|
|
51
|
+
internal fun TrackPlayerCore.isNetworkValidated(): Boolean {
|
|
52
|
+
val manager = connectivity()
|
|
53
|
+
val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
|
|
54
|
+
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Retries once the network is back; [afterLoss] first waits for it to drop, for work started while it was up. */
|
|
58
|
+
internal fun TrackPlayerCore.awaitNetworkThenRetry(afterLoss: Boolean = false) {
|
|
59
|
+
if (networkRetryCallback != null) return
|
|
60
|
+
val callback =
|
|
61
|
+
object : ConnectivityManager.NetworkCallback() {
|
|
62
|
+
private var lost = !afterLoss
|
|
63
|
+
private var seen: Network? = null
|
|
64
|
+
|
|
65
|
+
override fun onLost(network: Network) {
|
|
66
|
+
lost = true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override fun onCapabilitiesChanged(
|
|
70
|
+
network: Network,
|
|
71
|
+
capabilities: NetworkCapabilities,
|
|
72
|
+
) {
|
|
73
|
+
// Switching networks (Wi-Fi to mobile) breaks requests in flight just like a drop
|
|
74
|
+
if (seen != null && seen != network) lost = true
|
|
75
|
+
seen = network
|
|
76
|
+
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
|
|
77
|
+
lost = true
|
|
78
|
+
} else if (lost) {
|
|
79
|
+
enqueue { retryAfterNetwork(this) }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
networkRetryCallback = callback
|
|
84
|
+
connectivity().registerDefaultNetworkCallback(callback)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private fun TrackPlayerCore.retryAfterNetwork(callback: ConnectivityManager.NetworkCallback) {
|
|
88
|
+
if (networkRetryCallback !== callback) return
|
|
89
|
+
networkRetryCallback = null
|
|
90
|
+
try {
|
|
91
|
+
connectivity().unregisterNetworkCallback(callback)
|
|
92
|
+
} catch (_: IllegalArgumentException) {
|
|
93
|
+
}
|
|
94
|
+
// URLs the app could not fetch offline are asked for again; a failed item is retried only if nothing replaced it
|
|
95
|
+
checkUpcomingTracksForUrls(lookaheadCount, force = true)
|
|
96
|
+
if (exo.playbackState == Player.STATE_IDLE && exo.player.playerError != null) exo.prepare()
|
|
97
|
+
}
|
|
@@ -2,7 +2,6 @@ package com.margelo.nitro.nitroplayer.core
|
|
|
2
2
|
|
|
3
3
|
import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
|
|
4
4
|
import com.margelo.nitro.nitroplayer.media.MediaSessionManager
|
|
5
|
-
import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
|
|
6
5
|
import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -19,10 +18,6 @@ internal fun TrackPlayerCore.initFromService(binder: NitroPlayerPlaybackService.
|
|
|
19
18
|
setTrackPlayerCore(this@initFromService)
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
// Give MediaBrowserService access to this core and media session
|
|
23
|
-
NitroPlayerMediaBrowserService.trackPlayerCore = this
|
|
24
|
-
NitroPlayerMediaBrowserService.mediaSessionManager = mediaSessionManager
|
|
25
|
-
|
|
26
21
|
// Attach player listener
|
|
27
22
|
val listener = TrackPlayerEventListener(this)
|
|
28
23
|
playerListener = listener
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
package com.margelo.nitro.nitroplayer.core
|
|
4
4
|
|
|
5
|
+
import android.os.SystemClock
|
|
5
6
|
import androidx.media3.common.Player
|
|
6
7
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
7
8
|
|
|
@@ -37,6 +38,7 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
if (safeTracks.isEmpty()) return@run
|
|
41
|
+
safeTracks.forEach { urlRequestedAt.remove(it.id) }
|
|
40
42
|
|
|
41
43
|
val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
|
|
42
44
|
|
|
@@ -133,7 +135,13 @@ suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { cu
|
|
|
133
135
|
|
|
134
136
|
// ── URL lookahead ─────────────────────────────────────────────────────────
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
// One pick fires several queue events within a few hundred ms; the app is asked once for that burst
|
|
139
|
+
private const val URL_REQUEST_COALESCE_MS = 500L
|
|
140
|
+
|
|
141
|
+
internal fun TrackPlayerCore.checkUpcomingTracksForUrls(
|
|
142
|
+
lookahead: Int = 5,
|
|
143
|
+
force: Boolean = false,
|
|
144
|
+
) {
|
|
137
145
|
val upcomingTracks =
|
|
138
146
|
if (currentTrackIndex < 0) {
|
|
139
147
|
currentTracks.take(lookahead)
|
|
@@ -144,5 +152,10 @@ internal fun TrackPlayerCore.checkUpcomingTracksForUrls(lookahead: Int = 5) {
|
|
|
144
152
|
val currentNeedsUrl = currentTrack != null && currentTrack.url.isEmpty()
|
|
145
153
|
val candidates = if (currentNeedsUrl) listOf(currentTrack!!) + upcomingTracks else upcomingTracks
|
|
146
154
|
val needUrls = candidates.filter { it.url.isEmpty() }
|
|
147
|
-
|
|
155
|
+
val now = SystemClock.elapsedRealtime()
|
|
156
|
+
val ask = if (force) needUrls else needUrls.filter { track -> urlRequestedAt[track.id]?.let { now - it < URL_REQUEST_COALESCE_MS } != true }
|
|
157
|
+
if (ask.isEmpty()) return
|
|
158
|
+
if (urlRequestedAt.size > 1000) urlRequestedAt.clear()
|
|
159
|
+
ask.forEach { urlRequestedAt[it.id] = now }
|
|
160
|
+
notifyTracksNeedUpdate(ask, lookahead)
|
|
148
161
|
}
|