From 1bb91e35ed92b6fcb162ecd4de7fb34f08ba6b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl?= Date: Sun, 14 Jul 2024 21:19:19 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Video=20support=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ add basic video player in crop_viewer * ✨ show videos fake cropped in result view * ♻️ update video widgets * ♻️ show video in crop view * 🐛 fix always hidden crop ratio button * ✨ show crop video preview * 🐛 fix `InstaAssetCropTransform` * 🐛 fix `InstaAssetCropTransform` to fit aspect ratio * 💄 fix crop loader * 💄 fix video thumbnail size * ♻️ export photo_manger * 🚨 remove import * 💄 improve placeholders and image loading/error * 💄 fix cropview loading & error widgets * ✨ add `skipCropOnComplete` options builder param * 🐛 fix camera example crop view size issue * ♻️ update CropResult page in example * ✨ add `previewThumbnailSize` config parameter * ♻️ update camera examples to supports video * 🐛 fix camera example asset from picture broken size * ➕ use git dependency * ♻️ move cropDelegate into InstaAssetPickerConfig * ✨ new `InstaAssetsExportData` class * ✨ add `ffmpegCrop` and `ffmpegScale`, add ffmpeg in example * ♻️ improve example export progress with ffmpeg statistics * 🐛 fix camera video_player exception * 🐛 fix iCloud video loading * 💄 fix iCloud loading size * 🐛 fix saving crop param on dispose * 🐛 fix camera examples * 📝 update doc * ⬆️ use latest insta_assets_crop * 📝 update changelog, readme and migration guide * 📝 update doc * 📝 update doc * 📝 update doc * 📝 update doc --- CHANGELOG.md | 19 + MIGRATION_GUIDE.md | 46 +++ README.md | 110 +++--- example/analysis_options.yaml | 4 +- example/android/app/build.gradle | 5 +- .../android/app/src/main/AndroidManifest.xml | 3 + example/ios/Podfile | 2 +- example/ios/Podfile.lock | 18 +- example/lib/main.dart | 31 +- example/lib/pages/camera/camera_picker.dart | 272 ++++++++++--- .../pages/camera/wechat_camera_picker.dart | 38 +- example/lib/pages/restorable_picker.dart | 13 +- example/lib/pages/stateless_pickers.dart | 6 +- example/lib/post_provider.dart | 171 ++++++++ example/lib/utils.dart | 14 + example/lib/widgets/crop_result_view.dart | 157 ++++++-- .../lib/widgets/insta_picker_interface.dart | 10 + example/lib/widgets/post.dart | 217 ++++++++++ example/pubspec.lock | 30 +- example/pubspec.yaml | 5 +- lib/insta_assets_picker.dart | 15 +- lib/src/assets_picker.dart | 66 ++-- lib/src/insta_assets_crop_controller.dart | 110 ++++-- lib/src/widget/crop_transform.dart | 64 +++ lib/src/widget/crop_viewer.dart | 371 ++++++++++++------ .../widget/insta_asset_picker_delegate.dart | 18 +- lib/src/widget/video_player_mixin.dart | 163 ++++++++ pubspec.yaml | 14 +- 28 files changed, 1628 insertions(+), 364 deletions(-) create mode 100644 MIGRATION_GUIDE.md create mode 100644 example/lib/post_provider.dart create mode 100644 example/lib/utils.dart create mode 100644 example/lib/widgets/post.dart create mode 100644 lib/src/widget/crop_transform.dart create mode 100644 lib/src/widget/video_player_mixin.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab95fc..7ae2a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# Changelog + +## 3.0.0-dev.1 + +### Features + +- Video support [#50](https://github.com/LeGoffMael/insta_assets_picker/pull/50) + - video processing must be handled manually + - new `requestType` param set to `RequestType.common` by default. + - new `previewThumbnailSize` & `skipCropOnComplete` config parameters. + - new `InstaAssetCropTransform` widget to preview the cropped asset. +- Crop view initialization time is now much faster. + +### [Breaking changes](MIGRATION_GUIDE.md#3.0.0-dev.1) + +- new `InstaAssetPickerConfig` config class to provide picker configuration [#48](https://github.com/LeGoffMael/insta_assets_picker/pull/48) + - new `gridThumbnailSize`, `themeColor` & `selectPredicate` parameters +- updated `InstaAssetsExportDetails` class, crop file are now nullable and all the crop parameters are provided in a new class called `InstaAssetsExportData`. + ## 2.3.1 - bump `wechat_assets_picker` to 9.1.0 diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..6835f06 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,46 @@ +# Migration Guide + +This document gathered all breaking changes and migrations requirement between major versions. + +## 3.0.0-dev.1 + +### InstaAssetPickerConfig + +The picker configuration parameters must not be provided into a `InstaAssetPickerConfig` class in `pickerConfig` + +```diff +InstaAssetPicker.pickAssets( + context, +- title: 'Example title', +- pickerTheme: widget.getPickerTheme(context), ++ pickerConfig: InstaAssetPickerConfig( ++ title: 'Example title', ++ pickerTheme: widget.getPickerTheme(context), ++ ), +) +``` + +The picker is now showing image and video assets by default. To show only images, you can change the `requestType` param. +```diff +InstaAssetPicker.pickAssets( + context, ++ requestType: RequestType.image +) +``` + + +The `InstaAssetsExportDetails` was also updated. +The cropped files are now nullable, an all the crop parameters are returned in a new class called `InstaAssetsExportData`. + +```diff ++ class InstaAssetsExportData { ++ final File? croppedFile; ++ final InstaAssetsCropData selectedData; ++ } + +InstaAssetsExportDetails { +- final List croppedFiles; ++ final List data; +``` + + diff --git a/README.md b/README.md index c354b14..8673690 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

