rns-mediapicker 0.1.3 → 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,26 +109,56 @@ 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
+ }
125
+ // 1. AUDIO: Use GET_CONTENT but without the chooser for faster return
107
126
  if (type == "audio") {
108
127
  val intent =
109
128
  Intent(Intent.ACTION_GET_CONTENT).apply {
110
129
  addCategory(Intent.CATEGORY_OPENABLE)
111
130
  this.type = "audio/*"
131
+ // Some devices honor this to disable multi-select UI
132
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
112
133
  }
113
- activity.startActivityForResult(Intent.createChooser(intent, "Select Audio"), PICKER_REQUEST_CODE)
134
+ activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
114
135
  return
115
136
  }
116
137
 
138
+ // 2. MODERN PHOTO PICKER (Android 13+):
139
+ // We set the limit to 1 explicitly.
117
140
  if (Build.VERSION.SDK_INT >= 33) {
118
- val intent = Intent(MediaStore.ACTION_PICK_IMAGES)
141
+ val intent =
142
+ Intent(MediaStore.ACTION_PICK_IMAGES).apply {
143
+ // This is the key for Android 13+ to return on single tap
144
+ if (type == "image") {
145
+ this.type = "image/*"
146
+ } else if (type == "video") {
147
+ this.type = "video/*"
148
+ }
149
+ // Setting limit to 1 via the extra usually triggers auto-done on click
150
+ // Note: Standard ACTION_PICK_IMAGES is single-select by default
151
+ // unless EXTRA_PICK_IMAGES_MAX is set.
152
+ }
119
153
  activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
120
154
  return
121
155
  }
122
156
 
157
+ // 3. FALLBACK (Older Androids):
158
+ // Avoid Intent.createChooser to bypass the extra "Open with..." dialog
123
159
  val fallback =
124
160
  Intent(Intent.ACTION_GET_CONTENT).apply {
125
161
  addCategory(Intent.CATEGORY_OPENABLE)
126
- // FIXED: Explicitly use this.type to refer to Intent property
127
162
  this.type =
128
163
  when (type) {
129
164
  "video" -> "video/*"
@@ -133,8 +168,9 @@ class FastMediaPickerModule(
133
168
  if (type == "both") {
134
169
  putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
135
170
  }
171
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
136
172
  }
137
- activity.startActivityForResult(Intent.createChooser(fallback, "Select Media"), PICKER_REQUEST_CODE)
173
+ activity.startActivityForResult(fallback, PICKER_REQUEST_CODE)
138
174
  }
139
175
 
140
176
  private fun launchCamera(
@@ -168,7 +204,7 @@ class FastMediaPickerModule(
168
204
  }
169
205
 
170
206
  override fun onActivityResult(
171
- activity: Activity?,
207
+ activity: Activity,
172
208
  requestCode: Int,
173
209
  resultCode: Int,
174
210
  data: Intent?,
@@ -197,54 +233,87 @@ class FastMediaPickerModule(
197
233
  resultCode: Int,
198
234
  data: Intent?,
199
235
  ) {
200
- onActivityResult(currentActivity, requestCode, resultCode, data)
236
+ reactApplicationContext.currentActivity?.let { activity ->
237
+ onActivityResult(activity, requestCode, resultCode, data)
238
+ }
201
239
  }
202
240
 
203
241
  private fun processMedia(uri: Uri) {
204
- val resolver = reactContext.contentResolver
205
- val mime = resolver.getType(uri) ?: "image/jpeg"
206
-
207
- 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
+
208
255
  when {
209
- mime.startsWith("audio") -> {
210
- val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "mp3"
211
- val file = createTempFile(ext)
256
+ isAdpFile -> {
257
+ val file = createTempFile("adp")
212
258
  resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
213
- resolveResult(Uri.fromFile(file).toString(), false, true, 0, 0)
259
+ resolveResult(Uri.fromFile(file).toString(), false, false, 0, 0, typeOverride = "adp")
214
260
  }
215
261
 
216
- mime.startsWith("video") -> {
217
- val file = createTempFile("mp4")
218
- resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
219
-
220
- val retriever = MediaMetadataRetriever()
221
- retriever.setDataSource(reactContext, uri)
222
- val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
223
- val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
224
- retriever.release()
225
-
226
- resolveResult(Uri.fromFile(file).toString(), true, false, w, h)
227
- }
228
-
229
- else -> {
230
- val file = createTempFile("jpg")
231
- val bitmap = BitmapFactory.decodeStream(resolver.openInputStream(uri))
232
- val rotated = rotateIfNeeded(bitmap, uri)
233
- rotated.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, FileOutputStream(file))
234
- resolveResult(
235
- Uri.fromFile(file).toString(),
236
- false,
237
- false,
238
- rotated.width,
239
- rotated.height,
240
- )
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
+ }
241
310
  }
311
+ } catch (e: Exception) {
312
+ pickerPromise?.reject("E_PROCESS", e.message)
313
+ } finally {
314
+ cleanup()
242
315
  }
243
- } catch (e: Exception) {
244
- pickerPromise?.reject("E_PROCESS", e.message)
245
- } finally {
246
- cleanup()
247
- }
316
+ }.start()
248
317
  }
249
318
 
250
319
  private fun rotateIfNeeded(
@@ -271,36 +340,45 @@ class FastMediaPickerModule(
271
340
  isAudio: Boolean,
272
341
  w: Int,
273
342
  h: Int,
343
+ sizeKB: Int = 0,
344
+ typeOverride: String? = null, // ← for custom types like "adp"
274
345
  ) {
275
346
  pickerPromise?.resolve(
276
347
  Arguments.createMap().apply {
277
348
  putString("uri", uri)
278
349
  putBoolean("isVideo", isVideo)
279
350
  putBoolean("isAudio", isAudio)
280
- putInt("width", w)
281
- putInt("height", h)
282
- putString(
283
- "type",
284
- if (isAudio) {
285
- "audio"
286
- } else if (isVideo) {
287
- "video"
288
- } else {
289
- "image"
290
- },
291
- )
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)
292
355
  },
293
356
  )
294
357
  }
295
358
 
296
359
  private fun createTempFile(ext: String) = File(reactContext.cacheDir, "fast-${UUID.randomUUID()}.$ext")
297
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
+
298
375
  private fun cleanup() {
299
376
  pickerPromise = null
300
377
  cameraCaptureUri = null
301
378
  pendingFrontCamera = false
302
379
  pendingUseCamera = false
303
380
  pendingMediaType = null
381
+ pendingTargetKB = 0 // ← new
304
382
  }
305
383
 
306
384
  override fun onRequestPermissionsResult(
@@ -316,9 +394,11 @@ class FastMediaPickerModule(
316
394
  return true
317
395
  }
318
396
 
319
- currentActivity?.let { launch(it) }
397
+ reactApplicationContext.currentActivity?.let { activity ->
398
+ launch(activity)
399
+ }
320
400
  return true
321
401
  }
322
402
 
323
- override fun onNewIntent(intent: Intent?) {}
403
+ override fun onNewIntent(intent: Intent) {}
324
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.3",
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
  };