plux_media_picker 2.0.0
plux_media_picker: ^2.0.0 copied to clipboard
A Flutter plugin that provides a unified API to pick photos, videos, and files from the device's gallery, camera, and file system.
plux_media_picker #
A Flutter plugin that provides a unified API to pick photos, videos, and files from the device's gallery, camera, and file system. Supports iOS and Android only.
Features #
- Uses native system pickers (no custom UI)
- Single or multiple selection of media and of documents
- Built-in image compression with adjustable quality
- Media type filter for the gallery, extension filter for the file picker
- Every result reports its mime type and what kind of content it is
- Streaming reads of any picked file, chunk by chunk, without loading it into memory
- Native copying, so a file that has to end up on disk never travels through Dart
- Handles Android activity recreation (returns lost results via
getLostFiles) - Copies a file only when it has to, and says exactly when in What every call does to the file
Requirements #
| Platform | Minimum version |
|---|---|
| Android | minSdk 24, host activity must be a ComponentActivity |
| iOS | 15.0 |
Installation #
Add this to your pubspec.yaml:
dependencies:
plux_media_picker: ^2.0.0
Setup #
Android #
The camera and gallery pickers use registerForActivityResult, which requires the host activity
to extend ComponentActivity. The default FlutterActivity does not, so change
MainActivity to FlutterFragmentActivity:
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
Without this the plugin throws IllegalStateException when the activity attaches.
Only the camera needs a permission:
<uses-permission android:name="android.permission.CAMERA" />
The plugin requests it at runtime and reports a denial as
PluxMediaPickerExceptionCode.permissionAccessDenied. Picking from the gallery goes through the
Android photo picker, which runs in a separate process and grants access to the chosen items
only, so no storage permission is required. The FileProvider used for camera captures is
declared by the plugin's own manifest under the authority
${applicationId}.pluxmediapicker.fileprovider, which deliberately differs from the one
image_picker_android claims - two providers sharing an authority break the install.
iOS #
Add the following keys to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>We need camera access to take photos</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need microphone access to record video</string>
NSMicrophoneUsageDescription is required only if you record video.
NSPhotoLibraryUsageDescription is not needed: the gallery is shown by PHPickerViewController,
which runs out of process and returns only what the user picked.
Usage #
First, create an instance of the plugin:
import 'package:plux_media_picker/plux_media_picker.dart';
final picker = PluxMediaPicker();
Pick from Camera #
// Take a photo
PluxFile? photo = await picker.pickCamera(mediaType: PluxMediaType.image, quality: 0.8);
// Record a video
PluxFile? video = await picker.pickCamera(mediaType: PluxMediaType.video, quality: 0.8);
quality is the JPEG quality in the 0.0 – 1.0 range and applies to images only — a
recording is handed over exactly as the camera produced it. On Android a still at quality: 1.0
is not re-encoded either; on iOS the controller hands over a decoded UIImage, so a still always
goes through the JPEG encoder and quality only decides how hard it squeezes.
Pick from Gallery #
List<PluxFile> result = await picker.pickGallery(maxLimit: 10, quality: 0.8);
// Only photos, or only videos:
List<PluxFile> photos = await picker.pickGallery(mediaType: PluxMediaType.image);
// Guarantee a PluxFile.path, for a preview widget or another plugin:
List<PluxFile> files = await picker.pickGallery(asFile: true);
By default an image is copied only when it has to be re-encoded, that is when quality < 1.0;
videos and full quality images keep their original location and are read with readFileStream.
Pass asFile: true when the result has to be a real file anyway. The copy then happens inside the
pick, instead of a copyToFile call per item afterwards.
Pick from File System #
// Pass extensions without a leading dot; an empty list allows any file type.
List<PluxFile> result = await picker.pickFiles(allowedExtensions: ['pdf', 'txt']);
// Several documents at once:
List<PluxFile> many = await picker.pickFiles(maxLimit: 5);
Documents are never copied, so PluxFile.path is null and PluxFile.uri is the only handle —
read it with readFileStream, or materialise it with copyToFile.
Extensions are mapped to MIME types on Android and to UTTypes on iOS; ones that cannot be
resolved are logged and skipped, and if none of them resolve the picker shows all files.
uri and path #
PluxFile.uri is an opaque handle: always set, always what readFileStream expects, and valid
until clearCache. What it holds differs by platform, and the caller is not meant to care.
On Android it is the platform uri itself - a photo picker or SAF content://, or a file:// for
what the plugin wrote. It is readable as is, and pickFiles takes a persistable grant so it keeps
working after a restart.
On iOS it is a plux:// token backed by a bookmark. A url vended by UIDocumentPickerViewController
carries a sandbox extension that belongs to that url instance: hand it over as a string and the
rebuilt url can no longer enter the security scope, which is what makes an iCloud Drive document
unreadable. A bookmark taken while the scope is open keeps the access, and it also survives a
relaunch, which a scoped url does not.
PluxFile.path is set only when a real file exists that dart:io may open, so it is null exactly
where nothing was copied.
What every call does to the file #
"Copy" below means the plugin writes the whole content somewhere new. A move is not a copy: it only renames the entry, and it falls back to a copy across volumes. A re-encode produces new bytes by definition, so it is counted separately.
| Call | Platform | Case | What the plugin does | path |
Copy |
|---|---|---|---|---|---|
pickCamera(image, quality < 1.0) |
Android | – | the camera writes into the plugin cache, the still is re-encoded in place | set | re-encode, one file |
pickCamera(image, quality = 1.0) |
Android | – | the camera writes into the plugin cache | set | no |
pickCamera(video) |
Android | – | the camera writes into the plugin cache | set | no |
pickCamera(image) |
iOS | – | the controller hands over a UIImage, so it is always encoded to JPEG in the plugin cache |
set | one write, no original file exists |
pickCamera(video) |
iOS | – | the recording is moved into the plugin cache | set | move |
pickGallery(asFile: false) |
Android | video, or image at quality = 1.0 |
nothing, the photo picker uri is returned as is | null | no |
pickGallery(asFile: false) |
Android | image at quality < 1.0 |
re-encoded into the plugin cache | set | re-encode |
pickGallery(asFile: true) |
Android | video, or image at quality = 1.0 |
copied into the plugin cache | set | yes |
pickGallery(asFile: true) |
Android | image at quality < 1.0 |
re-encoded into the plugin cache, nothing extra | set | re-encode |
pickGallery(...) |
iOS | image at quality < 1.0 |
the item provider file is adopted, then re-encoded | set | move + re-encode |
pickGallery(...) |
iOS | everything else | the item provider file is adopted into the plugin cache | set | move |
pickFiles(...) |
both | – | nothing, the document stays where the user keeps it | null | no |
readFileStream(uri) |
both | – | opens a native reader and emits chunks | – | no |
copyToFile(file, path) |
both | – | copies the content natively to path |
set on the returned file | yes |
getLostFiles() |
Android | camera result | the capture is already the plugin's own file | set | no |
getLostFiles() |
Android | gallery result | copied into the plugin cache at pick time, because the grant dies with the process | set | yes |
getLostFiles() |
iOS | – | always empty | – | no |
clearCache() |
both | – | deletes the plugin's own files, forgets the handles, releases the document grants | – | – |
asFile on iOS changes nothing: a gallery item is always adopted into the cache, because the photo
library deletes its own copy as soon as the pick is delivered.
So the plugin writes new bytes in exactly three situations: an image is re-encoded, asFile or
copyToFile asks for a file that does not exist yet, and a gallery pick has to be kept for
getLostFiles. Everything else is either a move or a plain read of the original.
Reading a file as a stream #
readFileStream opens a native reader for a PluxFile.uri and emits the content in chunks,
which keeps large files off the Dart heap:
final stream = await picker.readFileStream(result.uri);
var received = 0;
await for (final Uint8List chunk in stream) {
received += chunk.length;
debugPrint('$received / ${result.size}');
}
Every PluxFile this plugin returns can be read this way, including recovered lost files.
Reading always starts at the beginning of the file.
Cancelling the subscription stops the native reader as well:
final subscription = stream.listen(handleChunk);
await subscription.cancel();
bufferSize sets the chunk size (256 KB by default). Larger chunks mean fewer platform messages
and more memory per chunk:
final stream = await picker.readFileStream(result.uri, bufferSize: 4 * 1024 * 1024);
Each call creates its own stream, so several files can be read at the same time, and the channel
is torn down as soon as the stream ends or is cancelled. A stream that is opened and never
listened to is dropped after a minute. If the handle cannot be resolved, the call throws
PluxMediaPickerExceptionCode.fileStreamCreationFailed; a failure to read the bytes is reported
on the stream's onError.
Copying a file #
When something else needs a real file — another plugin, a converter, a preview widget — let the platform copy it:
final copy = await picker.copyToFile(result, '${directory.path}/${result.name}');
final file = File(copy.path!);
copyToFile never moves the bytes through Dart. Streaming a 200 MB video only to write it back to
disk would push every byte across the platform channel in ~800 messages; this does the same work
in one native pass. Use readFileStream when the content is only passed through, and copyToFile
when a File is genuinely required.
Recovering Lost Results (Android only) #
If your app is killed by the system while the picker is open, the plugin saves the selected result. To retrieve it after the activity is recreated, call getLostFiles() (e.g., in initState):
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
List<PluxFile> lostFiles = await picker.getLostFiles();
if (lostFiles.isNotEmpty) {
// handle the recovered files - they carry a handle and can be streamed or copied
}
});
}
A recovered result is reported once and then forgotten, and each file remembers which picker
produced it in PluxFile.source, so it can be handed back to the call it belongs to. Gallery picks
are copied into the plugin's cache before being stored, because a photo picker grant does not
outlive the process. On iOS this always returns an empty list.
Clearing the cache #
The plugin writes compressed images and camera captures into its own directory inside the app cache. Once you have copied what you need, drop them:
await picker.clearCache();
Only the plugin's own files are removed; the rest of the app cache is left alone. It also gives back
the document grants it took and, on iOS, forgets every handle, so a PluxFile.uri from an earlier
pick stops working — call it when nothing picked so far is still in use, typically at app start.
Return Values & Error Handling #
pickCamerareturnsnullif the user cancels.pickGalleryandpickFilesreturn an empty list[]if nothing was selected. A cancellation is deliberately reported the same way, since there is nothing to handle differently.- All three throw
PluxMediaPickerExceptionon failure (denied permission, compression or save error); wrap calls intry-catch. - Calling a picker while another call of the same kind is pending fails the new call instead of abandoning the previous one.
readFileStreamthrowsPluxMediaPickerExceptionif the handle cannot be resolved, and forwards read errors to the stream'sonError.
try {
final file = await picker.pickCamera();
} on PluxMediaPickerException catch (ex) {
debugPrint('$ex');
}
PluxMediaPickerExceptionCode values: loadFileFailed, invalidImageData, compressionFailed,
saveFailed, invalidFile, permissionAccessDenied, mediaTypeGetFailed,
fileStreamCreationFailed.
API Reference #
| Method | Parameters | Returns | Description |
|---|---|---|---|
pickCamera |
mediaType: PluxMediaType (default image), quality: double (0.8) |
Future<PluxFile?> |
Opens the camera for a single media file. |
pickGallery |
maxLimit: int (10), quality: double (0.8), mediaType: PluxMediaType?, asFile: bool (false) |
Future<List<PluxFile>> |
Opens the gallery for media selection. |
pickFiles |
allowedExtensions: List<String> ([]), maxLimit: int (1) |
Future<List<PluxFile>> |
Opens the file manager. |
readFileStream |
uri: String, bufferSize: int (256 KB) |
Future<Stream<Uint8List>> |
Reads the file behind a PluxFile.uri in chunks. |
copyToFile |
file: PluxFile, destinationPath: String |
Future<PluxFile> |
Copies the content natively and describes the copy. |
getLostFiles |
– | Future<List<PluxFile>> |
Retrieves files selected before activity recreation (Android). |
clearCache |
– | Future<bool> |
Clears the files the plugin created and forgets every handle. |
pickCamera and pickGallery reject PluxMediaType.file with an ArgumentError: documents are
what pickFiles is for.
PluxFile #
| Field | Description |
|---|---|
uri |
Opaque handle, always set; what readFileStream and copyToFile expect |
path |
Absolute file path, null when the plugin created no file |
name |
File name with extension |
size |
Size in bytes, null when the source does not report one |
mimeType |
Reported by the platform, not guessed from the name |
type |
image, video or file |
source |
Which picker produced it: camera, gallery or files |
Example #
The app in example/ exercises every method: camera photo and video with a quality
slider, gallery multi-select with a limit, file picking with an extension filter, native copying,
streamed reads with a chunk size selector, progress and cancellation, lost-file recovery and cache
clearing.