-An image picker based on Instagram picker UI. It is using the powerful [flutter_wechat_assets_picker](https://pub.dev/packages/wechat_assets_picker) +An image (also with videos) picker based on Instagram picker UI. It is using the powerful [flutter_wechat_assets_picker](https://pub.dev/packages/wechat_assets_picker) package to handle the picker and a custom version of [image_crop](https://pub.dev/packages/image_crop) for crop. ## 🚀 Features @@ -23,15 +23,15 @@ package to handle the picker and a custom version of [image_crop](https://pub.de - ✅ Instagram layout - Scroll behaviors, animation - Preview, select, unselect action logic +- ✅ Image and Video ([but not video processing](#video)) support - ✅ Theme and language customization -- ✅ Multiple images pick (with maximum limit) -- ✅ Single image pick mode +- ✅ Multiple assets pick (with maximum limit) +- ✅ Single asset pick mode - ✅ Restore state of picker after pop -- ✅ Select aspect ratios to crop all images with (default to 1:1 & 4:5) -- ✅ Crop all images at once and receive a stream with a progress value +- ✅ Select aspect ratios to crop all assets with (default to 1:1 & 4:5) +- ✅ Crop all image assets at once and receive a stream with a progress value - ✅ Prepend or append a custom item in the assets list - ✅ Add custom action buttons -- ❌ Videos are not supported ## 📸 Screenshots @@ -58,7 +58,9 @@ For more details check out the [example](https://github.com/LeGoffMael/insta_ass ```dart Future?> callPicker() => InstaAssetPicker.pickAssets( context, - title: 'Select images', + pickerConfig: InstaAssetPickerConfig( + title: 'Select assets', + ), maxAssets: 10, onCompleted: (Stream stream) { // TODO : handle crop stream result @@ -73,13 +75,19 @@ Future?> callPicker() => InstaAssetPicker.pickAssets( Fields in `InstaAssetsExportDetails`: -| Name | Type | Description | -| -------------- | ------------------- | ------------------------------------------------------- | -| croppedFiles | `List` | List of all cropped files | -| selectedAssets | `List` | Selected assets without crop | -| aspectRatio | `double` | Selected aspect ratio (1 or 4/5) | -| progress | `double` | Progress indicator of the exportation (between 0 and 1) | +| Name | Type | Description | +| -------------- | ----------------------------- | --------------------------------------------------------------------- | +| data | `List` | Contains the selected assets, crop parameters and possible crop file. | +| selectedAssets | `List` | Selected assets without crop | +| aspectRatio | `double` | Selected aspect ratio (1 or 4/5) | +| progress | `double` | Progress indicator of the exportation (between 0 and 1) | + +Fields in `InstaAssetsExportData`: +| Name | Type | Description | +| ------------ | --------------------- | ------------------------------------------------------------------ | +| croppedFile | `File?` | The cropped file. Can be null if video or if choose to skip crop. | +| selectedData | `InstaAssetsCropData` | The selected asset and it's crop parameter (area, scale, ratio...) | ### Picker configuration @@ -98,24 +106,26 @@ Most of the components of the picker can be customized using theme. final theme = InstaAssetPicker.themeData(Theme.of(context).primaryColor); InstaAssetPicker.pickAssets( context, - pickerTheme: theme.copyWith( - canvasColor: Colors.black, // body background color - splashColor: Color.grey, // ontap splash color - colorScheme: theme.colorScheme.copyWith( - background: Colors.black87, // albums list background color - ), - appBarTheme: theme.appBarTheme.copyWith( - backgroundColor: Colors.black, // app bar background color - titleTextStyle: Theme.of(context) - .appBarTheme - .titleTextStyle - ?.copyWith(color: Colors.white), // change app bar title text style to be like app theme - ), - // edit `confirm` button style - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: Colors.blue, - disabledForegroundColor: Colors.red, + pickerConfig: InstaAssetPickerConfig( + pickerTheme: theme.copyWith( + canvasColor: Colors.black, // body background color + splashColor: Color.grey, // ontap splash color + colorScheme: theme.colorScheme.copyWith( + background: Colors.black87, // albums list background color + ), + appBarTheme: theme.appBarTheme.copyWith( + backgroundColor: Colors.black, // app bar background color + titleTextStyle: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.copyWith(color: Colors.white), // change app bar title text style to be like app theme + ), + // edit `confirm` button style + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: Colors.blue, + disabledForegroundColor: Colors.red, + ), ), ), ), @@ -126,24 +136,26 @@ InstaAssetPicker.pickAssets( ### Crop customization You can set the list of crop aspect ratios available. -You can also set the preferred size, for the cropped images. +You can also set the preferred size, for the cropped assets. ```dart InstaAssetPicker.pickAssets( context, - cropDelegate: InstaAssetCropDelegate( - // allows you to set the preferred size used when cropping the image. - // the final size will depends on the scale used when cropping. - preferredSize: 1080, - cropRatios: [ - // - allow you to set the list of aspect ratios selectable, - // the default values are [1/1, 4/5] like instagram. - // - if you want to disable cropping, you can set only one parameter, - // in this case, the "crop" button will not be displayed (#10). - // - if the value of cropRatios is different than the default value, - // the "crop" button will display the selected ratio value (i.e.: 1:1) - // instead of unfold arrows. - ]), + pickerConfig: InstaAssetPickerConfig( + cropDelegate: InstaAssetCropDelegate( + // allows you to set the preferred size used when cropping the asset. + // the final size will depends on the scale used when cropping. + preferredSize: 1080, + cropRatios: [ + // - allow you to set the list of aspect ratios selectable, + // the default values are [1/1, 4/5] like instagram. + // - if you want to disable cropping, you can set only one parameter, + // in this case, the "crop" button will not be displayed (#10). + // - if the value of cropRatios is different than the default value, + // the "crop" button will display the selected ratio value (i.e.: 1:1) + // instead of unfold arrows. + ]), + ), onCompleted: (_) {}, ); ``` @@ -158,6 +170,14 @@ However, since version `2.0.0`, it is now possible to trigger this action using The ability to take a photo from the camera must be handled on your side, but the picker is now able to refresh the list and select the new photo. New [examples](https://github.com/LeGoffMael/insta_assets_picker/tree/main/example/lib/pages/camera) have been written to show how to manage this process with the [camera](https://pub.dev/packages/camera) or [wechat_camera_picker](https://pub.dev/packages/wechat_camera_picker) package. +### Video + +Video are now supported on version `3.0.0`. You can pick a video asset and select the crop area directly in the picker. +However, as video processing is a heavy operation it is not handled by this package. +Which means you must handle it yourself. If you want to preview the video result, you can use the `InstaAssetCropTransform` which will transform the Image or VideoPlayer to fit the selected crop area. + +The example app has been updated to support videos (+ camera recording) and shows [how to process the video](https://github.com/LeGoffMael/insta_assets_picker/tree/main/example/lib/post_provider.dart#L84) using [ffmpeg_kit_flutter](https://pub.dev/packages/ffmpeg_kit_flutter). + ## ✨ Credit This package is based on [flutter_wechat_assets_picker](https://pub.dev/packages/wechat_assets_picker) by [AlexV525](https://github.com/AlexV525) and [image_crop](https://pub.dev/packages/image_crop) by [lykhonis](https://github.com/lykhonis). \ No newline at end of file diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 61b6c4d..d6afe1f 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -22,8 +22,8 @@ linter: # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + # since it is an example app only let's keep it simple + depend_on_referenced_packages: ignore # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 9eed4e8..aa32b3b 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -42,9 +42,8 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.insta_assets_picker.example" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion 21 + // minSdkVersion 21 + minSdkVersion 24 // sdk version needed for ffmpeg targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index fe7f475..f5f8f12 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/> + + + diff --git a/example/ios/Podfile b/example/ios/Podfile index 279576f..2faca47 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '12.1' # target required by ffmpeg # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 442a0d4..b62beef 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,13 @@ PODS: - camera_avfoundation (0.0.1): - Flutter + - ffmpeg-kit-ios-min (6.0) + - ffmpeg_kit_flutter_min (6.0.3): + - ffmpeg_kit_flutter_min/min (= 6.0.3) + - Flutter + - ffmpeg_kit_flutter_min/min (6.0.3): + - ffmpeg-kit-ios-min (= 6.0) + - Flutter - Flutter (1.0.0) - insta_assets_crop (0.0.1): - Flutter @@ -18,6 +25,7 @@ PODS: DEPENDENCIES: - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - ffmpeg_kit_flutter_min (from `.symlinks/plugins/ffmpeg_kit_flutter_min/ios`) - Flutter (from `Flutter`) - insta_assets_crop (from `.symlinks/plugins/insta_assets_crop/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -25,9 +33,15 @@ DEPENDENCIES: - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) +SPEC REPOS: + trunk: + - ffmpeg-kit-ios-min + EXTERNAL SOURCES: camera_avfoundation: :path: ".symlinks/plugins/camera_avfoundation/ios" + ffmpeg_kit_flutter_min: + :path: ".symlinks/plugins/ffmpeg_kit_flutter_min/ios" Flutter: :path: Flutter insta_assets_crop: @@ -43,6 +57,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e + ffmpeg-kit-ios-min: 4e9a088f4ee9629435960b9d68e54848975f1931 + ffmpeg_kit_flutter_min: 5eff47f4965bf9d1150e98961eb6129f5ae3f28c Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 insta_assets_crop: 46c0be4cbfe48cff466b6924ec93b77f656ebaa8 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 @@ -50,6 +66,6 @@ SPEC CHECKSUMS: sensors_plus: 18a9b346c43e157da17d2c8e99def703f9efb9d8 video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 -PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 +PODFILE CHECKSUM: e4638883b73f42d1731a85e8757b8cd858a2954c COCOAPODS: 1.15.2 diff --git a/example/lib/main.dart b/example/lib/main.dart index 32ef87e..b41f4cc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,21 +1,22 @@ -import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:insta_assets_picker_demo/pages/stateless_pickers.dart'; import 'package:insta_assets_picker_demo/pages/camera/camera_picker.dart'; import 'package:insta_assets_picker_demo/pages/camera/wechat_camera_picker.dart'; import 'package:insta_assets_picker_demo/pages/restorable_picker.dart'; +import 'package:insta_assets_picker_demo/post_provider.dart'; import 'package:insta_assets_picker_demo/widgets/insta_picker_interface.dart'; +import 'package:insta_assets_picker_demo/widgets/post.dart'; +import 'package:provider/provider.dart'; const kDefaultColor = Colors.deepPurple; -late List _cameras; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - _cameras = await availableCameras(); - runApp(const MyApp()); -} +void main() => runApp( + ChangeNotifierProvider( + create: (context) => PostProvider(), + child: const MyApp(), + ), + ); class MyApp extends StatelessWidget { const MyApp({super.key}); @@ -67,12 +68,22 @@ class PickersScreen extends StatelessWidget { const SinglePicker(), const MultiplePicker(), const RestorablePicker(), - CameraPicker(camera: _cameras.first), + const CameraPicker(), const WeChatCameraPicker(), ]; return Scaffold( - appBar: AppBar(title: const Text('Insta pickers')), + appBar: AppBar( + title: const Text('Insta pickers'), + actions: [ + IconButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const PostsPage()), + ), + icon: Icon(Icons.feed, semanticLabel: 'Feed'), + ) + ], + ), body: ListView.separated( padding: const EdgeInsets.all(16), itemBuilder: (BuildContext context, int index) { diff --git a/example/lib/pages/camera/camera_picker.dart b/example/lib/pages/camera/camera_picker.dart index 09276a2..2813777 100644 --- a/example/lib/pages/camera/camera_picker.dart +++ b/example/lib/pages/camera/camera_picker.dart @@ -1,14 +1,15 @@ +import 'dart:io'; +import 'dart:math'; + import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:insta_assets_picker/insta_assets_picker.dart'; +import 'package:insta_assets_picker_demo/utils.dart'; import 'package:insta_assets_picker_demo/widgets/crop_result_view.dart'; import 'package:insta_assets_picker_demo/widgets/insta_picker_interface.dart'; -import 'package:path/path.dart' as path; class CameraPicker extends StatefulWidget with InstaPickerInterface { - const CameraPicker({super.key, required this.camera}); - - final CameraDescription camera; + const CameraPicker({super.key}); @override State createState() => _CameraPickerState(); @@ -23,19 +24,14 @@ class CameraPicker extends StatefulWidget with InstaPickerInterface { } class _CameraPickerState extends State { + late List cameras; late CameraController _controller; late Future _initializeControllerFuture; @override void initState() { super.initState(); - _controller = CameraController(widget.camera, ResolutionPreset.max); - _initializeControllerFuture = _controller.initialize().then((_) { - if (!mounted) { - return; - } - setState(() {}); - }); + _initializeCamera(); } @override @@ -44,10 +40,28 @@ class _CameraPickerState extends State { super.dispose(); } + Future _initializeCamera() async { + cameras = await availableCameras(); + final int preferredIndex = cameras.indexWhere( + (CameraDescription e) => e.lensDirection == CameraLensDirection.back, + ); + + _controller = CameraController( + cameras[max(preferredIndex, 0)], + widget.cameraResolutionPreset, + ); + _initializeControllerFuture = _controller.initialize().then((_) { + if (!mounted) { + return; + } + setState(() {}); + }); + } + /// Needs a [BuildContext] that is coming from the picker Future _pickFromCamera(BuildContext context) async { Feedback.forTap(context); - final XFile? image = + final XFile? cameraFile = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (context) => CameraView( @@ -57,20 +71,38 @@ class _CameraPickerState extends State { ), ); - if (!context.mounted || image == null) return; + if (!context.mounted || cameraFile == null) return; - final AssetEntity? entity = await PhotoManager.editor.saveImageWithPath( - image.path, - title: path.basename(image.path), - ); + AssetEntity? entity; + try { + final PermissionState ps = await PhotoManager.requestPermissionExtend(); + if (ps == PermissionState.authorized || ps == PermissionState.limited) { + final File file = File(cameraFile.path); + final bool isVideo = isVideoFile(file); + final String title = getFileNameWithExtension(file); - if (entity == null) return; - - if (context.mounted) { - await InstaAssetPicker.refreshAndSelectEntity( - context, - entity, - ); + if (isVideo) { + entity = await PhotoManager.editor.saveVideo(file, title: title); + } else { + // NOTE: for some unknown reason, when an asset is saved + // from a picture took with `camera` package, it size is inversed. + final wrongSizeEntity = await PhotoManager.editor + .saveImageWithPath(file.path, title: title); + // TEMP FIX: Fetching it one more time seems to fix the issue + if (wrongSizeEntity != null) { + entity = await AssetEntity.fromId(wrongSizeEntity.id); + } + } + } else { + debugPrint( + 'Permission is not fully granted to save the captured file.'); + } + } catch (e) { + debugPrint('Exception $e'); + } finally { + if (context.mounted) { + await InstaAssetPicker.refreshAndSelectEntity(context, entity); + } } } @@ -138,8 +170,8 @@ class _CameraPickerState extends State { ); } -/// Widget based on Flutter docs : https://docs.flutter.dev/cookbook/plugins/picture-using-camera -class CameraView extends StatelessWidget { +/// Widget based on camera package example : https://github.com/flutter/packages/blob/main/packages/camera/camera/example/lib/main.dart +class CameraView extends StatefulWidget { const CameraView({ super.key, required this.controller, @@ -149,35 +181,171 @@ class CameraView extends StatelessWidget { final CameraController controller; final Future initializeControllerFuture; + @override + State createState() => _CameraViewState(); +} + +class _CameraViewState extends State { + void onTakePictureButtonPressed() { + takePicture().then((XFile? file) { + if (mounted) { + if (file != null) { + debugPrint('Picture saved to ${file.path}'); + } + Navigator.pop(context, file); + } + }); + } + + void onVideoRecordButtonPressed() { + startVideoRecording().then((_) { + if (mounted) { + setState(() {}); + } + }); + } + + void onStopButtonPressed() { + stopVideoRecording().then((XFile? file) { + if (mounted) { + setState(() {}); + if (file != null) { + debugPrint('Video recorded to ${file.path}'); + } + Navigator.pop(context, file); + } + }); + } + + Future startVideoRecording() async { + // Ensure that the camera is initialized. + await widget.initializeControllerFuture; + + if (!widget.controller.value.isInitialized) { + debugPrint('Error: select a camera first.'); + return; + } + + if (widget.controller.value.isRecordingVideo) { + // A recording is already started, do nothing. + return; + } + + try { + await widget.controller.startVideoRecording(); + } on CameraException catch (e) { + _showCameraException(e); + return; + } + } + + Future stopVideoRecording() async { + if (!widget.controller.value.isRecordingVideo) { + return null; + } + + try { + return widget.controller.stopVideoRecording(); + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + } + + Future takePicture() async { + // Ensure that the camera is initialized. + await widget.initializeControllerFuture; + + if (!widget.controller.value.isInitialized) { + debugPrint('Error: select a camera first.'); + return null; + } + + if (widget.controller.value.isTakingPicture) { + // A capture is already pending, do nothing. + return null; + } + + try { + final XFile file = await widget.controller.takePicture(); + return file; + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + } + + void _showCameraException(CameraException e) { + final errorMsg = 'Error: ${e.code}\n${e.description}'; + debugPrint(errorMsg); + } + + /// Display the control bar with buttons to take pictures and record videos. + Widget _captureControlRowWidget() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.camera_alt), + onPressed: widget.controller.value.isInitialized && + !widget.controller.value.isRecordingVideo + ? onTakePictureButtonPressed + : null, + ), + IconButton( + icon: const Icon(Icons.videocam), + color: widget.controller.value.isRecordingVideo ? Colors.red : null, + onPressed: widget.controller.value.isInitialized && + !widget.controller.value.isRecordingVideo + ? onVideoRecordButtonPressed + : onStopButtonPressed, + ), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Take a picture')), - body: FutureBuilder( - future: initializeControllerFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return CameraPreview(controller); - } else { - return const Center(child: CircularProgressIndicator()); - } - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: () async { - try { - // Ensure that the camera is initialized. - await initializeControllerFuture; - // Attempt to take a picture and get the image's file where it was saved. - final image = await controller.takePicture(); - - if (!context.mounted) return; - Navigator.pop(context, image); - } catch (e) { - debugPrint(e.toString()); - } - }, - child: const Icon(Icons.camera_alt), + appBar: AppBar(title: const Text('Camera example')), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black, + border: Border.all( + color: widget.controller.value.isRecordingVideo + ? Colors.redAccent + : Colors.grey, + width: 3.0, + ), + ), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: FutureBuilder( + future: widget.initializeControllerFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return Center( + child: AspectRatio( + aspectRatio: + 1 / widget.controller.value.aspectRatio, + child: CameraPreview(widget.controller), + ), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ), + ), + ), + ), + _captureControlRowWidget(), + ], + ), ), ); } diff --git a/example/lib/pages/camera/wechat_camera_picker.dart b/example/lib/pages/camera/wechat_camera_picker.dart index 7772140..b72c05a 100644 --- a/example/lib/pages/camera/wechat_camera_picker.dart +++ b/example/lib/pages/camera/wechat_camera_picker.dart @@ -16,12 +16,24 @@ class WeChatCameraPicker extends StatelessWidget with InstaPickerInterface { ); /// Needs a [BuildContext] that is coming from the picker - Future _pickFromWeChatCamera(BuildContext context) => - CameraPicker.pickFromCamera( - context, - locale: Localizations.maybeLocaleOf(context), - pickerConfig: CameraPickerConfig(theme: Theme.of(context)), - ); + Future _pickFromWeChatCamera(BuildContext context) async { + Feedback.forTap(context); + final AssetEntity? entity = await CameraPicker.pickFromCamera( + context, + locale: Localizations.maybeLocaleOf(context), + pickerConfig: CameraPickerConfig( + theme: Theme.of(context), + resolutionPreset: cameraResolutionPreset, + // to allow video recording + enableRecording: true, + ), + ); + if (entity == null) return; + + if (context.mounted) { + await InstaAssetPicker.refreshAndSelectEntity(context, entity); + } + } @override Widget build(BuildContext context) => buildLayout( @@ -54,19 +66,7 @@ class WeChatCameraPicker extends StatelessWidget with InstaPickerInterface { specialItemBuilder: (context, _, __) { // return a button that open the camera return ElevatedButton( - onPressed: () async { - Feedback.forTap(context); - final AssetEntity? entity = - await _pickFromWeChatCamera(context); - if (entity == null) return; - - if (context.mounted) { - await InstaAssetPicker.refreshAndSelectEntity( - context, - entity, - ); - } - }, + onPressed: () => _pickFromWeChatCamera(context), style: ElevatedButton.styleFrom( shape: const RoundedRectangleBorder(), foregroundColor: Colors.white, diff --git a/example/lib/pages/restorable_picker.dart b/example/lib/pages/restorable_picker.dart index 6ada2a0..08649a1 100644 --- a/example/lib/pages/restorable_picker.dart +++ b/example/lib/pages/restorable_picker.dart @@ -19,7 +19,10 @@ class RestorablePicker extends StatefulWidget with InstaPickerInterface { class _PickerScreenState extends State { final _instaAssetsPicker = InstaAssetPicker(); - late final _provider = DefaultAssetPickerProvider(maxAssets: 10); + late final _provider = DefaultAssetPickerProvider( + maxAssets: 10, + requestType: RequestType.common, + ); late final ThemeData _pickerTheme = widget.getPickerTheme(context); List selectedAssets = []; @@ -43,7 +46,7 @@ class _PickerScreenState extends State { ), provider: () => _provider, onCompleted: (cropStream) { - // example withtout StreamBuilder + // example without StreamBuilder cropStream.listen((event) { if (mounted) { setState(() { @@ -76,11 +79,7 @@ class _PickerScreenState extends State { 'Using this picker means that you must dispose it manually', ), ), - CropResultView( - selectedAssets: selectedAssets, - croppedFiles: exportDetails?.croppedFiles ?? [], - progress: exportDetails?.progress, - ) + CropResultView(result: exportDetails) ], ), ); diff --git a/example/lib/pages/stateless_pickers.dart b/example/lib/pages/stateless_pickers.dart index 908b37c..f1f31ef 100644 --- a/example/lib/pages/stateless_pickers.dart +++ b/example/lib/pages/stateless_pickers.dart @@ -8,8 +8,8 @@ class SinglePicker extends StatelessWidget with InstaPickerInterface { PickerDescription get description => const PickerDescription( icon: '☝️', label: 'Single Mode Picker', - description: 'Picker to select a single image. ' - 'Selecting a new image will replace the old one.', + description: 'Picker to select a single asset. ' + 'Selecting a new asset will replace the old one.', ); @override @@ -29,7 +29,7 @@ class MultiplePicker extends StatelessWidget with InstaPickerInterface { icon: '🖼️', label: 'Multiple Mode Picker', description: - 'Picker for selecting multiple images (max $_kMultiplePickerMax).', + 'Picker for selecting multiple assets (max $_kMultiplePickerMax).', ); @override diff --git a/example/lib/post_provider.dart b/example/lib/post_provider.dart new file mode 100644 index 0000000..9c316e3 --- /dev/null +++ b/example/lib/post_provider.dart @@ -0,0 +1,171 @@ +import 'dart:io'; +import 'package:ffmpeg_kit_flutter_min/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_min/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_min/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:insta_assets_picker/insta_assets_picker.dart'; +import 'package:insta_assets_picker_demo/utils.dart'; +import 'package:path_provider/path_provider.dart'; + +class Post { + const Post({ + required this.id, + required this.files, + required this.aspectRatio, + required this.createdAt, + }); + + final int id; + final List files; + final double aspectRatio; + final DateTime createdAt; +} + +class PostProgress { + const PostProgress(this.postId, this.asset, this.value, [this.hasError]); + + final int postId; + final AssetEntity asset; + final double value; + final bool? hasError; +} + +class PostProvider with ChangeNotifier { + List _posts = []; + List _progress = []; + + List get posts => _posts; + List get progress => _progress; + + void add(Post post) { + _posts = [post, ..._posts]; + notifyListeners(); + // remove the progress after a delay + Future.delayed(Duration(seconds: 1), () => _removeProgress(post.id)); + } + + void remove(int postId) { + final list = [..._posts]; + final index = list.indexWhere((p) => p.id == postId); + if (index == -1) return; + list.removeAt(index); + _posts = list; + notifyListeners(); + } + + PostProgress? _addProgress(int postId, AssetEntity asset) { + final list = [..._progress]; + if (list.indexWhere((p) => p.postId == postId) != -1) return null; + final p = PostProgress(postId, asset, 0); + _progress = [p, ...list]; + notifyListeners(); + return p; + } + + void _updateProgress(int postId, double value, {bool? hasError}) { + final list = [..._progress]; + final index = list.indexWhere((p) => p.postId == postId); + if (index == -1) return; + list[index] = PostProgress(postId, list[index].asset, value, hasError); + _progress = list; + notifyListeners(); + } + + void _removeProgress(int postId) { + final list = [..._progress]; + final index = list.indexWhere((p) => p.postId == postId); + if (index == -1) return; + list.removeAt(index); + _progress = list; + notifyListeners(); + } + + /// Crop the missing files using FFmpeg + Future uploadNewPost(InstaAssetsExportDetails exportDetails) async { + if (exportDetails.progress < 1 || exportDetails.selectedAssets.isEmpty) + return; + + final int postId = DateTime.now().millisecondsSinceEpoch; + final PostProgress? progress = + _addProgress(postId, exportDetails.selectedAssets.first); + + if (progress == null) { + throw 'Error: Progress already in progress'; + } + + final List files = []; + final double step = 1 / exportDetails.data.length; + + for (int i = 0; i < exportDetails.data.length; i++) { + final item = exportDetails.data[i]; + + final double progressValue = (i + 1) * step; + + if (item.croppedFile != null) { + files.add(item.croppedFile!); + _updateProgress(postId, progressValue); + continue; + } + + final File? originFile = await item.selectedData.asset.originFile; + + if (originFile == null) { + _updateProgress(postId, progressValue, hasError: true); + throw 'Error: File cannot be fetched'; + } + + final String extension = getFileExtension(originFile); + final String outputPath = + '${(await getTemporaryDirectory()).path}/output_${postId}_$i${extension}'; + + final String? ffmpegCrop = item.selectedData.ffmpegCrop; + final String? ffmpegScale = item.selectedData.ffmpegScale; + final List filters = [ + if (ffmpegCrop != null) 'crop=${ffmpegCrop}', + if (ffmpegScale != null) 'scale=${ffmpegScale}' + ]; + + FFmpegKitConfig.enableStatisticsCallback((stats) { + final asset = exportDetails.selectedAssets[i]; + if (asset.type != AssetType.video) return; + final double val = stats.getTime() / asset.duration / 1000; + // update progress based on ffmpeg statistics + _updateProgress(postId, progressValue - step + step * val.clamp(0, 1)); + }); + final session = await FFmpegKit.execute( + "-y -i \'${originFile.path}\' ${filters.isNotEmpty ? "-vf \'${filters.join(",")}\'" : ''} -c:a copy \'$outputPath\'", + ); + final returnCode = await session.getReturnCode(); + + if (ReturnCode.isSuccess(returnCode)) { + // SUCCESS + files.add(File(outputPath)); + _updateProgress(postId, progressValue); + } else if (ReturnCode.isCancel(returnCode)) { + // CANCEL + _updateProgress(postId, progressValue, hasError: true); + throw 'Error: FFmpeg execution got cancel.'; + } else { + _updateProgress(postId, progressValue, hasError: true); + // ERROR + throw 'Error: FFmpeg failed.'; + } + } + + if (files.isEmpty) { + _updateProgress(postId, 1, hasError: true); + throw 'Error: result is empty'; + } + + _updateProgress(postId, 1); + + add( + Post( + id: postId, + files: files, + aspectRatio: exportDetails.aspectRatio, + createdAt: DateTime.now(), + ), + ); + } +} diff --git a/example/lib/utils.dart b/example/lib/utils.dart new file mode 100644 index 0000000..0332fa3 --- /dev/null +++ b/example/lib/utils.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:mime/mime.dart'; +import 'package:path/path.dart' as path; + +/// Returns if [File] is a video based on its mime type +bool isVideoFile(File file) => + lookupMimeType(file.path)?.startsWith('video') ?? false; + +/// Returns [File] name with extension +String getFileNameWithExtension(File file) => path.basename(file.path); + +/// Returns [File] extension in `.xxx` format +String getFileExtension(File file) => path.extension(file.path).toLowerCase(); diff --git a/example/lib/widgets/crop_result_view.dart b/example/lib/widgets/crop_result_view.dart index bae6537..b0de9e9 100644 --- a/example/lib/widgets/crop_result_view.dart +++ b/example/lib/widgets/crop_result_view.dart @@ -1,7 +1,9 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:insta_assets_picker/insta_assets_picker.dart'; +import 'package:insta_assets_picker_demo/post_provider.dart'; +import 'package:insta_assets_picker_demo/widgets/post.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; class PickerCropResultScreen extends StatelessWidget { const PickerCropResultScreen({super.key, required this.cropStream}); @@ -10,16 +12,14 @@ class PickerCropResultScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final height = MediaQuery.of(context).size.height - kToolbarHeight; + final height = MediaQuery.sizeOf(context).height - kToolbarHeight; return Scaffold( appBar: AppBar(title: const Text('Insta picker result')), body: StreamBuilder( stream: cropStream, builder: (context, snapshot) => CropResultView( - selectedAssets: snapshot.data?.selectedAssets ?? [], - croppedFiles: snapshot.data?.croppedFiles ?? [], - progress: snapshot.data?.progress, + result: snapshot.data, heightFiles: height / 2, heightAssets: height / 4, ), @@ -31,19 +31,18 @@ class PickerCropResultScreen extends StatelessWidget { class CropResultView extends StatelessWidget { const CropResultView({ super.key, - required this.selectedAssets, - required this.croppedFiles, - this.progress, + required this.result, this.heightFiles = 300.0, this.heightAssets = 120.0, }); - final List selectedAssets; - final List croppedFiles; - final double? progress; + final InstaAssetsExportDetails? result; final double heightFiles; final double heightAssets; + List get data => result?.data ?? []; + List get selectedAssets => result?.selectedAssets ?? []; + Widget _buildTitle(String title, int length) { return SizedBox( height: 20.0, @@ -60,10 +59,7 @@ class CropResultView extends StatelessWidget { ), child: Text( length.toString(), - style: const TextStyle( - color: Colors.white, - height: 1.0, - ), + style: const TextStyle(color: Colors.white, height: .7), ), ), ], @@ -71,11 +67,13 @@ class CropResultView extends StatelessWidget { ); } - Widget _buildCroppedImagesListView(BuildContext context) { - if (progress == null) { + Widget _buildCroppedAssetsListView(BuildContext context) { + if (result?.progress == null) { return const SizedBox.shrink(); } + final double progress = result!.progress; + return Expanded( child: Stack( alignment: Alignment.center, @@ -84,7 +82,7 @@ class CropResultView extends StatelessWidget { physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 8.0), scrollDirection: Axis.horizontal, - itemCount: croppedFiles.length, + itemCount: data.length, itemBuilder: (BuildContext _, int index) { return Padding( padding: const EdgeInsets.symmetric( @@ -95,12 +93,17 @@ class CropResultView extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(4.0), ), - child: Image.file(croppedFiles[index]), + child: data[index]?.croppedFile != null + ? Image.file(data[index]!.croppedFile!) + : PickerResultPreview( + cropData: data[index]!.selectedData, + isAutoPlay: index == 0, + ), ), ); }, ), - if (progress! < 1.0) + if (progress < 1.0) Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( @@ -109,7 +112,7 @@ class CropResultView extends StatelessWidget { ), ), ), - if (progress! < 1.0) + if (progress < 1.0) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ClipRRect( @@ -118,7 +121,7 @@ class CropResultView extends StatelessWidget { height: 6, child: LinearProgressIndicator( value: progress, - semanticsLabel: '${progress! * 100}%', + semanticsLabel: '${progress * 100}%', ), ), ), @@ -138,7 +141,7 @@ class CropResultView extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: selectedAssets.length, itemBuilder: (BuildContext _, int index) { - final AssetEntity asset = selectedAssets.elementAt(index); + final AssetEntity asset = selectedAssets[index]; return Padding( padding: const EdgeInsets.symmetric( @@ -148,7 +151,7 @@ class CropResultView extends StatelessWidget { // TODO : add delete action child: RepaintBoundary( child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), + borderRadius: const BorderRadius.all(Radius.circular(8)), child: Image(image: AssetEntityImageProvider(asset)), ), ), @@ -166,11 +169,11 @@ class CropResultView extends StatelessWidget { AnimatedContainer( duration: kThemeChangeDuration, curve: Curves.easeInOut, - height: croppedFiles.isNotEmpty ? heightFiles : 40.0, + height: data.isNotEmpty ? heightFiles : 40.0, child: Column( children: [ - _buildTitle('Cropped Images', croppedFiles.length), - _buildCroppedImagesListView(context), + _buildTitle('Crop Results', data.length), + _buildCroppedAssetsListView(context), ], ), ), @@ -185,6 +188,104 @@ class CropResultView extends StatelessWidget { ], ), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: result != null && + result?.progress != null && + result!.progress >= 1 && + selectedAssets.isNotEmpty + ? () { + context.read().uploadNewPost(result!); + // go back to main and open post list page + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (BuildContext context) => PostsPage(), + ), + (route) => route.isFirst, + ); + } + : null, + icon: const Icon(Icons.cloud_upload), + label: const Text('Upload'), + ), + ), + ), + ], + ); + } +} + +class PickerResultPreview extends InstaAssetVideoPlayerStatefulWidget { + PickerResultPreview({ + super.key, + required this.cropData, + super.isAutoPlay, + super.isLoop, + }) : super(asset: cropData.asset); + + final InstaAssetsCropData cropData; + + @override + State createState() => _PickerResultVideoPlayerState(); +} + +class _PickerResultVideoPlayerState extends State + with InstaAssetVideoPlayerMixin { + @override + Widget buildLoader() => const Center(child: CircularProgressIndicator()); + + @override + Widget buildInitializationError() => + const Center(child: Text('Sorry the video could not be loaded.')); + + @override + Widget buildVideoPlayer() { + return GestureDetector( + onTap: playButtonCallback, + child: Stack( + alignment: Alignment.center, + children: [ + InstaAssetCropTransform( + asset: widget.asset, + cropParam: widget.cropData.cropParam, + child: VideoPlayer(videoController!), + ), + if (videoController != null) + AnimatedBuilder( + animation: videoController!, + builder: (_, __) => AnimatedOpacity( + opacity: isControllerPlaying ? 0 : 1, + duration: kThemeAnimationDuration, + child: CircleAvatar( + foregroundColor: Colors.white, + backgroundColor: Colors.black.withOpacity(0.7), + radius: 24, + child: const Icon(Icons.play_arrow_rounded, size: 40), + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + widget.asset.type == AssetType.image + ? InstaAssetCropTransform( + asset: widget.asset, + cropParam: widget.cropData.cropParam, + child: Image(image: AssetEntityImageProvider(widget.asset)), + ) + : buildDefault(), + const Text('⚠️ Preview ⚠️', style: TextStyle(color: Colors.redAccent)), ], ); } diff --git a/example/lib/widgets/insta_picker_interface.dart b/example/lib/widgets/insta_picker_interface.dart index 8e92e82..5732371 100644 --- a/example/lib/widgets/insta_picker_interface.dart +++ b/example/lib/widgets/insta_picker_interface.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:insta_assets_picker/insta_assets_picker.dart'; import 'package:insta_assets_picker_demo/main.dart'; @@ -28,6 +31,11 @@ mixin InstaPickerInterface on Widget { AppBar get _appBar => AppBar(title: Text(description.fullLabel)); + /// NOTE: Exception on android when playing video recorded from the camera + /// with [ResolutionPreset.max] after FFmpeg encoding + ResolutionPreset get cameraResolutionPreset => + Platform.isAndroid ? ResolutionPreset.high : ResolutionPreset.max; + Column pickerColumn({ String? text, required VoidCallback onPressed, @@ -83,6 +91,8 @@ mixin InstaPickerInterface on Widget { title: description.fullLabel, closeOnComplete: true, pickerTheme: getPickerTheme(context), + // skipCropOnComplete: true, // to test ffmpeg crop image + // previewThumbnailSize: const ThumbnailSize(240, 240), // to improve thumbnails speed in crop view ), maxAssets: maxAssets, onCompleted: (Stream cropStream) { diff --git a/example/lib/widgets/post.dart b/example/lib/widgets/post.dart new file mode 100644 index 0000000..21738f9 --- /dev/null +++ b/example/lib/widgets/post.dart @@ -0,0 +1,217 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:insta_assets_picker/insta_assets_picker.dart'; +import 'package:insta_assets_picker_demo/main.dart'; +import 'package:insta_assets_picker_demo/post_provider.dart'; +import 'package:insta_assets_picker_demo/utils.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; + +class PostsPage extends StatelessWidget { + const PostsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('My Posts')), + body: CustomScrollView( + slivers: [ + const SliverToBoxAdapter(child: PostProgressList()), + const PostList(), + ], + ), + ); + } +} + +class PostProgressList extends StatelessWidget { + const PostProgressList({super.key}); + + @override + Widget build(BuildContext context) { + final List list = + context.select((PostProvider p) => p.progress); + + return AnimatedSize( + duration: kThemeAnimationDuration, + child: list.isEmpty + ? const SizedBox.shrink() + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemBuilder: (BuildContext context, int index) { + final PostProgress progress = list[index]; + + return SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(4)), + child: Image( + image: AssetEntityImageProvider(progress.asset), + fit: BoxFit.cover, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: LinearProgressIndicator( + value: progress.value, + color: progress.hasError ?? false + ? Colors.redAccent + : kDefaultColor, + borderRadius: + const BorderRadius.all(Radius.circular(4)), + ), + ), + ), + progress.hasError ?? false + ? Icon( + Icons.error, + color: Colors.redAccent, + ) + : Text( + '${(progress.value * 100).toInt()}%', + style: TextStyle( + color: kDefaultColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }, + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemCount: list.length, + ), + ); + } +} + +class PostList extends StatelessWidget { + const PostList({super.key}); + + @override + Widget build(BuildContext context) { + final List posts = context.select((PostProvider p) => p.posts); + + if (posts.isEmpty) { + return SliverFillRemaining( + child: Center( + child: Text( + 'Empty list.\nOpen a picker to add a new post.', + textAlign: TextAlign.center, + ), + ), + ); + } + + return SliverList.separated( + itemCount: posts.length, + itemBuilder: (context, index) => PostCard( + key: ValueKey(posts[index].id), + post: posts[index], + ), + separatorBuilder: (_, __) => SizedBox(height: 32), + ); + } +} + +class PostCard extends StatelessWidget { + const PostCard({super.key, required this.post}); + + final Post post; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + + return SizedBox( + width: width, + height: width / post.aspectRatio, + child: PageView.builder( + itemCount: post.files.length, + itemBuilder: (context, index) { + final file = post.files[index]; + final isVideo = isVideoFile(file); + + if (isVideo) { + return _PostVideoPlayer(file: file); + } + return Image.file(file); + }, + ), + ); + } +} + +class _PostVideoPlayer extends StatefulWidget { + const _PostVideoPlayer({required this.file}); + + final File file; + + @override + State<_PostVideoPlayer> createState() => _PostVideoPlayerState(); +} + +/// Based on `video_player` example: https://pub.dev/packages/video_player/example +class _PostVideoPlayerState extends State<_PostVideoPlayer> { + late VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.file(widget.file); + + _controller.addListener(() { + setState(() {}); + }); + _controller.setLooping(true); + _controller.initialize().then((_) => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Stack( + children: [ + VideoPlayer(_controller), + AnimatedSwitcher( + duration: const Duration(milliseconds: 50), + reverseDuration: const Duration(milliseconds: 200), + child: _controller.value.isPlaying + ? const SizedBox.shrink() + : const ColoredBox( + color: Colors.black26, + child: Center( + child: Icon( + Icons.play_arrow, + color: Colors.white, + size: 40.0, + semanticLabel: 'Play', + ), + ), + ), + ), + GestureDetector( + onTap: () { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }, + ), + ], + ); +} diff --git a/example/pubspec.lock b/example/pubspec.lock index eff34e2..dc7cd04 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -137,6 +137,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + ffmpeg_kit_flutter_min: + dependency: "direct main" + description: + name: ffmpeg_kit_flutter_min + sha256: "123bfbc0e0b9e7cf6d32d8ba8e08b666d66af0f52c07683dd2305fbfc13f494a" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + ffmpeg_kit_flutter_platform_interface: + dependency: transitive + description: + name: ffmpeg_kit_flutter_platform_interface + sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee + url: "https://pub.dev" + source: hosted + version: "0.2.1" flutter: dependency: "direct main" description: flutter @@ -217,10 +233,10 @@ packages: dependency: transitive description: name: insta_assets_crop - sha256: c6ca1786b19e0d270843346c8205b1e90418f0c5146a8a4476ba618f768b0e89 + sha256: "1ad0627d0d79e695063be940ba9df34699ddbab2d80ccdddbf00666dda0022c5" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.1.0-dev.2" insta_assets_picker: dependency: "direct main" description: @@ -308,6 +324,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.0" + mime: + dependency: "direct main" + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" nested: dependency: transitive description: @@ -317,7 +341,7 @@ packages: source: hosted version: "1.0.0" path: - dependency: "direct main" + dependency: transitive description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index eac50a9..8166004 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -17,10 +17,13 @@ dependencies: path: ../ # for camera examples - path: ^1.8.3 camera: ^0.10.6 wechat_camera_picker: ^4.3.1 + # for video examples + ffmpeg_kit_flutter_min: ^6.0.3 + mime: ^1.0.5 + dev_dependencies: flutter_test: sdk: flutter diff --git a/lib/insta_assets_picker.dart b/lib/insta_assets_picker.dart index 0bf378f..d4387c4 100644 --- a/lib/insta_assets_picker.dart +++ b/lib/insta_assets_picker.dart @@ -2,14 +2,11 @@ library insta_assets_picker; export 'src/assets_picker.dart'; export 'src/insta_assets_crop_controller.dart' - show InstaAssetsExportDetails, InstaAssetsCropData; + show InstaAssetsExportDetails, InstaAssetsExportData, InstaAssetsCropData; export 'src/widget/circle_icon_button.dart'; -export 'package:wechat_assets_picker/wechat_assets_picker.dart' - show - AssetEntity, - AssetEntityImageProvider, - DefaultAssetPickerProvider, - SpecialItemPosition, - PhotoManager, - PermissionState; +export 'src/widget/crop_transform.dart'; +export 'src/widget/video_player_mixin.dart'; + +export 'package:photo_manager/photo_manager.dart'; +export 'package:wechat_assets_picker/wechat_assets_picker.dart'; export 'package:wechat_assets_picker/src/delegates/asset_picker_text_delegate.dart'; diff --git a/lib/src/assets_picker.dart b/lib/src/assets_picker.dart index 663c040..5728622 100644 --- a/lib/src/assets_picker.dart +++ b/lib/src/assets_picker.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:insta_assets_picker/insta_assets_picker.dart'; import 'package:insta_assets_picker/src/widget/insta_asset_picker_delegate.dart'; -import 'package:wechat_assets_picker/wechat_assets_picker.dart'; const _kGridCount = 4; const _kInitializeDelayDuration = Duration(milliseconds: 250); @@ -48,17 +47,21 @@ class InstaAssetPickerConfig { this.themeColor, this.textDelegate, this.gridThumbnailSize = defaultAssetGridPreviewSize, + this.previewThumbnailSize, /// [InstaAssetPickerBuilder] config this.title, + this.cropDelegate = const InstaAssetCropDelegate(), this.closeOnComplete = false, + this.skipCropOnComplete = false, this.actionsBuilder, }); /* [DefaultAssetPickerBuilderDelegate] config */ /// Specifies the number of assets in the cross axis. + /// /// Defaults to [_kGridCount], like instagram. final int gridCount; @@ -69,6 +72,7 @@ class InstaAssetPickerConfig { /// Set a special item in the picker with several positions. /// Since the grid view is reversed, [SpecialItemPosition.prepend] /// will be at the top and [SpecialItemPosition.append] at the bottom. + /// /// Defaults to [SpecialItemPosition.none]. final SpecialItemPosition? specialItemPosition; @@ -78,6 +82,7 @@ class InstaAssetPickerConfig { /// The loader indicator to display in the picker. final LoadingIndicatorBuilder? loadingIndicatorBuilder; + /// Predicate whether an asset can be selected or unselected. final AssetSelectPredicate? selectPredicate; /// Specifies if the limited permission overlay should be displayed. @@ -87,22 +92,37 @@ class InstaAssetPickerConfig { final Color? themeColor; /// Specifies the language to apply to the picker. + /// /// Default is the locale language from the context. final AssetPickerTextDelegate? textDelegate; /// Thumbnail size in the grid. final ThumbnailSize gridThumbnailSize; + /// Preview thumbnail size in the crop viewer. + final ThumbnailSize? previewThumbnailSize; + /* [InstaAssetPickerBuilder] config */ /// Specifies the text title in the picker [AppBar]. final String? title; + /// Customize the display and export options of crops + final InstaAssetCropDelegate cropDelegate; + /// Specifies if the picker should be closed after assets selection confirmation. + /// /// Defaults to `false`. final bool closeOnComplete; + /// Specifies if the assets should be cropped when the picker is closed. + /// Set to `true` if you want to perform the crop yourself. + /// + /// Defaults to `false`. + final bool skipCropOnComplete; + /// The [Widget] to display on top of the assets grid view. + /// /// Default is unselect all assets button. final InstaPickerActionsBuilder? actionsBuilder; } @@ -146,11 +166,11 @@ class InstaAssetPicker { /// Since the exception is thrown from the MethodChannel it cannot be caught by a try/catch /// /// check `AssetPickerDelegate.permissionCheck()` from flutter_wechat_assets_picker package for more information. - static Future _permissionCheck() => + static Future _permissionCheck(RequestType? requestType) => AssetPicker.permissionCheck( - requestOption: const PermissionRequestOption( + requestOption: PermissionRequestOption( androidPermission: AndroidPermission( - type: RequestType.image, + type: requestType ?? RequestType.common, mediaLocation: false, ), ), @@ -182,6 +202,14 @@ class InstaAssetPicker { static ThemeData themeData(Color? themeColor, {bool light = false}) => AssetPicker.themeData(themeColor, light: light); + static void _assertRequestType(RequestType requestType) { + assert( + requestType == RequestType.image || + requestType == RequestType.video || + requestType == RequestType.common, + 'Only images and videos can be shown in the picker for now'); + } + /// When using `restorableAssetsPicker` function, the picker's state is preserved even after pop /// /// ⚠️ [InstaAssetPicker] and [provider] must be disposed manually @@ -195,9 +223,6 @@ class InstaAssetPicker { /// Set [onPermissionDenied] to manually handle the denied permission error. /// The default behavior is to open a [ScaffoldMessenger]. /// - /// Crop parameters - /// - Set [cropDelegate] to customize the display and export of crops. - /// /// Those arguments are used by [InstaAssetPickerBuilder] /// /// - Set [provider] getter of type [DefaultAssetPickerProvider] to specifies picker options. @@ -216,9 +241,6 @@ class InstaAssetPicker { Function(BuildContext context, String delegateDescription)? onPermissionDenied, - /// Crop parameters - InstaAssetCropDelegate cropDelegate = const InstaAssetCropDelegate(), - /// InstaAssetPickerBuilder parameters required DefaultAssetPickerProvider Function() provider, required Function(Stream exportDetails) @@ -227,7 +249,7 @@ class InstaAssetPicker { }) async { PermissionState? ps; try { - ps = await _permissionCheck(); + ps = await _permissionCheck(null); } catch (e) { _openErrorPermission( context, @@ -239,14 +261,12 @@ class InstaAssetPicker { /// Provider must be initialized after permission check or gallery is empty (#43) final restoredProvider = provider(); - assert(restoredProvider.requestType == RequestType.image, - 'Only images can be shown in the picker for now'); + _assertRequestType(restoredProvider.requestType); builder ??= InstaAssetPickerBuilder( initialPermission: ps, provider: restoredProvider, keepScrollOffset: true, - cropDelegate: cropDelegate, onCompleted: onCompleted, config: pickerConfig, locale: Localizations.maybeLocaleOf(context), @@ -271,9 +291,6 @@ class InstaAssetPicker { /// Set [onPermissionDenied] to manually handle the denied permission error. /// The default behavior is to open a [ScaffoldMessenger]. /// - /// Crop options - /// - Set [cropDelegate] to customize the display and export of crops. - /// /// Those arguments are used by [InstaAssetPickerBuilder] /// /// - The [onCompleted] callback is called when the assets selection is confirmed. @@ -306,6 +323,10 @@ class InstaAssetPicker { /// /// - Set [initializeDelayDuration] to specifies the delay before loading the assets /// Defaults to [_kInitializeDelayDuration]. + /// + /// - Set [requestType] to specifies which type of asset to show in the picker. + /// Defaults is [RequestType.common]. Only [RequestType.image], [RequestType.common] + /// and [RequestType.common] are supported. static Future?> pickAssets( BuildContext context, { Key? key, @@ -314,9 +335,6 @@ class InstaAssetPicker { Function(BuildContext context, String delegateDescription)? onPermissionDenied, - /// Crop parameters - InstaAssetCropDelegate cropDelegate = const InstaAssetCropDelegate(), - /// InstaAssetPickerBuilder parameters required Function(Stream exportDetails) onCompleted, @@ -332,11 +350,14 @@ class InstaAssetPicker { bool sortPathsByModifiedDate = false, PMFilter? filterOptions, Duration initializeDelayDuration = _kInitializeDelayDuration, + RequestType requestType = RequestType.common, }) async { + _assertRequestType(requestType); + // must be called before initializing any picker provider to avoid `PlatformException(PERMISSION_REQUESTING)` type exception PermissionState? ps; try { - ps = await _permissionCheck(); + ps = await _permissionCheck(requestType); } catch (e) { _openErrorPermission( context, @@ -351,7 +372,7 @@ class InstaAssetPicker { maxAssets: maxAssets, pageSize: pageSize, pathThumbnailSize: pathThumbnailSize, - requestType: RequestType.image, + requestType: requestType, sortPathDelegate: sortPathDelegate, sortPathsByModifiedDate: sortPathsByModifiedDate, filterOptions: filterOptions, @@ -362,7 +383,6 @@ class InstaAssetPicker { initialPermission: ps, provider: provider, keepScrollOffset: false, - cropDelegate: cropDelegate, onCompleted: onCompleted, config: pickerConfig, locale: Localizations.maybeLocaleOf(context), diff --git a/lib/src/insta_assets_crop_controller.dart b/lib/src/insta_assets_crop_controller.dart index 39b5d89..5004c38 100644 --- a/lib/src/insta_assets_crop_controller.dart +++ b/lib/src/insta_assets_crop_controller.dart @@ -14,22 +14,37 @@ class InstaAssetsCropSingleton { static List cropParameters = []; } +class InstaAssetsExportData { + const InstaAssetsExportData({ + required this.croppedFile, + required this.selectedData, + }); + + /// The cropped file, can be null if the asset is not an image or if the + /// exportation was skipped ([skipCropOnComplete]=true) + final File? croppedFile; + + /// The selected data, contains the asset and it's crop values + final InstaAssetsCropData selectedData; +} + /// Contains all the parameters of the exportation class InstaAssetsExportDetails { - /// The list of the cropped files - final List croppedFiles; + /// The export result, containing the selected assets, crop parameters + /// and possible crop file. + final List data; - /// The selected thumbnails, can provided to the picker to preselect those assets + /// The selected thumbnails, can be provided to the picker to preselect those assets final List selectedAssets; - /// The selected [aspectRatio] (1 or 4/5) + /// The selected [aspectRatio] final double aspectRatio; /// The [progress] param represents progress indicator between `0.0` and `1.0`. final double progress; const InstaAssetsExportDetails({ - required this.croppedFiles, + required this.data, required this.selectedAssets, required this.aspectRatio, required this.progress, @@ -45,6 +60,27 @@ class InstaAssetsCropData { final double scale; final Rect? area; + /// Returns crop filter for ffmpeg in "out_w:out_h:x:y" format + String? get ffmpegCrop { + final area = this.area; + if (area == null) return null; + + final w = area.width * asset.orientatedWidth; + final h = area.height * asset.orientatedHeight; + final x = area.left * asset.orientatedWidth; + final y = area.top * asset.orientatedHeight; + + return '$w:$h:$x:$y'; + } + + /// Returns scale filter for ffmpeg in "iw*[scale]:ih*[scale]" format + String? get ffmpegScale { + final scale = cropParam?.scale; + if (scale == null) return null; + + return 'iw*$scale:ih*$scale'; + } + const InstaAssetsCropData({ required this.asset, required this.cropParam, @@ -73,7 +109,7 @@ class InstaAssetsCropController { /// The index of the selected aspectRatio among the possibilities final ValueNotifier cropRatioIndex; - /// Whether the image in the crop view is loaded + /// Whether the asset in the crop view is loaded final ValueNotifier isCropViewReady = ValueNotifier(false); /// The asset [AssetEntity] currently displayed in the crop view @@ -183,13 +219,14 @@ class InstaAssetsCropController { /// Apply all the crop parameters to the list of [selectedAssets] /// and returns the exportation as a [Stream] Stream exportCropFiles( - List selectedAssets, - ) async* { - List croppedFiles = []; + List selectedAssets, { + bool skipCrop = false, + }) async* { + final List data = []; /// Returns the [InstaAssetsExportDetails] with given progress value [p] InstaAssetsExportDetails makeDetail(double p) => InstaAssetsExportDetails( - croppedFiles: croppedFiles, + data: data, selectedAssets: selectedAssets, aspectRatio: aspectRatio, progress: p, @@ -197,36 +234,45 @@ class InstaAssetsCropController { // start progress yield makeDetail(0); - final list = cropParameters; + final List list = cropParameters; final step = 1 / list.length; - for (var i = 0; i < list.length; i++) { - final file = await list[i].asset.originFile; + for (int i = 0; i < list.length; i++) { + final asset = list[i].asset; - final scale = list[i].scale; - final area = list[i].area; + if (skipCrop || asset.type != AssetType.image) { + data.add( + InstaAssetsExportData(croppedFile: null, selectedData: list[i])); + } else { + final file = await asset.originFile; - if (file == null) { - throw 'error file is null'; - } + final scale = list[i].scale; + final area = list[i].area; - // makes the sample file to not be too small - final sampledFile = await InstaAssetsCrop.sampleImage( - file: file, - preferredSize: (cropDelegate.preferredSize / scale).round(), - ); + if (file == null) { + throw 'error file is null'; + } - if (area == null) { - croppedFiles.add(sampledFile); - } else { - // crop the file with the area selected - final croppedFile = - await InstaAssetsCrop.cropImage(file: sampledFile, area: area); - // delete the not needed sample file - sampledFile.delete(); + // makes the sample file to not be too small + final sampledFile = await InstaAssetsCrop.sampleImage( + file: file, + preferredSize: (cropDelegate.preferredSize / scale).round(), + ); - croppedFiles.add(croppedFile); + if (area == null) { + data.add(InstaAssetsExportData( + croppedFile: sampledFile, selectedData: list[i])); + } else { + // crop the file with the area selected + final croppedFile = + await InstaAssetsCrop.cropImage(file: sampledFile, area: area); + // delete the not needed sample file + sampledFile.delete(); + + data.add(InstaAssetsExportData( + croppedFile: croppedFile, selectedData: list[i])); + } } // increase progress diff --git a/lib/src/widget/crop_transform.dart b/lib/src/widget/crop_transform.dart new file mode 100644 index 0000000..0aa72cd --- /dev/null +++ b/lib/src/widget/crop_transform.dart @@ -0,0 +1,64 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:insta_assets_crop/insta_assets_crop.dart' as insta_crop_view; +import 'package:insta_assets_picker/insta_assets_picker.dart'; + +class InstaAssetCropTransform extends StatelessWidget { + const InstaAssetCropTransform({ + super.key, + required this.asset, + required this.cropParam, + required this.child, + }); + + final AssetEntity asset; + final insta_crop_view.CropInternal? cropParam; + final Widget child; + + @override + Widget build(BuildContext context) { + if (cropParam == null) return child; + + final scale = cropParam!.scale; + final view = cropParam!.view; + final area = cropParam!.area; + final aspectRatio = area.size.aspectRatio; + + return LayoutBuilder(builder: (_, constraints) { + Size size = constraints.biggest; + if (size.isInfinite) { + size = Size( + constraints.biggest.shortestSide, + constraints.biggest.shortestSide, + ); + } + + final ratio = max( + size.width / asset.orientatedSize.width, + size.height / asset.orientatedSize.height, + ); + + return SizedBox.fromSize( + size: Size(size.height * aspectRatio, size.height), + child: ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox.fromSize( + size: size, + child: insta_crop_view.CropTransform( + ratio: ratio, + scale: scale, + view: view, + childSize: asset.orientatedSize, + layoutSize: size, + getRect: (s) => Offset.zero & s, + child: child, + ), + ), + ), + ), + ); + }); + } +} diff --git a/lib/src/widget/crop_viewer.dart b/lib/src/widget/crop_viewer.dart index 8b31923..a9db8f7 100644 --- a/lib/src/widget/crop_viewer.dart +++ b/lib/src/widget/crop_viewer.dart @@ -1,11 +1,13 @@ import 'dart:math' as math; +import 'package:extended_image/extended_image.dart'; import 'package:flutter/material.dart'; -import 'package:insta_assets_crop/insta_assets_crop.dart'; +import 'package:insta_assets_crop/insta_assets_crop.dart' as insta_crop_view; import 'package:insta_assets_picker/insta_assets_picker.dart'; import 'package:insta_assets_picker/src/insta_assets_crop_controller.dart'; import 'package:provider/provider.dart'; -import 'package:extended_image/extended_image.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wechat_picker_library/wechat_picker_library.dart'; class CropViewer extends StatefulWidget { const CropViewer({ @@ -17,37 +19,30 @@ class CropViewer extends StatefulWidget { required this.height, this.opacity = 1.0, this.theme, + this.previewThumbnailSize, }); final DefaultAssetPickerProvider provider; - final AssetPickerTextDelegate textDelegate; - final InstaAssetsCropController controller; - final Widget loaderWidget; - - final double opacity; - - final double height; - + final double height, opacity; final ThemeData? theme; + final ThumbnailSize? previewThumbnailSize; @override State createState() => CropViewerState(); } class CropViewerState extends State { - final _cropKey = GlobalKey(); + final _cropKey = GlobalKey(); AssetEntity? _previousAsset; - final ValueNotifier _isLoadingError = ValueNotifier(false); @override - void dispose() { - // save current crop position on dispose (#25) + void deactivate() { + // save current crop position before dispose (#25) saveCurrentCropChanges(); - _isLoadingError.dispose(); - super.dispose(); + super.deactivate(); } /// Save the crop parameters state in [InstaAssetsCropController] @@ -61,118 +56,239 @@ class CropViewerState extends State { ); } - /// Returns the [Crop] widget - Widget _buildCropView(AssetEntity asset, CropInternal? cropParam) => Opacity( - opacity: widget.controller.isCropViewReady.value ? widget.opacity : 1.0, - child: Crop( - key: _cropKey, - image: AssetEntityImageProvider(asset, isOriginal: true), - placeholderWidget: ValueListenableBuilder( - valueListenable: _isLoadingError, - builder: (context, isLoadingError, child) => Stack( - alignment: Alignment.center, - children: [ - Opacity( - opacity: widget.opacity, - child: ExtendedImage( - // to match crop alignment - alignment: widget.controller.aspectRatio == 1.0 - ? Alignment.center - : Alignment.bottomCenter, - height: widget.height, - width: widget.height * widget.controller.aspectRatio, - image: AssetEntityImageProvider(asset, isOriginal: false), - enableMemoryCache: false, - fit: BoxFit.cover, - ), - ), - // show backdrop when image is loading or if an error occured - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - color: widget.theme?.cardColor.withOpacity(0.4)), - )), - isLoadingError - ? Text(widget.textDelegate.loadFailed) - : widget.loaderWidget, - ], - ), - ), - // if the image could not be loaded (i.e unsupported format like RAW) - // unselect it and clear cache, also show the error widget - onImageError: (exception, stackTrace) { - widget.provider.unSelectAsset(asset); - AssetEntityImageProvider(asset).evict(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _isLoadingError.value = true; - widget.controller.isCropViewReady.value = true; - }); - }, - onLoading: (isReady) => WidgetsBinding.instance.addPostFrameCallback( - (_) => widget.controller.isCropViewReady.value = isReady), - maximumScale: 10, - aspectRatio: widget.controller.aspectRatio, - disableResize: true, - backgroundColor: widget.theme!.canvasColor, - initialParam: cropParam, - ), - ); - @override Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + return SizedBox( height: widget.height, - width: MediaQuery.of(context).size.width, + width: width, child: ValueListenableBuilder( valueListenable: widget.controller.previewAsset, builder: (_, previewAsset, __) => Selector>( - selector: (_, DefaultAssetPickerProvider p) => p.selectedAssets, - builder: (_, List selected, __) { - _isLoadingError.value = false; - final int effectiveIndex = - selected.isEmpty ? 0 : selected.indexOf(selected.last); - - // if no asset is selected yet, returns the loader - if (previewAsset == null && selected.isEmpty) { - return widget.loaderWidget; - } - - final asset = previewAsset ?? selected[effectiveIndex]; - final savedCropParam = - widget.controller.get(asset)?.cropParam; - - // if the selected asset changed, save the previous crop parameters state - if (asset != _previousAsset && _previousAsset != null) { - saveCurrentCropChanges(); - } - - _previousAsset = asset; - - // don't show crop button if an asset is selected or if there is only one crop - return selected.length > 1 || - widget.controller.cropDelegate.cropRatios.length <= 1 - ? _buildCropView(asset, savedCropParam) - : ValueListenableBuilder( - valueListenable: widget.controller.cropRatioIndex, - builder: (context, index, child) => Stack( - children: [ - Positioned.fill( - child: _buildCropView(asset, savedCropParam), - ), - // Build crop aspect ratio button - Positioned( - left: 12, - bottom: 12, - child: _buildCropButton(), - ), - ], - ), - ); - }), + selector: (_, DefaultAssetPickerProvider p) => p.selectedAssets, + builder: (_, List selected, __) { + final int effectiveIndex = + selected.isEmpty ? 0 : selected.indexOf(selected.last); + + // if no asset is selected yet, returns the loader + if (previewAsset == null && selected.isEmpty) { + return widget.loaderWidget; + } + + final asset = previewAsset ?? selected[effectiveIndex]; + final savedCropParam = widget.controller.get(asset)?.cropParam; + + // if the selected asset changed, save the previous crop parameters state + if (asset != _previousAsset && _previousAsset != null) { + saveCurrentCropChanges(); + } + + _previousAsset = asset; + + // hide crop button if an asset is selected or if there is only one crop + final hideCropButton = selected.length > 1 || + widget.controller.cropDelegate.cropRatios.length <= 1; + + return ValueListenableBuilder( + valueListenable: widget.controller.cropRatioIndex, + builder: (context, _, __) => Opacity( + opacity: widget.opacity, + child: InnerCropView( + cropKey: _cropKey, + asset: asset, + cropParam: savedCropParam, + controller: widget.controller, + textDelegate: widget.textDelegate, + theme: widget.theme, + height: widget.height, + hideCropButton: hideCropButton, + previewThumbnailSize: widget.previewThumbnailSize, + ), + ), + ); + }, + ), ), ); } +} + +class InnerCropView extends InstaAssetVideoPlayerStatefulWidget { + const InnerCropView({ + super.key, + required super.asset, + required this.cropParam, + required this.controller, + required this.textDelegate, + required this.theme, + required this.height, + required this.hideCropButton, + required this.cropKey, + required this.previewThumbnailSize, + }); + + final insta_crop_view.CropInternal? cropParam; + final InstaAssetsCropController controller; + final AssetPickerTextDelegate textDelegate; + final ThemeData? theme; + final double height; + final bool hideCropButton; + final GlobalKey cropKey; + final ThumbnailSize? previewThumbnailSize; + + @override + State createState() => _InnerCropViewState(); +} + +class _InnerCropViewState extends State + with InstaAssetVideoPlayerMixin { + final ValueNotifier _isLoadingError = ValueNotifier(false); + + @override + void dispose() { + super.dispose(); + _isLoadingError.dispose(); + } + + @override + void onLoading(bool isLoading) { + super.onLoading(isLoading); + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.controller.isCropViewReady.value = !isLoading, + ); + } + + @override + void onError(bool isError) { + super.onError(isError); + _isLoadingError.value = isError; + } + + @override + Widget buildLoader() => Transform.scale( + scale: widget.asset.orientatedHeight / widget.height, + child: Image( + // generate video thumbnail (low quality for performances) + image: AssetEntityImageProvider( + widget.asset, + thumbnailSize: widget.previewThumbnailSize != null + ? ThumbnailSize( + (widget.previewThumbnailSize!.height * + widget.asset.orientatedSize.aspectRatio) + .toInt(), + widget.previewThumbnailSize!.height.toInt(), + ) + : ThumbnailSize( + (widget.height * widget.asset.orientatedSize.aspectRatio) + .toInt(), + widget.height.toInt(), + ), + isOriginal: false, + ), + ), + ); + + @override + Widget buildInitializationError() => ScaleText( + widget.textDelegate.loadFailed, + semanticsLabel: widget.textDelegate.semanticsTextDelegate.loadFailed, + ); + + @override + Widget buildVideoPlayer() => VideoPlayer(videoController!); + + @override + Widget buildDefault() { + if (!isLocallyAvailable && !isInitializing) { + initializeVideoPlayerController(); + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition(opacity: animation, child: child), + child: hasLoaded ? buildVideoPlayer() : buildLoader(), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + LocallyAvailableBuilder( + key: ValueKey(widget.asset.id), + asset: widget.asset, + builder: (BuildContext context, AssetEntity asset) => + insta_crop_view.Crop( + key: widget.cropKey, + maximumScale: 10, + aspectRatio: widget.controller.aspectRatio, + disableResize: true, + backgroundColor: widget.theme!.canvasColor, + initialParam: widget.cropParam, + size: widget.asset.orientatedSize, + child: widget.asset.type == AssetType.image + ? ExtendedImage( + image: AssetEntityImageProvider( + widget.asset, + isOriginal: true, + ), + loadStateChanged: (ExtendedImageState state) { + switch (state.extendedImageLoadState) { + case LoadState.completed: + onLoading(false); + onError(false); + return state.completedWidget; + case LoadState.loading: + onLoading(true); + onError(false); + return buildLoader(); + case LoadState.failed: + onLoading(false); + onError(true); + return buildLoader(); + } + }, + ) + // build video + : buildDefault(), + ), + ), + + ValueListenableBuilder( + valueListenable: _isLoadingError, + builder: (context, isLoadingError, __) => isLoadingError + ? Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.theme?.cardColor.withOpacity(0.4), + ), + child: Center(child: buildInitializationError()), + ), + ) + : const SizedBox.shrink(), + ), + + // Build crop aspect ratio button + Positioned( + left: 12, + right: 12, + bottom: 12, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + widget.hideCropButton + ? const SizedBox.shrink() + : _buildCropButton(), + if (widget.asset.type == AssetType.video) _buildPlayVideoButton(), + ], + ), + ), + ], + ); + } Widget _buildCropButton() { return Opacity( @@ -203,4 +319,25 @@ class CropViewerState extends State { ), ); } + + Widget _buildPlayVideoButton() { + if (videoController == null || !hasLoaded) return const SizedBox.shrink(); + + return AnimatedBuilder( + animation: videoController!, + builder: (_, __) => Opacity( + opacity: 0.6, + child: InstaPickerCircleIconButton( + onTap: playButtonCallback, + theme: widget.theme?.copyWith( + buttonTheme: const ButtonThemeData(padding: EdgeInsets.all(2)), + ), + size: 32, + icon: isControllerPlaying + ? const Icon(Icons.pause_rounded) + : const Icon(Icons.play_arrow_rounded), + ), + ), + ); + } } diff --git a/lib/src/widget/insta_asset_picker_delegate.dart b/lib/src/widget/insta_asset_picker_delegate.dart index 288f199..dd4718e 100644 --- a/lib/src/widget/insta_asset_picker_delegate.dart +++ b/lib/src/widget/insta_asset_picker_delegate.dart @@ -9,7 +9,6 @@ import 'package:insta_assets_picker/src/insta_assets_crop_controller.dart'; import 'package:insta_assets_picker/src/widget/crop_viewer.dart'; import 'package:provider/provider.dart'; -import 'package:wechat_assets_picker/wechat_assets_picker.dart'; import 'package:wechat_picker_library/wechat_picker_library.dart'; /// The reduced height of the crop view @@ -38,13 +37,13 @@ class InstaAssetPickerBuilder extends DefaultAssetPickerBuilderDelegate { required super.provider, required this.onCompleted, required InstaAssetPickerConfig config, - InstaAssetCropDelegate cropDelegate = const InstaAssetCropDelegate(), super.keepScrollOffset, super.locale, }) : _cropController = - InstaAssetsCropController(keepScrollOffset, cropDelegate), + InstaAssetsCropController(keepScrollOffset, config.cropDelegate), title = config.title, closeOnComplete = config.closeOnComplete, + skipCropOnComplete = config.skipCropOnComplete, actionsBuilder = config.actionsBuilder, super( gridCount: config.gridCount, @@ -59,6 +58,7 @@ class InstaAssetPickerBuilder extends DefaultAssetPickerBuilderDelegate { themeColor: config.themeColor, textDelegate: config.textDelegate, gridThumbnailSize: config.gridThumbnailSize, + previewThumbnailSize: config.previewThumbnailSize, shouldRevertGrid: false, ); @@ -78,6 +78,11 @@ class InstaAssetPickerBuilder extends DefaultAssetPickerBuilderDelegate { /// Defaults to `false`, like instagram final bool closeOnComplete; + /// Should the picker automatically crop when the selection is confirmed + /// + /// Defaults to `false`. + final bool skipCropOnComplete; + // LOCAL PARAMETERS /// Save last position of the grid view scroll controller @@ -112,7 +117,12 @@ class InstaAssetPickerBuilder extends DefaultAssetPickerBuilderDelegate { Navigator.of(context).pop(provider.selectedAssets); } _cropViewerKey.currentState?.saveCurrentCropChanges(); - onCompleted(_cropController.exportCropFiles(provider.selectedAssets)); + onCompleted( + _cropController.exportCropFiles( + provider.selectedAssets, + skipCrop: skipCropOnComplete, + ), + ); } /// The responsive height of the crop view diff --git a/lib/src/widget/video_player_mixin.dart b/lib/src/widget/video_player_mixin.dart new file mode 100644 index 0000000..898e5ab --- /dev/null +++ b/lib/src/widget/video_player_mixin.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:insta_assets_picker/insta_assets_picker.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wechat_picker_library/wechat_picker_library.dart'; + +abstract class InstaAssetVideoPlayerStatefulWidget extends StatefulWidget { + const InstaAssetVideoPlayerStatefulWidget({ + super.key, + required this.asset, + this.isLoop = false, + this.isAutoPlay = false, + }); + + final AssetEntity asset; + + final bool isLoop; + final bool isAutoPlay; +} + +/// Based on _VideoPageBuilderState from wechat_assets_picker: https://github.com/fluttercandies/flutter_wechat_assets_picker/blob/main/lib/src/widget/builder/video_page_builder.dart +mixin InstaAssetVideoPlayerMixin + on State { + /// Controller for the video player. + VideoPlayerController? videoController; + + /// Whether the controller has initialized. + bool hasLoaded = false; + + /// Whether there's any error when initialize the video controller. + bool hasErrorWhenInitializing = false; + + /// Whether the controller is playing. + bool get isControllerPlaying => videoController?.value.isPlaying ?? false; + + bool isInitializing = false; + bool isLocallyAvailable = false; + + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.asset != oldWidget.asset) { + videoController + ?..pause() + ..dispose(); + videoController = null; + hasLoaded = false; + isInitializing = false; + isLocallyAvailable = false; + onError(false); + onLoading(false); + } + } + + @override + void dispose() { + videoController + ?..pause() + ..dispose(); + super.dispose(); + } + + Future initializeVideoPlayerController() async { + isInitializing = true; + isLocallyAvailable = true; + onLoading(true); + final String? url = await widget.asset.getMediaUrl(); + if (url == null) { + onError(true); + if (mounted) { + setState(() {}); + } + onLoading(false); + return; + } + final Uri uri = Uri.parse(url); + if (Platform.isAndroid) { + videoController = VideoPlayerController.contentUri(uri); + } else { + videoController = VideoPlayerController.networkUrl(uri); + } + + try { + await videoController?.initialize(); + hasLoaded = true; + videoController?.setLooping(widget.isLoop); + if (widget.isAutoPlay) { + videoController?.play(); + } + } catch (e, s) { + FlutterError.presentError( + FlutterErrorDetails( + exception: e, + stack: s, + library: 'insta_assets_picker', + silent: true, + ), + ); + onError(true); + } finally { + if (mounted) { + setState(() {}); + } + } + onLoading(false); + } + + /// Callback for the play button. + /// + /// Normally it only switches play state for the player. If the video reaches the end, + /// then click the button will make the video replay. + Future playButtonCallback() async { + if (videoController == null) return; + if (isControllerPlaying) { + videoController?.pause(); + return; + } + if (videoController?.value.duration == videoController?.value.position) { + videoController + ?..seekTo(Duration.zero) + ..play(); + return; + } + videoController?.play(); + } + + void onLoading(bool isLoading) {} + void onError(bool isError) { + hasErrorWhenInitializing = isError; + } + + Widget buildLoader(); + Widget buildInitializationError(); + Widget buildVideoPlayer(); + + Widget buildDefault() => buildVideoPlayerBuilder( + builder: (BuildContext context, AssetEntity asset) { + if (hasErrorWhenInitializing) { + return buildInitializationError(); + } + if (!isLocallyAvailable && !isInitializing) { + initializeVideoPlayerController(); + } + if (!hasLoaded) { + return buildLoader(); + } + return buildVideoPlayer(); + }, + ); + + Widget buildVideoPlayerBuilder({ + required Widget Function(BuildContext, AssetEntity) builder, + }) => + LocallyAvailableBuilder( + key: ValueKey(widget.asset.id), + asset: widget.asset, + builder: builder, + ); + + @override + Widget build(BuildContext context) => buildDefault(); +} diff --git a/pubspec.yaml b/pubspec.yaml index d029b44..fd96245 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,12 @@ name: insta_assets_picker -description: An image picker similar with Instagram, supports multi picking, crop and aspect ratio. +description: An image (and videos) picker similar with Instagram, supports multi picking, crop and aspect ratio. version: 2.3.1 repository: https://github.com/LeGoffMael/insta_assets_picker topics: - picker - crop - image + - video - instagram environment: @@ -16,12 +17,17 @@ dependencies: flutter: sdk: flutter - insta_assets_crop: ^0.0.3 # custom package from image_crop + insta_assets_crop: ^0.1.0-dev.2 # custom package from image_crop fraction: ^5.0.2 # to show crop ratio in crop view + wechat_assets_picker: ^9.1.0 + + # match with wechat_assets_picker package + photo_manager: ^3.0.0 wechat_picker_library: ^1.0.0 - provider: ^6.0.5 # match with wechat_assets_picker package - extended_image: ^8.2.0 # match with wechat_assets_picker package + extended_image: ^8.2.0 + provider: ^6.0.5 + video_player: ^2.7.0 dev_dependencies: flutter_test: