@capgo/capacitor-video-player 8.2.4 → 8.2.7

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.
@@ -38,9 +38,20 @@ final class VideoPlayerCastController: NSObject {
38
38
  private var onPlay: (() -> Void)?
39
39
  private var onPause: (() -> Void)?
40
40
  private var onEnd: (() -> Void)?
41
- private var controlsHideTimer: Timer?
42
- private weak var tapGestureRecognizer: UITapGestureRecognizer?
43
- private static let overlayAutoHideDuration: TimeInterval = 3.0
41
+ private let controlsVisibilityObserver = AVPlayerControlsVisibilityObserver()
42
+ private var localSeekTimeObserver: Any?
43
+ private var timeJumpObserver: NSObjectProtocol?
44
+ private var observedPlayerForSeek: AVPlayer?
45
+ private var overlayInstallRetryWorkItem: DispatchWorkItem?
46
+ private var overlayInstallAttemptsRemaining = 0
47
+ private var lastForwardedSeekTime: Double?
48
+ private var arePlaybackControlsVisible = true
49
+
50
+ deinit {
51
+ overlayInstallRetryWorkItem?.cancel()
52
+ stopObservingLocalSeek()
53
+ controlsVisibilityObserver.stop()
54
+ }
44
55
 
45
56
  var isCasting: Bool {
46
57
  return remoteMediaClient != nil && isLoadedOnCast
@@ -73,9 +84,7 @@ final class VideoPlayerCastController: NSObject {
73
84
  }
74
85
 
75
86
  GCKCastContext.sharedInstance().sessionManager.add(self)
76
- self.addCastButton(to: playerViewController)
77
- self.addCastIndicator(to: playerViewController)
78
- self.beginObservingPlayerTaps(playerViewController)
87
+ self.installOverlayIfNeeded()
79
88
  self.loadMediaIfCastSessionAvailable()
80
89
  }
81
90
 
@@ -86,6 +95,20 @@ final class VideoPlayerCastController: NSObject {
86
95
  }
87
96
  }
88
97
 
