rns-mediapicker 0.1.4 → 3.0.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # 🎵 / 🖼️ / 🎥 rns-mediapicker
2
2
 
3
3
  A **high-performance native media picker** for **React Native** and **Expo**.
4
- Supports **audio, image, and video** selection from **camera or library**, automatic JPEG compression, EXIF-safe dimensions, and transparent-image flattening.
4
+ Supports **audio, image, video, `.adp` project files** from **camera or library**, automatic JPEG compression, EXIF-safe dimensions, and iterative quality targeting.
5
5
 
6
6
  ---
7
7
 
@@ -29,6 +29,7 @@ Add the plugin to your `app.json` or `app.config.js`.
29
29
  This automatically configures:
30
30
 
31
31
  - iOS permissions (`Info.plist`)
32
+ - iOS `.adp` custom UTType declaration
32
33
  - Android intent queries
33
34
  - Android `FileProvider`
34
35
 
@@ -44,87 +45,201 @@ This automatically configures:
44
45
 
45
46
  ## 🧑‍💻 Usage
46
47
 
48
+ ### Pick from library (image or video)
49
+
47
50
  ```js
48
51
  import MediaPicker from 'rns-mediapicker';
49
52
 
50
- const pickMedia = async () => {
51
- try {
52
- const result = await MediaPicker.pick(
53
- false, // useCamera: true = camera, false = picker
54
- 'both', // mediaType: 'audio' | 'image' | 'video' | 'both'
55
- 'back' // env: 'front' | 'back' (camera only)
56
- );
57
-
58
- console.log('URI:', result.uri);
59
- console.log('Type:', result.type);
60
- console.log('Width:', result.width);
61
- console.log('Height:', result.height);
62
- console.log('Is Video:', result.isVideo);
63
- console.log('Is Audio:', result.isAudio);
64
- } catch (error) {
65
- if (error.code === 'E_CANCELLED') {
66
- console.log('User cancelled picker');
67
- } else {
53
+ const result = await MediaPicker.pick(
54
+ false, // useCamera
55
+ 'both', // mediaType
56
+ 'back', // env
57
+ 0 // targetKB 0 means no compression
58
+ );
59
+
60
+ console.log(result.uri); // file:///...
61
+ console.log(result.type); // 'image' | 'video' | 'audio' | 'adp'
62
+ console.log(result.width); // number (0 for audio/adp)
63
+ console.log(result.height); // number (0 for audio/adp)
64
+ console.log(result.size); // KB (images only — field is absent for video/audio/adp)
65
+ console.log(result.isVideo);
66
+ console.log(result.isAudio);
67
+ ```
68
+
69
+ ---
70
+
71
+ ### Open camera
72
+
73
+ ```js
74
+ // Photo with back camera
75
+ const result = await MediaPicker.pick(true, 'image', 'back', 0);
76
+
77
+ // Video with front camera
78
+ const result = await MediaPicker.pick(true, 'video', 'front', 0);
79
+ ```
80
+
81
+ ---
82
+
83
+ ### Pick audio
84
+
85
+ ```js
86
+ const result = await MediaPicker.pick(false, 'audio', 'back', 0);
87
+
88
+ console.log(result.uri); // file:///...
89
+ console.log(result.type); // 'audio'
90
+ console.log(result.isAudio); // true
91
+ ```
92
+
93
+ ---
94
+
95
+ ### Pick with image compression (`targetKB`)
96
+
97
+ Compresses the image iteratively until it fits within the target size.
98
+ Quality steps down by 10% per iteration (from 100% → minimum 10%).
99
+
100
+ ```js
101
+ // Pick an image and compress to max 200KB
102
+ const result = await MediaPicker.pick(false, 'image', 'back', 200);
103
+
104
+ console.log(result.size); // Actual size in KB after compression
105
+ console.log(result.uri); // Compressed JPEG file URI
106
+ ```
107
+
108
+ > `targetKB = 0` disables compression and returns the full-quality image.
109
+
110
+ ---
111
+
112
+ ### Pick a `.adp` project file
113
+
114
+ `.adp` is a custom Audipella project file format. This opens the file browser
115
+ filtered to `.adp` files only (iOS) or a general file browser (Android).
116
+
117
+ ```js
118
+ const result = await MediaPicker.pick(false, 'adp', 'back', 0);
119
+
120
+ console.log(result.uri); // file:///...path/to/file.adp
121
+ console.log(result.type); // 'adp'
122
+ ```
123
+
124
+ ---
125
+
126
+ ### Error handling
127
+
128
+ ```js
129
+ try {
130
+ const result = await MediaPicker.pick(false, 'image', 'back', 0);
131
+ } catch (error) {
132
+ switch (error.code) {
133
+ case 'E_CANCELLED':
134
+ console.log('User cancelled');
135
+ break;
136
+ case 'E_PERMISSION_DENIED':
137
+ console.log('Permission denied');
138
+ break;
139
+ case 'E_CAMERA_UNAVAILABLE':
140
+ console.log('Camera not available on this device');
141
+ break;
142
+ case 'E_PROCESS':
143
+ console.log('Failed to process media:', error.message);
144
+ break;
145
+ default:
68
146
  console.error('Picker error:', error.message);
69
- }
70
147
  }
71
- };
148
+ }
72
149
  ```
