@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.
- package/README.md +80 -7
- package/build/ExpoTwoStepVideo.types.d.ts +49 -0
- package/build/ExpoTwoStepVideo.types.d.ts.map +1 -1
- package/build/ExpoTwoStepVideo.types.js.map +1 -1
- package/build/ExpoTwoStepVideoModule.d.ts +17 -0
- package/build/ExpoTwoStepVideoModule.d.ts.map +1 -1
- package/build/ExpoTwoStepVideoModule.js.map +1 -1
- package/build/ExpoTwoStepVideoModule.web.d.ts +4 -0
- package/build/ExpoTwoStepVideoModule.web.d.ts.map +1 -1
- package/build/ExpoTwoStepVideoModule.web.js +15 -0
- package/build/ExpoTwoStepVideoModule.web.js.map +1 -1
- package/build/ExpoTwoStepVideoView.d.ts.map +1 -1
- package/build/ExpoTwoStepVideoView.js +74 -13
- package/build/ExpoTwoStepVideoView.js.map +1 -1
- package/build/components/DoubleTapSkip.d.ts.map +1 -1
- package/build/components/DoubleTapSkip.js +35 -20
- package/build/components/DoubleTapSkip.js.map +1 -1
- package/build/components/PlayheadBar.d.ts.map +1 -1
- package/build/components/PlayheadBar.js +30 -18
- package/build/components/PlayheadBar.js.map +1 -1
- package/build/index.d.ts +83 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +91 -0
- package/build/index.js.map +1 -1
- package/ios/ExpoTwoStepVideoModule.swift +77 -1
- package/ios/ExpoTwoStepVideoView.swift +155 -36
- package/ios/TwoStepVideo/Core/MediaPicker.swift +355 -0
- package/ios/TwoStepVideo/TwoStepVideo.swift +4 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
231
|
-
//
|
|
232
|
-
let anchor =
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
321
|
-
let
|
|
414
|
+
let maxPanX = contentSize.width * (capped.scaleX - 1) / 2
|
|
415
|
+
let maxPanY = contentSize.height * (capped.scaleY - 1) / 2
|
|
322
416
|
|
|
323
|
-
// tx/ty constraints:
|
|
324
|
-
capped.tx = min(max(capped.tx, -
|
|
325
|
-
capped.ty = min(max(capped.ty, -
|
|
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
|
-
|
|
334
|
-
|
|
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":
|
|
338
|
-
"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
|
-
|
|
349
|
-
let
|
|
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":
|
|
353
|
-
"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
|
-
|
|
376
|
-
|
|
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
|
|
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
|
|