@movementinfra/expo-twostep-video 0.1.15 → 0.1.16

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.
@@ -667,11 +667,77 @@ public class ExpoTwoStepVideoModule: Module {
667
667
  self.activeVideoCompositions.removeAll()
668
668
  }
669
669
 
670
+ // MARK: - Media Picker Functions
671
+
672
+ /// Pick video(s) from the photo library using native PHPickerViewController
673
+ /// - Parameter selectionLimit: Maximum number of videos to select (default 1)
674
+ /// - Returns: Array of picked video metadata (or empty array if cancelled)
675
+ AsyncFunction("pickVideo") { (selectionLimit: Int?, promise: Promise) in
676
+ let options = PickerOptions(selectionLimit: selectionLimit ?? 1)
677
+
678
+ self.twoStep.mediaPicker.pickVideo(options: options) { result in
679
+ switch result {
680
+ case .success(let videos):
681
+ // Convert to JS-friendly dictionaries
682
+ let jsVideos = videos.map { video -> [String: Any] in
683
+ var dict: [String: Any] = [
684
+ "uri": video.uri,
685
+ "path": video.path,
686
+ "fileName": video.fileName,
687
+ "width": video.width,
688
+ "height": video.height,
689
+ "duration": video.duration,
690
+ "fileSize": video.fileSize,
691
+ "type": video.type
692
+ ]
693
+
694
+ if let creationDate = video.creationDate {
695
+ dict["creationDate"] = creationDate
696
+ }
697
+ if let modificationDate = video.modificationDate {
698
+ dict["modificationDate"] = modificationDate
699
+ }
700
+ if let assetIdentifier = video.assetIdentifier {
701
+ dict["assetIdentifier"] = assetIdentifier
702
+ }
703
+
704
+ return dict
705
+ }
706
+
707
+ promise.resolve(jsVideos)
708
+
709
+ case .failure(let error):
710
+ // Cancellation returns empty array, not an error
711
+ if (error as? MediaPickerError) == .cancelled {
712
+ promise.resolve([])
713
+ } else {
714
+ promise.reject("PICKER_ERROR", error.localizedDescription)
715
+ }
716
+ }
717
+ }
718
+ }
719
+
720
+ /// Clean up a specific picked video file
721
+ /// - Parameter path: File path to clean up
722
+ Function("cleanupPickedVideo") { (path: String) in
723
+ self.twoStep.mediaPicker.cleanupPickedVideo(at: path)
724
+ }
725
+
726
+ /// Clean up all picked video files from temp storage
727
+ Function("cleanupAllPickedVideos") {
728
+ self.twoStep.mediaPicker.cleanupAllPickedVideos()
729
+ }
730
+
731
+ /// Get list of currently tracked picked video paths
732
+ Function("getPickedVideoPaths") { () -> [String] in
733
+ return self.twoStep.mediaPicker.trackedPickedVideoPaths
734
+ }
735
+
670
736
  // MARK: - Video Player View
671
737
 
672
738
  View(ExpoTwoStepVideoView.self) {
673
739
  // Events emitted by the view
674
- Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError", "onPanZoomChange")
740
+ Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError", "onPanZoomChange", "onDoubleTapSkip")
675
741
 
676
742
  // Prop to set composition ID - view will load it
677
743
  Prop("compositionId") { (view: ExpoTwoStepVideoView, compositionId: String?) in
@@ -709,6 +775,16 @@ public class ExpoTwoStepVideoModule: Module {
709
775
  view.maxZoom = CGFloat(maxZoom ?? 5.0)
710
776
  }
711
777
 
778
+ // Prop to enable/disable native double-tap to skip
779
+ Prop("enableDoubleTapSkip") { (view: ExpoTwoStepVideoView, enable: Bool?) in
780
+ view.enableDoubleTapSkip = enable ?? true
781
+ }
782
+
783
+ // Prop to set double-tap skip interval in seconds
784
+ Prop("doubleTapSkipInterval") { (view: ExpoTwoStepVideoView, interval: Double?) in
785
+ view.doubleTapSkipInterval = interval ?? 5.0
786
+ }
787
+
712
788
  // Playback control functions
713
789
  AsyncFunction("play") { (view: ExpoTwoStepVideoView) in
714
790
  view.play()
@@ -45,6 +45,19 @@ class ExpoTwoStepVideoView: ExpoView {
45
45
  /// Gesture recognizers
46
46
  private var pinchGesture: UIPinchGestureRecognizer?
47
47
  private var panGesture: UIPanGestureRecognizer?
48
+ private var doubleTapLeftGesture: UITapGestureRecognizer?
49
+ private var doubleTapRightGesture: UITapGestureRecognizer?
50
+
51
+ // MARK: - Double-Tap Skip Properties
52
+
53
+ /// Whether double-tap to skip is enabled
54
+ var enableDoubleTapSkip: Bool = true
55
+
56
+ /// Seconds to skip on double-tap
57
+ var doubleTapSkipInterval: Double = 5.0
58
+
59
+ /// Event dispatcher for double-tap skip feedback
60
+ let onDoubleTapSkip = EventDispatcher()
48
61
 
49
62
  // MARK: - Initialization
50
63
 
@@ -159,6 +172,20 @@ class ExpoTwoStepVideoView: ExpoView {
159
172
  addGestureRecognizer(pan)
160
173
  panGesture = pan
161
174
 
175
+ // Double-tap on left side to skip backward
176
+ let doubleTapLeft = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTapLeft(_:)))
177
+ doubleTapLeft.numberOfTapsRequired = 2
178
+ doubleTapLeft.delegate = self
179
+ addGestureRecognizer(doubleTapLeft)
180
+ doubleTapLeftGesture = doubleTapLeft
181
+
182
+ // Double-tap on right side to skip forward
183
+ let doubleTapRight = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTapRight(_:)))
184
+ doubleTapRight.numberOfTapsRequired = 2
185
+ doubleTapRight.delegate = self
186
+ addGestureRecognizer(doubleTapRight)
187
+ doubleTapRightGesture = doubleTapRight
188
+
162
189
  isUserInteractionEnabled = true
163
190
  }
