@movementinfra/expo-twostep-video 0.1.14 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * TwoStepVideo - Professional video editing for React Native\n * Built on native AVFoundation with Expo Modules\n */\n\nimport { type EventSubscription } from 'expo-modules-core';\nimport { useState, useEffect } from 'react';\nimport ExpoTwoStepVideoModule from './ExpoTwoStepVideoModule';\nimport type { PanZoomVideoOptions } from './ExpoTwoStepVideo.types';\n\n// MARK: - Types\n\n/**\n * Quality presets for video export\n */\nexport type VideoQuality = 'low' | 'medium' | 'high' | 'highest';\n\n/**\n * Mirror axis options for video mirroring\n */\nexport type MirrorAxis = 'horizontal' | 'vertical' | 'both';\n\n/**\n * Video asset metadata returned after loading\n */\nexport interface VideoAsset {\n /** Unique identifier for this asset */\n id: string;\n /** Duration in seconds */\n duration: number;\n /** Video width in pixels */\n width: number;\n /** Video height in pixels */\n height: number;\n /** Frame rate (fps) */\n frameRate: number;\n /** Whether the video has audio */\n hasAudio: boolean;\n}\n\n/**\n * Video composition metadata\n */\nexport interface VideoComposition {\n /** Unique identifier for this composition */\n id: string;\n /** Total duration in seconds */\n duration: number;\n}\n\n/**\n * Time segment for multi-segment trimming\n */\nexport interface TimeSegment {\n /** Start time in seconds */\n start: number;\n /** End time in seconds */\n end: number;\n}\n\n/**\n * Export result containing output file information\n */\nexport interface ExportResult {\n /** File URI (e.g., \"file:///path/to/video.mp4\") */\n uri: string;\n /** Absolute file path */\n path: string;\n}\n\n/**\n * Export progress event\n */\nexport interface ExportProgressEvent {\n /** Progress from 0.0 to 1.0 */\n progress: number;\n /** Asset ID if exporting asset directly */\n assetId?: string;\n /** Composition ID if exporting composition */\n compositionId?: string;\n}\n\n/**\n * Options for loading a video asset\n */\nexport interface LoadAssetOptions {\n /** File URI (e.g., \"file:///path/to/video.mp4\") */\n uri: string;\n}\n\n/**\n * Options for trimming a video\n */\nexport interface TrimVideoOptions {\n /** Asset ID to trim */\n assetId: string;\n /** Start time in seconds */\n startTime: number;\n /** End time in seconds */\n endTime: number;\n}\n\n/**\n * Options for multi-segment trimming\n */\nexport interface TrimMultipleOptions {\n /** Asset ID to trim */\n assetId: string;\n /** Array of time segments to extract and concatenate */\n segments: TimeSegment[];\n}\n\n/**\n * Options for generating thumbnails\n */\nexport interface GenerateThumbnailsOptions {\n /** Asset ID to generate thumbnails from */\n assetId: string;\n /** Array of times (in seconds) to extract thumbnails */\n times: number[];\n /** Optional size for thumbnails */\n size?: {\n width: number;\n height: number;\n };\n}\n\n/**\n * Options for exporting a composition\n */\nexport interface ExportCompositionOptions {\n /** Composition ID to export */\n compositionId: string;\n /** Optional output URI (uses temp directory if not provided) */\n outputUri?: string;\n /** Quality preset (default: 'high') */\n quality?: VideoQuality;\n}\n\n/**\n * Options for exporting an asset directly\n */\nexport interface ExportAssetOptions {\n /** Asset ID to export */\n assetId: string;\n /** Optional output URI (uses temp directory if not provided) */\n outputUri?: string;\n /** Quality preset (default: 'high') */\n quality?: VideoQuality;\n}\n\n/**\n * Options for mirroring a video\n */\nexport interface MirrorVideoOptions {\n /** Asset ID to mirror */\n assetId: string;\n /** Axis to mirror on: 'horizontal', 'vertical', or 'both' */\n axis: MirrorAxis;\n /** Optional start time in seconds (for segment mirroring) */\n startTime?: number;\n /** Optional end time in seconds (for segment mirroring) */\n endTime?: number;\n}\n\n/**\n * Options for adjusting video speed\n */\nexport interface AdjustSpeedOptions {\n /** Asset ID to adjust */\n assetId: string;\n /** Speed multiplier (0.25 = 4x slower, 2.0 = 2x faster) */\n speed: number;\n /** Optional start time in seconds */\n startTime?: number;\n /** Optional end time in seconds */\n endTime?: number;\n}\n\n/**\n * Options for combined video transformation\n */\nexport interface TransformVideoOptions {\n /** Asset ID to transform */\n assetId: string;\n /** Speed multiplier (default: 1.0) */\n speed?: number;\n /** Optional mirror axis */\n mirrorAxis?: MirrorAxis;\n /** Optional start time in seconds (for trimming) */\n startTime?: number;\n /** Optional end time in seconds (for trimming) */\n endTime?: number;\n}\n\n/**\n * Options for looping a video segment\n */\nexport interface LoopSegmentOptions {\n /** Asset ID to loop */\n assetId: string;\n /** Start time of the segment to loop (in seconds) */\n startTime: number;\n /** End time of the segment to loop (in seconds) */\n endTime: number;\n /** Number of times to repeat (total plays = loopCount + 1) */\n loopCount: number;\n}\n\n/**\n * Result from looping a video segment\n */\nexport interface LoopResult {\n /** Unique identifier for this composition */\n id: string;\n /** Total duration in seconds */\n duration: number;\n /** Number of times the segment repeats */\n loopCount: number;\n /** Total number of times the segment plays */\n totalPlays: number;\n}\n\n// MARK: - Constants\n\n/**\n * Quality constants\n */\nexport const Quality = {\n LOW: ExpoTwoStepVideoModule.QUALITY_LOW,\n MEDIUM: ExpoTwoStepVideoModule.QUALITY_MEDIUM,\n HIGH: ExpoTwoStepVideoModule.QUALITY_HIGH,\n HIGHEST: ExpoTwoStepVideoModule.QUALITY_HIGHEST,\n} as const;\n\n/**\n * Mirror axis constants\n */\nexport const Mirror = {\n HORIZONTAL: ExpoTwoStepVideoModule.MIRROR_HORIZONTAL,\n VERTICAL: ExpoTwoStepVideoModule.MIRROR_VERTICAL,\n BOTH: ExpoTwoStepVideoModule.MIRROR_BOTH,\n} as const;\n\n// MARK: - API Functions\n\n/**\n * Load a video asset from a file URI\n *\n * @param options - Load options containing the file URI\n * @returns Promise resolving to video asset metadata\n *\n * @example\n * ```typescript\n * const asset = await TwoStepVideo.loadAsset({\n * uri: 'file:///path/to/video.mp4'\n * });\n * console.log(`Duration: ${asset.duration}s, Size: ${asset.width}x${asset.height}`);\n * ```\n */\nexport async function loadAsset(options: LoadAssetOptions): Promise<VideoAsset> {\n return await ExpoTwoStepVideoModule.loadAsset(options.uri);\n}\n\n/**\n * Load a video asset from the Photos library\n *\n * @param localIdentifier - PHAsset local identifier from the Photos library\n * @returns Promise resolving to video asset metadata\n *\n * @example\n * ```typescript\n * // After using expo-media-library to get a photo asset\n * const asset = await TwoStepVideo.loadAssetFromPhotos(photoAsset.id);\n * ```\n */\nexport async function loadAssetFromPhotos(localIdentifier: string): Promise<VideoAsset> {\n return await ExpoTwoStepVideoModule.loadAssetFromPhotos(localIdentifier);\n}\n\n/**\n * Validate a video file URI without loading the full asset\n * Useful for quick validation before processing\n *\n * @param uri - File URI to validate\n * @returns Promise resolving to true if valid video file\n *\n * @example\n * ```typescript\n * const isValid = await TwoStepVideo.validateVideoUri('file:///path/to/video.mp4');\n * if (isValid) {\n * const asset = await TwoStepVideo.loadAsset({ uri });\n * }\n * ```\n */\nexport async function validateVideoUri(uri: string): Promise<boolean> {\n return await ExpoTwoStepVideoModule.validateVideoUri(uri);\n}\n\n/**\n * Trim a video to a single time range\n *\n * @param options - Trim options including asset ID and time range\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * const asset = await TwoStepVideo.loadAsset({ uri });\n * const composition = await TwoStepVideo.trimVideo({\n * assetId: asset.id,\n * startTime: 5.0,\n * endTime: 15.0\n * });\n * console.log(`Trimmed to ${composition.duration}s`);\n * ```\n */\nexport async function trimVideo(options: TrimVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.trimVideo(\n options.assetId,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Trim a video to multiple segments and concatenate them\n *\n * @param options - Trim options including asset ID and segments\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * const composition = await TwoStepVideo.trimVideoMultiple({\n * assetId: asset.id,\n * segments: [\n * { start: 0, end: 3 }, // First 3 seconds\n * { start: 10, end: 13 }, // 3 seconds from middle\n * { start: 20, end: 23 } // 3 seconds from end\n * ]\n * });\n * ```\n */\nexport async function trimVideoMultiple(options: TrimMultipleOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.trimVideoMultiple(options.assetId, options.segments);\n}\n\n/**\n * Mirror a video horizontally, vertically, or both\n *\n * @param options - Mirror options including asset ID and axis\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Mirror horizontally (flip left-right, common for selfie videos)\n * const composition = await TwoStepVideo.mirrorVideo({\n * assetId: asset.id,\n * axis: 'horizontal'\n * });\n *\n * // Mirror a specific segment\n * const composition = await TwoStepVideo.mirrorVideo({\n * assetId: asset.id,\n * axis: 'vertical',\n * startTime: 5,\n * endTime: 10\n * });\n * ```\n */\nexport async function mirrorVideo(options: MirrorVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.mirrorVideo(\n options.assetId,\n options.axis,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Adjust the playback speed of a video\n *\n * @param options - Speed options including asset ID and speed multiplier\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Slow motion (0.5x speed = 2x slower)\n * const slowMo = await TwoStepVideo.adjustSpeed({\n * assetId: asset.id,\n * speed: 0.5\n * });\n *\n * // Fast forward (2x speed)\n * const fastForward = await TwoStepVideo.adjustSpeed({\n * assetId: asset.id,\n * speed: 2.0\n * });\n *\n * // Speed up a specific segment\n * const timelapse = await TwoStepVideo.adjustSpeed({\n * assetId: asset.id,\n * speed: 4.0,\n * startTime: 10,\n * endTime: 30\n * });\n * ```\n */\nexport async function adjustSpeed(options: AdjustSpeedOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.adjustSpeed(\n options.assetId,\n options.speed,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Transform a video with combined trimming, mirroring, and speed adjustment\n * Use the player's `loop` prop for continuous playback looping\n *\n * @param options - Transform options including asset ID, speed, and mirror axis\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Mirror and slow down\n * const transformed = await TwoStepVideo.transformVideo({\n * assetId: asset.id,\n * speed: 0.5,\n * mirrorAxis: 'horizontal'\n * });\n *\n * // Just mirror (speed defaults to 1.0)\n * const mirrored = await TwoStepVideo.transformVideo({\n * assetId: asset.id,\n * mirrorAxis: 'both'\n * });\n *\n * // Transform a specific segment (trim + mirror + speed)\n * const segment = await TwoStepVideo.transformVideo({\n * assetId: asset.id,\n * speed: 2.0,\n * mirrorAxis: 'horizontal',\n * startTime: 0,\n * endTime: 5\n * });\n *\n * // Play in loop mode\n * <TwoStepVideoView compositionId={segment.id} loop />\n * ```\n */\nexport async function transformVideo(options: TransformVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.transformVideo(\n options.assetId,\n options.speed,\n options.mirrorAxis,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Loop a segment of a video multiple times\n *\n * @param options - Loop options including segment time range and repeat count\n * @returns Promise resolving to loop result with duration info\n *\n * @example\n * ```typescript\n * // Loop a 2-second segment 3 times (plays 4 times total)\n * const looped = await TwoStepVideo.loopSegment({\n * assetId: asset.id,\n * startTime: 5,\n * endTime: 7,\n * loopCount: 3\n * });\n * console.log(`Duration: ${looped.duration}s (plays ${looped.totalPlays} times)`);\n *\n * // Create a perfect loop for social media\n * const perfectLoop = await TwoStepVideo.loopSegment({\n * assetId: asset.id,\n * startTime: 0,\n * endTime: 3,\n * loopCount: 4 // 15 seconds total (3s * 5 plays)\n * });\n * ```\n */\nexport async function loopSegment(options: LoopSegmentOptions): Promise<LoopResult> {\n return await ExpoTwoStepVideoModule.loopSegment(\n options.assetId,\n options.startTime,\n options.endTime,\n options.loopCount\n );\n}\n\n// Re-export PanZoomVideoOptions from types\nexport type { PanZoomVideoOptions } from './ExpoTwoStepVideo.types';\n\n/**\n * Apply pan and zoom transformation to a video\n *\n * This creates a composition with the pan/zoom baked in for export.\n * For real-time preview, use the gesture controls on TwoStepVideoView.\n *\n * @param options - Pan/zoom options including asset ID and transform values\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Get current pan/zoom from player and apply to export\n * const panZoomState = await playerRef.current.getPanZoomState();\n * const composition = await TwoStepVideo.panZoomVideo({\n * assetId: asset.id,\n * panX: panZoomState.panX,\n * panY: panZoomState.panY,\n * zoomLevel: panZoomState.zoomLevel\n * });\n *\n * // Export the pan/zoomed video\n * const result = await TwoStepVideo.exportVideo({\n * compositionId: composition.id,\n * quality: 'high'\n * });\n *\n * // Apply zoom only (no pan, centered)\n * const zoomed = await TwoStepVideo.panZoomVideo({\n * assetId: asset.id,\n * zoomLevel: 2.0 // 2x zoom, centered\n * });\n * ```\n */\nexport async function panZoomVideo(options: PanZoomVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.panZoomVideo(\n options.assetId,\n options.panX ?? 0,\n options.panY ?? 0,\n options.zoomLevel ?? 1.0,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Generate thumbnail images from a video at specific times\n *\n * @param options - Thumbnail generation options\n * @returns Promise resolving to array of base64 encoded PNG images\n *\n * @example\n * ```typescript\n * const thumbnails = await TwoStepVideo.generateThumbnails({\n * assetId: asset.id,\n * times: [1, 5, 10, 15],\n * size: { width: 300, height: 300 }\n * });\n *\n * // Use in Image component\n * <Image source={{ uri: `data:image/png;base64,${thumbnails[0]}` }} />\n * ```\n */\nexport async function generateThumbnails(\n options: GenerateThumbnailsOptions\n): Promise<string[]> {\n return await ExpoTwoStepVideoModule.generateThumbnails(\n options.assetId,\n options.times,\n options.size\n );\n}\n\n/**\n * Export a composition to a video file\n *\n * @param options - Export options including composition ID and quality\n * @returns Promise resolving to export result with file URI\n *\n * @example\n * ```typescript\n * const result = await TwoStepVideo.exportVideo({\n * compositionId: composition.id,\n * quality: 'high'\n * });\n * console.log(`Exported to: ${result.uri}`);\n * ```\n */\nexport async function exportVideo(options: ExportCompositionOptions): Promise<ExportResult> {\n return await ExpoTwoStepVideoModule.exportVideo(\n options.compositionId,\n options.outputUri,\n options.quality\n );\n}\n\n/**\n * Export an asset directly without creating a composition\n * Useful for re-encoding or changing quality without trimming\n *\n * @param options - Export options including asset ID and quality\n * @returns Promise resolving to export result with file URI\n *\n * @example\n * ```typescript\n * const result = await TwoStepVideo.exportAsset({\n * assetId: asset.id,\n * quality: 'medium'\n * });\n * ```\n */\nexport async function exportAsset(options: ExportAssetOptions): Promise<ExportResult> {\n return await ExpoTwoStepVideoModule.exportAsset(\n options.assetId,\n options.outputUri,\n options.quality\n );\n}\n\n/**\n * Clean up a temporary file\n * Call this after you're done with exported files in the temp directory\n *\n * @param uri - File URI to clean up\n *\n * @example\n * ```typescript\n * const result = await TwoStepVideo.exportVideo({ ... });\n * // ... do something with result.uri\n * TwoStepVideo.cleanupFile(result.uri);\n * ```\n */\nexport function cleanupFile(uri: string): void {\n ExpoTwoStepVideoModule.cleanupFile(uri);\n}\n\n/**\n * Release an asset from memory\n * Call this when you're done with an asset to free up memory\n *\n * @param assetId - ID of the asset to release\n */\nexport function releaseAsset(assetId: string): void {\n ExpoTwoStepVideoModule.releaseAsset(assetId);\n}\n\n/**\n * Release a composition from memory\n *\n * @param compositionId - ID of the composition to release\n */\nexport function releaseComposition(compositionId: string): void {\n ExpoTwoStepVideoModule.releaseComposition(compositionId);\n}\n\n/**\n * Release all cached assets and compositions\n * Useful for cleanup when unmounting screens\n */\nexport function releaseAll(): void {\n ExpoTwoStepVideoModule.releaseAll();\n}\n\n// MARK: - Event Listeners\n\n/**\n * Add a listener for export progress events\n *\n * @param listener - Callback function receiving progress events\n * @returns Subscription object with remove() method\n *\n * @example\n * ```typescript\n * const subscription = TwoStepVideo.addExportProgressListener((event) => {\n * console.log(`Export progress: ${Math.round(event.progress * 100)}%`);\n * setProgress(event.progress);\n * });\n *\n * // Later, remove the listener\n * subscription.remove();\n * ```\n */\nexport function addExportProgressListener(\n listener: (event: ExportProgressEvent) => void\n): EventSubscription {\n return ExpoTwoStepVideoModule.addListener('onExportProgress', listener);\n}\n\n// MARK: - Helper Hook (for React)\n\n/**\n * React hook for managing export progress\n * Only works in React components\n *\n * @returns Current export progress (0.0 to 1.0)\n *\n * @example\n * ```typescript\n * function VideoEditor() {\n * const progress = useExportProgress();\n *\n * return (\n * <View>\n * <Text>Export Progress: {Math.round(progress * 100)}%</Text>\n * </View>\n * );\n * }\n * ```\n */\nexport function useExportProgress(): number {\n const [progress, setProgress] = useState(0);\n\n useEffect(() => {\n const subscription = addExportProgressListener((event) => {\n setProgress(event.progress);\n });\n\n return () => subscription.remove();\n }, []);\n\n return progress;\n}\n\n// MARK: - Exports\n\nexport default {\n // Constants\n Quality,\n Mirror,\n\n // Core functions\n loadAsset,\n loadAssetFromPhotos,\n validateVideoUri,\n trimVideo,\n trimVideoMultiple,\n generateThumbnails,\n exportVideo,\n exportAsset,\n\n // Transformation functions\n mirrorVideo,\n adjustSpeed,\n transformVideo,\n loopSegment,\n panZoomVideo,\n\n // Memory management\n cleanupFile,\n releaseAsset,\n releaseComposition,\n releaseAll,\n\n // Events\n addExportProgressListener,\n\n // React hook\n useExportProgress,\n};\n\n// MARK: - View Component Export\n\nexport { default as TwoStepVideoView } from './ExpoTwoStepVideoView';\nexport { default as TwoStepPlayerControllerView } from './TwoStepPlayerControllerView';\nexport { default as VideoScrubber } from './VideoScrubber';\nexport { useVideoScrubber } from './hooks/useVideoScrubber';\nexport type {\n BaseVideoViewRef,\n TwoStepVideoViewProps,\n TwoStepVideoViewRef,\n TwoStepPlayerControllerViewProps,\n TwoStepPlayerControllerViewRef,\n PlaybackStatusEvent,\n ProgressEvent,\n ErrorEvent,\n PanZoomState,\n PanZoomChangeEvent,\n} from './ExpoTwoStepVideo.types';\nexport type {\n VideoScrubberProps,\n VideoScrubberRef,\n VideoScrubberTheme,\n} from './VideoScrubber.types';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * TwoStepVideo - Professional video editing for React Native\n * Built on native AVFoundation with Expo Modules\n */\n\nimport { type EventSubscription } from 'expo-modules-core';\nimport { useState, useEffect } from 'react';\nimport ExpoTwoStepVideoModule from './ExpoTwoStepVideoModule';\nimport type { PanZoomVideoOptions, PickVideoOptions, PickedVideo } from './ExpoTwoStepVideo.types';\n\n// MARK: - Types\n\n/**\n * Quality presets for video export\n */\nexport type VideoQuality = 'low' | 'medium' | 'high' | 'highest';\n\n/**\n * Mirror axis options for video mirroring\n */\nexport type MirrorAxis = 'horizontal' | 'vertical' | 'both';\n\n/**\n * Video asset metadata returned after loading\n */\nexport interface VideoAsset {\n /** Unique identifier for this asset */\n id: string;\n /** Duration in seconds */\n duration: number;\n /** Video width in pixels */\n width: number;\n /** Video height in pixels */\n height: number;\n /** Frame rate (fps) */\n frameRate: number;\n /** Whether the video has audio */\n hasAudio: boolean;\n}\n\n/**\n * Video composition metadata\n */\nexport interface VideoComposition {\n /** Unique identifier for this composition */\n id: string;\n /** Total duration in seconds */\n duration: number;\n}\n\n/**\n * Time segment for multi-segment trimming\n */\nexport interface TimeSegment {\n /** Start time in seconds */\n start: number;\n /** End time in seconds */\n end: number;\n}\n\n/**\n * Export result containing output file information\n */\nexport interface ExportResult {\n /** File URI (e.g., \"file:///path/to/video.mp4\") */\n uri: string;\n /** Absolute file path */\n path: string;\n}\n\n/**\n * Export progress event\n */\nexport interface ExportProgressEvent {\n /** Progress from 0.0 to 1.0 */\n progress: number;\n /** Asset ID if exporting asset directly */\n assetId?: string;\n /** Composition ID if exporting composition */\n compositionId?: string;\n}\n\n/**\n * Options for loading a video asset\n */\nexport interface LoadAssetOptions {\n /** File URI (e.g., \"file:///path/to/video.mp4\") */\n uri: string;\n}\n\n/**\n * Options for trimming a video\n */\nexport interface TrimVideoOptions {\n /** Asset ID to trim */\n assetId: string;\n /** Start time in seconds */\n startTime: number;\n /** End time in seconds */\n endTime: number;\n}\n\n/**\n * Options for multi-segment trimming\n */\nexport interface TrimMultipleOptions {\n /** Asset ID to trim */\n assetId: string;\n /** Array of time segments to extract and concatenate */\n segments: TimeSegment[];\n}\n\n/**\n * Options for generating thumbnails\n */\nexport interface GenerateThumbnailsOptions {\n /** Asset ID to generate thumbnails from */\n assetId: string;\n /** Array of times (in seconds) to extract thumbnails */\n times: number[];\n /** Optional size for thumbnails */\n size?: {\n width: number;\n height: number;\n };\n}\n\n/**\n * Options for exporting a composition\n */\nexport interface ExportCompositionOptions {\n /** Composition ID to export */\n compositionId: string;\n /** Optional output URI (uses temp directory if not provided) */\n outputUri?: string;\n /** Quality preset (default: 'high') */\n quality?: VideoQuality;\n}\n\n/**\n * Options for exporting an asset directly\n */\nexport interface ExportAssetOptions {\n /** Asset ID to export */\n assetId: string;\n /** Optional output URI (uses temp directory if not provided) */\n outputUri?: string;\n /** Quality preset (default: 'high') */\n quality?: VideoQuality;\n}\n\n/**\n * Options for mirroring a video\n */\nexport interface MirrorVideoOptions {\n /** Asset ID to mirror */\n assetId: string;\n /** Axis to mirror on: 'horizontal', 'vertical', or 'both' */\n axis: MirrorAxis;\n /** Optional start time in seconds (for segment mirroring) */\n startTime?: number;\n /** Optional end time in seconds (for segment mirroring) */\n endTime?: number;\n}\n\n/**\n * Options for adjusting video speed\n */\nexport interface AdjustSpeedOptions {\n /** Asset ID to adjust */\n assetId: string;\n /** Speed multiplier (0.25 = 4x slower, 2.0 = 2x faster) */\n speed: number;\n /** Optional start time in seconds */\n startTime?: number;\n /** Optional end time in seconds */\n endTime?: number;\n}\n\n/**\n * Options for combined video transformation\n */\nexport interface TransformVideoOptions {\n /** Asset ID to transform */\n assetId: string;\n /** Speed multiplier (default: 1.0) */\n speed?: number;\n /** Optional mirror axis */\n mirrorAxis?: MirrorAxis;\n /** Optional start time in seconds (for trimming) */\n startTime?: number;\n /** Optional end time in seconds (for trimming) */\n endTime?: number;\n}\n\n/**\n * Options for looping a video segment\n */\nexport interface LoopSegmentOptions {\n /** Asset ID to loop */\n assetId: string;\n /** Start time of the segment to loop (in seconds) */\n startTime: number;\n /** End time of the segment to loop (in seconds) */\n endTime: number;\n /** Number of times to repeat (total plays = loopCount + 1) */\n loopCount: number;\n}\n\n/**\n * Result from looping a video segment\n */\nexport interface LoopResult {\n /** Unique identifier for this composition */\n id: string;\n /** Total duration in seconds */\n duration: number;\n /** Number of times the segment repeats */\n loopCount: number;\n /** Total number of times the segment plays */\n totalPlays: number;\n}\n\n// MARK: - Constants\n\n/**\n * Quality constants\n */\nexport const Quality = {\n LOW: ExpoTwoStepVideoModule.QUALITY_LOW,\n MEDIUM: ExpoTwoStepVideoModule.QUALITY_MEDIUM,\n HIGH: ExpoTwoStepVideoModule.QUALITY_HIGH,\n HIGHEST: ExpoTwoStepVideoModule.QUALITY_HIGHEST,\n} as const;\n\n/**\n * Mirror axis constants\n */\nexport const Mirror = {\n HORIZONTAL: ExpoTwoStepVideoModule.MIRROR_HORIZONTAL,\n VERTICAL: ExpoTwoStepVideoModule.MIRROR_VERTICAL,\n BOTH: ExpoTwoStepVideoModule.MIRROR_BOTH,\n} as const;\n\n// MARK: - API Functions\n\n/**\n * Load a video asset from a file URI\n *\n * @param options - Load options containing the file URI\n * @returns Promise resolving to video asset metadata\n *\n * @example\n * ```typescript\n * const asset = await TwoStepVideo.loadAsset({\n * uri: 'file:///path/to/video.mp4'\n * });\n * console.log(`Duration: ${asset.duration}s, Size: ${asset.width}x${asset.height}`);\n * ```\n */\nexport async function loadAsset(options: LoadAssetOptions): Promise<VideoAsset> {\n return await ExpoTwoStepVideoModule.loadAsset(options.uri);\n}\n\n/**\n * Load a video asset from the Photos library\n *\n * @param localIdentifier - PHAsset local identifier from the Photos library\n * @returns Promise resolving to video asset metadata\n *\n * @example\n * ```typescript\n * // After using expo-media-library to get a photo asset\n * const asset = await TwoStepVideo.loadAssetFromPhotos(photoAsset.id);\n * ```\n */\nexport async function loadAssetFromPhotos(localIdentifier: string): Promise<VideoAsset> {\n return await ExpoTwoStepVideoModule.loadAssetFromPhotos(localIdentifier);\n}\n\n/**\n * Validate a video file URI without loading the full asset\n * Useful for quick validation before processing\n *\n * @param uri - File URI to validate\n * @returns Promise resolving to true if valid video file\n *\n * @example\n * ```typescript\n * const isValid = await TwoStepVideo.validateVideoUri('file:///path/to/video.mp4');\n * if (isValid) {\n * const asset = await TwoStepVideo.loadAsset({ uri });\n * }\n * ```\n */\nexport async function validateVideoUri(uri: string): Promise<boolean> {\n return await ExpoTwoStepVideoModule.validateVideoUri(uri);\n}\n\n/**\n * Trim a video to a single time range\n *\n * @param options - Trim options including asset ID and time range\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * const asset = await TwoStepVideo.loadAsset({ uri });\n * const composition = await TwoStepVideo.trimVideo({\n * assetId: asset.id,\n * startTime: 5.0,\n * endTime: 15.0\n * });\n * console.log(`Trimmed to ${composition.duration}s`);\n * ```\n */\nexport async function trimVideo(options: TrimVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.trimVideo(\n options.assetId,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Trim a video to multiple segments and concatenate them\n *\n * @param options - Trim options including asset ID and segments\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * const composition = await TwoStepVideo.trimVideoMultiple({\n * assetId: asset.id,\n * segments: [\n * { start: 0, end: 3 }, // First 3 seconds\n * { start: 10, end: 13 }, // 3 seconds from middle\n * { start: 20, end: 23 } // 3 seconds from end\n * ]\n * });\n * ```\n */\nexport async function trimVideoMultiple(options: TrimMultipleOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.trimVideoMultiple(options.assetId, options.segments);\n}\n\n/**\n * Mirror a video horizontally, vertically, or both\n *\n * @param options - Mirror options including asset ID and axis\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Mirror horizontally (flip left-right, common for selfie videos)\n * const composition = await TwoStepVideo.mirrorVideo({\n * assetId: asset.id,\n * axis: 'horizontal'\n * });\n *\n * // Mirror a specific segment\n * const composition = await TwoStepVideo.mirrorVideo({\n * assetId: asset.id,\n * axis: 'vertical',\n * startTime: 5,\n * endTime: 10\n * });\n * ```\n */\nexport async function mirrorVideo(options: MirrorVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.mirrorVideo(\n options.assetId,\n options.axis,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Adjust the playback speed of a video\n *\n * @param options - Speed options including asset ID and speed multiplier\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Slow motion (0.5x speed = 2x slower)\n * const slowMo = await TwoStepVideo.adjustSpeed({\n * assetId: asset.id,\n * speed: 0.5\n * });\n *\n * // Fast forward (2x speed)\n * const fastForward = await TwoStepVideo.adjustSpeed({\n * assetId: asset.id,\n * speed: 2.0\n * });\n *\n * // Speed up a specific segment\n * const timelapse = await TwoStepVideo.adjustSpeed({\n * assetId: asset.id,\n * speed: 4.0,\n * startTime: 10,\n * endTime: 30\n * });\n * ```\n */\nexport async function adjustSpeed(options: AdjustSpeedOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.adjustSpeed(\n options.assetId,\n options.speed,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Transform a video with combined trimming, mirroring, and speed adjustment\n * Use the player's `loop` prop for continuous playback looping\n *\n * @param options - Transform options including asset ID, speed, and mirror axis\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Mirror and slow down\n * const transformed = await TwoStepVideo.transformVideo({\n * assetId: asset.id,\n * speed: 0.5,\n * mirrorAxis: 'horizontal'\n * });\n *\n * // Just mirror (speed defaults to 1.0)\n * const mirrored = await TwoStepVideo.transformVideo({\n * assetId: asset.id,\n * mirrorAxis: 'both'\n * });\n *\n * // Transform a specific segment (trim + mirror + speed)\n * const segment = await TwoStepVideo.transformVideo({\n * assetId: asset.id,\n * speed: 2.0,\n * mirrorAxis: 'horizontal',\n * startTime: 0,\n * endTime: 5\n * });\n *\n * // Play in loop mode\n * <TwoStepVideoView compositionId={segment.id} loop />\n * ```\n */\nexport async function transformVideo(options: TransformVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.transformVideo(\n options.assetId,\n options.speed,\n options.mirrorAxis,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Loop a segment of a video multiple times\n *\n * @param options - Loop options including segment time range and repeat count\n * @returns Promise resolving to loop result with duration info\n *\n * @example\n * ```typescript\n * // Loop a 2-second segment 3 times (plays 4 times total)\n * const looped = await TwoStepVideo.loopSegment({\n * assetId: asset.id,\n * startTime: 5,\n * endTime: 7,\n * loopCount: 3\n * });\n * console.log(`Duration: ${looped.duration}s (plays ${looped.totalPlays} times)`);\n *\n * // Create a perfect loop for social media\n * const perfectLoop = await TwoStepVideo.loopSegment({\n * assetId: asset.id,\n * startTime: 0,\n * endTime: 3,\n * loopCount: 4 // 15 seconds total (3s * 5 plays)\n * });\n * ```\n */\nexport async function loopSegment(options: LoopSegmentOptions): Promise<LoopResult> {\n return await ExpoTwoStepVideoModule.loopSegment(\n options.assetId,\n options.startTime,\n options.endTime,\n options.loopCount\n );\n}\n\n// Re-export types from types file\nexport type { PanZoomVideoOptions, PickVideoOptions, PickedVideo, DoubleTapSkipEvent } from './ExpoTwoStepVideo.types';\n\n// MARK: - Media Picker Functions\n\n/**\n * Pick video(s) from the photo library using native PHPickerViewController\n *\n * This provides a native photo library picker that:\n * - Supports albums and favorites\n * - Handles iCloud video downloading automatically (with native progress UI)\n * - Returns comprehensive metadata including creation date\n * - No editing UI - just clean selection\n *\n * @param options - Picker options (optional)\n * @returns Promise resolving to array of picked videos (empty if cancelled)\n *\n * @example\n * ```typescript\n * // Pick a single video\n * const videos = await TwoStepVideo.pickVideo();\n * if (videos.length > 0) {\n * const video = videos[0];\n * console.log(`Selected: ${video.fileName}`);\n * console.log(`Duration: ${video.duration}s`);\n * console.log(`Size: ${video.width}x${video.height}`);\n * console.log(`File size: ${video.fileSize} bytes`);\n * console.log(`Created: ${video.creationDate}`);\n *\n * // Load the video for editing\n * const asset = await TwoStepVideo.loadAsset({ uri: video.uri });\n *\n * // Clean up the temp file when done\n * TwoStepVideo.cleanupPickedVideo(video.path);\n * }\n *\n * // Pick multiple videos\n * const videos = await TwoStepVideo.pickVideo({ selectionLimit: 5 });\n * ```\n */\nexport async function pickVideo(options?: PickVideoOptions): Promise<PickedVideo[]> {\n return await ExpoTwoStepVideoModule.pickVideo(options?.selectionLimit);\n}\n\n/**\n * Clean up a specific picked video file from temp storage\n * Call this when you're done with a video that was picked from the library\n *\n * @param path - File path to clean up (use the `path` property from PickedVideo)\n *\n * @example\n * ```typescript\n * const videos = await TwoStepVideo.pickVideo();\n * const video = videos[0];\n *\n * // ... use the video ...\n *\n * // Clean up when done\n * TwoStepVideo.cleanupPickedVideo(video.path);\n * ```\n */\nexport function cleanupPickedVideo(path: string): void {\n ExpoTwoStepVideoModule.cleanupPickedVideo(path);\n}\n\n/**\n * Clean up all picked video files from temp storage\n * Useful for cleanup when unmounting screens or releasing all resources\n *\n * @example\n * ```typescript\n * // In a cleanup effect\n * useEffect(() => {\n * return () => {\n * TwoStepVideo.cleanupAllPickedVideos();\n * TwoStepVideo.releaseAll();\n * };\n * }, []);\n * ```\n */\nexport function cleanupAllPickedVideos(): void {\n ExpoTwoStepVideoModule.cleanupAllPickedVideos();\n}\n\n/**\n * Get list of currently tracked picked video paths\n * Useful for debugging or manual cleanup\n *\n * @returns Array of file paths for picked videos in temp storage\n */\nexport function getPickedVideoPaths(): string[] {\n return ExpoTwoStepVideoModule.getPickedVideoPaths();\n}\n\n/**\n * Apply pan and zoom transformation to a video\n *\n * This creates a composition with the pan/zoom baked in for export.\n * For real-time preview, use the gesture controls on TwoStepVideoView.\n *\n * @param options - Pan/zoom options including asset ID and transform values\n * @returns Promise resolving to composition metadata\n *\n * @example\n * ```typescript\n * // Get current pan/zoom from player and apply to export\n * const panZoomState = await playerRef.current.getPanZoomState();\n * const composition = await TwoStepVideo.panZoomVideo({\n * assetId: asset.id,\n * panX: panZoomState.panX,\n * panY: panZoomState.panY,\n * zoomLevel: panZoomState.zoomLevel\n * });\n *\n * // Export the pan/zoomed video\n * const result = await TwoStepVideo.exportVideo({\n * compositionId: composition.id,\n * quality: 'high'\n * });\n *\n * // Apply zoom only (no pan, centered)\n * const zoomed = await TwoStepVideo.panZoomVideo({\n * assetId: asset.id,\n * zoomLevel: 2.0 // 2x zoom, centered\n * });\n * ```\n */\nexport async function panZoomVideo(options: PanZoomVideoOptions): Promise<VideoComposition> {\n return await ExpoTwoStepVideoModule.panZoomVideo(\n options.assetId,\n options.panX ?? 0,\n options.panY ?? 0,\n options.zoomLevel ?? 1.0,\n options.startTime,\n options.endTime\n );\n}\n\n/**\n * Generate thumbnail images from a video at specific times\n *\n * @param options - Thumbnail generation options\n * @returns Promise resolving to array of base64 encoded PNG images\n *\n * @example\n * ```typescript\n * const thumbnails = await TwoStepVideo.generateThumbnails({\n * assetId: asset.id,\n * times: [1, 5, 10, 15],\n * size: { width: 300, height: 300 }\n * });\n *\n * // Use in Image component\n * <Image source={{ uri: `data:image/png;base64,${thumbnails[0]}` }} />\n * ```\n */\nexport async function generateThumbnails(\n options: GenerateThumbnailsOptions\n): Promise<string[]> {\n return await ExpoTwoStepVideoModule.generateThumbnails(\n options.assetId,\n options.times,\n options.size\n );\n}\n\n/**\n * Export a composition to a video file\n *\n * @param options - Export options including composition ID and quality\n * @returns Promise resolving to export result with file URI\n *\n * @example\n * ```typescript\n * const result = await TwoStepVideo.exportVideo({\n * compositionId: composition.id,\n * quality: 'high'\n * });\n * console.log(`Exported to: ${result.uri}`);\n * ```\n */\nexport async function exportVideo(options: ExportCompositionOptions): Promise<ExportResult> {\n return await ExpoTwoStepVideoModule.exportVideo(\n options.compositionId,\n options.outputUri,\n options.quality\n );\n}\n\n/**\n * Export an asset directly without creating a composition\n * Useful for re-encoding or changing quality without trimming\n *\n * @param options - Export options including asset ID and quality\n * @returns Promise resolving to export result with file URI\n *\n * @example\n * ```typescript\n * const result = await TwoStepVideo.exportAsset({\n * assetId: asset.id,\n * quality: 'medium'\n * });\n * ```\n */\nexport async function exportAsset(options: ExportAssetOptions): Promise<ExportResult> {\n return await ExpoTwoStepVideoModule.exportAsset(\n options.assetId,\n options.outputUri,\n options.quality\n );\n}\n\n/**\n * Clean up a temporary file\n * Call this after you're done with exported files in the temp directory\n *\n * @param uri - File URI to clean up\n *\n * @example\n * ```typescript\n * const result = await TwoStepVideo.exportVideo({ ... });\n * // ... do something with result.uri\n * TwoStepVideo.cleanupFile(result.uri);\n * ```\n */\nexport function cleanupFile(uri: string): void {\n ExpoTwoStepVideoModule.cleanupFile(uri);\n}\n\n/**\n * Release an asset from memory\n * Call this when you're done with an asset to free up memory\n *\n * @param assetId - ID of the asset to release\n */\nexport function releaseAsset(assetId: string): void {\n ExpoTwoStepVideoModule.releaseAsset(assetId);\n}\n\n/**\n * Release a composition from memory\n *\n * @param compositionId - ID of the composition to release\n */\nexport function releaseComposition(compositionId: string): void {\n ExpoTwoStepVideoModule.releaseComposition(compositionId);\n}\n\n/**\n * Release all cached assets and compositions\n * Useful for cleanup when unmounting screens\n */\nexport function releaseAll(): void {\n ExpoTwoStepVideoModule.releaseAll();\n}\n\n// MARK: - Event Listeners\n\n/**\n * Add a listener for export progress events\n *\n * @param listener - Callback function receiving progress events\n * @returns Subscription object with remove() method\n *\n * @example\n * ```typescript\n * const subscription = TwoStepVideo.addExportProgressListener((event) => {\n * console.log(`Export progress: ${Math.round(event.progress * 100)}%`);\n * setProgress(event.progress);\n * });\n *\n * // Later, remove the listener\n * subscription.remove();\n * ```\n */\nexport function addExportProgressListener(\n listener: (event: ExportProgressEvent) => void\n): EventSubscription {\n return ExpoTwoStepVideoModule.addListener('onExportProgress', listener);\n}\n\n// MARK: - Helper Hook (for React)\n\n/**\n * React hook for managing export progress\n * Only works in React components\n *\n * @returns Current export progress (0.0 to 1.0)\n *\n * @example\n * ```typescript\n * function VideoEditor() {\n * const progress = useExportProgress();\n *\n * return (\n * <View>\n * <Text>Export Progress: {Math.round(progress * 100)}%</Text>\n * </View>\n * );\n * }\n * ```\n */\nexport function useExportProgress(): number {\n const [progress, setProgress] = useState(0);\n\n useEffect(() => {\n const subscription = addExportProgressListener((event) => {\n setProgress(event.progress);\n });\n\n return () => subscription.remove();\n }, []);\n\n return progress;\n}\n\n// MARK: - Exports\n\nexport default {\n // Constants\n Quality,\n Mirror,\n\n // Media picker\n pickVideo,\n cleanupPickedVideo,\n cleanupAllPickedVideos,\n getPickedVideoPaths,\n\n // Core functions\n loadAsset,\n loadAssetFromPhotos,\n validateVideoUri,\n trimVideo,\n trimVideoMultiple,\n generateThumbnails,\n exportVideo,\n exportAsset,\n\n // Transformation functions\n mirrorVideo,\n adjustSpeed,\n transformVideo,\n loopSegment,\n panZoomVideo,\n\n // Memory management\n cleanupFile,\n releaseAsset,\n releaseComposition,\n releaseAll,\n\n // Events\n addExportProgressListener,\n\n // React hook\n useExportProgress,\n};\n\n// MARK: - View Component Export\n\nexport { default as TwoStepVideoView } from './ExpoTwoStepVideoView';\nexport { default as TwoStepPlayerControllerView } from './TwoStepPlayerControllerView';\nexport { default as VideoScrubber } from './VideoScrubber';\nexport { useVideoScrubber } from './hooks/useVideoScrubber';\nexport type {\n BaseVideoViewRef,\n TwoStepVideoViewProps,\n TwoStepVideoViewRef,\n TwoStepPlayerControllerViewProps,\n TwoStepPlayerControllerViewRef,\n PlaybackStatusEvent,\n ProgressEvent,\n ErrorEvent,\n PanZoomState,\n PanZoomChangeEvent,\n} from './ExpoTwoStepVideo.types';\nexport type {\n VideoScrubberProps,\n VideoScrubberRef,\n VideoScrubberTheme,\n} from './VideoScrubber.types';\n"]}
@@ -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