@capgo/capacitor-video-player 8.2.5 → 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.
@@ -46,7 +46,7 @@ import org.json.JSONObject;
46
46
  )
47
47
  public class VideoPlayerPlugin extends Plugin {
48
48
 
49
- private final String pluginVersion = "8.2.5";
49
+ private final String pluginVersion = "8.2.7";
50
50
 
51
51
  // Permission alias constants
52
52
  private static final String PERMISSION_DENIED_ERROR = "Unable to access media videos, user denied permission request";
@@ -0,0 +1,227 @@
1
+ import AVKit
2
+ import UIKit
3
+
4
+ /// Mirrors AVKit playback-control visibility for custom overlay views.
5
+ ///
6
+ /// AVPlayerViewController does not expose control visibility publicly. This observer
7
+ /// locates the controls container using only the public view hierarchy and watches
8
+ /// `hidden` / `alpha` so overlay controls can follow native chrome.
9
+ final class AVPlayerControlsVisibilityObserver: NSObject {
10
+ private weak var playerViewController: AVPlayerViewController?
11
+ private weak var monitoredView: UIView?
12
+ private var onVisibilityChange: ((Bool) -> Void)?
13
+ private var retryWorkItem: DispatchWorkItem?
14
+ private var reanchorWorkItem: DispatchWorkItem?
15
+ private var hiddenObservation: NSKeyValueObservation?
16
+ private var alphaObservation: NSKeyValueObservation?
17
+ private var lastReportedVisible: Bool?
18
+ private var remainingInstallAttempts = 30
19
+ private var isActive = false
20
+
21
+ private static let reanchorInterval: TimeInterval = 0.25
22
+
23
+ deinit {
24
+ stop()
25
+ }
26
+
27
+ func start(
28
+ observing playerViewController: AVPlayerViewController,
29
+ onChange: @escaping (Bool) -> Void
30
+ ) {
31
+ stop()
32
+ isActive = true
33
+ self.playerViewController = playerViewController
34
+ self.onVisibilityChange = onChange
35
+ remainingInstallAttempts = 30
36
+ installObserverIfPossible()
37
+ scheduleReanchorCheck()
38
+ }
39
+
40
+ func stop() {
41
+ isActive = false
42
+ retryWorkItem?.cancel()
43
+ retryWorkItem = nil
44
+ reanchorWorkItem?.cancel()
45
+ reanchorWorkItem = nil
46
+ removeMonitoredViewObserver()
47
+ playerViewController = nil
48
+ onVisibilityChange = nil
49
+ lastReportedVisible = nil
50
+ }
51
+
52
+ static func controlsContainer(in playerView: UIView) -> UIView? {
53
+ guard let container = playerView.subviews.first else {
54
+ return nil
55
+ }
56
+ return container.subviews.last
57
+ }
58
+
59
+ static func areControlsVisible(in controlsView: UIView?) -> Bool {
60
+ guard let controlsView else {
61
+ return true
62
+ }
63
+ return !controlsView.isHidden && controlsView.alpha > 0.05
64
+ }
65
+
66
+ static func shouldReanchor(
67
+ monitoredView: UIView?,
68
+ currentControlsView: UIView?
69
+ ) -> Bool {
70
+ guard let monitoredView else {
71
+ return currentControlsView != nil
72
+ }
73
+ guard let currentControlsView else {
74
+ return true
75
+ }
76
+ if monitoredView !== currentControlsView {
77
+ return true
78
+ }
79
+ return monitoredView.superview == nil && monitoredView.window == nil
80
+ }
81
+
82
+ private func installObserverIfPossible() {
83
+ guard isActive else {
84
+ return
85
+ }
86
+
87
+ guard let playerView = playerViewController?.view else {
88
+ scheduleRetry()
89
+ return
90
+ }
91
+
92
+ guard let controlsView = Self.controlsContainer(in: playerView) else {
93
+ scheduleRetry()
94
+ return
95
+ }
96
+
97
+ if monitoredView === controlsView {
98
+ reportVisibilityIfNeeded(for: controlsView)
99
+ return
100
+ }
101
+
102
+ removeMonitoredViewObserver()
103
+ attachObserver(to: controlsView)
104
+ }
105
+
106
+ private func attachObserver(to controlsView: UIView) {
107
+ monitoredView = controlsView
108
+ hiddenObservation = controlsView.observe(\.isHidden, options: [.new]) { [weak self] view, _ in
109
+ self?.reportVisibilityIfNeeded(for: view)
110
+ }
111
+ alphaObservation = controlsView.observe(\.alpha, options: [.new]) { [weak self] view, _ in
112
+ self?.reportVisibilityIfNeeded(for: view)
113
+ }
114
+ reportVisibilityIfNeeded(for: controlsView)
115
+ }
116
+
117
+ private func reanchorIfNeeded() {
118
+ guard isActive else {
119
+ return
120
+ }
121
+
122
+ guard let playerView = playerViewController?.view else {
123
+ if monitoredView != nil {
124
+ removeMonitoredViewObserver()
125
+ }
126
+ scheduleRetry()
127
+ return
128
+ }
129
+
130
+ let currentControlsView = Self.controlsContainer(in: playerView)
131
+ guard Self.shouldReanchor(
132
+ monitoredView: monitoredView,
133
+ currentControlsView: currentControlsView
134
+ ) else {
135
+ if let monitoredView {
136
+ reportVisibilityIfNeeded(for: monitoredView)
137
+ }
138
+ return
139
+ }
140
+
141
+ removeMonitoredViewObserver()
142
+ if let currentControlsView {
143
+ remainingInstallAttempts = 30
144
+ attachObserver(to: currentControlsView)
145
+ } else {
146
+ scheduleRetry()
147
+ }
148
+ }
149
+
150
+ private func scheduleReanchorCheck() {
151
+ reanchorWorkItem?.cancel()
152
+ guard isActive else {
153
+ return
154
+ }
155
+
156
+ let workItem = DispatchWorkItem { [weak self] in
157
+ guard let self, self.isActive else {
158
+ return
159
+ }
160
+ self.reanchorIfNeeded()
161
+ self.scheduleReanchorCheck()
162
+ }
163
+ reanchorWorkItem = workItem
164
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.reanchorInterval, execute: workItem)
165
+ }
166
+
167
+ private func scheduleRetry() {
168
+ guard isActive else {
169
+ return
170
+ }
171
+
172
+ retryWorkItem?.cancel()
173
+ guard remainingInstallAttempts > 0 else {
174
+ lastReportedVisible = true
175
+ onVisibilityChange?(true)
176
+ return
177
+ }
178
+ remainingInstallAttempts -= 1
179
+
180
+ let workItem = DispatchWorkItem { [weak self] in
181
+ guard let self, self.isActive else {
182
+ return
183
+ }
184
+ self.installObserverIfPossible()
185
+ }
186
+ retryWorkItem = workItem
187
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
188
+ }
189
+
190
+ private func removeMonitoredViewObserver() {
191
+ hiddenObservation = nil
192
+ alphaObservation = nil
193
+ monitoredView = nil
194
+ }
195
+
196
+ private func reportVisibilityIfNeeded(for controlsView: UIView) {
197
+ let visible = Self.areControlsVisible(in: controlsView)
198
+ guard lastReportedVisible != visible else {
199
+ return
200
+ }
201
+ lastReportedVisible = visible
202
+ onVisibilityChange?(visible)
203
+ }
204
+ }
205
+
206
+ enum VideoPlayerCastSeekSync {
207
+ static let minimumForwardDelta: Double = 0.25
208
+
209
+ static func shouldForwardLocalSeek(
210
+ localTime: Double,
211
+ lastForwardedTime: Double?
212
+ ) -> Bool {
213
+ guard localTime.isFinite else {
214
+ return false
215
+ }
216
+ guard let lastForwardedTime else {
217
+ return false
218
+ }
219
+ return abs(localTime - lastForwardedTime) >= minimumForwardDelta
220
+ }
221
+ }
222
+
223
+ enum VideoPlayerCastOverlayLayout {
224
+ static func overlayView(for playerViewController: AVPlayerViewController) -> UIView? {
225
+ playerViewController.contentOverlayView
226
+ }
227
+ }
@@ -0,0 +1,20 @@
1
+ import AVKit
2
+ import UIKit
3
+
4
+ final class DismissObservingPlayerViewController: AVPlayerViewController {
5
+ var onDismiss: (() -> Void)?
6
+ var shouldReportDismiss: (() -> Bool)?
7
+ weak var adaptivePresentationDelegate: UIAdaptivePresentationControllerDelegate?
8
+
9
+ override func viewWillAppear(_ animated: Bool) {
10
+ super.viewWillAppear(animated)
11
+ presentationController?.delegate = adaptivePresentationDelegate
12
+ }
13
+
14
+ override func viewDidDisappear(_ animated: Bool) {
15
+ super.viewDidDisappear(animated)
16
+ guard isBeingDismissed else { return }
17
+ guard shouldReportDismiss?() ?? true else { return }
18
+ onDismiss?()
19
+ }
20
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ struct ExitEmissionGuard {
4
+ private(set) var didEmit = false
5
+
6
+ mutating func emitIfNeeded(_ action: () -> Void) {
7
+ guard !didEmit else { return }
8
+ didEmit = true
9
+ action()
10
+ }
11
+ }
@@ -23,7 +23,9 @@ class FullscreenVideoPlayer: NSObject {
23
23
  private var audioCategory: String?
24
24
  private var didActivateAudioSession: Bool = false
25
25
  private var didEmitReady: Bool = false
26
- private var didEmitExit: Bool = false
26
+ private var exitEmission = ExitEmissionGuard()
27
+ private var isTransitioningToPictureInPicture = false
28
+ private var suppressDismissExit = false
27
29
  private var timeObserver: Any?
28
30
  private var onPlay: (() -> Void)?
29
31
  private var onPause: (() -> Void)?
@@ -215,11 +217,20 @@ class FullscreenVideoPlayer: NSObject {
215
217
  player = AVPlayer(playerItem: playerItem)
216
218
  player?.rate = rate
217
219
 
218
- playerViewController = AVPlayerViewController()
219
- playerViewController?.player = player
220
- playerViewController?.showsPlaybackControls = showControls
221
- playerViewController?.allowsPictureInPicturePlayback = pipEnabled
222
- playerViewController?.delegate = self
220
+ let playerVC = DismissObservingPlayerViewController()
221
+ playerVC.player = player
222
+ playerVC.showsPlaybackControls = showControls
223
+ playerVC.allowsPictureInPicturePlayback = pipEnabled
224
+ playerVC.delegate = self
225
+ playerVC.shouldReportDismiss = { [weak self] in
226
+ guard let self else { return true }
227
+ return !self.isTransitioningToPictureInPicture && !self.suppressDismissExit
228
+ }
229
+ playerVC.onDismiss = { [weak self] in
230
+ self?.emitExitIfNeeded()
231
+ }
232
+ playerVC.adaptivePresentationDelegate = self
233
+ playerViewController = playerVC
223
234
 
224
235
  setupChromecast()
225
236
  setupObservers()
@@ -694,11 +705,12 @@ class FullscreenVideoPlayer: NSObject {
694
705
 
695
706
  presentingViewController = viewController
696
707
  viewController.present(playerVC, animated: true) {
697
- playerVC.presentationController?.delegate = self
698
708
  self.refreshSubtitleButton()
709
+ self.castController?.installOverlayIfNeeded()
699
710
  self.play()
700
711
  completion()
701
712
  }
713
+ configurePresentationDelegate(for: playerVC)
702
714
  }
703
715
 
704
716
  func show(on viewController: UIViewController, completion: @escaping () -> Void) {
@@ -712,9 +724,9 @@ class FullscreenVideoPlayer: NSObject {
712
724
  }
713
725
  presentingViewController = viewController
714
726
  viewController.present(playerVC, animated: true) {
715
- playerVC.presentationController?.delegate = self
716
727
  completion()
717
728
  }
729
+ configurePresentationDelegate(for: playerVC)
718
730
  }
719
731
 
720
732
  func hide(completion: @escaping () -> Void) {
@@ -726,7 +738,9 @@ class FullscreenVideoPlayer: NSObject {
726
738
  completion()
727
739
  return
728
740
  }
729
- playerVC.dismiss(animated: true) {
741
+ suppressDismissExit = true
742
+ playerVC.dismiss(animated: true) { [weak self] in
743
+ self?.suppressDismissExit = false
730
744
  completion()
731
745
  }
732
746
  }
@@ -807,13 +821,16 @@ class FullscreenVideoPlayer: NSObject {
807
821
  }
808
822
  }
809
823
 
810
- private func emitExitIfNeeded(currentTime: Double? = nil) {
811
- guard !didEmitExit else { return }
812
- didEmitExit = true
824
+ private func configurePresentationDelegate(for playerVC: UIViewController) {
825
+ playerVC.presentationController?.delegate = self
826
+ }
813
827
 
814
- let time = currentTime ?? getCurrentTime()
815
- cleanup(stopRemoteMedia: true)
816
- onExit?(time)
828
+ private func emitExitIfNeeded(currentTime: Double? = nil) {
829
+ exitEmission.emitIfNeeded {
830
+ let time = currentTime ?? getCurrentTime()
831
+ cleanup(stopRemoteMedia: true)
832
+ onExit?(time)
833
+ }
817
834
  }
818
835
 
819
836
  private func emitReadyIfNeeded() {
@@ -968,6 +985,14 @@ extension FullscreenVideoPlayer: AVPlayerViewControllerDelegate {
968
985
  emitExitIfNeeded()
969
986
  }
970
987
 
988
+ func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
989
+ isTransitioningToPictureInPicture = true
990
+ }
991
+
992
+ func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
993
+ isTransitioningToPictureInPicture = false
994
+ }
995
+
971
996
  func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(
972
997
  _ playerViewController: AVPlayerViewController
973
998
  ) -> Bool {
@@ -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.5"
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] = [
@@ -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.5",
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",