@movementinfra/expo-twostep-video 0.1.8 → 0.1.9
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/build/ExpoTwoStepVideo.types.d.ts +16 -0
- package/build/ExpoTwoStepVideo.types.d.ts.map +1 -1
- package/build/ExpoTwoStepVideo.types.js.map +1 -1
- package/build/TwoStepPlayerControllerView.d.ts +30 -0
- package/build/TwoStepPlayerControllerView.d.ts.map +1 -0
- package/build/TwoStepPlayerControllerView.js +54 -0
- package/build/TwoStepPlayerControllerView.js.map +1 -0
- package/build/VideoScrubber.d.ts +22 -0
- package/build/VideoScrubber.d.ts.map +1 -0
- package/build/VideoScrubber.js +467 -0
- package/build/VideoScrubber.js.map +1 -0
- package/build/VideoScrubber.types.d.ts +78 -0
- package/build/VideoScrubber.types.d.ts.map +1 -0
- package/build/VideoScrubber.types.js +2 -0
- package/build/VideoScrubber.types.js.map +1 -0
- package/build/hooks/useVideoScrubber.d.ts +86 -0
- package/build/hooks/useVideoScrubber.d.ts.map +1 -0
- package/build/hooks/useVideoScrubber.js +114 -0
- package/build/hooks/useVideoScrubber.js.map +1 -0
- package/build/index.d.ts +5 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/ExpoTwoStepVideoModule.swift +72 -8
- package/ios/ExpoTwoStepVideoView.swift +1 -1
- package/ios/ExpoTwostepPlayerControllerView.swift +297 -0
- package/package.json +1 -1
|
@@ -622,12 +622,12 @@ public class ExpoTwoStepVideoModule: Module {
|
|
|
622
622
|
|
|
623
623
|
// MARK: - Video Player View
|
|
624
624
|
|
|
625
|
-
View(
|
|
625
|
+
View(ExpoTwoStepVideoView.self) {
|
|
626
626
|
// Events emitted by the view
|
|
627
627
|
Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError")
|
|
628
628
|
|
|
629
629
|
// Prop to set composition ID - view will load it
|
|
630
|
-
Prop("compositionId") { (view:
|
|
630
|
+
Prop("compositionId") { (view: ExpoTwoStepVideoView, compositionId: String?) in
|
|
631
631
|
guard let compositionId = compositionId else { return }
|
|
632
632
|
guard let composition = self.activeCompositions[compositionId] else {
|
|
633
633
|
view.onError(["error": "Composition not found: \(compositionId)"])
|
|
@@ -638,7 +638,7 @@ public class ExpoTwoStepVideoModule: Module {
|
|
|
638
638
|
}
|
|
639
639
|
|
|
640
640
|
// Prop to set asset ID - view will load it for preview
|
|
641
|
-
Prop("assetId") { (view:
|
|
641
|
+
Prop("assetId") { (view: ExpoTwoStepVideoView, assetId: String?) in
|
|
642
642
|
guard let assetId = assetId else { return }
|
|
643
643
|
guard let asset = self.activeAssets[assetId] else {
|
|
644
644
|
view.onError(["error": "Asset not found: \(assetId)"])
|
|
@@ -648,27 +648,91 @@ public class ExpoTwoStepVideoModule: Module {
|
|
|
648
648
|
}
|
|
649
649
|
|
|
650
650
|
// Prop to enable continuous looping
|
|
651
|
-
Prop("loop") { (view:
|
|
651
|
+
Prop("loop") { (view: ExpoTwoStepVideoView, loop: Bool?) in
|
|
652
652
|
view.shouldLoop = loop ?? false
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
// Playback control functions
|
|
656
|
-
AsyncFunction("play") { (view:
|
|
656
|
+
AsyncFunction("play") { (view: ExpoTwoStepVideoView) in
|
|
657
657
|
view.play()
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
-
AsyncFunction("pause") { (view:
|
|
660
|
+
AsyncFunction("pause") { (view: ExpoTwoStepVideoView) in
|
|
661
661
|
view.pause()
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
-
AsyncFunction("seek") { (view:
|
|
664
|
+
AsyncFunction("seek") { (view: ExpoTwoStepVideoView, time: Double) in
|
|
665
665
|
view.seek(to: time)
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
AsyncFunction("replay") { (view:
|
|
668
|
+
AsyncFunction("replay") { (view: ExpoTwoStepVideoView) in
|
|
669
669
|
view.replay()
|
|
670
670
|
}
|
|
671
671
|
}
|
|
672
|
+
|
|
673
|
+
// MARK: - Video Player Controller View (with native controls)
|
|
674
|
+
|
|
675
|
+
View(ExpoTwoStepPlayerControllerView.self) {
|
|
676
|
+
// Events emitted by the view
|
|
677
|
+
Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError")
|
|
678
|
+
|
|
679
|
+
// Prop to set composition ID - view will load it
|
|
680
|
+
Prop("compositionId") { (view: ExpoTwoStepPlayerControllerView, compositionId: String?) in
|
|
681
|
+
guard let compositionId = compositionId else { return }
|
|
682
|
+
guard let composition = self.activeCompositions[compositionId] else {
|
|
683
|
+
view.onError(["error": "Composition not found: \(compositionId)"])
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
let videoComposition = self.activeVideoCompositions[compositionId]
|
|
687
|
+
view.loadComposition(compositionId: compositionId, composition: composition, videoComposition: videoComposition)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Prop to set asset ID - view will load it for preview
|
|
691
|
+
Prop("assetId") { (view: ExpoTwoStepPlayerControllerView, assetId: String?) in
|
|
692
|
+
guard let assetId = assetId else { return }
|
|
693
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
694
|
+
view.onError(["error": "Asset not found: \(assetId)"])
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
view.loadAsset(assetId: assetId, asset: asset.avAsset)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Prop to enable continuous looping
|
|
701
|
+
Prop("loop") { (view: ExpoTwoStepPlayerControllerView, loop: Bool?) in
|
|
702
|
+
view.shouldLoop = loop ?? false
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Prop to show/hide native playback controls
|
|
706
|
+
Prop("showsPlaybackControls") { (view: ExpoTwoStepPlayerControllerView, show: Bool?) in
|
|
707
|
+
view.showsPlaybackControls = show ?? true
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Playback control functions
|
|
711
|
+
AsyncFunction("play") { (view: ExpoTwoStepPlayerControllerView) in
|
|
712
|
+
view.play()
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
AsyncFunction("pause") { (view: ExpoTwoStepPlayerControllerView) in
|
|
716
|
+
view.pause()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
AsyncFunction("seek") { (view: ExpoTwoStepPlayerControllerView, time: Double) in
|
|
720
|
+
view.seek(to: time)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
AsyncFunction("replay") { (view: ExpoTwoStepPlayerControllerView) in
|
|
724
|
+
view.replay()
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Fullscreen functions
|
|
728
|
+
AsyncFunction("enterFullscreen") { (view: ExpoTwoStepPlayerControllerView) in
|
|
729
|
+
view.enterFullscreen()
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
AsyncFunction("exitFullscreen") { (view: ExpoTwoStepPlayerControllerView) in
|
|
733
|
+
view.exitFullscreen()
|
|
734
|
+
}
|
|
735
|
+
}
|
|
672
736
|
}
|
|
673
737
|
|
|
674
738
|
// MARK: - Private Helper Methods
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import AVKit
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
/// Native video player view using AVPlayerViewController with built-in controls and fullscreen support
|
|
7
|
+
class ExpoTwoStepPlayerControllerView: ExpoView {
|
|
8
|
+
|
|
9
|
+
// MARK: - Properties
|
|
10
|
+
|
|
11
|
+
private var playerViewController: AVPlayerViewController?
|
|
12
|
+
private var player: AVPlayer?
|
|
13
|
+
private var timeObserver: Any?
|
|
14
|
+
private var isObservingStatus: Bool = false
|
|
15
|
+
private var hostingViewController: UIViewController?
|
|
16
|
+
|
|
17
|
+
/// Event dispatchers
|
|
18
|
+
let onPlaybackStatusChange = EventDispatcher()
|
|
19
|
+
let onProgress = EventDispatcher()
|
|
20
|
+
let onEnd = EventDispatcher()
|
|
21
|
+
let onError = EventDispatcher()
|
|
22
|
+
|
|
23
|
+
/// Current composition/asset IDs being played
|
|
24
|
+
private var currentCompositionId: String?
|
|
25
|
+
private var currentAssetId: String?
|
|
26
|
+
|
|
27
|
+
/// Whether to loop playback continuously
|
|
28
|
+
var shouldLoop: Bool = false
|
|
29
|
+
|
|
30
|
+
/// Whether to show native playback controls
|
|
31
|
+
var showsPlaybackControls: Bool = true {
|
|
32
|
+
didSet {
|
|
33
|
+
playerViewController?.showsPlaybackControls = showsPlaybackControls
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// MARK: - Initialization
|
|
38
|
+
|
|
39
|
+
required init(appContext: AppContext? = nil) {
|
|
40
|
+
super.init(appContext: appContext)
|
|
41
|
+
clipsToBounds = true
|
|
42
|
+
backgroundColor = .black
|
|
43
|
+
setupPlayerViewController()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private func setupPlayerViewController() {
|
|
47
|
+
let pvc = AVPlayerViewController()
|
|
48
|
+
pvc.showsPlaybackControls = showsPlaybackControls
|
|
49
|
+
pvc.videoGravity = .resizeAspect
|
|
50
|
+
playerViewController = pvc
|
|
51
|
+
|
|
52
|
+
// Add the view - defer view controller containment until we have a window
|
|
53
|
+
pvc.view.frame = bounds
|
|
54
|
+
pvc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
55
|
+
addSubview(pvc.view)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override func layoutSubviews() {
|
|
59
|
+
super.layoutSubviews()
|
|
60
|
+
playerViewController?.view.frame = bounds
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override func didMoveToWindow() {
|
|
64
|
+
super.didMoveToWindow()
|
|
65
|
+
|
|
66
|
+
// When added to window, find hosting view controller for proper containment
|
|
67
|
+
if window != nil, hostingViewController == nil {
|
|
68
|
+
if let vc = findViewController() {
|
|
69
|
+
hostingViewController = vc
|
|
70
|
+
if let pvc = playerViewController {
|
|
71
|
+
vc.addChild(pvc)
|
|
72
|
+
pvc.didMove(toParent: vc)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private func findViewController() -> UIViewController? {
|
|
79
|
+
var responder: UIResponder? = self
|
|
80
|
+
while let next = responder?.next {
|
|
81
|
+
if let vc = next as? UIViewController {
|
|
82
|
+
return vc
|
|
83
|
+
}
|
|
84
|
+
responder = next
|
|
85
|
+
}
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// MARK: - Public Methods (called from module)
|
|
90
|
+
|
|
91
|
+
func loadComposition(compositionId: String, composition: AVMutableComposition, videoComposition: AVMutableVideoComposition?) {
|
|
92
|
+
// Clean up previous player
|
|
93
|
+
cleanup()
|
|
94
|
+
|
|
95
|
+
currentCompositionId = compositionId
|
|
96
|
+
currentAssetId = nil
|
|
97
|
+
|
|
98
|
+
// Create player item from composition
|
|
99
|
+
let playerItem = AVPlayerItem(asset: composition)
|
|
100
|
+
|
|
101
|
+
// Apply video composition for transforms (mirror, etc.)
|
|
102
|
+
if let videoComposition = videoComposition {
|
|
103
|
+
playerItem.videoComposition = videoComposition
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setupPlayer(with: playerItem)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func loadAsset(assetId: String, asset: AVAsset) {
|
|
110
|
+
// Clean up previous player
|
|
111
|
+
cleanup()
|
|
112
|
+
|
|
113
|
+
currentAssetId = assetId
|
|
114
|
+
currentCompositionId = nil
|
|
115
|
+
|
|
116
|
+
let playerItem = AVPlayerItem(asset: asset)
|
|
117
|
+
setupPlayer(with: playerItem)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func setupPlayer(with playerItem: AVPlayerItem) {
|
|
121
|
+
// Create player
|
|
122
|
+
player = AVPlayer(playerItem: playerItem)
|
|
123
|
+
playerViewController?.player = player
|
|
124
|
+
|
|
125
|
+
// Observe playback status
|
|
126
|
+
playerItem.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
|
|
127
|
+
isObservingStatus = true
|
|
128
|
+
|
|
129
|
+
// Observe when playback ends
|
|
130
|
+
NotificationCenter.default.addObserver(
|
|
131
|
+
self,
|
|
132
|
+
selector: #selector(playerDidFinishPlaying),
|
|
133
|
+
name: .AVPlayerItemDidPlayToEndTime,
|
|
134
|
+
object: playerItem
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Add periodic time observer for progress
|
|
138
|
+
let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
139
|
+
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
|
140
|
+
guard let self = self,
|
|
141
|
+
let duration = self.player?.currentItem?.duration,
|
|
142
|
+
duration.isNumeric && !duration.isIndefinite else { return }
|
|
143
|
+
|
|
144
|
+
let currentTime = CMTimeGetSeconds(time)
|
|
145
|
+
let totalDuration = CMTimeGetSeconds(duration)
|
|
146
|
+
|
|
147
|
+
self.onProgress([
|
|
148
|
+
"currentTime": currentTime,
|
|
149
|
+
"duration": totalDuration,
|
|
150
|
+
"progress": totalDuration > 0 ? currentTime / totalDuration : 0
|
|
151
|
+
])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onPlaybackStatusChange(["status": "ready"])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func play() {
|
|
158
|
+
player?.play()
|
|
159
|
+
onPlaybackStatusChange(["status": "playing"])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func pause() {
|
|
163
|
+
player?.pause()
|
|
164
|
+
onPlaybackStatusChange(["status": "paused"])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func seek(to time: Double) {
|
|
168
|
+
let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
169
|
+
player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] finished in
|
|
170
|
+
if finished {
|
|
171
|
+
self?.onPlaybackStatusChange(["status": "seeked", "time": time])
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func replay() {
|
|
177
|
+
seek(to: 0)
|
|
178
|
+
play()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func enterFullscreen() {
|
|
182
|
+
// Present player view controller modally for fullscreen experience
|
|
183
|
+
guard let hostVC = hostingViewController ?? findViewController(),
|
|
184
|
+
let player = player else { return }
|
|
185
|
+
|
|
186
|
+
let fullscreenVC = AVPlayerViewController()
|
|
187
|
+
fullscreenVC.player = player
|
|
188
|
+
fullscreenVC.showsPlaybackControls = true
|
|
189
|
+
fullscreenVC.modalPresentationStyle = .fullScreen
|
|
190
|
+
|
|
191
|
+
// Track playback position and state before presenting
|
|
192
|
+
let currentTime = player.currentTime()
|
|
193
|
+
let wasPlaying = player.rate != 0
|
|
194
|
+
|
|
195
|
+
hostVC.present(fullscreenVC, animated: true) {
|
|
196
|
+
// Restore playback position and state
|
|
197
|
+
fullscreenVC.player?.seek(to: currentTime)
|
|
198
|
+
if wasPlaying {
|
|
199
|
+
fullscreenVC.player?.play()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func exitFullscreen() {
|
|
205
|
+
// The fullscreen presentation is dismissed by the user via the player controls
|
|
206
|
+
// or by swiping down. We can also dismiss programmatically if needed.
|
|
207
|
+
guard let hostVC = hostingViewController ?? findViewController(),
|
|
208
|
+
hostVC.presentedViewController is AVPlayerViewController else { return }
|
|
209
|
+
|
|
210
|
+
hostVC.dismiss(animated: true)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
var isPlaying: Bool {
|
|
214
|
+
return player?.rate != 0 && player?.error == nil
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
var currentTime: Double {
|
|
218
|
+
guard let time = player?.currentTime() else { return 0 }
|
|
219
|
+
return CMTimeGetSeconds(time)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
var duration: Double {
|
|
223
|
+
guard let duration = player?.currentItem?.duration,
|
|
224
|
+
duration.isNumeric && !duration.isIndefinite else { return 0 }
|
|
225
|
+
return CMTimeGetSeconds(duration)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: - Observers
|
|
229
|
+
|
|
230
|
+
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
231
|
+
if keyPath == "status" {
|
|
232
|
+
if let playerItem = object as? AVPlayerItem {
|
|
233
|
+
switch playerItem.status {
|
|
234
|
+
case .readyToPlay:
|
|
235
|
+
onPlaybackStatusChange(["status": "ready"])
|
|
236
|
+
case .failed:
|
|
237
|
+
onError(["error": playerItem.error?.localizedDescription ?? "Unknown error"])
|
|
238
|
+
default:
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@objc private func playerDidFinishPlaying() {
|
|
246
|
+
if shouldLoop {
|
|
247
|
+
player?.seek(to: .zero) { [weak self] _ in
|
|
248
|
+
self?.player?.play()
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
onEnd([:])
|
|
252
|
+
onPlaybackStatusChange(["status": "ended"])
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// MARK: - Cleanup
|
|
257
|
+
|
|
258
|
+
private func cleanup() {
|
|
259
|
+
// Remove time observer first
|
|
260
|
+
if let observer = timeObserver, let player = player {
|
|
261
|
+
player.removeTimeObserver(observer)
|
|
262
|
+
}
|
|
263
|
+
timeObserver = nil
|
|
264
|
+
|
|
265
|
+
// Remove KVO observer only if we added it
|
|
266
|
+
if isObservingStatus, let playerItem = player?.currentItem {
|
|
267
|
+
playerItem.removeObserver(self, forKeyPath: "status")
|
|
268
|
+
isObservingStatus = false
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Remove notification observer
|
|
272
|
+
if let playerItem = player?.currentItem {
|
|
273
|
+
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Stop and clear player
|
|
277
|
+
player?.pause()
|
|
278
|
+
player?.replaceCurrentItem(with: nil)
|
|
279
|
+
player = nil
|
|
280
|
+
playerViewController?.player = nil
|
|
281
|
+
|
|
282
|
+
currentCompositionId = nil
|
|
283
|
+
currentAssetId = nil
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
deinit {
|
|
287
|
+
cleanup()
|
|
288
|
+
|
|
289
|
+
// Remove from parent view controller
|
|
290
|
+
if let pvc = playerViewController {
|
|
291
|
+
pvc.willMove(toParent: nil)
|
|
292
|
+
pvc.view.removeFromSuperview()
|
|
293
|
+
pvc.removeFromParent()
|
|
294
|
+
}
|
|
295
|
+
playerViewController = nil
|
|
296
|
+
}
|
|
297
|
+
}
|