diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edadd9f..10d547f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,108 +1,120 @@ +## 2.0.0 + +* **BREAKING CHANGE:** Overhaul of the entire video trimmer implementation. + * Two types of `TrimViewer` are available: `FixedTrimViewer` & `ScrollableTrimViewer`. By default it's set to `auto` so that it switches between these two based on the total video length and the maximum trim duration allowed. + * Rename `TrimEditor` to `TrimViewer`. Check out [this diagram](https://raw.githubusercontent.com/sbis04/video_trimmer/new_editor/screenshots/trim_viewer_preview_small.png) for better understanding of the keywords/terms. + * Separate the properties of into two types, `TrimEditorProperties` & `TrimAreaProperties`. Helps in identifying where the properties belong to easily. + * Fix some bugs related to wrong trimmer length while initialization. +* Add more customization options. +* Improve the documentation. +* Update the example project. +* Bug Fix: Use `circleSizeOnDrag` only on the selected holder. + ## 1.2.0 -* Update plugin versions +* Update plugin versions. ## 1.1.3 -* Changing `path` version to `1.8.0` (latest is `1.8.1`) because it creates a conflict with `flutter_test` as it uses the older version +* Changing `path` version to `1.8.0` (latest is `1.8.1`) because it creates a conflict with `flutter_test` as it uses the older version. ## 1.1.2 -* Update plugin versions +* Update plugin versions. ## 1.1.1 -* Format file (to pass static analysis) +* Format file (to pass static analysis). ## 1.1.0 -* Update ffmpeg_kit_flutter to 4.5.1-LTS -* Update other plugin versions -* Update Readme +* Update ffmpeg_kit_flutter to 4.5.1-LTS. +* Update other plugin versions. +* Update Readme. ## 1.0.0 * **BREAKING CHANGE:** Migrate to [FFmpegKit for Flutter](https://pub.dev/packages/ffmpeg_kit_flutter). `saveTrimmedVideo()` method is not async now, you'll need to use the callback `onSave: (outputPath) {}` to get the trimmed video output path. -* Add playback timestamp in the `showDuration` -* Simply configuration -* Update the plugin versions -* Update Docs +* Add playback timestamp in the `showDuration`. +* Simply configuration. +* Update the plugin versions. +* Update Docs. ## 0.6.0 -* Update the plugin versions -* Using flutter_lints +* Update the plugin versions. +* Using flutter_lints. ## 0.5.4 -* Update Docs +* Update Docs. ## 0.5.3 -* Add `borderWidth` and `scrubberWidth` properties under `TrimEditor` widget -* Fix padding and border decoration of `VideoViewer` -* Update the plugin versions +* Add `borderWidth` and `scrubberWidth` properties under `TrimEditor` widget. +* Fix padding and border decoration of `VideoViewer`. +* Update the plugin versions. ## 0.5.2 -* Fix iOS error while loading thumbnails in `TrimEditor` -* Remove an unused dependency -* Update packages +* Fix iOS error while loading thumbnails in `TrimEditor`. +* Remove an unused dependency. +* Update packages. ## 0.5.1 -* Update the example in Readme -* Update the plugin versions +* Update the example in Readme. +* Update the plugin versions. ## 0.5.0 -* Global refactoring, example is now a standalone screen -* Fixed the staggering issue when dragging the frame -* The whole frame can now be dragged in addition to the sides -* Updated packages -* Updated the example +* Global refactoring, example is now a standalone screen. +* Fixed the staggering issue when dragging the frame. +* The whole frame can now be dragged in addition to the sides. +* Updated packages. +* Updated the example. ## 0.4.0 -* Migrate to null safety -* Fix video thumbnail loading issues -* Bump up all dependencies -* Upgrade example +* Migrate to null safety. +* Fix video thumbnail loading issues. +* Bump up all dependencies. +* Upgrade example. ## 0.3.5 -* Update example app (small bug fixes) -* Update to latest plugin versions +* Update example app (small bug fixes). +* Update to latest plugin versions. ## 0.3.4 -* Fixed the issue with video getting struck for a few initial frames during playback +* Fixed the issue with video getting struck for a few initial frames during playback. ## 0.3.3 -* Updated plugin versions +* Updated plugin versions. ## 0.3.2 -* Minor changes +* Minor changes. ## 0.3.1 -* Improve the file structure of the package -* Now, you just have to import one file for using the package +* Improve the file structure of the package. +* Now, you just have to import one file for using the package. ## 0.3.0 -* Update the plugin versions -* Update example app (now includes how to retrieve the trimmed video) -* Update Readme -* Fixes some memory leak issues +* Update the plugin versions. +* Update example app (now includes how to retrieve the trimmed video). +* Update Readme. +* Fixes some memory leak issues. ## 0.2.7 * Add a new property called `maxVideoLength` for specifying the max length of the output video. -* Update Docs +* Update Docs. ## 0.2.6 @@ -111,62 +123,62 @@ **NOTE:** Applying this will take significantly greater amount of time to process the output video. -* Improve Docs +* Improve Docs. ## 0.2.5 -* Update Docs -* Reverted the FFmpeg trimmed video start & end position to **milliseconds** (earlier it was changed to **seconds** in `v0.2.4` to fix video freezing, but after testing it was found that the issue still persists) +* Update Docs. +* Reverted the FFmpeg trimmed video start & end position to **milliseconds** (earlier it was changed to **seconds** in `v0.2.4` to fix video freezing, but after testing it was found that the issue still persists). ## 0.2.4 -* Fix output video freezing during start and end -* Update the example app to use LTS version of FFmpeg (for wider device support) -* Update Readme +* Fix output video freezing during start and end. +* Update the example app to use LTS version of FFmpeg (for wider device support). +* Update Readme. ## 0.2.3 -* Fix issue with path returned +* Fix issue with path returned. ## 0.2.2 -* Change implementation of the `saveTrimmedVideo()` method -* `saveTrimmedVideo()` now returns the output video path -* Update Docs +* Change implementation of the `saveTrimmedVideo()` method. +* `saveTrimmedVideo()` now returns the output video path. +* Update Docs. ## 0.2.1 -* Fix over-scrolling && scroll-over issue +* Fix over-scrolling && scroll-over issue. ## 0.2.0 * BREAKING CHANGE: `loadVideo()` method implementation changed. Now, you can pass the video file to the method. -* Fix issue related to animation controller improperly disposing -* Update Docs +* Fix issue related to animation controller improperly disposing. +* Update Docs. ## 0.1.5 -* Fix for paths having white spaces +* Fix for paths having white spaces. ## 0.1.4 -* Smoothen the scrubber animation +* Smoothen the scrubber animation. ## 0.1.3 -* Code improvements -* Update Readme +* Code improvements. +* Update Readme. ## 0.1.2 -* Changed `StorageDir` format naming -* Update documentation +* Changed `StorageDir` format naming. +* Update documentation. ## 0.1.1 -* Correct documentation +* Correct documentation. ## 0.1.0 -* Initial Open Source release +* Initial Open Source release. diff --git a/README.md b/README.md index 8dc79ed8..ba9152ea 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,22 @@ ### Features -* Customizable video trimmer -* Video playback control -* Retrieving and storing video file +* Customizable video trimmer. +* Supports two types of trim viewer, fixed length and scrollable. +* Video playback control. +* Retrieving and storing video file. Also, supports conversion to **GIF**. -

TRIM EDITOR

+> **Migrating to v2.0.0:** If you were using 1.x.x version of this package, checkout the BREAKING CHANGES by going to the **Changelog** tab on the `pub.dev` package page. + +Following image shows the structure of the `TrimViewer`. It consists of the `Duration` on top (displaying the start, end, and scrubber time), `TrimArea` consisting of the thumbnails, and `TrimEditor` which is an overlay that let's you select a portion from the video. + +

+ Trim Editor +

+ +

TRIM VIEWER

Trim Editor @@ -37,7 +46,7 @@ Also, supports conversion to **GIF**. Trimmer

-

CUSTOMIZABLE VIDEO EDITOR

+

CUSTOMIZABLE VIDEO TRIMMER