98
+ func installOverlayIfNeeded() {
99
+ overlayInstallAttemptsRemaining = 30
100
+ let installOnMain = { [weak self] in
101
+ guard let self else { return }
102
+ self.installOverlayIfNeededOnMain()
103
+ }
104
+
105
+ if Thread.isMainThread {
106
+ installOnMain()
107
+ } else {
108
+ DispatchQueue.main.async(execute: installOnMain)
109
+ }
110
+ }
111
+
89
112
  func detach(stopRemoteMedia: Bool) {
90
113
  guard !isDetached else {
91
114
  return
@@ -95,24 +118,21 @@ final class VideoPlayerCastController: NSObject {
95
118
  pendingCastCommands.removeAll()
96
119
 
97
120
  DispatchQueue.main.async { [weak self] in
98
- guard let self = self,
99
- GCKCastContext.isSharedInstanceInitialized() else {
100
- return
101
- }
121
+ guard let self else { return }
102
122
 
103
- if stopRemoteMedia {
104
- self.remoteMediaClient?.stop()
123
+ if GCKCastContext.isSharedInstanceInitialized() {
124
+ if stopRemoteMedia {
125
+ self.remoteMediaClient?.stop()
126
+ }
127
+ self.stopRemoteMediaObservation()
128
+ GCKCastContext.sharedInstance().sessionManager.remove(self)
129
+ } else {
130
+ self.stopRemoteMediaObservation()
105
131
  }
106
132
 
107
- self.stopRemoteMediaObservation()
108
- GCKCastContext.sharedInstance().sessionManager.remove(self)
109
- self.controlsHideTimer?.invalidate()
110
- self.controlsHideTimer = nil
111
- if let tapRecognizer = self.tapGestureRecognizer,
112
- let view = self.playerViewController?.view {
113
- view.removeGestureRecognizer(tapRecognizer)
114
- }
115
- self.tapGestureRecognizer = nil
133
+ self.cancelOverlayInstallRetry()
134
+ self.stopObservingLocalSeek()
135
+ self.controlsVisibilityObserver.stop()
116
136
  self.castButton?.removeFromSuperview()
117
137
  self.castButton = nil
118
138
  self.castIndicatorLabel?.removeFromSuperview()
@@ -151,6 +171,7 @@ final class VideoPlayerCastController: NSObject {
151
171
  return false
152
172
  }
153
173
  guard isLoadedOnCast else {
174
+ syncLocalPlayhead(to: time)
154
175
  return enqueuePendingCastCommand(.seek(time))
155
176
  }
156
177
  let options = GCKMediaSeekOptions()
@@ -158,6 +179,7 @@ final class VideoPlayerCastController: NSObject {
158
179
  options.relative = false
159
180
  options.resumeState = .unchanged
160
181
  remoteMediaClient.seek(with: options)
182
+ syncLocalPlayhead(to: time)
161
183
  return true
162
184
  }
163
185
 
@@ -272,12 +294,63 @@ private extension VideoPlayerCastController {
272
294
  return GCKCastContext.setSharedInstanceWith(options, error: &error)
273
295
  }
274
296
 
275
- func addCastButton(to playerViewController: AVPlayerViewController) {
276
- guard castButton == nil else {
297
+ func installOverlayIfNeededOnMain() {
298
+ guard !isDetached,
299
+ castButton == nil,
300
+ let playerViewController = playerViewController else {
301
+ return
302
+ }
303
+
304
+ guard let overlayView = VideoPlayerCastOverlayLayout.overlayView(for: playerViewController) else {
305
+ scheduleOverlayInstallRetry()
277
306
  return
278
307
  }
279
308
 
280
- guard let overlayView = playerViewController.view else {
309
+ cancelOverlayInstallRetry()
310
+ addCastButton(to: overlayView)
311
+ addCastIndicator(to: overlayView)
312
+ beginObservingControlsVisibility(playerViewController)
313
+ updateCastOverlayVisibility(visible: arePlaybackControlsVisible)
314
+ }
315
+
316
+ func scheduleOverlayInstallRetry() {
317
+ guard !isDetached, castButton == nil, overlayInstallAttemptsRemaining > 0 else {
318
+ return
319
+ }
320
+ overlayInstallAttemptsRemaining -= 1
321
+ overlayInstallRetryWorkItem?.cancel()
322
+ let workItem = DispatchWorkItem { [weak self] in
323
+ guard let self, !self.isDetached else { return }
324
+ self.installOverlayIfNeededOnMain()
325
+ }
326
+ overlayInstallRetryWorkItem = workItem
327
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
328
+ }
329
+
330
+ func cancelOverlayInstallRetry() {
331
+ overlayInstallRetryWorkItem?.cancel()
332
+ overlayInstallRetryWorkItem = nil
333
+ overlayInstallAttemptsRemaining = 0
334
+ }
335
+
336
+ func syncLocalPlayhead(to time: Double) {
337
+ let apply = { [weak self] in
338
+ self?.player?.seek(
339
+ to: CMTime(seconds: time, preferredTimescale: 600),
340
+ toleranceBefore: .zero,
341
+ toleranceAfter: .zero
342
+ )
343
+ self?.lastForwardedSeekTime = time
344
+ }
345
+ if Thread.isMainThread {
346
+ apply()
347
+ } else {
348
+ DispatchQueue.main.async(execute: apply)
349
+ }
350
+ }
351
+
352
+ func addCastButton(to overlayView: UIView) {
353
+ guard castButton == nil else {
281
354
  return
282
355
  }
283
356
 
@@ -303,15 +376,11 @@ private extension VideoPlayerCastController {
303
376
  castButton = button
304
377
  }
305
378
 
306
- func addCastIndicator(to playerViewController: AVPlayerViewController) {
379
+ func addCastIndicator(to overlayView: UIView) {
307
380
  guard castIndicatorLabel == nil else {
308
381
  return
309
382
  }
310
383
 
311
- guard let overlayView = playerViewController.view else {
312
- return
313
- }
314
-
315
384
  let label = UILabel(frame: .zero)
316
385
  label.translatesAutoresizingMaskIntoConstraints = false
317
386
  label.text = "Casting"
@@ -339,46 +408,90 @@ private extension VideoPlayerCastController {
339
408
  castIndicatorLabel = label
340
409
  }
341
410
 
342
- func beginObservingPlayerTaps(_ playerViewController: AVPlayerViewController) {
343
- guard let overlayView = playerViewController.view else { return }
344
- // Remove any existing recognizer before adding a new one to prevent duplicates.
345
- if let existing = tapGestureRecognizer {
346
- overlayView.removeGestureRecognizer(existing)
411
+ func beginObservingControlsVisibility(_ playerViewController: AVPlayerViewController) {
412
+ controlsVisibilityObserver.start(observing: playerViewController) { [weak self] visible in
413
+ self?.arePlaybackControlsVisible = visible
414
+ self?.updateCastOverlayVisibility(visible: visible)
347
415
  }
348
- let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handlePlayerTap))
349
- tapRecognizer.cancelsTouchesInView = false
350
- overlayView.addGestureRecognizer(tapRecognizer)
351
- self.tapGestureRecognizer = tapRecognizer
352
- // Show the overlay immediately; it will auto-hide after the standard controls delay.
353
- showOverlayControls()
354
416
  }
355
417
 
356
- @objc func handlePlayerTap() {
357
- showOverlayControls()
418
+ func updateCastOverlayVisibility(visible: Bool) {
419
+ castButton?.isHidden = !visible
420
+ castButton?.alpha = visible ? 1 : 0
421
+ castIndicatorLabel?.isHidden = !visible || !isCasting
422
+ castIndicatorLabel?.alpha = visible ? 1 : 0
358
423
  }
359
424
 
360
- func showOverlayControls() {
361
- castButton?.isHidden = false
362
- castIndicatorLabel?.isHidden = !isCasting
363
- controlsHideTimer?.invalidate()
364
- controlsHideTimer = Timer.scheduledTimer(withTimeInterval: Self.overlayAutoHideDuration, repeats: false) { [weak self] _ in
365
- self?.hideOverlayControls()
425
+ func beginObservingLocalSeekWhileCasting() {
426
+ stopObservingLocalSeek()
427
+ guard let player = player else {
428
+ return
429
+ }
430
+
431
+ observedPlayerForSeek = player
432
+ timeJumpObserver = NotificationCenter.default.addObserver(
433
+ forName: .AVPlayerItemTimeJumped,
434
+ object: nil,
435
+ queue: .main
436
+ ) { [weak self] notification in
437
+ guard let self,
438
+ let item = notification.object as? AVPlayerItem,
439
+ item === self.player?.currentItem else {
440
+ return
441
+ }
442
+ self.forwardLocalSeekToRemoteIfNeeded()
443
+ }
444
+
445
+ let interval = CMTime(seconds: 0.25, preferredTimescale: 600)
446
+ localSeekTimeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
447
+ self?.forwardLocalSeekToRemoteIfNeeded()
366
448
  }
367
449
  }
368
450
 
369
- func hideOverlayControls() {
370
- controlsHideTimer = nil
371
- let button = castButton
372
- let label = castIndicatorLabel
373
- UIView.animate(withDuration: 0.3, animations: {
374
- button?.alpha = 0
375
- label?.alpha = 0
376
- }, completion: { _ in
377
- button?.isHidden = true
378
- label?.isHidden = true
379
- button?.alpha = 1
380
- label?.alpha = 1
381
- })
451
+ func stopObservingLocalSeek() {
452
+ if let timeJumpObserver {
453
+ NotificationCenter.default.removeObserver(timeJumpObserver)
454
+ self.timeJumpObserver = nil
455
+ }
456
+ if let localSeekTimeObserver {
457
+ observedPlayerForSeek?.removeTimeObserver(localSeekTimeObserver)
458
+ self.localSeekTimeObserver = nil
459
+ }
460
+ observedPlayerForSeek = nil
461
+ }
462
+
463
+ func syncCastingObserversIfNeeded() {
464
+ guard isCasting else {
465
+ stopObservingLocalSeek()
466
+ return
467
+ }
468
+ if localSeekTimeObserver == nil {
469
+ beginObservingLocalSeekWhileCasting()
470
+ }
471
+ }
472
+
473
+ func forwardLocalSeekToRemoteIfNeeded() {
474
+ guard !isDetached,
475
+ isCasting,
476
+ let remoteMediaClient = remoteMediaClient,
477
+ let player = player else {
478
+ return
479
+ }
480
+
481
+ let localTime = player.currentTime().seconds
482
+ guard VideoPlayerCastSeekSync.shouldForwardLocalSeek(
483
+ localTime: localTime,
484
+ lastForwardedTime: lastForwardedSeekTime
485
+ ) else {
486
+ return
487
+ }
488
+
489
+ lastForwardedSeekTime = localTime
490
+ let options = GCKMediaSeekOptions()
491
+ options.interval = localTime
492
+ options.relative = false
493
+ options.resumeState = .unchanged
494
+ remoteMediaClient.seek(with: options)
382
495
  }
383
496
 
384
497
  func loadMediaIfCastSessionAvailable() {
@@ -401,6 +514,9 @@ private extension VideoPlayerCastController {
401
514
  mediaLoadRequestDataBuilder.mediaInformation = mediaInfo
402
515
  mediaLoadRequestDataBuilder.autoplay = NSNumber(value: localWasPlaying)
403
516
  mediaLoadRequestDataBuilder.startTime = playPosition.isFinite ? playPosition : 0
517
+ if playPosition.isFinite {
518
+ lastForwardedSeekTime = playPosition
519
+ }
404
520
  let request = remoteMediaClient.loadMedia(with: mediaLoadRequestDataBuilder.build())
405
521
  request.delegate = self
406
522
  mediaLoadRequest = request
@@ -419,6 +535,14 @@ private extension VideoPlayerCastController {
419
535
 
420
536
  observeRemoteMediaClient(remoteMediaClient)
421
537
  isLoadedOnCast = isCurrentVideo(remoteMediaClient.mediaStatus?.mediaInformation)
538
+ if isLoadedOnCast {
539
+ player?.pause()
540
+ let position = remoteMediaClient.approximateStreamPosition()
541
+ if position.isFinite {
542
+ syncLocalPlayhead(to: position)
543
+ }
544
+ syncCastingObserversIfNeeded()
545
+ }
422
546
  handleRemoteMediaStatus(remoteMediaClient.mediaStatus)
423
547
  }
424
548
 
@@ -477,6 +601,7 @@ private extension VideoPlayerCastController {
477
601
  options.relative = false
478
602
  options.resumeState = .unchanged
479
603
  remoteMediaClient.seek(with: options)
604
+ syncLocalPlayhead(to: time)
480
605
  case .volume(let volume):
481
606
  remoteMediaClient.setStreamVolume(volume)
482
607
  case .muted(let muted):
@@ -591,6 +716,9 @@ private extension VideoPlayerCastController {
591
716
  stopRemoteMediaObservation()
592
717
  clearMediaLoadRequest()
593
718
  pendingCastCommands.removeAll()
719
+ DispatchQueue.main.async { [weak self] in
720
+ self?.syncCastingObserversIfNeeded()
721
+ }
594
722
  let seekTime = CMTime(seconds: position, preferredTimescale: 600)
595
723
  player?.seek(to: seekTime) { [weak self] _ in
596
724
  if shouldResume {
@@ -613,8 +741,12 @@ private extension VideoPlayerCastController {
613
741
  isLoadedOnCast = true
614
742
  didNotifyRemoteEnd = false
615
743
  DispatchQueue.main.async { [weak self] in
616
- // Reveal the cast indicator and extend the controls visibility window.
617
- self?.showOverlayControls()
744
+ guard let self else { return }
745
+ if let position = self.player?.currentTime().seconds, position.isFinite {
746
+ self.lastForwardedSeekTime = position
747
+ }
748
+ self.syncCastingObserversIfNeeded()
749
+ self.updateCastOverlayVisibility(visible: self.arePlaybackControlsVisible)
618
750
  }
619
751
  flushPendingCastCommands()
620
752
  }
@@ -630,6 +762,9 @@ private extension VideoPlayerCastController {
630
762
  clearMediaLoadRequest()
631
763
  isLoadingOnCast = false
632
764
  isLoadedOnCast = false
765
+ DispatchQueue.main.async { [weak self] in
766
+ self?.syncCastingObserversIfNeeded()
767
+ }
633
768
  if !pendingCastCommands.isEmpty {
634
769
  let didApplyPlaybackCommand = applyPendingCastCommandsToLocalPlayer()
635
770
  if localWasPlaying && !didApplyPlaybackCommand {
@@ -755,6 +890,8 @@ final class VideoPlayerCastController {
755
890
  _ = player
756
891
  }
757
892
 
893
+ func installOverlayIfNeeded() {}
894
+
758
895
  func detach(stopRemoteMedia: Bool) {
759
896
  _ = stopRemoteMedia
760
897
  }
@@ -8,7 +8,7 @@ import AVKit
8
8
  */
9
9
  @objc(VideoPlayerPlugin)
10
10
  public class VideoPlayerPlugin: CAPPlugin, CAPBridgedPlugin {
11
- private let pluginVersion: String = "8.2.4"
11
+ private let pluginVersion: String = "8.2.7"
12
12
  public let identifier = "VideoPlayerPlugin"
13
13
  public let jsName = "VideoPlayer"
14
14
  public let pluginMethods: [CAPPluginMethod] = [
@@ -77,6 +77,16 @@ final class HLSSubtitlePlaylistTests: XCTestCase {
77
77
  XCTAssertEqual(result.asset.url.scheme, "file")
78
78
  }
79
79
 
80
+ func testIsHLSStreamDetectsMpegurlQuery() {
81
+ let url = URL(string: "https://cdn.example.com/video?format=mpegurl")!
82
+ XCTAssertTrue(HLSVideoAssetFactory.isHLSStream(url))
83
+ }
84
+
85
+ func testIsHLSStreamRejectsProgressiveMp4() {
86
+ let url = URL(string: "https://cdn.example.com/video.mp4")!
87
+ XCTAssertFalse(HLSVideoAssetFactory.isHLSStream(url))
88
+ }
89
+
80
90
  func testMakeAssetUsesResourceLoaderForLocalHLSSubtitles() {
81
91
  let videoURL = URL(fileURLWithPath: "/var/mobile/Containers/Data/Application/stream.m3u8")
82
92
  let tracks = [
@@ -0,0 +1,103 @@
1
+ import AVKit
2
+ import XCTest
3
+ @testable import VideoPlayerPlugin
4
+
5
+ final class VideoPlayerCastControllerTests: XCTestCase {
6
+ func testCastOverlayTargetsContentOverlayView() {
7
+ let playerViewController = AVPlayerViewController()
8
+ playerViewController.loadViewIfNeeded()
9
+
10
+ let overlayView = VideoPlayerCastOverlayLayout.overlayView(for: playerViewController)
11
+
12
+ XCTAssertNotNil(overlayView)
13
+ XCTAssertIdentical(overlayView, playerViewController.contentOverlayView)
14
+ }
15
+
16
+ func testSeekSyncRequiresBaselineAndMeaningfulDelta() {
17
+ XCTAssertFalse(
18
+ VideoPlayerCastSeekSync.shouldForwardLocalSeek(localTime: 42, lastForwardedTime: nil)
19
+ )
20
+ XCTAssertFalse(
21
+ VideoPlayerCastSeekSync.shouldForwardLocalSeek(localTime: 10.1, lastForwardedTime: 10)
22
+ )
23
+ XCTAssertTrue(
24
+ VideoPlayerCastSeekSync.shouldForwardLocalSeek(localTime: 10.5, lastForwardedTime: 10)
25
+ )
26
+ XCTAssertFalse(
27
+ VideoPlayerCastSeekSync.shouldForwardLocalSeek(localTime: .nan, lastForwardedTime: 10)
28
+ )
29
+ XCTAssertFalse(
30
+ VideoPlayerCastSeekSync.shouldForwardLocalSeek(localTime: .infinity, lastForwardedTime: 10)
31
+ )
32
+ XCTAssertTrue(
33
+ VideoPlayerCastSeekSync.shouldForwardLocalSeek(localTime: 10.25, lastForwardedTime: 10)
34
+ )
35
+ }
36
+
37
+ func testControlsVisibilityReflectsHiddenAndAlpha() {
38
+ let controlsView = UIView()
39
+ controlsView.isHidden = false
40
+ controlsView.alpha = 1
41
+
42
+ XCTAssertTrue(AVPlayerControlsVisibilityObserver.areControlsVisible(in: controlsView))
43
+
44
+ controlsView.isHidden = true
45
+ XCTAssertFalse(AVPlayerControlsVisibilityObserver.areControlsVisible(in: controlsView))
46
+
47
+ controlsView.isHidden = false
48
+ controlsView.alpha = 0
49
+ XCTAssertFalse(AVPlayerControlsVisibilityObserver.areControlsVisible(in: controlsView))
50
+ }
51
+
52
+ func testControlsContainerLocatorFindsLastSubviewInFirstContainer() {
53
+ let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 240))
54
+ let container = UIView(frame: rootView.bounds)
55
+ let contentView = UIView(frame: rootView.bounds)
56
+ let controlsView = UIView(frame: CGRect(x: 0, y: 180, width: 320, height: 60))
57
+
58
+ rootView.addSubview(container)
59
+ container.addSubview(contentView)
60
+ container.addSubview(controlsView)
61
+
62
+ XCTAssertIdentical(
63
+ AVPlayerControlsVisibilityObserver.controlsContainer(in: rootView),
64
+ controlsView
65
+ )
66
+ }
67
+
68
+ func testShouldReanchorWhenControlsViewChanges() {
69
+ let oldControls = UIView()
70
+ let newControls = UIView()
71
+
72
+ XCTAssertTrue(
73
+ AVPlayerControlsVisibilityObserver.shouldReanchor(
74
+ monitoredView: oldControls,
75
+ currentControlsView: newControls
76
+ )
77
+ )
78
+ }
79
+
80
+ func testShouldReanchorWhenMonitoredViewIsDetached() {
81
+ let detachedControls = UIView()
82
+
83
+ XCTAssertTrue(
84
+ AVPlayerControlsVisibilityObserver.shouldReanchor(
85
+ monitoredView: detachedControls,
86
+ currentControlsView: detachedControls
87
+ )
88
+ )
89
+ }
90
+
91
+ func testShouldNotReanchorWhenMonitoringCurrentControls() {
92
+ let container = UIView()
93
+ let controls = UIView()
94
+ container.addSubview(controls)
95
+
96
+ XCTAssertFalse(
97
+ AVPlayerControlsVisibilityObserver.shouldReanchor(
98
+ monitoredView: controls,
99
+ currentControlsView: controls
100
+ )
101
+ )
102
+ }
103
+ }
@@ -25,9 +25,14 @@ class VideoPlayerTests: XCTestCase {
25
25
  XCTAssertEqual(tracks[1].language, "fr")
26
26
  }
27
27
 
28
- func testSubtitleTrackResolvesBareFilePath() {
29
- let track = VideoSubtitleTrack(url: "/var/mobile/Containers/Data/Application/subtitles-en.vtt", language: "en")
30
- XCTAssertEqual(track.resolvedURL?.isFileURL, true)
31
- XCTAssertEqual(track.resolvedURL?.path, "/var/mobile/Containers/Data/Application/subtitles-en.vtt")
28
+ func testExitEmissionGuardEmitsOnce() {
29
+ var guardState = ExitEmissionGuard()
30
+ var emitCount = 0
31
+
32
+ guardState.emitIfNeeded { emitCount += 1 }
33
+ guardState.emitIfNeeded { emitCount += 1 }
34
+
35
+ XCTAssertEqual(emitCount, 1)
36
+ XCTAssertTrue(guardState.didEmit)
32
37
  }
33
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-video-player",
3
- "version": "8.2.4",
3
+ "version": "8.2.7",
4
4
  "description": "Capacitor plugin to play video in native player",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",