@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.
@@ -622,12 +622,12 @@ public class ExpoTwoStepVideoModule: Module {
622
622
 
623
623
  // MARK: - Video Player View
624
624
 
625
- View(ExpoTwostepVideoView.self) {
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: ExpoTwostepVideoView, compositionId: String?) in
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: ExpoTwostepVideoView, assetId: String?) in
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: ExpoTwostepVideoView, loop: Bool?) in
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: ExpoTwostepVideoView) in
656
+ AsyncFunction("play") { (view: ExpoTwoStepVideoView) in
657
657
  view.play()
658
658
  }
659
659
 
660
- AsyncFunction("pause") { (view: ExpoTwostepVideoView) in
660
+ AsyncFunction("pause") { (view: ExpoTwoStepVideoView) in
661
661
  view.pause()
662
662
  }
663
663
 
664
- AsyncFunction("seek") { (view: ExpoTwostepVideoView, time: Double) in
664
+ AsyncFunction("seek") { (view: ExpoTwoStepVideoView, time: Double) in
665
665
  view.seek(to: time)
666
666
  }
667
667
 
668
- AsyncFunction("replay") { (view: ExpoTwostepVideoView) in
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
@@ -3,7 +3,7 @@ import AVFoundation
3
3
  import UIKit
4
4
 
5
5
  /// Native video player view that can play compositions directly
6
- class ExpoTwostepVideoView: ExpoView {
6
+ class ExpoTwoStepVideoView: ExpoView {
7
7
 
8
8
  // MARK: - Properties
9
9
 
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@movementinfra/expo-twostep-video",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Minimal video editing for React Native using AVFoundation",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",