Skip to content

[image_picker] Add the ability to pick multiple videos - platform implementations #9818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/image_picker/image_picker_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.8.13

* Adds support for `getMultiVideoWithOptions`.

## 0.8.12+25

* Updates kotlin version to 2.2.0 to enable gradle 8.11 support.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public class ImagePickerDelegate
@VisibleForTesting static final int REQUEST_CAMERA_IMAGE_PERMISSION = 2345;
@VisibleForTesting static final int REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY = 2346;
@VisibleForTesting static final int REQUEST_CODE_CHOOSE_MEDIA_FROM_GALLERY = 2347;
@VisibleForTesting static final int REQUEST_CODE_CHOOSE_MULTI_VIDEO_FROM_GALLERY = 2348;

@VisibleForTesting static final int REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY = 2352;
@VisibleForTesting static final int REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA = 2353;
Expand Down Expand Up @@ -474,6 +475,38 @@ private void launchMultiPickImageFromGalleryIntent(Boolean usePhotoPicker, int l
pickMultiImageIntent, REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY);
}

public void chooseMultiVideoFromGallery(
@NonNull VideoSelectionOptions options,
boolean usePhotoPicker,
int limit,
@NonNull Messages.Result<List<String>> result) {
if (!setPendingOptionsAndResult(null, options, result)) {
finishWithAlreadyActiveError(result);
return;
}

launchMultiPickVideoFromGalleryIntent(usePhotoPicker, limit);
}

private void launchMultiPickVideoFromGalleryIntent(Boolean usePhotoPicker, int limit) {
Intent pickMultiVideoIntent;
if (usePhotoPicker) {
pickMultiVideoIntent =
new ActivityResultContracts.PickMultipleVisualMedia(limit)
.createIntent(
activity,
new PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.VideoOnly.INSTANCE)
.build());
} else {
pickMultiVideoIntent = new Intent(Intent.ACTION_GET_CONTENT);
pickMultiVideoIntent.setType("video/*");
pickMultiVideoIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
}
activity.startActivityForResult(
pickMultiVideoIntent, REQUEST_CODE_CHOOSE_MULTI_VIDEO_FROM_GALLERY);
}

