react-native-picture-selector 1.0.2 → 1.0.6
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 +377 -235
- package/package.json +1 -5
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# react-native-picture-selector
|
|
2
2
|
|
|
3
|
-
High-performance photo and video picker for React Native, built on **
|
|
3
|
+
High-performance photo and video picker for React Native, built on **Nitro Modules** (JSI, zero bridge overhead).
|
|
4
4
|
|
|
5
5
|
| Platform | Native library | Min OS |
|
|
6
6
|
|----------|---------------|--------|
|
|
7
|
-
| Android |
|
|
8
|
-
| iOS |
|
|
7
|
+
| Android | LuckSiege/PictureSelector v3.11.2 | Android 7.0 (API 24) |
|
|
8
|
+
| iOS | SilenceLove/HXPhotoPicker v5.0.5 | iOS 13.0 |
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -58,22 +58,7 @@ Add required keys to **`ios/YourApp/Info.plist`**:
|
|
|
58
58
|
|
|
59
59
|
Permissions are declared in the library manifest and merged automatically. If you target **Android 13+ (API 33)** and also support older versions you need no extra steps — the manifest already uses `maxSdkVersion` to apply the right permission per API level.
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```xml
|
|
64
|
-
<!-- Only needed if you customise the FileProvider authority -->
|
|
65
|
-
<provider
|
|
66
|
-
android:name="androidx.core.content.FileProvider"
|
|
67
|
-
android:authorities="${applicationId}.fileprovider"
|
|
68
|
-
android:exported="false"
|
|
69
|
-
android:grantUriPermissions="true">
|
|
70
|
-
<meta-data
|
|
71
|
-
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
72
|
-
android:resource="@xml/file_paths" />
|
|
73
|
-
</provider>
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
#### Manual package registration (non-autolinking setups)
|
|
61
|
+
For apps that don't use autolinking, register the package manually:
|
|
77
62
|
|
|
78
63
|
```kotlin
|
|
79
64
|
// android/app/src/main/java/.../MainApplication.kt
|
|
@@ -93,8 +78,9 @@ const assets = await PictureSelector.openPicker({
|
|
|
93
78
|
maxCount: 1,
|
|
94
79
|
})
|
|
95
80
|
|
|
96
|
-
console.log(assets[0].uri)
|
|
97
|
-
console.log(assets[0].width)
|
|
81
|
+
console.log(assets[0].uri) // file:///data/user/0/.../image.jpg
|
|
82
|
+
console.log(assets[0].width) // 4032
|
|
83
|
+
console.log(assets[0].fileSize) // 2457600
|
|
98
84
|
```
|
|
99
85
|
|
|
100
86
|
---
|
|
@@ -103,24 +89,30 @@ console.log(assets[0].width) // 1280
|
|
|
103
89
|
|
|
104
90
|
### `PictureSelector.openPicker(options?)`
|
|
105
91
|
|
|
106
|
-
Opens the native gallery picker. Returns
|
|
92
|
+
Opens the native gallery picker. Returns `Promise<MediaAsset[]>`.
|
|
93
|
+
|
|
94
|
+
The promise rejects with `PickerError` when the user dismisses the picker without making a selection.
|
|
107
95
|
|
|
108
96
|
```ts
|
|
97
|
+
import { PictureSelector, MediaType, toPickerError } from 'react-native-picture-selector'
|
|
98
|
+
|
|
99
|
+
// Basic usage
|
|
100
|
+
const assets = await PictureSelector.openPicker()
|
|
101
|
+
|
|
102
|
+
// With options
|
|
109
103
|
const assets = await PictureSelector.openPicker({
|
|
110
104
|
mediaType: MediaType.ALL,
|
|
111
105
|
maxCount: 9,
|
|
112
106
|
compress: { enabled: true, quality: 0.7 },
|
|
113
107
|
})
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Rejects with `PickerError` when the user dismisses without selecting:
|
|
117
108
|
|
|
118
|
-
|
|
109
|
+
// Handle cancel
|
|
119
110
|
try {
|
|
120
111
|
const assets = await PictureSelector.openPicker()
|
|
121
|
-
} catch (
|
|
122
|
-
|
|
123
|
-
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const e = toPickerError(err)
|
|
114
|
+
if (e.code === 'CANCELLED') {
|
|
115
|
+
// user tapped back — not an error
|
|
124
116
|
}
|
|
125
117
|
}
|
|
126
118
|
```
|
|
@@ -129,9 +121,21 @@ try {
|
|
|
129
121
|
|
|
130
122
|
### `PictureSelector.openCamera(options?)`
|
|
131
123
|
|
|
132
|
-
Opens the device camera
|
|
124
|
+
Opens the device camera. Returns `Promise<MediaAsset[]>` (always one item).
|
|
133
125
|
|
|
134
126
|
```ts
|
|
127
|
+
// Take a photo
|
|
128
|
+
const [photo] = await PictureSelector.openCamera({
|
|
129
|
+
mediaType: MediaType.IMAGE,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Record a short video
|
|
133
|
+
const [video] = await PictureSelector.openCamera({
|
|
134
|
+
mediaType: MediaType.VIDEO,
|
|
135
|
+
maxVideoDuration: 30,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Take a photo and crop it immediately
|
|
135
139
|
const [photo] = await PictureSelector.openCamera({
|
|
136
140
|
mediaType: MediaType.IMAGE,
|
|
137
141
|
crop: { enabled: true, ratioX: 1, ratioY: 1 },
|
|
@@ -142,7 +146,7 @@ const [photo] = await PictureSelector.openCamera({
|
|
|
142
146
|
|
|
143
147
|
### `usePictureSelector(defaultOptions?)`
|
|
144
148
|
|
|
145
|
-
React hook that
|
|
149
|
+
React hook that wraps the picker with loading, error, and asset state.
|
|
146
150
|
|
|
147
151
|
```tsx
|
|
148
152
|
import { usePictureSelector, MediaType } from 'react-native-picture-selector'
|
|
@@ -156,10 +160,10 @@ function PhotoPicker() {
|
|
|
156
160
|
return (
|
|
157
161
|
<>
|
|
158
162
|
<Button onPress={() => pick()} title="Gallery" disabled={loading} />
|
|
159
|
-
<Button onPress={() => shoot()} title="Camera"
|
|
160
|
-
<Button onPress={clear}
|
|
163
|
+
<Button onPress={() => shoot()} title="Camera" disabled={loading} />
|
|
164
|
+
<Button onPress={clear} title="Clear" />
|
|
161
165
|
|
|
162
|
-
{error && <Text>Error: {error.message}</Text>}
|
|
166
|
+
{error && <Text style={{ color: 'red' }}>Error: {error.message}</Text>}
|
|
163
167
|
|
|
164
168
|
{assets.map((a, i) => (
|
|
165
169
|
<Image key={i} source={{ uri: a.uri }} style={{ width: 80, height: 80 }} />
|
|
@@ -169,13 +173,24 @@ function PhotoPicker() {
|
|
|
169
173
|
}
|
|
170
174
|
```
|
|
171
175
|
|
|
176
|
+
You can also override options per call:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// use hook defaults
|
|
180
|
+
await pick()
|
|
181
|
+
|
|
182
|
+
// override for this call only
|
|
183
|
+
await pick({ maxCount: 1, crop: { enabled: true, ratioX: 16, ratioY: 9 } })
|
|
184
|
+
await shoot({ mediaType: MediaType.VIDEO, maxVideoDuration: 15 })
|
|
185
|
+
```
|
|
186
|
+
|
|
172
187
|
#### Hook state
|
|
173
188
|
|
|
174
189
|
| Property | Type | Description |
|
|
175
190
|
|----------|------|-------------|
|
|
176
191
|
| `assets` | `MediaAsset[]` | Currently selected assets |
|
|
177
192
|
| `loading` | `boolean` | `true` while the picker is open |
|
|
178
|
-
| `error` | `PickerError \| null` | Last non-cancel error |
|
|
193
|
+
| `error` | `PickerError \| null` | Last non-cancel error, `null` otherwise |
|
|
179
194
|
|
|
180
195
|
#### Hook actions
|
|
181
196
|
|
|
@@ -183,105 +198,132 @@ function PhotoPicker() {
|
|
|
183
198
|
|--------|-----------|-------------|
|
|
184
199
|
| `pick` | `(options?) => Promise<MediaAsset[]>` | Open gallery picker |
|
|
185
200
|
| `shoot` | `(options?) => Promise<MediaAsset[]>` | Open camera |
|
|
186
|
-
| `clear` | `() => void` | Reset assets and error |
|
|
201
|
+
| `clear` | `() => void` | Reset `assets` and `error` to initial state |
|
|
187
202
|
|
|
188
|
-
> **Cancellation** — `CANCELLED` errors are swallowed by the hook. `pick()` and `shoot()` return `[]` on cancel instead of throwing.
|
|
203
|
+
> **Cancellation** — `CANCELLED` errors are silently swallowed by the hook. `pick()` and `shoot()` return `[]` on cancel instead of throwing.
|
|
189
204
|
|
|
190
205
|
---
|
|
191
206
|
|
|
192
207
|
## Configuration (`PictureSelectorOptions`)
|
|
193
208
|
|
|
194
|
-
All fields are optional.
|
|
209
|
+
All fields are optional. Omitting an option uses the native library default.
|
|
210
|
+
|
|
211
|
+
---
|
|
195
212
|
|
|
196
213
|
### Media type
|
|
197
214
|
|
|
198
215
|
| Field | Type | Default | Description |
|
|
199
216
|
|-------|------|---------|-------------|
|
|
200
217
|
| `mediaType` | `MediaType` | `IMAGE` | `IMAGE`, `VIDEO`, or `ALL` |
|
|
201
|
-
| `maxCount` | `number` | `1` | Maximum selectable items |
|
|
202
|
-
| `enableCamera` | `boolean` | `true` | Show camera
|
|
218
|
+
| `maxCount` | `number` | `1` | Maximum selectable items (gallery picker only) |
|
|
219
|
+
| `enableCamera` | `boolean` | `true` | Show camera shortcut inside gallery grid |
|
|
203
220
|
|
|
204
221
|
```ts
|
|
205
222
|
import { MediaType } from 'react-native-picture-selector'
|
|
206
223
|
|
|
207
|
-
//
|
|
224
|
+
// Images only, single selection
|
|
208
225
|
openPicker({ mediaType: MediaType.IMAGE })
|
|
209
226
|
|
|
210
|
-
//
|
|
211
|
-
openPicker({ mediaType: MediaType.VIDEO })
|
|
227
|
+
// Videos only, up to 3
|
|
228
|
+
openPicker({ mediaType: MediaType.VIDEO, maxCount: 3 })
|
|
212
229
|
|
|
213
|
-
//
|
|
230
|
+
// Mixed photo + video, up to 9
|
|
214
231
|
openPicker({ mediaType: MediaType.ALL, maxCount: 9 })
|
|
232
|
+
|
|
233
|
+
// Gallery without in-picker camera button
|
|
234
|
+
openPicker({ enableCamera: false })
|
|
215
235
|
```
|
|
216
236
|
|
|
217
237
|
---
|
|
218
238
|
|
|
219
239
|
### Cropping (`crop`)
|
|
220
240
|
|
|
221
|
-
Crop
|
|
241
|
+
Crop is applied automatically **after** the user selects a photo. It activates only when `maxCount === 1`.
|
|
222
242
|
|
|
223
243
|
| Field | Type | Default | Description |
|
|
224
244
|
|-------|------|---------|-------------|
|
|
225
|
-
| `crop.enabled` | `boolean` |
|
|
226
|
-
| `crop.ratioX` | `number` | `1` |
|
|
227
|
-
| `crop.ratioY` | `number` | `1` |
|
|
228
|
-
| `crop.freeStyle` | `boolean` | `false` |
|
|
245
|
+
| `crop.enabled` | `boolean` | `false` | Enable the crop editor |
|
|
246
|
+
| `crop.ratioX` | `number` | `1` | Crop width part of the ratio |
|
|
247
|
+
| `crop.ratioY` | `number` | `1` | Crop height part of the ratio |
|
|
248
|
+
| `crop.freeStyle` | `boolean` | `false` | Let the user freely resize the crop frame |
|
|
229
249
|
| `crop.circular` | `boolean` | `false` | Circular crop mask **(iOS only)** |
|
|
230
250
|
|
|
231
251
|
```ts
|
|
232
|
-
// Square crop
|
|
252
|
+
// Square crop (avatar)
|
|
233
253
|
openPicker({ crop: { enabled: true, ratioX: 1, ratioY: 1 } })
|
|
234
254
|
|
|
235
|
-
// 16:9
|
|
255
|
+
// Widescreen banner 16:9
|
|
236
256
|
openPicker({ crop: { enabled: true, ratioX: 16, ratioY: 9 } })
|
|
237
257
|
|
|
238
|
-
//
|
|
258
|
+
// Portrait 3:4
|
|
259
|
+
openPicker({ crop: { enabled: true, ratioX: 3, ratioY: 4 } })
|
|
260
|
+
|
|
261
|
+
// Free-form — user defines any size
|
|
239
262
|
openPicker({ crop: { enabled: true, freeStyle: true } })
|
|
240
263
|
|
|
241
|
-
// Circular (iOS)
|
|
264
|
+
// Circular (iOS only — falls back to 1:1 on Android)
|
|
242
265
|
openPicker({ crop: { enabled: true, circular: true } })
|
|
243
266
|
```
|
|
244
267
|
|
|
245
|
-
> Android
|
|
268
|
+
> **Android** uses the uCrop library for cropping.
|
|
269
|
+
> **iOS** uses the built-in HXPhotoPicker photo editor.
|
|
270
|
+
|
|
271
|
+
The cropped file path is available at `asset.editedUri`. The original (un-cropped) file is at `asset.uri`.
|
|
246
272
|
|
|
247
273
|
---
|
|
248
274
|
|
|
249
275
|
### Compression (`compress`)
|
|
250
276
|
|
|
277
|
+
Compression runs **after** crop (if enabled). The original file is never modified — a new compressed copy is returned.
|
|
278
|
+
|
|
251
279
|
| Field | Type | Default | Description |
|
|
252
280
|
|-------|------|---------|-------------|
|
|
253
|
-
| `compress.enabled` | `boolean` |
|
|
254
|
-
| `compress.quality` | `number` | `0.8` | JPEG quality `0.0
|
|
255
|
-
| `compress.maxWidth` | `number` | `1920` | Max output width (
|
|
256
|
-
| `compress.maxHeight` | `number` | `1920` | Max output height (
|
|
281
|
+
| `compress.enabled` | `boolean` | `false` | Enable compression |
|
|
282
|
+
| `compress.quality` | `number` | `0.8` | JPEG quality `0.0` (smallest) – `1.0` (lossless) |
|
|
283
|
+
| `compress.maxWidth` | `number` | `1920` | Max output width in pixels (aspect preserved) |
|
|
284
|
+
| `compress.maxHeight` | `number` | `1920` | Max output height in pixels (aspect preserved) |
|
|
257
285
|
|
|
258
286
|
```ts
|
|
287
|
+
// Light compression for fast upload
|
|
259
288
|
openPicker({
|
|
260
|
-
compress: {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
},
|
|
289
|
+
compress: { enabled: true, quality: 0.8, maxWidth: 1920, maxHeight: 1920 },
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Heavy compression for thumbnails
|
|
293
|
+
openPicker({
|
|
294
|
+
compress: { enabled: true, quality: 0.5, maxWidth: 640, maxHeight: 640 },
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// Compress but keep original dimensions
|
|
298
|
+
openPicker({
|
|
299
|
+
compress: { enabled: true, quality: 0.7 },
|
|
266
300
|
})
|
|
267
301
|
```
|
|
268
302
|
|
|
269
|
-
> Android uses
|
|
303
|
+
> **Android** uses the Luban library. Files under 100 KB are passed through without compression.
|
|
304
|
+
> **iOS** uses HXPhotoPicker's native compression pipeline.
|
|
270
305
|
|
|
271
306
|
---
|
|
272
307
|
|
|
273
308
|
### Video limits
|
|
274
309
|
|
|
275
|
-
| Field | Type | Description |
|
|
276
|
-
|
|
277
|
-
| `maxVideoDuration` | `number` |
|
|
278
|
-
| `minVideoDuration` | `number` |
|
|
310
|
+
| Field | Type | Unit | Description |
|
|
311
|
+
|-------|------|------|-------------|
|
|
312
|
+
| `maxVideoDuration` | `number` | seconds | Reject (hide) videos longer than this |
|
|
313
|
+
| `minVideoDuration` | `number` | seconds | Reject (hide) videos shorter than this |
|
|
279
314
|
|
|
280
315
|
```ts
|
|
316
|
+
// Only show videos between 3 and 60 seconds
|
|
281
317
|
openPicker({
|
|
282
318
|
mediaType: MediaType.VIDEO,
|
|
283
|
-
maxVideoDuration: 60,
|
|
284
319
|
minVideoDuration: 3,
|
|
320
|
+
maxVideoDuration: 60,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// Camera: stop recording automatically at 15 s
|
|
324
|
+
openCamera({
|
|
325
|
+
mediaType: MediaType.VIDEO,
|
|
326
|
+
maxVideoDuration: 15,
|
|
285
327
|
})
|
|
286
328
|
```
|
|
287
329
|
|
|
@@ -291,30 +333,49 @@ openPicker({
|
|
|
291
333
|
|
|
292
334
|
| Field | Type | Description |
|
|
293
335
|
|-------|------|-------------|
|
|
294
|
-
| `theme` | `PickerTheme` | Preset theme
|
|
295
|
-
| `themeColor` | `string` | Hex accent color, e.g. `"#007AFF"` (
|
|
336
|
+
| `theme` | `PickerTheme` | Preset theme for the picker UI (Android only) |
|
|
337
|
+
| `themeColor` | `string` | Hex accent color, e.g. `"#007AFF"` (both platforms) |
|
|
296
338
|
|
|
297
339
|
```ts
|
|
298
340
|
import { PickerTheme } from 'react-native-picture-selector'
|
|
299
341
|
|
|
300
|
-
// Android WeChat
|
|
342
|
+
// Android — WeChat-style dark green theme
|
|
301
343
|
openPicker({ theme: PickerTheme.WECHAT })
|
|
302
344
|
|
|
303
|
-
//
|
|
304
|
-
openPicker({
|
|
345
|
+
// Android — clean white theme
|
|
346
|
+
openPicker({ theme: PickerTheme.WHITE })
|
|
347
|
+
|
|
348
|
+
// Android — dark / night theme
|
|
349
|
+
openPicker({ theme: PickerTheme.DARK })
|
|
350
|
+
|
|
351
|
+
// Both platforms — custom accent colour
|
|
352
|
+
openPicker({ themeColor: '#1DB954' }) // Spotify green
|
|
353
|
+
openPicker({ themeColor: '#007AFF' }) // iOS blue
|
|
354
|
+
openPicker({ themeColor: '#E91E63' }) // Pink
|
|
305
355
|
```
|
|
306
356
|
|
|
357
|
+
> `theme` enum values (`WECHAT`, `WHITE`, `DARK`) are ignored on iOS. Use `themeColor` for cross-platform tinting.
|
|
358
|
+
|
|
307
359
|
---
|
|
308
360
|
|
|
309
361
|
### Pre-selected assets
|
|
310
362
|
|
|
311
|
-
Pass `file://` URIs of previously selected files to
|
|
363
|
+
Pass `file://` URIs of previously selected files to pre-check them in the gallery grid.
|
|
312
364
|
|
|
313
365
|
```ts
|
|
314
|
-
|
|
366
|
+
// Store selection
|
|
367
|
+
const [selectedAssets, setSelectedAssets] = useState<MediaAsset[]>([])
|
|
368
|
+
|
|
369
|
+
// First open — nothing pre-selected
|
|
370
|
+
const assets = await PictureSelector.openPicker({ maxCount: 9 })
|
|
371
|
+
setSelectedAssets(assets)
|
|
372
|
+
|
|
373
|
+
// Re-open — restore previous selection
|
|
374
|
+
const updated = await PictureSelector.openPicker({
|
|
315
375
|
maxCount: 9,
|
|
316
|
-
selectedAssets:
|
|
376
|
+
selectedAssets: selectedAssets.map((a) => a.uri),
|
|
317
377
|
})
|
|
378
|
+
setSelectedAssets(updated)
|
|
318
379
|
```
|
|
319
380
|
|
|
320
381
|
---
|
|
@@ -325,64 +386,101 @@ Every item in the returned array has this shape:
|
|
|
325
386
|
|
|
326
387
|
| Field | Type | Description |
|
|
327
388
|
|-------|------|-------------|
|
|
328
|
-
| `uri` | `string` | `file://`
|
|
329
|
-
| `type` | `"image" \| "video"` | Media
|
|
389
|
+
| `uri` | `string` | `file://` path of the final file (compressed / cropped if applicable) |
|
|
390
|
+
| `type` | `"image" \| "video"` | Media kind |
|
|
330
391
|
| `mimeType` | `string` | e.g. `"image/jpeg"`, `"video/mp4"` |
|
|
331
392
|
| `width` | `number` | Width in pixels |
|
|
332
393
|
| `height` | `number` | Height in pixels |
|
|
333
|
-
| `duration` | `number` | Duration in **milliseconds** (0 for images) |
|
|
334
|
-
| `fileName` | `string` | File name with extension |
|
|
335
|
-
| `fileSize` | `number` | Size in bytes |
|
|
336
|
-
| `editedUri?` | `string` |
|
|
337
|
-
| `isOriginal?` | `boolean` | iOS: user tapped the
|
|
338
|
-
| `bucketName?` | `string` | Android: album/folder name |
|
|
394
|
+
| `duration` | `number` | Duration in **milliseconds** (`0` for images) |
|
|
395
|
+
| `fileName` | `string` | File name with extension, e.g. `"photo.jpg"` |
|
|
396
|
+
| `fileSize` | `number` | Size in **bytes** |
|
|
397
|
+
| `editedUri?` | `string` | Path of the edited file after crop (if crop was applied) |
|
|
398
|
+
| `isOriginal?` | `boolean` | iOS only: `true` if the user tapped "Original" in the picker |
|
|
399
|
+
| `bucketName?` | `string` | Android only: album / folder name the file came from |
|
|
339
400
|
|
|
340
401
|
```ts
|
|
341
402
|
const [asset] = await PictureSelector.openPicker()
|
|
342
403
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
404
|
+
asset.uri // "file:///data/user/0/com.myapp/cache/image.jpg"
|
|
405
|
+
asset.type // "image"
|
|
406
|
+
asset.mimeType // "image/jpeg"
|
|
407
|
+
asset.width // 4032
|
|
408
|
+
asset.height // 3024
|
|
409
|
+
asset.fileSize // 2457600 (bytes ≈ 2.4 MB)
|
|
410
|
+
asset.duration // 0 (image has no duration)
|
|
411
|
+
asset.fileName // "IMG_20240101_120000.jpg"
|
|
412
|
+
asset.editedUri // "file:///...cropped.jpg" or undefined
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Video example:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const [video] = await PictureSelector.openPicker({ mediaType: MediaType.VIDEO })
|
|
419
|
+
|
|
420
|
+
video.type // "video"
|
|
421
|
+
video.mimeType // "video/mp4"
|
|
422
|
+
video.duration // 12500 (12.5 seconds in ms)
|
|
423
|
+
video.fileSize // 8388608 (≈ 8 MB)
|
|
351
424
|
```
|
|
352
425
|
|
|
353
426
|
---
|
|
354
427
|
|
|
355
428
|
## Error handling
|
|
356
429
|
|
|
430
|
+
Wrap picker calls in try/catch and use `toPickerError` to normalise any error into a typed `PickerError`:
|
|
431
|
+
|
|
357
432
|
```ts
|
|
358
|
-
import { toPickerError } from 'react-native-picture-selector'
|
|
433
|
+
import { PictureSelector, toPickerError } from 'react-native-picture-selector'
|
|
359
434
|
import type { PickerError } from 'react-native-picture-selector'
|
|
360
435
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
436
|
+
async function pickPhoto() {
|
|
437
|
+
try {
|
|
438
|
+
const assets = await PictureSelector.openPicker()
|
|
439
|
+
return assets
|
|
440
|
+
} catch (err) {
|
|
441
|
+
const e = toPickerError(err)
|
|
442
|
+
|
|
443
|
+
switch (e.code) {
|
|
444
|
+
case 'CANCELLED':
|
|
445
|
+
// User dismissed — not an error, just return empty
|
|
446
|
+
return []
|
|
447
|
+
|
|
448
|
+
case 'PERMISSION_DENIED':
|
|
449
|
+
// Show a settings prompt
|
|
450
|
+
Alert.alert(
|
|
451
|
+
'Permission required',
|
|
452
|
+
'Please allow photo access in Settings.',
|
|
453
|
+
[{ text: 'Open Settings', onPress: () => Linking.openSettings() }]
|
|
454
|
+
)
|
|
455
|
+
return []
|
|
456
|
+
|
|
457
|
+
case 'UNKNOWN':
|
|
458
|
+
default:
|
|
459
|
+
console.error('[Picker]', e.message)
|
|
460
|
+
return []
|
|
461
|
+
}
|
|
377
462
|
}
|
|
378
463
|
}
|
|
379
464
|
```
|
|
380
465
|
|
|
466
|
+
### Error codes
|
|
467
|
+
|
|
381
468
|
| Code | When |
|
|
382
469
|
|------|------|
|
|
383
|
-
| `CANCELLED` | User tapped Back
|
|
384
|
-
| `PERMISSION_DENIED` | Runtime permission not granted |
|
|
385
|
-
| `UNKNOWN` | Any
|
|
470
|
+
| `CANCELLED` | User tapped Back or Cancel without selecting |
|
|
471
|
+
| `PERMISSION_DENIED` | Runtime permission was not granted |
|
|
472
|
+
| `UNKNOWN` | Any unexpected native error |
|
|
473
|
+
|
|
474
|
+
### `toPickerError(err)`
|
|
475
|
+
|
|
476
|
+
Normalises an unknown thrown value into a `PickerError` object:
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
interface PickerError {
|
|
480
|
+
code: 'CANCELLED' | 'PERMISSION_DENIED' | 'UNKNOWN'
|
|
481
|
+
message: string
|
|
482
|
+
}
|
|
483
|
+
```
|
|
386
484
|
|
|
387
485
|
---
|
|
388
486
|
|
|
@@ -391,32 +489,41 @@ try {
|
|
|
391
489
|
```ts
|
|
392
490
|
import { MediaType, PickerTheme } from 'react-native-picture-selector'
|
|
393
491
|
|
|
394
|
-
|
|
395
|
-
MediaType.
|
|
396
|
-
MediaType.
|
|
492
|
+
// MediaType
|
|
493
|
+
MediaType.IMAGE // photos only
|
|
494
|
+
MediaType.VIDEO // videos only
|
|
495
|
+
MediaType.ALL // photos + videos
|
|
397
496
|
|
|
398
|
-
|
|
399
|
-
PickerTheme.
|
|
400
|
-
PickerTheme.
|
|
401
|
-
PickerTheme.
|
|
497
|
+
// PickerTheme (Android only)
|
|
498
|
+
PickerTheme.DEFAULT // system default
|
|
499
|
+
PickerTheme.WECHAT // WeChat green style
|
|
500
|
+
PickerTheme.WHITE // light / white style
|
|
501
|
+
PickerTheme.DARK // dark / night style
|
|
402
502
|
```
|
|
403
503
|
|
|
404
504
|
---
|
|
405
505
|
|
|
406
506
|
## Common recipes
|
|
407
507
|
|
|
408
|
-
### Avatar picker
|
|
508
|
+
### Avatar picker — square crop + resize
|
|
409
509
|
|
|
410
510
|
```ts
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
511
|
+
async function pickAvatar(): Promise<string | null> {
|
|
512
|
+
try {
|
|
513
|
+
const [asset] = await PictureSelector.openPicker({
|
|
514
|
+
mediaType: MediaType.IMAGE,
|
|
515
|
+
maxCount: 1,
|
|
516
|
+
crop: { enabled: true, ratioX: 1, ratioY: 1 },
|
|
517
|
+
compress: { enabled: true, quality: 0.85, maxWidth: 512, maxHeight: 512 },
|
|
518
|
+
})
|
|
519
|
+
return asset.uri
|
|
520
|
+
} catch {
|
|
521
|
+
return null
|
|
522
|
+
}
|
|
523
|
+
}
|
|
417
524
|
```
|
|
418
525
|
|
|
419
|
-
### Multi-photo
|
|
526
|
+
### Multi-photo for a post
|
|
420
527
|
|
|
421
528
|
```ts
|
|
422
529
|
const photos = await PictureSelector.openPicker({
|
|
@@ -424,9 +531,10 @@ const photos = await PictureSelector.openPicker({
|
|
|
424
531
|
maxCount: 9,
|
|
425
532
|
compress: { enabled: true, quality: 0.7, maxWidth: 1920, maxHeight: 1920 },
|
|
426
533
|
})
|
|
534
|
+
// photos[0].uri … photos[8].uri
|
|
427
535
|
```
|
|
428
536
|
|
|
429
|
-
### Short video
|
|
537
|
+
### Short video clip picker
|
|
430
538
|
|
|
431
539
|
```ts
|
|
432
540
|
const [clip] = await PictureSelector.openPicker({
|
|
@@ -435,123 +543,130 @@ const [clip] = await PictureSelector.openPicker({
|
|
|
435
543
|
minVideoDuration: 1,
|
|
436
544
|
maxVideoDuration: 30,
|
|
437
545
|
})
|
|
546
|
+
|
|
547
|
+
console.log(`${clip.duration / 1000}s, ${clip.fileSize} bytes`)
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Banner / cover image — 16:9 crop
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
const [banner] = await PictureSelector.openPicker({
|
|
554
|
+
mediaType: MediaType.IMAGE,
|
|
555
|
+
maxCount: 1,
|
|
556
|
+
crop: { enabled: true, ratioX: 16, ratioY: 9 },
|
|
557
|
+
compress: { enabled: true, quality: 0.8, maxWidth: 1280, maxHeight: 720 },
|
|
558
|
+
})
|
|
438
559
|
```
|
|
439
560
|
|
|
440
|
-
###
|
|
561
|
+
### Camera capture → instant upload
|
|
441
562
|
|
|
442
563
|
```ts
|
|
443
564
|
async function captureAndUpload() {
|
|
444
565
|
const [photo] = await PictureSelector.openCamera({
|
|
445
566
|
mediaType: MediaType.IMAGE,
|
|
446
|
-
compress: { enabled: true, quality: 0.8 },
|
|
567
|
+
compress: { enabled: true, quality: 0.8, maxWidth: 1920, maxHeight: 1920 },
|
|
447
568
|
})
|
|
448
569
|
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
uri:
|
|
570
|
+
const form = new FormData()
|
|
571
|
+
form.append('file', {
|
|
572
|
+
uri: photo.uri,
|
|
452
573
|
type: photo.mimeType,
|
|
453
574
|
name: photo.fileName,
|
|
454
575
|
} as any)
|
|
455
576
|
|
|
456
|
-
await fetch('https://api.
|
|
577
|
+
const res = await fetch('https://your-api.com/upload', {
|
|
578
|
+
method: 'POST',
|
|
579
|
+
body: form,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
return res.json()
|
|
457
583
|
}
|
|
458
584
|
```
|
|
459
585
|
|
|
460
|
-
### Chat
|
|
586
|
+
### Chat input — hook version
|
|
461
587
|
|
|
462
588
|
```tsx
|
|
463
|
-
|
|
589
|
+
import React from 'react'
|
|
590
|
+
import { View, Pressable, Text, Image, ScrollView } from 'react-native'
|
|
591
|
+
import { usePictureSelector, MediaType } from 'react-native-picture-selector'
|
|
592
|
+
|
|
593
|
+
function ChatInput({ onSend }: { onSend: (uris: string[]) => void }) {
|
|
464
594
|
const { assets, loading, pick, clear } = usePictureSelector({
|
|
465
595
|
mediaType: MediaType.ALL,
|
|
466
596
|
maxCount: 9,
|
|
597
|
+
compress: { enabled: true, quality: 0.7 },
|
|
467
598
|
})
|
|
468
599
|
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
await uploadAll(assets)
|
|
600
|
+
const handleSend = () => {
|
|
601
|
+
onSend(assets.map((a) => a.uri))
|
|
472
602
|
clear()
|
|
473
603
|
}
|
|
474
604
|
|
|
475
605
|
return (
|
|
476
606
|
<View>
|
|
477
607
|
<Pressable onPress={() => pick()} disabled={loading}>
|
|
478
|
-
<Text
|
|
608
|
+
<Text>{loading ? 'Opening…' : '📎 Attach'}</Text>
|
|
479
609
|
</Pressable>
|
|
480
610
|
|
|
481
611
|
{assets.length > 0 && (
|
|
482
|
-
|
|
483
|
-
<
|
|
484
|
-
|
|
612
|
+
<>
|
|
613
|
+
<ScrollView horizontal>
|
|
614
|
+
{assets.map((a, i) => (
|
|
615
|
+
<Image
|
|
616
|
+
key={i}
|
|
617
|
+
source={{ uri: a.uri }}
|
|
618
|
+
style={{ width: 64, height: 64, marginRight: 4, borderRadius: 8 }}
|
|
619
|
+
/>
|
|
620
|
+
))}
|
|
621
|
+
</ScrollView>
|
|
622
|
+
|
|
623
|
+
<Pressable onPress={handleSend}>
|
|
624
|
+
<Text>Send {assets.length} file{assets.length > 1 ? 's' : ''}</Text>
|
|
625
|
+
</Pressable>
|
|
626
|
+
|
|
627
|
+
<Pressable onPress={clear}>
|
|
628
|
+
<Text>✕ Cancel</Text>
|
|
629
|
+
</Pressable>
|
|
630
|
+
</>
|
|
485
631
|
)}
|
|
486
632
|
</View>
|
|
487
633
|
)
|
|
488
634
|
}
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
---
|
|
492
|
-
|
|
493
|
-
## Architecture
|
|
494
635
|
|
|
636
|
+
export default ChatInput
|
|
495
637
|
```
|
|
496
|
-
JavaScript / TypeScript
|
|
497
|
-
PictureSelector.openPicker()
|
|
498
|
-
usePictureSelector()
|
|
499
|
-
│
|
|
500
|
-
│ JSI (zero serialization overhead)
|
|
501
|
-
│ react-native-nitro-modules
|
|
502
|
-
▼
|
|
503
|
-
HybridPictureSelector
|
|
504
|
-
┌──────────────────────┬───────────────────────┐
|
|
505
|
-
│ Android (Kotlin) │ iOS (Swift 5.9) │
|
|
506
|
-
│ │ │
|
|
507
|
-
│ PictureSelector v3 │ HXPhotoPicker v5 │
|
|
508
|
-
│ ├─ GlideEngine │ ├─ PickerConfiguration│
|
|
509
|
-
│ ├─ UCropEngine │ ├─ PhotoPickerController│
|
|
510
|
-
│ └─ LubanCompress │ └─ async result map │
|
|
511
|
-
└──────────────────────┴───────────────────────┘
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
Communication goes through a **statically compiled JSI binding** generated by `nitrogen`. There is no JSON serialization and no async bridge queue — results are returned via native Promises resolved directly on the JS thread.
|
|
515
|
-
|
|
516
|
-
---
|
|
517
|
-
|
|
518
|
-
## Development workflow
|
|
519
|
-
|
|
520
|
-
### Generate native bridge code
|
|
521
638
|
|
|
522
|
-
|
|
639
|
+
### Profile settings — restore previous selection
|
|
523
640
|
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
### Run example
|
|
641
|
+
```tsx
|
|
642
|
+
function ProfilePhotoScreen() {
|
|
643
|
+
const [photo, setPhoto] = useState<MediaAsset | null>(null)
|
|
644
|
+
|
|
645
|
+
const changePhoto = async () => {
|
|
646
|
+
try {
|
|
647
|
+
const [asset] = await PictureSelector.openPicker({
|
|
648
|
+
mediaType: MediaType.IMAGE,
|
|
649
|
+
maxCount: 1,
|
|
650
|
+
crop: { enabled: true, ratioX: 1, ratioY: 1 },
|
|
651
|
+
compress: { enabled: true, quality: 0.9, maxWidth: 512, maxHeight: 512 },
|
|
652
|
+
// restore the current photo if user re-opens
|
|
653
|
+
selectedAssets: photo ? [photo.uri] : [],
|
|
654
|
+
})
|
|
655
|
+
setPhoto(asset)
|
|
656
|
+
} catch {
|
|
657
|
+
// cancelled — do nothing
|
|
658
|
+
}
|
|
659
|
+
}
|
|
545
660
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
661
|
+
return (
|
|
662
|
+
<Pressable onPress={changePhoto}>
|
|
663
|
+
{photo
|
|
664
|
+
? <Image source={{ uri: photo.uri }} style={{ width: 100, height: 100, borderRadius: 50 }} />
|
|
665
|
+
: <Text>Tap to set photo</Text>
|
|
666
|
+
}
|
|
667
|
+
</Pressable>
|
|
668
|
+
)
|
|
669
|
+
}
|
|
555
670
|
```
|
|
556
671
|
|
|
557
672
|
---
|
|
@@ -561,46 +676,46 @@ npx react-native run-android
|
|
|
561
676
|
### Android
|
|
562
677
|
|
|
563
678
|
- Minimum SDK: **24** (Android 7.0)
|
|
564
|
-
- PictureSelector v3 handles runtime permissions internally. On Android 13+ it requests `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO
|
|
565
|
-
- The `theme` option
|
|
679
|
+
- PictureSelector v3 handles runtime permissions internally. On Android 13+ it requests `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO`; on older versions it requests `READ_EXTERNAL_STORAGE`.
|
|
680
|
+
- The `theme` option maps to PictureSelector's built-in style presets. `PickerTheme.WECHAT` is the most polished preset.
|
|
566
681
|
- Video files captured by camera are placed in the app cache directory.
|
|
567
682
|
- Glide is used for thumbnail rendering inside the gallery grid.
|
|
568
|
-
- uCrop handles all cropping; circular crop
|
|
569
|
-
- Luban handles compression; files under 100 KB
|
|
683
|
+
- uCrop handles all cropping; circular crop falls back to a 1:1 fixed-ratio crop.
|
|
684
|
+
- Luban handles JPEG compression; files under 100 KB bypass compression unchanged.
|
|
570
685
|
|
|
571
686
|
### iOS
|
|
572
687
|
|
|
573
688
|
- Minimum deployment target: **iOS 13.0**
|
|
574
689
|
- Swift 5.9 is required for the C++ interop used by Nitro Modules.
|
|
575
|
-
- HXPhotoPicker requests permissions automatically the first time the picker is opened. The `Info.plist` keys must be present or the app will crash.
|
|
576
|
-
- The `circular` crop option is iOS-only;
|
|
577
|
-
- `isOriginal` in `MediaAsset` is only populated on iOS (when the user taps the "Original"
|
|
690
|
+
- HXPhotoPicker requests permissions automatically the first time the picker is opened. The `Info.plist` keys **must** be present or the app will crash on first use.
|
|
691
|
+
- The `circular` crop option is iOS-only; it renders a circular preview mask but saves a square file.
|
|
692
|
+
- `isOriginal` in `MediaAsset` is only populated on iOS (when the user taps the "Original" toggle).
|
|
578
693
|
- `bucketName` is only populated on Android.
|
|
579
|
-
- `themeColor` sets HXPhotoPicker's global `themeColor` property.
|
|
580
|
-
-
|
|
694
|
+
- `themeColor` sets HXPhotoPicker's global `themeColor` property (navigation bar, selection indicators).
|
|
695
|
+
- `theme` enum values (`WECHAT`, `WHITE`, `DARK`) are ignored on iOS — use `themeColor` instead.
|
|
581
696
|
|
|
582
697
|
---
|
|
583
698
|
|
|
584
699
|
## Permissions summary
|
|
585
700
|
|
|
586
|
-
### Android
|
|
701
|
+
### Android — declared automatically
|
|
587
702
|
|
|
588
|
-
| Permission |
|
|
589
|
-
|
|
590
|
-
| `CAMERA` |
|
|
591
|
-
| `READ_EXTERNAL_STORAGE` | Android
|
|
592
|
-
| `READ_MEDIA_IMAGES` | Android 13
|
|
593
|
-
| `READ_MEDIA_VIDEO` | Android 13
|
|
594
|
-
| `WRITE_EXTERNAL_STORAGE` | Android
|
|
703
|
+
| Permission | API level |
|
|
704
|
+
|-----------|-----------|
|
|
705
|
+
| `CAMERA` | All |
|
|
706
|
+
| `READ_EXTERNAL_STORAGE` | ≤ API 32 (Android 12) |
|
|
707
|
+
| `READ_MEDIA_IMAGES` | ≥ API 33 (Android 13) |
|
|
708
|
+
| `READ_MEDIA_VIDEO` | ≥ API 33 (Android 13) |
|
|
709
|
+
| `WRITE_EXTERNAL_STORAGE` | ≤ API 28 (Android 9) |
|
|
595
710
|
|
|
596
|
-
### iOS
|
|
711
|
+
### iOS — `Info.plist` keys required
|
|
597
712
|
|
|
598
713
|
| Key | Purpose |
|
|
599
714
|
|-----|---------|
|
|
600
|
-
| `NSPhotoLibraryUsageDescription` | Read photos
|
|
601
|
-
| `NSPhotoLibraryAddUsageDescription` | Save to library |
|
|
602
|
-
| `NSCameraUsageDescription` |
|
|
603
|
-
| `NSMicrophoneUsageDescription` |
|
|
715
|
+
| `NSPhotoLibraryUsageDescription` | Read photos and videos from the library |
|
|
716
|
+
| `NSPhotoLibraryAddUsageDescription` | Save captured media to the library |
|
|
717
|
+
| `NSCameraUsageDescription` | Access the camera for capture |
|
|
718
|
+
| `NSMicrophoneUsageDescription` | Record audio with video |
|
|
604
719
|
|
|
605
720
|
---
|
|
606
721
|
|
|
@@ -610,18 +725,45 @@ npx react-native run-android
|
|
|
610
725
|
|---------|--------|
|
|
611
726
|
| Audio file selection | Not supported |
|
|
612
727
|
| iCloud Photos (iOS) | Partial — depends on HXPhotoPicker internals |
|
|
613
|
-
|
|
|
728
|
+
| Live Photos | Not exposed |
|
|
729
|
+
| Animated GIF selection | Not exposed |
|
|
614
730
|
| Background upload / processing | Out of scope |
|
|
615
731
|
| Save to gallery | Out of scope |
|
|
616
|
-
| Document picker | Out of scope |
|
|
732
|
+
| Document picker (PDF, etc.) | Out of scope |
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
## Architecture
|
|
737
|
+
|
|
738
|
+
```
|
|
739
|
+
JavaScript / TypeScript
|
|
740
|
+
PictureSelector.openPicker()
|
|
741
|
+
PictureSelector.openCamera()
|
|
742
|
+
usePictureSelector()
|
|
743
|
+
│
|
|
744
|
+
│ JSI — zero serialization, zero bridge queue
|
|
745
|
+
│ react-native-nitro-modules
|
|
746
|
+
▼
|
|
747
|
+
HybridPictureSelector
|
|
748
|
+
┌──────────────────────┬────────────────────────┐
|
|
749
|
+
│ Android (Kotlin) │ iOS (Swift 5.9) │
|
|
750
|
+
│ │ │
|
|
751
|
+
│ PictureSelector v3 │ HXPhotoPicker v5 │
|
|
752
|
+
│ ├─ GlideEngine │ ├─ PickerConfiguration│
|
|
753
|
+
│ ├─ UCropEngine │ ├─ PhotoPickerController│
|
|
754
|
+
│ └─ LubanCompress │ └─ async result map │
|
|
755
|
+
└──────────────────────┴────────────────────────┘
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Results travel from native to JavaScript through a **statically compiled JSI binding** — no JSON serialisation, no async bridge queue, no reflection. Native promises resolve directly on the JS thread.
|
|
617
759
|
|
|
618
760
|
---
|
|
619
761
|
|
|
620
762
|
## License
|
|
621
763
|
|
|
622
|
-
MIT
|
|
764
|
+
MIT
|
|
623
765
|
|
|
624
|
-
Native
|
|
625
|
-
-
|
|
626
|
-
-
|
|
627
|
-
-
|
|
766
|
+
Native dependencies:
|
|
767
|
+
- LuckSiege/PictureSelector — Apache 2.0
|
|
768
|
+
- SilenceLove/HXPhotoPicker — MIT
|
|
769
|
+
- mrousavy/nitro — MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-picture-selector",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "High-performance photo/video picker for React Native using Nitro Modules",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -33,10 +33,6 @@
|
|
|
33
33
|
"camera",
|
|
34
34
|
"gallery"
|
|
35
35
|
],
|
|
36
|
-
"repository": {
|
|
37
|
-
"type": "git",
|
|
38
|
-
"url": "https://github.com/yourorg/react-native-picture-selector.git"
|
|
39
|
-
},
|
|
40
36
|
"license": "MIT",
|
|
41
37
|
"peerDependencies": {
|
|
42
38
|
"react": "*",
|