react-native-nitro-player 1.7.0-alpha.2 → 1.7.0-alpha.4

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.
Files changed (59) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +46 -14
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +2 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +36 -12
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +16 -6
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +7 -3
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +8 -2
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +26 -1
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +60 -0
  15. package/ios/cast/CastQueueDiff.swift +58 -0
  16. package/ios/cast/CastQueueReconciler.swift +228 -0
  17. package/ios/core/CastSessionManager.swift +215 -31
  18. package/ios/core/TrackPlayerCast.swift +96 -65
  19. package/ios/core/TrackPlayerCore.swift +31 -0
  20. package/ios/core/TrackPlayerListener.swift +19 -3
  21. package/ios/core/TrackPlayerPlayback.swift +12 -10
  22. package/ios/core/TrackPlayerQueue.swift +10 -2
  23. package/ios/core/TrackPlayerQueueBuild.swift +21 -9
  24. package/ios/core/TrackPlayerTempQueue.swift +27 -15
  25. package/ios/core/TrackPlayerUrlLoader.swift +6 -2
  26. package/ios/playlist/PlaylistManager.swift +60 -1
  27. package/ios/queue/HybridPlayerQueue.swift +40 -13
  28. package/lib/hooks/callbackManager.d.ts +2 -0
  29. package/lib/hooks/callbackManager.js +23 -8
  30. package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
  31. package/lib/types/PlayerQueue.d.ts +14 -0
  32. package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
  33. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
  34. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
  35. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
  36. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
  37. package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
  42. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
  43. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
  44. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
  45. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
  46. package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
  47. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
  48. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
  49. package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
  50. package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
  51. package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
  52. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
  53. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
  54. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
  55. package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
  56. package/package.json +1 -1
  57. package/src/hooks/callbackManager.ts +25 -10
  58. package/src/specs/TrackPlayer.nitro.ts +11 -0
  59. package/src/types/PlayerQueue.ts +17 -0
@@ -249,6 +249,61 @@ class PlaylistManager private constructor(
249
249
  return true
250
250
  }
251
251
 
252
+ /** Removed count; null when the playlist does not exist. Unknown ids are ignored. */
253
+ fun removeTracksFromPlaylist(
254
+ playlistId: String,
255
+ trackIds: List<String>,
256
+ ): Int? {
257
+ val ids = trackIds.toHashSet()
258
+ var removed = 0
259
+ val found =
260
+ playlists.computeIfPresent(playlistId) { _, playlist ->
261
+ val tracks = playlist.tracks.toMutableList()
262
+ val before = tracks.size
263
+ if (tracks.removeAll { it.id in ids }) {
264
+ removed = before - tracks.size
265
+ playlist.copy(tracks = tracks)
266
+ } else {
267
+ playlist
268
+ }
269
+ } != null
270
+ if (!found) return null
271
+ if (removed > 0) {
272
+ scheduleSave()
273
+ notifyPlaylistChanged(playlistId, QueueOperation.REMOVE)
274
+ NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
275
+ }
276
+ return removed
277
+ }
278
+
279
+ /** Fisher-Yates; [firstTrackId] is pinned at index 0. Null when the playlist does not exist. */
280
+ fun shufflePlaylist(
281
+ playlistId: String,
282
+ firstTrackId: String?,
283
+ ): Boolean? {
284
+ var changed = false
285
+ val found =
286
+ playlists.computeIfPresent(playlistId) { _, playlist ->
287
+ val tracks = playlist.tracks.toMutableList()
288
+ val pinned = tracks.indexOfFirst { it.id == firstTrackId }.takeIf { it >= 0 }?.let { tracks.removeAt(it) }
289
+ tracks.shuffle()
290
+ pinned?.let { tracks.add(0, it) }
291
+ if (tracks.map { it.id } == playlist.tracks.map { it.id }) {
292
+ playlist
293
+ } else {
294
+ changed = true
295
+ playlist.copy(tracks = tracks)
296
+ }
297
+ } != null
298
+ if (!found) return null
299
+ if (changed) {
300
+ scheduleSave()
301
+ notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
302
+ NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
303
+ }
304
+ return changed
305
+ }
306
+
252
307
  /**
253
308
  * Update entire track objects across all playlists
254
309
  * Matches by track.id and replaces the entire track object
@@ -317,6 +372,11 @@ class PlaylistManager private constructor(
317
372
  return true
318
373
  }
319
374
 
375
+ /** Mirrors the core's current playlist (playSong bypasses loadPlaylist); Android Auto and persistence read this. */
376
+ fun setCurrentPlaylistId(playlistId: String?) {
377
+ currentPlaylistId = playlistId
378
+ }
379
+
320
380
  /**
321
381
  * Get the current playlist ID
322
382
  */
