@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.
- package/README.md +80 -7
- package/build/ExpoTwoStepVideo.types.d.ts +55 -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 +112 -2
- package/build/ExpoTwoStepVideoView.js.map +1 -1
- package/build/components/DoubleTapSkip.d.ts +9 -0
- package/build/components/DoubleTapSkip.d.ts.map +1 -0
- package/build/components/DoubleTapSkip.js +139 -0
- package/build/components/DoubleTapSkip.js.map +1 -0
- package/build/components/PlayheadBar.d.ts +10 -0
- package/build/components/PlayheadBar.d.ts.map +1 -0
- package/build/components/PlayheadBar.js +156 -0
- package/build/components/PlayheadBar.js.map +1 -0
- 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
|
@@ -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
|
}
|