rns-mediapicker 0.0.8 → 0.1.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 +67 -45
- package/android/src/main/java/com/rnsmediapicker/FastMediaPickerModule.kt +138 -108
- package/index.d.ts +5 -1
- package/ios/FastMediaPicker.swift +195 -101
- package/package.json +3 -2
- package/withMediaPicker.js +5 -0
package/README.md
CHANGED
|
@@ -1,74 +1,96 @@
|
|
|
1
1
|
### rns-mediapicker
|
|
2
|
-
A high-performance media picker for React Native and Expo. It handles image/video selection with native efficiency, automatic JPEG compression, and white-background flattening for transparent images.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
6
10
|
yarn add rns-mediapicker
|
|
7
|
-
|
|
11
|
+
npx expo install rns-mediapicker
|
|
12
|
+
npm install rns-mediapicker
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Expo Configuration
|
|
17
|
+
|
|
18
|
+
Add the plugin to your app.json or app.config.js to automatically configure permissions, intent queries, and FileProvider.
|
|
8
19
|
|
|
9
|
-
```bash
|
|
10
|
-
Add the plugin to your app.json or app.config.js to automate permissions and Android manifest queries.
|
|
11
|
-
```
|
|
12
|
-
```js
|
|
13
20
|
{
|
|
14
21
|
"expo": {
|
|
15
22
|
"plugins": ["rns-mediapicker"]
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
|
-
```
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
21
30
|
import MediaPicker from 'rns-mediapicker';
|
|
22
31
|
|
|
23
32
|
const pickMedia = async () => {
|
|
24
33
|
try {
|
|
25
34
|
const result = await MediaPicker.pick(
|
|
26
|
-
false,
|
|
27
|
-
'both',
|
|
28
|
-
'back'
|
|
35
|
+
false,
|
|
36
|
+
'both',
|
|
37
|
+
'back'
|
|
29
38
|
);
|
|
30
39
|
|
|
31
|
-
console.log(
|
|
32
|
-
console.log(
|
|
33
|
-
console.log(
|
|
40
|
+
console.log('URI:', result.uri);
|
|
41
|
+
console.log('Type:', result.type);
|
|
42
|
+
console.log('Width:', result.width);
|
|
43
|
+
console.log('Height:', result.height);
|
|
44
|
+
console.log('Is Video:', result.isVideo);
|
|
45
|
+
console.log('Is Audio:', result.isAudio);
|
|
34
46
|
} catch (error) {
|
|
35
47
|
if (error.code === 'E_CANCELLED') {
|
|
36
|
-
console.log('User
|
|
48
|
+
console.log('User cancelled picker');
|
|
37
49
|
} else {
|
|
38
|
-
console.error('Picker
|
|
50
|
+
console.error('Picker error:', error.message);
|
|
39
51
|
}
|
|
40
52
|
}
|
|
41
53
|
};
|
|
42
|
-
```
|
|
43
54
|
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
46
59
|
pick(useCamera, mediaType, env)
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
useCamera (boolean): true opens camera, false opens picker
|
|
62
|
+
mediaType (string): 'audio', 'image', 'video', 'both'
|
|
63
|
+
env (string): 'front' or 'back' camera (camera only)
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Response Object
|
|
52
68
|
|
|
53
|
-
```bash
|
|
54
|
-
response-object
|
|
55
|
-
```
|
|
56
|
-
```js
|
|
57
69
|
{
|
|
58
|
-
uri: string;
|
|
59
|
-
width: number;
|
|
60
|
-
height: number;
|
|
61
|
-
type: string;
|
|
62
|
-
isVideo: boolean;
|
|
70
|
+
uri: string;
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
type: string;
|
|
74
|
+
isVideo: boolean;
|
|
75
|
+
isAudio: boolean;
|
|
63
76
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Features
|
|
81
|
+
|
|
82
|
+
- Audio / Image / Video support
|
|
83
|
+
- Camera and Library selection
|
|
84
|
+
- Camera video capture (Android & iOS)
|
|
85
|
+
- Automatic JPEG compression
|
|
86
|
+
- White-background flattening
|
|
87
|
+
- EXIF-safe rotation and dimensions
|
|
88
|
+
- Scoped-storage safe temp files
|
|
89
|
+
- Android FileProvider configured
|
|
90
|
+
- iOS swipe-to-dismiss cancellation handling
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -4,6 +4,7 @@ import android.Manifest
|
|
|
4
4
|
import android.app.Activity
|
|
5
5
|
import android.content.Intent
|
|
6
6
|
import android.graphics.*
|
|
7
|
+
import android.media.ExifInterface
|
|
7
8
|
import android.media.MediaMetadataRetriever
|
|
8
9
|
import android.net.Uri
|
|
9
10
|
import android.os.Build
|
|
@@ -12,7 +13,6 @@ import android.webkit.MimeTypeMap
|
|
|
12
13
|
import androidx.core.content.FileProvider
|
|
13
14
|
import com.facebook.react.bridge.*
|
|
14
15
|
import com.facebook.react.module.annotations.ReactModule
|
|
15
|
-
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
16
16
|
import com.facebook.react.modules.core.PermissionAwareActivity
|
|
17
17
|
import com.facebook.react.modules.core.PermissionListener
|
|
18
18
|
import java.io.File
|
|
@@ -34,7 +34,6 @@ class FastMediaPickerModule(
|
|
|
34
34
|
companion object {
|
|
35
35
|
private const val PICKER_REQUEST_CODE = 4123
|
|
36
36
|
private const val PERMISSION_REQUEST = 9123
|
|
37
|
-
private const val MAX_IMAGE_SIZE = 1200
|
|
38
37
|
private const val JPEG_QUALITY = 85
|
|
39
38
|
}
|
|
40
39
|
|
|
@@ -48,8 +47,6 @@ class FastMediaPickerModule(
|
|
|
48
47
|
|
|
49
48
|
@ReactMethod fun removeListeners(count: Int) {}
|
|
50
49
|
|
|
51
|
-
// -------------------- PUBLIC API --------------------
|
|
52
|
-
|
|
53
50
|
@ReactMethod
|
|
54
51
|
fun pick(
|
|
55
52
|
useCamera: Boolean,
|
|
@@ -66,18 +63,14 @@ class FastMediaPickerModule(
|
|
|
66
63
|
cleanup()
|
|
67
64
|
pickerPromise = promise
|
|
68
65
|
pendingUseCamera = useCamera
|
|
69
|
-
pendingMediaType = mediaType
|
|
66
|
+
pendingMediaType = mediaType.lowercase()
|
|
70
67
|
pendingFrontCamera = env?.lowercase() == "front"
|
|
71
68
|
|
|
72
69
|
val permissions = mutableListOf<String>()
|
|
73
|
-
|
|
74
70
|
if (useCamera) {
|
|
75
71
|
permissions.add(Manifest.permission.CAMERA)
|
|
76
|
-
} else {
|
|
77
|
-
|
|
78
|
-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
79
|
-
permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
|
80
|
-
}
|
|
72
|
+
} else if (Build.VERSION.SDK_INT < 33) {
|
|
73
|
+
permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
val missing =
|
|
@@ -86,12 +79,12 @@ class FastMediaPickerModule(
|
|
|
86
79
|
}
|
|
87
80
|
|
|
88
81
|
if (missing.isNotEmpty()) {
|
|
89
|
-
val pa =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
82
|
+
val pa = activity as? PermissionAwareActivity
|
|
83
|
+
if (pa == null) {
|
|
84
|
+
promise.reject("E_NOT_PERMISSION_AWARE", "Activity not PermissionAware")
|
|
85
|
+
cleanup()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
95
88
|
pa.requestPermissions(missing.toTypedArray(), PERMISSION_REQUEST, this)
|
|
96
89
|
return
|
|
97
90
|
}
|
|
@@ -101,92 +94,78 @@ class FastMediaPickerModule(
|
|
|
101
94
|
|
|
102
95
|
private fun launch(activity: Activity) {
|
|
103
96
|
if (pendingUseCamera) {
|
|
104
|
-
launchCamera(activity, pendingFrontCamera)
|
|
97
|
+
launchCamera(activity, pendingMediaType, pendingFrontCamera)
|
|
105
98
|
} else {
|
|
106
99
|
launchPicker(activity, pendingMediaType ?: "both")
|
|
107
100
|
}
|
|
108
101
|
}
|
|
109
102
|
|
|
110
|
-
// -------------------- PERMISSIONS CALLBACK --------------------
|
|
111
|
-
|
|
112
|
-
override fun onRequestPermissionsResult(
|
|
113
|
-
requestCode: Int,
|
|
114
|
-
permissions: Array<String>,
|
|
115
|
-
grantResults: IntArray,
|
|
116
|
-
): Boolean {
|
|
117
|
-
if (requestCode != PERMISSION_REQUEST) return false
|
|
118
|
-
|
|
119
|
-
if (grantResults.any { it != android.content.pm.PackageManager.PERMISSION_GRANTED }) {
|
|
120
|
-
pickerPromise?.reject("E_PERMISSION_DENIED", "Required permission denied")
|
|
121
|
-
cleanup()
|
|
122
|
-
return true
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
currentActivity?.let { launch(it) }
|
|
126
|
-
return true
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// -------------------- PICKER / CAMERA --------------------
|
|
130
|
-
|
|
131
103
|
private fun launchPicker(
|
|
132
104
|
activity: Activity,
|
|
133
105
|
type: String,
|
|
134
106
|
) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
107
|
+
if (type == "audio") {
|
|
108
|
+
val intent =
|
|
109
|
+
Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
110
|
+
addCategory(Intent.CATEGORY_OPENABLE)
|
|
111
|
+
this.type = "audio/*"
|
|
140
112
|
}
|
|
141
|
-
|
|
113
|
+
activity.startActivityForResult(Intent.createChooser(intent, "Select Audio"), PICKER_REQUEST_CODE)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
142
116
|
|
|
143
|
-
|
|
117
|
+
if (Build.VERSION.SDK_INT >= 33) {
|
|
118
|
+
val intent = Intent(MediaStore.ACTION_PICK_IMAGES)
|
|
144
119
|
activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
val fallback =
|
|
124
|
+
Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
125
|
+
addCategory(Intent.CATEGORY_OPENABLE)
|
|
126
|
+
type =
|
|
127
|
+
when (type) {
|
|
128
|
+
"video" -> "video/*"
|
|
129
|
+
"image" -> "image/*"
|
|
130
|
+
else -> "*/*"
|
|
157
131
|
}
|
|
132
|
+
if (type == "both") {
|
|
133
|
+
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
|
158
134
|
}
|
|
159
|
-
try {
|
|
160
|
-
activity.startActivityForResult(Intent.createChooser(fallbackIntent, "Select Media"), PICKER_REQUEST_CODE)
|
|
161
|
-
} catch (fallbackEx: Exception) {
|
|
162
|
-
pickerPromise?.reject("E_NO_PICKER", "No picker available")
|
|
163
|
-
cleanup()
|
|
164
135
|
}
|
|
165
|
-
|
|
136
|
+
activity.startActivityForResult(Intent.createChooser(fallback, "Select Media"), PICKER_REQUEST_CODE)
|
|
166
137
|
}
|
|
167
138
|
|
|
168
139
|
private fun launchCamera(
|
|
169
140
|
activity: Activity,
|
|
141
|
+
type: String?,
|
|
170
142
|
useFrontCamera: Boolean,
|
|
171
143
|
) {
|
|
172
|
-
val
|
|
144
|
+
val isVideo = type == "video"
|
|
145
|
+
val ext = if (isVideo) "mp4" else "jpg"
|
|
146
|
+
val file = createTempFile(ext)
|
|
147
|
+
|
|
173
148
|
val authority = "${reactContext.packageName}.provider"
|
|
174
149
|
cameraCaptureUri = FileProvider.getUriForFile(reactContext, authority, file)
|
|
175
150
|
|
|
176
151
|
val intent =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
putExtra("android.intent.extras.CAMERA_FACING", 1)
|
|
182
|
-
putExtra("android.intent.extra.USE_FRONT_CAMERA", true)
|
|
183
|
-
}
|
|
152
|
+
if (isVideo) {
|
|
153
|
+
Intent(MediaStore.ACTION_VIDEO_CAPTURE)
|
|
154
|
+
} else {
|
|
155
|
+
Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
|
184
156
|
}
|
|
157
|
+
|
|
158
|
+
intent.putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureUri)
|
|
159
|
+
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
|
160
|
+
|
|
161
|
+
if (useFrontCamera) {
|
|
162
|
+
intent.putExtra("android.intent.extras.CAMERA_FACING", 1)
|
|
163
|
+
intent.putExtra("android.intent.extra.USE_FRONT_CAMERA", true)
|
|
164
|
+
}
|
|
165
|
+
|
|
185
166
|
activity.startActivityForResult(intent, PICKER_REQUEST_CODE)
|
|
186
167
|
}
|
|
187
168
|
|
|
188
|
-
// -------------------- ACTIVITY RESULT --------------------
|
|
189
|
-
|
|
190
169
|
override fun onActivityResult(
|
|
191
170
|
activity: Activity?,
|
|
192
171
|
requestCode: Int,
|
|
@@ -202,57 +181,62 @@ class FastMediaPickerModule(
|
|
|
202
181
|
}
|
|
203
182
|
|
|
204
183
|
val uri = data?.data ?: cameraCaptureUri
|
|
205
|
-
if (uri
|
|
206
|
-
processMedia(uri)
|
|
207
|
-
} else {
|
|
184
|
+
if (uri == null) {
|
|
208
185
|
pickerPromise?.reject("E_NO_URI", "No media selected")
|
|
209
186
|
cleanup()
|
|
187
|
+
return
|
|
210
188
|
}
|
|
189
|
+
|
|
190
|
+
processMedia(uri)
|
|
211
191
|
}
|
|
212
192
|
|
|
213
|
-
|
|
193
|
+
override fun onActivityResult(
|
|
194
|
+
requestCode: Int,
|
|
195
|
+
resultCode: Int,
|
|
196
|
+
data: Intent?,
|
|
197
|
+
) {
|
|
198
|
+
onActivityResult(currentActivity, requestCode, resultCode, data)
|
|
199
|
+
}
|
|
214
200
|
|
|
215
201
|
private fun processMedia(uri: Uri) {
|
|
216
202
|
val resolver = reactContext.contentResolver
|
|
217
203
|
val mime = resolver.getType(uri) ?: "image/jpeg"
|
|
218
|
-
val isVideo = mime.startsWith("video")
|
|
219
204
|
|
|
220
205
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
206
|
+
when {
|
|
207
|
+
mime.startsWith("audio") -> {
|
|
208
|
+
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "mp3"
|
|
209
|
+
val file = createTempFile(ext)
|
|
210
|
+
resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
|
|
211
|
+
resolveResult(Uri.fromFile(file).toString(), false, true, 0, 0)
|
|
225
212
|
}
|
|
226
213
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
|
|
231
|
-
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
|
|
232
|
-
retriever.release()
|
|
214
|
+
mime.startsWith("video") -> {
|
|
215
|
+
val file = createTempFile("mp4")
|
|
216
|
+
resolver.openInputStream(uri)?.use { it.copyTo(FileOutputStream(file)) }
|
|
233
217
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
242
|
-
BitmapFactory.decodeStream(input, null, options)
|
|
243
|
-
width = options.outWidth
|
|
244
|
-
height = options.outHeight
|
|
218
|
+
val retriever = MediaMetadataRetriever()
|
|
219
|
+
retriever.setDataSource(reactContext, uri)
|
|
220
|
+
val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
|
|
221
|
+
val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
|
|
222
|
+
retriever.release()
|
|
223
|
+
|
|
224
|
+
resolveResult(Uri.fromFile(file).toString(), true, false, w, h)
|
|
245
225
|
}
|
|
246
226
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
BitmapFactory.decodeStream(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
227
|
+
else -> {
|
|
228
|
+
val file = createTempFile("jpg")
|
|
229
|
+
val bitmap = BitmapFactory.decodeStream(resolver.openInputStream(uri))
|
|
230
|
+
val rotated = rotateIfNeeded(bitmap, uri)
|
|
231
|
+
rotated.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, FileOutputStream(file))
|
|
232
|
+
resolveResult(
|
|
233
|
+
Uri.fromFile(file).toString(),
|
|
234
|
+
false,
|
|
235
|
+
false,
|
|
236
|
+
rotated.width,
|
|
237
|
+
rotated.height,
|
|
253
238
|
)
|
|
254
239
|
}
|
|
255
|
-
resolveResult(Uri.fromFile(file).toString(), false, width, height)
|
|
256
240
|
}
|
|
257
241
|
} catch (e: Exception) {
|
|
258
242
|
pickerPromise?.reject("E_PROCESS", e.message)
|
|
@@ -261,9 +245,28 @@ class FastMediaPickerModule(
|
|
|
261
245
|
}
|
|
262
246
|
}
|
|
263
247
|
|
|
248
|
+
private fun rotateIfNeeded(
|
|
249
|
+
bitmap: Bitmap,
|
|
250
|
+
uri: Uri,
|
|
251
|
+
): Bitmap {
|
|
252
|
+
val input = reactContext.contentResolver.openInputStream(uri) ?: return bitmap
|
|
253
|
+
val exif = ExifInterface(input)
|
|
254
|
+
val rotation =
|
|
255
|
+
when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
|
256
|
+
ExifInterface.ORIENTATION_ROTATE_90 -> 90
|
|
257
|
+
ExifInterface.ORIENTATION_ROTATE_180 -> 180
|
|
258
|
+
ExifInterface.ORIENTATION_ROTATE_270 -> 270
|
|
259
|
+
else -> 0
|
|
260
|
+
}
|
|
261
|
+
if (rotation == 0) return bitmap
|
|
262
|
+
val matrix = Matrix().apply { postRotate(rotation.toFloat()) }
|
|
263
|
+
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
264
|
+
}
|
|
265
|
+
|
|
264
266
|
private fun resolveResult(
|
|
265
267
|
uri: String,
|
|
266
268
|
isVideo: Boolean,
|
|
269
|
+
isAudio: Boolean,
|
|
267
270
|
w: Int,
|
|
268
271
|
h: Int,
|
|
269
272
|
) {
|
|
@@ -271,9 +274,19 @@ class FastMediaPickerModule(
|
|
|
271
274
|
Arguments.createMap().apply {
|
|
272
275
|
putString("uri", uri)
|
|
273
276
|
putBoolean("isVideo", isVideo)
|
|
277
|
+
putBoolean("isAudio", isAudio)
|
|
274
278
|
putInt("width", w)
|
|
275
279
|
putInt("height", h)
|
|
276
|
-
putString(
|
|
280
|
+
putString(
|
|
281
|
+
"type",
|
|
282
|
+
if (isAudio) {
|
|
283
|
+
"audio"
|
|
284
|
+
} else if (isVideo) {
|
|
285
|
+
"video"
|
|
286
|
+
} else {
|
|
287
|
+
"image"
|
|
288
|
+
},
|
|
289
|
+
)
|
|
277
290
|
},
|
|
278
291
|
)
|
|
279
292
|
}
|
|
@@ -288,5 +301,22 @@ class FastMediaPickerModule(
|
|
|
288
301
|
pendingMediaType = null
|
|
289
302
|
}
|
|
290
303
|
|
|
304
|
+
override fun onRequestPermissionsResult(
|
|
305
|
+
requestCode: Int,
|
|
306
|
+
permissions: Array<String>,
|
|
307
|
+
grantResults: IntArray,
|
|
308
|
+
): Boolean {
|
|
309
|
+
if (requestCode != PERMISSION_REQUEST) return false
|
|
310
|
+
|
|
311
|
+
if (grantResults.any { it != android.content.pm.PackageManager.PERMISSION_GRANTED }) {
|
|
312
|
+
pickerPromise?.reject("E_PERMISSION_DENIED", "Required permission denied")
|
|
313
|
+
cleanup()
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
currentActivity?.let { launch(it) }
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
|
|
291
321
|
override fun onNewIntent(intent: Intent?) {}
|
|
292
322
|
}
|
package/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mediapicker Module
|
|
3
|
+
* All functions use options objects for better autocomplete and clarity.
|
|
4
|
+
*/
|
|
1
5
|
declare module 'rns-mediapicker' {
|
|
2
6
|
export interface PickerResponse {
|
|
3
7
|
uri: string;
|
|
@@ -16,7 +20,7 @@ declare module 'rns-mediapicker' {
|
|
|
16
20
|
*/
|
|
17
21
|
pick(
|
|
18
22
|
useCamera?: boolean,
|
|
19
|
-
mediaType?: 'image' | 'video' | 'both',
|
|
23
|
+
mediaType?: 'image' | 'video' | 'both' | 'audio',
|
|
20
24
|
env?: 'front' | 'back' | string
|
|
21
25
|
): Promise<PickerResponse>;
|
|
22
26
|
}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
import Photos
|
|
3
|
+
import PhotosUI
|
|
3
4
|
import UIKit
|
|
4
5
|
import UniformTypeIdentifiers
|
|
5
6
|
import React
|
|
6
7
|
|
|
7
8
|
@objc(FastMediaPicker)
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
class FastMediaPicker: NSObject,
|
|
10
|
+
UIImagePickerControllerDelegate,
|
|
11
|
+
UINavigationControllerDelegate,
|
|
12
|
+
UIAdaptivePresentationControllerDelegate,
|
|
13
|
+
PHPickerViewControllerDelegate,
|
|
14
|
+
UIDocumentPickerDelegate,
|
|
15
|
+
RCTBridgeModule {
|
|
10
16
|
|
|
11
|
-
static func moduleName() -> String! {
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
static func requiresMainQueueSetup() -> Bool {
|
|
16
|
-
return true
|
|
17
|
-
}
|
|
17
|
+
static func moduleName() -> String! { return "FastMediaPicker" }
|
|
18
|
+
static func requiresMainQueueSetup() -> Bool { return true }
|
|
18
19
|
|
|
19
20
|
private var resolve: RCTPromiseResolveBlock?
|
|
20
21
|
private var reject: RCTPromiseRejectBlock?
|
|
@@ -29,136 +30,228 @@ class FastMediaPicker: NSObject, UIImagePickerControllerDelegate, UINavigationCo
|
|
|
29
30
|
) {
|
|
30
31
|
self.resolve = resolve
|
|
31
32
|
self.reject = reject
|
|
33
|
+
let typeLower = mediaType.lowercased()
|
|
32
34
|
|
|
33
35
|
DispatchQueue.main.async {
|
|
34
|
-
let picker = UIImagePickerController()
|
|
35
|
-
picker.delegate = self
|
|
36
|
-
// 2. Set the presentation controller delegate to catch swipes
|
|
37
|
-
picker.presentationController?.delegate = self
|
|
38
|
-
picker.allowsEditing = false
|
|
39
36
|
|
|
37
|
+
// 1. AUDIO (Document Picker)
|
|
38
|
+
if typeLower == "audio" {
|
|
39
|
+
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.audio])
|
|
40
|
+
picker.delegate = self
|
|
41
|
+
picker.allowsMultipleSelection = false
|
|
42
|
+
self.topMostViewController()?.present(picker, animated: true)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. CAMERA
|
|
40
47
|
if useCamera {
|
|
41
48
|
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
|
42
49
|
self.safeReject("E_CAMERA_UNAVAILABLE", "Camera not available")
|
|
43
50
|
return
|
|
44
51
|
}
|
|
52
|
+
|
|
53
|
+
let picker = UIImagePickerController()
|
|
54
|
+
picker.delegate = self
|
|
55
|
+
picker.presentationController?.delegate = self
|
|
45
56
|
picker.sourceType = .camera
|
|
46
|
-
|
|
57
|
+
picker.allowsEditing = false
|
|
58
|
+
|
|
59
|
+
if env?.lowercased() == "front" {
|
|
47
60
|
picker.cameraDevice = .front
|
|
48
61
|
}
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
|
|
63
|
+
if typeLower == "video" {
|
|
64
|
+
picker.mediaTypes = [UTType.movie.identifier]
|
|
65
|
+
} else if typeLower == "image" {
|
|
66
|
+
picker.mediaTypes = [UTType.image.identifier]
|
|
67
|
+
} else {
|
|
68
|
+
picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
self.topMostViewController()?.present(picker, animated: true)
|
|
72
|
+
return
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
// 3. LIBRARY (PHPicker)
|
|
76
|
+
var config = PHPickerConfiguration()
|
|
77
|
+
config.selectionLimit = 1
|
|
78
|
+
|
|
79
|
+
if typeLower == "image" { config.filter = .images }
|
|
80
|
+
else if typeLower == "video" { config.filter = .videos }
|
|
81
|
+
else { config.filter = .any(of: [.images, .videos]) }
|
|
82
|
+
|
|
83
|
+
let picker = PHPickerViewController(configuration: config)
|
|
84
|
+
picker.delegate = self
|
|
85
|
+
picker.presentationController?.delegate = self
|
|
86
|
+
self.topMostViewController()?.present(picker, animated: true)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// MARK: - PHPicker
|
|
91
|
+
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
92
|
+
picker.dismiss(animated: true)
|
|
93
|
+
|
|
94
|
+
guard let result = results.first else {
|
|
95
|
+
safeReject("E_CANCELLED", "User cancelled")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let provider = result.itemProvider
|
|
100
|
+
|
|
101
|
+
if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
|
102
|
+
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, error in
|
|
103
|
+
guard let self = self else { return }
|
|
104
|
+
if let url = url {
|
|
105
|
+
self.handlePickedVideo(url: url)
|
|
106
|
+
} else {
|
|
107
|
+
self.safeReject("E_LOAD", error?.localizedDescription ?? "Video load failed")
|
|
108
|
+
}
|
|
61
109
|
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
62
112
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
self.
|
|
113
|
+
if provider.canLoadObject(ofClass: UIImage.self) {
|
|
114
|
+
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
|
115
|
+
guard let self = self, let img = image as? UIImage else { return }
|
|
116
|
+
self.processPickedImage(img)
|
|
67
117
|
}
|
|
68
118
|
}
|
|
69
119
|
}
|
|
70
120
|
|
|
71
|
-
// MARK: -
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
121
|
+
// MARK: - Document Picker (Audio)
|
|
122
|
+
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
123
|
+
guard let url = urls.first else {
|
|
124
|
+
safeReject("E_CANCELLED", "No file selected")
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let access = url.startAccessingSecurityScopedResource()
|
|
129
|
+
defer { if access { url.stopAccessingSecurityScopedResource() } }
|
|
130
|
+
|
|
131
|
+
let ext = url.pathExtension.isEmpty ? "mp3" : url.pathExtension.lowercased()
|
|
132
|
+
let fileURL = makeTempURL(ext: ext)
|
|
133
|
+
|
|
134
|
+
do {
|
|
135
|
+
if FileManager.default.fileExists(atPath: fileURL.path) {
|
|
136
|
+
try FileManager.default.removeItem(at: fileURL)
|
|
137
|
+
}
|
|
138
|
+
try FileManager.default.copyItem(at: url, to: fileURL)
|
|
139
|
+
|
|
140
|
+
DispatchQueue.main.async {
|
|
141
|
+
self.resolve?([
|
|
142
|
+
"uri": fileURL.absoluteString,
|
|
143
|
+
"type": "audio",
|
|
144
|
+
"isAudio": true,
|
|
145
|
+
"isVideo": false
|
|
146
|
+
])
|
|
147
|
+
self.cleanupPromise()
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
safeReject("E_AUDIO_COPY", error.localizedDescription)
|
|
77
151
|
}
|
|
78
152
|
}
|
|
79
153
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
self.safeReject("E_CANCELLED", "User cancelled via swipe")
|
|
154
|
+
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
|
155
|
+
safeReject("E_CANCELLED", "User cancelled")
|
|
83
156
|
}
|
|
84
157
|
|
|
85
|
-
|
|
158
|
+
// MARK: - UIImagePicker (Camera)
|
|
159
|
+
func imagePickerController(_ picker: UIImagePickerController,
|
|
160
|
+
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
|
86
161
|
picker.dismiss(animated: true)
|
|
87
162
|
|
|
88
163
|
if let mediaURL = info[.mediaURL] as? URL {
|
|
89
164
|
handlePickedVideo(url: mediaURL)
|
|
90
|
-
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage) {
|
|
91
169
|
processPickedImage(image)
|
|
92
|
-
|
|
93
|
-
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
safeReject("E_NO_MEDIA", "Failed to retrieve media")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
177
|
+
picker.dismiss(animated: true) {
|
|
178
|
+
self.safeReject("E_CANCELLED", "User cancelled")
|
|
94
179
|
}
|
|
95
180
|
}
|
|
96
181
|
|
|
97
|
-
// MARK: - Handlers
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
182
|
+
// MARK: - Media Handlers
|
|
183
|
+
private func processPickedImage(_ image: UIImage) {
|
|
184
|
+
let fileURL = makeTempURL(ext: "jpg")
|
|
185
|
+
|
|
186
|
+
let format = UIGraphicsImageRendererFormat()
|
|
187
|
+
format.scale = image.scale
|
|
188
|
+
format.opaque = true
|
|
189
|
+
|
|
190
|
+
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
|
191
|
+
let output = renderer.image { ctx in
|
|
192
|
+
UIColor.white.setFill()
|
|
193
|
+
ctx.fill(CGRect(origin: .zero, size: image.size))
|
|
194
|
+
image.draw(at: .zero)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
guard let data = output.jpegData(compressionQuality: 0.8) else {
|
|
198
|
+
safeReject("E_IMAGE_WRITE", "Image encoding failed")
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
do {
|
|
203
|
+
try data.write(to: fileURL)
|
|
204
|
+
DispatchQueue.main.async {
|
|
205
|
+
self.resolve?([
|
|
206
|
+
"uri": fileURL.absoluteString,
|
|
207
|
+
"width": image.size.width,
|
|
208
|
+
"height": image.size.height,
|
|
209
|
+
"type": "image",
|
|
210
|
+
"isVideo": false
|
|
211
|
+
])
|
|
212
|
+
self.cleanupPromise()
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
safeReject("E_IMAGE_WRITE", error.localizedDescription)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
133
218
|
|
|
134
219
|
private func handlePickedVideo(url: URL) {
|
|
135
|
-
// 4. Force lowercase extension
|
|
136
220
|
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension.lowercased()
|
|
137
221
|
let fileURL = makeTempURL(ext: ext)
|
|
138
|
-
|
|
222
|
+
|
|
139
223
|
do {
|
|
140
224
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
|
141
225
|
try FileManager.default.removeItem(at: fileURL)
|
|
142
226
|
}
|
|
143
227
|
try FileManager.default.copyItem(at: url, to: fileURL)
|
|
228
|
+
|
|
144
229
|
let dims = videoDimensions(for: fileURL)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
230
|
+
DispatchQueue.main.async {
|
|
231
|
+
self.resolve?([
|
|
232
|
+
"uri": fileURL.absoluteString,
|
|
233
|
+
"width": dims.width,
|
|
234
|
+
"height": dims.height,
|
|
235
|
+
"type": "video",
|
|
236
|
+
"isVideo": true
|
|
237
|
+
])
|
|
238
|
+
self.cleanupPromise()
|
|
239
|
+
}
|
|
153
240
|
} catch {
|
|
154
241
|
safeReject("E_VIDEO_COPY", error.localizedDescription)
|
|
155
242
|
}
|
|
156
243
|
}
|
|
157
244
|
|
|
158
245
|
// MARK: - Helpers
|
|
246
|
+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
247
|
+
safeReject("E_CANCELLED", "User cancelled via swipe")
|
|
248
|
+
}
|
|
249
|
+
|
|
159
250
|
private func safeReject(_ code: String, _ message: String) {
|
|
160
|
-
|
|
161
|
-
|
|
251
|
+
DispatchQueue.main.async {
|
|
252
|
+
self.reject?(code, message, nil)
|
|
253
|
+
self.cleanupPromise()
|
|
254
|
+
}
|
|
162
255
|
}
|
|
163
256
|
|
|
164
257
|
private func cleanupPromise() {
|
|
@@ -167,28 +260,29 @@ class FastMediaPicker: NSObject, UIImagePickerControllerDelegate, UINavigationCo
|
|
|
167
260
|
}
|
|
168
261
|
|
|
169
262
|
private func topMostViewController() -> UIViewController? {
|
|
170
|
-
let
|
|
263
|
+
let window = UIApplication.shared.connectedScenes
|
|
171
264
|
.compactMap { $0 as? UIWindowScene }
|
|
172
265
|
.flatMap { $0.windows }
|
|
173
266
|
.first { $0.isKeyWindow }
|
|
174
|
-
|
|
175
|
-
var
|
|
176
|
-
while let presented =
|
|
177
|
-
|
|
267
|
+
|
|
268
|
+
var top = window?.rootViewController
|
|
269
|
+
while let presented = top?.presentedViewController {
|
|
270
|
+
top = presented
|
|
178
271
|
}
|
|
179
|
-
return
|
|
272
|
+
return top
|
|
180
273
|
}
|
|
181
274
|
|
|
182
275
|
private func makeTempURL(ext: String) -> URL {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return tempDir.appendingPathComponent("fastpicker-\(UUID().uuidString).\(ext.lowercased())")
|
|
276
|
+
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
277
|
+
.appendingPathComponent("fastpicker-\(UUID().uuidString).\(ext)")
|
|
186
278
|
}
|
|
187
279
|
|
|
188
280
|
private func videoDimensions(for url: URL) -> (width: CGFloat, height: CGFloat) {
|
|
189
281
|
let asset = AVAsset(url: url)
|
|
190
|
-
guard let track = asset.tracks(withMediaType: .video).first else {
|
|
282
|
+
guard let track = asset.tracks(withMediaType: .video).first else {
|
|
283
|
+
return (0, 0)
|
|
284
|
+
}
|
|
191
285
|
let size = track.naturalSize.applying(track.preferredTransform)
|
|
192
|
-
return (
|
|
286
|
+
return (abs(size.width), abs(size.height))
|
|
193
287
|
}
|
|
194
288
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rns-mediapicker",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.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",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"url": "git+https://github.com/raiidr/rns-mediapicker.git"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
|
-
"p": "npm publish --access public"
|
|
23
|
+
"p": "npm publish --access public",
|
|
24
|
+
"i": "npm publish --access public"
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"react-native",
|
package/withMediaPicker.js
CHANGED
|
@@ -20,6 +20,7 @@ const withAndroidConfig = (config) => {
|
|
|
20
20
|
const permissions = [
|
|
21
21
|
{ name: 'android.permission.CAMERA' },
|
|
22
22
|
{ name: 'android.permission.READ_MEDIA_IMAGES' },
|
|
23
|
+
{ name: 'android.permission.READ_MEDIA_AUDIO' },
|
|
23
24
|
{ name: 'android.permission.READ_MEDIA_VIDEO' },
|
|
24
25
|
{ name: 'android.permission.READ_EXTERNAL_STORAGE', maxSdkVersion: '32' },
|
|
25
26
|
];
|
|
@@ -63,6 +64,10 @@ const withAndroidConfig = (config) => {
|
|
|
63
64
|
action: [{ $: { 'android:name': 'android.intent.action.OPEN_DOCUMENT' } }],
|
|
64
65
|
data: [{ $: { 'android:mimeType': 'video/*' } }],
|
|
65
66
|
},
|
|
67
|
+
{
|
|
68
|
+
action: [{ $: { 'android:name': 'android.intent.action.GET_CONTENT' } }],
|
|
69
|
+
data: [{ $: { 'android:mimeType': 'audio/*' } }],
|
|
70
|
+
},
|
|
66
71
|
];
|
|
67
72
|
|
|
68
73
|
intents.forEach((intent) => {
|