@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.
- package/android/src/main/java/com/capgo/videoplayer/VideoPlayerPlugin.java +1 -1
- package/ios/Sources/VideoPlayerPlugin/AVPlayerControlsVisibilityObserver.swift +227 -0
- package/ios/Sources/VideoPlayerPlugin/DismissObservingPlayerViewController.swift +43 -0
- package/ios/Sources/VideoPlayerPlugin/ExitEmissionGuard.swift +11 -0
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +40 -15
- package/ios/Sources/VideoPlayerPlugin/PlayerDismissExitDetection.swift +16 -0
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +200 -63
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +1 -1
- package/ios/Tests/VideoPlayerPluginTests/PlayerDismissExitDetectionTests.swift +49 -0
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerCastControllerTests.swift +103 -0
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerPluginTests.swift +9 -11
- package/package.json +4 -2
- package/scripts/check-capacitor-plugin-wiring.mjs +254 -0
- package/scripts/generate-ai-changelog.mjs +135 -0
- package/scripts/test-ios.sh +109 -0
|
@@ -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.
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
811
|
-
|
|
812
|
-
|
|
824
|
+
private func configurePresentationDelegate(for playerVC: UIViewController) {
|
|
825
|
+
playerVC.presentationController?.delegate = self
|
|
826
|
+
}
|
|
813
827
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
+
}
|