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 +162 -47
- package/android/src/main/java/com/rnsmediapicker/FastMediaPickerModule.kt +140 -60
- package/index.d.ts +8 -4
- package/index.js +13 -5
- package/ios/FastMediaPicker.m +5 -6
- package/ios/FastMediaPicker.swift +80 -27
- package/package.json +1 -1
- package/withMediaPicker.js +22 -0
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,
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
| useCamera | boolean | `true` opens the camera, `false` opens the media picker |
|
|
83
|
-
| mediaType | string | `'
|
|
84
|
-
| env | string | `'front'` or `'back'`
|
|
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
|
-
```
|
|
170
|
+
```ts
|
|
91
171
|
{
|
|
92
|
-
uri: string; // Local file URI
|
|
93
|
-
width: number; //
|
|
94
|
-
height: number; //
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 /
|
|
106
|
-
- 📷 Camera and 📁 Library selection
|
|
107
|
-
- 🎬 Camera video capture (Android & iOS)
|
|
108
|
-
- 🗜️
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
-
|
|
114
|
-
- 🍎 iOS
|
|
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 app
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
236
|
+
reactApplicationContext.currentActivity?.let { activity ->
|
|
237
|
+
onActivityResult(activity, requestCode, resultCode, data)
|
|
238
|
+
}
|
|
201
239
|
}
|
|
202
240
|
|
|
203
241
|
private fun processMedia(uri: Uri) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
val
|
|
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,
|
|
259
|
+
resolveResult(Uri.fromFile(file).toString(), false, false, 0, 0, typeOverride = "adp")
|
|
214
260
|
}
|
|
215
261
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
rotated
|
|
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
|
-
}
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
putString(
|
|
283
|
-
|
|
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 {
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
};
|
package/ios/FastMediaPicker.m
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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" {
|
|
79
|
-
|
|
80
|
-
else
|
|
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) {
|
|
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(
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
165
|
-
|
|
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
|
|
192
|
-
|
|
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
package/withMediaPicker.js
CHANGED
|
@@ -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
|
};
|