react-native-picture-selector 1.0.1 → 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.
Files changed (2) hide show
  1. package/README.md +377 -235
  2. 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 **[Nitro Modules](https://github.com/mrousavy/nitro)** (JSI, zero bridge overhead).
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 | [LuckSiege/PictureSelector](https://github.com/LuckSiege/PictureSelector) v3.11.2 | Android 7.0 (API 24) |
8
- | iOS | [SilenceLove/HXPhotoPicker](https://github.com/SilenceLove/HXPhotoPicker) v5.0.5 | iOS 13.0 |
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
- Add the FileProvider authority to your app's `AndroidManifest.xml` if you override the default:
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) // file:///...
97
- console.log(assets[0].width) // 1280
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 a `Promise<MediaAsset[]>`.
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
- ```ts
109
+ // Handle cancel
119
110
  try {
120
111
  const assets = await PictureSelector.openPicker()
121
- } catch (e) {
122
- if ((e as PickerError).code === 'CANCELLED') {
123
- // user tapped back / cancel — not an error
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 for capture. Returns `Promise<MediaAsset[]>` (always one item).
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 manages loading state and the selected asset list.
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" disabled={loading} />
160
- <Button onPress={clear} title="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 button inside the gallery |
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
- // images only
224
+ // Images only, single selection
208
225
  openPicker({ mediaType: MediaType.IMAGE })
209
226
 
210
- // videos only
211
- openPicker({ mediaType: MediaType.VIDEO })
227
+ // Videos only, up to 3
228
+ openPicker({ mediaType: MediaType.VIDEO, maxCount: 3 })
212
229
 
213
- // mixed, up to 9
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 applies only when `maxCount === 1`.
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` | | Enable crop after selection |
226
- | `crop.ratioX` | `number` | `1` | Width ratio |
227
- | `crop.ratioY` | `number` | `1` | Height ratio |
228
- | `crop.freeStyle` | `boolean` | `false` | Free-form crop (ignores ratio) |
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 crop
255
+ // Widescreen banner 16:9
236
256
  openPicker({ crop: { enabled: true, ratioX: 16, ratioY: 9 } })
237
257
 
238
- // Free-style
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 uses **uCrop**. iOS uses the built-in **HXPhotoPicker editor**.
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` | | Enable compression |
254
- | `compress.quality` | `number` | `0.8` | JPEG quality `0.0`–`1.0` |
255
- | `compress.maxWidth` | `number` | `1920` | Max output width (px) |
256
- | `compress.maxHeight` | `number` | `1920` | Max output height (px) |
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
- enabled: true,
262
- quality: 0.6,
263
- maxWidth: 1280,
264
- maxHeight: 1280,
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 **Luban**. iOS uses **HXPhotoPicker** native compression.
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` | Max video duration in **seconds** |
278
- | `minVideoDuration` | `number` | Min video duration in **seconds** |
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: `DEFAULT`, `WECHAT`, `WHITE`, `DARK` (Android) |
295
- | `themeColor` | `string` | Hex accent color, e.g. `"#007AFF"` (iOS: `themeColor`; Android: accent) |
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 style
342
+ // Android WeChat-style dark green theme
301
343
  openPicker({ theme: PickerTheme.WECHAT })
302
344
 
303
- // Custom accent color (both platforms)
304
- openPicker({ themeColor: '#1DB954' })
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 restore the selection state:
363
+ Pass `file://` URIs of previously selected files to pre-check them in the gallery grid.
312
364
 
313
365
  ```ts
314
- openPicker({
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: previousAssets.map((a) => a.uri),
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://` URI of the final file |
329
- | `type` | `"image" \| "video"` | Media type |
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` | URI after crop/edit (when editing was applied) |
337
- | `isOriginal?` | `boolean` | iOS: user tapped the Original button |
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
- console.log(asset.uri) // "file:///data/user/0/.../image.jpg"
344
- console.log(asset.type) // "image"
345
- console.log(asset.mimeType) // "image/jpeg"
346
- console.log(asset.width) // 4032
347
- console.log(asset.height) // 3024
348
- console.log(asset.fileSize) // 2457600 (bytes)
349
- console.log(asset.duration) // 0 (image)
350
- console.log(asset.editedUri) // "file:///..." or undefined
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
- try {
362
- const assets = await PictureSelector.openPicker()
363
- } catch (err) {
364
- const e = toPickerError(err)
365
-
366
- switch (e.code) {
367
- case 'CANCELLED':
368
- // user dismissed — treat as no-op
369
- break
370
- case 'PERMISSION_DENIED':
371
- // show settings prompt
372
- break
373
- case 'UNKNOWN':
374
- // unexpected error
375
- console.error(e.message)
376
- break
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 / Cancel |
384
- | `PERMISSION_DENIED` | Runtime permission not granted |
385
- | `UNKNOWN` | Any other native error |
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
- MediaType.IMAGE // 'image'
395
- MediaType.VIDEO // 'video'
396
- MediaType.ALL // 'all'
492
+ // MediaType
493
+ MediaType.IMAGE // photos only
494
+ MediaType.VIDEO // videos only
495
+ MediaType.ALL // photos + videos
397
496
 
398
- PickerTheme.DEFAULT // 'default'
399
- PickerTheme.WECHAT // 'wechat' (Android)
400
- PickerTheme.WHITE // 'white' (Android)
401
- PickerTheme.DARK // 'dark' (Android)
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 (square crop)
508
+ ### Avatar picker square crop + resize
409
509
 
410
510
  ```ts
411
- const [avatar] = await PictureSelector.openPicker({
412
- mediaType: MediaType.IMAGE,
413
- maxCount: 1,
414
- crop: { enabled: true, ratioX: 1, ratioY: 1 },
415
- compress: { enabled: true, quality: 0.85, maxWidth: 512, maxHeight: 512 },
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 with compression
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 clips
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
- ### Upload immediately after capture
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 body = new FormData()
450
- body.append('file', {
451
- uri: photo.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.example.com/upload', { method: 'POST', body })
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-style picker with hook
586
+ ### Chat input hook version
461
587
 
462
588
  ```tsx
463
- function ChatInput() {
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 send = async () => {
470
- if (assets.length === 0) return
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>📎 Attach</Text>
608
+ <Text>{loading ? 'Opening…' : '📎 Attach'}</Text>
479
609
  </Pressable>
480
610
 
481
611
  {assets.length > 0 && (
482
- <Pressable onPress={send}>
483
- <Text>Send ({assets.length})</Text>
484
- </Pressable>
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
- After modifying `src/specs/PictureSelector.nitro.ts` run:
639
+ ### Profile settings — restore previous selection
523
640
 
524
- ```sh
525
- npx nitrogen generate
526
- # or
527
- bun run generate
528
- ```
529
-
530
- This populates `nitrogen/generated/` with:
531
- - `android/kotlin/com/nitro/pictureselector/` — Kotlin abstract spec classes
532
- - `ios/` — Swift protocol files and Objective-C++ bridge
533
-
534
- **Do not edit files inside `nitrogen/generated/` manually** they are overwritten on each codegen run.
535
-
536
- ### Build TypeScript
537
-
538
- ```sh
539
- bun run build
540
- # or
541
- npx bob build
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
- ```sh
547
- cd example
548
- npm install
549
- # iOS
550
- npx pod-install
551
- npx react-native run-ios
552
-
553
- # Android
554
- npx react-native run-android
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`. On older versions it requests `READ_EXTERNAL_STORAGE`.
565
- - The `theme` option uses PictureSelector's built-in style presets. `PickerTheme.WECHAT` is the most polished.
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 uses uCrop's `setCircleDimmedLayer(true)`.
569
- - Luban handles compression; files under 100 KB are passed through unchanged.
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; on Android it falls back to a 1:1 fixed ratio.
577
- - `isOriginal` in `MediaAsset` is only populated on iOS (when the user taps the "Original" button).
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
- - The `theme` enum values `WECHAT`, `WHITE`, `DARK` have no effect on iOS (only `themeColor` is used).
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 | When requested |
589
- |-----------|----------------|
590
- | `CAMERA` | Camera capture |
591
- | `READ_EXTERNAL_STORAGE` | Android 12 |
592
- | `READ_MEDIA_IMAGES` | Android 13+ |
593
- | `READ_MEDIA_VIDEO` | Android 13+ |
594
- | `WRITE_EXTERNAL_STORAGE` | Android 9 only |
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 (`Info.plist`)
711
+ ### iOS `Info.plist` keys required
597
712
 
598
713
  | Key | Purpose |
599
714
  |-----|---------|
600
- | `NSPhotoLibraryUsageDescription` | Read photos/videos |
601
- | `NSPhotoLibraryAddUsageDescription` | Save to library |
602
- | `NSCameraUsageDescription` | Camera capture |
603
- | `NSMicrophoneUsageDescription` | Video with audio |
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
- | LivePhoto | Not exposed |
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 © react-native-picture-selector contributors
764
+ MIT
623
765
 
624
- Native libraries:
625
- - [LuckSiege/PictureSelector](https://github.com/LuckSiege/PictureSelector) — Apache 2.0
626
- - [SilenceLove/HXPhotoPicker](https://github.com/SilenceLove/HXPhotoPicker) — MIT
627
- - [mrousavy/nitro](https://github.com/mrousavy/nitro) — MIT
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.1",
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": "*",