73
150
 
74
151
  ---
75
152
 
76
153
  ## 🧩 API Reference
77
154
 
78
- ### `pick(useCamera, mediaType, env)`
155
+ ### `pick(useCamera, mediaType, env, targetKB)`
79
156
 
80
- | Parameter | Type | Description |
81
- |-----------|----------|-------------|
82
- | useCamera | boolean | `true` opens the camera, `false` opens the media picker |
83
- | mediaType | string | `'audio'`, `'image'`, `'video'`, or `'both'` |
84
- | env | string | `'front'` or `'back'` camera (camera mode only) |
157
+ | Parameter | Type | Default | Description |
158
+ |-------------|----------|------------|-------------|
159
+ | `useCamera` | boolean | — | `true` opens the camera, `false` opens the file/media picker |
160
+ | `mediaType` | string | — | `'image'`, `'video'`, `'both'`, `'audio'`, or `'adp'` |
161
+ | `env` | string | `'back'` | `'front'` or `'back'` camera only, best-effort on Android |
162
+ | `targetKB` | number | `0` | Max image size in KB. `0` = no compression. Images only |
163
+
164
+ All four parameters must be passed. Use `0` for `targetKB` to disable compression, and `'back'` for `env` when camera direction is not relevant.
85
165
 
86
166
  ---
87
167
 
88
168
  ## 📦 Response Object
89
169
 
90
- ```js
170
+ ```ts
91
171
  {
92
- uri: string; // Local file URI
93
- width: number; // Media width (0 for audio)
94
- height: number; // Media height (0 for audio)
95
- type: string; // 'image' | 'video' | 'audio'
96
- isVideo: boolean; // Helper flag
97
- isAudio: boolean; // Helper flag
172
+ uri: string; // Local file URI (file://...)
173
+ width: number; // Width in pixels (0 for audio and adp)
174
+ height: number; // Height in pixels (0 for audio and adp)
175
+ size?: number; // File size in KB — present for images only, absent for video/audio/adp
176
+ type: 'image' | 'video' | 'audio' | 'adp';
177
+ isVideo: boolean;
178
+ isAudio: boolean;
98
179
  }
