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.
- package/README.md +3 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +75 -37
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +106 -23
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +15 -3
- package/ios/core/CastSessionManager.swift +197 -56
- package/ios/core/TrackPlayerCast.swift +231 -10
- package/ios/core/TrackPlayerPlayback.swift +3 -1
- package/ios/core/TrackPlayerQueue.swift +27 -11
- package/ios/core/TrackPlayerQueueBuild.swift +6 -0
- package/ios/core/TrackPlayerUrlLoader.swift +26 -2
- package/lib/components/CastButton.js +68 -43
- package/package.json +1 -1
- package/src/components/CastButton.tsx +84 -47
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// `#if canImport(GoogleCast)` so the library still builds when the SDK is not
|
|
8
8
|
// linked — in that case every method is an inert no-op and casting is disabled.
|
|
9
9
|
//
|
|
10
|
+
// Threading: the SDK is main-thread-affine, so SDK calls hop to main and reads are served from lock-guarded caches.
|
|
11
|
+
//
|
|
10
12
|
|
|
11
13
|
import Foundation
|
|
12
14
|
import MediaPlayer
|
|
@@ -19,15 +21,67 @@ final class CastSessionManager: NSObject {
|
|
|
19
21
|
weak var core: TrackPlayerCore?
|
|
20
22
|
|
|
21
23
|
private var isConfigured = false
|
|
22
|
-
private var
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
private var progressTimer: Timer?
|
|
25
|
+
|
|
26
|
+
// MARK: - Lock-guarded caches (written on main, read from playerQueue / JS thread)
|
|
27
|
+
|
|
28
|
+
private let stateLock = NSLock()
|
|
29
|
+
private var _lastCurrentTrackId: String?
|
|
30
|
+
private var _loadedTracks: [TrackItem] = []
|
|
31
|
+
private var _hasLoadedMedia = false
|
|
32
|
+
private var _cachedPlaybackState: TrackPlayerState = .stopped
|
|
33
|
+
private var _lastEmittedPlaybackState: TrackPlayerState?
|
|
34
|
+
private var _lastKnownRemotePosition: Double = 0
|
|
35
|
+
private var _lastKnownRemoteDuration: Double = 0
|
|
26
36
|
private var cachedState: CastState = .noDevicesAvailable
|
|
27
37
|
private var cachedDeviceName: String?
|
|
38
|
+
|
|
39
|
+
/// Track ID playing on the receiver (or expected to, right after a locally-initiated jump).
|
|
40
|
+
var currentRemoteTrackId: String? {
|
|
41
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
42
|
+
return _lastCurrentTrackId
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// IDs of the tracks last sent to the receiver, in queue order.
|
|
46
|
+
var loadedTrackIds: [String] {
|
|
47
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
48
|
+
return _loadedTracks.map { $0.id }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Whether the receiver currently has media loaded.
|
|
52
|
+
var hasLoadedMedia: Bool {
|
|
53
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
54
|
+
return _hasLoadedMedia
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/// Last position reported by the receiver, used to resume locally on disconnect.
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
var lastKnownRemotePosition: Double {
|
|
59
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
60
|
+
return _lastKnownRemotePosition
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Last duration reported by the receiver for the current item.
|
|
64
|
+
var lastKnownRemoteDuration: Double {
|
|
65
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
66
|
+
return _lastKnownRemoteDuration
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Last playback state reported by the receiver.
|
|
70
|
+
var cachedPlaybackState: TrackPlayerState {
|
|
71
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
72
|
+
return _cachedPlaybackState
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Pre-set the expected current track after a local jump so the receiver's status echo doesn't duplicate the track-change event.
|
|
76
|
+
func setExpectedCurrentTrack(_ trackId: String?) {
|
|
77
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
78
|
+
_lastCurrentTrackId = trackId
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func withState<T>(_ body: () -> T) -> T {
|
|
82
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
83
|
+
return body()
|
|
84
|
+
}
|
|
31
85
|
|
|
32
86
|
// MARK: - Configuration
|
|
33
87
|
|
|
@@ -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
|
|
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 {
|
|
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? {
|
|
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
|
|
203
|
+
func setQueueRepeatMode(_ mode: RepeatMode) {
|
|
144
204
|
#if canImport(GoogleCast)
|
|
145
205
|
onMain {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
168
|
-
options.playPosition = position.isFinite ? position : 0
|
|
169
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
304
|
-
|
|
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:
|
|
309
|
-
case .notConnected:
|
|
310
|
-
case .connecting:
|
|
311
|
-
case .connected:
|
|
312
|
-
@unknown default:
|
|
415
|
+
case .noDevicesAvailable: newState = .noDevicesAvailable
|
|
416
|
+
case .notConnected: newState = .notConnected
|
|
417
|
+
case .connecting: newState = .connecting
|
|
418
|
+
case .connected: newState = .connected
|
|
419
|
+
@unknown default: newState = .notConnected
|
|
420
|
+
}
|
|
421
|
+
let name = GCKCastContext.sharedInstance().sessionManager.currentCastSession?.device.friendlyName
|
|
422
|
+
withState {
|
|
423
|
+
cachedState = newState
|
|
424
|
+
cachedDeviceName = name
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
364
|
-
|
|
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
|
-
|
|
59
|
-
|
|
86
|
+
// MARK: - Receiver queue loading (all on playerQueue)
|
|
87
|
+
|
|
88
|
+
/// Load the receiver queue starting at the current track.
|
|
60
89
|
func loadCastQueue(autoplay: Bool, position: Double) {
|
|
61
90
|
let queue = getActualQueueInternal()
|
|
62
91
|
guard !queue.isEmpty else { return }
|
|
63
92
|
let current = getCurrentTrack()
|
|
64
93
|
let startIndex = current
|
|
65
94
|
.flatMap { c in queue.firstIndex(where: { $0.id == c.id }) }
|
|
66
|
-
?? max(0, currentTrackIndex)
|
|
67
|
-
|
|
95
|
+
?? max(0, min(currentTrackIndex, queue.count - 1))
|
|
96
|
+
loadCastQueue(fromActualIndex: startIndex, autoplay: autoplay, position: position)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Atomically (re)load the receiver queue from `index` in the actual queue (lazy targets stop the receiver and wait for updateTracks).
|
|
100
|
+
func loadCastQueue(fromActualIndex index: Int, autoplay: Bool, position: Double) {
|
|
101
|
+
let queue = getActualQueueInternal()
|
|
102
|
+
guard index >= 0, index < queue.count else { return }
|
|
103
|
+
let slice = Array(queue[index...])
|
|
104
|
+
let playable = castableUpcoming(slice)
|
|
105
|
+
|
|
106
|
+
guard !playable.isEmpty else {
|
|
107
|
+
// Lazy target — silence the receiver and wait for updateTracks to reload.
|
|
108
|
+
castManager?.stop()
|
|
109
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Resume mid-track only when the receiver starts on the requested track.
|
|
114
|
+
let effectivePosition = playable[0].id == slice[0].id ? position : 0
|
|
115
|
+
|
|
116
|
+
castManager?.setExpectedCurrentTrack(playable[0].id)
|
|
117
|
+
castManager?.loadQueue(
|
|
118
|
+
tracks: playable,
|
|
119
|
+
position: effectivePosition,
|
|
120
|
+
autoplay: autoplay,
|
|
121
|
+
repeatMode: currentRepeatMode
|
|
122
|
+
)
|
|
123
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Sync the receiver's upcoming items: append-only extensions don't interrupt playback; anything else reloads at the current position.
|
|
127
|
+
func syncCastQueueAfterCurrent() {
|
|
128
|
+
guard let cast = castManager else { return }
|
|
129
|
+
guard cast.hasLoadedMedia else {
|
|
130
|
+
// Nothing loaded remotely (e.g. the target was lazy) — full load from current.
|
|
131
|
+
loadCastQueue(autoplay: intendedToPlay, position: 0)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let queue = getActualQueueInternal()
|
|
136
|
+
guard let current = getCurrentTrack(),
|
|
137
|
+
let currentIdx = queue.firstIndex(where: { $0.id == current.id })
|
|
138
|
+
else { return }
|
|
139
|
+
|
|
140
|
+
let desired = castableUpcoming(Array(queue[(currentIdx + 1)...]))
|
|
141
|
+
let desiredIds = desired.map { $0.id }
|
|
142
|
+
|
|
143
|
+
let loaded = cast.loadedTrackIds
|
|
144
|
+
let existingUpcoming: [String]
|
|
145
|
+
if let ei = loaded.firstIndex(of: current.id) {
|
|
146
|
+
existingUpcoming = Array(loaded[(ei + 1)...])
|
|
147
|
+
} else {
|
|
148
|
+
existingUpcoming = []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if desiredIds == existingUpcoming { return } // already in sync
|
|
152
|
+
|
|
153
|
+
if desiredIds.count > existingUpcoming.count,
|
|
154
|
+
Array(desiredIds.prefix(existingUpcoming.count)) == existingUpcoming {
|
|
155
|
+
// Strict tail extension — append without touching current playback.
|
|
156
|
+
let toAppend = Array(desired.suffix(desiredIds.count - existingUpcoming.count))
|
|
157
|
+
castManager?.appendToQueue(tracks: toAppend)
|
|
158
|
+
} else {
|
|
159
|
+
// Order changed / items removed — atomic reload preserving position.
|
|
160
|
+
loadCastQueue(
|
|
161
|
+
fromActualIndex: currentIdx,
|
|
162
|
+
autoplay: intendedToPlay,
|
|
163
|
+
position: cast.lastKnownRemotePosition
|
|
164
|
+
)
|
|
165
|
+
}
|
|
68
166
|
}
|
|
69
167
|
|
|
70
|
-
// MARK: -
|
|
71
|
-
// These bypass getStateInternal()/notify* which read the (paused) local player.
|
|
168
|
+
// MARK: - Cast-mode navigation (mirrors the local skip logic, minus AVQueuePlayer)
|
|
72
169
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
170
|
+
/// skipToIndex while casting: apply the local path's list/index mutations, then reload the receiver at the target.
|
|
171
|
+
func skipToIndexCastInternal(index: Int) -> Bool {
|
|
172
|
+
let actualQueue = getActualQueueInternal()
|
|
173
|
+
guard index >= 0 && index < actualQueue.count else { return false }
|
|
174
|
+
|
|
175
|
+
// Same section boundaries as the local skipToIndexInternal.
|
|
176
|
+
let currentPos = currentTemporaryType != .none ? currentTrackIndex + 1 : currentTrackIndex
|
|
177
|
+
let effectivePlayNextSize = currentTemporaryType == .playNext
|
|
178
|
+
? max(0, playNextStack.count - 1) : playNextStack.count
|
|
179
|
+
let effectiveUpNextSize = currentTemporaryType == .upNext
|
|
180
|
+
? max(0, upNextQueue.count - 1) : upNextQueue.count
|
|
181
|
+
let playNextStart = currentPos + 1
|
|
182
|
+
let playNextEnd = playNextStart + effectivePlayNextSize
|
|
183
|
+
let upNextEnd = playNextEnd + effectiveUpNextSize
|
|
184
|
+
|
|
185
|
+
if index == currentPos {
|
|
186
|
+
castManager?.seek(to: 0)
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let target = actualQueue[index]
|
|
191
|
+
|
|
192
|
+
// Skipping away from a playing temp track removes it from its list (mirrors the local handler).
|
|
193
|
+
if currentTemporaryType != .none, let currentId = activeCurrentTrackId {
|
|
194
|
+
if currentTemporaryType == .playNext,
|
|
195
|
+
let i = playNextStack.firstIndex(where: { $0.id == currentId }) {
|
|
196
|
+
playNextStack.remove(at: i)
|
|
197
|
+
} else if currentTemporaryType == .upNext,
|
|
198
|
+
let i = upNextQueue.firstIndex(where: { $0.id == currentId }) {
|
|
199
|
+
upNextQueue.remove(at: i)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if index < currentPos || index >= upNextEnd {
|
|
204
|
+
// Original-playlist target — temps are cleared, same as the local path.
|
|
205
|
+
guard let originalIndex = currentTracks.firstIndex(where: { $0.id == target.id }) else { return false }
|
|
206
|
+
playNextStack.removeAll()
|
|
207
|
+
upNextQueue.removeAll()
|
|
208
|
+
currentTemporaryType = .none
|
|
209
|
+
currentTrackIndex = originalIndex
|
|
210
|
+
} else if index < playNextEnd {
|
|
211
|
+
// playNext section: everything before the target is dropped.
|
|
212
|
+
if let t = playNextStack.firstIndex(where: { $0.id == target.id }), t > 0 {
|
|
213
|
+
playNextStack.removeSubrange(0..<t)
|
|
214
|
+
}
|
|
215
|
+
currentTemporaryType = .playNext
|
|
216
|
+
} else {
|
|
217
|
+
// upNext section: playNext is consumed, earlier upNext entries dropped.
|
|
218
|
+
playNextStack.removeAll()
|
|
219
|
+
if let t = upNextQueue.firstIndex(where: { $0.id == target.id }), t > 0 {
|
|
220
|
+
upNextQueue.removeSubrange(0..<t)
|
|
221
|
+
}
|
|
222
|
+
currentTemporaryType = .upNext
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
castManager?.setExpectedCurrentTrack(target.id)
|
|
226
|
+
notifyTemporaryQueueChange()
|
|
227
|
+
onChangeTrackListeners.forEach { $0(target, .skip) }
|
|
228
|
+
|
|
229
|
+
// Reload the receiver from the target's position in the refreshed queue.
|
|
230
|
+
let newQueue = getActualQueueInternal()
|
|
231
|
+
let newIndex = newQueue.firstIndex(where: { $0.id == target.id }) ?? 0
|
|
232
|
+
loadCastQueue(fromActualIndex: newIndex, autoplay: intendedToPlay, position: 0)
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// skipToPrevious while casting: restart after the threshold, otherwise step back through temp/original tracks.
|
|
237
|
+
func skipToPreviousCastInternal() {
|
|
238
|
+
let position = castManager?.lastKnownRemotePosition ?? 0
|
|
239
|
+
if position > Constants.skipToPreviousThreshold {
|
|
240
|
+
castManager?.seek(to: 0)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if currentTemporaryType != .none {
|
|
245
|
+
// Leaving a temp track removes it, then return to the current original.
|
|
246
|
+
if let currentId = activeCurrentTrackId {
|
|
247
|
+
if currentTemporaryType == .playNext,
|
|
248
|
+
let i = playNextStack.firstIndex(where: { $0.id == currentId }) {
|
|
249
|
+
playNextStack.remove(at: i)
|
|
250
|
+
} else if currentTemporaryType == .upNext,
|
|
251
|
+
let i = upNextQueue.firstIndex(where: { $0.id == currentId }) {
|
|
252
|
+
upNextQueue.remove(at: i)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
76
255
|
currentTemporaryType = .none
|
|
256
|
+
notifyTemporaryQueueChange()
|
|
257
|
+
} else if currentTrackIndex > 0 {
|
|
258
|
+
currentTrackIndex -= 1
|
|
259
|
+
} else {
|
|
260
|
+
castManager?.seek(to: 0)
|
|
261
|
+
return
|
|
77
262
|
}
|
|
263
|
+
|
|
264
|
+
guard let target = currentTracks[safe: currentTrackIndex] else { return }
|
|
265
|
+
castManager?.setExpectedCurrentTrack(target.id)
|
|
266
|
+
onChangeTrackListeners.forEach { $0(target, .skip) }
|
|
267
|
+
loadCastQueue(autoplay: intendedToPlay, position: 0)
|
|
268
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// MARK: - Emit (cast-derived events → JS listeners; run on playerQueue)
|
|
272
|
+
|
|
273
|
+
/// Track change from the receiver: temp-list bookkeeping, index/type classification, and the JS event.
|
|
274
|
+
func emitCastTrackChange(_ track: TrackItem, previousTrackId: String? = nil) {
|
|
275
|
+
// Leaving a temp track removes it from its list (mirrors the local handler).
|
|
276
|
+
if let prev = previousTrackId, prev != track.id {
|
|
277
|
+
if let i = playNextStack.firstIndex(where: { $0.id == prev }) {
|
|
278
|
+
playNextStack.remove(at: i)
|
|
279
|
+
notifyTemporaryQueueChange()
|
|
280
|
+
} else if let i = upNextQueue.firstIndex(where: { $0.id == prev }) {
|
|
281
|
+
upNextQueue.remove(at: i)
|
|
282
|
+
notifyTemporaryQueueChange()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Classify the new current track — temp lists take priority (matches determineCurrentTemporaryType).
|
|
287
|
+
if playNextStack.contains(where: { $0.id == track.id }) {
|
|
288
|
+
currentTemporaryType = .playNext
|
|
289
|
+
} else if upNextQueue.contains(where: { $0.id == track.id }) {
|
|
290
|
+
currentTemporaryType = .upNext
|
|
291
|
+
} else {
|
|
292
|
+
currentTemporaryType = .none
|
|
293
|
+
if let idx = currentTracks.firstIndex(where: { $0.id == track.id }) {
|
|
294
|
+
currentTrackIndex = idx
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
78
298
|
onChangeTrackListeners.forEach { $0(track, .skip) }
|
|
299
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
79
300
|
}
|
|
80
301
|
|
|
81
302
|
func emitCastPlaybackState(_ state: TrackPlayerState) {
|
|
@@ -47,7 +47,8 @@ extension TrackPlayerCore {
|
|
|
47
47
|
|
|
48
48
|
func skipToPrevious() async {
|
|
49
49
|
if isCasting {
|
|
50
|
-
|
|
50
|
+
// The receiver queue starts at the current track, so queuePreviousItem has nothing behind it — use core state instead.
|
|
51
|
+
await withPlayerQueueNoThrow { self.skipToPreviousCastInternal() }
|
|
51
52
|
return
|
|
52
53
|
}
|
|
53
54
|
await withPlayerQueueNoThrow { self.skipToPreviousInternal() }
|
|
@@ -57,6 +58,7 @@ extension TrackPlayerCore {
|
|
|
57
58
|
await withPlayerQueueNoThrow {
|
|
58
59
|
self.currentRepeatMode = mode
|
|
59
60
|
self.player?.actionAtItemEnd = (mode == .track) ? .none : .advance
|
|
61
|
+
if self.isCasting { self.castManager?.setQueueRepeatMode(mode) }
|
|
60
62
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
61
63
|
}
|
|
62
64
|
}
|