164
191
 
@@ -183,18 +210,6 @@ class ExpoTwoStepVideoView: ExpoView {
183
210
  return AVMakeRect(aspectRatio: videoSize, insideRect: layerBounds)
184
211
  }
185
212
 
186
- /// Adjust an anchor point to be relative to the video content rect.
187
- /// Clamps points outside the video area to the nearest edge.
188
- private func adjustedAnchorPoint(for point: CGPoint) -> CGPoint {
189
- let contentRect = videoContentRect()
190
-
191
- // Clamp the point to the video content rect
192
- let clampedX = min(max(point.x, contentRect.minX), contentRect.maxX)
193
- let clampedY = min(max(point.y, contentRect.minY), contentRect.maxY)
194
-
195
- return CGPoint(x: clampedX, y: clampedY)
196
- }
197
-
198
213
  // MARK: - Gesture Handlers
199
214
 
200
215
  /// Get the current visual transform, reading from the presentation layer if an animation is in progress
@@ -227,14 +242,39 @@ class ExpoTwoStepVideoView: ExpoView {
227
242
  beginGesture()
228
243
 
229
244
  case .changed:
230
- let rawAnchor = gesture.location(in: self)
231
- // Adjust anchor to account for letterboxing/pillarboxing
232
- let anchor = adjustedAnchorPoint(for: rawAnchor)
245
+ // Get the pinch center in the PARENT's coordinate system (self)
246
+ // This gives us a stable anchor point that doesn't change with the view's transform
247
+ let anchor = gesture.location(in: self)
248
+
249
+ // Get the incremental scale change
233
250
  let scale = gesture.scale
234
- let scaleTransform = CGAffineTransform.anchoredScale(scale: scale, anchor: anchor)
235
- let newTransform = gestureStartTransform.concatenating(scaleTransform)
236
- currentTransform = newTransform
237
- playerContainerView.transform = newTransform
251
+ gesture.scale = 1.0
252
+
253
+ // Check if new scale would exceed limits
254
+ let currentScale = currentTransform.scaleX
255
+ let proposedScale = currentScale * scale
256
+
257
+ // Skip if we'd go below min zoom
258
+ if proposedScale < minZoom && scale < 1.0 {
259
+ return
260
+ }
261
+
262
+ // Clamp scale factor if we'd exceed max zoom
263
+ var effectiveScale = scale
264
+ if proposedScale > maxZoom {
265
+ effectiveScale = maxZoom / currentScale
266
+ }
267
+
268
+ // Calculate new transform that scales around the anchor point
269
+ // Formula: The anchor point in screen coordinates should stay fixed
270
+ // newTx = anchor.x * (1 - scale) + currentTx * scale
271
+ // newTy = anchor.y * (1 - scale) + currentTy * scale
272
+ let newScale = currentScale * effectiveScale
273
+ let newTx = anchor.x * (1 - effectiveScale) + currentTransform.tx * effectiveScale
274
+ let newTy = anchor.y * (1 - effectiveScale) + currentTransform.ty * effectiveScale
275
+
276
+ currentTransform = CGAffineTransform(a: newScale, b: 0, c: 0, d: newScale, tx: newTx, ty: newTy)
277
+ playerContainerView.transform = currentTransform
238
278
 
239
279
  case .ended, .cancelled:
240
280
  onGestureEnded()
@@ -244,6 +284,57 @@ class ExpoTwoStepVideoView: ExpoView {
244
284
  }
245
285
  }