99
180
  ```
100
181
 
101
182
  ---
102
183
 
184
+ ## 🗂️ Media Type Reference
185
+
186
+ | `mediaType` | Opens | Returns |
187
+ |-------------|-------|---------|
188
+ | `'image'` | Photo library / camera | `type: 'image'` |
189
+ | `'video'` | Video library / camera | `type: 'video'` |
190
+ | `'both'` | Photo + video library / camera | `type: 'image'` or `'video'` |
191
+ | `'audio'` | File browser (audio files) | `type: 'audio'` |
192
+ | `'adp'` | File browser (`.adp` files on iOS; all files on Android) | `type: 'adp'` |
193
+
194
+ ---
195
+
196
+ ## 🚨 Error Codes
197
+
198
+ | Code | Description |
199
+ |------|-------------|
200
+ | `E_CANCELLED` | User dismissed the picker or cancelled |
201
+ | `E_PERMISSION_DENIED` | Camera or storage permission denied |
202
+ | `E_NOT_PERMISSION_AWARE` | Android activity does not implement `PermissionAwareActivity` |
203
+ | `E_CAMERA_UNAVAILABLE` | Device has no camera (iOS) |
204
+ | `E_NO_ACTIVITY` | Android activity not found |
205
+ | `E_NO_URI` | No media URI returned from picker |
206
+ | `E_PROCESS` | Failed to decode, compress, or copy the file |
207
+ | `E_AUDIO_COPY` | Failed to copy audio/adp file to temp location |
208
+ | `E_VIDEO_COPY` | Failed to copy video file to temp location |
209
+ | `E_IMAGE_WRITE` | Failed to encode or write image to disk |
210
+ | `E_LOAD` | Failed to load media from PHPicker (iOS) |
211
+
212
+ ---
213
+
103
214
  ## ✨ Features
104
215
 
105
- - 🎵 Audio / 🖼️ Image / 🎥 Video support
106
- - 📷 Camera and 📁 Library selection
107
- - 🎬 Camera video capture (Android & iOS)
108
- - 🗜️ Automatic JPEG compression
109
- - White-background flattening for transparent images
110
- - 🔄 EXIF-safe rotation and accurate dimensions
111
- - 📂 Scoped-storage safe temporary files
112
- - 🤖 Android 11+ intent queries handled automatically
113
- - 🔐 Android `FileProvider` preconfigured
114
- - 🍎 iOS swipe-to-dismiss cancellation handling
216
+ - 🖼️ Image / 🎥 Video / 🎵 Audio / 📁 `.adp` project file support
217
+ - 📷 Camera and 📁 Library selection
218
+ - 🎬 Camera video capture (Android & iOS)
219
+ - 🗜️ Iterative JPEG compression with `targetKB` target
220
+ - 🔄 EXIF-safe rotation and accurate dimensions
221
+ - 📂 Scoped-storage safe temporary files
222
+ - 🤖 Android 11+ intent queries handled automatically
223
+ - 🔐 Android `FileProvider` preconfigured
224
+ - 🍎 iOS swipe-to-dismiss cancellation handling
225
+ - 🍎 iOS `.adp` UTType auto-registered via Expo plugin
115
226
  - 🧹 Clean promise lifecycle (no leaks)
116
227
 
117
228
  ---
118
229
 
119
230
  ## 📝 Notes
120
231
 
121
- - All returned files are copied into the apps **temporary cache directory**
122
- - Audio files preserve original encoding
232
+ - All returned files are copied into the app's **temporary cache directory**
233
+ - Audio files preserve their original encoding and extension
234
+ - `.adp` files are copied as-is — no processing applied
235
+ - `targetKB` compression only applies to **images**, not video or audio
236
+ - The `size` field is only present in the response for images; it is absent (not `0`) for video, audio, and adp results
123
237
  - Front camera selection is **best-effort** on Android
238
+ - On Android, `.adp` picker opens a general file browser since Android has no built-in MIME type for `.adp`; the file is identified by its extension after selection
124
239
  - No external native dependencies required
125
240
 
126
241
  ---
127
242
 
128
243
  ## 📄 License
129
244
 
130
- MIT
245
+ MIT
@@ -8,6 +8,7 @@ import android.media.ExifInterface
8
8
  import android.media.MediaMetadataRetriever
9
9
  import android.net.Uri
10
10
  import android.os.Build
11
+ import android.provider.OpenableColumns
11
12
  import android.provider.MediaStore
12
13
  import android.webkit.MimeTypeMap
13
14
  import androidx.core.content.FileProvider
@@ -30,11 +31,12 @@ class FastMediaPickerModule(
30
31
  private var pendingFrontCamera = false
31
32
  private var pendingUseCamera = false
32
33
  private var pendingMediaType: String? = null
34
+ private var pendingTargetKB: Int = 0 // ← new
35
+
33
36
 
34
37
  companion object {
35
38
  private const val PICKER_REQUEST_CODE = 4123
36
39
  private const val PERMISSION_REQUEST = 9123
37
- private const val JPEG_QUALITY = 85
38
40
  }
39
41
 
40
42
  init {
@@ -52,10 +54,11 @@ class FastMediaPickerModule(
52
54
  useCamera: Boolean,
53
55
  mediaType: String,
54
56
  env: String?,
57
+ targetKB: Int, // ← matches iOS pick:mediaType:env:targetKB:resolver:rejecter:
55
58
  promise: Promise,
56
59
  ) {
57
60
  val activity =
58
- currentActivity ?: run {
61
+ reactApplicationContext.currentActivity ?: run {
59
62
  promise.reject("E_NO_ACTIVITY", "Activity not found")
60
63
  return
61
64
  }
@@ -65,6 +68,8 @@ class FastMediaPickerModule(
65
68
  pendingUseCamera = useCamera
66
69
  pendingMediaType = mediaType.lowercase()
67
70
  pendingFrontCamera = env?.lowercase() == "front"
71
+ pendingTargetKB = targetKB // ← new
72
+
68
73
 
69
74
  val permissions = mutableListOf<String>()
70
75
  if (useCamera) {
@@ -104,6 +109,19 @@ class FastMediaPickerModule(
104
109
  activity: Activity,
105
110
  type: String,
106
111
  ) {
112
+
113
+ // 1a. ADP: Custom project file — use */* so the file manager shows all files,
114
+ // Android has no built-in MIME type for .adp so we can't filter more tightly here.
115
+ if (type == "adp") {
116
+ val intent =
117
+ Intent(Intent.ACTION_GET_CONTENT).apply {
118
+ addCategory(Intent.CATEGORY_OPENABLE)
119
+ this.type = "*/*"
120
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
121
+ }
122
+ activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
123
+ return
124
+ }
107
125
  // 1. AUDIO: Use GET_CONTENT but without the chooser for faster return
108
126
  if (type == "audio") {
109
127
  val intent =
@@ -186,7 +204,7 @@ class FastMediaPickerModule(
186
204
  }
187
205
 
188
206
  override fun onActivityResult(
189
- activity: Activity?,
207
+ activity: Activity,
190
208
  requestCode: Int,
191
209
  resultCode: Int,
192
210
  data: Intent?,
@@ -215,54 +233,87 @@ class FastMediaPickerModule(
215
233
  resultCode: Int,
216
234
  data: Intent?,
217
235
  ) {
218
- onActivityResult(currentActivity, requestCode, resultCode, data)
236
+ reactApplicationContext.currentActivity?.let { activity ->
237
+ onActivityResult(activity, requestCode, resultCode, data)
238
+ }
219
239
  }
220
240
 
221
241
  private fun processMedia(uri: Uri) {
222
- val resolver = reactContext.contentResolver
223
- val mime = resolver.getType(uri) ?: "image/jpeg"
224
-
225
- try {
242
+ // Mirrors iOS: heavy processing (decode, compress, copy) happens off the main thread.
243
+ // Only the promise resolve/reject lands back via the RN bridge.
244
+ Thread {
245
+ val resolver = reactContext.contentResolver
246
+ val mime = resolver.getType(uri) ?: "image/jpeg"
247
+ val displayName = getDisplayName(uri)
248
+
249
+ try {
250
+
251
+ val isAdpFile = uri.toString().endsWith(".adp", ignoreCase = true) ||
252
+ mime.equals("application/adp", ignoreCase = true) ||
253
+ displayName.endsWith(".adp", ignoreCase = true)
254
+
226
255
  when {
227
- mime.startsWith("audio") -> {
228
- val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "mp3"
229
- val file = createTempFile(ext)
230
- resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
231
- resolveResult(Uri.fromFile(file).toString(), false, true, 0, 0)
232
- }
233
-
234
- mime.startsWith("video") -> {
235
- val file = createTempFile("mp4")
256
+ isAdpFile -> {
257
+ val file = createTempFile("adp")
236
258
  resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
237
-
238
- val retriever = MediaMetadataRetriever()
239
- retriever.setDataSource(reactContext, uri)
240
- val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
241
- val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
242
- retriever.release()
243
-
244
- resolveResult(Uri.fromFile(file).toString(), true, false, w, h)
259
+ resolveResult(Uri.fromFile(file).toString(), false, false, 0, 0, typeOverride = "adp")
245
260
  }
246
261
 
247
- else -> {
248
- val file = createTempFile("jpg")
249
- val bitmap = BitmapFactory.decodeStream(resolver.openInputStream(uri))
250
- val rotated = rotateIfNeeded(bitmap, uri)
251
- rotated.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, FileOutputStream(file))
252
- resolveResult(
253
- Uri.fromFile(file).toString(),
254
- false,
255
- false,
256
- rotated.width,
257
- rotated.height,
258
- )
262
+ mime.startsWith("audio") -> {
263
+ val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "mp3"
264
+ val file = createTempFile(ext)
265
+ resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
266
+ resolveResult(Uri.fromFile(file).toString(), false, true, 0, 0)
267
+ }
268
+
269
+ mime.startsWith("video") -> {
270
+ val file = createTempFile("mp4")
271
+ resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
272
+
273
+ val retriever = MediaMetadataRetriever()
274
+ retriever.setDataSource(reactContext, uri)
275
+ val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
276
+ val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
277
+ retriever.release()
278
+
279
+ resolveResult(Uri.fromFile(file).toString(), true, false, w, h)
280
+ }
281
+
282
+ else -> {
283
+ val file = createTempFile("jpg")
284
+ val bitmap = BitmapFactory.decodeStream(resolver.openInputStream(uri))
285
+ val rotated = rotateIfNeeded(bitmap, uri)
286
+
287
+ var quality = 100
288
+ var outStream = java.io.ByteArrayOutputStream()
289
+ rotated.compress(Bitmap.CompressFormat.JPEG, quality, outStream)
290
+
291
+ if (pendingTargetKB > 0) {
292
+ val targetBytes = pendingTargetKB * 1024
293
+ while (outStream.size() > targetBytes && quality > 10) {
294
+ quality -= 10
295
+ outStream = java.io.ByteArrayOutputStream()
296
+ rotated.compress(Bitmap.CompressFormat.JPEG, quality, outStream)
297
+ }
298
+ }
299
+
300
+ FileOutputStream(file).use { it.write(outStream.toByteArray()) }
301
+ resolveResult(
302
+ Uri.fromFile(file).toString(),
303
+ false,
304
+ false,
305
+ rotated.width,
306
+ rotated.height,
307
+ outStream.size() / 1024,
308
+ )
309
+ }
259
310
  }
311
+ } catch (e: Exception) {
312
+ pickerPromise?.reject("E_PROCESS", e.message)
313
+ } finally {
314
+ cleanup()
260
315
  }
261
- } catch (e: Exception) {
262
- pickerPromise?.reject("E_PROCESS", e.message)
263
- } finally {
264
- cleanup()
265
- }
316
+ }.start()
266
317
  }
267
318
 
268
319
  private fun rotateIfNeeded(
@@ -289,36 +340,45 @@ class FastMediaPickerModule(
289
340
  isAudio: Boolean,
290
341
  w: Int,
291
342
  h: Int,
343
+ sizeKB: Int = 0,
344
+ typeOverride: String? = null, // ← for custom types like "adp"
292
345
  ) {
293
346
  pickerPromise?.resolve(
294
347
  Arguments.createMap().apply {
295
348
  putString("uri", uri)
296
349
  putBoolean("isVideo", isVideo)
297
350
  putBoolean("isAudio", isAudio)
298
- putInt("width", w)
299
- putInt("height", h)
300
- putString(
301
- "type",
302
- if (isAudio) {
303
- "audio"
304
- } else if (isVideo) {
305
- "video"
306
- } else {
307
- "image"
308
- },
309
- )
351
+ putDouble("width", w.toDouble())
352
+ putDouble("height", h.toDouble())
353
+ putString("type", typeOverride ?: if (isAudio) "audio" else if (isVideo) "video" else "image")
354
+ if (!isAudio && !isVideo && sizeKB > 0) putInt("size", sizeKB)
310
355
  },
311
356
  )
312
357
  }
313
358
 
314
359
  private fun createTempFile(ext: String) = File(reactContext.cacheDir, "fast-${UUID.randomUUID()}.$ext")
315
360
 
361
+ private fun getDisplayName(uri: Uri): String {
362
+ return runCatching {
363
+ reactContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
364
+ ?.use { cursor ->
365
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
366
+ if (nameIndex >= 0 && cursor.moveToFirst()) {
367
+ cursor.getString(nameIndex) ?: ""
368
+ } else {
369
+ ""
370
+ }
371
+ } ?: ""
372
+ }.getOrDefault("")
373
+ }
374
+
316
375
  private fun cleanup() {
317
376
  pickerPromise = null
318
377
  cameraCaptureUri = null
319
378
  pendingFrontCamera = false
320
379
  pendingUseCamera = false
321
380
  pendingMediaType = null
381
+ pendingTargetKB = 0 // ← new
322
382
  }
323
383
 
324
384
  override fun onRequestPermissionsResult(
@@ -334,9 +394,11 @@ class FastMediaPickerModule(
334
394
  return true
335
395
  }
336
396
 
337
- currentActivity?.let { launch(it) }
397
+ reactApplicationContext.currentActivity?.let { activity ->
398
+ launch(activity)
399
+ }
338
400
  return true
339
401
  }
340
402
 
341
- override fun onNewIntent(intent: Intent?) {}
403
+ override fun onNewIntent(intent: Intent) {}
342
404
  }
package/index.d.ts CHANGED
@@ -7,21 +7,25 @@ declare module 'rns-mediapicker' {
7
7
  uri: string;
8
8
  width: number;
9
9
  height: number;
10
- type: 'image' | 'video';
10
+ size: number;
11
+ type: 'image' | 'video' | 'audio' | 'adp'; // ← adp added
11
12
  isVideo: boolean;
13
+ isAudio: boolean;
12
14
  }
13
15
 
14
16
  export interface MediaPickerType {
15
17
  /**
16
18
  * Launch the media picker or camera.
17
19
  * @param useCamera Set to true to open the camera directly.
18
- * @param mediaType Specify 'image', 'video', or 'both'.
20
+ * @param mediaType Specify 'image', 'video', 'both', or 'audio'.
19
21
  * @param env Use 'front' or 'back'.
22
+ * @param targetKB Max file size in KB (for image compression).
20
23
  */
21
24
  pick(
22
25
  useCamera?: boolean,
23
- mediaType?: 'image' | 'video' | 'both' | 'audio',
24
- env?: 'front' | 'back' | string
26
+ mediaType?: 'image' | 'video' | 'both' | 'audio' | 'adp', // ← adp added
27
+ env?: 'front' | 'back' | string,
28
+ targetKB?: number
25
29
  ): Promise<PickerResponse>;
26
30
  }
27
31
 
package/index.js CHANGED
@@ -5,17 +5,25 @@ const NativeModule = NativeModules.FastMediaPicker
5
5
  : TurboModuleRegistry.get('FastMediaPicker');
6
6
 
7
7
  const MediaPicker = {
8
- pick: async (useCamera = false, mediaType = 'both', env = 'back') => {
8
+ /**
9
+ * @param {boolean} useCamera
10
+ * @param {'image' | 'video' | 'both' | 'audio'} mediaType
11
+ * @param {'front' | 'back'} env
12
+ * @param {number} targetKB - Max size in KB (0 for no compression)
13
+ */
14
+ pick: async (useCamera = false, mediaType = 'both', env = 'back', targetKB = 0) => {
9
15
  if (!NativeModule) {
10
16
  throw new Error("MediaPicker: Native module not found.");
11
17
  }
12
18
 
13
- // Match the signature: pick(boolean, String, String)
14
- // React Native automatically injects the promise as the last argument
19
+ const validTypes = ['image', 'video', 'both', 'audio', 'adp']; // ← adp added
20
+ const safeMediaType = validTypes.includes(mediaType) ? mediaType : 'both';
21
+
15
22
  return await NativeModule.pick(
16
23
  !!useCamera,
17
- mediaType || 'both',
18
- env || 'back'
24
+ safeMediaType, // ← was: mediaType || 'both' (didn't validate)
25
+ env || 'back',
26
+ targetKB || 0
19
27
  );
20
28
  }
21
29
  };
@@ -1,11 +1,10 @@
1
1
  #import <React/RCTBridgeModule.h>
2
2
 
3
- @interface RCT_EXTERN_MODULE(FastMediaPicker, NSObject)
3
+ @interface RCT_EXTERN_MODULE (FastMediaPicker, NSObject)
4
4
 
5
- RCT_EXTERN_METHOD(pick:(BOOL)useCamera
6
- mediaType:(NSString *)mediaType
7
- env:(NSString *)env
8
- resolver:(RCTPromiseResolveBlock)resolve
9
- rejecter:(RCTPromiseRejectBlock)reject)
5
+ RCT_EXTERN_METHOD(pick : (BOOL)useCamera mediaType : (NSString *)
6
+ mediaType env : (NSString *)env targetKB : (NSInteger)
7
+ targetKB resolver : (RCTPromiseResolveBlock)
8
+ resolve rejecter : (RCTPromiseRejectBlock)reject)
10
9
 
11
10
  @end
@@ -1,38 +1,55 @@
1
1
  import AVFoundation
2
2
  import Photos
3
3
  import PhotosUI
4
+ import React
4
5
  import UIKit
5
6
  import UniformTypeIdentifiers
6
- import React
7
7
 
8
8
  @objc(FastMediaPicker)
9
9
  class FastMediaPicker: NSObject,
10
- UIImagePickerControllerDelegate,
11
- UINavigationControllerDelegate,
12
- UIAdaptivePresentationControllerDelegate,
13
- PHPickerViewControllerDelegate,
14
- UIDocumentPickerDelegate,
15
- RCTBridgeModule {
10
+ UIImagePickerControllerDelegate,
11
+ UINavigationControllerDelegate,
12
+ UIAdaptivePresentationControllerDelegate,
13
+ PHPickerViewControllerDelegate,
14
+ UIDocumentPickerDelegate,
15
+ RCTBridgeModule
16
+ {
16
17
 
17
18
  static func moduleName() -> String! { return "FastMediaPicker" }
18
19
  static func requiresMainQueueSetup() -> Bool { return true }
19
20
 
20
21
  private var resolve: RCTPromiseResolveBlock?
21
22
  private var reject: RCTPromiseRejectBlock?
23
+ // Added to store the target size across picker callbacks
24
+ private var targetKB: Int = 0
22
25
 
23
- @objc(pick:mediaType:env:resolver:rejecter:)
26
+ @objc(pick:mediaType:env:targetKB:resolver:rejecter:)
24
27
  func pick(
25
28
  _ useCamera: Bool,
26
29
  mediaType: String,
27
30
  env: String?,
31
+ targetKB: Int,
28
32
  resolver resolve: @escaping RCTPromiseResolveBlock,
29
33
  rejecter reject: @escaping RCTPromiseRejectBlock
30
34
  ) {
31
35
  self.resolve = resolve
32
36
  self.reject = reject
37
+ self.targetKB = targetKB
33
38
  let typeLower = mediaType.lowercased()
34
39
 
35
40
  DispatchQueue.main.async {
41
+ // 1a. ADP (Custom project file)
42
+ if typeLower == "adp" {
43
+ // UTType(filenameExtension:) resolves our custom .adp type at runtime.
44
+ // Falls back to .data (generic binary) if not declared in Info.plist yet.
45
+ let adpType = UTType(filenameExtension: "adp") ?? .data
46
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: [adpType])
47
+ picker.delegate = self
48
+ picker.allowsMultipleSelection = false
49
+ self.topMostViewController()?.present(picker, animated: true)
50
+ return
51
+ }
52
+
36
53
  // 1. AUDIO (Document Picker)
37
54
  if typeLower == "audio" {
38
55
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.audio])
@@ -75,9 +92,13 @@ class FastMediaPicker: NSObject,
75
92
  var config = PHPickerConfiguration()
76
93
  config.selectionLimit = 1
77
94
 
78
- if typeLower == "image" { config.filter = .images }
79
- else if typeLower == "video" { config.filter = .videos }
80
- else { config.filter = .any(of: [.images, .videos]) }
95
+ if typeLower == "image" {
96
+ config.filter = .images
97
+ } else if typeLower == "video" {
98
+ config.filter = .videos
99
+ } else {
100
+ config.filter = .any(of: [.images, .videos])
101
+ }
81
102
 
82
103
  let picker = PHPickerViewController(configuration: config)
83
104
  picker.delegate = self
@@ -101,7 +122,8 @@ class FastMediaPicker: NSObject,
101
122
 
102
123
  // Check for Video
103
124
  if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
104
- provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, error in
125
+ provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) {
126
+ [weak self] url, error in
105
127
  if let url = url {
106
128
  self?.handlePickedVideo(url: url)
107
129
  } else {
@@ -122,12 +144,14 @@ class FastMediaPicker: NSObject,
122
144
  }
123
145
  return
124
146
  }
125
-
147
+
126
148
  safeReject("E_NO_MEDIA", "Unsupported media type")
127
149
  }
128
150
 
129
151
  // MARK: - Document Picker (Audio)
130
- func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
152
+ func documentPicker(
153
+ _ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]
154
+ ) {
131
155
  guard let url = urls.first else {
132
156
  safeReject("E_CANCELLED", "No file selected")
133
157
  return
@@ -145,12 +169,22 @@ class FastMediaPicker: NSObject,
145
169
  }
146
170
  try FileManager.default.copyItem(at: url, to: fileURL)
147
171
 
148
- self.safeResolve([
149
- "uri": fileURL.absoluteString,
150
- "type": "audio",
151
- "isAudio": true,
152
- "isVideo": false
153
- ])
172
+ // Distinguish .adp project files from regular audio by extension
173
+ if ext == "adp" {
174
+ self.safeResolve([
175
+ "uri": fileURL.absoluteString,
176
+ "type": "adp",
177
+ "isAudio": false,
178
+ "isVideo": false,
179
+ ])
180
+ } else {
181
+ self.safeResolve([
182
+ "uri": fileURL.absoluteString,
183
+ "type": "audio",
184
+ "isAudio": true,
185
+ "isVideo": false,
186
+ ])
187
+ }
154
188
  } catch {
155
189
  safeReject("E_AUDIO_COPY", error.localizedDescription)
156
190
  }
@@ -161,8 +195,10 @@ class FastMediaPicker: NSObject,
161
195
  }
162
196
 
163
197
  // MARK: - UIImagePicker (Camera)
164
- func imagePickerController(_ picker: UIImagePickerController,
165
- didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
198
+ func imagePickerController(
199
+ _ picker: UIImagePickerController,
200
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
201
+ ) {
166
202
  picker.dismiss(animated: true)
167
203
 
168
204
  if let mediaURL = info[.mediaURL] as? URL {
@@ -188,21 +224,38 @@ class FastMediaPicker: NSObject,
188
224
  private func processPickedImage(_ image: UIImage) {
189
225
  let fileURL = makeTempURL(ext: "jpg")
190
226
 
191
- // Standardize image orientation and convert to Data
192
- guard let data = image.jpegData(compressionQuality: 0.8) else {
227
+ // 1. Initial attempt at high quality (Standardize orientation automatically via jpegData)
228
+ var compression: CGFloat = 1.0
229
+ guard var data = image.jpegData(compressionQuality: compression) else {
193
230
  safeReject("E_IMAGE_WRITE", "Image encoding failed")
194
231
  return
195
232
  }
196
233
 
234
+ // 2. Iterative compression logic
235
+ if targetKB > 0 {
236
+ let targetBytes = targetKB * 1024
237
+
238
+ // If the initial file is already larger than target, reduce quality
239
+ if data.count > targetBytes {
240
+ while data.count > targetBytes && compression > 0.1 {
241
+ compression -= 0.1
242
+ if let compressedData = image.jpegData(compressionQuality: compression) {
243
+ data = compressedData
244
+ }
245
+ }
246
+ }
247
+ }
248
+
197
249
  do {
198
250
  try data.write(to: fileURL)
199
251
  self.safeResolve([
200
252
  "uri": fileURL.absoluteString,
201
253
  "width": image.size.width,
202
254
  "height": image.size.height,
255
+ "size": data.count / 1024, // Size in KB
203
256
  "type": "image",
204
257
  "isAudio": false,
205
- "isVideo": false
258
+ "isVideo": false,
206
259
  ])
207
260
  } catch {
208
261
  safeReject("E_IMAGE_WRITE", error.localizedDescription)
@@ -227,7 +280,7 @@ class FastMediaPicker: NSObject,
227
280
  "height": dims.height,
228
281
  "type": "video",
229
282
  "isAudio": false,
230
- "isVideo": true
283
+ "isVideo": true,
231
284
  ])
232
285
  } catch {
233
286
  safeReject("E_VIDEO_COPY", error.localizedDescription)
@@ -284,4 +337,4 @@ class FastMediaPicker: NSObject,
284
337
  let size = track.naturalSize.applying(track.preferredTransform)
285
338
  return (abs(size.width), abs(size.height))
286
339
  }
287
- }
340
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rns-mediapicker",
3
- "version": "0.1.4",
3
+ "version": "3.0.0",
4
4
  "description": "High-performance React Native module for picking media on Android and iOS.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -138,6 +138,28 @@ const withIosConfig = (config) => {
138
138
  plist.NSMicrophoneUsageDescription ||
139
139
  'Allow access to your microphone when recording videos.';
140
140
 
141
+ // Register .adp as a known custom type so UIDocumentPickerViewController
142
+ // can filter to it exclusively instead of falling back to showing all files.
143
+ const adpTypeIdentifier = 'com.audipella.adp';
144
+ const existingImports = plist.UTImportedTypeDeclarations || [];
145
+ const alreadyDeclared = existingImports.some(
146
+ (t) => t.UTTypeIdentifier === adpTypeIdentifier
147
+ );
148
+
149
+ if (!alreadyDeclared) {
150
+ plist.UTImportedTypeDeclarations = [
151
+ ...existingImports,
152
+ {
153
+ UTTypeIdentifier: adpTypeIdentifier,
154
+ UTTypeDescription: 'Audipella Project',
155
+ UTTypeConformsTo: ['public.data'],
156
+ UTTypeTagSpecification: {
157
+ 'public.filename-extension': ['adp'],
158
+ },
159
+ },
160
+ ];
161
+ }
162
+
141
163
  return config;
142
164
  });
143
165
  };