@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.
- 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 +20 -0
- package/ios/Sources/VideoPlayerPlugin/ExitEmissionGuard.swift +11 -0
- package/ios/Sources/VideoPlayerPlugin/FullscreenVideoPlayer.swift +40 -15
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerCastController.swift +200 -63
- package/ios/Sources/VideoPlayerPlugin/VideoPlayerPlugin.swift +1 -1
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerCastControllerTests.swift +103 -0
- package/ios/Tests/VideoPlayerPluginTests/VideoPlayerPluginTests.swift +9 -4
- package/package.json +1 -1
|
@@ -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.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
|
+
}
|
|
@@ -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 {
|
|
@@ -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
|
|
42
|
-
private
|
|
43
|
-
private
|
|
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.
|
|
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
|
|
99
|
-
GCKCastContext.isSharedInstanceInitialized() else {
|
|
100
|
-
return
|
|
101
|
-
}
|
|
121
|
+
guard let self else { return }
|
|
102
122
|
|
|
103
|
-
if
|
|
104
|
-
|
|
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.
|
|
108
|
-
|
|
109
|
-
self.
|
|
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
|
|
276
|
-
guard
|
|
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
|
-
|
|
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
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
617
|
-
self?.
|
|
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.
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|