react-native-nitro-player 1.7.0-alpha.3 → 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.
- package/README.md +9 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +46 -14
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +36 -12
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +9 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +7 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +8 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +26 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +60 -0
- package/ios/cast/CastQueueDiff.swift +58 -0
- package/ios/cast/CastQueueReconciler.swift +228 -0
- package/ios/core/CastSessionManager.swift +215 -31
- package/ios/core/TrackPlayerCast.swift +96 -65
- package/ios/core/TrackPlayerCore.swift +31 -0
- package/ios/core/TrackPlayerListener.swift +19 -3
- package/ios/core/TrackPlayerPlayback.swift +12 -10
- package/ios/core/TrackPlayerQueue.swift +10 -2
- package/ios/core/TrackPlayerQueueBuild.swift +21 -9
- package/ios/core/TrackPlayerTempQueue.swift +27 -15
- package/ios/core/TrackPlayerUrlLoader.swift +6 -2
- package/ios/playlist/PlaylistManager.swift +60 -1
- package/ios/queue/HybridPlayerQueue.swift +40 -13
- package/lib/hooks/callbackManager.d.ts +2 -0
- package/lib/hooks/callbackManager.js +23 -8
- package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
- package/lib/types/PlayerQueue.d.ts +14 -0
- package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
- package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
- package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
- package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
- package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +25 -10
- package/src/specs/TrackPlayer.nitro.ts +11 -0
- 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.
|
|
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
|
-
///
|
|
235
|
-
func loadQueue(
|
|
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.
|
|
257
|
+
_lastCurrentTrackId = tracks[startIndex].id
|
|
242
258
|
}
|
|
243
259
|
onMain {
|
|
244
|
-
|
|
245
|
-
let
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
///
|
|
262
|
-
func
|
|
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
|
-
|
|
271
|
-
|
|
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 }
|