react-native-nitro-player 1.5.0-alpha.0 → 1.5.0-alpha.3

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.
@@ -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
 
@@ -41,9 +95,15 @@ final class CastSessionManager: NSObject {
41
95
  let criteria = GCKDiscoveryCriteria(applicationID: appId)
42
96
  let options = GCKCastOptions(discoveryCriteria: criteria)
43
97
  options.suspendSessionsWhenBackgrounded = false
98
+ // We expose a custom Cast button (not GCKUICastButton), so device discovery
99
+ // must begin immediately instead of waiting for a tap on a GCKUICastButton —
100
+ // otherwise no devices are ever found.
101
+ options.startDiscoveryAfterFirstTapOnCastButton = false
44
102
  GCKCastContext.setSharedInstanceWith(options)
45
103
 
46
104
  let context = GCKCastContext.sharedInstance()
105
+ // Begin scanning right away so devices are available before the picker opens.
106
+ context.discoveryManager.startDiscovery()
47
107
  context.sessionManager.add(self)
48
108
  NotificationCenter.default.addObserver(
49
109
  self,
@@ -60,17 +120,23 @@ final class CastSessionManager: NSObject {
60
120
 
61
121
  var isCasting: Bool {
62
122
  #if canImport(GoogleCast)
63
- return isConfigured && GCKCastContext.sharedInstance().sessionManager.currentCastSession != nil
123
+ return currentCastState() == .connected
64
124
  #else
65
125
  return false
66
126
  #endif
67
127
  }
68
128
 
69
129
  /// Cached — safe to call from the JS thread.
70
- func currentCastState() -> CastState { cachedState }
130
+ func currentCastState() -> CastState {
131
+ stateLock.lock(); defer { stateLock.unlock() }
132
+ return cachedState
133
+ }
71
134
 
72
135
  /// Cached — safe to call from the JS thread.
73
- func deviceName() -> String? { cachedDeviceName }
136
+ func deviceName() -> String? {
137
+ stateLock.lock(); defer { stateLock.unlock() }
138
+ return cachedDeviceName
139
+ }
74
140
 
75
141
  // MARK: - UI / session control
76
142
 
@@ -122,12 +188,6 @@ final class CastSessionManager: NSObject {
122
188
  #endif
123
189
  }
124
190
 
125
- func skipToPrevious() {
126
- #if canImport(GoogleCast)
127
- onMain { self.remoteClient?.queuePreviousItem() }
128
- #endif
129
- }
130
-
131
191
  func setVolume(_ volume0to1: Float) {
132
192
  #if canImport(GoogleCast)
133
193
  onMain { self.remoteClient?.setStreamVolume(volume0to1) }
@@ -140,38 +200,81 @@ final class CastSessionManager: NSObject {
140
200
  #endif
141
201
  }
142
202
 
143
- func jump(toQueueIndex index: Int) {
203
+ func setQueueRepeatMode(_ mode: RepeatMode) {
144
204
  #if canImport(GoogleCast)
145
205
  onMain {
146
- guard let client = self.remoteClient,
147
- let status = client.mediaStatus,
148
- index >= 0, index < Int(status.queueItemCount) else { return }
149
- if let item = status.queueItem(at: UInt(index)) {
150
- 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
151
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
152
226
  }
227
+ guard hadMedia else { return }
228
+ onMain { self.remoteClient?.stop() }
153
229
  #endif
154
230
  }
155
231
 
156
232
  // MARK: - Queue loading
157
233
 
158
- 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) {
159
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
+ }
160
243
  onMain {
161
- guard let client = self.remoteClient, !tracks.isEmpty else { return }
162
- self.loadedTracks = tracks
244
+ guard let client = self.remoteClient else { return }
163
245
  let items = tracks.compactMap { self.makeQueueItem(for: $0, autoplay: autoplay) }
164
246
  guard !items.isEmpty else { return }
165
247
 
166
248
  let options = GCKMediaQueueLoadOptions()
167
- options.startIndex = UInt(max(0, min(startIndex, items.count - 1)))
168
- options.playPosition = position.isFinite ? position : 0
169
- 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
+ }
170
256
  client.queueLoad(items, with: options)
171
257
  }
172
258
  #endif
173
259
  }
174
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
+
175
278
  // MARK: - Private helpers
176
279
 
177
280
  private func onMain(_ block: @escaping () -> Void) {
@@ -185,8 +288,9 @@ final class CastSessionManager: NSObject {
185
288
  }
186
289
 
187
290
  private func makeQueueItem(for track: TrackItem, autoplay: Bool) -> GCKMediaQueueItem? {
188
- let urlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
189
- 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 }
190
294
 
191
295
  let metadata = GCKMediaMetadata(metadataType: .musicTrack)
192
296
  metadata.setString(track.title, forKey: kGCKMetadataKeyTitle)
@@ -243,7 +347,10 @@ final class CastSessionManager: NSObject {
243
347
  let position = client.approximateStreamPosition()
244
348
  let duration = client.mediaStatus?.mediaInformation?.streamDuration ?? 0
245
349
  let safeDuration = (duration.isFinite && duration > 0) ? duration : 0
246
- if position.isFinite { lastKnownRemotePosition = position }
350
+ withState {
351
+ if position.isFinite { _lastKnownRemotePosition = position }
352
+ _lastKnownRemoteDuration = safeDuration
353
+ }
247
354
  core?.emitCastProgress(position.isFinite ? position : 0, safeDuration)
248
355
  updateNowPlaying(position: position, duration: safeDuration)
249
356
  }
@@ -253,14 +360,10 @@ final class CastSessionManager: NSObject {
253
360
  guard let status else { return nil }
254
361
  let item = status.queueItem(withItemID: status.currentItemID)
255
362
  let info = item?.mediaInformation ?? status.mediaInformation
256
- if let customData = info?.customData as? [String: Any],
257
- let trackId = customData["trackId"] as? String {
258
- return loadedTracks.first { $0.id == trackId }
259
- }
260
- if let url = info?.contentURL?.absoluteString {
261
- return loadedTracks.first { DownloadManagerCore.shared.getEffectiveUrl(track: $0) == url }
262
- }
263
- 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 } }
264
367
  }
265
368
 
266
369
  private func mapPlayerState(_ state: GCKMediaPlayerState) -> TrackPlayerState {
@@ -274,7 +377,8 @@ final class CastSessionManager: NSObject {
274
377
  }
275
378
 
276
379
  private func updateNowPlaying(position: Double, duration: Double) {
277
- 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 }
278
382
  var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
279
383
  info[MPMediaItemPropertyTitle] = track.title
280
384
  info[MPMediaItemPropertyArtist] = track.artist
@@ -293,25 +397,32 @@ final class CastSessionManager: NSObject {
293
397
  /// Refreshes the cache from live Cast state and notifies listeners. Callers must be on the main thread.
294
398
  private func emitCastState() {
295
399
  refreshCacheOnMain()
296
- core?.notifyCastStateChangeListeners(cachedState, cachedDeviceName)
400
+ core?.notifyCastStateChangeListeners(currentCastState(), deviceName())
297
401
  }
298
402
 
299
403
  /// Re-read live Cast state into the cache. MUST be called on the main thread.
300
404
  private func refreshCacheOnMain() {
301
405
  #if canImport(GoogleCast)
302
406
  guard isConfigured else {
303
- cachedState = .noDevicesAvailable
304
- cachedDeviceName = nil
407
+ withState {
408
+ cachedState = .noDevicesAvailable
409
+ cachedDeviceName = nil
410
+ }
305
411
  return
306
412
  }
413
+ let newState: CastState
307
414
  switch GCKCastContext.sharedInstance().castState {
308
- case .noDevicesAvailable: cachedState = .noDevicesAvailable
309
- case .notConnected: cachedState = .notConnected
310
- case .connecting: cachedState = .connecting
311
- case .connected: cachedState = .connected
312
- @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
313
425
  }
314
- cachedDeviceName = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.device.friendlyName
315
426
  #endif
316
427
  }
317
428
  }
@@ -334,7 +445,13 @@ extension CastSessionManager: GCKSessionManagerListener {
334
445
 
335
446
  func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
336
447
  stopProgressTimer()
337
- lastCurrentTrackId = nil
448
+ withState {
449
+ _lastCurrentTrackId = nil
450
+ _loadedTracks = []
451
+ _hasLoadedMedia = false
452
+ _cachedPlaybackState = .stopped
453
+ _lastEmittedPlaybackState = nil
454
+ }
338
455
  core?.handleCastDisconnected()
339
456
  emitCastState()
340
457
  }
@@ -353,15 +470,39 @@ extension CastSessionManager: GCKRemoteMediaClientListener {
353
470
  func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) {
354
471
  guard let core else { return }
355
472
 
356
- // Track change
357
- if let track = currentRemoteTrack(mediaStatus), track.id != lastCurrentTrackId {
358
- lastCurrentTrackId = track.id
359
- 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
+ }
360
495
  }
361
496
 
362
- // Playback state
363
- let state = mapPlayerState(mediaStatus?.playerState ?? .unknown)
364
- 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
+ }
365
506
  }
366
507
  }
367
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
  }