@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.
@@ -0,0 +1,355 @@
1
+ import Foundation
2
+ import PhotosUI
3
+ import AVFoundation
4
+ import UIKit
5
+
6
+ /// Result from picking a video from the photo library
7
+ public struct PickedVideo {
8
+ /// File URI (file:// path to temp copy)
9
+ public let uri: String
10
+ /// Absolute file path
11
+ public let path: String
12
+ /// Original filename
13
+ public let fileName: String
14
+ /// Video width in pixels
15
+ public let width: Int
16
+ /// Video height in pixels
17
+ public let height: Int
18
+ /// Duration in seconds
19
+ public let duration: Double
20
+ /// File size in bytes
21
+ public let fileSize: Int64
22
+ /// Date the video was originally created/captured (ISO 8601 string)
23
+ public let creationDate: String?
24
+ /// Date the video was last modified (ISO 8601 string)
25
+ public let modificationDate: String?
26
+ /// PHAsset local identifier (for use with loadAssetFromPhotos)
27
+ public let assetIdentifier: String?
28
+ /// Media type (always "video" for this picker)
29
+ public let type: String = "video"
30
+ }
31
+
32
+ /// Options for the video picker
33
+ public struct PickerOptions {
34
+ /// Maximum number of videos to select (default 1)
35
+ public let selectionLimit: Int
36
+
37
+ public init(selectionLimit: Int = 1) {
38
+ self.selectionLimit = selectionLimit
39
+ }
40
+ }
41
+
42
+ /// Native media picker using PHPickerViewController
43
+ public class MediaPicker: NSObject {
44
+
45
+ // MARK: - Properties
46
+
47
+ /// Shared instance for convenience
48
+ public static let shared = MediaPicker()
49
+
50
+ /// Serial queue for thread-safe access to mutable state
51
+ private let stateQueue = DispatchQueue(label: "com.twostepvideo.mediapicker.state")
52
+
53
+ /// Directory for storing picked video copies
54
+ private var pickedVideosDirectory: URL {
55
+ let tempDir = FileManager.default.temporaryDirectory
56
+ let pickedDir = tempDir.appendingPathComponent("TwoStepPickedVideos", isDirectory: true)
57
+ // Ensure directory exists (may have been deleted by cleanup)
58
+ if !FileManager.default.fileExists(atPath: pickedDir.path) {
59
+ try? FileManager.default.createDirectory(at: pickedDir, withIntermediateDirectories: true, attributes: nil)
60
+ }
61
+ return pickedDir
62
+ }
63
+
64
+ /// Track picked video paths for cleanup (access via stateQueue)
65
+ private var _pickedVideoPaths: Set<String> = []
66
+
67
+ /// Completion handler for current pick operation (access via stateQueue)
68
+ private var _pickCompletion: ((Result<[PickedVideo], Error>) -> Void)?
69
+
70
+ // MARK: - Public Methods
71
+
72
+ /// Pick video(s) from the photo library
73
+ /// - Parameters:
74
+ /// - options: Picker options
75
+ /// - completion: Completion handler with picked videos or error
76
+ public func pickVideo(
77
+ options: PickerOptions = PickerOptions(),
78
+ completion: @escaping (Result<[PickedVideo], Error>) -> Void
79
+ ) {
80
+ // Store completion for delegate callback (thread-safe)
81
+ stateQueue.sync { self._pickCompletion = completion }
82
+
83
+ // Request photo library permission first (for metadata access like creationDate)
84
+ // This provides the natural permission flow users expect
85
+ requestPhotoLibraryPermissionIfNeeded { [weak self] granted in
86
+ guard let self = self else { return }
87
+
88
+ // We proceed even if not granted - picker still works, just without some metadata
89
+ // But the permission dialog will have been shown if needed
90
+
91
+ // Everything must happen on main thread for PHPickerViewController
92
+ DispatchQueue.main.async {
93
+ // Configure picker
94
+ var config = PHPickerConfiguration(photoLibrary: .shared())
95
+ config.filter = .videos
96
+ config.selectionLimit = options.selectionLimit
97
+ config.preferredAssetRepresentationMode = .current // Preserves original quality
98
+
99
+ let picker = PHPickerViewController(configuration: config)
100
+ picker.delegate = self
101
+
102
+ guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
103
+ let rootVC = windowScene.windows.first?.rootViewController else {
104
+ completion(.failure(MediaPickerError.noRootViewController))
105
+ return
106
+ }
107
+
108
+ // Find the top-most presented controller
109
+ var topVC = rootVC
110
+ while let presented = topVC.presentedViewController {
111
+ topVC = presented
112
+ }
113
+
114
+ topVC.present(picker, animated: true)
115
+ }
116
+ }
117
+ }
118
+
119
+ /// Request photo library permission if not already determined
120
+ /// This triggers the native permission dialog if needed
121
+ private func requestPhotoLibraryPermissionIfNeeded(completion: @escaping (Bool) -> Void) {
122
+ let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
123
+
124
+ switch status {
125
+ case .notDetermined:
126
+ // Request permission - this shows the native dialog
127
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in
128
+ let granted = newStatus == .authorized || newStatus == .limited
129
+ completion(granted)
130
+ }
131
+ case .authorized, .limited:
132
+ completion(true)
133
+ case .denied, .restricted:
134
+ completion(false)
135
+ @unknown default:
136
+ completion(false)
137
+ }
138
+ }
139
+
140
+ /// Clean up a specific picked video file
141
+ /// - Parameter path: File path to clean up
142
+ public func cleanupPickedVideo(at path: String) {
143
+ let url = URL(fileURLWithPath: path)
144
+ try? FileManager.default.removeItem(at: url)
145
+ stateQueue.sync { _pickedVideoPaths.remove(path) }
146
+ }
147
+
148
+ /// Clean up all picked video files
149
+ public func cleanupAllPickedVideos() {
150
+ let paths = stateQueue.sync { Array(_pickedVideoPaths) }
151
+ for path in paths {
152
+ try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
153
+ }
154
+ stateQueue.sync { _pickedVideoPaths.removeAll() }
155
+
156
+ // Also clean up the entire directory to catch any orphaned files
157
+ try? FileManager.default.removeItem(at: pickedVideosDirectory)
158
+ try? FileManager.default.createDirectory(at: pickedVideosDirectory, withIntermediateDirectories: true, attributes: nil)
159
+ }
160
+
161
+ /// Get list of currently tracked picked video paths
162
+ public var trackedPickedVideoPaths: [String] {
163
+ stateQueue.sync { Array(_pickedVideoPaths) }
164
+ }
165
+
166
+ // MARK: - Private Methods
167
+
168
+ /// Process a picked PHPickerResult
169
+ private func processPickerResult(_ result: PHPickerResult) async throws -> PickedVideo {
170
+ // Get the asset identifier for PHAsset access
171
+ let assetIdentifier = result.assetIdentifier
172
+
173
+ // Get PHAsset metadata if available
174
+ var creationDate: Date?
175
+ var modificationDate: Date?
176
+
177
+ if let identifier = assetIdentifier {
178
+ let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil)
179
+ if let phAsset = fetchResult.firstObject {
180
+ creationDate = phAsset.creationDate
181
+ modificationDate = phAsset.modificationDate
182
+ }
183
+ }
184
+
185
+ // Load the video file
186
+ let itemProvider = result.itemProvider
187
+
188
+ guard itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) else {
189
+ throw MediaPickerError.invalidMediaType
190
+ }
191
+
192
+ // Load and copy the file (must copy inside completion handler as temp file is deleted after)
193
+ let (destURL, fileName) = try await loadAndCopyVideo(from: itemProvider)
194
+
195
+ // Track the path for cleanup (thread-safe)
196
+ stateQueue.sync { _pickedVideoPaths.insert(destURL.path) }
197
+
198
+ // Get video metadata
199
+ let asset = AVURLAsset(url: destURL)
200
+ let duration = try await asset.load(.duration)
201
+ let tracks = try await asset.load(.tracks)
202
+
203
+ var width: Int = 0
204
+ var height: Int = 0
205
+
206
+ if let videoTrack = tracks.first(where: { $0.mediaType == .video }) {
207
+ let size = try await videoTrack.load(.naturalSize)
208
+ let transform = try await videoTrack.load(.preferredTransform)
209
+
210
+ // Apply transform to get actual display size
211
+ let transformedSize = size.applying(transform)
212
+ width = Int(abs(transformedSize.width))
213
+ height = Int(abs(transformedSize.height))
214
+ }
215
+
216
+ // Get file size
217
+ let fileAttributes = try FileManager.default.attributesOfItem(atPath: destURL.path)
218
+ let fileSize = (fileAttributes[.size] as? Int64) ?? 0
219
+
220
+ // Format dates as ISO 8601
221
+ let dateFormatter = ISO8601DateFormatter()
222
+ dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
223
+
224
+ return PickedVideo(
225
+ uri: destURL.absoluteString,
226
+ path: destURL.path,
227
+ fileName: fileName,
228
+ width: width,
229
+ height: height,
230
+ duration: CMTimeGetSeconds(duration),
231
+ fileSize: fileSize,
232
+ creationDate: creationDate.map { dateFormatter.string(from: $0) },
233
+ modificationDate: modificationDate.map { dateFormatter.string(from: $0) },
234
+ assetIdentifier: assetIdentifier
235
+ )
236
+ }
237
+
238
+ /// Load video from item provider and copy to our managed directory
239
+ /// The copy MUST happen inside the completion handler as the temp file is deleted immediately after
240
+ private func loadAndCopyVideo(from itemProvider: NSItemProvider) async throws -> (URL, String) {
241
+ try await withCheckedThrowingContinuation { continuation in
242
+ itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] tempURL, error in
243
+ guard let self = self else {
244
+ continuation.resume(throwing: MediaPickerError.failedToLoadVideo)
245
+ return
246
+ }
247
+
248
+ if let error = error {
249
+ continuation.resume(throwing: error)
250
+ return
251
+ }
252
+
253
+ guard let tempURL = tempURL else {
254
+ continuation.resume(throwing: MediaPickerError.failedToLoadVideo)
255
+ return
256
+ }
257
+
258
+ do {
259
+ // Generate unique filename - use suggested name or create one
260
+ let suggestedName = itemProvider.suggestedName
261
+ let fileExtension = tempURL.pathExtension.isEmpty ? "mov" : tempURL.pathExtension
262
+ let fileName: String
263
+ if let suggested = suggestedName, !suggested.isEmpty {
264
+ // Ensure it has the right extension
265
+ if suggested.lowercased().hasSuffix(".\(fileExtension.lowercased())") {
266
+ fileName = suggested
267
+ } else {
268
+ fileName = "\(suggested).\(fileExtension)"
269
+ }
270
+ } else {
271
+ fileName = "video_\(UUID().uuidString).\(fileExtension)"
272
+ }
273
+
274
+ let destURL = self.pickedVideosDirectory.appendingPathComponent(fileName)
275
+
276
+ // Remove existing file at destination if any
277
+ if FileManager.default.fileExists(atPath: destURL.path) {
278
+ try FileManager.default.removeItem(at: destURL)
279
+ }
280
+
281
+ // Copy IMMEDIATELY while temp file still exists
282
+ try FileManager.default.copyItem(at: tempURL, to: destURL)
283
+
284
+ continuation.resume(returning: (destURL, fileName))
285
+ } catch {
286
+ continuation.resume(throwing: error)
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // MARK: - PHPickerViewControllerDelegate
294
+
295
+ extension MediaPicker: PHPickerViewControllerDelegate {
296
+
297
+ public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
298
+ // Dismiss picker first
299
+ picker.dismiss(animated: true)
300
+
301
+ // Get completion handler (thread-safe)
302
+ let completion = stateQueue.sync { _pickCompletion }
303
+
304
+ // Handle cancellation
305
+ guard !results.isEmpty else {
306
+ completion?(.success([]))
307
+ stateQueue.sync { _pickCompletion = nil }
308
+ return
309
+ }
310
+
311
+ // Process results asynchronously
312
+ Task {
313
+ do {
314
+ var pickedVideos: [PickedVideo] = []
315
+
316
+ for result in results {
317
+ let pickedVideo = try await processPickerResult(result)
318
+ pickedVideos.append(pickedVideo)
319
+ }
320
+
321
+ await MainActor.run {
322
+ completion?(.success(pickedVideos))
323
+ self.stateQueue.sync { self._pickCompletion = nil }
324
+ }
325
+ } catch {
326
+ await MainActor.run {
327
+ completion?(.failure(error))
328
+ self.stateQueue.sync { self._pickCompletion = nil }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ // MARK: - Errors
336
+
337
+ public enum MediaPickerError: LocalizedError {
338
+ case noRootViewController
339
+ case invalidMediaType
340
+ case failedToLoadVideo
341
+ case cancelled
342
+
343
+ public var errorDescription: String? {
344
+ switch self {
345
+ case .noRootViewController:
346
+ return "Could not find root view controller to present picker"
347
+ case .invalidMediaType:
348
+ return "Selected item is not a video"
349
+ case .failedToLoadVideo:
350
+ return "Failed to load video file"
351
+ case .cancelled:
352
+ return "User cancelled video selection"
353
+ }
354
+ }
355
+ }
@@ -20,11 +20,15 @@ public class TwoStepVideo {
20
20
  /// Video exporter instance
21
21
  public let videoExporter: VideoExporter
22
22
 
23
+ /// Media picker instance for selecting videos from photo library
24
+ public let mediaPicker: MediaPicker
25
+
23
26
  /// Initialize a new instance
24
27
  public init() {
25
28
  self.assetLoader = AssetLoader()
26
29
  self.videoTrimmer = VideoTrimmer()
27
30
  self.videoTransformer = VideoTransformer()
28
31
  self.videoExporter = VideoExporter()
32
+ self.mediaPicker = MediaPicker()
29
33
  }
30
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@movementinfra/expo-twostep-video",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Minimal video editing for React Native using AVFoundation",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",