react-native-nitro-player 1.5.0 → 1.5.1-alpha.0
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/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +71 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +82 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +52 -29
- package/ios/core/TrackPlayerCore.swift +9 -1
- package/ios/core/TrackPlayerListener.swift +20 -5
- package/ios/core/TrackPlayerNotify.swift +9 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +17 -0
- package/lib/hooks/callbackManager.js +67 -1
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +87 -1
- package/src/hooks/useActualQueue.ts +18 -13
- package/src/hooks/useNowPlaying.ts +12 -2
|
@@ -37,6 +37,14 @@ internal fun TrackPlayerCore.castableUpcoming(tracks: List<TrackItem>): List<Tra
|
|
|
37
37
|
return result
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Upcoming MediaItems kept materialized behind the current one. The logical queue
|
|
42
|
+
* (currentTracks + temp lists) stays complete; only ExoPlayer's timeline is windowed,
|
|
43
|
+
* and it is topped up on every item transition. Building a MediaItem is not free —
|
|
44
|
+
* each one resolves the effective URL, which used to stat() the download record.
|
|
45
|
+
*/
|
|
46
|
+
internal const val QUEUE_WINDOW_SIZE = 4
|
|
47
|
+
|
|
40
48
|
internal fun TrackPlayerCore.mediaIdFor(track: TrackItem): String {
|
|
41
49
|
val playlistId = currentPlaylistId ?: ""
|
|
42
50
|
return if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
|
|
@@ -70,8 +78,9 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
|
|
|
70
78
|
return
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
val windowEnd = minOf(index + 1 + QUEUE_WINDOW_SIZE, currentTracks.size)
|
|
73
82
|
val mediaItems =
|
|
74
|
-
currentTracks.subList(index,
|
|
83
|
+
currentTracks.subList(index, windowEnd).map { track ->
|
|
75
84
|
makeMediaItem(track, mediaIdFor(track))
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -92,7 +101,7 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
92
101
|
val currentTrackId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
93
102
|
|
|
94
103
|
if (
|
|
95
|
-
currentTrackId != null &&
|
|
104
|
+
currentTrackId != null &&
|
|
96
105
|
currentTracks.none { it.id == currentTrackId } &&
|
|
97
106
|
currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
|
|
98
107
|
) {
|
|
@@ -131,12 +140,32 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
|
|
143
|
+
// Window the materialized timeline; onMediaItemTransition tops it back up.
|
|
144
|
+
// NOT while casting: the receiver holds the whole queue (rebuildQueueAndPlayFromIndex
|
|
145
|
+
// loads it unwindowed), so truncating here would cut it down to the window size.
|
|
146
|
+
if (!isCastingField && newQueueTracks.size > QUEUE_WINDOW_SIZE) {
|
|
147
|
+
newQueueTracks = newQueueTracks.subList(0, QUEUE_WINDOW_SIZE)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Keep the longest matching prefix of already-buffered items and rebuild only from
|
|
151
|
+
// the first divergence. A pure append (the common window top-up) touches nothing
|
|
152
|
+
// ExoPlayer has already prepared, so gapless transitions survive.
|
|
153
|
+
val existingCount = exo.mediaItemCount - currentIndex - 1
|
|
154
|
+
var commonPrefix = 0
|
|
155
|
+
while (commonPrefix < existingCount && commonPrefix < newQueueTracks.size &&
|
|
156
|
+
extractTrackId(exo.getMediaItemAt(currentIndex + 1 + commonPrefix).mediaId) == newQueueTracks[commonPrefix].id
|
|
157
|
+
) {
|
|
158
|
+
commonPrefix++
|
|
159
|
+
}
|
|
135
160
|
|
|
136
|
-
if (exo.mediaItemCount > currentIndex + 1) {
|
|
137
|
-
exo.removeMediaItems(currentIndex + 1, exo.mediaItemCount)
|
|
161
|
+
if (exo.mediaItemCount > currentIndex + 1 + commonPrefix) {
|
|
162
|
+
exo.removeMediaItems(currentIndex + 1 + commonPrefix, exo.mediaItemCount)
|
|
163
|
+
}
|
|
164
|
+
if (commonPrefix < newQueueTracks.size) {
|
|
165
|
+
exo.addMediaItems(
|
|
166
|
+
newQueueTracks.subList(commonPrefix, newQueueTracks.size).map { makeMediaItem(it, mediaIdFor(it)) },
|
|
167
|
+
)
|
|
138
168
|
}
|
|
139
|
-
exo.addMediaItems(newMediaItems)
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
/** Desired upcoming list after the current track: playNext + upNext + remaining originals, with the playing temp track ([currentId]) skipped. */
|
|
@@ -203,7 +232,8 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
|
|
|
203
232
|
return
|
|
204
233
|
}
|
|
205
234
|
|
|
206
|
-
|
|
235
|
+
currentTrackIndex = 0
|
|
236
|
+
val mediaItems = tracks.take(1 + QUEUE_WINDOW_SIZE).map { makeMediaItem(it, mediaIdFor(it)) }
|
|
207
237
|
exo.setMediaItems(mediaItems, true)
|
|
208
238
|
if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
|
|
209
239
|
exo.prepare()
|
|
@@ -239,15 +269,17 @@ internal fun TrackPlayerCore.makeMediaItem(
|
|
|
239
269
|
try {
|
|
240
270
|
val headersRaw = payload.toHashMap()["headers"]
|
|
241
271
|
if (headersRaw is Map<*, *>) {
|
|
242
|
-
val headers =
|
|
243
|
-
|
|
244
|
-
|
|
272
|
+
val headers =
|
|
273
|
+
headersRaw
|
|
274
|
+
.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }
|
|
275
|
+
.toMap()
|
|
245
276
|
if (headers.isNotEmpty()) {
|
|
246
277
|
com.margelo.nitro.nitroplayer.media.AuthAwareHttpDataSourceFactory
|
|
247
278
|
.setHeadersForUrl(effectiveUrl, headers)
|
|
248
279
|
}
|
|
249
280
|
}
|
|
250
|
-
} catch (_: Exception) {
|
|
281
|
+
} catch (_: Exception) {
|
|
282
|
+
}
|
|
251
283
|
}
|
|
252
284
|
|
|
253
285
|
return MediaItem
|
|
@@ -43,12 +43,16 @@ internal fun TrackPlayerCore.initFromService(binder: NitroPlayerPlaybackService.
|
|
|
43
43
|
if (sessionId != 0) {
|
|
44
44
|
try {
|
|
45
45
|
EqualizerCore.getInstance(context).initialize(sessionId)
|
|
46
|
-
} catch (_: Exception) {
|
|
46
|
+
} catch (_: Exception) {
|
|
47
|
+
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// Start progress ticks on the main looper
|
|
50
|
-
playerHandler.postDelayed(progressUpdateRunnable,
|
|
51
|
+
playerHandler.postDelayed(progressUpdateRunnable, TrackPlayerCore.PROGRESS_INTERVAL_MS)
|
|
51
52
|
|
|
52
53
|
// Signal that the player is ready — unblocks all withPlayerContext callers
|
|
53
54
|
completeServiceReady()
|
|
55
|
+
|
|
56
|
+
// Flush any commands JS issued before the service bound, in their original order
|
|
57
|
+
startCommandDraining()
|
|
54
58
|
}
|
|
@@ -14,16 +14,21 @@ import com.margelo.nitro.nitroplayer.TrackItem
|
|
|
14
14
|
suspend fun TrackPlayerCore.loadPlaylist(
|
|
15
15
|
playlistId: String,
|
|
16
16
|
startIndex: Int? = null,
|
|
17
|
-
) =
|
|
18
|
-
|
|
17
|
+
) = withPlayerContext { loadPlaylistOnQueue(playlistId, startIndex) }
|
|
18
|
+
|
|
19
|
+
internal fun TrackPlayerCore.loadPlaylistOnQueue(
|
|
20
|
+
playlistId: String,
|
|
21
|
+
startIndex: Int? = null,
|
|
22
|
+
) {
|
|
23
|
+
run {
|
|
19
24
|
playNextStack.clear()
|
|
20
25
|
upNextQueue.clear()
|
|
21
26
|
currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
|
|
22
|
-
val playlist = playlistManager.getPlaylist(playlistId) ?: return@
|
|
27
|
+
val playlist = playlistManager.getPlaylist(playlistId) ?: return@run
|
|
23
28
|
|
|
24
29
|
val targetIndex =
|
|
25
30
|
if (startIndex != null) {
|
|
26
|
-
if (startIndex < 0 || startIndex >= playlist.tracks.size) return@
|
|
31
|
+
if (startIndex < 0 || startIndex >= playlist.tracks.size) return@run
|
|
27
32
|
startIndex
|
|
28
33
|
} else {
|
|
29
34
|
0
|
|
@@ -41,6 +46,7 @@ suspend fun TrackPlayerCore.loadPlaylist(
|
|
|
41
46
|
checkUpcomingTracksForUrls(lookaheadCount)
|
|
42
47
|
notifyTemporaryQueueChange()
|
|
43
48
|
}
|
|
49
|
+
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
52
|
* Debounced update — coalesces rapid back-to-back mutations into one player rebuild.
|
|
@@ -80,39 +86,43 @@ internal fun TrackPlayerCore.addToUpNextInternal(trackId: String) {
|
|
|
80
86
|
|
|
81
87
|
// ── Remove / clear ────────────────────────────────────────────────────────
|
|
82
88
|
|
|
83
|
-
suspend fun TrackPlayerCore.removeFromPlayNext(trackId: String): Boolean =
|
|
84
|
-
withPlayerContext {
|
|
85
|
-
val idx = playNextStack.indexOfFirst { it.id == trackId }
|
|
86
|
-
if (idx < 0) return@withPlayerContext false
|
|
87
|
-
playNextStack.removeAt(idx)
|
|
88
|
-
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
89
|
-
notifyTemporaryQueueChange()
|
|
90
|
-
true
|
|
91
|
-
}
|
|
89
|
+
suspend fun TrackPlayerCore.removeFromPlayNext(trackId: String): Boolean = withPlayerContext { removeFromPlayNextOnQueue(trackId) }
|
|
92
90
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
91
|
+
internal fun TrackPlayerCore.removeFromPlayNextOnQueue(trackId: String): Boolean {
|
|
92
|
+
val idx = playNextStack.indexOfFirst { it.id == trackId }
|
|
93
|
+
if (idx < 0) return false
|
|
94
|
+
playNextStack.removeAt(idx)
|
|
95
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
96
|
+
notifyTemporaryQueueChange()
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
102
99
|
|
|
103
|
-
suspend fun TrackPlayerCore.
|
|
104
|
-
withPlayerContext {
|
|
105
|
-
playNextStack.clear()
|
|
106
|
-
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
107
|
-
notifyTemporaryQueueChange()
|
|
108
|
-
}
|
|
100
|
+
suspend fun TrackPlayerCore.removeFromUpNext(trackId: String): Boolean = withPlayerContext { removeFromUpNextOnQueue(trackId) }
|
|
109
101
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
internal fun TrackPlayerCore.removeFromUpNextOnQueue(trackId: String): Boolean {
|
|
103
|
+
val idx = upNextQueue.indexOfFirst { it.id == trackId }
|
|
104
|
+
if (idx < 0) return false
|
|
105
|
+
upNextQueue.removeAt(idx)
|
|
106
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
107
|
+
notifyTemporaryQueueChange()
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
suspend fun TrackPlayerCore.clearPlayNext() = withPlayerContext { clearPlayNextOnQueue() }
|
|
112
|
+
|
|
113
|
+
internal fun TrackPlayerCore.clearPlayNextOnQueue() {
|
|
114
|
+
playNextStack.clear()
|
|
115
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
116
|
+
notifyTemporaryQueueChange()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
suspend fun TrackPlayerCore.clearUpNext() = withPlayerContext { clearUpNextOnQueue() }
|
|
120
|
+
|
|
121
|
+
internal fun TrackPlayerCore.clearUpNextOnQueue() {
|
|
122
|
+
upNextQueue.clear()
|
|
123
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
124
|
+
notifyTemporaryQueueChange()
|
|
125
|
+
}
|
|
116
126
|
|
|
117
127
|
// ── Reorder ───────────────────────────────────────────────────────────────
|
|
118
128
|
|
|
@@ -123,26 +133,30 @@ suspend fun TrackPlayerCore.clearUpNext() =
|
|
|
123
133
|
suspend fun TrackPlayerCore.reorderTemporaryTrack(
|
|
124
134
|
trackId: String,
|
|
125
135
|
newIndex: Int,
|
|
126
|
-
): Boolean =
|
|
127
|
-
withPlayerContext {
|
|
128
|
-
val combined = (playNextStack + upNextQueue).toMutableList()
|
|
129
|
-
val fromIdx = combined.indexOfFirst { it.id == trackId }
|
|
130
|
-
if (fromIdx < 0) return@withPlayerContext false
|
|
131
|
-
val track = combined.removeAt(fromIdx)
|
|
132
|
-
val clampedIndex = newIndex.coerceIn(0, combined.size)
|
|
133
|
-
combined.add(clampedIndex, track)
|
|
134
|
-
|
|
135
|
-
// Split back at original playNextStack.size boundary (reduced if an item was moved out)
|
|
136
|
-
val pnSize = playNextStack.size
|
|
137
|
-
playNextStack.clear()
|
|
138
|
-
upNextQueue.clear()
|
|
139
|
-
playNextStack.addAll(combined.take(pnSize))
|
|
140
|
-
upNextQueue.addAll(combined.drop(pnSize))
|
|
136
|
+
): Boolean = withPlayerContext { reorderTemporaryTrackOnQueue(trackId, newIndex) }
|
|
141
137
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
internal fun TrackPlayerCore.reorderTemporaryTrackOnQueue(
|
|
139
|
+
trackId: String,
|
|
140
|
+
newIndex: Int,
|
|
141
|
+
): Boolean {
|
|
142
|
+
val combined = (playNextStack + upNextQueue).toMutableList()
|
|
143
|
+
val fromIdx = combined.indexOfFirst { it.id == trackId }
|
|
144
|
+
if (fromIdx < 0) return false
|
|
145
|
+
val track = combined.removeAt(fromIdx)
|
|
146
|
+
val clampedIndex = newIndex.coerceIn(0, combined.size)
|
|
147
|
+
combined.add(clampedIndex, track)
|
|
148
|
+
|
|
149
|
+
// Split back at original playNextStack.size boundary (reduced if an item was moved out)
|
|
150
|
+
val pnSize = playNextStack.size
|
|
151
|
+
playNextStack.clear()
|
|
152
|
+
upNextQueue.clear()
|
|
153
|
+
playNextStack.addAll(combined.take(pnSize))
|
|
154
|
+
upNextQueue.addAll(combined.drop(pnSize))
|
|
155
|
+
|
|
156
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
157
|
+
notifyTemporaryQueueChange()
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
146
160
|
|
|
147
161
|
// ── Read-only accessors ────────────────────────────────────────────────────
|
|
148
162
|
|
|
@@ -12,8 +12,10 @@ import com.margelo.nitro.nitroplayer.TrackItem
|
|
|
12
12
|
|
|
13
13
|
// ── Track updates (URL resolution) ────────────────────────────────────────
|
|
14
14
|
|
|
15
|
-
suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
16
|
-
|
|
15
|
+
suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) = withPlayerContext { updateTracksOnQueue(tracks) }
|
|
16
|
+
|
|
17
|
+
internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
18
|
+
run {
|
|
17
19
|
val currentTrack = getCurrentTrack()
|
|
18
20
|
val currentTrackId = currentTrack?.id
|
|
19
21
|
val currentTrackIsEmpty = currentTrack?.url.isNullOrEmpty()
|
|
@@ -32,7 +34,7 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
|
32
34
|
else -> true
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
|
-
if (safeTracks.isEmpty()) return@
|
|
37
|
+
if (safeTracks.isEmpty()) return@run
|
|
36
38
|
|
|
37
39
|
val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
|
|
38
40
|
|
|
@@ -64,12 +66,13 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
|
64
66
|
} else {
|
|
65
67
|
rebuildQueueFromCurrentPosition()
|
|
66
68
|
}
|
|
67
|
-
return@
|
|
69
|
+
return@run
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
rebuildQueueFromCurrentPosition()
|
|
71
73
|
}
|
|
72
74
|
}
|
|
75
|
+
}
|
|
73
76
|
|
|
74
77
|
// ── Track queries ─────────────────────────────────────────────────────────
|
|
75
78
|
|
|
@@ -84,14 +87,42 @@ internal fun TrackPlayerCore.getTracksNeedingUrlsInternal(): List<TrackItem> {
|
|
|
84
87
|
|
|
85
88
|
suspend fun TrackPlayerCore.getNextTracks(count: Int): List<TrackItem> = withPlayerContext { getNextTracksInternal(count) }
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Walks the queue structure directly instead of materializing the whole queue — this
|
|
92
|
+
* runs on every skip and every progress tick, so it must not be O(playlist) just to
|
|
93
|
+
* read the next few tracks.
|
|
94
|
+
*/
|
|
87
95
|
internal fun TrackPlayerCore.getNextTracksInternal(count: Int): List<TrackItem> {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
val
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
if (count <= 0) return emptyList()
|
|
97
|
+
val out = ArrayList<TrackItem>(count)
|
|
98
|
+
val currentId = if (isExoInitialized) exo.currentMediaItem?.mediaId?.let { extractTrackId(it) } else null
|
|
99
|
+
|
|
100
|
+
var skippedPlayNext = currentTemporaryType != TrackPlayerCore.TemporaryType.PLAY_NEXT
|
|
101
|
+
for (track in playNextStack) {
|
|
102
|
+
if (!skippedPlayNext && track.id == currentId) {
|
|
103
|
+
skippedPlayNext = true
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
out.add(track)
|
|
107
|
+
if (out.size == count) return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var skippedUpNext = currentTemporaryType != TrackPlayerCore.TemporaryType.UP_NEXT
|
|
111
|
+
for (track in upNextQueue) {
|
|
112
|
+
if (!skippedUpNext && track.id == currentId) {
|
|
113
|
+
skippedUpNext = true
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
out.add(track)
|
|
117
|
+
if (out.size == count) return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
var index = currentTrackIndex + 1
|
|
121
|
+
while (index < currentTracks.size && out.size < count) {
|
|
122
|
+
out.add(currentTracks[index])
|
|
123
|
+
index++
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { currentTrackIndex }
|
|
@@ -39,6 +39,15 @@ class DownloadDatabase private constructor(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
private val downloadedTracks = mutableMapOf<String, DownloadedTrackRecord>()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Memoized `File.exists()` verdicts, keyed by track ID.
|
|
45
|
+
*
|
|
46
|
+
* Queue building asks "is this downloaded?" once per track, and each answer used to
|
|
47
|
+
* cost a stat() — O(playlist) syscalls per rebuild, on the main looper. Records only
|
|
48
|
+
* change through this class, so the verdict is cached and dropped in [saveToDisk].
|
|
49
|
+
*/
|
|
50
|
+
private val existenceCache = mutableMapOf<String, Boolean>()
|
|
42
51
|
private val playlistTracks = mutableMapOf<String, MutableSet<String>>()
|
|
43
52
|
private val fileManager = DownloadFileManager.getInstance(context)
|
|
44
53
|
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
@@ -82,11 +91,21 @@ class DownloadDatabase private constructor(
|
|
|
82
91
|
fun isTrackDownloaded(trackId: String): Boolean {
|
|
83
92
|
synchronized(this) {
|
|
84
93
|
val record = downloadedTracks[trackId] ?: return false
|
|
85
|
-
|
|
86
|
-
return File(record.localPath).exists()
|
|
94
|
+
return cachedFileExists(trackId, record.localPath)
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
/** Caller must hold the monitor. */
|
|
99
|
+
private fun cachedFileExists(
|
|
100
|
+
trackId: String,
|
|
101
|
+
path: String,
|
|
102
|
+
): Boolean = existenceCache.getOrPut(trackId) { File(path).exists() }
|
|
103
|
+
|
|
104
|
+
/** Drops every memoized verdict — files may have changed outside the app. */
|
|
105
|
+
fun invalidateExistenceCache() {
|
|
106
|
+
synchronized(this) { existenceCache.clear() }
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
fun isPlaylistDownloaded(playlistId: String): Boolean {
|
|
91
110
|
synchronized(this) {
|
|
92
111
|
val trackIds = playlistTracks[playlistId] ?: return false
|
|
@@ -118,8 +137,8 @@ class DownloadDatabase private constructor(
|
|
|
118
137
|
synchronized(this) {
|
|
119
138
|
val record = downloadedTracks[trackId] ?: return null
|
|
120
139
|
|
|
121
|
-
// Verify file still exists
|
|
122
|
-
if (!
|
|
140
|
+
// Verify file still exists (memoized)
|
|
141
|
+
if (!cachedFileExists(trackId, record.localPath)) {
|
|
123
142
|
// File was deleted externally, clean up record
|
|
124
143
|
downloadedTracks.remove(trackId)
|
|
125
144
|
saveToDisk()
|
|
@@ -302,6 +321,9 @@ class DownloadDatabase private constructor(
|
|
|
302
321
|
|
|
303
322
|
// Persistence — called while holding synchronized(this); snapshots data then writes on IO.
|
|
304
323
|
private fun saveToDisk() {
|
|
324
|
+
// Every record mutation funnels through here, so this is the one place that has
|
|
325
|
+
// to drop memoized File.exists() verdicts.
|
|
326
|
+
existenceCache.clear()
|
|
305
327
|
val trackSnapshot = downloadedTracks.toMap()
|
|
306
328
|
val playlistSnapshot = playlistTracks.mapValues { it.value.toSet() }
|
|
307
329
|
ioScope.launch {
|
|
@@ -25,40 +25,63 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
25
25
|
super.init()
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// MARK: - Ordered dispatch
|
|
29
|
+
//
|
|
30
|
+
// `Promise.async` starts an unordered Task, so the hop onto the player queue used
|
|
31
|
+
// to happen at an arbitrary later point: two calls made in order from JS could
|
|
32
|
+
// reach the player in reverse order (play-then-pause landing as pause-then-play).
|
|
33
|
+
// Enqueueing synchronously here — on the JS thread, at call time — makes the serial
|
|
34
|
+
// queue's FIFO order equal the JS call order.
|
|
35
|
+
|
|
36
|
+
private func enqueue<T>(_ block: @escaping () -> T) -> Promise<T> {
|
|
37
|
+
let promise = Promise<T>()
|
|
38
|
+
core.playerQueue.async { promise.resolve(withResult: block()) }
|
|
39
|
+
return promise
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private func enqueueThrowing<T>(_ block: @escaping () throws -> T) -> Promise<T> {
|
|
43
|
+
let promise = Promise<T>()
|
|
44
|
+
core.playerQueue.async {
|
|
45
|
+
do { promise.resolve(withResult: try block()) }
|
|
46
|
+
catch { promise.reject(withError: error) }
|
|
47
|
+
}
|
|
48
|
+
return promise
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
// MARK: - Playback Control (async Promise<Void>)
|
|
29
52
|
|
|
30
53
|
func play() throws -> Promise<Void> {
|
|
31
|
-
|
|
54
|
+
enqueue { self.core.playOnQueue() }
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
func pause() throws -> Promise<Void> {
|
|
35
|
-
|
|
58
|
+
enqueue { self.core.pauseOnQueue() }
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
func seek(position: Double) throws -> Promise<Void> {
|
|
39
|
-
|
|
62
|
+
enqueue { self.core.seekOnQueue(position: position) }
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
func skipToNext() throws -> Promise<Void> {
|
|
43
|
-
|
|
66
|
+
enqueue { self.core.skipToNextOnQueue() }
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
func skipToPrevious() throws -> Promise<Void> {
|
|
47
|
-
|
|
70
|
+
enqueue { self.core.skipToPreviousOnQueue() }
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
func playSong(songId: String, fromPlaylist: String?) throws -> Promise<Void> {
|
|
51
|
-
|
|
74
|
+
enqueue { self.core.playSongInternal(songId: songId, fromPlaylist: fromPlaylist) }
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
func skipToIndex(index: Double) throws -> Promise<Bool> {
|
|
55
|
-
|
|
78
|
+
enqueue { self.core.skipToIndexOnQueue(index: Int(index)) }
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// MARK: - Repeat / Volume / Config
|
|
59
82
|
|
|
60
83
|
func setRepeatMode(mode: RepeatMode) throws -> Promise<Void> {
|
|
61
|
-
|
|
84
|
+
enqueue { self.core.setRepeatModeOnQueue(mode: mode) }
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
func getRepeatMode() throws -> RepeatMode {
|
|
@@ -66,12 +89,12 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
func setVolume(volume: Double) throws -> Promise<Void> {
|
|
69
|
-
|
|
92
|
+
enqueue { self.core.setVolumeOnQueue(volume: volume) }
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
func configure(config: PlayerConfig) throws -> Promise<Void> {
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
enqueue {
|
|
97
|
+
self.core.configureOnQueue(
|
|
75
98
|
androidAutoEnabled: config.androidAutoEnabled,
|
|
76
99
|
carPlayEnabled: config.carPlayEnabled,
|
|
77
100
|
showInNotification: config.showInNotification,
|
|
@@ -83,80 +106,80 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
83
106
|
// MARK: - Queue / State reads
|
|
84
107
|
|
|
85
108
|
func getActualQueue() throws -> Promise<[TrackItem]> {
|
|
86
|
-
|
|
109
|
+
enqueue { self.core.getActualQueueInternal() }
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
func getState() throws -> Promise<PlayerState> {
|
|
90
|
-
|
|
113
|
+
enqueue { self.core.getStateInternal() }
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
func getCurrentTrackIndex() throws -> Promise<Double> {
|
|
94
|
-
|
|
117
|
+
enqueue { Double(self.core.currentTrackIndex) }
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
// MARK: - URL updates / lazy loading
|
|
98
121
|
|
|
99
122
|
func updateTracks(tracks: [TrackItem]) throws -> Promise<Void> {
|
|
100
|
-
|
|
123
|
+
enqueue { self.core.updateTracksInternal(tracks: tracks) }
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
func getTracksById(trackIds: [String]) throws -> Promise<[TrackItem]> {
|
|
104
|
-
|
|
127
|
+
enqueue { self.core.getPlaylistManager().getTracksById(trackIds: trackIds) }
|
|
105
128
|
}
|
|
106
129
|
|
|
107
130
|
func getTracksNeedingUrls() throws -> Promise<[TrackItem]> {
|
|
108
|
-
|
|
131
|
+
enqueue { self.core.getTracksNeedingUrlsInternal() }
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
func getNextTracks(count: Double) throws -> Promise<[TrackItem]> {
|
|
112
|
-
|
|
135
|
+
enqueue { self.core.getNextTracksInternal(count: Int(count)) }
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
// MARK: - Playback speed
|
|
116
139
|
func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
|
|
117
|
-
|
|
140
|
+
enqueue { self.core.setPlaybackSpeedOnQueue(speed) }
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
func getPlaybackSpeed() throws -> Promise<Double> {
|
|
121
|
-
|
|
144
|
+
enqueue { self.core.currentPlaybackSpeed }
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
// MARK: - Temporary queue v2
|
|
125
148
|
|
|
126
149
|
func addToUpNext(trackId: String) throws -> Promise<Void> {
|
|
127
|
-
|
|
150
|
+
enqueueThrowing { try self.core.addToUpNextOnQueue(trackId: trackId) }
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
func playNext(trackId: String) throws -> Promise<Void> {
|
|
131
|
-
|
|
154
|
+
enqueueThrowing { try self.core.playNextOnQueue(trackId: trackId) }
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
func removeFromPlayNext(trackId: String) throws -> Promise<Bool> {
|
|
135
|
-
|
|
158
|
+
enqueue { self.core.removeFromPlayNextOnQueue(trackId: trackId) }
|
|
136
159
|
}
|
|
137
160
|
|
|
138
161
|
func removeFromUpNext(trackId: String) throws -> Promise<Bool> {
|
|
139
|
-
|
|
162
|
+
enqueue { self.core.removeFromUpNextOnQueue(trackId: trackId) }
|
|
140
163
|
}
|
|
141
164
|
|
|
142
165
|
func clearPlayNext() throws -> Promise<Void> {
|
|
143
|
-
|
|
166
|
+
enqueue { self.core.clearPlayNextOnQueue() }
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
func clearUpNext() throws -> Promise<Void> {
|
|
147
|
-
|
|
170
|
+
enqueue { self.core.clearUpNextOnQueue() }
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
func reorderTemporaryTrack(trackId: String, newIndex: Double) throws -> Promise<Bool> {
|
|
151
|
-
|
|
174
|
+
enqueue { self.core.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: Int(newIndex)) }
|
|
152
175
|
}
|
|
153
176
|
|
|
154
177
|
func getPlayNextQueue() throws -> Promise<[TrackItem]> {
|
|
155
|
-
|
|
178
|
+
enqueue { self.core.playNextStack }
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
func getUpNextQueue() throws -> Promise<[TrackItem]> {
|
|
159
|
-
|
|
182
|
+
enqueue { self.core.upNextQueue }
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
// MARK: - Android Auto (iOS no-op)
|
|
@@ -26,6 +26,10 @@ class TrackPlayerCore: NSObject {
|
|
|
26
26
|
static let preferredForwardBufferDuration: Double = 30.0
|
|
27
27
|
static let preloadAssetKeys: [String] = ["playable", "duration", "tracks", "preferredTransform"]
|
|
28
28
|
static let gaplessPreloadCount: Int = 3
|
|
29
|
+
/// Upcoming AVPlayerItems kept materialized behind the current one. The logical
|
|
30
|
+
/// queue (currentTracks + temp lists) stays complete; only the AVQueuePlayer is
|
|
31
|
+
/// windowed, and it is topped up on every item transition.
|
|
32
|
+
static let queueWindowSize: Int = 4
|
|
29
33
|
// Stall & failure recovery
|
|
30
34
|
static let maxFailedItemRetries: Int = 3
|
|
31
35
|
static let failedItemRetryDelay: TimeInterval = 2.0
|
|
@@ -44,7 +48,10 @@ class TrackPlayerCore: NSObject {
|
|
|
44
48
|
internal var currentPlaylistId: String?
|
|
45
49
|
internal var currentTrackIndex: Int = -1
|
|
46
50
|
internal var currentTracks: [TrackItem] = []
|
|
47
|
-
|
|
51
|
+
// Bumped on every updatePlaylist request (playerQueue-owned). A queued rebuild
|
|
52
|
+
// whose generation is stale was superseded by a later request and is dropped,
|
|
53
|
+
// so a burst of playlist mutations collapses into a single rebuild.
|
|
54
|
+
internal var playlistUpdateGeneration: UInt64 = 0
|
|
48
55
|
internal var isManuallySeeked = false
|
|
49
56
|
internal var currentRepeatMode: RepeatMode = .off
|
|
50
57
|
internal var currentPlaybackSpeed: Double = 1.0
|
|
@@ -239,6 +246,7 @@ class TrackPlayerCore: NSObject {
|
|
|
239
246
|
NotificationCenter.default.removeObserver(self)
|
|
240
247
|
self.pathMonitor?.cancel()
|
|
241
248
|
self.pathMonitor = nil
|
|
249
|
+
for asset in self.preloadedAssets.values { asset.cancelLoading() }
|
|
242
250
|
self.preloadedAssets.removeAll()
|
|
243
251
|
self.failedItemRetryCounts.removeAll()
|
|
244
252
|
self.redirectResolver.clear()
|