react-native-nitro-player 1.5.0-alpha.1 → 1.5.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/core/ExoPlayerCore.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +75 -37
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +106 -23
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +15 -3
- package/ios/core/CastSessionManager.swift +191 -56
- package/ios/core/TrackPlayerCast.swift +231 -10
- package/ios/core/TrackPlayerPlayback.swift +3 -1
- package/ios/core/TrackPlayerQueue.swift +27 -11
- package/ios/core/TrackPlayerQueueBuild.swift +6 -0
- package/ios/core/TrackPlayerUrlLoader.swift +26 -2
- package/package.json +1 -1
|
@@ -80,6 +80,8 @@ class ExoPlayerCore(
|
|
|
80
80
|
}
|
|
81
81
|
val currentMediaItem: MediaItem? get() = player.currentMediaItem
|
|
82
82
|
val currentMediaItemIndex: Int get() = player.currentMediaItemIndex
|
|
83
|
+
|
|
84
|
+
fun getMediaItemAt(index: Int): MediaItem = player.getMediaItemAt(index)
|
|
83
85
|
val currentPosition: Long get() = player.currentPosition
|
|
84
86
|
val duration: Long get() = player.duration
|
|
85
87
|
val mediaItemCount: Int get() = player.mediaItemCount
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
package com.margelo.nitro.nitroplayer.core
|
|
4
4
|
|
|
5
5
|
import android.app.Activity
|
|
6
|
-
import androidx.media3.common.MediaItem
|
|
7
6
|
import androidx.media3.common.Player
|
|
8
7
|
import com.margelo.nitro.nitroplayer.CastState
|
|
8
|
+
import com.margelo.nitro.nitroplayer.TrackItem
|
|
9
9
|
import com.margelo.nitro.nitroplayer.media.NitroCastConfig
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -21,35 +21,91 @@ import com.margelo.nitro.nitroplayer.media.NitroCastConfig
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
// ── Backend switching ──────────────────────────────────────────────────────
|
|
24
|
+
// The queue is rebuilt for the target backend (not item-copied): cast needs remote URLs, local prefers download paths.
|
|
24
25
|
|
|
25
26
|
internal fun TrackPlayerCore.switchToCastPlayer() {
|
|
26
27
|
val controller = castSessionController ?: return
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
if (!isExoInitialized) return
|
|
29
|
+
val local = exo.player
|
|
30
|
+
if (local === controller.castPlayer) return
|
|
31
|
+
|
|
32
|
+
// Capture context BEFORE swapping — the queue helpers read `exo`, and the cast timeline is still empty.
|
|
33
|
+
val currentId = local.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
34
|
+
val currentTrack = getCurrentTrack() ?: currentTracks.getOrNull(currentTrackIndex)
|
|
35
|
+
val upcoming = buildUpcomingQueueTracks(currentId)
|
|
36
|
+
val positionMs = local.currentPosition.coerceAtLeast(0L)
|
|
37
|
+
val wasPlaying = local.playWhenReady
|
|
38
|
+
val repeat = local.repeatMode
|
|
39
|
+
|
|
40
|
+
playerListener?.let { local.removeListener(it) }
|
|
41
|
+
local.playWhenReady = false // silence local output — audio now comes from the device
|
|
42
|
+
|
|
43
|
+
isCastingField = true // before building items so makeMediaItem picks remote URLs
|
|
44
|
+
exo = ExoPlayerCore(controller.castPlayer)
|
|
45
|
+
playerListener?.let { controller.castPlayer.addListener(it) }
|
|
46
|
+
setMediaSessionPlayer(controller.castPlayer)
|
|
47
|
+
|
|
48
|
+
val castList = ArrayList<TrackItem>()
|
|
49
|
+
currentTrack?.let { castList.add(it) }
|
|
50
|
+
castList.addAll(upcoming)
|
|
51
|
+
val playable = castableUpcoming(castList)
|
|
52
|
+
|
|
53
|
+
if (playable.isNotEmpty()) {
|
|
54
|
+
lastCastWaitTrackId = null
|
|
55
|
+
val items = playable.map { makeMediaItem(it, mediaIdFor(it)) }
|
|
56
|
+
// Resume mid-track only when the receiver starts on the locally playing track.
|
|
57
|
+
val resumeFromCurrent = currentTrack != null && playable.first().id == currentTrack.id
|
|
58
|
+
controller.castPlayer.setMediaItems(items, 0, if (resumeFromCurrent) positionMs else 0L)
|
|
59
|
+
controller.castPlayer.repeatMode = repeat
|
|
60
|
+
controller.castPlayer.playWhenReady = wasPlaying
|
|
61
|
+
controller.castPlayer.prepare()
|
|
62
|
+
} else {
|
|
63
|
+
// Nothing castable yet (lazy queue) — request URLs; updateTracks will load.
|
|
64
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
notifyCastStateChange(CastState.CONNECTED, controller.currentDeviceName())
|
|
30
68
|
}
|
|
31
69
|
|
|
32
70
|
internal fun TrackPlayerCore.switchToLocalPlayer() {
|
|
33
71
|
val controller = castSessionController ?: return
|
|
34
|
-
switchToPlayer(controller.localPlayer)
|
|
35
|
-
isCastingField = false
|
|
36
|
-
notifyCastStateChange(controller.getCastState(), controller.currentDeviceName())
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
private fun TrackPlayerCore.switchToPlayer(target: Player) {
|
|
40
72
|
if (!isExoInitialized) return
|
|
41
|
-
val
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
val cast = exo.player
|
|
74
|
+
if (cast === controller.localPlayer) return
|
|
75
|
+
|
|
76
|
+
// Capture context before swapping (the cast timeline empties once the session ends).
|
|
77
|
+
val currentId = cast.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
78
|
+
val currentTrack = getCurrentTrack() ?: currentTracks.getOrNull(currentTrackIndex)
|
|
79
|
+
val upcoming = buildUpcomingQueueTracks(currentId)
|
|
80
|
+
val positionMs = cast.currentPosition.coerceAtLeast(0L)
|
|
81
|
+
val wasPlaying = cast.playWhenReady
|
|
82
|
+
val repeat = cast.repeatMode
|
|
83
|
+
|
|
84
|
+
playerListener?.let { cast.removeListener(it) }
|
|
85
|
+
cast.playWhenReady = false
|
|
86
|
+
|
|
87
|
+
isCastingField = false // before building items so makeMediaItem restores download paths
|
|
88
|
+
lastCastWaitTrackId = null
|
|
89
|
+
exo = ExoPlayerCore(controller.localPlayer)
|
|
90
|
+
playerListener?.let { controller.localPlayer.addListener(it) }
|
|
91
|
+
setMediaSessionPlayer(controller.localPlayer)
|
|
92
|
+
|
|
93
|
+
val localList = ArrayList<TrackItem>()
|
|
94
|
+
currentTrack?.let { localList.add(it) }
|
|
95
|
+
localList.addAll(upcoming)
|
|
96
|
+
|
|
97
|
+
if (localList.isNotEmpty()) {
|
|
98
|
+
val items = localList.map { makeMediaItem(it, mediaIdFor(it)) }
|
|
99
|
+
controller.localPlayer.setMediaItems(items, 0, positionMs)
|
|
100
|
+
controller.localPlayer.repeatMode = repeat
|
|
101
|
+
controller.localPlayer.playWhenReady = wasPlaying
|
|
102
|
+
controller.localPlayer.prepare()
|
|
103
|
+
}
|
|
49
104
|
|
|
50
|
-
|
|
51
|
-
|
|
105
|
+
notifyCastStateChange(controller.getCastState(), controller.currentDeviceName())
|
|
106
|
+
}
|
|
52
107
|
|
|
108
|
+
private fun TrackPlayerCore.setMediaSessionPlayer(target: Player) {
|
|
53
109
|
try {
|
|
54
110
|
castSessionController?.let { it.mediaSession.player = target }
|
|
55
111
|
} catch (e: Exception) {
|
|
@@ -57,24 +113,6 @@ private fun TrackPlayerCore.switchToPlayer(target: Player) {
|
|
|
57
113
|
}
|
|
58
114
|
}
|
|
59
115
|
|
|
60
|
-
/** Copy the queue, index, position, repeat mode and play-when-ready from one player to another. */
|
|
61
|
-
private fun transferPlaybackState(
|
|
62
|
-
from: Player,
|
|
63
|
-
to: Player,
|
|
64
|
-
) {
|
|
65
|
-
val count = from.mediaItemCount
|
|
66
|
-
if (count == 0) return
|
|
67
|
-
val items = ArrayList<MediaItem>(count)
|
|
68
|
-
for (i in 0 until count) items.add(from.getMediaItemAt(i))
|
|
69
|
-
val index = from.currentMediaItemIndex.coerceAtLeast(0)
|
|
70
|
-
val position = if (from.currentPosition >= 0) from.currentPosition else 0L
|
|
71
|
-
|
|
72
|
-
to.setMediaItems(items, index, position)
|
|
73
|
-
to.repeatMode = from.repeatMode
|
|
74
|
-
to.playWhenReady = from.playWhenReady
|
|
75
|
-
to.prepare()
|
|
76
|
-
}
|
|
77
|
-
|
|
78
116
|
// ── Notifications ──────────────────────────────────────────────────────────
|
|
79
117
|
|
|
80
118
|
internal fun TrackPlayerCore.notifyCastStateChange(
|
|
@@ -100,6 +100,9 @@ class TrackPlayerCore private constructor(
|
|
|
100
100
|
/** True while the active backend is the CastPlayer (audio routed to a remote device). */
|
|
101
101
|
@Volatile internal var isCastingField: Boolean = false
|
|
102
102
|
|
|
103
|
+
/** Last track announced from a cast "waiting for URL" state — dedups onChangeTrack across updateTracks re-entries. */
|
|
104
|
+
internal var lastCastWaitTrackId: String? = null
|
|
105
|
+
|
|
103
106
|
// ── Progress & playlist-update runnables ───────────────────────────────
|
|
104
107
|
internal val progressUpdateRunnable =
|
|
105
108
|
object : Runnable {
|
|
@@ -6,6 +6,7 @@ import android.net.Uri
|
|
|
6
6
|
import androidx.media3.common.MediaItem
|
|
7
7
|
import androidx.media3.common.MediaMetadata
|
|
8
8
|
import androidx.media3.common.Player
|
|
9
|
+
import com.margelo.nitro.nitroplayer.Reason
|
|
9
10
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -15,17 +16,63 @@ import com.margelo.nitro.nitroplayer.TrackItem
|
|
|
15
16
|
* when jumping to a specific index.
|
|
16
17
|
*/
|
|
17
18
|
|
|
19
|
+
// ── Cast-safety helpers ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** A track the Cast receiver can actually load: non-empty remote (non-local) URL. */
|
|
22
|
+
internal fun TrackPlayerCore.isTrackCastable(track: TrackItem): Boolean =
|
|
23
|
+
track.url.isNotEmpty() &&
|
|
24
|
+
!track.url.startsWith("/") &&
|
|
25
|
+
!track.url.startsWith("file:")
|
|
26
|
+
|
|
27
|
+
/** Receiver-safe run: keeps castable tracks, skips local-only ones, stops at the first lazy (empty-URL) track whose resolution is pending — receivers auto-skip failed loads instead of waiting like ExoPlayer. */
|
|
28
|
+
internal fun TrackPlayerCore.castableUpcoming(tracks: List<TrackItem>): List<TrackItem> {
|
|
29
|
+
val result = ArrayList<TrackItem>(tracks.size)
|
|
30
|
+
for (track in tracks) {
|
|
31
|
+
when {
|
|
32
|
+
isTrackCastable(track) -> result.add(track)
|
|
33
|
+
track.url.isEmpty() -> break
|
|
34
|
+
// local-only → skip and keep scanning
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
internal fun TrackPlayerCore.mediaIdFor(track: TrackItem): String {
|
|
41
|
+
val playlistId = currentPlaylistId ?: ""
|
|
42
|
+
return if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
|
|
43
|
+
}
|
|
44
|
+
|
|
18
45
|
// ── Full rebuild (jump to index) ───────────────────────────────────────────
|
|
19
46
|
|
|
20
47
|
internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
|
|
21
48
|
if (!isExoInitialized) return
|
|
22
49
|
if (index < 0 || index >= currentTracks.size) return
|
|
23
50
|
|
|
24
|
-
|
|
51
|
+
if (isCastingField) {
|
|
52
|
+
currentTrackIndex = index
|
|
53
|
+
val target = currentTracks[index]
|
|
54
|
+
val castTracks = castableUpcoming(currentTracks.subList(index, currentTracks.size))
|
|
55
|
+
if (castTracks.isEmpty()) {
|
|
56
|
+
// Lazy target — silence the receiver and wait for updateTracks to reload (dedup so re-entries don't spam JS).
|
|
57
|
+
if (exo.mediaItemCount > 0) exo.clearMediaItems()
|
|
58
|
+
if (lastCastWaitTrackId != target.id) {
|
|
59
|
+
lastCastWaitTrackId = target.id
|
|
60
|
+
notifyTrackChange(target, Reason.SKIP)
|
|
61
|
+
}
|
|
62
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
lastCastWaitTrackId = null
|
|
66
|
+
// Single atomic queueLoad — no separate clear (each queue op is a receiver RPC).
|
|
67
|
+
exo.setMediaItems(castTracks.map { makeMediaItem(it, mediaIdFor(it)) }, true)
|
|
68
|
+
exo.prepare()
|
|
69
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
25
73
|
val mediaItems =
|
|
26
74
|
currentTracks.subList(index, currentTracks.size).map { track ->
|
|
27
|
-
|
|
28
|
-
makeMediaItem(track, mediaId)
|
|
75
|
+
makeMediaItem(track, mediaIdFor(track))
|
|
29
76
|
}
|
|
30
77
|
|
|
31
78
|
currentTrackIndex = index
|
|
@@ -63,8 +110,38 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
63
110
|
}
|
|
64
111
|
}
|
|
65
112
|
|
|
66
|
-
val newQueueTracks = ArrayList<TrackItem>(playNextStack.size + upNextQueue.size + currentTracks.size)
|
|
67
113
|
val currentId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
114
|
+
var newQueueTracks: List<TrackItem> = buildUpcomingQueueTracks(currentId)
|
|
115
|
+
|
|
116
|
+
if (isCastingField) {
|
|
117
|
+
newQueueTracks = castableUpcoming(newQueueTracks)
|
|
118
|
+
|
|
119
|
+
// Every queue edit is a receiver RPC — skip the rebuild when the remote queue already matches.
|
|
120
|
+
val itemCount = exo.mediaItemCount
|
|
121
|
+
if (itemCount - currentIndex - 1 == newQueueTracks.size) {
|
|
122
|
+
var inSync = true
|
|
123
|
+
for (i in newQueueTracks.indices) {
|
|
124
|
+
val existingId = extractTrackId(exo.getMediaItemAt(currentIndex + 1 + i).mediaId)
|
|
125
|
+
if (existingId != newQueueTracks[i].id) {
|
|
126
|
+
inSync = false
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (inSync) return
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
val newMediaItems = newQueueTracks.map { makeMediaItem(it, mediaIdFor(it)) }
|
|
135
|
+
|
|
136
|
+
if (exo.mediaItemCount > currentIndex + 1) {
|
|
137
|
+
exo.removeMediaItems(currentIndex + 1, exo.mediaItemCount)
|
|
138
|
+
}
|
|
139
|
+
exo.addMediaItems(newMediaItems)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Desired upcoming list after the current track: playNext + upNext + remaining originals, with the playing temp track ([currentId]) skipped. */
|
|
143
|
+
internal fun TrackPlayerCore.buildUpcomingQueueTracks(currentId: String?): List<TrackItem> {
|
|
144
|
+
val newQueueTracks = ArrayList<TrackItem>(playNextStack.size + upNextQueue.size + currentTracks.size)
|
|
68
145
|
|
|
69
146
|
// playNext stack — skip the currently playing track by ID (not position)
|
|
70
147
|
if (currentTemporaryType == TrackPlayerCore.TemporaryType.PLAY_NEXT && currentId != null) {
|
|
@@ -94,34 +171,39 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
94
171
|
newQueueTracks.addAll(upNextQueue)
|
|
95
172
|
}
|
|
96
173
|
|
|
97
|
-
// Remaining original tracks (after currentTrackIndex, not after
|
|
174
|
+
// Remaining original tracks (after currentTrackIndex, not after the player's currentIndex)
|
|
98
175
|
if (currentTrackIndex + 1 < currentTracks.size) {
|
|
99
176
|
newQueueTracks.addAll(currentTracks.subList(currentTrackIndex + 1, currentTracks.size))
|
|
100
177
|
}
|
|
101
|
-
|
|
102
|
-
val playlistId = currentPlaylistId ?: ""
|
|
103
|
-
val newMediaItems =
|
|
104
|
-
newQueueTracks.map { track ->
|
|
105
|
-
val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
|
|
106
|
-
makeMediaItem(track, mediaId)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (exo.mediaItemCount > currentIndex + 1) {
|
|
110
|
-
exo.removeMediaItems(currentIndex + 1, exo.mediaItemCount)
|
|
111
|
-
}
|
|
112
|
-
exo.addMediaItems(newMediaItems)
|
|
178
|
+
return newQueueTracks
|
|
113
179
|
}
|
|
114
180
|
|
|
115
181
|
// ── Full queue set (initial load or no active item) ───────────────────────
|
|
116
182
|
|
|
117
183
|
internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
|
|
118
184
|
currentTracks = tracks
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
185
|
+
|
|
186
|
+
if (isCastingField) {
|
|
187
|
+
currentTrackIndex = 0
|
|
188
|
+
val castTracks = castableUpcoming(tracks)
|
|
189
|
+
if (castTracks.isEmpty()) {
|
|
190
|
+
// Lazy first track — silence the receiver and wait for updateTracks to supply URLs.
|
|
191
|
+
if (exo.mediaItemCount > 0) exo.clearMediaItems()
|
|
192
|
+
val first = tracks.firstOrNull()
|
|
193
|
+
if (first != null && lastCastWaitTrackId != first.id) {
|
|
194
|
+
lastCastWaitTrackId = first.id
|
|
195
|
+
notifyTrackChange(first, null)
|
|
196
|
+
}
|
|
197
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
198
|
+
return
|
|
124
199
|
}
|
|
200
|
+
lastCastWaitTrackId = null
|
|
201
|
+
exo.setMediaItems(castTracks.map { makeMediaItem(it, mediaIdFor(it)) }, true)
|
|
202
|
+
exo.prepare()
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
val mediaItems = tracks.map { makeMediaItem(it, mediaIdFor(it)) }
|
|
125
207
|
exo.setMediaItems(mediaItems, true)
|
|
126
208
|
if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
|
|
127
209
|
exo.prepare()
|
|
@@ -148,7 +230,8 @@ internal fun TrackPlayerCore.makeMediaItem(
|
|
|
148
230
|
}
|
|
149
231
|
}
|
|
150
232
|
|
|
151
|
-
|
|
233
|
+
// The receiver fetches media itself, so local download paths are unplayable there — cast uses the remote URL.
|
|
234
|
+
val effectiveUrl = if (isCastingField) track.url else downloadManager.getEffectiveUrl(track)
|
|
152
235
|
|
|
153
236
|
// Register custom HTTP headers (e.g. Authorization) from extraPayload.headers so they are
|
|
154
237
|
// injected into the request for this URL. Applies to remote streams only — see ExoPlayerBuilder.
|
|
@@ -36,12 +36,13 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
|
36
36
|
|
|
37
37
|
val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
|
|
38
38
|
|
|
39
|
-
// Replace current
|
|
40
|
-
|
|
39
|
+
// Replace the current MediaItem when its URL just resolved (local only — the cast branch below reloads instead).
|
|
40
|
+
val currentTrackResolvedNow = currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()
|
|
41
|
+
if (!isCastingField && currentTrackResolvedNow) {
|
|
41
42
|
val exoIndex = exo.currentMediaItemIndex
|
|
42
43
|
if (exoIndex >= 0) {
|
|
43
44
|
val playlistId = currentPlaylistId ?: ""
|
|
44
|
-
val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate
|
|
45
|
+
val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate!!.id}" else currentTrackUpdate!!.id
|
|
45
46
|
exo.replaceMediaItem(exoIndex, makeMediaItem(currentTrackUpdate, mediaId))
|
|
46
47
|
if (exo.playbackState == Player.STATE_IDLE) exo.prepare()
|
|
47
48
|
}
|
|
@@ -55,6 +56,17 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
|
55
56
|
playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
|
|
56
57
|
upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
if (isCastingField) {
|
|
61
|
+
// Resolved current track (or empty receiver) → atomic reload from the current index; otherwise resync only the upcoming items.
|
|
62
|
+
if (currentTrackResolvedNow || exo.mediaItemCount == 0) {
|
|
63
|
+
rebuildQueueAndPlayFromIndex(currentTrackIndex)
|
|
64
|
+
} else {
|
|
65
|
+
rebuildQueueFromCurrentPosition()
|
|
66
|
+
}
|
|
67
|
+
return@withPlayerContext
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
rebuildQueueFromCurrentPosition()
|
|
59
71
|
}
|
|
60
72
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// `#if canImport(GoogleCast)` so the library still builds when the SDK is not
|
|
8
8
|
// linked — in that case every method is an inert no-op and casting is disabled.
|
|
9
9
|
//
|
|
10
|
+
// Threading: the SDK is main-thread-affine, so SDK calls hop to main and reads are served from lock-guarded caches.
|
|
11
|
+
//
|
|
10
12
|
|
|
11
13
|
import Foundation
|
|
12
14
|
import MediaPlayer
|
|
@@ -19,15 +21,67 @@ final class CastSessionManager: NSObject {
|
|
|
19
21
|
weak var core: TrackPlayerCore?
|
|
20
22
|
|
|
21
23
|
private var isConfigured = false
|
|
22
|
-
private var
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
private var progressTimer: Timer?
|
|
25
|
+
|
|
26
|
+
// MARK: - Lock-guarded caches (written on main, read from playerQueue / JS thread)
|
|
27
|
+
|
|
28
|
+
private let stateLock = NSLock()
|
|
29
|
+
private var _lastCurrentTrackId: String?
|
|
30
|
+
private var _loadedTracks: [TrackItem] = []
|
|
31
|
+
private var _hasLoadedMedia = false
|
|
32
|
+
private var _cachedPlaybackState: TrackPlayerState = .stopped
|
|
33
|
+
private var _lastEmittedPlaybackState: TrackPlayerState?
|
|
34
|
+
private var _lastKnownRemotePosition: Double = 0
|
|
35
|
+
private var _lastKnownRemoteDuration: Double = 0
|
|
26
36
|
private var cachedState: CastState = .noDevicesAvailable
|
|
27
37
|
private var cachedDeviceName: String?
|
|
38
|
+
|
|
39
|
+
/// Track ID playing on the receiver (or expected to, right after a locally-initiated jump).
|
|
40
|
+
var currentRemoteTrackId: String? {
|
|
41
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
42
|
+
return _lastCurrentTrackId
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// IDs of the tracks last sent to the receiver, in queue order.
|
|
46
|
+
var loadedTrackIds: [String] {
|
|
47
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
48
|
+
return _loadedTracks.map { $0.id }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Whether the receiver currently has media loaded.
|
|
52
|
+
var hasLoadedMedia: Bool {
|
|
53
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
54
|
+
return _hasLoadedMedia
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/// Last position reported by the receiver, used to resume locally on disconnect.
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
var lastKnownRemotePosition: Double {
|
|
59
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
60
|
+
return _lastKnownRemotePosition
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Last duration reported by the receiver for the current item.
|
|
64
|
+
var lastKnownRemoteDuration: Double {
|
|
65
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
66
|
+
return _lastKnownRemoteDuration
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Last playback state reported by the receiver.
|
|
70
|
+
var cachedPlaybackState: TrackPlayerState {
|
|
71
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
72
|
+
return _cachedPlaybackState
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Pre-set the expected current track after a local jump so the receiver's status echo doesn't duplicate the track-change event.
|
|
76
|
+
func setExpectedCurrentTrack(_ trackId: String?) {
|
|
77
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
78
|
+
_lastCurrentTrackId = trackId
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func withState<T>(_ body: () -> T) -> T {
|
|
82
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
83
|
+
return body()
|
|
84
|
+
}
|
|
31
85
|
|
|
32
86
|
// MARK: - Configuration
|
|
33
87
|
|
|
@@ -66,17 +120,23 @@ final class CastSessionManager: NSObject {
|
|
|
66
120
|
|
|
67
121
|
var isCasting: Bool {
|
|
68
122
|
#if canImport(GoogleCast)
|
|
69
|
-
return
|
|
123
|
+
return currentCastState() == .connected
|
|
70
124
|
#else
|
|
71
125
|
return false
|
|
72
126
|
#endif
|
|
73
127
|
}
|
|
74
128
|
|
|
75
129
|
/// Cached — safe to call from the JS thread.
|
|
76
|
-
func currentCastState() -> CastState {
|
|
130
|
+
func currentCastState() -> CastState {
|
|
131
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
132
|
+
return cachedState
|
|
133
|
+
}
|
|
77
134
|
|
|
78
135
|
/// Cached — safe to call from the JS thread.
|
|
79
|
-
func deviceName() -> String? {
|
|
136
|
+
func deviceName() -> String? {
|
|
137
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
138
|
+
return cachedDeviceName
|
|
139
|
+
}
|
|
80
140
|
|
|
81
141
|
// MARK: - UI / session control
|
|
82
142
|
|
|
@@ -128,12 +188,6 @@ final class CastSessionManager: NSObject {
|
|
|
128
188
|
#endif
|
|
129
189
|
}
|
|
130
190
|
|
|
131
|
-
func skipToPrevious() {
|
|
132
|
-
#if canImport(GoogleCast)
|
|
133
|
-
onMain { self.remoteClient?.queuePreviousItem() }
|
|
134
|
-
#endif
|
|
135
|
-
}
|
|
136
|
-
|
|
137
191
|
func setVolume(_ volume0to1: Float) {
|
|
138
192
|
#if canImport(GoogleCast)
|
|
139
193
|
onMain { self.remoteClient?.setStreamVolume(volume0to1) }
|
|
@@ -146,38 +200,81 @@ final class CastSessionManager: NSObject {
|
|
|
146
200
|
#endif
|
|
147
201
|
}
|
|
148
202
|
|
|
149
|
-
func
|
|
203
|
+
func setQueueRepeatMode(_ mode: RepeatMode) {
|
|
150
204
|
#if canImport(GoogleCast)
|
|
151
205
|
onMain {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
206
|
+
let gckMode: GCKMediaRepeatMode
|
|
207
|
+
switch mode {
|
|
208
|
+
case .track: gckMode = .single
|
|
209
|
+
case .playlist: gckMode = .all
|
|
210
|
+
default: gckMode = .off
|
|
157
211
|
}
|
|
212
|
+
self.remoteClient?.queueSetRepeatMode(gckMode)
|
|
213
|
+
}
|
|
214
|
+
#endif
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// Stop and unload the receiver (used when jumping to a track whose URL hasn't resolved yet).
|
|
218
|
+
func stop() {
|
|
219
|
+
#if canImport(GoogleCast)
|
|
220
|
+
// No-op when nothing is loaded — avoids a pointless receiver RPC per updateTracks re-entry.
|
|
221
|
+
let hadMedia: Bool = withState {
|
|
222
|
+
let had = _hasLoadedMedia || !_loadedTracks.isEmpty
|
|
223
|
+
_hasLoadedMedia = false
|
|
224
|
+
_loadedTracks = []
|
|
225
|
+
return had
|
|
158
226
|
}
|
|
227
|
+
guard hadMedia else { return }
|
|
228
|
+
onMain { self.remoteClient?.stop() }
|
|
159
229
|
#endif
|
|
160
230
|
}
|
|
161
231
|
|
|
162
232
|
// MARK: - Queue loading
|
|
163
233
|
|
|
164
|
-
|
|
234
|
+
/// Atomically replace the receiver queue — `tracks` must be castable and start at the item that should play.
|
|
235
|
+
func loadQueue(tracks: [TrackItem], position: Double, autoplay: Bool, repeatMode: RepeatMode) {
|
|
165
236
|
#if canImport(GoogleCast)
|
|
237
|
+
guard !tracks.isEmpty else { return }
|
|
238
|
+
withState {
|
|
239
|
+
_loadedTracks = tracks
|
|
240
|
+
_hasLoadedMedia = true // optimistic; corrected by the next media status
|
|
241
|
+
_lastCurrentTrackId = tracks.first?.id
|
|
242
|
+
}
|
|
166
243
|
onMain {
|
|
167
|
-
guard let client = self.remoteClient
|
|
168
|
-
self.loadedTracks = tracks
|
|
244
|
+
guard let client = self.remoteClient else { return }
|
|
169
245
|
let items = tracks.compactMap { self.makeQueueItem(for: $0, autoplay: autoplay) }
|
|
170
246
|
guard !items.isEmpty else { return }
|
|
171
247
|
|
|
172
248
|
let options = GCKMediaQueueLoadOptions()
|
|
173
|
-
options.startIndex =
|
|
174
|
-
options.playPosition = position.isFinite ? position : 0
|
|
175
|
-
|
|
249
|
+
options.startIndex = 0
|
|
250
|
+
options.playPosition = position.isFinite ? max(0, position) : 0
|
|
251
|
+
switch repeatMode {
|
|
252
|
+
case .track: options.repeatMode = .single
|
|
253
|
+
case .playlist: options.repeatMode = .all
|
|
254
|
+
default: options.repeatMode = .off
|
|
255
|
+
}
|
|
176
256
|
client.queueLoad(items, with: options)
|
|
177
257
|
}
|
|
178
258
|
#endif
|
|
179
259
|
}
|
|
180
260
|
|
|
261
|
+
/// Append tracks to the END of the receiver queue without touching playback.
|
|
262
|
+
func appendToQueue(tracks: [TrackItem]) {
|
|
263
|
+
#if canImport(GoogleCast)
|
|
264
|
+
guard !tracks.isEmpty else { return }
|
|
265
|
+
withState {
|
|
266
|
+
let known = Set(_loadedTracks.map { $0.id })
|
|
267
|
+
_loadedTracks.append(contentsOf: tracks.filter { !known.contains($0.id) })
|
|
268
|
+
}
|
|
269
|
+
onMain {
|
|
270
|
+
guard let client = self.remoteClient else { return }
|
|
271
|
+
let items = tracks.compactMap { self.makeQueueItem(for: $0, autoplay: true) }
|
|
272
|
+
guard !items.isEmpty else { return }
|
|
273
|
+
client.queueInsert(items, beforeItemWithID: kGCKMediaQueueInvalidItemID)
|
|
274
|
+
}
|
|
275
|
+
#endif
|
|
276
|
+
}
|
|
277
|
+
|
|
181
278
|
// MARK: - Private helpers
|
|
182
279
|
|
|
183
280
|
private func onMain(_ block: @escaping () -> Void) {
|
|
@@ -191,8 +288,9 @@ final class CastSessionManager: NSObject {
|
|
|
191
288
|
}
|
|
192
289
|
|
|
193
290
|
private func makeQueueItem(for track: TrackItem, autoplay: Bool) -> GCKMediaQueueItem? {
|
|
194
|
-
|
|
195
|
-
|
|
291
|
+
// The receiver fetches media itself — only remote URLs are playable (callers pre-filter with isTrackCastable).
|
|
292
|
+
let urlString = track.url
|
|
293
|
+
guard !urlString.isEmpty, let contentURL = URL(string: urlString) else { return nil }
|
|
196
294
|
|
|
197
295
|
let metadata = GCKMediaMetadata(metadataType: .musicTrack)
|
|
198
296
|
metadata.setString(track.title, forKey: kGCKMetadataKeyTitle)
|
|
@@ -249,7 +347,10 @@ final class CastSessionManager: NSObject {
|
|
|
249
347
|
let position = client.approximateStreamPosition()
|
|
250
348
|
let duration = client.mediaStatus?.mediaInformation?.streamDuration ?? 0
|
|
251
349
|
let safeDuration = (duration.isFinite && duration > 0) ? duration : 0
|
|
252
|
-
|
|
350
|
+
withState {
|
|
351
|
+
if position.isFinite { _lastKnownRemotePosition = position }
|
|
352
|
+
_lastKnownRemoteDuration = safeDuration
|
|
353
|
+
}
|
|
253
354
|
core?.emitCastProgress(position.isFinite ? position : 0, safeDuration)
|
|
254
355
|
updateNowPlaying(position: position, duration: safeDuration)
|
|
255
356
|
}
|
|
@@ -259,14 +360,10 @@ final class CastSessionManager: NSObject {
|
|
|
259
360
|
guard let status else { return nil }
|
|
260
361
|
let item = status.queueItem(withItemID: status.currentItemID)
|
|
261
362
|
let info = item?.mediaInformation ?? status.mediaInformation
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
if let url = info?.contentURL?.absoluteString {
|
|
267
|
-
return loadedTracks.first { DownloadManagerCore.shared.getEffectiveUrl(track: $0) == url }
|
|
268
|
-
}
|
|
269
|
-
return nil
|
|
363
|
+
guard let customData = info?.customData as? [String: Any],
|
|
364
|
+
let trackId = customData["trackId"] as? String
|
|
365
|
+
else { return nil }
|
|
366
|
+
return withState { _loadedTracks.first { $0.id == trackId } }
|
|
270
367
|
}
|
|
271
368
|
|
|
272
369
|
private func mapPlayerState(_ state: GCKMediaPlayerState) -> TrackPlayerState {
|
|
@@ -280,7 +377,8 @@ final class CastSessionManager: NSObject {
|
|
|
280
377
|
}
|
|
281
378
|
|
|
282
379
|
private func updateNowPlaying(position: Double, duration: Double) {
|
|
283
|
-
|
|
380
|
+
let track = withState { _lastCurrentTrackId.flatMap { id in _loadedTracks.first { $0.id == id } } }
|
|
381
|
+
guard let track else { return }
|
|
284
382
|
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
|
285
383
|
info[MPMediaItemPropertyTitle] = track.title
|
|
286
384
|
info[MPMediaItemPropertyArtist] = track.artist
|
|
@@ -299,25 +397,32 @@ final class CastSessionManager: NSObject {
|
|
|
299
397
|
/// Refreshes the cache from live Cast state and notifies listeners. Callers must be on the main thread.
|
|
300
398
|
private func emitCastState() {
|
|
301
399
|
refreshCacheOnMain()
|
|
302
|
-
core?.notifyCastStateChangeListeners(
|
|
400
|
+
core?.notifyCastStateChangeListeners(currentCastState(), deviceName())
|
|
303
401
|
}
|
|
304
402
|
|
|
305
403
|
/// Re-read live Cast state into the cache. MUST be called on the main thread.
|
|
306
404
|
private func refreshCacheOnMain() {
|
|
307
405
|
#if canImport(GoogleCast)
|
|
308
406
|
guard isConfigured else {
|
|
309
|
-
|
|
310
|
-
|
|
407
|
+
withState {
|
|
408
|
+
cachedState = .noDevicesAvailable
|
|
409
|
+
cachedDeviceName = nil
|
|
410
|
+
}
|
|
311
411
|
return
|
|
312
412
|
}
|
|
413
|
+
let newState: CastState
|
|
313
414
|
switch GCKCastContext.sharedInstance().castState {
|
|
314
|
-
case .noDevicesAvailable:
|
|
315
|
-
case .notConnected:
|
|
316
|
-
case .connecting:
|
|
317
|
-
case .connected:
|
|
318
|
-
@unknown default:
|
|
415
|
+
case .noDevicesAvailable: newState = .noDevicesAvailable
|
|
416
|
+
case .notConnected: newState = .notConnected
|
|
417
|
+
case .connecting: newState = .connecting
|
|
418
|
+
case .connected: newState = .connected
|
|
419
|
+
@unknown default: newState = .notConnected
|
|
420
|
+
}
|
|
421
|
+
let name = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.device.friendlyName
|
|
422
|
+
withState {
|
|
423
|
+
cachedState = newState
|
|
424
|
+
cachedDeviceName = name
|
|
319
425
|
}
|
|
320
|
-
cachedDeviceName = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.device.friendlyName
|
|
321
426
|
#endif
|
|
322
427
|
}
|
|
323
428
|
}
|
|
@@ -340,7 +445,13 @@ extension CastSessionManager: GCKSessionManagerListener {
|
|
|
340
445
|
|
|
341
446
|
func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
|
|
342
447
|
stopProgressTimer()
|
|
343
|
-
|
|
448
|
+
withState {
|
|
449
|
+
_lastCurrentTrackId = nil
|
|
450
|
+
_loadedTracks = []
|
|
451
|
+
_hasLoadedMedia = false
|
|
452
|
+
_cachedPlaybackState = .stopped
|
|
453
|
+
_lastEmittedPlaybackState = nil
|
|
454
|
+
}
|
|
344
455
|
core?.handleCastDisconnected()
|
|
345
456
|
emitCastState()
|
|
346
457
|
}
|
|
@@ -359,15 +470,39 @@ extension CastSessionManager: GCKRemoteMediaClientListener {
|
|
|
359
470
|
func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) {
|
|
360
471
|
guard let core else { return }
|
|
361
472
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
473
|
+
let hasMedia = mediaStatus?.mediaInformation != nil
|
|
474
|
+
let state = mapPlayerState(mediaStatus?.playerState ?? .unknown)
|
|
475
|
+
|
|
476
|
+
// Track change — core state mutations must run on the player queue.
|
|
477
|
+
if let track = currentRemoteTrack(mediaStatus) {
|
|
478
|
+
let previousId: String? = withState {
|
|
479
|
+
let prev = _lastCurrentTrackId
|
|
480
|
+
_lastCurrentTrackId = track.id
|
|
481
|
+
_hasLoadedMedia = hasMedia
|
|
482
|
+
_cachedPlaybackState = state
|
|
483
|
+
return prev
|
|
484
|
+
}
|
|
485
|
+
if previousId != track.id {
|
|
486
|
+
core.playerQueue.async {
|
|
487
|
+
core.emitCastTrackChange(track, previousTrackId: previousId)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
withState {
|
|
492
|
+
_hasLoadedMedia = hasMedia
|
|
493
|
+
_cachedPlaybackState = state
|
|
494
|
+
}
|
|
366
495
|
}
|
|
367
496
|
|
|
368
|
-
// Playback state
|
|
369
|
-
let
|
|
370
|
-
|
|
497
|
+
// Playback state — only forward actual changes (status updates are frequent).
|
|
498
|
+
let stateChanged: Bool = withState {
|
|
499
|
+
guard _lastEmittedPlaybackState != state else { return false }
|
|
500
|
+
_lastEmittedPlaybackState = state
|
|
501
|
+
return true
|
|
502
|
+
}
|
|
503
|
+
if stateChanged {
|
|
504
|
+
core.emitCastPlaybackState(state)
|
|
505
|
+
}
|
|
371
506
|
}
|
|
372
507
|
}
|
|
373
508
|
#endif
|
|
@@ -10,12 +10,40 @@
|
|
|
10
10
|
// to the receiver. The same queue model (currentTracks / temp queues) stays the
|
|
11
11
|
// single source of truth, so disconnect can restore local playback seamlessly.
|
|
12
12
|
//
|
|
13
|
+
// Receivers auto-skip failed loads (AVPlayer waits), so only receiver-loadable tracks are ever enqueued remotely.
|
|
14
|
+
//
|
|
13
15
|
|
|
14
16
|
import AVFoundation
|
|
15
17
|
import Foundation
|
|
16
18
|
|
|
17
19
|
extension TrackPlayerCore {
|
|
18
20
|
|
|
21
|
+
// MARK: - Cast-safety helpers
|
|
22
|
+
|
|
23
|
+
/// A track the Cast receiver can actually load: non-empty remote (non-local) URL.
|
|
24
|
+
func isTrackCastable(_ track: TrackItem) -> Bool {
|
|
25
|
+
!track.url.isEmpty && !track.url.hasPrefix("/") && !track.url.hasPrefix("file:")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Receiver-safe run: keeps castable tracks, skips local-only ones, stops at the first lazy (empty-URL) track whose resolution is pending.
|
|
29
|
+
func castableUpcoming(_ tracks: [TrackItem]) -> [TrackItem] {
|
|
30
|
+
var result: [TrackItem] = []
|
|
31
|
+
for track in tracks {
|
|
32
|
+
if isTrackCastable(track) {
|
|
33
|
+
result.append(track)
|
|
34
|
+
} else if track.url.isEmpty {
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
// local-only → skip and keep scanning
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Track ID playing on the ACTIVE backend — receiver while casting, local AVQueuePlayer otherwise.
|
|
43
|
+
var activeCurrentTrackId: String? {
|
|
44
|
+
isCasting ? castManager?.currentRemoteTrackId : player?.currentItem?.trackId
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
// MARK: - Session lifecycle (called by CastSessionManager)
|
|
20
48
|
|
|
21
49
|
/// A Cast session became active — transfer current playback to the device.
|
|
@@ -38,9 +66,9 @@ extension TrackPlayerCore {
|
|
|
38
66
|
playerQueue.async { [weak self] in
|
|
39
67
|
guard let self else { return }
|
|
40
68
|
guard self.isCasting else { return }
|
|
69
|
+
let resumePosition = self.castManager?.lastKnownRemotePosition ?? 0
|
|
41
70
|
self.isCasting = false
|
|
42
71
|
|
|
43
|
-
let resumePosition = self.castManager?.lastKnownRemotePosition ?? 0
|
|
44
72
|
let index = self.currentTrackIndex
|
|
45
73
|
guard index >= 0 && index < self.currentTracks.count else { return }
|
|
46
74
|
|
|
@@ -55,27 +83,220 @@ extension TrackPlayerCore {
|
|
|
55
83
|
}
|
|
56
84
|
}
|
|
57
85
|
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
// MARK: - Receiver queue loading (all on playerQueue)
|
|
87
|
+
|
|
88
|
+
/// Load the receiver queue starting at the current track.
|
|
60
89
|
func loadCastQueue(autoplay: Bool, position: Double) {
|
|
61
90
|
let queue = getActualQueueInternal()
|
|
62
91
|
guard !queue.isEmpty else { return }
|
|
63
92
|
let current = getCurrentTrack()
|
|
64
93
|
let startIndex = current
|
|
65
94
|
.flatMap { c in queue.firstIndex(where: { $0.id == c.id }) }
|
|
66
|
-
?? max(0, currentTrackIndex)
|
|
67
|
-
|
|
95
|
+
?? max(0, min(currentTrackIndex, queue.count - 1))
|
|
96
|
+
loadCastQueue(fromActualIndex: startIndex, autoplay: autoplay, position: position)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Atomically (re)load the receiver queue from `index` in the actual queue (lazy targets stop the receiver and wait for updateTracks).
|
|
100
|
+
func loadCastQueue(fromActualIndex index: Int, autoplay: Bool, position: Double) {
|
|
101
|
+
let queue = getActualQueueInternal()
|
|
102
|
+
guard index >= 0, index < queue.count else { return }
|
|
103
|
+
let slice = Array(queue[index...])
|
|
104
|
+
let playable = castableUpcoming(slice)
|
|
105
|
+
|
|
106
|
+
guard !playable.isEmpty else {
|
|
107
|
+
// Lazy target — silence the receiver and wait for updateTracks to reload.
|
|
108
|
+
castManager?.stop()
|
|
109
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Resume mid-track only when the receiver starts on the requested track.
|
|
114
|
+
let effectivePosition = playable[0].id == slice[0].id ? position : 0
|
|
115
|
+
|
|
116
|
+
castManager?.setExpectedCurrentTrack(playable[0].id)
|
|
117
|
+
castManager?.loadQueue(
|
|
118
|
+
tracks: playable,
|
|
119
|
+
position: effectivePosition,
|
|
120
|
+
autoplay: autoplay,
|
|
121
|
+
repeatMode: currentRepeatMode
|
|
122
|
+
)
|
|
123
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Sync the receiver's upcoming items: append-only extensions don't interrupt playback; anything else reloads at the current position.
|
|
127
|
+
func syncCastQueueAfterCurrent() {
|
|
128
|
+
guard let cast = castManager else { return }
|
|
129
|
+
guard cast.hasLoadedMedia else {
|
|
130
|
+
// Nothing loaded remotely (e.g. the target was lazy) — full load from current.
|
|
131
|
+
loadCastQueue(autoplay: intendedToPlay, position: 0)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let queue = getActualQueueInternal()
|
|
136
|
+
guard let current = getCurrentTrack(),
|
|
137
|
+
let currentIdx = queue.firstIndex(where: { $0.id == current.id })
|
|
138
|
+
else { return }
|
|
139
|
+
|
|
140
|
+
let desired = castableUpcoming(Array(queue[(currentIdx + 1)...]))
|
|
141
|
+
let desiredIds = desired.map { $0.id }
|
|
142
|
+
|
|
143
|
+
let loaded = cast.loadedTrackIds
|
|
144
|
+
let existingUpcoming: [String]
|
|
145
|
+
if let ei = loaded.firstIndex(of: current.id) {
|
|
146
|
+
existingUpcoming = Array(loaded[(ei + 1)...])
|
|
147
|
+
} else {
|
|
148
|
+
existingUpcoming = []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if desiredIds == existingUpcoming { return } // already in sync
|
|
152
|
+
|
|
153
|
+
if desiredIds.count > existingUpcoming.count,
|
|
154
|
+
Array(desiredIds.prefix(existingUpcoming.count)) == existingUpcoming {
|
|
155
|
+
// Strict tail extension — append without touching current playback.
|
|
156
|
+
let toAppend = Array(desired.suffix(desiredIds.count - existingUpcoming.count))
|
|
157
|
+
castManager?.appendToQueue(tracks: toAppend)
|
|
158
|
+
} else {
|
|
159
|
+
// Order changed / items removed — atomic reload preserving position.
|
|
160
|
+
loadCastQueue(
|
|
161
|
+
fromActualIndex: currentIdx,
|
|
162
|
+
autoplay: intendedToPlay,
|
|
163
|
+
position: cast.lastKnownRemotePosition
|
|
164
|
+
)
|
|
165
|
+
}
|
|
68
166
|
}
|
|
69
167
|
|
|
70
|
-
// MARK: -
|
|
71
|
-
// These bypass getStateInternal()/notify* which read the (paused) local player.
|
|
168
|
+
// MARK: - Cast-mode navigation (mirrors the local skip logic, minus AVQueuePlayer)
|
|
72
169
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
170
|
+
/// skipToIndex while casting: apply the local path's list/index mutations, then reload the receiver at the target.
|
|
171
|
+
func skipToIndexCastInternal(index: Int) -> Bool {
|
|
172
|
+
let actualQueue = getActualQueueInternal()
|
|
173
|
+
guard index >= 0 && index < actualQueue.count else { return false }
|
|
174
|
+
|
|
175
|
+
// Same section boundaries as the local skipToIndexInternal.
|
|
176
|
+
let currentPos = currentTemporaryType != .none ? currentTrackIndex + 1 : currentTrackIndex
|
|
177
|
+
let effectivePlayNextSize = currentTemporaryType == .playNext
|
|
178
|
+
? max(0, playNextStack.count - 1) : playNextStack.count
|
|
179
|
+
let effectiveUpNextSize = currentTemporaryType == .upNext
|
|
180
|
+
? max(0, upNextQueue.count - 1) : upNextQueue.count
|
|
181
|
+
let playNextStart = currentPos + 1
|
|
182
|
+
let playNextEnd = playNextStart + effectivePlayNextSize
|
|
183
|
+
let upNextEnd = playNextEnd + effectiveUpNextSize
|
|
184
|
+
|
|
185
|
+
if index == currentPos {
|
|
186
|
+
castManager?.seek(to: 0)
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let target = actualQueue[index]
|
|
191
|
+
|
|
192
|
+
// Skipping away from a playing temp track removes it from its list (mirrors the local handler).
|
|
193
|
+
if currentTemporaryType != .none, let currentId = activeCurrentTrackId {
|
|
194
|
+
if currentTemporaryType == .playNext,
|
|
195
|
+
let i = playNextStack.firstIndex(where: { $0.id == currentId }) {
|
|
196
|
+
playNextStack.remove(at: i)
|
|
197
|
+
} else if currentTemporaryType == .upNext,
|
|
198
|
+
let i = upNextQueue.firstIndex(where: { $0.id == currentId }) {
|
|
199
|
+
upNextQueue.remove(at: i)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if index < currentPos || index >= upNextEnd {
|
|
204
|
+
// Original-playlist target — temps are cleared, same as the local path.
|
|
205
|
+
guard let originalIndex = currentTracks.firstIndex(where: { $0.id == target.id }) else { return false }
|
|
206
|
+
playNextStack.removeAll()
|
|
207
|
+
upNextQueue.removeAll()
|
|
208
|
+
currentTemporaryType = .none
|
|
209
|
+
currentTrackIndex = originalIndex
|
|
210
|
+
} else if index < playNextEnd {
|
|
211
|
+
// playNext section: everything before the target is dropped.
|
|
212
|
+
if let t = playNextStack.firstIndex(where: { $0.id == target.id }), t > 0 {
|
|
213
|
+
playNextStack.removeSubrange(0..<t)
|
|
214
|
+
}
|
|
215
|
+
currentTemporaryType = .playNext
|
|
216
|
+
} else {
|
|
217
|
+
// upNext section: playNext is consumed, earlier upNext entries dropped.
|
|
218
|
+
playNextStack.removeAll()
|
|
219
|
+
if let t = upNextQueue.firstIndex(where: { $0.id == target.id }), t > 0 {
|
|
220
|
+
upNextQueue.removeSubrange(0..<t)
|
|
221
|
+
}
|
|
222
|
+
currentTemporaryType = .upNext
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
castManager?.setExpectedCurrentTrack(target.id)
|
|
226
|
+
notifyTemporaryQueueChange()
|
|
227
|
+
onChangeTrackListeners.forEach { $0(target, .skip) }
|
|
228
|
+
|
|
229
|
+
// Reload the receiver from the target's position in the refreshed queue.
|
|
230
|
+
let newQueue = getActualQueueInternal()
|
|
231
|
+
let newIndex = newQueue.firstIndex(where: { $0.id == target.id }) ?? 0
|
|
232
|
+
loadCastQueue(fromActualIndex: newIndex, autoplay: intendedToPlay, position: 0)
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// skipToPrevious while casting: restart after the threshold, otherwise step back through temp/original tracks.
|
|
237
|
+
func skipToPreviousCastInternal() {
|
|
238
|
+
let position = castManager?.lastKnownRemotePosition ?? 0
|
|
239
|
+
if position > Constants.skipToPreviousThreshold {
|
|
240
|
+
castManager?.seek(to: 0)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if currentTemporaryType != .none {
|
|
245
|
+
// Leaving a temp track removes it, then return to the current original.
|
|
246
|
+
if let currentId = activeCurrentTrackId {
|
|
247
|
+
if currentTemporaryType == .playNext,
|
|
248
|
+
let i = playNextStack.firstIndex(where: { $0.id == currentId }) {
|
|
249
|
+
playNextStack.remove(at: i)
|
|
250
|
+
} else if currentTemporaryType == .upNext,
|
|
251
|
+
let i = upNextQueue.firstIndex(where: { $0.id == currentId }) {
|
|
252
|
+
upNextQueue.remove(at: i)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
76
255
|
currentTemporaryType = .none
|
|
256
|
+
notifyTemporaryQueueChange()
|
|
257
|
+
} else if currentTrackIndex > 0 {
|
|
258
|
+
currentTrackIndex -= 1
|
|
259
|
+
} else {
|
|
260
|
+
castManager?.seek(to: 0)
|
|
261
|
+
return
|
|
77
262
|
}
|
|
263
|
+
|
|
264
|
+
guard let target = currentTracks[safe: currentTrackIndex] else { return }
|
|
265
|
+
castManager?.setExpectedCurrentTrack(target.id)
|
|
266
|
+
onChangeTrackListeners.forEach { $0(target, .skip) }
|
|
267
|
+
loadCastQueue(autoplay: intendedToPlay, position: 0)
|
|
268
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// MARK: - Emit (cast-derived events → JS listeners; run on playerQueue)
|
|
272
|
+
|
|
273
|
+
/// Track change from the receiver: temp-list bookkeeping, index/type classification, and the JS event.
|
|
274
|
+
func emitCastTrackChange(_ track: TrackItem, previousTrackId: String? = nil) {
|
|
275
|
+
// Leaving a temp track removes it from its list (mirrors the local handler).
|
|
276
|
+
if let prev = previousTrackId, prev != track.id {
|
|
277
|
+
if let i = playNextStack.firstIndex(where: { $0.id == prev }) {
|
|
278
|
+
playNextStack.remove(at: i)
|
|
279
|
+
notifyTemporaryQueueChange()
|
|
280
|
+
} else if let i = upNextQueue.firstIndex(where: { $0.id == prev }) {
|
|
281
|
+
upNextQueue.remove(at: i)
|
|
282
|
+
notifyTemporaryQueueChange()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Classify the new current track — temp lists take priority (matches determineCurrentTemporaryType).
|
|
287
|
+
if playNextStack.contains(where: { $0.id == track.id }) {
|
|
288
|
+
currentTemporaryType = .playNext
|
|
289
|
+
} else if upNextQueue.contains(where: { $0.id == track.id }) {
|
|
290
|
+
currentTemporaryType = .upNext
|
|
291
|
+
} else {
|
|
292
|
+
currentTemporaryType = .none
|
|
293
|
+
if let idx = currentTracks.firstIndex(where: { $0.id == track.id }) {
|
|
294
|
+
currentTrackIndex = idx
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
78
298
|
onChangeTrackListeners.forEach { $0(track, .skip) }
|
|
299
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
79
300
|
}
|
|
80
301
|
|
|
81
302
|
func emitCastPlaybackState(_ state: TrackPlayerState) {
|
|
@@ -47,7 +47,8 @@ extension TrackPlayerCore {
|
|
|
47
47
|
|
|
48
48
|
func skipToPrevious() async {
|
|
49
49
|
if isCasting {
|
|
50
|
-
|
|
50
|
+
// The receiver queue starts at the current track, so queuePreviousItem has nothing behind it — use core state instead.
|
|
51
|
+
await withPlayerQueueNoThrow { self.skipToPreviousCastInternal() }
|
|
51
52
|
return
|
|
52
53
|
}
|
|
53
54
|
await withPlayerQueueNoThrow { self.skipToPreviousInternal() }
|
|
@@ -57,6 +58,7 @@ extension TrackPlayerCore {
|
|
|
57
58
|
await withPlayerQueueNoThrow {
|
|
58
59
|
self.currentRepeatMode = mode
|
|
59
60
|
self.player?.actionAtItemEnd = (mode == .track) ? .none : .advance
|
|
61
|
+
if self.isCasting { self.castManager?.setQueueRepeatMode(mode) }
|
|
60
62
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
61
63
|
}
|
|
62
64
|
}
|
|
@@ -20,11 +20,7 @@ extension TrackPlayerCore {
|
|
|
20
20
|
|
|
21
21
|
func skipToIndex(index: Int) async -> Bool {
|
|
22
22
|
if isCasting {
|
|
23
|
-
return await withPlayerQueueNoThrow {
|
|
24
|
-
guard index >= 0 && index < self.getActualQueueInternal().count else { return false }
|
|
25
|
-
self.castManager?.jump(toQueueIndex: index)
|
|
26
|
-
return true
|
|
27
|
-
}
|
|
23
|
+
return await withPlayerQueueNoThrow { self.skipToIndexCastInternal(index: index) }
|
|
28
24
|
}
|
|
29
25
|
return await withPlayerQueueNoThrow { self.skipToIndexInternal(index: index) }
|
|
30
26
|
}
|
|
@@ -40,6 +36,29 @@ extension TrackPlayerCore {
|
|
|
40
36
|
// MARK: - Internal (run on playerQueue)
|
|
41
37
|
|
|
42
38
|
func getStateInternal() -> PlayerState {
|
|
39
|
+
// While casting, report the receiver's cached state — the local AVQueuePlayer is paused/stale.
|
|
40
|
+
if isCasting, let cast = castManager {
|
|
41
|
+
let currentTrack = getCurrentTrack()
|
|
42
|
+
let playingType: CurrentPlayingType
|
|
43
|
+
if currentTrack == nil { playingType = .notPlaying }
|
|
44
|
+
else {
|
|
45
|
+
switch currentTemporaryType {
|
|
46
|
+
case .none: playingType = .playlist
|
|
47
|
+
case .playNext: playingType = .playNext
|
|
48
|
+
case .upNext: playingType = .upNext
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return PlayerState(
|
|
52
|
+
currentTrack: currentTrack.map { Variant_NullType_TrackItem.second($0) },
|
|
53
|
+
currentPosition: cast.lastKnownRemotePosition,
|
|
54
|
+
totalDuration: cast.lastKnownRemoteDuration,
|
|
55
|
+
currentState: cast.cachedPlaybackState,
|
|
56
|
+
currentPlaylistId: currentPlaylistId.map { Variant_NullType_String.second($0) },
|
|
57
|
+
currentIndex: currentTrackIndex >= 0 ? Double(currentTrackIndex) : -1.0,
|
|
58
|
+
currentPlayingType: playingType
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
guard let player else {
|
|
44
63
|
return PlayerState(
|
|
45
64
|
currentTrack: nil, currentPosition: 0.0, totalDuration: 0.0,
|
|
@@ -96,7 +115,7 @@ extension TrackPlayerCore {
|
|
|
96
115
|
if let current = getCurrentTrack() { queue.append(current) }
|
|
97
116
|
|
|
98
117
|
// Add playNext stack — skip the currently playing track by ID (already added as current)
|
|
99
|
-
let currentId =
|
|
118
|
+
let currentId = activeCurrentTrackId
|
|
100
119
|
if currentTemporaryType == .playNext, let currentId = currentId {
|
|
101
120
|
var skipped = false
|
|
102
121
|
for track in playNextStack {
|
|
@@ -126,10 +145,7 @@ extension TrackPlayerCore {
|
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
func getCurrentTrack() -> TrackItem? {
|
|
129
|
-
if currentTemporaryType != .none,
|
|
130
|
-
let currentItem = player?.currentItem,
|
|
131
|
-
let trackId = currentItem.trackId
|
|
132
|
-
{
|
|
148
|
+
if currentTemporaryType != .none, let trackId = activeCurrentTrackId {
|
|
133
149
|
if currentTemporaryType == .playNext { return playNextStack.first(where: { $0.id == trackId }) }
|
|
134
150
|
if currentTemporaryType == .upNext { return upNextQueue.first(where: { $0.id == trackId }) }
|
|
135
151
|
}
|
|
@@ -222,7 +238,7 @@ extension TrackPlayerCore {
|
|
|
222
238
|
}
|
|
223
239
|
|
|
224
240
|
func determineCurrentTemporaryType() -> TemporaryType {
|
|
225
|
-
guard let trackId =
|
|
241
|
+
guard let trackId = activeCurrentTrackId else { return .none }
|
|
226
242
|
if playNextStack.contains(where: { $0.id == trackId }) { return .playNext }
|
|
227
243
|
if upNextQueue.contains(where: { $0.id == trackId }) { return .upNext }
|
|
228
244
|
return .none
|
|
@@ -240,6 +240,12 @@ extension TrackPlayerCore {
|
|
|
240
240
|
/// - Parameter changedTrackIds: When non-nil, performs a surgical update:
|
|
241
241
|
/// only AVPlayerItems whose track ID is in this set are removed and re-created.
|
|
242
242
|
func rebuildAVQueueFromCurrentPosition(changedTrackIds: Set<String>? = nil) {
|
|
243
|
+
// While casting, queue changes are applied to the receiver — the local AVQueuePlayer is dormant.
|
|
244
|
+
if isCasting {
|
|
245
|
+
syncCastQueueAfterCurrent()
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
243
249
|
guard let player = self.player else { return }
|
|
244
250
|
|
|
245
251
|
let currentItem = player.currentItem
|
|
@@ -78,8 +78,8 @@ extension TrackPlayerCore {
|
|
|
78
78
|
// Update in PlaylistManager
|
|
79
79
|
let affectedPlaylists = self.playlistManager.updateTracks(tracks: safeTracks)
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
if let update = currentTrack, currentTrackIsEmpty, !update.url.isEmpty {
|
|
81
|
+
// Replace the current AVPlayerItem when its URL just resolved (local only — cast reloads below instead).
|
|
82
|
+
if !self.isCasting, let update = currentTrack, currentTrackIsEmpty, !update.url.isEmpty {
|
|
83
83
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
84
84
|
"🔄 Replacing current AVPlayerItem for track with resolved URL: \(update.id)")
|
|
85
85
|
if let newItem = self.createGaplessPlayerItem(for: update, isPreload: false) {
|
|
@@ -101,6 +101,30 @@ extension TrackPlayerCore {
|
|
|
101
101
|
"📥 Synced currentTracks from PlaylistManager (\(self.currentTracks.count) tracks)")
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
if self.isCasting {
|
|
105
|
+
// Resolved URLs are applied by (re)loading/extending the remote queue — never by touching the silent local player.
|
|
106
|
+
let currentResolvedNow = currentTrackIsEmpty
|
|
107
|
+
&& currentTrackId.map { id in updatedTrackIds.contains(id) } ?? false
|
|
108
|
+
let staleOnReceiver = !updatedTrackIds
|
|
109
|
+
.isDisjoint(with: Set(self.castManager?.loadedTrackIds ?? []))
|
|
110
|
+
|
|
111
|
+
if currentResolvedNow || !(self.castManager?.hasLoadedMedia ?? false) {
|
|
112
|
+
// Parked track just became castable (or nothing loaded) — atomic load from current.
|
|
113
|
+
self.loadCastQueue(autoplay: self.intendedToPlay, position: 0)
|
|
114
|
+
} else if staleOnReceiver {
|
|
115
|
+
// URLs changed for items already on the receiver — reload at position before stale ones fail.
|
|
116
|
+
self.loadCastQueue(
|
|
117
|
+
autoplay: self.intendedToPlay,
|
|
118
|
+
position: self.castManager?.lastKnownRemotePosition ?? 0
|
|
119
|
+
)
|
|
120
|
+
} else {
|
|
121
|
+
// Newly castable tracks extend the receiver queue without interrupting playback.
|
|
122
|
+
self.syncCastQueueAfterCurrent()
|
|
123
|
+
}
|
|
124
|
+
NitroPlayerLogger.log("TrackPlayerCore", "✅ Cast queue synced after track updates")
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
104
128
|
if self.player?.currentItem == nil, let player = self.player {
|
|
105
129
|
// No AVPlayerItem exists yet — lazy-load mode: URLs were empty when the queue first loaded.
|
|
106
130
|
NitroPlayerLogger.log("TrackPlayerCore",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "1.5.0
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
|
|
5
5
|
"main": "lib/index",
|
|
6
6
|
"module": "lib/index",
|