@movementinfra/expo-twostep-video 0.1.8 → 0.1.10

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
 
@@ -11,6 +11,7 @@ class ExpoTwostepVideoView: ExpoView {
11
11
  private var playerLayer: AVPlayerLayer?
12
12
  private var timeObserver: Any?
13
13
  private var isObservingStatus: Bool = false
14
+ private var isAudioSessionConfigured: Bool = false
14
15
 
15
16
  /// Event dispatchers
16
17
  let onPlaybackStatusChange = EventDispatcher()
@@ -32,6 +33,8 @@ class ExpoTwostepVideoView: ExpoView {
32
33
  clipsToBounds = true
33
34
  backgroundColor = .black
34
35
  setupPlayerLayer()
36
+ setupAudioSession()
37
+ setupAudioSessionObservers()
35
38
  }
36
39
 
37
40
  private func setupPlayerLayer() {
@@ -43,6 +46,87 @@ class ExpoTwostepVideoView: ExpoView {
43
46
  }
44
47
  }
45
48
 
49
+ /// Configure audio session for video playback
50
+ /// Uses .playback category to ensure audio plays even when silent switch is on
51
+ private func setupAudioSession() {
52
+ do {
53
+ let audioSession = AVAudioSession.sharedInstance()
54
+ // .playback category: audio plays even with silent switch, stops other audio
55
+ // .defaultToSpeaker: routes audio to speaker by default (not earpiece)
56
+ try audioSession.setCategory(.playback, mode: .moviePlayback, options: [.defaultToSpeaker])
57
+ try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
58
+ isAudioSessionConfigured = true
59
+ } catch {
60
+ print("ExpoTwoStepVideoView: Failed to configure audio session: \(error)")
61
+ // Try a simpler configuration as fallback
62
+ do {
63
+ try AVAudioSession.sharedInstance().setCategory(.playback)
64
+ try AVAudioSession.sharedInstance().setActive(true)
65
+ isAudioSessionConfigured = true
66
+ } catch {
67
+ print("ExpoTwoStepVideoView: Fallback audio session also failed: \(error)")
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Listen for audio session interruptions (phone calls, other apps)
73
+ private func setupAudioSessionObservers() {
74
+ NotificationCenter.default.addObserver(
75
+ self,
76
+ selector: #selector(handleAudioSessionInterruption),
77
+ name: AVAudioSession.interruptionNotification,
78
+ object: AVAudioSession.sharedInstance()
79
+ )
80
+
81
+ NotificationCenter.default.addObserver(
82
+ self,
83
+ selector: #selector(handleAudioSessionRouteChange),
84
+ name: AVAudioSession.routeChangeNotification,
85
+ object: AVAudioSession.sharedInstance()
86
+ )
87
+ }
88
+
89
+ @objc private func handleAudioSessionInterruption(notification: Notification) {
90
+ guard let userInfo = notification.userInfo,
91
+ let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
92
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
93
+ return
94
+ }
95
+
96
+ switch type {
97
+ case .began:
98
+ // Interruption began - pause playback
99
+ player?.pause()
100
+ onPlaybackStatusChange(["status": "interrupted"])
101
+ case .ended:
102
+ // Interruption ended - check if we should resume
103
+ guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
104
+ let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
105
+ if options.contains(.shouldResume) {
106
+ // Re-activate audio session and resume
107
+ setupAudioSession()
108
+ player?.play()
109
+ onPlaybackStatusChange(["status": "playing"])
110
+ }
111
+ @unknown default:
112
+ break
113
+ }
114
+ }
115
+
116
+ @objc private func handleAudioSessionRouteChange(notification: Notification) {
117
+ guard let userInfo = notification.userInfo,
118
+ let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
119
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
120
+ return
121
+ }
122
+
123
+ // Pause when headphones are unplugged
124
+ if reason == .oldDeviceUnavailable {
125
+ player?.pause()
126
+ onPlaybackStatusChange(["status": "paused"])
127
+ }
128
+ }
129
+
46
130
  override func layoutSubviews() {
47
131
  super.layoutSubviews()
48
132
  playerLayer?.frame = bounds
@@ -117,6 +201,21 @@ class ExpoTwostepVideoView: ExpoView {
117
201
  }
118
202
 
119
203
  func play() {
204
+ // Ensure audio session is properly configured before playing
205
+ // This handles cases where another component may have changed the audio session
206
+ if !isAudioSessionConfigured {
207
+ setupAudioSession()
208
+ } else {
209
+ // Re-activate in case it was deactivated
210
+ do {
211
+ try AVAudioSession.sharedInstance().setActive(true, options: [.notifyOthersOnDeactivation])
212
+ } catch {
213
+ print("ExpoTwoStepVideoView: Failed to activate audio session: \(error)")
214
+ // Try full reconfiguration
215
+ setupAudioSession()
216
+ }
217
+ }
218
+
120
219
  player?.play()
121
220
  onPlaybackStatusChange(["status": "playing"])
122
221
  }
@@ -216,6 +315,9 @@ class ExpoTwostepVideoView: ExpoView {
216
315
 
217
316
  deinit {
218
317
  cleanup()
318
+ // Remove audio session observers
319
+ NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
320
+ NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
219
321
  // Also remove the layer to break any potential retain cycles
220
322
  playerLayer?.removeFromSuperlayer()
221
323
  playerLayer = nil
@@ -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.10",
4
4
  "description": "Minimal video editing for React Native using AVFoundation",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",