Trim Editor @@ -49,7 +58,7 @@ Add the dependency `video_trimmer` to your **pubspec.yaml** file: ```yaml dependencies: - video_trimmer: ^1.1.0 + video_trimmer: ^2.0.0 ``` ### Android configuration @@ -184,22 +193,15 @@ VideoViewer(trimmer: _trimmer) ### Display the video trimmer area ```dart -TrimEditor( +TrimViewer( trimmer: _trimmer, viewerHeight: 50.0, viewerWidth: MediaQuery.of(context).size.width, - maxVideoLength: Duration(seconds: 10), - onChangeStart: (value) { - _startValue = value; - }, - onChangeEnd: (value) { - _endValue = value; - }, - onChangePlaybackState: (value) { - setState(() { - _isPlaying = value; - }); - }, + maxVideoLength: const Duration(seconds: 10), + onChangeStart: (value) => _startValue = value, + onChangeEnd: (value) => _endValue = value, + onChangePlaybackState: (value) => + setState(() => _isPlaying = value), ) ``` @@ -351,22 +353,15 @@ class _TrimmerViewState extends State { child: VideoViewer(trimmer: _trimmer), ), Center( - child: TrimEditor( + child: TrimViewer( trimmer: _trimmer, viewerHeight: 50.0, viewerWidth: MediaQuery.of(context).size.width, - maxVideoLength: Duration(seconds: 10), - onChangeStart: (value) { - _startValue = value; - }, - onChangeEnd: (value) { - _endValue = value; - }, - onChangePlaybackState: (value) { - setState(() { - _isPlaying = value; - }); - }, + maxVideoLength: const Duration(seconds: 10), + onChangeStart: (value) => _startValue = value, + onChangeEnd: (value) => _endValue = value, + onChangePlaybackState: (value) => + setState(() => _isPlaying = value), ), ), TextButton( diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 35cf6340..680955fe 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -407,7 +407,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.5; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -545,7 +545,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.5; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", @@ -578,7 +578,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.5; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", diff --git a/example/lib/trimmer_view.dart b/example/lib/trimmer_view.dart index 1fcf6c09..515eba3f 100644 --- a/example/lib/trimmer_view.dart +++ b/example/lib/trimmer_view.dart @@ -69,70 +69,71 @@ class _TrimmerViewState extends State { appBar: AppBar( title: const Text("Video Trimmer"), ), - body: Builder( - builder: (context) => Center( - child: Container( - padding: const EdgeInsets.only(bottom: 30.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Visibility( - visible: _progressVisibility, - child: const LinearProgressIndicator( - backgroundColor: Colors.red, - ), - ), - ElevatedButton( - onPressed: _progressVisibility ? null : () => _saveVideo(), - child: const Text("SAVE"), - ), - Expanded( - child: VideoViewer(trimmer: _trimmer), + body: Center( + child: Container( + padding: const EdgeInsets.only(bottom: 30.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Visibility( + visible: _progressVisibility, + child: const LinearProgressIndicator( + backgroundColor: Colors.red, ), - Center( - child: TrimEditor( + ), + ElevatedButton( + onPressed: _progressVisibility ? null : () => _saveVideo(), + child: const Text("SAVE"), + ), + Expanded( + child: VideoViewer(trimmer: _trimmer), + ), + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TrimViewer( trimmer: _trimmer, viewerHeight: 50.0, viewerWidth: MediaQuery.of(context).size.width, maxVideoLength: const Duration(seconds: 10), - onChangeStart: (value) { - _startValue = value; - }, - onChangeEnd: (value) { - _endValue = value; - }, - onChangePlaybackState: (value) { - setState(() { - _isPlaying = value; - }); - }, + editorProperties: TrimEditorProperties( + borderPaintColor: Colors.yellow, + borderWidth: 4, + borderRadius: 5, + circlePaintColor: Colors.yellow.shade800, + ), + areaProperties: TrimAreaProperties.edgeBlur( + thumbnailQuality: 10, + ), + onChangeStart: (value) => _startValue = value, + onChangeEnd: (value) => _endValue = value, + onChangePlaybackState: (value) => + setState(() => _isPlaying = value), ), ), - TextButton( - child: _isPlaying - ? const Icon( - Icons.pause, - size: 80.0, - color: Colors.white, - ) - : const Icon( - Icons.play_arrow, - size: 80.0, - color: Colors.white, - ), - onPressed: () async { - bool playbackState = await _trimmer.videPlaybackControl( - startValue: _startValue, - endValue: _endValue, - ); - setState(() { - _isPlaying = playbackState; - }); - }, - ) - ], - ), + ), + TextButton( + child: _isPlaying + ? const Icon( + Icons.pause, + size: 80.0, + color: Colors.white, + ) + : const Icon( + Icons.play_arrow, + size: 80.0, + color: Colors.white, + ), + onPressed: () async { + bool playbackState = await _trimmer.videPlaybackControl( + startValue: _startValue, + endValue: _endValue, + ); + setState(() => _isPlaying = playbackState); + }, + ) + ], ), ), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index d4346c7e..a060b986 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -226,6 +226,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + transparent_image: + dependency: transitive + description: + name: transparent_image + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" vector_math: dependency: transitive description: @@ -281,7 +288,7 @@ packages: path: ".." relative: true source: path - version: "1.2.0" + version: "2.0.0" win32: dependency: transitive description: diff --git a/lib/src/thumbnail_viewer.dart b/lib/src/thumbnail_viewer.dart deleted file mode 100644 index a916c0a3..00000000 --- a/lib/src/thumbnail_viewer.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:video_thumbnail/video_thumbnail.dart'; - -class ThumbnailViewer extends StatelessWidget { - final File videoFile; - final int videoDuration; - final double thumbnailHeight; - final BoxFit fit; - final int numberOfThumbnails; - final int quality; - - /// For showing the thumbnails generated from the video, - /// like a frame by frame preview - const ThumbnailViewer({ - Key? key, - required this.videoFile, - required this.videoDuration, - required this.thumbnailHeight, - required this.numberOfThumbnails, - required this.fit, - this.quality = 75, - }) : super(key: key); - - Stream> generateThumbnail() async* { - final String videoPath = videoFile.path; - - double eachPart = videoDuration / numberOfThumbnails; - - List byteList = []; - - // the cache of last thumbnail - Uint8List? lastBytes; - - for (int i = 1; i <= numberOfThumbnails; i++) { - Uint8List? bytes; - bytes = await VideoThumbnail.thumbnailData( - video: videoPath, - imageFormat: ImageFormat.JPEG, - timeMs: (eachPart * i).toInt(), - quality: quality, - ); - - // if current thumbnail is null use the last thumbnail - if (bytes != null) { - lastBytes = bytes; - } else { - bytes = lastBytes; - } - - byteList.add(bytes); - - yield byteList; - } - } - - @override - Widget build(BuildContext context) { - return StreamBuilder>( - stream: generateThumbnail(), - builder: (context, snapshot) { - if (snapshot.hasData) { - List imageBytes = snapshot.data!; - return ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: imageBytes.length, - itemBuilder: (context, index) { - return SizedBox( - height: thumbnailHeight, - width: thumbnailHeight, - child: Image( - image: MemoryImage(imageBytes[index]!), - fit: fit, - ), - ); - }); - } else { - return Container( - color: Colors.grey[900], - height: thumbnailHeight, - width: double.maxFinite, - ); - } - }, - ); - } -} diff --git a/lib/src/trim_viewer/fixed_viewer/fixed_thumbnail_viewer.dart b/lib/src/trim_viewer/fixed_viewer/fixed_thumbnail_viewer.dart new file mode 100644 index 00000000..3d76a3d4 --- /dev/null +++ b/lib/src/trim_viewer/fixed_viewer/fixed_thumbnail_viewer.dart @@ -0,0 +1,108 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:transparent_image/transparent_image.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +class FixedThumbnailViewer extends StatelessWidget { + final File videoFile; + final int videoDuration; + final double thumbnailHeight; + final BoxFit fit; + final int numberOfThumbnails; + final VoidCallback onThumbnailLoadingComplete; + final int quality; + + /// For showing the thumbnails generated from the video, + /// like a frame by frame preview + const FixedThumbnailViewer({ + Key? key, + required this.videoFile, + required this.videoDuration, + required this.thumbnailHeight, + required this.numberOfThumbnails, + required this.fit, + required this.onThumbnailLoadingComplete, + this.quality = 75, + }) : super(key: key); + + Stream> generateThumbnail() async* { + final String videoPath = videoFile.path; + double eachPart = videoDuration / numberOfThumbnails; + List byteList = []; + // the cache of last thumbnail + Uint8List? lastBytes; + for (int i = 1; i <= numberOfThumbnails; i++) { + Uint8List? bytes; + try { + bytes = await VideoThumbnail.thumbnailData( + video: videoPath, + imageFormat: ImageFormat.JPEG, + timeMs: (eachPart * i).toInt(), + quality: quality, + ); + } catch (e) { + debugPrint('ERROR: Couldn\'t generate thumbnails: $e'); + } + // if current thumbnail is null use the last thumbnail + if (bytes != null) { + lastBytes = bytes; + } else { + bytes = lastBytes; + } + byteList.add(bytes); + if (byteList.length == numberOfThumbnails) { + onThumbnailLoadingComplete(); + } + yield byteList; + } + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: generateThumbnail(), + builder: (context, snapshot) { + if (snapshot.hasData) { + List imageBytes = snapshot.data!; + return Row( + mainAxisSize: MainAxisSize.max, + children: List.generate( + numberOfThumbnails, + (index) => SizedBox( + height: thumbnailHeight, + width: thumbnailHeight, + child: Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: 0.2, + child: Image.memory( + imageBytes[0] ?? kTransparentImage, + fit: fit, + ), + ), + index < imageBytes.length + ? FadeInImage( + placeholder: MemoryImage(kTransparentImage), + image: MemoryImage(imageBytes[index]!), + fit: fit, + ) + : const SizedBox(), + ], + ), + ), + ), + ); + } else { + return Container( + color: Colors.grey[900], + height: thumbnailHeight, + width: double.maxFinite, + ); + } + }, + ); + } +} diff --git a/lib/src/trim_editor.dart b/lib/src/trim_viewer/fixed_viewer/fixed_trim_viewer.dart similarity index 61% rename from lib/src/trim_editor.dart rename to lib/src/trim_viewer/fixed_viewer/fixed_trim_viewer.dart index 84bae2c7..0bcba00d 100644 --- a/lib/src/trim_editor.dart +++ b/lib/src/trim_viewer/fixed_viewer/fixed_trim_viewer.dart @@ -1,12 +1,18 @@ +import 'dart:developer'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:video_player/video_player.dart'; -import 'package:video_trimmer/src/thumbnail_viewer.dart'; -import 'package:video_trimmer/src/trim_editor_painter.dart'; +import 'package:video_trimmer/src/trim_viewer/trim_editor_painter.dart'; import 'package:video_trimmer/src/trimmer.dart'; -class TrimEditor extends StatefulWidget { +import '../../utils/editor_drag_type.dart'; +import '../trim_area_properties.dart'; +import '../trim_editor_properties.dart'; +import 'fixed_thumbnail_viewer.dart'; + +class FixedTrimViewer extends StatefulWidget { /// The Trimmer instance controlling the data. final Trimmer trimmer; @@ -16,56 +22,9 @@ class TrimEditor extends StatefulWidget { /// For defining the total trimmer area height final double viewerHeight; - /// For defining the image fit type of each thumbnail image. - /// - /// By default it is set to `BoxFit.fitHeight`. - final BoxFit fit; - /// For defining the maximum length of the output video. final Duration maxVideoLength; - /// For specifying a size to the holder at the - /// two ends of the video trimmer area, while it is `idle`. - /// - /// By default it is set to `5.0`. - final double circleSize; - - /// For specifying the width of the border around - /// the trim area. By default it is set to `3`. - final double borderWidth; - - /// For specifying the width of the video scrubber - final double scrubberWidth; - - /// For specifying a size to the holder at - /// the two ends of the video trimmer area, while it is being - /// `dragged`. - /// - /// By default it is set to `8.0`. - final double circleSizeOnDrag; - - /// For specifying a color to the circle. - /// - /// By default it is set to `Colors.white`. - final Color circlePaintColor; - - /// For specifying a color to the border of - /// the trim area. - /// - /// By default it is set to `Colors.white`. - final Color borderPaintColor; - - /// For specifying a color to the video - /// scrubber inside the trim area. - /// - /// By default it is set to `Colors.white`. - final Color scrubberPaintColor; - - /// For specifying the quality of each - /// generated image thumbnail, to be displayed in the trimmer - /// area. - final int thumbnailQuality; - /// For showing the start and the end point of the /// video on top of the trimmer area. /// @@ -95,10 +54,13 @@ class TrimEditor extends StatefulWidget { /// playing, otherwise paused. final Function(bool isPlaying)? onChangePlaybackState; - /// Determines the touch size of the side handles, left and right. The rest, in - /// the center, will move the whole frame if [maxVideoLength] is inferior to the - /// total duration of the video. - final int sideTapSize; + /// Properties for customizing the trim editor. + final TrimEditorProperties editorProperties; + + /// Properties for customizing the fixed trim area. + final FixedTrimAreaProperties areaProperties; + + final VoidCallback onThumbnailLoadingComplete; /// Widget for displaying the video trimmer. /// @@ -116,42 +78,10 @@ class TrimEditor extends StatefulWidget { /// /// The optional parameters are: /// - /// * [fit] for specifying the image fit type of each thumbnail image. - /// By default it is set to `BoxFit.fitHeight`. - /// - /// /// * [maxVideoLength] for specifying the maximum length of the /// output video. /// /// - /// * [circleSize] for specifying a size to the holder at the - /// two ends of the video trimmer area, while it is `idle`. - /// By default it is set to `5.0`. - /// - /// - /// * [circleSizeOnDrag] for specifying a size to the holder at - /// the two ends of the video trimmer area, while it is being - /// `dragged`. By default it is set to `8.0`. - /// - /// - /// * [circlePaintColor] for specifying a color to the circle. - /// By default it is set to `Colors.white`. - /// - /// - /// * [borderPaintColor] for specifying a color to the border of - /// the trim area. By default it is set to `Colors.white`. - /// - /// - /// * [scrubberPaintColor] for specifying a color to the video - /// scrubber inside the trim area. By default it is set to - /// `Colors.white`. - /// - /// - /// * [thumbnailQuality] for specifying the quality of each - /// generated image thumbnail, to be displayed in the trimmer - /// area. - /// - /// /// * [showDuration] for showing the start and the end point of the /// video on top of the trimmer area. By default it is set to `true`. /// @@ -170,34 +100,35 @@ class TrimEditor extends StatefulWidget { /// * [onChangePlaybackState] is a callback to the video playback /// state to know whether it is currently playing or paused. /// - const TrimEditor({ - Key? key, + /// + /// * [editorProperties] defines properties for customizing the trim editor. + /// + /// + /// * [areaProperties] defines properties for customizing the fixed trim area. + /// + const FixedTrimViewer({ + super.key, required this.trimmer, + required this.onThumbnailLoadingComplete, this.viewerWidth = 50.0 * 8, this.viewerHeight = 50, - this.fit = BoxFit.fitHeight, this.maxVideoLength = const Duration(milliseconds: 0), - this.circleSize = 5.0, - this.borderWidth = 3, - this.scrubberWidth = 1, - this.circleSizeOnDrag = 8.0, - this.circlePaintColor = Colors.white, - this.borderPaintColor = Colors.white, - this.scrubberPaintColor = Colors.white, - this.thumbnailQuality = 75, this.showDuration = true, - this.sideTapSize = 24, this.durationTextStyle = const TextStyle(color: Colors.white), this.onChangeStart, this.onChangeEnd, this.onChangePlaybackState, - }) : super(key: key); + this.editorProperties = const TrimEditorProperties(), + this.areaProperties = const FixedTrimAreaProperties(), + }); @override - State createState() => _TrimEditorState(); + State createState() => _FixedTrimViewerState(); } -class _TrimEditorState extends State with TickerProviderStateMixin { +class _FixedTrimViewerState extends State + with TickerProviderStateMixin { + final _trimmerAreaKey = GlobalKey(); File? get _videoFile => widget.trimmer.currentVideoFile; double _videoStartPos = 0.0; @@ -217,12 +148,14 @@ class _TrimEditorState extends State with TickerProviderStateMixin { int _numberOfThumbnails = 0; - late double _circleSize; + late double _startCircleSize; + late double _endCircleSize; + late double _borderRadius; double? fraction; double? maxLengthPixels; - ThumbnailViewer? thumbnailWidget; + FixedThumbnailViewer? thumbnailWidget; Animation? _scrubberAnimation; AnimationController? _animationController; @@ -244,67 +177,80 @@ class _TrimEditorState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); - - widget.trimmer.eventStream.listen((event) { - if (event == TrimmerEvent.initialized) { - //The video has been initialized, now we can load stuff - - _initializeVideoController(); - videoPlayerController.seekTo(const Duration(milliseconds: 0)); - setState(() { - Duration totalDuration = videoPlayerController.value.duration; - - if (widget.maxVideoLength > const Duration(milliseconds: 0) && - widget.maxVideoLength < totalDuration) { - if (widget.maxVideoLength < totalDuration) { - fraction = widget.maxVideoLength.inMilliseconds / - totalDuration.inMilliseconds; - - maxLengthPixels = _thumbnailViewerW * fraction!; - } - } else { - maxLengthPixels = _thumbnailViewerW; + _startCircleSize = widget.editorProperties.circleSize; + _endCircleSize = widget.editorProperties.circleSize; + _borderRadius = widget.editorProperties.borderRadius; + _thumbnailViewerH = widget.viewerHeight; + log('thumbnailViewerW: $_thumbnailViewerW'); + SchedulerBinding.instance.addPostFrameCallback((_) { + final renderBox = + _trimmerAreaKey.currentContext?.findRenderObject() as RenderBox?; + final trimmerActualWidth = renderBox?.size.width; + log('RENDER BOX: $trimmerActualWidth'); + if (trimmerActualWidth == null) return; + _thumbnailViewerW = trimmerActualWidth; + _initializeVideoController(); + videoPlayerController.seekTo(const Duration(milliseconds: 0)); + _numberOfThumbnails = trimmerActualWidth ~/ _thumbnailViewerH; + log('numberOfThumbnails: $_numberOfThumbnails'); + log('thumbnailViewerW: $_thumbnailViewerW'); + setState(() { + _thumbnailViewerW = _numberOfThumbnails * _thumbnailViewerH; + + final FixedThumbnailViewer thumbnailWidget = FixedThumbnailViewer( + videoFile: _videoFile!, + videoDuration: _videoDuration, + fit: widget.areaProperties.thumbnailFit, + thumbnailHeight: _thumbnailViewerH, + numberOfThumbnails: _numberOfThumbnails, + quality: widget.areaProperties.thumbnailQuality, + onThumbnailLoadingComplete: widget.onThumbnailLoadingComplete, + ); + this.thumbnailWidget = thumbnailWidget; + Duration totalDuration = videoPlayerController.value.duration; + + if (widget.maxVideoLength > const Duration(milliseconds: 0) && + widget.maxVideoLength < totalDuration) { + if (widget.maxVideoLength < totalDuration) { + fraction = widget.maxVideoLength.inMilliseconds / + totalDuration.inMilliseconds; + + maxLengthPixels = _thumbnailViewerW * fraction!; } + } else { + maxLengthPixels = _thumbnailViewerW; + } - _videoEndPos = fraction != null - ? _videoDuration.toDouble() * fraction! - : _videoDuration.toDouble(); - - widget.onChangeEnd!(_videoEndPos); - - _endPos = Offset( - maxLengthPixels != null ? maxLengthPixels! : _thumbnailViewerW, - _thumbnailViewerH, - ); - - // Defining the tween points - _linearTween = Tween(begin: _startPos.dx, end: _endPos.dx); - _animationController = AnimationController( - vsync: this, - duration: - Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()), - ); - - _scrubberAnimation = _linearTween.animate(_animationController!) - ..addListener(() { - setState(() {}); - }) - ..addStatusListener((status) { - if (status == AnimationStatus.completed) { - _animationController!.stop(); - } - }); - }); - } + _videoEndPos = fraction != null + ? _videoDuration.toDouble() * fraction! + : _videoDuration.toDouble(); + + widget.onChangeEnd!(_videoEndPos); + + _endPos = Offset( + maxLengthPixels != null ? maxLengthPixels! : _thumbnailViewerW, + _thumbnailViewerH, + ); + + // Defining the tween points + _linearTween = Tween(begin: _startPos.dx, end: _endPos.dx); + _animationController = AnimationController( + vsync: this, + duration: + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()), + ); + + _scrubberAnimation = _linearTween.animate(_animationController!) + ..addListener(() { + setState(() {}); + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _animationController!.stop(); + } + }); + }); }); - - _circleSize = widget.circleSize; - - _thumbnailViewerH = widget.viewerHeight; - - _numberOfThumbnails = widget.viewerWidth ~/ _thumbnailViewerH; - - _thumbnailViewerW = _numberOfThumbnails * _thumbnailViewerH; } Future _initializeVideoController() async { @@ -345,16 +291,6 @@ class _TrimEditorState extends State with TickerProviderStateMixin { videoPlayerController.setVolume(1.0); _videoDuration = videoPlayerController.value.duration.inMilliseconds; - - final ThumbnailViewer thumbnailWidget = ThumbnailViewer( - videoFile: _videoFile!, - videoDuration: _videoDuration, - fit: widget.fit, - thumbnailHeight: _thumbnailViewerH, - numberOfThumbnails: _numberOfThumbnails, - quality: widget.thumbnailQuality, - ); - this.thumbnailWidget = thumbnailWidget; } } @@ -369,10 +305,10 @@ class _TrimEditorState extends State with TickerProviderStateMixin { final startDifference = _startPos.dx - details.localPosition.dx; final endDifference = _endPos.dx - details.localPosition.dx; - //First we determine whether the dragging motion should be allowed. The allowed - //zone is widget.sideTapSize (left) + frame (center) + widget.sideTapSize (right) - if (startDifference <= widget.sideTapSize && - endDifference >= -widget.sideTapSize) { + // First we determine whether the dragging motion should be allowed. The allowed + // zone is widget.sideTapSize (left) + frame (center) + widget.sideTapSize (right) + if (startDifference <= widget.editorProperties.sideTapSize && + endDifference >= -widget.editorProperties.sideTapSize) { _allowDrag = true; } else { debugPrint("Dragging is outside of frame, ignoring gesture..."); @@ -380,10 +316,12 @@ class _TrimEditorState extends State with TickerProviderStateMixin { return; } - //Now we determine which part is dragged - if (details.localPosition.dx <= _startPos.dx + widget.sideTapSize) { + // Now we determine which part is dragged + if (details.localPosition.dx <= + _startPos.dx + widget.editorProperties.sideTapSize) { _dragType = EditorDragType.left; - } else if (details.localPosition.dx <= _endPos.dx - widget.sideTapSize) { + } else if (details.localPosition.dx <= + _endPos.dx - widget.editorProperties.sideTapSize) { _dragType = EditorDragType.center; } else { _dragType = EditorDragType.right; @@ -396,9 +334,8 @@ class _TrimEditorState extends State with TickerProviderStateMixin { void _onDragUpdate(DragUpdateDetails details) { if (!_allowDrag) return; - _circleSize = widget.circleSizeOnDrag; - if (_dragType == EditorDragType.left) { + _startCircleSize = widget.editorProperties.circleSizeOnDrag; if ((_startPos.dx + details.delta.dx >= 0) && (_startPos.dx + details.delta.dx <= _endPos.dx) && !(_endPos.dx - _startPos.dx - details.delta.dx > maxLengthPixels!)) { @@ -406,6 +343,8 @@ class _TrimEditorState extends State with TickerProviderStateMixin { _onStartDragged(); } } else if (_dragType == EditorDragType.center) { + _startCircleSize = widget.editorProperties.circleSizeOnDrag; + _endCircleSize = widget.editorProperties.circleSizeOnDrag; if ((_startPos.dx + details.delta.dx >= 0) && (_endPos.dx + details.delta.dx <= _thumbnailViewerW)) { _startPos += details.delta; @@ -414,6 +353,7 @@ class _TrimEditorState extends State with TickerProviderStateMixin { _onEndDragged(); } } else { + _endCircleSize = widget.editorProperties.circleSizeOnDrag; if ((_endPos.dx + details.delta.dx <= _thumbnailViewerW) && (_endPos.dx + details.delta.dx >= _startPos.dx) && !(_endPos.dx - _startPos.dx + details.delta.dx > maxLengthPixels!)) { @@ -447,7 +387,8 @@ class _TrimEditorState extends State with TickerProviderStateMixin { /// Drag gesture ended, update UI accordingly. void _onDragEnd(DragEndDetails details) { setState(() { - _circleSize = widget.circleSize; + _startCircleSize = widget.editorProperties.circleSize; + _endCircleSize = widget.editorProperties.circleSize; if (_dragType == EditorDragType.right) { videoPlayerController .seekTo(Duration(milliseconds: _videoEndPos.toInt())); @@ -518,18 +459,27 @@ class _TrimEditorState extends State with TickerProviderStateMixin { startPos: _startPos, endPos: _endPos, scrubberAnimationDx: _scrubberAnimation?.value ?? 0, - circleSize: _circleSize, - borderWidth: widget.borderWidth, - scrubberWidth: widget.scrubberWidth, - circlePaintColor: widget.circlePaintColor, - borderPaintColor: widget.borderPaintColor, - scrubberPaintColor: widget.scrubberPaintColor, + startCircleSize: _startCircleSize, + endCircleSize: _endCircleSize, + borderRadius: _borderRadius, + borderWidth: widget.editorProperties.borderWidth, + scrubberWidth: widget.editorProperties.scrubberWidth, + circlePaintColor: widget.editorProperties.circlePaintColor, + borderPaintColor: widget.editorProperties.borderPaintColor, + scrubberPaintColor: widget.editorProperties.scrubberPaintColor, ), - child: Container( - color: Colors.grey[900], - height: _thumbnailViewerH, - width: _thumbnailViewerW, - child: thumbnailWidget ?? Container(), + child: ClipRRect( + borderRadius: + BorderRadius.circular(widget.areaProperties.borderRadius), + child: Container( + key: _trimmerAreaKey, + color: Colors.grey[900], + height: _thumbnailViewerH, + width: _thumbnailViewerW == 0.0 + ? widget.viewerWidth + : _thumbnailViewerW, + child: thumbnailWidget ?? Container(), + ), ), ), ], @@ -537,14 +487,3 @@ class _TrimEditorState extends State with TickerProviderStateMixin { ); } } - -enum EditorDragType { - /// The user is dragging the left part of the frame. - left, - - /// The user is dragging the whole frame. - center, - - /// The user is dragging the right part of the frame. - right -} diff --git a/lib/src/trim_viewer/scrollable_viewer/scrollable_thumbnail_viewer.dart b/lib/src/trim_viewer/scrollable_viewer/scrollable_thumbnail_viewer.dart new file mode 100644 index 00000000..8207d8ca --- /dev/null +++ b/lib/src/trim_viewer/scrollable_viewer/scrollable_thumbnail_viewer.dart @@ -0,0 +1,120 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:transparent_image/transparent_image.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +class ScrollableThumbnailViewer extends StatelessWidget { + final File videoFile; + final int videoDuration; + final double thumbnailHeight; + final BoxFit fit; + final int numberOfThumbnails; + final int quality; + final ScrollController scrollController; + final VoidCallback onThumbnailLoadingComplete; + + /// For showing the thumbnails generated from the video, + /// like a frame by frame preview + const ScrollableThumbnailViewer({ + Key? key, + required this.videoFile, + required this.videoDuration, + required this.thumbnailHeight, + required this.numberOfThumbnails, + required this.fit, + required this.scrollController, + required this.onThumbnailLoadingComplete, + this.quality = 75, + }) : super(key: key); + + Stream> generateThumbnail() async* { + final String videoPath = videoFile.path; + double eachPart = videoDuration / numberOfThumbnails; + List byteList = []; + // the cache of last thumbnail + Uint8List? lastBytes; + for (int i = 1; i <= numberOfThumbnails; i++) { + Uint8List? bytes; + try { + bytes = await VideoThumbnail.thumbnailData( + video: videoPath, + imageFormat: ImageFormat.JPEG, + timeMs: (eachPart * i).toInt(), + quality: quality, + ); + } catch (e) { + debugPrint('ERROR: Couldn\'t generate thumbnails: $e'); + } + // if current thumbnail is null use the last thumbnail + if (bytes != null) { + lastBytes = bytes; + } else { + bytes = lastBytes; + } + byteList.add(bytes); + if (byteList.length == numberOfThumbnails) { + onThumbnailLoadingComplete(); + } + yield byteList; + } + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: SizedBox( + width: numberOfThumbnails * thumbnailHeight, + height: thumbnailHeight, + child: StreamBuilder>( + stream: generateThumbnail(), + builder: (context, snapshot) { + if (snapshot.hasData) { + List imageBytes = snapshot.data!; + return Row( + mainAxisSize: MainAxisSize.max, + children: List.generate( + numberOfThumbnails, + (index) => SizedBox( + height: thumbnailHeight, + width: thumbnailHeight, + child: Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: 0.2, + child: Image.memory( + imageBytes[0] ?? kTransparentImage, + fit: fit, + ), + ), + index < imageBytes.length + ? FadeInImage( + placeholder: MemoryImage(kTransparentImage), + image: MemoryImage(imageBytes[index]!), + fit: fit, + ) + : const SizedBox(), + ], + ), + ), + ), + ); + } else { + return Container( + color: Colors.grey[900], + height: thumbnailHeight, + width: double.maxFinite, + ); + } + }, + ), + ), + ), + ); + } +} diff --git a/lib/src/trim_viewer/scrollable_viewer/scrollable_trim_viewer.dart b/lib/src/trim_viewer/scrollable_viewer/scrollable_trim_viewer.dart new file mode 100644 index 00000000..bc6f78af --- /dev/null +++ b/lib/src/trim_viewer/scrollable_viewer/scrollable_trim_viewer.dart @@ -0,0 +1,730 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:video_player/video_player.dart'; +import 'package:video_trimmer/src/trim_viewer/trim_editor_painter.dart'; +import 'package:video_trimmer/src/trim_viewer/trim_area_properties.dart'; +import 'package:video_trimmer/src/trim_viewer/trim_editor_properties.dart'; +import 'package:video_trimmer/src/trimmer.dart'; + +import '../../utils/editor_drag_type.dart'; +import 'scrollable_thumbnail_viewer.dart'; + +class ScrollableTrimViewer extends StatefulWidget { + /// The Trimmer instance controlling the data. + final Trimmer trimmer; + + /// For defining the total trimmer area width + final double viewerWidth; + + /// For defining the total trimmer area height + final double viewerHeight; + + /// For defining the maximum length of the output video. + final Duration maxVideoLength; + + /// For showing the start and the end point of the + /// video on top of the trimmer area. + /// + /// By default it is set to `true`. + final bool showDuration; + + /// For providing a `TextStyle` to the + /// duration text. + /// + /// By default it is set to `TextStyle(color: Colors.white)` + final TextStyle durationTextStyle; + + /// Callback to the video start position + /// + /// Returns the selected video start position in `milliseconds`. + final Function(double startValue)? onChangeStart; + + /// Callback to the video end position. + /// + /// Returns the selected video end position in `milliseconds`. + final Function(double endValue)? onChangeEnd; + + /// Callback to the video playback + /// state to know whether it is currently playing or paused. + /// + /// Returns a `boolean` value. If `true`, video is currently + /// playing, otherwise paused. + final Function(bool isPlaying)? onChangePlaybackState; + + /// This is the fraction of padding present beside the trimmer editor, + /// calculated on the `maxVideoLength` value. + final double paddingFraction; + + /// Properties for customizing the trim editor. + final TrimEditorProperties editorProperties; + + /// Properties for customizing the trim area. + final TrimAreaProperties areaProperties; + + final VoidCallback onThumbnailLoadingComplete; + + /// Widget for displaying the video trimmer. + /// + /// This has frame wise preview of the video with a + /// slider for selecting the part of the video to be + /// trimmed. + /// + /// The required parameters are [viewerWidth] & [viewerHeight] + /// + /// * [viewerWidth] to define the total trimmer area width. + /// + /// + /// * [viewerHeight] to define the total trimmer area height. + /// + /// + /// The optional parameters are: + /// + /// * [maxVideoLength] for specifying the maximum length of the + /// output video. + /// + /// + /// * [showDuration] for showing the start and the end point of the + /// video on top of the trimmer area. By default it is set to `true`. + /// + /// + /// * [durationTextStyle] is for providing a `TextStyle` to the + /// duration text. By default it is set to + /// `TextStyle(color: Colors.white)` + /// + /// + /// * [onChangeStart] is a callback to the video start position. + /// + /// + /// * [onChangeEnd] is a callback to the video end position. + /// + /// + /// * [onChangePlaybackState] is a callback to the video playback + /// state to know whether it is currently playing or paused. + /// + /// + /// * [editorProperties] defines properties for customizing the trim editor. + /// + /// + /// * [areaProperties] defines properties for customizing the trim area. + /// + const ScrollableTrimViewer({ + super.key, + required this.trimmer, + required this.maxVideoLength, + required this.onThumbnailLoadingComplete, + this.viewerWidth = 50 * 8, + this.viewerHeight = 50, + this.showDuration = true, + this.durationTextStyle = const TextStyle(color: Colors.white), + this.onChangeStart, + this.onChangeEnd, + this.onChangePlaybackState, + this.paddingFraction = 0.2, + this.editorProperties = const TrimEditorProperties(), + this.areaProperties = const TrimAreaProperties(), + }); + + @override + State createState() => _ScrollableTrimViewerState(); +} + +class _ScrollableTrimViewerState extends State + with TickerProviderStateMixin { + final _trimmerAreaKey = GlobalKey(); + File? get _videoFile => widget.trimmer.currentVideoFile; + + double _videoStartPos = 0.0; + double _videoEndPos = 0.0; + + double _localPosition = 0.0; + + Offset _startPos = const Offset(0, 0); + Offset _endPos = const Offset(0, 0); + + double _startFraction = 0.0; + double _endFraction = 1.0; + + int _videoDuration = 0; + int _currentPosition = 0; + int _trimmerAreaDuration = 0; + int _remainingDuration = 0; + + double _thumbnailViewerW = 0.0; + double _thumbnailViewerH = 0.0; + + int _numberOfThumbnails = 0; + + double _autoStartScrollPos = 0.0; + double _autoEndScrollPos = 0.0; + + late double _startCircleSize; + late double _endCircleSize; + late double _borderRadius; + + double? fraction; + double? maxLengthPixels; + + ScrollableThumbnailViewer? thumbnailWidget; + + Animation? _scrubberAnimation; + AnimationController? _animationController; + late Tween _linearTween; + + /// Quick access to VideoPlayerController, only not null after [TrimmerEvent.initialized] + /// has been emitted. + VideoPlayerController get videoPlayerController => + widget.trimmer.videoPlayerController!; + + /// Keep track of the drag type, e.g. whether the user drags the left, center or + /// right part of the frame. Set this in [_onDragStart] when the dragging starts. + EditorDragType _dragType = EditorDragType.left; + + /// Whether the dragging is allowed. Dragging is ignore if the user's gesture is outside + /// of the frame, to make the UI more realistic. + bool _allowDrag = true; + + late final ScrollController _scrollController; + double scrollByValue = 10.0; + double currentScrollValue = 0.0; + double totalVideoLengthInPixels = 0.0; + + Timer? _scrollStartTimer; + Timer? _scrollingTimer; + + void startScrolling(bool isTowardsEnd) { + _scrollingTimer = + Timer.periodic(const Duration(milliseconds: 300), (timer) { + setState(() { + final midPoint = (_endPos.dx - _startPos.dx) / 2; + var speedMultiplier = 1; + if (isTowardsEnd) { + if (_localPosition >= _endPos.dx) { + speedMultiplier = 5; + } else if (_localPosition > (midPoint + (midPoint * 2 / 3))) { + speedMultiplier = 4; + } else if (_localPosition > (midPoint + midPoint / 3)) { + speedMultiplier = 2; + } + log('End scroll speed: ${speedMultiplier}x'); + if (_endPos.dx >= _autoEndScrollPos && + currentScrollValue <= totalVideoLengthInPixels) { + currentScrollValue = math.min( + currentScrollValue + scrollByValue * speedMultiplier, + _numberOfThumbnails * _thumbnailViewerH); + } else { + _scrollingTimer?.cancel(); + return; + } + } else { + if (_localPosition <= _startPos.dx) { + speedMultiplier = 5; + } else if (_localPosition < (midPoint - (midPoint * 2 / 3))) { + speedMultiplier = 4; + } else if (_localPosition < (midPoint - midPoint / 3)) { + speedMultiplier = 2; + } + log('Start scroll speed: ${speedMultiplier}x'); + if (_startPos.dx <= _autoStartScrollPos && currentScrollValue != 0) { + currentScrollValue = math.max( + 0, currentScrollValue - scrollByValue * speedMultiplier); + } else { + _scrollingTimer?.cancel(); + return; + } + } + // log('scroll pixels: ${_scrollController.position.pixels}'); + }); + + log('SCROLL: $currentScrollValue, (${((_scrollController.position.pixels / _scrollController.position.maxScrollExtent) * 100).toStringAsFixed(2)}%)'); + _scrollController.animateTo( + currentScrollValue, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 100), + ); + final durationChange = (_scrollController.position.pixels / + _scrollController.position.maxScrollExtent) * + _remainingDuration; + _videoStartPos = (_trimmerAreaDuration * _startFraction) + durationChange; + _videoEndPos = (_trimmerAreaDuration * _endFraction) + durationChange; + }); + setState(() {}); + } + + void startTimer(bool isTowardsEnd) { + var start = 300; + _scrollStartTimer = Timer.periodic( + const Duration(milliseconds: 100), + (Timer timer) { + if (start == 0) { + timer.cancel(); + log('ANIMATE'); + if (_scrollingTimer?.isActive ?? false) return; + startScrolling(isTowardsEnd); + } else { + start -= 100; + } + }, + ); + } + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _startCircleSize = widget.editorProperties.circleSize; + _endCircleSize = widget.editorProperties.circleSize; + _borderRadius = widget.editorProperties.borderRadius; + _thumbnailViewerH = widget.viewerHeight; + SchedulerBinding.instance.addPostFrameCallback((_) { + final renderBox = + _trimmerAreaKey.currentContext?.findRenderObject() as RenderBox?; + final trimmerActualWidth = renderBox?.size.width; + log('RENDER BOX: ${renderBox?.size.width}'); + if (trimmerActualWidth == null) return; + _thumbnailViewerW = trimmerActualWidth; + _initializeVideoController(); + // The video has been initialized, now we can load stuff + videoPlayerController.seekTo(const Duration(milliseconds: 0)); + setState(() { + final totalDuration = videoPlayerController.value.duration; + log('Total Video Length: $totalDuration'); + final maxVideoLength = widget.maxVideoLength; + log('Max Video Length: $maxVideoLength'); + final paddingFraction = widget.paddingFraction; + log('Padding Fraction: $paddingFraction'); + // trimAreaTime = maxVideoLength + (paddingFraction * maxVideoLength) * 2 + final trimAreaDuration = Duration( + milliseconds: (maxVideoLength.inMilliseconds + + ((paddingFraction * maxVideoLength.inMilliseconds) * 2) + .toInt())); + log('Trim Area Duration: $trimAreaDuration'); + final remainingDuration = totalDuration - trimAreaDuration; + log('Remaining Duration: $remainingDuration'); + _remainingDuration = remainingDuration.inMilliseconds; + final trimAreaLength = _thumbnailViewerW; + log('TRIM AREA LENGTH: $trimAreaLength'); + final autoScrollAreaLength = trimAreaLength * 0.02; + log('autoScrollAreaLength: $autoScrollAreaLength'); + _autoStartScrollPos = autoScrollAreaLength; + _autoEndScrollPos = trimAreaLength - autoScrollAreaLength; + log('autoStartScrollPos: $_autoStartScrollPos, autoEndScrollPos: $_autoEndScrollPos'); + final thumbnailHeight = widget.viewerHeight; + final numberOfThumbnailsInArea = trimAreaLength / thumbnailHeight; + final numberOfThumbnailsTotal = (numberOfThumbnailsInArea * + (totalDuration.inMilliseconds / + trimAreaDuration.inMilliseconds)) + .toInt(); + log('THUMBNAILS: in area=$numberOfThumbnailsInArea, total=$numberOfThumbnailsTotal'); + + // find precise durations according to the number of thumbnails; + // preciseTotalLength = numberOfThumbnailsTotal * thumbnailHeight + // totalDuration => preciseTotalLength + // areaDuration => (preciseTotalLength * areaDuration) / totalDuration + _numberOfThumbnails = numberOfThumbnailsTotal; + final thumbnailWidget = ScrollableThumbnailViewer( + scrollController: _scrollController, + videoFile: _videoFile!, + videoDuration: _videoDuration, + fit: widget.areaProperties.thumbnailFit, + thumbnailHeight: _thumbnailViewerH, + numberOfThumbnails: _numberOfThumbnails, + quality: widget.areaProperties.thumbnailQuality, + onThumbnailLoadingComplete: widget.onThumbnailLoadingComplete, + ); + this.thumbnailWidget = thumbnailWidget; + log('========================='); + final preciseTotalLength = numberOfThumbnailsTotal * thumbnailHeight; + log('preciseTotalLength: $preciseTotalLength'); + totalVideoLengthInPixels = preciseTotalLength - trimAreaLength; + log('totalVideoLengthInPixels: $totalVideoLengthInPixels'); + final preciseAreaDuration = Duration( + milliseconds: (totalDuration.inMilliseconds * trimAreaLength) ~/ + preciseTotalLength); + _trimmerAreaDuration = preciseAreaDuration.inMilliseconds; + log('preciseAreaDuration: $preciseAreaDuration'); + final trimmerFraction = + maxVideoLength.inMilliseconds / preciseAreaDuration.inMilliseconds; + log('trimmerFraction: $trimmerFraction'); + final trimmerCover = trimmerFraction * trimAreaLength; + maxLengthPixels = trimmerCover; + _endPos = Offset(trimmerCover, thumbnailHeight); + log('START: $_startPos, END: $_endPos'); + + _videoEndPos = + preciseAreaDuration.inMilliseconds.toDouble() * trimmerFraction; + log('Video End Pos: $_videoEndPos ms'); + widget.onChangeEnd!(_videoEndPos); + log('Video Selected Duration: ${_videoEndPos - _videoStartPos}'); + + // Defining the tween points + _linearTween = Tween(begin: _startPos.dx, end: _endPos.dx); + _animationController = AnimationController( + vsync: this, + duration: + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()), + ); + + _scrubberAnimation = _linearTween.animate(_animationController!) + ..addListener(() { + setState(() {}); + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _animationController!.stop(); + } + }); + }); + }); + } + + Future _initializeVideoController() async { + if (_videoFile == null) return; + videoPlayerController.addListener(() { + final bool isPlaying = videoPlayerController.value.isPlaying; + + if (isPlaying) { + widget.onChangePlaybackState!(true); + setState(() { + _currentPosition = + videoPlayerController.value.position.inMilliseconds; + + if (_currentPosition > _videoEndPos.toInt()) { + videoPlayerController.pause(); + widget.onChangePlaybackState!(false); + _animationController!.stop(); + } else { + if (!_animationController!.isAnimating) { + widget.onChangePlaybackState!(true); + _animationController!.forward(); + } + } + }); + } else { + if (videoPlayerController.value.isInitialized) { + if (_animationController != null) { + if ((_scrubberAnimation?.value ?? 0).toInt() == + (_endPos.dx).toInt()) { + _animationController!.reset(); + } + _animationController!.stop(); + widget.onChangePlaybackState!(false); + } + } + } + }); + + videoPlayerController.setVolume(1.0); + _videoDuration = videoPlayerController.value.duration.inMilliseconds; + } + + /// Called when the user starts dragging the frame, on either side on the whole frame. + /// Determine which [EditorDragType] is used. + void _onDragStart(DragStartDetails details) { + log("onDragStart"); + log(details.localPosition.toString()); + log((_startPos.dx - details.localPosition.dx).abs().toString()); + log((_endPos.dx - details.localPosition.dx).abs().toString()); + + final startDifference = _startPos.dx - details.localPosition.dx; + final endDifference = _endPos.dx - details.localPosition.dx; + + // First we determine whether the dragging motion should be allowed. The allowed + // zone is widget.sideTapSize (left) + frame (center) + widget.sideTapSize (right) + if (startDifference <= widget.editorProperties.sideTapSize && + endDifference >= -widget.editorProperties.sideTapSize) { + _allowDrag = true; + } else { + debugPrint("Dragging is outside of frame, ignoring gesture..."); + _allowDrag = false; + return; + } + + // Now we determine which part is dragged + if (details.localPosition.dx <= + _startPos.dx + widget.editorProperties.sideTapSize) { + _dragType = EditorDragType.left; + } else if (details.localPosition.dx <= + _endPos.dx - widget.editorProperties.sideTapSize) { + _dragType = EditorDragType.center; + } else { + _dragType = EditorDragType.right; + } + } + + /// Called during dragging, only executed if [_allowDrag] was set to true in + /// [_onDragStart]. + /// Makes sure the limits are respected. + void _onDragUpdate(DragUpdateDetails details) { + if (!_allowDrag) return; + + // log('Local pos: ${details.localPosition}'); + _localPosition = details.localPosition.dx; + + if (_dragType == EditorDragType.left) { + _startCircleSize = widget.editorProperties.circleSizeOnDrag; + if ((_startPos.dx + details.delta.dx >= 0) && + (_startPos.dx + details.delta.dx <= _endPos.dx) && + !(_endPos.dx - _startPos.dx - details.delta.dx > maxLengthPixels!)) { + _startPos += details.delta; + _onStartDragged(); + } + } else if (_dragType == EditorDragType.center) { + _startCircleSize = widget.editorProperties.circleSizeOnDrag; + _endCircleSize = widget.editorProperties.circleSizeOnDrag; + if ((_startPos.dx + details.delta.dx >= 0) && + (_endPos.dx + details.delta.dx <= _thumbnailViewerW)) { + _startPos += details.delta; + _endPos += details.delta; + _onStartDragged(); + _onEndDragged(); + } + } else { + _endCircleSize = widget.editorProperties.circleSizeOnDrag; + if ((_endPos.dx + details.delta.dx <= _thumbnailViewerW) && + (_endPos.dx + details.delta.dx >= _startPos.dx) && + !(_endPos.dx - _startPos.dx + details.delta.dx > maxLengthPixels!)) { + _endPos += details.delta; + _onEndDragged(); + } + } + // log('Video Duration :: Start: ${_videoStartPos / 1000}ms, End: ${_videoEndPos / 1000}ms'); + // log('UPDATE => START: ${_startPos.dx}, END: ${_endPos.dx}'); + _scrollStartTimer?.cancel(); + if (_endPos.dx >= _autoEndScrollPos && + currentScrollValue <= totalVideoLengthInPixels) { + startTimer(true); + } else if (_startPos.dx <= _autoStartScrollPos && + currentScrollValue != 0.0) { + startTimer(false); + } + + setState(() {}); + } + + void _onStartDragged() { + if (_scrollingTimer?.isActive ?? false) return; + _startFraction = (_startPos.dx / _thumbnailViewerW); + _videoStartPos = (_trimmerAreaDuration * _startFraction) + + (_scrollController.position.pixels / + _scrollController.position.maxScrollExtent) * + _remainingDuration; + widget.onChangeStart!(_videoStartPos); + _linearTween.begin = _startPos.dx; + _animationController!.duration = + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()); + _animationController!.reset(); + } + + void _onEndDragged() { + if (_scrollingTimer?.isActive ?? false) return; + _endFraction = _endPos.dx / _thumbnailViewerW; + _videoEndPos = (_trimmerAreaDuration * _endFraction) + + (_scrollController.position.pixels / + _scrollController.position.maxScrollExtent) * + _remainingDuration; + widget.onChangeEnd!(_videoEndPos); + _linearTween.end = _endPos.dx; + _animationController!.duration = + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()); + _animationController!.reset(); + } + + /// Drag gesture ended, update UI accordingly. + void _onDragEnd(DragEndDetails details) { + log('onDragEnd'); + _scrollStartTimer?.cancel(); + _scrollingTimer?.cancel(); + setState(() { + _startCircleSize = widget.editorProperties.circleSize; + _endCircleSize = widget.editorProperties.circleSize; + if (_dragType == EditorDragType.right) { + videoPlayerController + .seekTo(Duration(milliseconds: _videoEndPos.toInt())); + } else { + videoPlayerController + .seekTo(Duration(milliseconds: _videoStartPos.toInt())); + } + }); + } + + @override + void dispose() { + videoPlayerController.pause(); + _scrollController.dispose(); + _scrollStartTimer?.cancel(); + _scrollingTimer?.cancel(); + widget.onChangePlaybackState!(false); + if (_videoFile != null) { + videoPlayerController.setVolume(0.0); + videoPlayerController.dispose(); + widget.onChangePlaybackState!(false); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.showDuration + ? SizedBox( + width: _thumbnailViewerW, + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + Duration(milliseconds: _videoStartPos.toInt()) + .toString() + .split('.')[0], + style: widget.durationTextStyle, + ), + videoPlayerController.value.isPlaying + ? Text( + Duration(milliseconds: _currentPosition.toInt()) + .toString() + .split('.')[0], + style: widget.durationTextStyle, + ) + : Container(), + Text( + Duration(milliseconds: _videoEndPos.toInt()) + .toString() + .split('.')[0], + style: widget.durationTextStyle, + ), + ], + ), + ), + ) + : Container(), + Stack( + clipBehavior: Clip.none, + children: [ + CustomPaint( + foregroundPainter: TrimEditorPainter( + startPos: _startPos, + endPos: _endPos, + scrubberAnimationDx: _scrubberAnimation?.value ?? 0, + startCircleSize: _startCircleSize, + endCircleSize: _endCircleSize, + borderRadius: _borderRadius, + borderWidth: widget.editorProperties.borderWidth, + scrubberWidth: widget.editorProperties.scrubberWidth, + circlePaintColor: widget.editorProperties.circlePaintColor, + borderPaintColor: widget.editorProperties.borderPaintColor, + scrubberPaintColor: + widget.editorProperties.scrubberPaintColor, + ), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + widget.areaProperties.borderRadius), + child: Container( + key: _trimmerAreaKey, + color: Colors.grey[900], + height: _thumbnailViewerH, + width: _thumbnailViewerW == 0.0 + ? widget.viewerWidth + : _thumbnailViewerW, + child: thumbnailWidget ?? Container(), + ), + ), + _scrollController.positions.isNotEmpty + ? AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + gradient: widget.areaProperties.blurEdges + ? LinearGradient( + stops: const [0.0, 0.1, 0.9, 1.0], + colors: [ + _scrollController.position.pixels == 0.0 + ? Colors.transparent + : widget.areaProperties.blurColor, + Colors.transparent, + Colors.transparent, + _scrollController.position.pixels == + _scrollController + .position.maxScrollExtent + ? Colors.transparent + : widget.areaProperties.blurColor, + ], + ) + : null, + ), + height: _thumbnailViewerH, + width: widget.viewerWidth, + child: Row( + children: [ + AnimatedOpacity( + opacity: + _scrollController.position.pixels != 0.0 + ? 1.0 + : 0.0, + duration: const Duration(milliseconds: 300), + child: widget.areaProperties.startIcon), + const Spacer(), + AnimatedOpacity( + opacity: _scrollController.position.pixels != + _scrollController + .position.maxScrollExtent + ? 1.0 + : 0.0, + duration: const Duration(milliseconds: 300), + child: widget.areaProperties.endIcon, + ), + ], + ), + ) + : const SizedBox(), + ], + ), + ), + // This widget is in development for making the DEBUGGING + // process of this package easier + Visibility( + visible: false, + child: Row( + children: [ + Container( + color: Colors.red.withOpacity(0.6), + height: _thumbnailViewerH, + // 2% of total trimmer width + width: (_thumbnailViewerW == 0.0 + ? widget.viewerWidth + : _thumbnailViewerW) * + 0.02, + ), + const Spacer(), + Container( + color: Colors.red.withOpacity(0.6), + height: _thumbnailViewerH, + // 2% of total trimmer width + width: (_thumbnailViewerW == 0.0 + ? widget.viewerWidth + : _thumbnailViewerW) * + 0.02, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/trim_viewer/trim_area_properties.dart b/lib/src/trim_viewer/trim_area_properties.dart new file mode 100644 index 00000000..7c32ca84 --- /dev/null +++ b/lib/src/trim_viewer/trim_area_properties.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; + +class TrimAreaProperties { + /// For defining the image fit type of each thumbnail image. + /// + /// By default it is set to `BoxFit.fitHeight`. + final BoxFit thumbnailFit; + + /// For specifying the quality of each + /// generated image thumbnail, to be displayed in the trimmer + /// area. + /// + /// By default it is set to `75`. + final int thumbnailQuality; + + /// For adding a blur to the trim area edges. Use `blurColor` + /// for specifying the color of the blur (usually it's the + /// background color which helps in blending). + /// + /// By default it is set to `false`. + final bool blurEdges; + + /// For specifying the color of the blur. Use the color of the + /// background to blend with it. + /// + /// By default it is set to `Colors.black`. + final Color blurColor; + + /// For specifying the widget to be placed at the start + /// of the trimmer area. You can pass `null` for hiding + /// the widget. + /// + /// By default it is set as: + /// + /// ```dart + /// Icon( + /// Icons.arrow_back_ios_new_rounded, + /// color: Colors.white, + /// size: 16, + /// ) + /// ``` + final Widget? startIcon; + + /// For specifying the widget to be placed at the end + /// of the trimmer area. You can pass `null` for hiding + /// the widget. + /// + /// By default it is set as: + /// + /// ```dart + /// Icon( + /// Icons.arrow_forward_ios_rounded, + /// color: Colors.white, + /// size: 16, + /// ) + /// ``` + final Widget? endIcon; + + /// For specifying the size of the circular border radius + /// to be applied to each corner of the trimmer area Container. + /// + /// By default it is set to `4.0`. + final double borderRadius; + + /// Helps defining the Trim Area properties. + /// + /// A better look at the structure of the **Trim Viewer**: + /// + /// ![](https://raw.githubusercontent.com/sbis04/video_trimmer/new_editor/screenshots/trim_viewer_preview_small.png) + /// + /// + /// All the parameters are optional: + /// + /// * [thumbnailFit] for specifying the image fit type of each thumbnail image. + /// By default it is set to `BoxFit.fitHeight`. + /// + /// + /// * [thumbnailQuality] for specifying the quality of each + /// generated image thumbnail, to be displayed in the trimmer + /// area. By default it is set to `75`. + /// + /// + /// * [blurEdges] for adding a blur to the trim area edges. Use `blurColor` + /// for specifying the color of the blur (usually it's the background color + /// which helps in blending). By default it is set to `false`. + /// + /// + /// * [blurColor] for specifying the color of the blur. Use the color of the + /// background to blend with it. By default it is set to `Colors.black`. + /// + /// + /// * [startIcon] for specifying the widget to be placed at the start + /// of the trimmer area. You can pass `null` for hiding + /// the widget. + /// + /// + /// * [endIcon] for specifying the widget to be placed at the end + /// of the trimmer area. You can pass `null` for hiding + /// the widget. + /// + /// + /// * [borderRadius] for specifying the size of the circular border radius + /// to be applied to each corner of the trimmer area Container. + /// By default it is set to `4.0`. + /// + const TrimAreaProperties({ + this.thumbnailFit = BoxFit.fitHeight, + this.thumbnailQuality = 75, + this.blurEdges = false, + this.blurColor = Colors.black, + this.startIcon, + this.endIcon, + this.borderRadius = 4.0, + }); + + /// Helps defining the Fixed Trim Area properties. + /// + /// A better look at the structure of the **Trim Viewer**: + /// + /// ![](https://raw.githubusercontent.com/sbis04/video_trimmer/new_editor/screenshots/trim_viewer_preview_small.png) + /// + /// + /// All the parameters are optional: + /// + /// * [thumbnailFit] for specifying the image fit type of each thumbnail image. + /// By default it is set to `BoxFit.fitHeight`. + /// + /// + /// * [thumbnailQuality] for specifying the quality of each + /// generated image thumbnail, to be displayed in the trimmer + /// area. By default it is set to `75`. + /// + /// + /// * [borderRadius] for specifying the size of the circular border radius + /// to be applied to each corner of the trimmer area Container. + /// By default it is set to `4.0`. + /// + factory TrimAreaProperties.fixed({ + BoxFit thumbnailFit, + int thumbnailQuality, + double borderRadius, + }) = FixedTrimAreaProperties; + + /// Helps defining the Trim Area properties with blur & arrows on the edges. + /// + /// A better look at the structure of the **Trim Viewer**: + /// + /// ![](https://raw.githubusercontent.com/sbis04/video_trimmer/new_editor/screenshots/trim_viewer_preview_small.png) + /// + factory TrimAreaProperties.edgeBlur({ + BoxFit thumbnailFit, + int thumbnailQuality, + bool blurEdges, + Color blurColor, + Widget? startIcon, + Widget? endIcon, + double borderRadius, + }) = _TrimAreaPropertiesWithBlur; +} + +class FixedTrimAreaProperties extends TrimAreaProperties { + /// Helps defining the Fixed Trim Area properties. + /// + /// A better look at the structure of the **Trim Viewer**: + /// + /// ![](https://raw.githubusercontent.com/sbis04/video_trimmer/new_editor/screenshots/trim_viewer_preview_small.png) + /// + /// + /// All the parameters are optional: + /// + /// * [thumbnailFit] for specifying the image fit type of each thumbnail image. + /// By default it is set to `BoxFit.fitHeight`. + /// + /// + /// * [thumbnailQuality] for specifying the quality of each + /// generated image thumbnail, to be displayed in the trimmer + /// area. By default it is set to `75`. + /// + /// + /// * [borderRadius] for specifying the size of the circular border radius + /// to be applied to each corner of the trimmer area Container. + /// By default it is set to `4.0`. + /// + const FixedTrimAreaProperties({ + super.thumbnailFit, + super.thumbnailQuality, + super.borderRadius, + }); +} + +class _TrimAreaPropertiesWithBlur extends TrimAreaProperties { + _TrimAreaPropertiesWithBlur({ + super.thumbnailFit, + super.thumbnailQuality, + blurEdges, + super.blurColor, + super.borderRadius, + endIcon, + startIcon, + }) : super( + blurEdges: true, + startIcon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + size: 16, + ), + endIcon: const Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.white, + size: 16, + ), + ); +} diff --git a/lib/src/trim_editor_painter.dart b/lib/src/trim_viewer/trim_editor_painter.dart similarity index 74% rename from lib/src/trim_editor_painter.dart rename to lib/src/trim_viewer/trim_editor_painter.dart index 38202dc4..9e7ab84a 100644 --- a/lib/src/trim_editor_painter.dart +++ b/lib/src/trim_viewer/trim_editor_painter.dart @@ -10,10 +10,20 @@ class TrimEditorPainter extends CustomPainter { /// To define the horizontal length of the selected video area final double scrubberAnimationDx; - /// For specifying a size to the holder at the - /// two ends of the video trimmer area, while it is `idle`. + /// For specifying a circular border radius + /// to the corners of the trim area. + /// By default it is set to `4.0`. + final double borderRadius; + + /// For specifying a size to the start holder + /// of the video trimmer area. + /// By default it is set to `0.5`. + final double startCircleSize; + + /// For specifying a size to the end holder + /// of the video trimmer area. /// By default it is set to `0.5`. - final double circleSize; + final double endCircleSize; /// For specifying the width of the border around /// the trim area. By default it is set to `3`. @@ -55,11 +65,21 @@ class TrimEditorPainter extends CustomPainter { /// /// The optional parameters are: /// - /// * [circleSize] for specifying a size to the holder at the - /// two ends of the video trimmer area, while it is `idle`. + /// * [startCircleSize] for specifying a size to the start holder + /// of the video trimmer area. + /// By default it is set to `0.5`. + /// + /// + /// * [endCircleSize] for specifying a size to the end holder + /// of the video trimmer area. /// By default it is set to `0.5`. /// /// + /// * [borderRadius] for specifying a circular border radius + /// to the corners of the trim area. + /// By default it is set to `4.0`. + /// + /// /// * [borderWidth] for specifying the width of the border around /// the trim area. By default it is set to `3`. /// @@ -86,7 +106,9 @@ class TrimEditorPainter extends CustomPainter { required this.startPos, required this.endPos, required this.scrubberAnimationDx, - this.circleSize = 0.5, + this.startCircleSize = 0.5, + this.endCircleSize = 0.5, + this.borderRadius = 4, this.borderWidth = 3, this.scrubberWidth = 1, this.showScrubber = true, @@ -116,6 +138,10 @@ class TrimEditorPainter extends CustomPainter { ..strokeCap = StrokeCap.round; final rect = Rect.fromPoints(startPos, endPos); + final roundedRect = RRect.fromRectAndRadius( + rect, + Radius.circular(borderRadius), + ); if (showScrubber) { if (scrubberAnimationDx.toInt() > startPos.dx.toInt()) { @@ -127,11 +153,13 @@ class TrimEditorPainter extends CustomPainter { } } - canvas.drawRect(rect, borderPaint); + canvas.drawRRect(roundedRect, borderPaint); + // Paint start holder canvas.drawCircle( - startPos + Offset(0, endPos.dy / 2), circleSize, circlePaint); + startPos + Offset(0, endPos.dy / 2), startCircleSize, circlePaint); + // Paint end holder canvas.drawCircle( - endPos + Offset(0, -endPos.dy / 2), circleSize, circlePaint); + endPos + Offset(0, -endPos.dy / 2), endCircleSize, circlePaint); } @override diff --git a/lib/src/trim_viewer/trim_editor_properties.dart b/lib/src/trim_viewer/trim_editor_properties.dart new file mode 100644 index 00000000..17fff809 --- /dev/null +++ b/lib/src/trim_viewer/trim_editor_properties.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +class TrimEditorProperties { + /// For specifying a size to the holder at the + /// two ends of the video trimmer area, while it is `idle`. + /// + /// By default it is set to `5.0`. + final double circleSize; + + /// For specifying a size to the holder at + /// the two ends of the video trimmer area, while it is being + /// `dragged`. + /// + /// By default it is set to `8.0`. + final double circleSizeOnDrag; + + /// For specifying the width of the border around + /// the trim area. By default it is set to `3`. + final double borderWidth; + + /// For specifying the width of the video scrubber + final double scrubberWidth; + + /// For specifying a circular border radius + /// to the corners of the trim area. + /// + /// By default it is set to `4.0`. + final double borderRadius; + + /// For specifying a color to the circle. + /// + /// By default it is set to `Colors.white`. + final Color circlePaintColor; + + /// For specifying a color to the border of + /// the trim area. + /// + /// By default it is set to `Colors.white`. + final Color borderPaintColor; + + /// For specifying a color to the video + /// scrubber inside the trim area. + /// + /// By default it is set to `Colors.white`. + final Color scrubberPaintColor; + + /// Determines the touch size of the side handles, left and right. The rest, in + /// the center, will move the whole frame if [maxVideoLength] is inferior to the + /// total duration of the video. + final int sideTapSize; + + /// Helps defining the Trim Editor properties. + /// + /// A better look at the structure of the **Trim Viewer**: + /// + /// ![](https://raw.githubusercontent.com/sbis04/video_trimmer/new_editor/screenshots/trim_viewer_preview_small.png) + /// + /// + /// All the parameters are optional: + /// + /// * [circleSize] for specifying a size to the holder at the + /// two ends of the video trimmer area, while it is `idle`. + /// By default it is set to `5.0`. + /// + /// + /// * [circleSizeOnDrag] for specifying a size to the holder at + /// the two ends of the video trimmer area, while it is being + /// `dragged`. By default it is set to `8.0`. + /// + /// + /// * [borderWidth] for specifying the width of the border around + /// the trim area. By default it is set to `3.0`. + /// + /// * [scrubberWidth] for specifying the width of the video scrubber. + /// By default it is set to `1.0`. + /// + /// + /// * [borderRadius] for applying a circular border radius + /// to the corners of the trim area. By default it is set to `4.0`. + /// + /// + /// * [circlePaintColor] for specifying a color to the circle. + /// By default it is set to `Colors.white`. + /// + /// + /// * [borderPaintColor] for specifying a color to the border of + /// the trim area. By default it is set to `Colors.white`. + /// + /// + /// * [scrubberPaintColor] for specifying a color to the video + /// scrubber inside the trim area. By default it is set to + /// `Colors.white`. + /// + /// + /// * [sideTapSize] determines the touch size of the side handles, left and right. + /// The rest, in the center, will move the whole frame if [maxVideoLength] is + /// inferior to the total duration of the video. + /// + const TrimEditorProperties({ + this.circleSize = 5.0, + this.circleSizeOnDrag = 8.0, + this.borderWidth = 3.0, + this.scrubberWidth = 1.0, + this.borderRadius = 4.0, + this.circlePaintColor = Colors.white, + this.borderPaintColor = Colors.white, + this.scrubberPaintColor = Colors.white, + this.sideTapSize = 24, + }); +} diff --git a/lib/src/trim_viewer/trim_viewer.dart b/lib/src/trim_viewer/trim_viewer.dart new file mode 100644 index 00000000..50bf548e --- /dev/null +++ b/lib/src/trim_viewer/trim_viewer.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:video_trimmer/video_trimmer.dart'; + +import 'fixed_viewer/fixed_trim_viewer.dart'; +import 'scrollable_viewer/scrollable_trim_viewer.dart'; + +enum ViewerType { + /// Automatically decide whether to use the + /// fixed length or scrollable editor. + auto, + + /// Use fixed length editor, `FixedTrimViewer`. + fixed, + + /// Use scrollable editor, `ScrollableTrimViewer`. + scrollable, +} + +class TrimViewer extends StatefulWidget { + /// The Trimmer instance controlling the data. + final Trimmer trimmer; + + /// For defining the total trimmer area width + final double viewerWidth; + + /// For defining the total trimmer area height + final double viewerHeight; + + /// For specifying the type of the trim viewer. + /// You can choose among: `auto`, `fixed`, and `scrollable`. + /// + /// **NOTE:** While using `scrollable` if the total video + /// duration is less than maxVideoLength + padding, it + /// will throw an error. + /// + /// By default it is set to `ViewerType.auto`. + final ViewerType type; + + /// For defining the maximum length of the output video. + /// + /// **NOTE:** When explicitly setting the `type` to `scrollable`, + /// specifying this property is mandatory. + final Duration maxVideoLength; + + /// For showing the start and the end point of the + /// video on top of the trimmer area. + /// + /// By default it is set to `true`. + final bool showDuration; + + /// For providing a `TextStyle` to the + /// duration text. + /// + /// By default it is set to `TextStyle(color: Colors.white)` + final TextStyle durationTextStyle; + + /// Callback to the video start position + /// + /// Returns the selected video start position in `milliseconds`. + final Function(double startValue)? onChangeStart; + + /// Callback to the video end position. + /// + /// Returns the selected video end position in `milliseconds`. + final Function(double endValue)? onChangeEnd; + + /// Callback to the video playback + /// state to know whether it is currently playing or paused. + /// + /// Returns a `boolean` value. If `true`, video is currently + /// playing, otherwise paused. + final Function(bool isPlaying)? onChangePlaybackState; + + /// This is the fraction of padding present beside the trimmer editor, + /// calculated on the `maxVideoLength` value. + final double paddingFraction; + + /// Properties for customizing the trim editor. + final TrimEditorProperties editorProperties; + + /// Properties for customizing the trim area. + final TrimAreaProperties areaProperties; + + /// Callback for thumbnail loader to know when all the + /// thumbnails are loaded. + final VoidCallback? onThumbnailLoadingComplete; + + /// Widget for displaying the video trimmer. + /// + /// This has frame wise preview of the video with a + /// slider for selecting the part of the video to be + /// trimmed. It automatically selected whether to use + /// `FixedTrimViewer` or `ScrollableTrimViewer`. + /// + /// If you want to use a specific kind of trim viewer, use + /// the `type` property. + /// + /// The required parameters are [viewerWidth] & [viewerHeight] + /// + /// * [viewerWidth] to define the total trimmer area width. + /// + /// + /// * [viewerHeight] to define the total trimmer area height. + /// + /// + /// The optional parameters are: + /// + /// * [type] for specifying the type of the trim viewer. + /// + /// + /// * [fit] for specifying the image fit type of each thumbnail image. + /// By default it is set to `BoxFit.fitHeight`. + /// + /// + /// * [maxVideoLength] for specifying the maximum length of the + /// output video. + /// + /// + /// * [circlePaintColor] for specifying a color to the circle. + /// By default it is set to `Colors.white`. + /// + /// + /// * [borderPaintColor] for specifying a color to the border of + /// the trim area. By default it is set to `Colors.white`. + /// + /// + /// * [scrubberPaintColor] for specifying a color to the video + /// scrubber inside the trim area. By default it is set to + /// `Colors.white`. + /// + /// + /// * [thumbnailQuality] for specifying the quality of each + /// generated image thumbnail, to be displayed in the trimmer + /// area. + /// + /// + /// * [showDuration] for showing the start and the end point of the + /// video on top of the trimmer area. By default it is set to `true`. + /// + /// + /// * [durationTextStyle] is for providing a `TextStyle` to the + /// duration text. By default it is set to + /// `TextStyle(color: Colors.white)` + /// + /// + /// * [onChangeStart] is a callback to the video start position. + /// + /// + /// * [onChangeEnd] is a callback to the video end position. + /// + /// + /// * [onChangePlaybackState] is a callback to the video playback + /// state to know whether it is currently playing or paused. + /// + /// + /// * [editorProperties] defines properties for customizing the trim editor. + /// + /// + /// * [areaProperties] defines properties for customizing the trim area. + /// + /// + /// * [onThumbnailLoadingComplete] is a callback for thumbnail loader to + /// know when all the thumbnails are loaded. + /// + const TrimViewer({ + Key? key, + required this.trimmer, + this.maxVideoLength = const Duration(milliseconds: 0), + this.type = ViewerType.auto, + this.viewerWidth = 50 * 8, + this.viewerHeight = 50, + this.showDuration = true, + this.durationTextStyle = const TextStyle(color: Colors.white), + this.onChangeStart, + this.onChangeEnd, + this.onChangePlaybackState, + this.paddingFraction = 0.2, + this.editorProperties = const TrimEditorProperties(), + this.areaProperties = const TrimAreaProperties(), + this.onThumbnailLoadingComplete, + }) : super(key: key); + + @override + State createState() => _TrimViewerState(); +} + +class _TrimViewerState extends State with TickerProviderStateMixin { + bool? _isScrollableAllowed; + + @override + void initState() { + super.initState(); + widget.trimmer.eventStream.listen((event) { + if (event == TrimmerEvent.initialized) { + final totalDuration = + widget.trimmer.videoPlayerController!.value.duration; + final maxVideoLength = widget.maxVideoLength; + final paddingFraction = widget.paddingFraction; + final trimAreaDuration = Duration( + milliseconds: (maxVideoLength.inMilliseconds + + ((paddingFraction * maxVideoLength.inMilliseconds) * 2) + .toInt())); + + final shouldScroll = trimAreaDuration <= totalDuration && + maxVideoLength.compareTo(const Duration(milliseconds: 0)) != 0; + if (widget.type == ViewerType.scrollable && !shouldScroll) { + throw 'Total video duration is less than maxVideoLength + padding. ' + 'Can\'t use `ScrollableTrimViewer`. Change the type to `ViewerType.auto`.'; + } + setState(() => _isScrollableAllowed = shouldScroll); + } + }); + } + + @override + Widget build(BuildContext context) { + final scrollableViewer = ScrollableTrimViewer( + trimmer: widget.trimmer, + maxVideoLength: widget.maxVideoLength, + viewerWidth: widget.viewerWidth, + viewerHeight: widget.viewerHeight, + showDuration: widget.showDuration, + durationTextStyle: widget.durationTextStyle, + onChangeStart: widget.onChangeStart, + onChangeEnd: widget.onChangeEnd, + onChangePlaybackState: widget.onChangePlaybackState, + paddingFraction: widget.paddingFraction, + editorProperties: widget.editorProperties, + areaProperties: widget.areaProperties, + onThumbnailLoadingComplete: () { + if (widget.onThumbnailLoadingComplete != null) { + widget.onThumbnailLoadingComplete!(); + } + }, + ); + + final fixedTrimViewer = FixedTrimViewer( + trimmer: widget.trimmer, + maxVideoLength: widget.maxVideoLength, + viewerWidth: widget.viewerWidth, + viewerHeight: widget.viewerHeight, + showDuration: widget.showDuration, + durationTextStyle: widget.durationTextStyle, + onChangeStart: widget.onChangeStart, + onChangeEnd: widget.onChangeEnd, + onChangePlaybackState: widget.onChangePlaybackState, + editorProperties: widget.editorProperties, + areaProperties: FixedTrimAreaProperties( + thumbnailFit: widget.areaProperties.thumbnailFit, + thumbnailQuality: widget.areaProperties.thumbnailQuality, + borderRadius: widget.areaProperties.borderRadius, + ), + onThumbnailLoadingComplete: () { + if (widget.onThumbnailLoadingComplete != null) { + widget.onThumbnailLoadingComplete!(); + } + }, + ); + + return _isScrollableAllowed == null + ? const SizedBox() + : widget.type == ViewerType.fixed + ? fixedTrimViewer + : widget.type == ViewerType.scrollable + ? scrollableViewer + : _isScrollableAllowed == true + ? scrollableViewer + : fixedTrimViewer; + } +} diff --git a/lib/src/trimmer.dart b/lib/src/trimmer.dart index dab171e4..81ddd22f 100644 --- a/lib/src/trimmer.dart +++ b/lib/src/trimmer.dart @@ -9,8 +9,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:video_player/video_player.dart'; -import 'package:video_trimmer/src/file_formats.dart'; -import 'package:video_trimmer/src/storage_dir.dart'; +import 'package:video_trimmer/src/utils/file_formats.dart'; +import 'package:video_trimmer/src/utils/storage_dir.dart'; enum TrimmerEvent { initialized } diff --git a/lib/src/utils/editor_drag_type.dart b/lib/src/utils/editor_drag_type.dart new file mode 100644 index 00000000..ce1aae57 --- /dev/null +++ b/lib/src/utils/editor_drag_type.dart @@ -0,0 +1,10 @@ +enum EditorDragType { + /// The user is dragging the left part of the frame. + left, + + /// The user is dragging the whole frame. + center, + + /// The user is dragging the right part of the frame. + right +} diff --git a/lib/src/file_formats.dart b/lib/src/utils/file_formats.dart similarity index 100% rename from lib/src/file_formats.dart rename to lib/src/utils/file_formats.dart diff --git a/lib/src/storage_dir.dart b/lib/src/utils/storage_dir.dart similarity index 100% rename from lib/src/storage_dir.dart rename to lib/src/utils/storage_dir.dart diff --git a/lib/video_trimmer.dart b/lib/video_trimmer.dart index ce8133b5..c6942c75 100644 --- a/lib/video_trimmer.dart +++ b/lib/video_trimmer.dart @@ -1,9 +1,11 @@ library video_trimmer; -export 'package:video_trimmer/src/video_viewer.dart'; export 'package:video_trimmer/src/trimmer.dart'; -export 'package:video_trimmer/src/trim_editor.dart'; -export 'package:video_trimmer/src/trim_editor_painter.dart'; -export 'package:video_trimmer/src/thumbnail_viewer.dart'; -export 'package:video_trimmer/src/file_formats.dart'; -export 'package:video_trimmer/src/storage_dir.dart'; +export 'package:video_trimmer/src/video_viewer.dart'; +export 'package:video_trimmer/src/utils/file_formats.dart'; +export 'package:video_trimmer/src/utils/storage_dir.dart'; +export 'package:video_trimmer/src/trim_viewer/trim_editor_properties.dart'; +export 'package:video_trimmer/src/trim_viewer/trim_area_properties.dart'; +// Two types of trim viewers, the `TrimViewer` class helps to auto select +// based on the length. +export 'package:video_trimmer/src/trim_viewer/trim_viewer.dart'; diff --git a/pubspec.lock b/pubspec.lock index e7ef97ed..b920ed2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -212,6 +212,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + transparent_image: + dependency: "direct main" + description: + name: transparent_image + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 229e0dd6..e7997246 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: video_trimmer description: A Flutter package for trimming videos. This supports retrieving, trimming, and storage of trimmed video files to the file system. -version: 1.2.0 +version: 2.0.0 homepage: https://github.com/sbis04/video_trimmer environment: @@ -16,6 +16,7 @@ dependencies: path_provider: ^2.0.11 intl: ^0.17.0 path: ^1.8.2 + transparent_image: ^2.0.0 dev_dependencies: flutter_lints: ^2.0.1 diff --git a/screenshots/trim_viewer_preview.png b/screenshots/trim_viewer_preview.png new file mode 100644 index 00000000..bbc1d300 Binary files /dev/null and b/screenshots/trim_viewer_preview.png differ diff --git a/screenshots/trim_viewer_preview_small.png b/screenshots/trim_viewer_preview_small.png new file mode 100644 index 00000000..a29c0bd4 Binary files /dev/null and b/screenshots/trim_viewer_preview_small.png differ diff --git a/screenshots/updated_trimmer_demo.gif b/screenshots/updated_trimmer_demo.gif new file mode 100644 index 00000000..f96dc1eb Binary files /dev/null and b/screenshots/updated_trimmer_demo.gif differ