public void takeImageWithCamera(
@NonNull ImageSelectionOptions options, @NonNull Messages.Result<List<String>> result) {
if (!setPendingOptionsAndResult(options, null, result)) {
Expand Down Expand Up @@ -617,6 +650,9 @@ public boolean onActivityResult(
case REQUEST_CODE_CHOOSE_MULTI_IMAGE_FROM_GALLERY:
handlerRunnable = () -> handleChooseMultiImageResult(resultCode, data);
break;
case REQUEST_CODE_CHOOSE_MULTI_VIDEO_FROM_GALLERY:
handlerRunnable = () -> handleChooseMultiVideoResult(resultCode, data);
break;
case REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA:
handlerRunnable = () -> handleCaptureImageResult(resultCode);
break;
Expand Down Expand Up @@ -696,6 +732,24 @@ private void handleChooseImageResult(int resultCode, Intent data) {
finishWithSuccess(null);
}

private void handleChooseMultiVideoResult(int resultCode, Intent intent) {
if (resultCode == Activity.RESULT_OK && intent != null) {
ArrayList<MediaPath> paths = getPathsFromIntent(intent, false);
// If there's no valid Uri, return an error
if (paths == null) {
finishWithError(
"missing_valid_video_uri", "Cannot find at least one of the selected videos.");
return;
}

handleMediaResult(paths);
return;
}

// User cancelled choosing a video.
finishWithSuccess(null);
}

public class MediaPath {
public MediaPath(@NonNull String path, @Nullable String mimeType) {
this.path = path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ public void pickVideos(

setCameraDevice(delegate, source);
if (generalOptions.getAllowMultiple()) {
result.error(new RuntimeException("Multi-video selection is not implemented"));
int limit = ImagePickerUtils.getLimitFromOption(generalOptions);
delegate.chooseMultiVideoFromGallery(
options, generalOptions.getUsePhotoPicker(), limit, result);
} else {
switch (source.getType()) {
case GALLERY:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,41 @@ public void pickVideos_whenSourceIsCamera_invokesTakeImageWithCamera_FrontCamera
verify(mockImagePickerDelegate).setCameraDevice(eq(ImagePickerDelegate.CameraDevice.FRONT));
}

@Test
public void pickVideos_invokesChooseMultiVideoFromGallery() {
plugin.pickVideos(
SOURCE_GALLERY,
DEFAULT_VIDEO_OPTIONS,
GENERAL_OPTIONS_ALLOW_MULTIPLE_DONT_USE_PHOTO_PICKER,
mockResult);
verify(mockImagePickerDelegate)
.chooseMultiVideoFromGallery(any(), eq(false), eq(Integer.MAX_VALUE), any());
verifyNoInteractions(mockResult);
}

@Test
public void pickVideos_usingPhotoPicker_invokesChooseMultiVideoFromGallery() {
plugin.pickVideos(
SOURCE_GALLERY,
DEFAULT_VIDEO_OPTIONS,
GENERAL_OPTIONS_ALLOW_MULTIPLE_USE_PHOTO_PICKER,
mockResult);
verify(mockImagePickerDelegate)
.chooseMultiVideoFromGallery(any(), eq(true), eq(Integer.MAX_VALUE), any());
verifyNoInteractions(mockResult);
}

@Test
public void pickVideos_withLimit5_invokesChooseMultiVideoFromGallery() {
plugin.pickVideos(
SOURCE_GALLERY,
DEFAULT_VIDEO_OPTIONS,
GENERAL_OPTIONS_ALLOW_MULTIPLE_DONT_USE_PHOTO_PICKER_WITH_LIMIT,
mockResult);
verify(mockImagePickerDelegate).chooseMultiVideoFromGallery(any(), eq(false), eq(5), any());
verifyNoInteractions(mockResult);
}

@Test
public void onConstructor_whenContextTypeIsActivity_shouldNotCrash() {
new ImagePickerPlugin(mockImagePickerDelegate, mockActivity);
Expand Down
99 changes: 59 additions & 40 deletions packages/image_picker/image_picker_android/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,10 @@ class _MyHomePageState extends State<MyHomePage> {
Future<void> _playVideo(XFile? file) async {
if (file != null && mounted) {
await _disposeVideoController();
late VideoPlayerController controller;

controller = VideoPlayerController.file(File(file.path));
final VideoPlayerController controller =
VideoPlayerController.file(File(file.path));
_controller = controller;
const double volume = 1.0;
await controller.setVolume(volume);
await controller.setVolume(1.0);
await controller.initialize();
await controller.setLooping(true);
await controller.play();
Expand All @@ -95,21 +93,28 @@ class _MyHomePageState extends State<MyHomePage> {
Future<void> _onImageButtonPressed(
ImageSource source, {
required BuildContext context,
bool isMultiImage = false,
bool allowMultiple = false,
bool isMedia = false,
}) async {
if (_controller != null) {
await _controller!.setVolume(0.0);
}
if (context.mounted) {
if (_isVideo) {
final XFile? file = await _picker.getVideo(
source: source, maxDuration: const Duration(seconds: 10));
if (file != null && context.mounted) {
_showPickedSnackBar(context, <XFile>[file]);
final List<XFile> files;
if (allowMultiple) {
files = await _picker.getMultiVideoWithOptions();
} else {
final XFile? file = await _picker.getVideo(
source: source, maxDuration: const Duration(seconds: 10));
files = <XFile>[if (file != null) file];
}
if (files.isNotEmpty && context.mounted) {
_showPickedSnackBar(context, files);
// Just play the first file, to keep the example simple.
await _playVideo(files.first);
}
await _playVideo(file);
} else if (isMultiImage) {
} else if (allowMultiple) {
await _displayPickImageDialog(context, true, (double? maxWidth,
double? maxHeight, int? quality, int? limit) async {
try {
Expand All @@ -121,7 +126,7 @@ class _MyHomePageState extends State<MyHomePage> {
final List<XFile> pickedFileList = isMedia
? await _picker.getMedia(
options: MediaOptions(
allowMultiple: isMultiImage,
allowMultiple: allowMultiple,
imageOptions: imageOptions,
limit: limit,
),
Expand Down Expand Up @@ -151,7 +156,7 @@ class _MyHomePageState extends State<MyHomePage> {
final List<XFile> pickedFileList = <XFile>[];
final XFile? media = _firstOrNull(await _picker.getMedia(
options: MediaOptions(
allowMultiple: isMultiImage,
allowMultiple: allowMultiple,
imageOptions: ImageOptions(
maxWidth: maxWidth,
maxHeight: maxHeight,
Expand Down Expand Up @@ -290,8 +295,7 @@ class _MyHomePageState extends State<MyHomePage> {
Widget _buildInlineVideoPlayer(int index) {
final VideoPlayerController controller =
VideoPlayerController.file(File(_mediaFileList![index].path));
const double volume = 1.0;
controller.setVolume(volume);
controller.setVolume(1.0);
controller.initialize();
controller.setLooping(true);
controller.play();
Expand All @@ -314,7 +318,7 @@ class _MyHomePageState extends State<MyHomePage> {
if (response.file != null) {
if (response.type == RetrieveType.video) {
_isVideo = true;
await _playVideo(response.file);
await _playVideo(response.files?.firstOrNull);
} else {
_isVideo = false;
setState(() {
Expand Down Expand Up @@ -380,8 +384,8 @@ class _MyHomePageState extends State<MyHomePage> {
_onImageButtonPressed(ImageSource.gallery, context: context);
},
heroTag: 'image0',
tooltip: 'Pick Image from gallery',
label: const Text('Pick Image from gallery'),
tooltip: 'Pick image from gallery',
label: const Text('Pick image from gallery'),
icon: const Icon(Icons.photo),
),
),
Expand All @@ -393,13 +397,12 @@ class _MyHomePageState extends State<MyHomePage> {
_onImageButtonPressed(
ImageSource.gallery,
context: context,
isMultiImage: true,
isMedia: true,
allowMultiple: true,
);
},
heroTag: 'multipleMedia',
tooltip: 'Pick Multiple Media from gallery',
label: const Text('Pick Multiple Media from gallery'),
heroTag: 'image1',
tooltip: 'Pick multiple images',
label: const Text('Pick multiple images'),
icon: const Icon(Icons.photo_library),
),
),
Expand All @@ -415,9 +418,9 @@ class _MyHomePageState extends State<MyHomePage> {
);
},
heroTag: 'media',
tooltip: 'Pick Single Media from gallery',
label: const Text('Pick Single Media from gallery'),
icon: const Icon(Icons.photo_library),
tooltip: 'Pick item from gallery',
label: const Text('Pick item from gallery'),
icon: const Icon(Icons.photo_outlined),
),
),
Padding(
Expand All @@ -428,13 +431,14 @@ class _MyHomePageState extends State<MyHomePage> {
_onImageButtonPressed(
ImageSource.gallery,
context: context,
isMultiImage: true,
allowMultiple: true,
isMedia: true,
);
},
heroTag: 'image1',
tooltip: 'Pick Multiple Image from gallery',
label: const Text('Pick Multiple Image from gallery'),
icon: const Icon(Icons.photo_library),
heroTag: 'multipleMedia',
tooltip: 'Pick multiple items',
label: const Text('Pick multiple items'),
icon: const Icon(Icons.photo_library_outlined),
),
),
Padding(
Expand All @@ -445,8 +449,8 @@ class _MyHomePageState extends State<MyHomePage> {
_onImageButtonPressed(ImageSource.camera, context: context);
},
heroTag: 'image2',
tooltip: 'Take a Photo',
label: const Text('Take a Photo'),
tooltip: 'Take a photo',
label: const Text('Take a photo'),
icon: const Icon(Icons.camera_alt),
),
),
Expand All @@ -458,9 +462,24 @@ class _MyHomePageState extends State<MyHomePage> {
_isVideo = true;
_onImageButtonPressed(ImageSource.gallery, context: context);
},
heroTag: 'video0',
tooltip: 'Pick Video from gallery',
label: const Text('Pick Video from gallery'),
heroTag: 'video',
tooltip: 'Pick video from gallery',
label: const Text('Pick video from gallery'),
icon: const Icon(Icons.video_file),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: FloatingActionButton.extended(
backgroundColor: Colors.red,
onPressed: () {
_isVideo = true;
_onImageButtonPressed(ImageSource.gallery,
context: context, allowMultiple: true);
},
heroTag: 'multiVideo',
tooltip: 'Pick multiple videos',
label: const Text('Pick multiple videos'),
icon: const Icon(Icons.video_library),
),
),
Expand All @@ -472,9 +491,9 @@ class _MyHomePageState extends State<MyHomePage> {
_isVideo = true;
_onImageButtonPressed(ImageSource.camera, context: context);
},
heroTag: 'video1',
tooltip: 'Take a Video',
label: const Text('Take a Video'),
heroTag: 'takeVideo',
tooltip: 'Take a video',
label: const Text('Take a video'),
icon: const Icon(Icons.videocam),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies:
# The example app is bundled with the plugin so we use a path dependency on
# the parent directory to use the current plugin's version.
path: ../
image_picker_platform_interface: ^2.10.0
image_picker_platform_interface: ^2.11.0
mime: ^2.0.0
video_player: ^2.1.4

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,27 @@ class ImagePickerAndroid extends ImagePickerPlatform {
return path != null ? XFile(path) : null;
}

@override
Future<List<XFile>> getMultiVideoWithOptions({
MultiVideoPickerOptions options = const MultiVideoPickerOptions(),
}) async {
final List<String> paths = await _hostApi.pickVideos(
SourceSpecification(type: SourceType.gallery),
VideoSelectionOptions(maxDurationSeconds: options.maxDuration?.inSeconds),
GeneralOptions(
allowMultiple: true,
usePhotoPicker: useAndroidPhotoPicker,
limit: options.limit,
),
);

if (paths.isEmpty) {
return <XFile>[];
}

return paths.map((String path) => XFile(path)).toList();
}

MediaSelectionOptions _mediaOptionsToMediaSelectionOptions(
MediaOptions mediaOptions) {
final ImageSelectionOptions imageSelectionOptions =
Expand Down
Loading