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
|
@@ -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) {
|
|
@@ -20,11 +20,17 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
|
|
|
20
20
|
|
|
21
21
|
suspend fun TrackPlayerCore.play() = withPlayerContext { playOnQueue() }
|
|
22
22
|
|
|
23
|
-
internal fun TrackPlayerCore.playOnQueue()
|
|
23
|
+
internal fun TrackPlayerCore.playOnQueue() {
|
|
24
|
+
audioFocusController?.acquire()
|
|
25
|
+
exo.play()
|
|
26
|
+
}
|
|
24
27
|
|
|
25
28
|
suspend fun TrackPlayerCore.pause() = withPlayerContext { pauseOnQueue() }
|
|
26
29
|
|
|
27
|
-
internal fun TrackPlayerCore.pauseOnQueue()
|
|
30
|
+
internal fun TrackPlayerCore.pauseOnQueue() {
|
|
31
|
+
exo.pause()
|
|
32
|
+
audioFocusController?.abandon()
|
|
33
|
+
}
|
|
28
34
|
|
|
29
35
|
suspend fun TrackPlayerCore.seek(position: Double) = withPlayerContext { seekOnQueue(position) }
|
|
30
36
|
|
|
@@ -55,9 +61,8 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
|
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
currentTemporaryType != TrackPlayerCore.TemporaryType.NONE -> {
|
|
58
|
-
// playFromIndexInternal clears both temp lists (
|
|
59
|
-
|
|
60
|
-
playFromIndexInternal(currentTrackIndex)
|
|
64
|
+
// playFromIndexInternal clears both temp lists; cursor may be -1 (anchor removed).
|
|
65
|
+
if (currentTracks.isEmpty()) exo.seekTo(0) else playFromIndexInternal(maxOf(0, currentTrackIndex))
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
currentTrackIndex > 0 -> {
|
|
@@ -76,12 +81,7 @@ suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext
|
|
|
76
81
|
internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
77
82
|
val previousMode = currentRepeatMode
|
|
78
83
|
currentRepeatMode = mode
|
|
79
|
-
|
|
80
|
-
when (mode) {
|
|
81
|
-
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
|
82
|
-
else -> Player.REPEAT_MODE_OFF
|
|
83
|
-
},
|
|
84
|
-
)
|
|
84
|
+
applyBackendRepeatMode()
|
|
85
85
|
if (!isCastingField && previousMode != mode &&
|
|
86
86
|
(previousMode == RepeatMode.PLAYLIST || mode == RepeatMode.PLAYLIST)
|
|
87
87
|
) {
|
|
@@ -89,19 +89,44 @@ internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** True while the playing item was removed from the playlist and is finishing. */
|
|
93
|
+
internal fun TrackPlayerCore.isTransientPlayOut(): Boolean {
|
|
94
|
+
if (!isExoInitialized || currentTemporaryType != TrackPlayerCore.TemporaryType.NONE) return false
|
|
95
|
+
val item = exo.currentMediaItem ?: return false
|
|
96
|
+
return findTrack(item) == null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Follows the user's mode except during a transient play-out, which must finish once. */
|
|
100
|
+
internal fun TrackPlayerCore.backendRepeatMode(): Int =
|
|
101
|
+
when {
|
|
102
|
+
isTransientPlayOut() -> Player.REPEAT_MODE_OFF
|
|
103
|
+
currentRepeatMode == RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
|
104
|
+
else -> Player.REPEAT_MODE_OFF
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
internal fun TrackPlayerCore.applyBackendRepeatMode() {
|
|
108
|
+
if (!isExoInitialized) return
|
|
109
|
+
val desired = backendRepeatMode()
|
|
110
|
+
if (exo.player.repeatMode != desired) exo.setRepeatMode(desired)
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
|
|
93
114
|
|
|
94
115
|
suspend fun TrackPlayerCore.setVolume(volume: Double) = withPlayerContext { setVolumeOnQueue(volume) }
|
|
95
116
|
|
|
96
117
|
internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
|
|
97
118
|
val clamped = volume.coerceIn(0.0, 100.0)
|
|
98
|
-
|
|
119
|
+
userVolume = (clamped / 100.0).toFloat()
|
|
120
|
+
exo.setVolume(userVolume * if (isDucked) DUCK_VOLUME_FACTOR else 1f)
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
|
|
102
124
|
|
|
103
125
|
internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
|
|
104
|
-
|
|
126
|
+
// JS is calling, so the keep-alive task is registered: start it now if a headless start needs it
|
|
127
|
+
updateJsKeepAlive()
|
|
128
|
+
config.androidAudioFocus?.let { applyAudioFocusMode(it) }
|
|
129
|
+
config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.setAndroidAutoEnabled(context, it) }
|
|
105
130
|
config.lookaheadCount?.let { lookaheadCount = it.toInt() }
|
|
106
131
|
config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
|
|
107
132
|
remoteSkipIntervalMs(config.remoteSkipForwardInterval)?.let { remoteSkipForwardIntervalMs = it }
|
|
@@ -185,6 +210,7 @@ internal fun TrackPlayerCore.playSongInternal(
|
|
|
185
210
|
|
|
186
211
|
if (targetPlaylistId == null || songIndex < 0) return
|
|
187
212
|
|
|
213
|
+
playlistManager.setCurrentPlaylistId(targetPlaylistId)
|
|
188
214
|
if (currentPlaylistId != targetPlaylistId) {
|
|
189
215
|
val playlist = playlistManager.getPlaylist(targetPlaylistId) ?: return
|
|
190
216
|
currentPlaylistId = targetPlaylistId
|
|
@@ -72,8 +72,8 @@ suspend fun TrackPlayerCore.getActualQueue(): List<TrackItem> = withPlayerContex
|
|
|
72
72
|
|
|
73
73
|
internal fun TrackPlayerCore.getActualQueueInternal(): List<TrackItem> {
|
|
74
74
|
if (!isExoInitialized) return emptyList()
|
|
75
|
+
// May be -1 (no anchor while a temp plays); the arithmetic below handles it.
|
|
75
76
|
val currentIndex = currentTrackIndex
|
|
76
|
-
if (currentIndex < 0) return emptyList()
|
|
77
77
|
|
|
78
78
|
val queue = ArrayList<TrackItem>(currentTracks.size + playNextStack.size + upNextQueue.size)
|
|
79
79
|
|
|
@@ -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
|
|
@@ -88,6 +102,7 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
|
|
|
88
102
|
currentTrackIndex = index
|
|
89
103
|
exo.setMediaItems(mediaItems, true)
|
|
90
104
|
exo.prepare()
|
|
105
|
+
applyBackendRepeatMode()
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
// ── Surgical rebuild (preserve current item) ──────────────────────────────
|
|
@@ -105,15 +120,19 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
105
120
|
currentTracks.none { it.id == currentTrackId } &&
|
|
106
121
|
currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
|
|
107
122
|
) {
|
|
108
|
-
if (currentTracks.
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
if (currentTracks.isNotEmpty()) {
|
|
124
|
+
// currentTrackIndex + 1 is the resume slot set by assignCurrentTracks.
|
|
125
|
+
playFromIndexInternal(minOf(currentTrackIndex + 1, currentTracks.size - 1))
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
// Transient play-out: the removed item must finish once, not loop.
|
|
129
|
+
applyBackendRepeatMode()
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
// Keep the logical playlist pointer in sync after playlist mutations.
|
|
114
133
|
// Without this, getActualQueue/getState can report a stale index until the next track transition.
|
|
115
134
|
if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && currentTrackId != null) {
|
|
116
|
-
val resolvedIndex =
|
|
135
|
+
val resolvedIndex = currentTrackIndexOf(currentTrackId, currentTrackIndex)
|
|
117
136
|
if (resolvedIndex >= 0) {
|
|
118
137
|
currentTrackIndex = resolvedIndex
|
|
119
138
|
}
|
|
@@ -248,6 +267,7 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
|
|
|
248
267
|
if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
|
|
249
268
|
exo.prepare()
|
|
250
269
|
}
|
|
270
|
+
applyBackendRepeatMode()
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
// ── MediaItem construction (member extension to access downloadManager) ────
|
|
@@ -262,6 +282,7 @@ internal fun TrackPlayerCore.makeMediaItem(
|
|
|
262
282
|
.setTitle(track.title)
|
|
263
283
|
.setArtist(track.artist)
|
|
264
284
|
.setAlbumTitle(track.album)
|
|
285
|
+
if (track.duration > 0) metaBuilder.setDurationMs((track.duration * 1000).toLong())
|
|
265
286
|
|
|
266
287
|
track.artwork?.asSecondOrNull()?.let { artworkUrl ->
|
|
267
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
|
|
|
@@ -24,7 +25,9 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
24
25
|
val safeTracks =
|
|
25
26
|
tracks.filter { track ->
|
|
26
27
|
when {
|
|
27
|
-
|
|
28
|
+
// Same URL is a metadata-only edit (a title or artwork refresh): it reaches
|
|
29
|
+
// the session without touching the media, so gapless is unaffected.
|
|
30
|
+
track.id == currentTrackId && !currentTrackIsEmpty -> track.url == currentTrack?.url
|
|
28
31
|
|
|
29
32
|
// preserve gapless
|
|
30
33
|
track.id == currentTrackId && currentTrackIsEmpty -> track.url.isNotEmpty()
|
|
@@ -35,12 +38,15 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
if (safeTracks.isEmpty()) return@run
|
|
41
|
+
safeTracks.forEach { urlRequestedAt.remove(it.id) }
|
|
38
42
|
|
|
39
43
|
val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
|
|
40
44
|
|
|
41
45
|
// Replace the current MediaItem when its URL just resolved (local only — the cast branch below reloads instead).
|
|
42
46
|
val currentTrackResolvedNow = currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()
|
|
43
|
-
|
|
47
|
+
val currentMetadataChanged =
|
|
48
|
+
currentTrackUpdate != null && !currentTrackIsEmpty && currentTrackUpdate.url == currentTrack?.url
|
|
49
|
+
if (!isCastingField && (currentTrackResolvedNow || currentMetadataChanged)) {
|
|
44
50
|
val exoIndex = exo.currentMediaItemIndex
|
|
45
51
|
if (exoIndex >= 0) {
|
|
46
52
|
val playlistId = currentPlaylistId ?: ""
|
|
@@ -53,7 +59,7 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
|
53
59
|
if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
|
|
54
60
|
val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
|
|
55
61
|
if (refreshedPlaylist != null) {
|
|
56
|
-
|
|
62
|
+
assignCurrentTracks(refreshedPlaylist.tracks)
|
|
57
63
|
val updatedById = currentTracks.associateBy { it.id }
|
|
58
64
|
playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
|
|
59
65
|
upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
|
|
@@ -129,7 +135,13 @@ suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { cu
|
|
|
129
135
|
|
|
130
136
|
// ── URL lookahead ─────────────────────────────────────────────────────────
|
|
131
137
|
|
|
132
|
-
|
|
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
|
+
) {
|
|
133
145
|
val upcomingTracks =
|
|
134
146
|
if (currentTrackIndex < 0) {
|
|
135
147
|
currentTracks.take(lookahead)
|
|
@@ -140,5 +152,10 @@ internal fun TrackPlayerCore.checkUpcomingTracksForUrls(lookahead: Int = 5) {
|
|
|
140
152
|
val currentNeedsUrl = currentTrack != null && currentTrack.url.isEmpty()
|
|
141
153
|
val candidates = if (currentNeedsUrl) listOf(currentTrack!!) + upcomingTracks else upcomingTracks
|
|
142
154
|
val needUrls = candidates.filter { it.url.isEmpty() }
|
|
143
|
-
|
|
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)
|
|
144
161
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.download
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import com.margelo.nitro.JNIOnLoad
|
|
4
5
|
import com.margelo.nitro.core.AnyMap
|
|
5
6
|
import com.margelo.nitro.core.NullType
|
|
6
7
|
import com.margelo.nitro.nitroplayer.*
|
|
@@ -56,6 +57,8 @@ class DownloadDatabase private constructor(
|
|
|
56
57
|
private val ioScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1) + SupervisorJob())
|
|
57
58
|
|
|
58
59
|
init {
|
|
60
|
+
// Persisted extraPayloads are AnyMaps; a process started without JS has not loaded Nitro's JNI yet
|
|
61
|
+
JNIOnLoad.initializeNativeNitro()
|
|
59
62
|
loadFromDisk()
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.margelo.nitro.nitroplayer.download
|
|
2
2
|
|
|
3
|
+
import com.margelo.nitro.JNIOnLoad
|
|
3
4
|
import com.margelo.nitro.core.AnyMap
|
|
4
5
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
5
6
|
import com.margelo.nitro.nitroplayer.Variant_NullType_String
|
|
@@ -8,6 +9,11 @@ import org.json.JSONObject
|
|
|
8
9
|
// Survives process death inside the worker's inputData so a completed
|
|
9
10
|
// download can be recorded even when the in-memory metadata maps are gone.
|
|
10
11
|
internal object TrackItemJson {
|
|
12
|
+
// extraPayload is an AnyMap; downloads can run in a process JS never started
|
|
13
|
+
init {
|
|
14
|
+
JNIOnLoad.initializeNativeNitro()
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
fun toJson(track: TrackItem): String =
|
|
12
18
|
JSONObject()
|
|
13
19
|
.apply {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
package com.margelo.nitro.nitroplayer.media
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioAttributes
|
|
5
|
+
import android.media.AudioFocusRequest
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.os.Build
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owns audio focus for the 'duck' mode. ExoPlayer's own handling pauses on every transient
|
|
11
|
+
* loss, duckable or not, which is why a notification can stop playback outright.
|
|
12
|
+
*/
|
|
13
|
+
class AudioFocusController(
|
|
14
|
+
context: Context,
|
|
15
|
+
private val onDuck: (Boolean) -> Unit,
|
|
16
|
+
private val onPause: () -> Unit,
|
|
17
|
+
) {
|
|
18
|
+
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
19
|
+
private var request: AudioFocusRequest? = null
|
|
20
|
+
private var ducked = false
|
|
21
|
+
private var held = false
|
|
22
|
+
|
|
23
|
+
private val listener =
|
|
24
|
+
AudioManager.OnAudioFocusChangeListener { change ->
|
|
25
|
+
when (change) {
|
|
26
|
+
// Another app took focus for good — stop and let it go.
|
|
27
|
+
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
28
|
+
setDucked(false)
|
|
29
|
+
onPause()
|
|
30
|
+
abandon()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
|
|
34
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
|
|
35
|
+
-> setDucked(true)
|
|
36
|
+
|
|
37
|
+
AudioManager.AUDIOFOCUS_GAIN -> setDucked(false)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun setDucked(next: Boolean) {
|
|
42
|
+
if (ducked == next) return
|
|
43
|
+
ducked = next
|
|
44
|
+
onDuck(next)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns false when the system refused focus, so the caller can leave playback alone. */
|
|
48
|
+
fun acquire(): Boolean {
|
|
49
|
+
if (held) return true
|
|
50
|
+
val attributes =
|
|
51
|
+
AudioAttributes
|
|
52
|
+
.Builder()
|
|
53
|
+
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
54
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
55
|
+
.build()
|
|
56
|
+
val result =
|
|
57
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
58
|
+
val built =
|
|
59
|
+
AudioFocusRequest
|
|
60
|
+
.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
61
|
+
.setAudioAttributes(attributes)
|
|
62
|
+
.setWillPauseWhenDucked(false)
|
|
63
|
+
.setOnAudioFocusChangeListener(listener)
|
|
64
|
+
.build()
|
|
65
|
+
request = built
|
|
66
|
+
audioManager.requestAudioFocus(built)
|
|
67
|
+
} else {
|
|
68
|
+
@Suppress("DEPRECATION")
|
|
69
|
+
audioManager.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
|
70
|
+
}
|
|
71
|
+
held = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
72
|
+
return held
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fun abandon() {
|
|
76
|
+
if (!held) return
|
|
77
|
+
held = false
|
|
78
|
+
setDucked(false)
|
|
79
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
80
|
+
request?.let { audioManager.abandonAudioFocusRequest(it) }
|
|
81
|
+
request = null
|
|
82
|
+
} else {
|
|
83
|
+
@Suppress("DEPRECATION")
|
|
84
|
+
audioManager.abandonAudioFocus(listener)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -35,6 +35,8 @@ class CastSessionController(
|
|
|
35
35
|
private val castContext: CastContext,
|
|
36
36
|
val castPlayer: CastPlayer,
|
|
37
37
|
val localPlayer: Player,
|
|
38
|
+
/** What the session drives locally — wraps [localPlayer] to route remote commands through the core. */
|
|
39
|
+
val localSessionPlayer: Player,
|
|
38
40
|
val mediaSession: MediaSession,
|
|
39
41
|
private val mainHandler: Handler,
|
|
40
42
|
) {
|
|
@@ -8,6 +8,7 @@ import androidx.media3.datasource.DefaultDataSource
|
|
|
8
8
|
import androidx.media3.exoplayer.DefaultLoadControl
|
|
9
9
|
import androidx.media3.exoplayer.ExoPlayer
|
|
10
10
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
11
|
+
import androidx.media3.exoplayer.source.ShuffleOrder
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Builds an [ExoPlayer] with the standard NitroPlayer configuration.
|
|
@@ -59,5 +60,7 @@ object ExoPlayerBuilder {
|
|
|
59
60
|
.setWakeMode(C.WAKE_MODE_NETWORK)
|
|
60
61
|
.setPauseAtEndOfMediaItems(false)
|
|
61
62
|
.build()
|
|
63
|
+
// Queue order is ours; a controller's shuffle toggle must not randomize the window.
|
|
64
|
+
.apply { setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) }
|
|
62
65
|
}
|
|
63
66
|
}
|