@@ -0,0 +1,58 @@
1
+ // Pure receiver-queue diff: no Cast SDK types, no operation ever contains the current item.
2
+
3
+ import Foundation
4
+
5
+ enum CastQueueOp: Equatable {
6
+ case remove([String])
7
+ case insert([String])
8
+ case moveToEnd([String])
9
+ case moveBefore([String], anchor: String)
10
+ }
11
+
12
+ enum CastQueueDiff {
13
+ /// Converges in at most 4 ops. Caller guarantees `currentId` occurs exactly once in both lists.
14
+ static func nextOp(existing: [String], desired: [String], currentId: String) -> CastQueueOp? {
15
+ let desiredSet = Set(desired)
16
+ let existingSet = Set(existing)
17
+
18
+ let toRemove = existing.filter { !desiredSet.contains($0) }
19
+ if !toRemove.isEmpty { return .remove(toRemove) }
20
+
21
+ let toInsert = desired.filter { !existingSet.contains($0) }
22
+ if !toInsert.isEmpty { return .insert(toInsert) }
23
+
24
+ if existing == desired { return nil }
25
+ guard let cur = desired.firstIndex(of: currentId) else { return nil }
26
+
27
+ let after = Array(desired[(cur + 1)...])
28
+ if !after.isEmpty, Array(existing.suffix(after.count)) != after { return .moveToEnd(after) }
29
+
30
+ let before = Array(desired[..<cur])
31
+ if !before.isEmpty, Array(existing.prefix(before.count)) != before {
32
+ return .moveBefore(before, anchor: currentId)
33
+ }
34
+ return nil
35
+ }
36
+
37
+ static func apply(_ op: CastQueueOp, to existing: [String]) -> [String] {
38
+ switch op {
39
+ case .remove(let ids):
40
+ let dropped = Set(ids)
41
+ return existing.filter { !dropped.contains($0) }
42
+ case .insert(let ids):
43
+ return existing + ids
44
+ case .moveToEnd(let ids):
45
+ let moved = Set(ids)
46
+ return existing.filter { !moved.contains($0) } + ids
47
+ case .moveBefore(let ids, let anchor):
48
+ let moved = Set(ids)
49
+ var out: [String] = []
50
+ out.reserveCapacity(existing.count)
51
+ for id in existing where !moved.contains(id) {
52
+ if id == anchor { out.append(contentsOf: ids) }
53
+ out.append(id)
54
+ }
55
+ return out
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,228 @@
1
+ // Pure state machine: one receiver request in flight at a time, newest desired state wins.
2
+
3
+ import Foundation
4
+
5
+ typealias CastItemID = UInt
6
+
7
+ struct CastQueueLoad {
8
+ let tracks: [TrackItem]
9
+ let startIndex: Int
10
+ let position: Double
11
+ let autoplay: Bool
12
+ let repeatMode: RepeatMode
13
+ }
14
+
15
+ enum CastQueueEffect {
16
+ case none
17
+ case send(CastQueueOp)
18
+ case sendLoad(CastQueueLoad)
19
+ case sendUnload
20
+ case recover(String)
21
+ }
22
+
23
+ final class CastQueueReconciler {
24
+
25
+ private struct Busy {
26
+ enum Kind {
27
+ case load(CastQueueLoad)
28
+ case unload
29
+ case op(CastQueueOp)
30
+ }
31
+ let kind: Kind
32
+ let expected: [String]
33
+ var request: Int?
34
+ var completed = false
35
+ var observed = false
36
+ }
37
+
38
+ private struct Desired {
39
+ let generation: UInt64
40
+ let ids: [String]
41
+ let currentId: String
42
+ }
43
+
44
+ private enum PendingLoad {
45
+ case load(CastQueueLoad)
46
+ case unload
47
+ }
48
+
49
+ private(set) var order: [CastItemID] = []
50
+ /// Learned from our own completed operations, never guessed.
51
+ private(set) var trackOf: [CastItemID: String] = [:]
52
+ private(set) var unverified: Set<CastItemID> = []
53
+
54
+ private var valid = false
55
+ private var busy: Busy?
56
+ private var pendingLoad: PendingLoad?
57
+ private var latest: Desired?
58
+ private var generationCounter: UInt64 = 0
59
+
60
+ var orderedTrackIds: [String] { order.compactMap { trackOf[$0] } }
61
+
62
+ var hasInFlightRequest: Bool { busy != nil }
63
+
64
+ // MARK: - Inputs
65
+
66
+ func desired(ids: [String], currentId: String) -> CastQueueEffect {
67
+ generationCounter &+= 1
68
+ latest = Desired(generation: generationCounter, ids: ids, currentId: currentId)
69
+ guard busy == nil else { return .none }
70
+ return dispatch()
71
+ }
72
+
73
+ func loadRequested(_ load: CastQueueLoad) -> CastQueueEffect {
74
+ pendingLoad = .load(load)
75
+ guard busy == nil else { return .none }
76
+ return dispatch()
77
+ }
78
+
79
+ func unloadRequested() -> CastQueueEffect {
80
+ pendingLoad = .unload
81
+ guard busy == nil else { return .none }
82
+ return dispatch()
83
+ }
84
+
85
+ func requestAssigned(_ requestId: Int) {
86
+ busy?.request = requestId
87
+ }
88
+
89
+ func requestCompleted(_ requestId: Int) -> CastQueueEffect {
90
+ guard var current = busy, current.request == requestId else { return .none }
91
+ current.completed = true
92
+ busy = current
93
+ return finishIfReady()
94
+ }
95
+
96
+ func requestFailed(_ requestId: Int, reason: String) -> CastQueueEffect {
97
+ guard let current = busy, current.request == requestId else { return .none }
98
+ busy = nil
99
+ valid = false
100
+ return .recover(reason)
101
+ }
102
+
103
+ func queueObserved(_ ids: [CastItemID]) -> CastQueueEffect {
104
+ guard var current = busy else {
105
+ // Another sender changed the queue: reconstruct from the map rather than reloading.
106
+ if ids.contains(where: { trackOf[$0] == nil }) {
107
+ valid = false
108
+ return .recover("foreign receiver item")
109
+ }
110
+ order = ids
111
+ return latest == nil ? .none : plan()
112
+ }
113
+
114
+ guard ids.count == current.expected.count else { return .none }
115
+ var learned: [CastItemID: String] = [:]
116
+ for (index, id) in ids.enumerated() {
117
+ if let known = trackOf[id] {
118
+ if known != current.expected[index] { return .none }
119
+ } else {
120
+ learned[id] = current.expected[index]
121
+ }
122
+ }
123
+ let introduced = introducedCount(current)
124
+ if learned.count != introduced {
125
+ if learned.isEmpty {
126
+ order = ids
127
+ return .none
128
+ }
129
+ busy = nil
130
+ valid = false
131
+ return .recover("foreign receiver item")
132
+ }
133
+ for (id, trackId) in learned {
134
+ trackOf[id] = trackId
135
+ unverified.insert(id)
136
+ }
137
+ order = ids
138
+ current.observed = true
139
+ busy = current
140
+ return finishIfReady()
141
+ }
142
+
143
+ func verified(_ id: CastItemID, trackId: String) -> CastQueueEffect {
144
+ guard let known = trackOf[id] else { return .none }
145
+ unverified.remove(id)
146
+ guard known != trackId else { return .none }
147
+ busy = nil
148
+ valid = false
149
+ return .recover("id map mismatch")
150
+ }
151
+
152
+ func sessionEnded() {
153
+ order = []
154
+ trackOf = [:]
155
+ unverified = []
156
+ valid = false
157
+ busy = nil
158
+ pendingLoad = nil
159
+ latest = nil
160
+ }
161
+
162
+ // MARK: - Internals
163
+
164
+ private func introducedCount(_ current: Busy) -> Int {
165
+ switch current.kind {
166
+ case .load: return current.expected.count
167
+ case .unload: return 0
168
+ case .op(let op):
169
+ if case .insert(let ids) = op { return ids.count }
170
+ return 0
171
+ }
172
+ }
173
+
174
+ private func finishIfReady() -> CastQueueEffect {
175
+ guard let current = busy, current.completed, current.observed else { return .none }
176
+ busy = nil
177
+ return dispatch()
178
+ }
179
+
180
+ /// Loads win over diffs: they are jumps or recoveries, and they reset the id map.
181
+ private func dispatch() -> CastQueueEffect {
182
+ guard busy == nil else { return .none }
183
+ if let pending = pendingLoad {
184
+ pendingLoad = nil
185
+ order = []
186
+ trackOf = [:]
187
+ unverified = []
188
+ valid = true
189
+ switch pending {
190
+ case .load(let load):
191
+ busy = Busy(kind: .load(load), expected: load.tracks.map(\.id))
192
+ return .sendLoad(load)
193
+ case .unload:
194
+ busy = Busy(kind: .unload, expected: [])
195
+ return .sendUnload
196
+ }
197
+ }
198
+ return plan()
199
+ }
200
+
201
+ private func plan() -> CastQueueEffect {
202
+ while true {
203
+ guard valid, let target = latest else { return .none }
204
+ let existing = orderedTrackIds
205
+ guard existing.count == order.count else {
206
+ valid = false
207
+ return .recover("unmapped receiver item")
208
+ }
209
+ guard existing.filter({ $0 == target.currentId }).count == 1,
210
+ target.ids.filter({ $0 == target.currentId }).count == 1
211
+ else {
212
+ valid = false
213
+ return .recover("current id precondition")
214
+ }
215
+ guard
216
+ let op = CastQueueDiff.nextOp(
217
+ existing: existing, desired: target.ids, currentId: target.currentId)
218
+ else {
219
+ // Clear only the generation we planned for; a newer one is planned instead.
220
+ guard let newest = latest, newest.generation == target.generation else { continue }
221
+ latest = nil
222
+ return .none
223
+ }
224
+ busy = Busy(kind: .op(op), expected: CastQueueDiff.apply(op, to: existing))
225
+ return .send(op)
226
+ }
227
+ }
228
+ }
@@ -36,12 +36,26 @@ final class CastSessionManager: NSObject {
36
36
  private var cachedState: CastState = .noDevicesAvailable
37
37
  private var cachedDeviceName: String?
38
38
 
39
+ // MARK: - Receiver queue reconciliation (main thread only)
40
+
41
+ /// Serializes every receiver queue mutation and owns the item-id map.
42
+ private let reconciler = CastQueueReconciler()
43
+ private var catalog: [String: TrackItem] = [:]
44
+ private var watchdog: DispatchWorkItem?
45
+ private static let requestTimeout: TimeInterval = 10
46
+
39
47
  /// Track ID playing on the receiver (or expected to, right after a locally-initiated jump).
40
48
  var currentRemoteTrackId: String? {
41
49
  stateLock.lock(); defer { stateLock.unlock() }
42
50
  return _lastCurrentTrackId
43
51
  }
44
52
 
53
+ /// The receiver's current track, resolved from what we last sent it.
54
+ var currentRemoteTrackItem: TrackItem? {
55
+ stateLock.lock(); defer { stateLock.unlock() }
56
+ return _lastCurrentTrackId.flatMap { id in _loadedTracks.first { $0.id == id } }
57
+ }
58
+
45
59
  /// IDs of the tracks last sent to the receiver, in queue order.
46
60
  var loadedTrackIds: [String] {
47
61
  stateLock.lock(); defer { stateLock.unlock() }
@@ -225,52 +239,39 @@ final class CastSessionManager: NSObject {
225
239
  return had
226
240
  }
227
241
  guard hadMedia else { return }
228
- onMain { self.remoteClient?.stop() }
242
+ onMain { self.run(self.reconciler.unloadRequested()) }
229
243
  #endif
230
244
  }
231
245
 
232
- // MARK: - Queue loading
246
+ // MARK: - Queue loading / reconciliation
233
247
 
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) {
248
+ /// Serialized with every other receiver mutation. `startIndex` is the item that should play.
249
+ func loadQueue(
250
+ tracks: [TrackItem], startIndex: Int, position: Double, autoplay: Bool, repeatMode: RepeatMode
251
+ ) {
236
252
  #if canImport(GoogleCast)
237
- guard !tracks.isEmpty else { return }
253
+ guard !tracks.isEmpty, startIndex >= 0, startIndex < tracks.count else { return }
238
254
  withState {
239
255
  _loadedTracks = tracks
240
256
  _hasLoadedMedia = true // optimistic; corrected by the next media status
241
- _lastCurrentTrackId = tracks.first?.id
257
+ _lastCurrentTrackId = tracks[startIndex].id
242
258
  }
243
259
  onMain {
244
- guard let client = self.remoteClient else { return }
245
- let items = tracks.compactMap { self.makeQueueItem(for: $0, autoplay: autoplay) }
246
- guard !items.isEmpty else { return }
247
-
248
- let options = GCKMediaQueueLoadOptions()
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
- }
256
- client.queueLoad(items, with: options)
260
+ for track in tracks { self.catalog[track.id] = track }
261
+ let load = CastQueueLoad(
262
+ tracks: tracks, startIndex: startIndex, position: position, autoplay: autoplay,
263
+ repeatMode: repeatMode)
264
+ self.run(self.reconciler.loadRequested(load))
257
265
  }
258
266
  #endif
259
267
  }
260
268
 
261
- /// Append tracks to the END of the receiver queue without touching playback.
262
- func appendToQueue(tracks: [TrackItem]) {
269
+ /// Current-preserving operations, no reload.
270
+ func reconcileQueue(desired tracks: [TrackItem], currentTrackId: String) {
263
271
  #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
272
  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)
273
+ for track in tracks { self.catalog[track.id] = track }
274
+ self.run(self.reconciler.desired(ids: tracks.map(\.id), currentId: currentTrackId))
274
275
  }
275
276
  #endif
276
277
  }
@@ -428,6 +429,170 @@ final class CastSessionManager: NSObject {
428
429
  }
429
430
 
430
431
  #if canImport(GoogleCast)
432
+ // MARK: - Reconciler host (main thread)
433
+
434
+ extension CastSessionManager {
435
+
436
+ /// Every receiver mutation goes through here.
437
+ fileprivate func run(_ effect: CastQueueEffect) {
438
+ switch effect {
439
+ case .none:
440
+ publishOrder()
441
+ case .send(let op):
442
+ send(op)
443
+ case .sendLoad(let load):
444
+ sendLoad(load)
445
+ case .sendUnload:
446
+ guard let client = remoteClient else { return recover("no client") }
447
+ track(client.stop())
448
+ case .recover(let reason):
449
+ recover(reason)
450
+ }
451
+ }
452
+
453
+ private func send(_ op: CastQueueOp) {
454
+ guard let client = remoteClient else { return recover("no client") }
455
+ let ids = reconciler.order
456
+ let trackOf = reconciler.trackOf
457
+ func itemIDs(_ trackIds: [String]) -> [NSNumber]? {
458
+ var out: [NSNumber] = []
459
+ for trackId in trackIds {
460
+ guard let id = ids.first(where: { trackOf[$0] == trackId }) else { return nil }
461
+ out.append(NSNumber(value: id))
462
+ }
463
+ return out
464
+ }
465
+ switch op {
466
+ case .remove(let trackIds):
467
+ guard let items = itemIDs(trackIds) else { return recover("unknown item for remove") }
468
+ track(client.queueRemoveItems(withIDs: items))
469
+ case .insert(let trackIds):
470
+ let tracks = trackIds.compactMap { catalog[$0] }
471
+ guard tracks.count == trackIds.count else { return recover("catalog miss") }
472
+ let items = tracks.compactMap { makeQueueItem(for: $0, autoplay: true) }
473
+ guard items.count == tracks.count else { return recover("uncastable item") }
474
+ track(client.queueInsert(items, beforeItemWithID: kGCKMediaQueueInvalidItemID))
475
+ case .moveToEnd(let trackIds):
476
+ guard let items = itemIDs(trackIds) else { return recover("unknown item for reorder") }
477
+ track(
478
+ client.queueReorderItems(
479
+ withIDs: items, insertBeforeItemWithID: kGCKMediaQueueInvalidItemID))
480
+ case .moveBefore(let trackIds, let anchor):
481
+ guard let items = itemIDs(trackIds),
482
+ let anchorId = ids.first(where: { trackOf[$0] == anchor })
483
+ else { return recover("unknown item for reorder") }
484
+ track(client.queueReorderItems(withIDs: items, insertBeforeItemWithID: anchorId))
485
+ }
486
+ }
487
+
488
+ private func sendLoad(_ load: CastQueueLoad) {
489
+ guard let client = remoteClient else { return recover("no client") }
490
+ let items = load.tracks.compactMap { makeQueueItem(for: $0, autoplay: load.autoplay) }
491
+ guard items.count == load.tracks.count else { return recover("uncastable item") }
492
+ let options = GCKMediaQueueLoadOptions()
493
+ options.startIndex = UInt(load.startIndex)
494
+ options.playPosition = load.position.isFinite ? max(0, load.position) : 0
495
+ switch load.repeatMode {
496
+ case .track: options.repeatMode = .single
497
+ case .playlist: options.repeatMode = .all
498
+ default: options.repeatMode = .off
499
+ }
500
+ track(client.queueLoad(items, with: options))
501
+ }
502
+
503
+ /// Bounds the wait so a lost callback cannot stall the queue.
504
+ private func track(_ request: GCKRequest) {
505
+ request.delegate = self
506
+ reconciler.requestAssigned(request.requestID)
507
+ watchdog?.cancel()
508
+ let work = DispatchWorkItem { [weak self] in
509
+ guard let self else { return }
510
+ self.run(self.reconciler.requestFailed(request.requestID, reason: "request timeout"))
511
+ }
512
+ watchdog = work
513
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.requestTimeout, execute: work)
514
+ }
515
+
516
+ private func recover(_ reason: String) {
517
+ watchdog?.cancel()
518
+ watchdog = nil
519
+ NitroPlayerLogger.log("CastSessionManager", "Cast queue recovery: \(reason)")
520
+ guard let core else { return }
521
+ core.playerQueue.async {
522
+ core.loadCastQueue(
523
+ autoplay: core.intendedToPlay,
524
+ position: self.lastKnownRemotePosition)
525
+ }
526
+ }
527
+
528
+ fileprivate func observeReceiverQueue(_ client: GCKRemoteMediaClient) {
529
+ let queue = client.mediaQueue
530
+ let ids = (0..<queue.itemCount).map { queue.itemID(at: $0) }
531
+ run(reconciler.queueObserved(ids))
532
+ sweepVerification(queue)
533
+ }
534
+
535
+ /// Ids the SDK's rolling fetch window drops stay pending and are requested again.
536
+ private func sweepVerification(_ queue: GCKMediaQueue) {
537
+ let pending = reconciler.unverified
538
+ guard !pending.isEmpty else { return }
539
+ var requested = 0
540
+ for (index, id) in reconciler.order.enumerated() where pending.contains(id) {
541
+ if let item = queue.item(at: UInt(index), fetchIfNeeded: true) { verify(item) }
542
+ requested += 1
543
+ if requested >= 20 { break }
544
+ }
545
+ }
546
+
547
+ fileprivate func verify(_ item: GCKMediaQueueItem?) {
548
+ guard let item,
549
+ let customData = item.mediaInformation.customData as? [String: Any],
550
+ let trackId = customData["trackId"] as? String
551
+ else { return }
552
+ run(reconciler.verified(item.itemID, trackId: trackId))
553
+ }
554
+
555
+ /// Republish so playerQueue readers see the reconciled order.
556
+ fileprivate func publishOrder() {
557
+ let tracks = reconciler.orderedTrackIds.compactMap { catalog[$0] }
558
+ guard !tracks.isEmpty else { return }
559
+ withState { _loadedTracks = tracks }
560
+ }
561
+ }
562
+
563
+ // MARK: - GCKRequestDelegate
564
+
565
+ extension CastSessionManager: GCKRequestDelegate {
566
+ func requestDidComplete(_ request: GCKRequest) {
567
+ watchdog?.cancel()
568
+ watchdog = nil
569
+ run(reconciler.requestCompleted(request.requestID))
570
+ if let client = remoteClient { observeReceiverQueue(client) }
571
+ }
572
+
573
+ func request(_ request: GCKRequest, didFailWithError error: GCKError) {
574
+ run(reconciler.requestFailed(request.requestID, reason: "request failed: \(error.code)"))
575
+ }
576
+
577
+ func request(_ request: GCKRequest, didAbortWith abortReason: GCKRequestAbortReason) {
578
+ run(reconciler.requestFailed(request.requestID, reason: "request aborted: \(abortReason.rawValue)"))
579
+ }
580
+ }
581
+
582
+ // MARK: - GCKMediaQueueDelegate
583
+
584
+ extension CastSessionManager: GCKMediaQueueDelegate {
585
+ func mediaQueueDidChange(_ queue: GCKMediaQueue) {
586
+ if let client = remoteClient { observeReceiverQueue(client) }
587
+ }
588
+
589
+ func mediaQueue(_ queue: GCKMediaQueue, didUpdateItemsAtIndexes indexes: [NSNumber]) {
590
+ for index in indexes {
591
+ verify(queue.item(at: index.uintValue, fetchIfNeeded: false))
592
+ }
593
+ }
594
+ }
595
+
431
596
  // MARK: - GCKSessionManagerListener
432
597
 
433
598
  extension CastSessionManager: GCKSessionManagerListener {
@@ -445,6 +610,14 @@ extension CastSessionManager: GCKSessionManagerListener {
445
610
 
446
611
  func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
447
612
  stopProgressTimer()
613
+ (session as? GCKCastSession)?.remoteMediaClient?.mediaQueue.remove(self)
614
+ watchdog?.cancel()
615
+ watchdog = nil
616
+ // The handler runs asynchronously, so hand it a snapshot taken before the reset.
617
+ let snapshot = CastTransferSnapshot(
618
+ trackId: currentRemoteTrackId, position: lastKnownRemotePosition)
619
+ reconciler.sessionEnded()
620
+ catalog = [:]
448
621
  withState {
449
622
  _lastCurrentTrackId = nil
450
623
  _loadedTracks = []
@@ -452,12 +625,15 @@ extension CastSessionManager: GCKSessionManagerListener {
452
625
  _cachedPlaybackState = .stopped
453
626
  _lastEmittedPlaybackState = nil
454
627
  }
455
- core?.handleCastDisconnected()
628
+ core?.handleCastDisconnected(snapshot)
456
629
  emitCastState()
457
630
  }
458
631
 
459
632
  private func handleSessionStarted(_ session: GCKSession) {
460
633
  (session as? GCKCastSession)?.remoteMediaClient?.add(self)
634
+ (session as? GCKCastSession)?.remoteMediaClient?.mediaQueue.add(self)
635
+ reconciler.sessionEnded()
636
+ catalog = [:]
461
637
  startProgressTimer()
462
638
  core?.handleCastConnected()
463
639
  emitCastState()
@@ -494,6 +670,14 @@ extension CastSessionManager: GCKRemoteMediaClientListener {
494
670
  }
495
671
  }
496
672
 
673
+ observeReceiverQueue(client)
674
+ if let status = mediaStatus {
675
+ verify(status.queueItem(withItemID: status.currentItemID))
676
+ if status.preloadedItemID != kGCKMediaQueueInvalidItemID {
677
+ verify(status.queueItem(withItemID: status.preloadedItemID))
678
+ }
679
+ }
680
+
497
681
  // Playback state — only forward actual changes (status updates are frequent).
498
682
  let stateChanged: Bool = withState {
499
683
  guard _lastEmittedPlaybackState != state else { return false }