246
286
 
287
+ // MARK: - Double-Tap Handlers
288
+
289
+ @objc private func handleDoubleTapLeft(_ gesture: UITapGestureRecognizer) {
290
+ guard enableDoubleTapSkip else { return }
291
+
292
+ let location = gesture.location(in: self)
293
+ // Only handle taps on the left half
294
+ guard location.x < bounds.width / 2 else { return }
295
+
296
+ guard let player = player,
297
+ let currentItem = player.currentItem else { return }
298
+
299
+ let currentTime = CMTimeGetSeconds(player.currentTime())
300
+ let newTime = max(0, currentTime - doubleTapSkipInterval)
301
+ let seekTime = CMTime(seconds: newTime, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
302
+
303
+ player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero)
304
+
305
+ // Emit event for UI feedback
306
+ onDoubleTapSkip([
307
+ "direction": "backward",
308
+ "skipInterval": doubleTapSkipInterval,
309
+ "newTime": newTime
310
+ ])
311
+ }
312
+
313
+ @objc private func handleDoubleTapRight(_ gesture: UITapGestureRecognizer) {
314
+ guard enableDoubleTapSkip else { return }
315
+
316
+ let location = gesture.location(in: self)
317
+ // Only handle taps on the right half
318
+ guard location.x >= bounds.width / 2 else { return }
319
+
320
+ guard let player = player,
321
+ let currentItem = player.currentItem else { return }
322
+
323
+ let duration = CMTimeGetSeconds(currentItem.duration)
324
+ let currentTime = CMTimeGetSeconds(player.currentTime())
325
+ let newTime = min(duration, currentTime + doubleTapSkipInterval)
326
+ let seekTime = CMTime(seconds: newTime, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
327
+
328
+ player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero)
329
+
330
+ // Emit event for UI feedback
331
+ onDoubleTapSkip([
332
+ "direction": "forward",
333
+ "skipInterval": doubleTapSkipInterval,
334
+ "newTime": newTime
335
+ ])
336
+ }
337
+
247
338
  @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
248
339
  switch gesture.state {
249
340
  case .began:
@@ -316,13 +407,16 @@ class ExpoTwoStepVideoView: ExpoView {
316
407
  }
317
408
 
318
409
  // Constrain pan to keep content visible
410
+ // When scaled, the content extends beyond the view bounds.
411
+ // The maximum pan distance is half the excess on each side.
412
+ // For a 2x scale on 100px width: content is 200px, excess is 100px, max pan is ±50px
319
413
  let contentSize = bounds.size
320
- let maxX = contentSize.width * (capped.scaleX - 1)
321
- let maxY = contentSize.height * (capped.scaleY - 1)
414
+ let maxPanX = contentSize.width * (capped.scaleX - 1) / 2
415
+ let maxPanY = contentSize.height * (capped.scaleY - 1) / 2
322
416
 
323
- // tx/ty constraints: can't pan past edges
324
- capped.tx = min(max(capped.tx, -maxX), 0)
325
- capped.ty = min(max(capped.ty, -maxY), 0)
417
+ // tx/ty constraints: allow panning in both directions, but keep content edges visible
418
+ capped.tx = min(max(capped.tx, -maxPanX), maxPanX)
419
+ capped.ty = min(max(capped.ty, -maxPanY), maxPanY)
326
420
 
327
421
  return capped
328
422
  }
