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.
@@ -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
- switchToPlayer(controller.castPlayer)
28
- isCastingField = true
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 current = exo.player
42
- if (current === target) return
43
-
44
- // Move listener, queue and position from the current player to the target,
45
- // then make the target authoritative for the MediaSession & notification.
46
- playerListener?.let { current.removeListener(it) }
47
- transferPlaybackState(current, target)
48
- current.playWhenReady = false // silence the player we're leaving
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
- exo = ExoPlayerCore(target)
51
- playerListener?.let { target.addListener(it) }
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
- val playlistId = currentPlaylistId ?: ""
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
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
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 ExoPlayer's currentIndex)
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
- val playlistId = currentPlaylistId ?: ""
120
- val mediaItems =
121
- tracks.map { track ->
122
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
123
- makeMediaItem(track, mediaId)
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
- val effectiveUrl = downloadManager.getEffectiveUrl(track)
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 track's MediaItem if it was empty-URL and now has a URL
40
- if (currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()) {
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.id}" else currentTrackUpdate.id
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 loadedTracks: [TrackItem] = []
23
- private var lastCurrentTrackId: String?
24
- // GoogleCast reads are main-thread-affine; Nitro sync getters run off-main, so
25
- // serve them from a cache refreshed on the main thread by the Cast callbacks.
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
- private(set) var lastKnownRemotePosition: Double = 0
30
- private var progressTimer: Timer?
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 isConfigured && GCKCastContext.sharedInstance().sessionManager.currentCastSession != nil
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 { cachedState }
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? { cachedDeviceName }
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 jump(toQueueIndex index: Int) {
203
+ func setQueueRepeatMode(_ mode: RepeatMode) {
150
204
  #if canImport(GoogleCast)
151
205
  onMain {
152
- guard let client = self.remoteClient,
153
- let status = client.mediaStatus,
154
- index >= 0, index < Int(status.queueItemCount) else { return }
155
- if let item = status.queueItem(at: UInt(index)) {
156
- client.queueJumpToItem(withID: item.itemID)
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
- func loadQueue(tracks: [TrackItem], startIndex: Int, position: Double, autoplay: Bool) {
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, !tracks.isEmpty else { return }
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 = UInt(max(0, min(startIndex, items.count - 1)))
174
- options.playPosition = position.isFinite ? position : 0
175
- options.repeatMode = .off
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
- let urlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
195
- guard let contentURL = URL(string: urlString) else { return nil }
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
- if position.isFinite { lastKnownRemotePosition = position }
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
- if let customData = info?.customData as? [String: Any],
263
- let trackId = customData["trackId"] as? String {
264
- return loadedTracks.first { $0.id == trackId }
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
- guard let track = lastCurrentTrackId.flatMap({ id in loadedTracks.first { $0.id == id } }) else { return }
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(cachedState, cachedDeviceName)
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
- cachedState = .noDevicesAvailable
310
- cachedDeviceName = nil
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: cachedState = .noDevicesAvailable
315
- case .notConnected: cachedState = .notConnected
316
- case .connecting: cachedState = .connecting
317
- case .connected: cachedState = .connected
318
- @unknown default: cachedState = .notConnected
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
- lastCurrentTrackId = nil
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
- // Track change
363
- if let track = currentRemoteTrack(mediaStatus), track.id != lastCurrentTrackId {
364
- lastCurrentTrackId = track.id
365
- core.emitCastTrackChange(track)
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 state = mapPlayerState(mediaStatus?.playerState ?? .unknown)
370
- core.emitCastPlaybackState(state)
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
- /// Mirror the current effective queue to the Cast device, starting at the current track.
59
- /// Must be called on `playerQueue`.
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
- castManager?.loadQueue(tracks: queue, startIndex: startIndex, position: position, autoplay: autoplay)
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: - Emit (cast-derived events → JS listeners)
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
- func emitCastTrackChange(_ track: TrackItem) {
74
- if let idx = currentTracks.firstIndex(where: { $0.id == track.id }) {
75
- currentTrackIndex = idx
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
- castManager?.skipToPrevious()
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 = player?.currentItem?.trackId
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 = player?.currentItem?.trackId else { return .none }
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
- // If the current track had no URL and now has one, replace the current AVPlayerItem
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-alpha.1",
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",