@capgo/capacitor-video-player 8.1.8 → 8.1.10
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/CapgoCapacitorVideoPlayer.podspec +1 -0
- package/Package.swift +4 -2
- package/README.md +55 -27
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +1 -1
- package/dist/docs.json +4 -4
- package/dist/esm/definitions.d.ts +5 -4
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +100 -4
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +792 -0
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +39 -27
- package/package.json +1 -1
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import AVKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
#if canImport(GoogleCast)
|
|
7
|
+
import GoogleCast
|
|
8
|
+
|
|
9
|
+
final class VideoPlayerCastController: NSObject {
|
|
10
|
+
private enum PendingCastCommand {
|
|
11
|
+
case play
|
|
12
|
+
case pause
|
|
13
|
+
case seek(Double)
|
|
14
|
+
case volume(Float)
|
|
15
|
+
case muted(Bool)
|
|
16
|
+
case rate(Float)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private let videoUrl: String
|
|
20
|
+
private let title: String?
|
|
21
|
+
private let smallTitle: String?
|
|
22
|
+
private let artwork: String?
|
|
23
|
+
|
|
24
|
+
private weak var player: AVPlayer?
|
|
25
|
+
private weak var playerViewController: AVPlayerViewController?
|
|
26
|
+
private weak var castButton: GCKUICastButton?
|
|
27
|
+
private weak var castIndicatorLabel: UILabel?
|
|
28
|
+
private weak var observedRemoteMediaClient: GCKRemoteMediaClient?
|
|
29
|
+
private var mediaLoadRequest: GCKRequest?
|
|
30
|
+
private var pendingCastCommands: [PendingCastCommand] = []
|
|
31
|
+
private var isLoadedOnCast = false
|
|
32
|
+
private var isLoadingOnCast = false
|
|
33
|
+
private var localWasPlaying = false
|
|
34
|
+
private var isDetached = false
|
|
35
|
+
private var lastRemoteIsPlaying: Bool?
|
|
36
|
+
private var didNotifyRemoteEnd = false
|
|
37
|
+
private var onPlay: (() -> Void)?
|
|
38
|
+
private var onPause: (() -> Void)?
|
|
39
|
+
private var onEnd: (() -> Void)?
|
|
40
|
+
private var controlsHideTimer: Timer?
|
|
41
|
+
private weak var tapGestureRecognizer: UITapGestureRecognizer?
|
|
42
|
+
private static let overlayAutoHideDuration: TimeInterval = 3.0
|
|
43
|
+
|
|
44
|
+
var isCasting: Bool {
|
|
45
|
+
return remoteMediaClient != nil && isLoadedOnCast
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private var remoteMediaClient: GCKRemoteMediaClient? {
|
|
49
|
+
guard GCKCastContext.isSharedInstanceInitialized() else {
|
|
50
|
+
return nil
|
|
51
|
+
}
|
|
52
|
+
return GCKCastContext.sharedInstance().sessionManager.currentCastSession?.remoteMediaClient
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
init(videoUrl: String, title: String?, smallTitle: String?, artwork: String?) {
|
|
56
|
+
self.videoUrl = videoUrl
|
|
57
|
+
self.title = title
|
|
58
|
+
self.smallTitle = smallTitle
|
|
59
|
+
self.artwork = artwork
|
|
60
|
+
super.init()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func attach(to playerViewController: AVPlayerViewController, player: AVPlayer) {
|
|
64
|
+
self.playerViewController = playerViewController
|
|
65
|
+
self.player = player
|
|
66
|
+
|
|
67
|
+
let attachOnMain = { [weak self] in
|
|
68
|
+
guard let self = self,
|
|
69
|
+
Self.configureCastContext() else {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
GCKCastContext.sharedInstance().sessionManager.add(self)
|
|
74
|
+
self.addCastButton(to: playerViewController)
|
|
75
|
+
self.addCastIndicator(to: playerViewController)
|
|
76
|
+
self.beginObservingPlayerTaps(playerViewController)
|
|
77
|
+
self.loadMediaIfCastSessionAvailable()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if Thread.isMainThread {
|
|
81
|
+
attachOnMain()
|
|
82
|
+
} else {
|
|
83
|
+
DispatchQueue.main.async(execute: attachOnMain)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func detach(stopRemoteMedia: Bool) {
|
|
88
|
+
guard !isDetached else {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
isDetached = true
|
|
92
|
+
clearMediaLoadRequest()
|
|
93
|
+
pendingCastCommands.removeAll()
|
|
94
|
+
|
|
95
|
+
DispatchQueue.main.async { [weak self] in
|
|
96
|
+
guard let self = self,
|
|
97
|
+
GCKCastContext.isSharedInstanceInitialized() else {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if stopRemoteMedia {
|
|
102
|
+
self.remoteMediaClient?.stop()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
self.stopRemoteMediaObservation()
|
|
106
|
+
GCKCastContext.sharedInstance().sessionManager.remove(self)
|
|
107
|
+
self.controlsHideTimer?.invalidate()
|
|
108
|
+
self.controlsHideTimer = nil
|
|
109
|
+
if let tapRecognizer = self.tapGestureRecognizer,
|
|
110
|
+
let view = self.playerViewController?.view {
|
|
111
|
+
view.removeGestureRecognizer(tapRecognizer)
|
|
112
|
+
}
|
|
113
|
+
self.tapGestureRecognizer = nil
|
|
114
|
+
self.castButton?.removeFromSuperview()
|
|
115
|
+
self.castButton = nil
|
|
116
|
+
self.castIndicatorLabel?.removeFromSuperview()
|
|
117
|
+
self.castIndicatorLabel = nil
|
|
118
|
+
self.player = nil
|
|
119
|
+
self.playerViewController = nil
|
|
120
|
+
self.isLoadedOnCast = false
|
|
121
|
+
self.isLoadingOnCast = false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func play() -> Bool {
|
|
126
|
+
guard let remoteMediaClient = remoteMediaClient else {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
guard isLoadedOnCast else {
|
|
130
|
+
return enqueuePendingCastCommand(.play)
|
|
131
|
+
}
|
|
132
|
+
remoteMediaClient.play()
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func pause() -> Bool {
|
|
137
|
+
guard let remoteMediaClient = remoteMediaClient else {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
guard isLoadedOnCast else {
|
|
141
|
+
return enqueuePendingCastCommand(.pause)
|
|
142
|
+
}
|
|
143
|
+
remoteMediaClient.pause()
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func setCurrentTime(_ time: Double) -> Bool {
|
|
148
|
+
guard let remoteMediaClient = remoteMediaClient else {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
guard isLoadedOnCast else {
|
|
152
|
+
return enqueuePendingCastCommand(.seek(time))
|
|
153
|
+
}
|
|
154
|
+
let options = GCKMediaSeekOptions()
|
|
155
|
+
options.interval = time
|
|
156
|
+
options.relative = false
|
|
157
|
+
options.resumeState = .unchanged
|
|
158
|
+
remoteMediaClient.seek(with: options)
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func isPlaying() -> Bool {
|
|
163
|
+
guard let playerState = remoteMediaClient?.mediaStatus?.playerState else {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
return playerState == .playing || playerState == .buffering
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func getDuration() -> Double {
|
|
170
|
+
return remoteMediaClient?.mediaStatus?.mediaInformation?.streamDuration ?? 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func getCurrentTime() -> Double {
|
|
174
|
+
return remoteMediaClient?.approximateStreamPosition() ?? 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func getVolume() -> Float {
|
|
178
|
+
return remoteMediaClient?.mediaStatus?.volume ?? 0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func setVolume(_ volume: Float) -> Bool {
|
|
182
|
+
guard let remoteMediaClient = remoteMediaClient else {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
guard isLoadedOnCast else {
|
|
186
|
+
return enqueuePendingCastCommand(.volume(volume))
|
|
187
|
+
}
|
|
188
|
+
remoteMediaClient.setStreamVolume(volume)
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func getMuted() -> Bool {
|
|
193
|
+
return remoteMediaClient?.mediaStatus?.isMuted ?? false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func setMuted(_ muted: Bool) -> Bool {
|
|
197
|
+
guard let remoteMediaClient = remoteMediaClient else {
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
guard isLoadedOnCast else {
|
|
201
|
+
return enqueuePendingCastCommand(.muted(muted))
|
|
202
|
+
}
|
|
203
|
+
remoteMediaClient.setStreamMuted(muted)
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func getRate() -> Float {
|
|
208
|
+
return remoteMediaClient?.mediaStatus?.playbackRate ?? 0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func setRate(_ rate: Float) -> Bool {
|
|
212
|
+
guard let remoteMediaClient = remoteMediaClient else {
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
guard isLoadedOnCast else {
|
|
216
|
+
return enqueuePendingCastCommand(.rate(rate))
|
|
217
|
+
}
|
|
218
|
+
remoteMediaClient.setPlaybackRate(rate)
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func restartPlayback() -> Bool {
|
|
223
|
+
guard let remoteMediaClient = remoteMediaClient,
|
|
224
|
+
let mediaInfo = makeMediaInformation() else {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
didNotifyRemoteEnd = false
|
|
229
|
+
isLoadedOnCast = false
|
|
230
|
+
isLoadingOnCast = true
|
|
231
|
+
clearMediaLoadRequest()
|
|
232
|
+
|
|
233
|
+
let mediaLoadRequestDataBuilder = GCKMediaLoadRequestDataBuilder()
|
|
234
|
+
mediaLoadRequestDataBuilder.mediaInformation = mediaInfo
|
|
235
|
+
mediaLoadRequestDataBuilder.autoplay = NSNumber(value: true)
|
|
236
|
+
mediaLoadRequestDataBuilder.startTime = 0
|
|
237
|
+
let request = remoteMediaClient.loadMedia(with: mediaLoadRequestDataBuilder.build())
|
|
238
|
+
request.delegate = self
|
|
239
|
+
mediaLoadRequest = request
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func setOnPlay(_ callback: @escaping () -> Void) {
|
|
244
|
+
onPlay = callback
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func setOnPause(_ callback: @escaping () -> Void) {
|
|
248
|
+
onPause = callback
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func setOnEnd(_ callback: @escaping () -> Void) {
|
|
252
|
+
onEnd = callback
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private extension VideoPlayerCastController {
|
|
257
|
+
static func configureCastContext() -> Bool {
|
|
258
|
+
guard Thread.isMainThread else {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if GCKCastContext.isSharedInstanceInitialized() {
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// This plugin intentionally uses the default media receiver.
|
|
267
|
+
let criteria = GCKDiscoveryCriteria(applicationID: kGCKDefaultMediaReceiverApplicationID)
|
|
268
|
+
let options = GCKCastOptions(discoveryCriteria: criteria)
|
|
269
|
+
var error: GCKError?
|
|
270
|
+
return GCKCastContext.setSharedInstanceWith(options, error: &error)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func addCastButton(to playerViewController: AVPlayerViewController) {
|
|
274
|
+
guard castButton == nil else {
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
guard let overlayView = playerViewController.view else {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
overlayView.isUserInteractionEnabled = true
|
|
283
|
+
let button = GCKUICastButton(frame: .zero)
|
|
284
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
285
|
+
button.tintColor = .white
|
|
286
|
+
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
|
287
|
+
button.layer.cornerRadius = 22
|
|
288
|
+
button.clipsToBounds = true
|
|
289
|
+
button.accessibilityLabel = "Cast"
|
|
290
|
+
|
|
291
|
+
overlayView.addSubview(button)
|
|
292
|
+
NSLayoutConstraint.activate([
|
|
293
|
+
button.widthAnchor.constraint(equalToConstant: 44),
|
|
294
|
+
button.heightAnchor.constraint(equalToConstant: 44),
|
|
295
|
+
// Position below AVPlayerViewController's top row of controls (Done/X and route picker)
|
|
296
|
+
// using trailing to stay away from the leading-side dismiss button.
|
|
297
|
+
button.topAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.topAnchor, constant: 60),
|
|
298
|
+
button.trailingAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.trailingAnchor, constant: -16)
|
|
299
|
+
])
|
|
300
|
+
|
|
301
|
+
castButton = button
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func addCastIndicator(to playerViewController: AVPlayerViewController) {
|
|
305
|
+
guard castIndicatorLabel == nil else {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
guard let overlayView = playerViewController.view else {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let label = UILabel(frame: .zero)
|
|
314
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
315
|
+
label.text = "Casting"
|
|
316
|
+
label.textColor = .white
|
|
317
|
+
label.font = UIFont.preferredFont(forTextStyle: .caption1)
|
|
318
|
+
label.backgroundColor = UIColor.black.withAlphaComponent(0.55)
|
|
319
|
+
label.layer.cornerRadius = 10
|
|
320
|
+
label.clipsToBounds = true
|
|
321
|
+
label.isHidden = true
|
|
322
|
+
|
|
323
|
+
overlayView.addSubview(label)
|
|
324
|
+
|
|
325
|
+
if let castButton = castButton {
|
|
326
|
+
NSLayoutConstraint.activate([
|
|
327
|
+
label.centerYAnchor.constraint(equalTo: castButton.centerYAnchor),
|
|
328
|
+
label.trailingAnchor.constraint(equalTo: castButton.leadingAnchor, constant: -8)
|
|
329
|
+
])
|
|
330
|
+
} else {
|
|
331
|
+
NSLayoutConstraint.activate([
|
|
332
|
+
label.topAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.topAnchor, constant: 60),
|
|
333
|
+
label.trailingAnchor.constraint(equalTo: overlayView.safeAreaLayoutGuide.trailingAnchor, constant: -68)
|
|
334
|
+
])
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
castIndicatorLabel = label
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func beginObservingPlayerTaps(_ playerViewController: AVPlayerViewController) {
|
|
341
|
+
guard let overlayView = playerViewController.view else { return }
|
|
342
|
+
// Remove any existing recognizer before adding a new one to prevent duplicates.
|
|
343
|
+
if let existing = tapGestureRecognizer {
|
|
344
|
+
overlayView.removeGestureRecognizer(existing)
|
|
345
|
+
}
|
|
346
|
+
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handlePlayerTap))
|
|
347
|
+
tapRecognizer.cancelsTouchesInView = false
|
|
348
|
+
overlayView.addGestureRecognizer(tapRecognizer)
|
|
349
|
+
self.tapGestureRecognizer = tapRecognizer
|
|
350
|
+
// Show the overlay immediately; it will auto-hide after the standard controls delay.
|
|
351
|
+
showOverlayControls()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@objc func handlePlayerTap() {
|
|
355
|
+
showOverlayControls()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func showOverlayControls() {
|
|
359
|
+
castButton?.isHidden = false
|
|
360
|
+
castIndicatorLabel?.isHidden = !isCasting
|
|
361
|
+
controlsHideTimer?.invalidate()
|
|
362
|
+
controlsHideTimer = Timer.scheduledTimer(withTimeInterval: Self.overlayAutoHideDuration, repeats: false) { [weak self] _ in
|
|
363
|
+
self?.hideOverlayControls()
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
func hideOverlayControls() {
|
|
368
|
+
controlsHideTimer = nil
|
|
369
|
+
let button = castButton
|
|
370
|
+
let label = castIndicatorLabel
|
|
371
|
+
UIView.animate(withDuration: 0.3, animations: {
|
|
372
|
+
button?.alpha = 0
|
|
373
|
+
label?.alpha = 0
|
|
374
|
+
}, completion: { _ in
|
|
375
|
+
button?.isHidden = true
|
|
376
|
+
label?.isHidden = true
|
|
377
|
+
button?.alpha = 1
|
|
378
|
+
label?.alpha = 1
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
func loadMediaIfCastSessionAvailable() {
|
|
383
|
+
guard let remoteMediaClient = remoteMediaClient,
|
|
384
|
+
let mediaInfo = makeMediaInformation() else {
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
observeRemoteMediaClient(remoteMediaClient)
|
|
389
|
+
let playPosition = player?.currentTime().seconds ?? 0
|
|
390
|
+
localWasPlaying = (player?.rate ?? 0) > 0
|
|
391
|
+
player?.pause()
|
|
392
|
+
isLoadedOnCast = false
|
|
393
|
+
isLoadingOnCast = true
|
|
394
|
+
lastRemoteIsPlaying = nil
|
|
395
|
+
didNotifyRemoteEnd = false
|
|
396
|
+
clearMediaLoadRequest()
|
|
397
|
+
|
|
398
|
+
let mediaLoadRequestDataBuilder = GCKMediaLoadRequestDataBuilder()
|
|
399
|
+
mediaLoadRequestDataBuilder.mediaInformation = mediaInfo
|
|
400
|
+
mediaLoadRequestDataBuilder.autoplay = NSNumber(value: localWasPlaying)
|
|
401
|
+
mediaLoadRequestDataBuilder.startTime = playPosition.isFinite ? playPosition : 0
|
|
402
|
+
let request = remoteMediaClient.loadMedia(with: mediaLoadRequestDataBuilder.build())
|
|
403
|
+
request.delegate = self
|
|
404
|
+
mediaLoadRequest = request
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
func resumeCastSession(_ session: GCKSession) {
|
|
408
|
+
clearMediaLoadRequest()
|
|
409
|
+
pendingCastCommands.removeAll()
|
|
410
|
+
isLoadingOnCast = false
|
|
411
|
+
|
|
412
|
+
guard let remoteMediaClient = session.remoteMediaClient else {
|
|
413
|
+
isLoadedOnCast = false
|
|
414
|
+
stopRemoteMediaObservation()
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
observeRemoteMediaClient(remoteMediaClient)
|
|
419
|
+
isLoadedOnCast = isCurrentVideo(remoteMediaClient.mediaStatus?.mediaInformation)
|
|
420
|
+
handleRemoteMediaStatus(remoteMediaClient.mediaStatus)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
func observeRemoteMediaClient(_ remoteMediaClient: GCKRemoteMediaClient) {
|
|
424
|
+
if let observedRemoteMediaClient = observedRemoteMediaClient,
|
|
425
|
+
observedRemoteMediaClient === remoteMediaClient {
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
observedRemoteMediaClient?.remove(self)
|
|
430
|
+
remoteMediaClient.add(self)
|
|
431
|
+
observedRemoteMediaClient = remoteMediaClient
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
func stopRemoteMediaObservation() {
|
|
435
|
+
observedRemoteMediaClient?.remove(self)
|
|
436
|
+
observedRemoteMediaClient = nil
|
|
437
|
+
lastRemoteIsPlaying = nil
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private func enqueuePendingCastCommand(_ command: PendingCastCommand) -> Bool {
|
|
441
|
+
guard isLoadingOnCast else {
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
pendingCastCommands.append(command)
|
|
446
|
+
if pendingCastCommands.count > 20 {
|
|
447
|
+
pendingCastCommands.removeFirst(pendingCastCommands.count - 20)
|
|
448
|
+
}
|
|
449
|
+
return true
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
func flushPendingCastCommands() {
|
|
453
|
+
guard let remoteMediaClient = remoteMediaClient,
|
|
454
|
+
isLoadedOnCast else {
|
|
455
|
+
pendingCastCommands.removeAll()
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let commands = pendingCastCommands
|
|
460
|
+
pendingCastCommands.removeAll()
|
|
461
|
+
for command in commands {
|
|
462
|
+
apply(command, to: remoteMediaClient)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private func apply(_ command: PendingCastCommand, to remoteMediaClient: GCKRemoteMediaClient) {
|
|
467
|
+
switch command {
|
|
468
|
+
case .play:
|
|
469
|
+
remoteMediaClient.play()
|
|
470
|
+
case .pause:
|
|
471
|
+
remoteMediaClient.pause()
|
|
472
|
+
case .seek(let time):
|
|
473
|
+
let options = GCKMediaSeekOptions()
|
|
474
|
+
options.interval = time
|
|
475
|
+
options.relative = false
|
|
476
|
+
options.resumeState = .unchanged
|
|
477
|
+
remoteMediaClient.seek(with: options)
|
|
478
|
+
case .volume(let volume):
|
|
479
|
+
remoteMediaClient.setStreamVolume(volume)
|
|
480
|
+
case .muted(let muted):
|
|
481
|
+
remoteMediaClient.setStreamMuted(muted)
|
|
482
|
+
case .rate(let rate):
|
|
483
|
+
remoteMediaClient.setPlaybackRate(rate)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
func applyPendingCastCommandsToLocalPlayer() -> Bool {
|
|
488
|
+
var didApplyPlaybackCommand = false
|
|
489
|
+
let commands = pendingCastCommands
|
|
490
|
+
pendingCastCommands.removeAll()
|
|
491
|
+
for command in commands {
|
|
492
|
+
switch command {
|
|
493
|
+
case .play:
|
|
494
|
+
player?.play()
|
|
495
|
+
didApplyPlaybackCommand = true
|
|
496
|
+
case .pause:
|
|
497
|
+
player?.pause()
|
|
498
|
+
didApplyPlaybackCommand = true
|
|
499
|
+
case .seek(let time):
|
|
500
|
+
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
|
|
501
|
+
case .volume(let volume):
|
|
502
|
+
player?.volume = volume
|
|
503
|
+
case .muted(let muted):
|
|
504
|
+
player?.isMuted = muted
|
|
505
|
+
case .rate(let rate):
|
|
506
|
+
player?.rate = rate
|
|
507
|
+
didApplyPlaybackCommand = true
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return didApplyPlaybackCommand
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
func makeMediaInformation() -> GCKMediaInformation? {
|
|
514
|
+
guard let url = URL(string: videoUrl) else {
|
|
515
|
+
return nil
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let metadata = GCKMediaMetadata()
|
|
519
|
+
metadata.setString(title ?? url.lastPathComponent, forKey: kGCKMetadataKeyTitle)
|
|
520
|
+
if let smallTitle = smallTitle, !smallTitle.isEmpty {
|
|
521
|
+
metadata.setString(smallTitle, forKey: kGCKMetadataKeySubtitle)
|
|
522
|
+
}
|
|
523
|
+
if let artwork = artwork,
|
|
524
|
+
let artworkUrl = URL(string: artwork) {
|
|
525
|
+
metadata.addImage(GCKImage(url: artworkUrl, width: 480, height: 360))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let builder = GCKMediaInformationBuilder(contentURL: url)
|
|
529
|
+
builder.streamType = .buffered
|
|
530
|
+
builder.contentType = contentType(for: url)
|
|
531
|
+
builder.metadata = metadata
|
|
532
|
+
if let duration = player?.currentItem?.duration.seconds,
|
|
533
|
+
duration.isFinite,
|
|
534
|
+
duration > 0 {
|
|
535
|
+
builder.streamDuration = duration
|
|
536
|
+
}
|
|
537
|
+
return builder.build()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
func isCurrentVideo(_ mediaInformation: GCKMediaInformation?) -> Bool {
|
|
541
|
+
guard let mediaInformation = mediaInformation else {
|
|
542
|
+
return false
|
|
543
|
+
}
|
|
544
|
+
if mediaInformation.contentID == videoUrl {
|
|
545
|
+
return true
|
|
546
|
+
}
|
|
547
|
+
guard let expectedUrl = URL(string: videoUrl) else {
|
|
548
|
+
return false
|
|
549
|
+
}
|
|
550
|
+
return mediaInformation.contentURL == expectedUrl
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
func contentType(for url: URL) -> String {
|
|
554
|
+
switch url.pathExtension.lowercased() {
|
|
555
|
+
case "m3u8":
|
|
556
|
+
return "application/x-mpegURL"
|
|
557
|
+
case "mpd":
|
|
558
|
+
return "application/dash+xml"
|
|
559
|
+
case "mov":
|
|
560
|
+
return "video/quicktime"
|
|
561
|
+
case "m4v":
|
|
562
|
+
return "video/x-m4v"
|
|
563
|
+
case "webm":
|
|
564
|
+
return "video/webm"
|
|
565
|
+
case "mp4":
|
|
566
|
+
return "video/mp4"
|
|
567
|
+
default:
|
|
568
|
+
return "video/mp4"
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func resumeLocalPlayback(from session: GCKSession) {
|
|
573
|
+
guard !isDetached,
|
|
574
|
+
isLoadedOnCast else {
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let remoteMediaClient = session.remoteMediaClient
|
|
579
|
+
let position = remoteMediaClient?.approximateStreamPosition() ?? 0
|
|
580
|
+
let shouldResume = remoteMediaClient?.mediaStatus?.playerState == .playing ||
|
|
581
|
+
remoteMediaClient?.mediaStatus?.playerState == .buffering
|
|
582
|
+
|
|
583
|
+
isLoadedOnCast = false
|
|
584
|
+
isLoadingOnCast = false
|
|
585
|
+
stopRemoteMediaObservation()
|
|
586
|
+
clearMediaLoadRequest()
|
|
587
|
+
pendingCastCommands.removeAll()
|
|
588
|
+
let seekTime = CMTime(seconds: position, preferredTimescale: 600)
|
|
589
|
+
player?.seek(to: seekTime) { [weak self] _ in
|
|
590
|
+
if shouldResume {
|
|
591
|
+
self?.player?.play()
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
func clearMediaLoadRequest() {
|
|
597
|
+
mediaLoadRequest?.delegate = nil
|
|
598
|
+
mediaLoadRequest = nil
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
func completeMediaLoadRequest(_ request: GCKRequest) {
|
|
602
|
+
guard request === mediaLoadRequest else {
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
clearMediaLoadRequest()
|
|
606
|
+
isLoadingOnCast = false
|
|
607
|
+
isLoadedOnCast = true
|
|
608
|
+
didNotifyRemoteEnd = false
|
|
609
|
+
DispatchQueue.main.async { [weak self] in
|
|
610
|
+
// Reveal the cast indicator and extend the controls visibility window.
|
|
611
|
+
self?.showOverlayControls()
|
|
612
|
+
}
|
|
613
|
+
flushPendingCastCommands()
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
func failMediaLoadRequest(_ request: GCKRequest) {
|
|
617
|
+
guard request === mediaLoadRequest else {
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
cancelPendingCastHandoff(resumeLocalIfNeeded: true)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
func cancelPendingCastHandoff(resumeLocalIfNeeded: Bool) {
|
|
624
|
+
clearMediaLoadRequest()
|
|
625
|
+
isLoadingOnCast = false
|
|
626
|
+
isLoadedOnCast = false
|
|
627
|
+
if !pendingCastCommands.isEmpty {
|
|
628
|
+
let didApplyPlaybackCommand = applyPendingCastCommandsToLocalPlayer()
|
|
629
|
+
if localWasPlaying && !didApplyPlaybackCommand {
|
|
630
|
+
player?.play()
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
if localWasPlaying {
|
|
635
|
+
player?.play()
|
|
636
|
+
} else if resumeLocalIfNeeded {
|
|
637
|
+
player?.pause()
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
func shouldHandleRemoteMediaStatus(_ mediaStatus: GCKMediaStatus) -> Bool {
|
|
642
|
+
if isLoadedOnCast || isLoadingOnCast {
|
|
643
|
+
return true
|
|
644
|
+
}
|
|
645
|
+
guard isCurrentVideo(mediaStatus.mediaInformation) else {
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
isLoadedOnCast = true
|
|
649
|
+
return true
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
func handleRemoteMediaStatus(_ mediaStatus: GCKMediaStatus?) {
|
|
653
|
+
guard !isDetached,
|
|
654
|
+
let mediaStatus = mediaStatus,
|
|
655
|
+
shouldHandleRemoteMediaStatus(mediaStatus) else {
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
switch mediaStatus.playerState {
|
|
660
|
+
case .playing, .buffering:
|
|
661
|
+
didNotifyRemoteEnd = false
|
|
662
|
+
if lastRemoteIsPlaying != true {
|
|
663
|
+
DispatchQueue.main.async { [weak self] in
|
|
664
|
+
self?.onPlay?()
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
lastRemoteIsPlaying = true
|
|
668
|
+
case .paused:
|
|
669
|
+
if lastRemoteIsPlaying == true {
|
|
670
|
+
DispatchQueue.main.async { [weak self] in
|
|
671
|
+
self?.onPause?()
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
lastRemoteIsPlaying = false
|
|
675
|
+
case .idle:
|
|
676
|
+
if mediaStatus.idleReason == .finished,
|
|
677
|
+
!didNotifyRemoteEnd {
|
|
678
|
+
didNotifyRemoteEnd = true
|
|
679
|
+
DispatchQueue.main.async { [weak self] in
|
|
680
|
+
self?.onEnd?()
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
lastRemoteIsPlaying = false
|
|
684
|
+
default:
|
|
685
|
+
break
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
extension VideoPlayerCastController: GCKSessionManagerListener {
|
|
691
|
+
@objc func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKSession) {
|
|
692
|
+
loadMediaIfCastSessionAvailable()
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
@objc func sessionManager(_ sessionManager: GCKSessionManager, didResumeSession session: GCKSession) {
|
|
696
|
+
resumeCastSession(session)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
@objc func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKSession, withError error: Error?) {
|
|
700
|
+
if isLoadedOnCast {
|
|
701
|
+
resumeLocalPlayback(from: session)
|
|
702
|
+
} else if isLoadingOnCast {
|
|
703
|
+
cancelPendingCastHandoff(resumeLocalIfNeeded: true)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
@objc func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKSession, withError error: Error) {
|
|
708
|
+
cancelPendingCastHandoff(resumeLocalIfNeeded: true)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
extension VideoPlayerCastController: GCKRequestDelegate {
|
|
713
|
+
@objc func requestDidComplete(_ request: GCKRequest) {
|
|
714
|
+
completeMediaLoadRequest(request)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
@objc func request(_ request: GCKRequest, didFailWithError error: GCKError) {
|
|
718
|
+
failMediaLoadRequest(request)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
@objc func request(_ request: GCKRequest, didAbortWith abortReason: GCKRequestAbortReason) {
|
|
722
|
+
failMediaLoadRequest(request)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
extension VideoPlayerCastController: GCKRemoteMediaClientListener {
|
|
727
|
+
@objc(remoteMediaClient:didUpdateMediaStatus:)
|
|
728
|
+
func remoteMediaClient(_ client: GCKRemoteMediaClient, didUpdate mediaStatus: GCKMediaStatus?) {
|
|
729
|
+
handleRemoteMediaStatus(mediaStatus)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#else
|
|
734
|
+
|
|
735
|
+
final class VideoPlayerCastController {
|
|
736
|
+
var isCasting: Bool {
|
|
737
|
+
return false
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
init(videoUrl: String, title: String?, smallTitle: String?, artwork: String?) {
|
|
741
|
+
_ = videoUrl
|
|
742
|
+
_ = title
|
|
743
|
+
_ = smallTitle
|
|
744
|
+
_ = artwork
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
func attach(to playerViewController: AVPlayerViewController, player: AVPlayer) {
|
|
748
|
+
_ = playerViewController
|
|
749
|
+
_ = player
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
func detach(stopRemoteMedia: Bool) {
|
|
753
|
+
_ = stopRemoteMedia
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
func play() -> Bool { return false }
|
|
757
|
+
func pause() -> Bool { return false }
|
|
758
|
+
func isPlaying() -> Bool { return false }
|
|
759
|
+
func getDuration() -> Double { return 0 }
|
|
760
|
+
func getCurrentTime() -> Double { return 0 }
|
|
761
|
+
func setCurrentTime(_ time: Double) -> Bool {
|
|
762
|
+
_ = time
|
|
763
|
+
return false
|
|
764
|
+
}
|
|
765
|
+
func getVolume() -> Float { return 0 }
|
|
766
|
+
func setVolume(_ volume: Float) -> Bool {
|
|
767
|
+
_ = volume
|
|
768
|
+
return false
|
|
769
|
+
}
|
|
770
|
+
func getMuted() -> Bool { return false }
|
|
771
|
+
func setMuted(_ muted: Bool) -> Bool {
|
|
772
|
+
_ = muted
|
|
773
|
+
return false
|
|
774
|
+
}
|
|
775
|
+
func getRate() -> Float { return 0 }
|
|
776
|
+
func setRate(_ rate: Float) -> Bool {
|
|
777
|
+
_ = rate
|
|
778
|
+
return false
|
|
779
|
+
}
|
|
780
|
+
func restartPlayback() -> Bool { return false }
|
|
781
|
+
func setOnPlay(_ callback: @escaping () -> Void) {
|
|
782
|
+
_ = callback
|
|
783
|
+
}
|
|
784
|
+
func setOnPause(_ callback: @escaping () -> Void) {
|
|
785
|
+
_ = callback
|
|
786
|
+
}
|
|
787
|
+
func setOnEnd(_ callback: @escaping () -> Void) {
|
|
788
|
+
_ = callback
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
#endif
|