@capgo/capacitor-video-player 8.2.5 → 8.2.8

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.8";
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,43 @@
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
+ private var wasVisibleInHierarchy = false
9
+
10
+ override func viewWillAppear(_ animated: Bool) {
11
+ super.viewWillAppear(animated)
12
+ presentationController?.delegate = adaptivePresentationDelegate
13
+ }
14
+
15
+ override func viewDidAppear(_ animated: Bool) {
16
+ super.viewDidAppear(animated)
17
+ wasVisibleInHierarchy = true
18
+ }
19
+
20
+ override func viewDidDisappear(_ animated: Bool) {
21
+ super.viewDidDisappear(animated)
22
+ reportDismissIfNeeded()
23
+ }
24
+
25
+ override func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
26
+ super.dismiss(animated: animated) { [weak self] in
27
+ self?.reportDismissIfNeeded()
28
+ completion?()
29
+ }
30
+ }
31
+
32
+ private func reportDismissIfNeeded() {
33
+ guard wasVisibleInHierarchy else { return }
34
+ guard PlayerDismissExitDetection.shouldReportExit(
35
+ presentingViewController: presentingViewController,
36
+ viewWindow: view.window
37
+ ) else {
38
+ return
39
+ }
40
+ guard shouldReportDismiss?() ?? true else { return }
41
+ onDismiss?()
42
+ }
43
+ }
@@ -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 {
@@ -0,0 +1,16 @@
1
+ import UIKit
2
+
3
+ enum PlayerDismissExitDetection {
4
+ /// Returns true when a player view controller has left the visible hierarchy
5
+ /// because it was dismissed, not merely covered by another view controller.
6
+ static func shouldReportExit(
7
+ presentingViewController: UIViewController?,
8
+ viewWindow: UIWindow?
9
+ ) -> Bool {
10
+ // iOS 18+/26: AVKit Done/X can dismiss a modal AVPlayerViewController without
11
+ // setting isBeingDismissed or calling presentationControllerDidDismiss.
12
+ // Require both presenter and window to be nil so parent dismissals that only
13
+ // transiently set isBeingDismissed/isMovingFromParent do not emit exit.
14
+ presentingViewController == nil && viewWindow == nil
15
+ }
16
+ }