@@ -330,12 +424,16 @@ class ExpoTwoStepVideoView: ExpoView {
330
424
  /// Emit the current pan/zoom state to JavaScript
331
425
  private func emitPanZoomChange() {
332
426
  let scale = currentTransform.scaleX
333
- let panX = scale > 1.0 ? currentTransform.tx / (bounds.width * (scale - 1)) : 0
334
- let panY = scale > 1.0 ? currentTransform.ty / (bounds.height * (scale - 1)) : 0
427
+ // Convert tx/ty to normalized -1 to 1 range
428
+ // maxPan = size * (scale - 1) / 2, so panX = tx / maxPan
429
+ let maxPanX = bounds.width * (scale - 1) / 2
430
+ let maxPanY = bounds.height * (scale - 1) / 2
431
+ let panX = (scale > 1.0 && maxPanX > 0) ? currentTransform.tx / maxPanX : 0
432
+ let panY = (scale > 1.0 && maxPanY > 0) ? currentTransform.ty / maxPanY : 0
335
433
 
336
434
  onPanZoomChange([
337
- "panX": -panX, // Normalize to -1 to 0 range
338
- "panY": -panY,
435
+ "panX": panX, // -1 to 1, positive = content shifted right
436
+ "panY": panY, // -1 to 1, positive = content shifted down
339
437
  "zoomLevel": scale
340
438
  ])
341
439
  }
@@ -345,12 +443,15 @@ class ExpoTwoStepVideoView: ExpoView {
345
443
  /// Get the current pan/zoom state
346
444
  func getPanZoomState() -> [String: CGFloat] {
347
445
  let scale = currentTransform.scaleX
348
- let panX = scale > 1.0 ? currentTransform.tx / (bounds.width * (scale - 1)) : 0
349
- let panY = scale > 1.0 ? currentTransform.ty / (bounds.height * (scale - 1)) : 0
446
+ // Convert tx/ty to normalized -1 to 1 range
447
+ let maxPanX = bounds.width * (scale - 1) / 2
448
+ let maxPanY = bounds.height * (scale - 1) / 2
449
+ let panX = (scale > 1.0 && maxPanX > 0) ? currentTransform.tx / maxPanX : 0
450
+ let panY = (scale > 1.0 && maxPanY > 0) ? currentTransform.ty / maxPanY : 0
350
451
 
351
452
  return [
352
- "panX": -panX,
353
- "panY": -panY,
453
+ "panX": panX, // -1 to 1, positive = content shifted right
454
+ "panY": panY, // -1 to 1, positive = content shifted down
354
455
  "zoomLevel": scale
355
456
  ]
356
457
  }
@@ -372,8 +473,12 @@ class ExpoTwoStepVideoView: ExpoView {
372
473
  if let x = panX, let y = panY {
373
474
  let scale = newTransform.scaleX
374
475
  if scale > 1.0 {
375
- newTransform.tx = -x * bounds.width * (scale - 1)
376
- newTransform.ty = -y * bounds.height * (scale - 1)
476
+ // Convert normalized -1 to 1 values to tx/ty
477
+ // maxPan = size * (scale - 1) / 2, so tx = panX * maxPan
478
+ let maxPanX = bounds.width * (scale - 1) / 2
479
+ let maxPanY = bounds.height * (scale - 1) / 2
480
+ newTransform.tx = x * maxPanX
481
+ newTransform.ty = y * maxPanY
377
482
  }
378
483
  }
379
484
 
@@ -590,7 +695,7 @@ class ExpoTwoStepVideoView: ExpoView {
590
695
  // MARK: - UIGestureRecognizerDelegate
591
696
 
592
697
  extension ExpoTwoStepVideoView: UIGestureRecognizerDelegate {
593
- /// Allow pinch and pan gestures to work simultaneously
698
+ /// Allow gestures to work simultaneously
594
699
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
595
700
  // Allow our pinch and pan to work together
596
701
  if (gestureRecognizer == pinchGesture && otherGestureRecognizer == panGesture) ||
@@ -601,15 +706,29 @@ extension ExpoTwoStepVideoView: UIGestureRecognizerDelegate {
601
706
  if gestureRecognizer == pinchGesture {
602
707
  return true
603
708
  }
709
+ // Allow double-tap gestures to work with each other (left and right can coexist)
710
+ if (gestureRecognizer == doubleTapLeftGesture || gestureRecognizer == doubleTapRightGesture) &&
711
+ (otherGestureRecognizer == doubleTapLeftGesture || otherGestureRecognizer == doubleTapRightGesture) {
712
+ return true
713
+ }
604
714
  return false
605
715
  }
606
716
 
607
- /// Only allow pan gesture when zoomed in
717
+ /// Only allow pan gesture when zoomed in, and filter double-tap by location
608
718
  override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
609
719
  if gestureRecognizer == panGesture {
610
720
  // Read from actual view transform to be accurate
611
721
  return playerContainerView.transform.scaleX > 1.01
612
722
  }
723
+ // Filter double-tap gestures by their location (left half vs right half)
724
+ if gestureRecognizer == doubleTapLeftGesture {
725
+ let location = gestureRecognizer.location(in: self)
726
+ return location.x < bounds.width / 2
727
+ }
728
+ if gestureRecognizer == doubleTapRightGesture {
729
+ let location = gestureRecognizer.location(in: self)
730
+ return location.x >= bounds.width / 2
731
+ }
613
732
  return true
614
733
  }
615
734