diff --git a/android/build.gradle b/android/build.gradle index e63b407..e883ef6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ group 'me.anharu.video_editor' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.5.10' repositories { google() jcenter() @@ -41,5 +41,5 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'com.github.MasayukiSuda:Mp4Composer-android:v0.3.9' + implementation 'com.github.MasayukiSuda:Mp4Composer-android:v0.4.1' } diff --git a/android/src/main/kotlin/me/anharu/video_editor/SpeedChanger.kt b/android/src/main/kotlin/me/anharu/video_editor/SpeedChanger.kt new file mode 100644 index 0000000..e2e142a --- /dev/null +++ b/android/src/main/kotlin/me/anharu/video_editor/SpeedChanger.kt @@ -0,0 +1,43 @@ +package me.anharu.video_editor + + +import android.app.Activity +import com.daasuu.mp4compose.composer.Mp4Composer +import io.flutter.plugin.common.MethodChannel.Result + +class SpeedChanger(inputVideo: String, outputVideo: String, val result: Result, val activity: Activity) { + var composer: Mp4Composer = Mp4Composer(inputVideo, outputVideo) + + fun speed(speed:Float) { + composer.timeScale(10F) + .listener(object : Mp4Composer.Listener { + override fun onProgress(progress: Double) { + + } + + override fun onCurrentWrittenVideoTime(timeUs: Long) { + TODO("Not yet implemented") + } + + override fun onCompleted() { + activity.runOnUiThread(Runnable { + result.success(null) + }) + } + + override fun onCanceled() { + activity.runOnUiThread(Runnable { + result.error("user_cancelled", "Cancelled by user", null) + }) + } + + override fun onFailed(exception: Exception?) { + exception?.printStackTrace() + activity.runOnUiThread(Runnable { + result.error("video_trim_failed", exception?.localizedMessage, exception?.stackTrace) + }) + } + + }).start(); + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/me/anharu/video_editor/VideoEditorPlugin.kt b/android/src/main/kotlin/me/anharu/video_editor/VideoEditorPlugin.kt index 44d4d9b..c71720b 100644 --- a/android/src/main/kotlin/me/anharu/video_editor/VideoEditorPlugin.kt +++ b/android/src/main/kotlin/me/anharu/video_editor/VideoEditorPlugin.kt @@ -1,26 +1,20 @@ package me.anharu.video_editor import android.Manifest -import android.app.Application import android.app.Activity -import android.content.pm.PackageManager -import android.os.Environment import androidx.annotation.NonNull import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import com.daasuu.mp4compose.composer.Mp4Composer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.PluginRegistry.Registrar -import me.anharu.video_editor.VideoGeneratorService -import java.io.File -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.PluginRegistry +import io.flutter.plugin.common.PluginRegistry.Registrar /** VideoEditorPlugin */ @@ -57,30 +51,66 @@ public class VideoEditorPlugin : FlutterPlugin, MethodCallHandler, PluginRegistr } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else if (call.method == "writeVideofile") { - - var getActivity = activity ?: return - checkPermission(getActivity) - - val srcFilePath: String = call.argument("srcFilePath") ?: run { - result.error("src_file_path_not_found", "the src file path is not found.", null) - return + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "writeVideofile" -> { + + val getActivity = activity ?: return + checkPermission(getActivity) + + val srcFilePath: String = call.argument("srcFilePath") ?: run { + result.error("src_file_path_not_found", "the src file path is not found.", null) + return + } + val destFilePath: String = call.argument("destFilePath") ?: run { + result.error("dest_file_path_not_found", "the dest file path is not found.", null) + return + } + val processing: HashMap> = call.argument("processing") + ?: run { + result.error("processing_data_not_found", "the processing is not found.", null) + return + } + + val startTime: Long = call.argument("startTime")?.toLong() ?: 0 + val endTime: Long = call.argument("endTime")?.toLong() ?: -1 + val generator = VideoGeneratorService(Mp4Composer(srcFilePath, destFilePath)) + generator.writeVideofile(processing, result, getActivity, startTime = startTime, endTime = endTime) + } + "trim_video" -> { + val getActivity = activity ?: return + val srcFilePath: String = call.argument("srcFilePath") ?: run { + result.error("src_file_path_not_found", "the src file path is not found.", null) + return + } + val destFilePath: String = call.argument("destFilePath") ?: run { + result.error("dest_file_path_not_found", "the dest file path is not found.", null) + return + } + val startTime: Long = call.argument("startTime")?.toLong() ?: 0 + val endTime: Long = call.argument("endTime")?.toLong() ?: -1 + VideoTrimmer(srcFilePath, destFilePath, result, getActivity).trimVideo(startTime, endTime) + } + "speed_change" -> { + val getActivity = activity ?: return + val srcFilePath: String = call.argument("srcFilePath") ?: run { + result.error("src_file_path_not_found", "the src file path is not found.", null) + return + } + val destFilePath: String = call.argument("destFilePath") ?: run { + result.error("dest_file_path_not_found", "the dest file path is not found.", null) + return + } + val speed: Float = call.argument("speed")?.toFloat() ?: 0F + print("===>$speed"); + SpeedChanger(srcFilePath, destFilePath, result, getActivity).speed(speed) } - val destFilePath: String = call.argument("destFilePath") ?: run { - result.error("dest_file_path_not_found", "the dest file path is not found.", null) - return + else -> { + print("===>sxxxxxxxx"); + result.notImplemented() } - val processing: HashMap> = call.argument("processing") - ?: run { - result.error("processing_data_not_found", "the processing is not found.", null) - return - } - val generator = VideoGeneratorService(Mp4Composer(srcFilePath, destFilePath)) - generator.writeVideofile(processing, result, getActivity) - } else { - result.notImplemented() } } diff --git a/android/src/main/kotlin/me/anharu/video_editor/VideoGeneratorService.kt b/android/src/main/kotlin/me/anharu/video_editor/VideoGeneratorService.kt index 0035c32..6b93c17 100644 --- a/android/src/main/kotlin/me/anharu/video_editor/VideoGeneratorService.kt +++ b/android/src/main/kotlin/me/anharu/video_editor/VideoGeneratorService.kt @@ -17,13 +17,13 @@ import me.anharu.video_editor.filter.GlTextOverlayFilter interface VideoGeneratorServiceInterface { - fun writeVideofile(processing: HashMap>, result: Result, activity: Activity); + fun writeVideofile(processing: HashMap>, result: Result, activity: Activity, startTime: Long = 0, endTime: Long = -1); } class VideoGeneratorService( private val composer: Mp4Composer ) : VideoGeneratorServiceInterface { - override fun writeVideofile(processing: HashMap>, result: Result, activity: Activity ) { + override fun writeVideofile(processing: HashMap>, result: Result, activity: Activity,startTime: Long, endTime: Long) { val filters: MutableList = mutableListOf() try { processing.forEach { (k, v) -> @@ -50,10 +50,14 @@ class VideoGeneratorService( }) } composer.filter(GlFilterGroup( filters)) + .trim(startTime, endTime) .listener(object : Mp4Composer.Listener { override fun onProgress(progress: Double) { println("onProgress = " + progress) } + override fun onCurrentWrittenVideoTime(timeUs: Long) { + TODO("Not yet implemented") + } override fun onCompleted() { activity.runOnUiThread(Runnable { @@ -68,7 +72,7 @@ class VideoGeneratorService( } override fun onFailed(exception: Exception) { - println(exception); + exception.printStackTrace() activity.runOnUiThread(Runnable { result.error("video_processing_failed", "video processing is failed.", null) }) diff --git a/android/src/main/kotlin/me/anharu/video_editor/VideoTrimmer.kt b/android/src/main/kotlin/me/anharu/video_editor/VideoTrimmer.kt new file mode 100644 index 0000000..c7ddda1 --- /dev/null +++ b/android/src/main/kotlin/me/anharu/video_editor/VideoTrimmer.kt @@ -0,0 +1,42 @@ +package me.anharu.video_editor + +import android.app.Activity +import com.daasuu.mp4compose.composer.Mp4Composer +import io.flutter.plugin.common.MethodChannel.Result + +class VideoTrimmer(inputVideo: String, outputVideo: String, val result: Result, val activity: Activity) { + var composer: Mp4Composer = Mp4Composer(inputVideo, outputVideo) + + fun trimVideo(startTime: Long = 0, endTime: Long = -1) { + composer.trim(startTime, endTime) + .listener(object : Mp4Composer.Listener { + override fun onProgress(progress: Double) { + + } + + override fun onCurrentWrittenVideoTime(timeUs: Long) { + + } + + override fun onCompleted() { + activity.runOnUiThread(Runnable { + result.success(null) + }) + } + + override fun onCanceled() { + activity.runOnUiThread(Runnable { + result.error("user_cancelled", "Cancelled by user", null) + }) + } + + override fun onFailed(exception: Exception?) { + exception?.printStackTrace() + activity.runOnUiThread(Runnable { + result.error("video_trim_failed", exception?.localizedMessage, exception?.stackTrace) + }) + } + + }).start(); + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/me/anharu/video_editor/filter/GlImageOverlayFilter.kt b/android/src/main/kotlin/me/anharu/video_editor/filter/GlImageOverlayFilter.kt index f984e60..3fe19c3 100644 --- a/android/src/main/kotlin/me/anharu/video_editor/filter/GlImageOverlayFilter.kt +++ b/android/src/main/kotlin/me/anharu/video_editor/filter/GlImageOverlayFilter.kt @@ -1,15 +1,14 @@ package me.anharu.video_editor.filter -import com.daasuu.mp4compose.filter.GlOverlayFilter; -import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas +import com.daasuu.mp4compose.filter.GlOverlayFilter import me.anharu.video_editor.ImageOverlay class GlImageOverlayFilter(imageOverlay: ImageOverlay) : GlOverlayFilter() { private val imageOverlay: ImageOverlay = imageOverlay; protected override fun drawCanvas(canvas: Canvas) { - canvas.drawBitmap(BitmapFactory.decodeByteArray(imageOverlay.bitmap, 0, imageOverlay.bitmap.size),imageOverlay.x.toFloat(),imageOverlay.y.toFloat(),null); + canvas.drawBitmap(BitmapFactory.decodeByteArray(imageOverlay.bitmap, 0, imageOverlay.bitmap.size), imageOverlay.x.toFloat(), imageOverlay.y.toFloat(), null); } } \ No newline at end of file diff --git a/example/android/build.gradle b/example/android/build.gradle index c384adc..d57d8c3 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.4.32' repositories { google() jcenter() diff --git a/example/ios/Podfile b/example/ios/Podfile index b30a428..1e8c3c9 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -10,81 +10,32 @@ project 'Runner', { 'Release' => :release, } -def parse_KV_file(file, separator='=') - file_abs_path = File.expand_path(file) - if !File.exists? file_abs_path - return []; +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" end - generated_key_values = {} - skip_line_start_symbols = ["#", "/"] - File.foreach(file_abs_path) do |line| - next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } - plugin = line.split(pattern=separator) - if plugin.length == 2 - podname = plugin[0].strip() - path = plugin[1].strip() - podpath = File.expand_path("#{path}", file_abs_path) - generated_key_values[podname] = podpath - else - puts "Invalid plugin specification: #{line}" - end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches end - generated_key_values + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" end +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + target 'Runner' do use_frameworks! use_modular_headers! - - # Flutter Pod - copied_flutter_dir = File.join(__dir__, 'Flutter') - copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework') - copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec') - unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path) - # Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet. - # That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration. - # CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist. - - generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig') - unless File.exist?(generated_xcode_build_settings_path) - raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path) - cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR']; - - unless File.exist?(copied_framework_path) - FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir) - end - unless File.exist?(copied_podspec_path) - FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir) - end - end - - # Keep pod path relative so it can be checked into Podfile.lock. - pod 'Flutter', :path => 'Flutter' - - # Plugin Pods - - # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock - # referring to absolute paths on developers' machines. - system('rm -rf .symlinks') - system('mkdir -p .symlinks/plugins') - plugin_pods = parse_KV_file('../.flutter-plugins') - plugin_pods.each do |name, path| - symlink = File.join('.symlinks', 'plugins', name) - File.symlink(path, symlink) - pod name, :path => File.join(symlink, 'ios') - end + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end -# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. -install! 'cocoapods', :disable_input_output_paths => true - post_install do |installer| installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['ENABLE_BITCODE'] = 'NO' - end + flutter_additional_ios_build_settings(target) end end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index e5010f2..8c7c282 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,64 +1,67 @@ PODS: - Flutter (1.0.0) - - flutter_plugin_android_lifecycle (0.0.1): - - Flutter - gallery_saver (0.0.1): - Flutter - image_picker (0.0.1): - Flutter + - libwebp (1.2.0): + - libwebp/demux (= 1.2.0) + - libwebp/mux (= 1.2.0) + - libwebp/webp (= 1.2.0) + - libwebp/demux (1.2.0): + - libwebp/webp + - libwebp/mux (1.2.0): + - libwebp/demux + - libwebp/webp (1.2.0) - path_provider (0.0.1): - Flutter - - path_provider_macos (0.0.1): - - Flutter - tapioca (0.0.1): - Flutter - video_player (0.0.1): - Flutter - - video_player_web (0.0.1): + - video_thumbnail (0.0.1): - Flutter + - libwebp DEPENDENCIES: - Flutter (from `Flutter`) - - flutter_plugin_android_lifecycle (from `.symlinks/plugins/flutter_plugin_android_lifecycle/ios`) - gallery_saver (from `.symlinks/plugins/gallery_saver/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) - - path_provider_macos (from `.symlinks/plugins/path_provider_macos/ios`) - tapioca (from `.symlinks/plugins/tapioca/ios`) - video_player (from `.symlinks/plugins/video_player/ios`) - - video_player_web (from `.symlinks/plugins/video_player_web/ios`) + - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) + +SPEC REPOS: + trunk: + - libwebp EXTERNAL SOURCES: Flutter: :path: Flutter - flutter_plugin_android_lifecycle: - :path: ".symlinks/plugins/flutter_plugin_android_lifecycle/ios" gallery_saver: :path: ".symlinks/plugins/gallery_saver/ios" image_picker: :path: ".symlinks/plugins/image_picker/ios" path_provider: :path: ".symlinks/plugins/path_provider/ios" - path_provider_macos: - :path: ".symlinks/plugins/path_provider_macos/ios" tapioca: :path: ".symlinks/plugins/tapioca/ios" video_player: :path: ".symlinks/plugins/video_player/ios" - video_player_web: - :path: ".symlinks/plugins/video_player_web/ios" + video_thumbnail: + :path: ".symlinks/plugins/video_thumbnail/ios" SPEC CHECKSUMS: - Flutter: 0e3d915762c693b495b44d77113d4970485de6ec - flutter_plugin_android_lifecycle: dc0b544e129eebb77a6bfb1239d4d1c673a60a35 + Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c gallery_saver: 9fc173c9f4fcc48af53b2a9ebea1b643255be542 - image_picker: 66aa71bc96850a90590a35d4c4a2907b0d823109 + image_picker: 50e7c7ff960e5f58faa4d1f4af84a771c671bc4a + libwebp: e90b9c01d99205d03b6bb8f2c8c415e5a4ef66f0 path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c - path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 tapioca: 59e9fc89671d49e2cefe6cd59c3efe678e589e89 video_player: 9cc823b1d9da7e8427ee591e8438bfbcde500e6e - video_player_web: da8cadb8274ed4f8dbee8d7171b420dedd437ce7 + video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1 -PODFILE CHECKSUM: 1b66dae606f75376c5f2135a8290850eeb09ae83 +PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c -COCOAPODS: 1.8.4 +COCOAPODS: 1.10.1 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 79caed7..7128b22 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,12 +9,8 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A6AD7630C727F4F40465969 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95372749F4B46B8D3DE0D702 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -27,8 +23,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -40,7 +34,6 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 5104AEC5B63CEEF2B36FABB2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -48,7 +41,6 @@ 95372749F4B46B8D3DE0D702 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -62,8 +54,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, 5A6AD7630C727F4F40465969 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -82,9 +72,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -141,7 +129,6 @@ 5104AEC5B63CEEF2B36FABB2 /* Pods-Runner.release.xcconfig */, DA7286E8D329A799972AF22A /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -181,6 +168,7 @@ TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; + DevelopmentTeam = WQZP98NKJH; LastSwiftMigration = 1100; }; }; @@ -230,7 +218,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 8AACEC173AA42BE33FDE3CAD /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -238,9 +226,24 @@ files = ( ); inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/gallery_saver/gallery_saver.framework", + "${BUILT_PRODUCTS_DIR}/image_picker/image_picker.framework", + "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", + "${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework", + "${BUILT_PRODUCTS_DIR}/tapioca/tapioca.framework", + "${BUILT_PRODUCTS_DIR}/video_player/video_player.framework", + "${BUILT_PRODUCTS_DIR}/video_thumbnail/video_thumbnail.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/gallery_saver.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/tapioca.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_thumbnail.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -319,7 +322,6 @@ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -375,6 +377,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = WQZP98NKJH; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -386,7 +389,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = me.anharu.videoEditorExample; + PRODUCT_BUNDLE_IDENTIFIER = com.umuieme.videoeditor; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -396,7 +399,6 @@ }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -452,7 +454,6 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -509,6 +510,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = WQZP98NKJH; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -520,7 +522,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = me.anharu.videoEditorExample; + PRODUCT_BUNDLE_IDENTIFIER = com.umuieme.videoeditor; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -536,6 +538,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = WQZP98NKJH; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -547,7 +550,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = me.anharu.videoEditorExample; + PRODUCT_BUNDLE_IDENTIFIER = com.umuieme.videoeditor; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a1..919434a 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140c..fb2dffc 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,8 +27,6 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + + - - $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + Used to demonstrate image picker plugin + NSMicrophoneUsageDescription + Used to capture audio for image picker plugin + NSPhotoLibraryAddUsageDescription + needs permission to write videos + NSPhotoLibraryUsageDescription + Used to demonstrate image picker plugin UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,18 +54,5 @@ UIViewControllerBasedStatusBarAppearance - NSCameraUsageDescription - Used to demonstrate image picker plugin - NSMicrophoneUsageDescription - Used to capture audio for image picker plugin - NSPhotoLibraryUsageDescription - Used to demonstrate image picker plugin - NSPhotoLibraryAddUsageDescription - needs permission to write videos - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - diff --git a/example/lib/main.dart b/example/lib/main.dart index 0e8a206..d7b09f3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,13 +1,17 @@ -import 'package:flutter/material.dart'; -import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/services.dart' show rootBundle; -import 'package:tapioca/src/video_editor.dart'; -import 'package:tapioca/tapioca.dart'; +import 'package:gallery_saver/gallery_saver.dart'; +import 'package:image/image.dart' as IMG; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:gallery_saver/gallery_saver.dart'; +import 'package:tapioca/tapioca.dart'; +import 'package:tapioca_example/video_player_screen.dart'; +import 'package:tapioca_example/video_trimmer/video_trim_screen.dart'; import 'package:video_player/video_player.dart'; void main() => runApp(MyApp()); @@ -20,40 +24,23 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { String _platformVersion = 'Unknown'; final navigatorKey = GlobalKey(); - File _video; + PickedFile? _video = PickedFile( + "/data/user/0/me.anharu.video_editor_example/cache/image_picker6730465733272534646.mp4"); bool isLoading = false; + GlobalKey textKey = GlobalKey(); + GlobalKey _repaintBoundaryKey = GlobalKey(); @override void initState() { super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - try { - platformVersion = await VideoEditor.platformVersion; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; - - setState(() { - _platformVersion = platformVersion; - }); } _pickVideo() async { - try { - File video = await ImagePicker.pickVideo(source: ImageSource.gallery); - print(video.path); + PickedFile? video = + await ImagePicker().getVideo(source: ImageSource.gallery); + if (video == null) return; + print("videopath: ${video.path}"); setState(() { _video = video; isLoading = true; @@ -63,6 +50,78 @@ class _MyAppState extends State { } } + void _processVideo() async { + try { + print("clicked!"); + navigatorKey.currentState + ?.push(MaterialPageRoute(builder: (_) => VideoScreen(_video!.path))); + return; + // await _pickVideo(); + var tempDir = await getTemporaryDirectory(); + final path = '${tempDir.path}/result.mp4'; + print(tempDir); + final imageBitmap = await getImage(); + try { + final tapiocaBalls = [ + TapiocaBall.imageOverlay(imageBitmap!, 50, 50), + ]; + if (_video != null) { + final cup = Cup(Content(_video!.path), tapiocaBalls); + cup.suckUp(path, startTime: 400, endTime: 10000).then((_) async { + print("finished"); + GallerySaver.saveVideo(path); + navigatorKey.currentState?.push( + MaterialPageRoute(builder: (context) => VideoScreen(path)), + ); + setState(() { + isLoading = false; + }); + }); + } else { + print("video is null"); + } + } on PlatformException { + print("error!!!!"); + } + } catch (error) { + print(error); + setState(() { + isLoading = false; + }); + } + } + + Future getImage() async { + print("getImage called"); + print( + "getImage called boundary ${_repaintBoundaryKey.currentContext?.findRenderObject() is RenderRepaintBoundary} "); + RenderRepaintBoundary boundary = _repaintBoundaryKey.currentContext + ?.findRenderObject() as RenderRepaintBoundary; + print("getImage called boundary $boundary "); + + ui.Image image = await boundary.toImage(pixelRatio: 5); + print("getImage called image $image "); + ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); + print("getImage called bytedata $byteData "); + return byteData!.buffer.asUint8List(); + // RenderBox renderObject = + // textKey.currentContext?.findRenderObject() as RenderBox; + // + // print("image size ==== ${renderObject.size}"); + // + // return resizeImage( + // byteData!.buffer.asUint8List(), renderObject.size.width.toInt()); + } + + Future resizeImage(Uint8List data, int width) async { + IMG.Image? img = IMG.decodeImage(data); + print("before resize ==== ${img?.width} === ${img?.height}"); + IMG.Image resized = IMG.copyResize(img!, width: width); + print("before resize ==== ${resized.width} === ${resized.height}"); + var a = Uint8List.fromList(IMG.encodePng(resized)); + return a; + } + @override Widget build(BuildContext context) { return MaterialApp( @@ -72,109 +131,32 @@ class _MyAppState extends State { title: const Text('Plugin example app'), ), body: Center( - child: isLoading ? CircularProgressIndicator() : RaisedButton( - child: Text("Pick a video and Edit it"), - color: Colors.orange, - textColor: Colors.white, - onPressed: () async { - print("clicked!"); - await _pickVideo(); - var tempDir = await getTemporaryDirectory(); - final path = '${tempDir.path}/result.mp4'; - print(tempDir); - final imageBitmap = - (await rootBundle.load("assets/tapioca_drink.png")) - .buffer - .asUint8List(); - try { - final tapiocaBalls = [ - TapiocaBall.filter(Filters.pink), - TapiocaBall.imageOverlay(imageBitmap, 300, 300), - TapiocaBall.textOverlay( - "text", 100, 10, 100, Color(0xffffc0cb)), - ]; - if (_video != null) { - final cup = Cup(Content(_video.path), tapiocaBalls); - cup.suckUp(path).then((_) async { - print("finished"); - GallerySaver.saveVideo(path).then((bool success) { - print(success.toString()); - }); - navigatorKey.currentState.push( - MaterialPageRoute(builder: (context) => VideoScreen(path)), - ); - setState(() { - isLoading = false; - }); - }); - } else { - print("video is null"); - } - } on PlatformException { - print("error!!!!"); - } - }, - )), + child: isLoading + ? CircularProgressIndicator() + : Column( + children: [ + RepaintBoundary( + key: _repaintBoundaryKey, + child: Text( + "Hello how are you", + key: textKey, + style: TextStyle(fontSize: 22, color: Colors.red), + ), + ), + RaisedButton( + child: Text("Pick a video and Edit it"), + color: Colors.orange, + textColor: Colors.white, + onPressed: _processVideo, + ), + ElevatedButton(onPressed: (){ + navigatorKey.currentState?.push( + MaterialPageRoute(builder: (_) => VideoTrimScreen())); + }, child: Text("Trim video")) + ], + )), ), ); } } -class VideoScreen extends StatefulWidget { - final String path; - - VideoScreen(this.path); - - @override - _VideoAppState createState() => _VideoAppState(path); -} - -class _VideoAppState extends State { - final String path; - - _VideoAppState(this.path); - - VideoPlayerController _controller; - - @override - void initState() { - super.initState(); - _controller = VideoPlayerController.file(File(path)) - ..initialize().then((_) { - // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed. - setState(() {}); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: _controller.value.initialized - ? AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: VideoPlayer(_controller), - ) - : Container(), - ), - floatingActionButton: FloatingActionButton( - onPressed: () { - setState(() { - _controller.value.isPlaying - ? _controller.pause() - : _controller.play(); - }); - }, - child: Icon( - _controller.value.isPlaying ? Icons.pause : Icons.play_arrow, - ), - ), - ); - } - - @override - void dispose() { - super.dispose(); - _controller.dispose(); - } -} diff --git a/example/lib/video_player_screen.dart b/example/lib/video_player_screen.dart new file mode 100644 index 0000000..2f41907 --- /dev/null +++ b/example/lib/video_player_screen.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class VideoScreen extends StatefulWidget { + final String path; + + VideoScreen(this.path); + + @override + _VideoAppState createState() => _VideoAppState(path); +} + +class _VideoAppState extends State { + final String path; + + _VideoAppState(this.path); + + late VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + initialize(); + } + initialize() async { + try { + _controller = VideoPlayerController.file(File(path)); + await _controller.initialize(); + setState(() {}); + } catch(error){ + print(error); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: _controller.value.isInitialized + ? AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ) + : CircularProgressIndicator(), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }); + }, + child: Icon( + _controller.value.isPlaying ? Icons.pause : Icons.play_arrow, + ), + ), + ); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } +} diff --git a/example/lib/video_trimmer/video_trim_screen.dart b/example/lib/video_trimmer/video_trim_screen.dart new file mode 100644 index 0000000..c8191cd --- /dev/null +++ b/example/lib/video_trimmer/video_trim_screen.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:tapioca/tapioca.dart'; +import 'package:tapioca_example/video_player_screen.dart'; +import 'package:video_player/video_player.dart'; + +class VideoTrimScreen extends StatefulWidget { + @override + _VideoTrimScreenState createState() => _VideoTrimScreenState(); +} + +class _VideoTrimScreenState extends State { + PickedFile? _video; + bool isLoading = false; + VideoPlayerController? _controller; + double startPos = 0; + double endPos = -1; + + initializeVideo() { + if (_video == null) return; + _controller = VideoPlayerController.file(File(_video!.path)) + ..initialize().then((_) { + // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed. + setState(() {}); + print("output video ==== ${_controller!.value.duration.inSeconds}"); + }); + } + + _onVideoSelectPressed() async { + await _pickVideo(); + await initializeVideo(); + } + + _pickVideo() async { + try { + PickedFile? video = + await ImagePicker().getVideo(source: ImageSource.gallery); + if (video == null) return; + print("videopath: ${video.path}"); + _video = video; + isLoading = true; + setState(() {}); + } catch (error) { + print(error); + } + } + + onTrimVideoPressed() async { + try { + print("start time === $startPos === end time === $endPos"); + var tempDir = await getTemporaryDirectory(); + final path = '${tempDir.path}/result.mp4'; + print("outputpath === $path"); + // await VideoEditor.onTrimVideo(_video!.path, path, startPos, endPos); + print("outputpath after === $path"); + await VideoEditor.speed(_video!.path, path, 3); + Navigator.push( + context, MaterialPageRoute(builder: (_) => VideoScreen(path))); + } on PlatformException catch (e) { + print(e); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + onPressed: this._onVideoSelectPressed, + icon: Icon(Icons.ondemand_video)), + IconButton( + onPressed: this.onTrimVideoPressed, icon: Icon(Icons.done)) + ], + ), + body: Center( + child: _controller != null && _controller!.value.isInitialized + ? Stack( + children: [ + AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), + ), + Positioned( + left: 20, + right: 20, + child: TrimEditor( + viewerWidth: MediaQuery.of(context).size.width - 40, + viewerHeight: 50, + videoFile: _video!.path, + videoPlayerController: _controller!, + fit: BoxFit.cover, + onChangeEnd: (position) { + this.endPos = position; + print("onchange end ==== $position"); + }, + onChangeStart: (position) { + this.startPos = position; + print("onchange start ==== $position"); + }, + onChangePlaybackState: (state) {}, + ), + ) + ], + ) + : CircularProgressIndicator(), + )); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index bc6b35c..44d06dd 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -3,7 +3,7 @@ description: Demonstrates how to use the video_editor plugin. publish_to: 'none' environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: @@ -11,11 +11,12 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^0.1.2 - image_picker: ^0.6.5+2 - path_provider: ^1.6.7 - gallery_saver: ^2.0.1 - video_player: ^0.10.9+1 + cupertino_icons: ^1.0.3 + image_picker: ^0.7.5+3 + path_provider: ^2.0.2 + gallery_saver: ^2.1.0 + video_player: ^2.1.4 + image: ^3.0.2 dev_dependencies: flutter_test: diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 64c9a1a..d3eed95 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -19,7 +19,7 @@ void main() { expect( find.byWidgetPredicate( (Widget widget) => widget is Text && - widget.data.startsWith('Running on:'), + widget.data!.startsWith('Running on:'), ), findsOneWidget, ); diff --git a/ios/Classes/SpeedChanger.swift b/ios/Classes/SpeedChanger.swift new file mode 100644 index 0000000..82c3f22 --- /dev/null +++ b/ios/Classes/SpeedChanger.swift @@ -0,0 +1,167 @@ +// +// VideoTrimmer.swift +// tapioca +// +// Created by Umesh Basnet on 09/06/2021. +// + +import Foundation +import AVFoundation + +import AVFoundation +import Foundation +import Flutter + +import UIKit +import AVFoundation + +enum SpeedoMode { + case Slower + case Faster +} + +class VSVideoSpeeder: NSObject { + + var sourceURL: URL + var outputURL: URL + var result: FlutterResult + + + init(sourceFile: String, outputFile: String, result: @escaping FlutterResult) { + self.sourceURL = URL.init(fileURLWithPath: sourceFile) + self.outputURL = URL.init(fileURLWithPath: outputFile) + self.result = result + } + + func verifyPresetForAsset(preset: String, asset: AVAsset) -> Bool { + let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: asset) + let filteredPresets = compatiblePresets.filter { $0 == preset } + return filteredPresets.count > 0 || preset == AVAssetExportPresetPassthrough + } + + func removeFileAtURLIfExists(url: URL) { + + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: url.path) else { return } + + do { + try fileManager.removeItem(at: url) + } catch let error { + print("TrimVideo - Couldn't remove existing destination file: \(String(describing: error))") + } + } + + + + /// Range is b/w 1x, 2x and 3x. Will not happen anything if scale is out of range. Exporter will be nil in case url is invalid or unable to make asset instance. + func scaleAsset( by scale: Int64, withMode mode: SpeedoMode) { + + + guard sourceURL.isFileURL else { return } + guard outputURL.isFileURL else { return } + + let manager = FileManager.default + + + let asset = AVAsset(url: self.sourceURL) + let length = Float(asset.duration.value) / Float(asset.duration.timescale) + print("video length: \(length) seconds") + + + + do { + try manager.createDirectory(at: outputURL, withIntermediateDirectories: true, attributes: nil) + + }catch let error { + print(error) + } + + //Remove existing file + _ = try? manager.removeItem(at: outputURL) + + + + /// Check the valid scale + if scale < 1 || scale > 20 { + /// Can not proceed, Invalid range + self.result(nil) + return + } + + /// Asset + + /// Video Tracks + let videoTracks = asset.tracks(withMediaType: AVMediaType.video) + if videoTracks.count == 0 { + /// Can not find any video track + self.result(nil) + return + } + + /// Get the scaled video duration + let scaledVideoDuration = (mode == .Faster) ? CMTimeMake(value: asset.duration.value / scale, timescale: asset.duration.timescale) : CMTimeMake(value: asset.duration.value * scale, timescale: asset.duration.timescale) + let timeRange = CMTimeRangeMake(start: CMTime.zero, duration: asset.duration) + + /// Video track + let videoTrack = videoTracks.first! + + let mixComposition = AVMutableComposition() + let compositionVideoTrack = mixComposition.addMutableTrack(withMediaType: AVMediaType.video, preferredTrackID: kCMPersistentTrackID_Invalid) + + /// Audio Tracks + let audioTracks = asset.tracks(withMediaType: AVMediaType.audio) + if audioTracks.count > 0 { + /// Use audio if video contains the audio track + let compositionAudioTrack = mixComposition.addMutableTrack(withMediaType: AVMediaType.audio, preferredTrackID: kCMPersistentTrackID_Invalid) + + /// Audio track + let audioTrack = audioTracks.first! + do { + try compositionAudioTrack?.insertTimeRange(timeRange, of: audioTrack, at: CMTime.zero) + compositionAudioTrack?.scaleTimeRange(timeRange, toDuration: scaledVideoDuration) + } catch _ { + /// Ignore audio error + } + } + + do { + try compositionVideoTrack?.insertTimeRange(timeRange, of: videoTrack, at: CMTime.zero) + compositionVideoTrack?.scaleTimeRange(timeRange, toDuration: scaledVideoDuration) + + /// Keep original transformation + compositionVideoTrack?.preferredTransform = videoTrack.preferredTransform + + + + + + guard let exportSession = AVAssetExportSession(asset: mixComposition, presetName: AVAssetExportPresetHighestQuality) else {return} + exportSession.outputURL = outputURL + exportSession.outputFileType = .mp4 + exportSession.exportAsynchronously{ + switch exportSession.status { + case .completed: + print("exported at \(self.outputURL)") + self.result(nil) + case .failed: + print("failed \(String(describing: exportSession.error))") + self.result(FlutterError(code: "video_trim_failed", message: exportSession.error?.localizedDescription, details: exportSession.error)) + + case .cancelled: + print("cancelled \(String(describing: exportSession.error))") + self.result(FlutterError(code: "video_trim_cancelled", message: exportSession.error?.localizedDescription, details: exportSession.error)) + default: break + } + } + + + } catch let error { + print(error.localizedDescription) + self.result(FlutterError(code: "video_trim_failed", message: "exportSession.error?.localizedDescriptio", details: "exportSession.error")) + + return + } + } + +} diff --git a/ios/Classes/SwiftVideoEditorPlugin.swift b/ios/Classes/SwiftVideoEditorPlugin.swift index ee0457b..7fe8c03 100644 --- a/ios/Classes/SwiftVideoEditorPlugin.swift +++ b/ios/Classes/SwiftVideoEditorPlugin.swift @@ -37,6 +37,53 @@ public class SwiftVideoEditorPlugin: NSObject, FlutterPlugin { return } video.writeVideofile(srcPath: srcName, destPath: destName, processing: processing,result: result) + case "trim_video": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "arguments_not_found", + message: "the arguments is not found.", + details: nil)) + return + } + guard let srcName = args["srcFilePath"] as? String else { + result(FlutterError(code: "src_file_path_not_found", + message: "the src file path sr is not found.", + details: nil)) + return + } + guard let destName = args["destFilePath"] as? String else { + result(FlutterError(code: "dest_file_path_not_found", + message: "the dest file path is not found.", + details: nil)) + return + } + let startTime = args["startTime"] as? Double + let endTime = args["endTime"] as? Double + + VideoTrimmer(sourceFile: srcName, outputFile: destName, result: result).trimVideo(startTime: startTime ?? 0, endTime: endTime ?? -1 ) + + case "speed_change": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "arguments_not_found", + message: "the arguments is not found.", + details: nil)) + return + } + guard let srcName = args["srcFilePath"] as? String else { + result(FlutterError(code: "src_file_path_not_found", + message: "the src file path sr is not found.", + details: nil)) + return + } + guard let destName = args["destFilePath"] as? String else { + result(FlutterError(code: "dest_file_path_not_found", + message: "the dest file path is not found.", + details: nil)) + return + } + let speed = args["speed"] as? Double + + + VSVideoSpeeder(sourceFile: srcName, outputFile: destName, result: result).scaleAsset(by: 5, withMode: SpeedoMode.Faster) default: result("iOS d" + UIDevice.current.systemVersion) } diff --git a/ios/Classes/VideoGeneratorService.swift b/ios/Classes/VideoGeneratorService.swift index a9bfb5c..743a35a 100644 --- a/ios/Classes/VideoGeneratorService.swift +++ b/ios/Classes/VideoGeneratorService.swift @@ -168,10 +168,10 @@ public class VideoGeneratorService: VideoGeneratorServiceInterface { assetExport.outputURL = movieDestinationUrl assetExport.exportAsynchronously(completionHandler:{ switch assetExport.status{ - case AVAssetExportSessionStatus.failed: - print("failed \(assetExport.error)") - case AVAssetExportSessionStatus.cancelled: - print("cancelled \(assetExport.error)") + case AVAssetExportSession.Status.failed: + print("failed \(String(describing: assetExport.error))") + case AVAssetExportSession.Status.cancelled: + print("cancelled \(String(describing: assetExport.error))") default: print("Movie complete") result(nil) diff --git a/ios/Classes/VideoTrimmer.swift b/ios/Classes/VideoTrimmer.swift new file mode 100644 index 0000000..7ef48d9 --- /dev/null +++ b/ios/Classes/VideoTrimmer.swift @@ -0,0 +1,101 @@ +// +// VideoTrimmer.swift +// tapioca +// +// Created by Umesh Basnet on 09/06/2021. +// + +import Foundation +import AVFoundation + +import AVFoundation +import Foundation +import Flutter + +class VideoTrimmer { + + typealias TrimCompletion = (Error?) -> Void + typealias TrimPoints = [(CMTime, CMTime)] + + var sourceURL: URL + var outputURL: URL + var result: FlutterResult + + init(sourceFile: String, outputFile: String, result: @escaping FlutterResult) { + self.sourceURL = URL.init(fileURLWithPath: sourceFile) + self.outputURL = URL.init(fileURLWithPath: outputFile) + self.result = result + } + + func verifyPresetForAsset(preset: String, asset: AVAsset) -> Bool { + let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: asset) + let filteredPresets = compatiblePresets.filter { $0 == preset } + return filteredPresets.count > 0 || preset == AVAssetExportPresetPassthrough + } + + func removeFileAtURLIfExists(url: URL) { + + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: url.path) else { return } + + do { + try fileManager.removeItem(at: url) + } catch let error { + print("TrimVideo - Couldn't remove existing destination file: \(String(describing: error))") + } + } + + func trimVideo(startTime:Double, endTime:Double) + { + + guard sourceURL.isFileURL else { return } + guard outputURL.isFileURL else { return } + + let manager = FileManager.default + + + let asset = AVAsset(url: self.sourceURL) + let length = Float(asset.duration.value) / Float(asset.duration.timescale) + print("video length: \(length) seconds") + + + + do { + try manager.createDirectory(at: outputURL, withIntermediateDirectories: true, attributes: nil) + + }catch let error { + print(error) + } + + //Remove existing file + _ = try? manager.removeItem(at: outputURL) + + + guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {return} + exportSession.outputURL = outputURL + exportSession.outputFileType = .mp4 + + let startCMTime = CMTime(seconds: startTime/1000, preferredTimescale: 1000) + let endCMTime = CMTime(seconds:endTime/1000, preferredTimescale: 1000) + let timeRange = CMTimeRange(start: startCMTime, end: endCMTime) + + exportSession.timeRange = timeRange + exportSession.exportAsynchronously{ + switch exportSession.status { + case .completed: + print("exported at \(self.outputURL)") + self.result(nil) + case .failed: + print("failed \(String(describing: exportSession.error))") + self.result(FlutterError(code: "video_trim_failed", message: exportSession.error?.localizedDescription, details: exportSession.error)) + + case .cancelled: + print("cancelled \(String(describing: exportSession.error))") + self.result(FlutterError(code: "video_trim_cancelled", message: exportSession.error?.localizedDescription, details: exportSession.error)) + default: break + } + } + + } +} diff --git a/lib/src/cup.dart b/lib/src/cup.dart index cc8294d..9eb51bd 100644 --- a/lib/src/cup.dart +++ b/lib/src/cup.dart @@ -1,5 +1,5 @@ -import 'tapioca_ball.dart'; import 'content.dart'; +import 'tapioca_ball.dart'; import 'video_editor.dart'; /// Cup is a class to wrap a Content object and List object. @@ -14,8 +14,13 @@ class Cup { Cup(this.content, this.tapiocaBalls); /// Edit the video based on the [tapiocaBalls](list of processing) - Future suckUp(String destFilePath) { - final Map> processing = Map.fromIterable(tapiocaBalls, key: (v) => v.toTypeName(), value: (v) => v.toMap()); - return VideoEditor.writeVideofile(content.name, destFilePath, processing); + Future suckUp(String destFilePath, + {int startTime = 0, int endTime = -1}) { + final Map> processing = Map.fromIterable( + tapiocaBalls, + key: (v) => v.toTypeName(), + value: (v) => v.toMap()); + return VideoEditor.writeVideofile(content.name, destFilePath, processing, + startTime: startTime, endTime: endTime); } -} \ No newline at end of file +} diff --git a/lib/src/tapioca_ball.dart b/lib/src/tapioca_ball.dart index 68d6ec7..4fc0af2 100644 --- a/lib/src/tapioca_ball.dart +++ b/lib/src/tapioca_ball.dart @@ -38,7 +38,7 @@ enum Filters { } class _Filter extends TapiocaBall { - String color; + late String color; _Filter(Filters type) { switch (type) { case Filters.pink: diff --git a/lib/src/video_editor.dart b/lib/src/video_editor.dart index ab9271a..14a3311 100644 --- a/lib/src/video_editor.dart +++ b/lib/src/video_editor.dart @@ -1,16 +1,46 @@ import 'dart:async'; + import 'package:flutter/services.dart'; class VideoEditor { - static const MethodChannel _channel = - const MethodChannel('video_editor'); + static const MethodChannel _channel = const MethodChannel('video_editor'); static Future get platformVersion async { final String version = await _channel.invokeMethod('getPlatformVersion'); return version; } - static Future writeVideofile(String srcFilePath, String destFilePath, Map> processing) async { - await _channel.invokeMethod('writeVideofile', { 'srcFilePath': srcFilePath, 'destFilePath': destFilePath, 'processing': processing }); + static Future writeVideofile(String srcFilePath, String destFilePath, + Map> processing, + {int? startTime, int? endTime}) async { + await _channel.invokeMethod('writeVideofile', { + 'srcFilePath': srcFilePath, + 'destFilePath': destFilePath, + 'processing': processing, + 'startTime': startTime, + 'endTime': endTime + }); + } + + static Future onTrimVideo(String srcFilePath, String destFilePath, + double startTime, double endTime) async { + await _channel.invokeMethod('trim_video', { + 'srcFilePath': srcFilePath, + 'destFilePath': destFilePath, + 'startTime': startTime.toInt(), + 'endTime': endTime.toInt() + }); + } + + static Future speed( + String srcFilePath, + String destFilePath, + double speed, + ) async { + await _channel.invokeMethod('speed_change', { + 'srcFilePath': srcFilePath, + 'destFilePath': destFilePath, + 'speed': speed, + }); } } diff --git a/lib/src/video_trimmer/thumbnail_viewer.dart b/lib/src/video_trimmer/thumbnail_viewer.dart new file mode 100644 index 0000000..3978c11 --- /dev/null +++ b/lib/src/video_trimmer/thumbnail_viewer.dart @@ -0,0 +1,77 @@ +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 + ThumbnailViewer({ + required this.videoFile, + required this.videoDuration, + required this.thumbnailHeight, + required this.numberOfThumbnails, + required this.fit, + this.quality = 75, + }); + + Stream> generateThumbnail() async* { + final String _videoPath = videoFile.path; + + double _eachPart = videoDuration / numberOfThumbnails; + + List _byteList = []; + + 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 (_bytes != null) _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 Container( + 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/video_trimmer/trim_editor.dart b/lib/src/video_trimmer/trim_editor.dart new file mode 100644 index 0000000..25f1b24 --- /dev/null +++ b/lib/src/video_trimmer/trim_editor.dart @@ -0,0 +1,521 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:tapioca/src/video_editor.dart'; +import 'package:tapioca/src/video_trimmer/thumbnail_viewer.dart'; +import 'package:tapioca/src/video_trimmer/trim_editor_painter.dart'; +import 'package:video_player/video_player.dart'; + +class TrimEditor extends StatefulWidget { + + final VideoPlayerController videoPlayerController; + final String videoFile; + /// For defining the total trimmer area width + final double viewerWidth; + + /// 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 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. + /// + /// 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; + + /// 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: + /// + /// * [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`. + /// + /// + /// * [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. + /// + TrimEditor({ + required this.viewerWidth, + required this.viewerHeight, + required this.videoPlayerController, + required this.videoFile, + this.fit = BoxFit.fitHeight, + this.maxVideoLength = const Duration(milliseconds: 0), + this.circleSize = 5.0, + this.circleSizeOnDrag = 8.0, + this.circlePaintColor = Colors.white, + this.borderPaintColor = Colors.white, + this.scrubberPaintColor = Colors.white, + this.thumbnailQuality = 75, + this.showDuration = true, + this.durationTextStyle = const TextStyle(color: Colors.white), + this.onChangeStart, + this.onChangeEnd, + this.onChangePlaybackState, + }); + + @override + _TrimEditorState createState() => _TrimEditorState(); +} + +class _TrimEditorState extends State with TickerProviderStateMixin { + File? _videoFile; + + double _videoStartPos = 0.0; + double _videoEndPos = 0.0; + + bool _canUpdateStart = true; + bool _isLeftDrag = true; + + Offset _startPos = Offset(0, 0); + Offset _endPos = Offset(0, 0); + + double _startFraction = 0.0; + double _endFraction = 1.0; + + int _videoDuration = 0; + int _currentPosition = 0; + + double _thumbnailViewerW = 0.0; + double _thumbnailViewerH = 0.0; + + int _numberOfThumbnails = 0; + + late double _circleSize; + + double? fraction; + double? maxLengthPixels; + + ThumbnailViewer? thumbnailWidget; + + late Animation _scrubberAnimation; + AnimationController? _animationController; + late Tween _linearTween; + + Future _initializeVideoController() async { + if (_videoFile != null) { + widget.videoPlayerController.addListener(() { + final bool isPlaying = widget.videoPlayerController.value.isPlaying; + + if (isPlaying) { + widget.onChangePlaybackState!(true); + setState(() { + _currentPosition = + widget.videoPlayerController.value.position.inMilliseconds; + + if (_currentPosition > _videoEndPos.toInt()) { + widget.onChangePlaybackState!(false); + widget.videoPlayerController.pause(); + _animationController!.stop(); + } else { + if (!_animationController!.isAnimating) { + widget.onChangePlaybackState!(true); + _animationController!.forward(); + } + } + }); + } else { + if (widget.videoPlayerController.value.isInitialized) { + if (_animationController != null) { + if ((_scrubberAnimation.value).toInt() == (_endPos.dx).toInt()) { + _animationController!.reset(); + } + _animationController!.stop(); + widget.onChangePlaybackState!(false); + } + } + } + }); + + widget.videoPlayerController.setVolume(1.0); + _videoDuration = widget.videoPlayerController.value.duration.inMilliseconds; + print(_videoFile!.path); + + _videoEndPos = fraction != null + ? _videoDuration.toDouble() * fraction! + : _videoDuration.toDouble(); + + widget.onChangeEnd!(_videoEndPos); + + final ThumbnailViewer _thumbnailWidget = ThumbnailViewer( + videoFile: _videoFile!, + videoDuration: _videoDuration, + fit: widget.fit, + thumbnailHeight: _thumbnailViewerH, + numberOfThumbnails: _numberOfThumbnails, + quality: widget.thumbnailQuality, + ); + thumbnailWidget = _thumbnailWidget; + } + } + + void _setVideoStartPosition(DragUpdateDetails details) async { + if (!(_startPos.dx + details.delta.dx < 0) && + !(_startPos.dx + details.delta.dx > _thumbnailViewerW) && + !(_startPos.dx + details.delta.dx > _endPos.dx)) { + if (maxLengthPixels != null) { + if (!(_endPos.dx - _startPos.dx - details.delta.dx > + maxLengthPixels!)) { + setState(() { + if (!(_startPos.dx + details.delta.dx < 0)) + _startPos += details.delta; + + _startFraction = (_startPos.dx / _thumbnailViewerW); + + _videoStartPos = _videoDuration * _startFraction; + widget.onChangeStart!(_videoStartPos); + }); + await widget.videoPlayerController.pause(); + await widget.videoPlayerController + .seekTo(Duration(milliseconds: _videoStartPos.toInt())); + _linearTween.begin = _startPos.dx; + _animationController!.duration = + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()); + _animationController!.reset(); + } + } else { + setState(() { + if (!(_startPos.dx + details.delta.dx < 0)) + _startPos += details.delta; + + _startFraction = (_startPos.dx / _thumbnailViewerW); + + _videoStartPos = _videoDuration * _startFraction; + widget.onChangeStart!(_videoStartPos); + }); + await widget.videoPlayerController.pause(); + await widget.videoPlayerController + .seekTo(Duration(milliseconds: _videoStartPos.toInt())); + _linearTween.begin = _startPos.dx; + _animationController!.duration = + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()); + _animationController!.reset(); + } + } + } + + void _setVideoEndPosition(DragUpdateDetails details) async { + if (!(_endPos.dx + details.delta.dx > _thumbnailViewerW) && + !(_endPos.dx + details.delta.dx < 0) && + !(_endPos.dx + details.delta.dx < _startPos.dx)) { + if (maxLengthPixels != null) { + if (!(_endPos.dx - _startPos.dx + details.delta.dx > + maxLengthPixels!)) { + setState(() { + _endPos += details.delta; + _endFraction = _endPos.dx / _thumbnailViewerW; + + _videoEndPos = _videoDuration * _endFraction; + widget.onChangeEnd!(_videoEndPos); + }); + await widget.videoPlayerController.pause(); + await widget.videoPlayerController + .seekTo(Duration(milliseconds: _videoEndPos.toInt())); + _linearTween.end = _endPos.dx; + _animationController!.duration = + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()); + _animationController!.reset(); + } + } else { + setState(() { + _endPos += details.delta; + _endFraction = _endPos.dx / _thumbnailViewerW; + + _videoEndPos = _videoDuration * _endFraction; + widget.onChangeEnd!(_videoEndPos); + }); + await widget.videoPlayerController.pause(); + await widget.videoPlayerController + .seekTo(Duration(milliseconds: _videoEndPos.toInt())); + _linearTween.end = _endPos.dx; + _animationController!.duration = + Duration(milliseconds: (_videoEndPos - _videoStartPos).toInt()); + _animationController!.reset(); + } + } + } + + @override + void initState() { + super.initState(); + _circleSize = widget.circleSize; + + _videoFile = File(widget.videoFile); + _thumbnailViewerH = widget.viewerHeight; + + _numberOfThumbnails = widget.viewerWidth ~/ _thumbnailViewerH; + + _thumbnailViewerW = _numberOfThumbnails * _thumbnailViewerH; + + Duration totalDuration = widget.videoPlayerController.value.duration; + + if (widget.maxVideoLength > Duration(milliseconds: 0) && + widget.maxVideoLength < totalDuration) { + if (widget.maxVideoLength < totalDuration) { + fraction = + widget.maxVideoLength.inMilliseconds / totalDuration.inMilliseconds; + + maxLengthPixels = _thumbnailViewerW * fraction!; + } + } + + _initializeVideoController(); + _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(); + } + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: (DragStartDetails details) { + print("START"); + print(details.localPosition); + print((_startPos.dx - details.localPosition.dx).abs()); + print((_endPos.dx - details.localPosition.dx).abs()); + + if (_endPos.dx >= _startPos.dx) { + if ((_startPos.dx - details.localPosition.dx).abs() > + (_endPos.dx - details.localPosition.dx).abs()) { + setState(() { + _canUpdateStart = false; + }); + } else { + setState(() { + _canUpdateStart = true; + }); + } + } else { + if (_startPos.dx > details.localPosition.dx) { + _isLeftDrag = true; + } else { + _isLeftDrag = false; + } + } + }, + onHorizontalDragEnd: (DragEndDetails details) { + setState(() { + _circleSize = widget.circleSize; + }); + }, + onHorizontalDragUpdate: (DragUpdateDetails details) { + _circleSize = widget.circleSizeOnDrag; + + if (_endPos.dx >= _startPos.dx) { + _isLeftDrag = false; + if (_canUpdateStart && _startPos.dx + details.delta.dx > 0) { + _isLeftDrag = false; // To prevent from scrolling over + _setVideoStartPosition(details); + } else if (!_canUpdateStart && + _endPos.dx + details.delta.dx < _thumbnailViewerW) { + _isLeftDrag = true; // To prevent from scrolling over + _setVideoEndPosition(details); + } + } else { + if (_isLeftDrag && _startPos.dx + details.delta.dx > 0) { + _setVideoStartPosition(details); + } else if (!_isLeftDrag && + _endPos.dx + details.delta.dx < _thumbnailViewerW) { + _setVideoEndPosition(details); + } + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.showDuration + ? Container( + 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, + ), + Text( + Duration(milliseconds: _videoEndPos.toInt()) + .toString() + .split('.')[0], + style: widget.durationTextStyle, + ), + ], + ), + ), + ) + : Container(), + CustomPaint( + foregroundPainter: TrimEditorPainter( + startPos: _startPos, + endPos: _endPos, + scrubberAnimationDx: _scrubberAnimation.value, + circleSize: _circleSize, + circlePaintColor: widget.circlePaintColor, + borderPaintColor: widget.borderPaintColor, + scrubberPaintColor: widget.scrubberPaintColor, + ), + child: Container( + color: Colors.grey[900], + height: _thumbnailViewerH, + width: _thumbnailViewerW, + child: thumbnailWidget == null ? Column() : thumbnailWidget, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/video_trimmer/trim_editor_painter.dart b/lib/src/video_trimmer/trim_editor_painter.dart new file mode 100644 index 0000000..38202dc --- /dev/null +++ b/lib/src/video_trimmer/trim_editor_painter.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +class TrimEditorPainter extends CustomPainter { + /// To define the start offset + final Offset startPos; + + /// To define the end offset + final Offset endPos; + + /// 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`. + /// By default it is set to `0.5`. + 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 whether to show the scrubber + final bool showScrubber; + + /// 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 circle. + /// By default it is set to `Colors.white` + final Color circlePaintColor; + + /// For specifying a color to the video + /// scrubber inside the trim area. By default it is set to + /// `Colors.white`. + final Color scrubberPaintColor; + + /// For drawing the trim editor slider + /// + /// The required parameters are [startPos], [endPos] + /// & [scrubberAnimationDx] + /// + /// * [startPos] to define the start offset + /// + /// + /// * [endPos] to define the end offset + /// + /// + /// * [scrubberAnimationDx] to define the horizontal length of the + /// selected video area + /// + /// + /// 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`. + /// By default it is set to `0.5`. + /// + /// + /// * [borderWidth] for specifying the width of the border around + /// the trim area. By default it is set to `3`. + /// + /// + /// * [scrubberWidth] for specifying the width of the video scrubber + /// + /// + /// * [showScrubber] for specifying whether to show the scrubber + /// + /// + /// * [borderPaintColor] for specifying a color to the border of + /// the trim area. By default it is set to `Colors.white`. + /// + /// + /// * [circlePaintColor] for specifying a color to the circle. + /// 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`. + /// + TrimEditorPainter({ + required this.startPos, + required this.endPos, + required this.scrubberAnimationDx, + this.circleSize = 0.5, + this.borderWidth = 3, + this.scrubberWidth = 1, + this.showScrubber = true, + this.borderPaintColor = Colors.white, + this.circlePaintColor = Colors.white, + this.scrubberPaintColor = Colors.white, + }); + + @override + void paint(Canvas canvas, Size size) { + var borderPaint = Paint() + ..color = borderPaintColor + ..strokeWidth = borderWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + var circlePaint = Paint() + ..color = circlePaintColor + ..strokeWidth = 1 + ..style = PaintingStyle.fill + ..strokeCap = StrokeCap.round; + + var scrubberPaint = Paint() + ..color = scrubberPaintColor + ..strokeWidth = scrubberWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final rect = Rect.fromPoints(startPos, endPos); + + if (showScrubber) { + if (scrubberAnimationDx.toInt() > startPos.dx.toInt()) { + canvas.drawLine( + Offset(scrubberAnimationDx, 0), + Offset(scrubberAnimationDx, 0) + Offset(0, endPos.dy), + scrubberPaint, + ); + } + } + + canvas.drawRect(rect, borderPaint); + canvas.drawCircle( + startPos + Offset(0, endPos.dy / 2), circleSize, circlePaint); + canvas.drawCircle( + endPos + Offset(0, -endPos.dy / 2), circleSize, circlePaint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/lib/tapioca.dart b/lib/tapioca.dart index 8b28602..493b82e 100644 --- a/lib/tapioca.dart +++ b/lib/tapioca.dart @@ -1,3 +1,5 @@ export 'package:tapioca/src/cup.dart'; export 'package:tapioca/src/content.dart'; -export 'package:tapioca/src/tapioca_ball.dart'; \ No newline at end of file +export 'package:tapioca/src/tapioca_ball.dart'; +export 'package:tapioca/src/video_trimmer/trim_editor.dart'; +export 'package:tapioca/src/video_editor.dart'; \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 947fd21..6097ce3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,12 +4,14 @@ version: 1.0.2+1 homepage: https://github.com/anharu2394/tapioca environment: - sdk: ">=2.1.0 <3.0.0" - flutter: ">=1.12.0 <2.0.0" + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" dependencies: flutter: sdk: flutter + video_player: ^2.1.5 + video_thumbnail: ^0.3.3 dev_dependencies: flutter_test: diff --git a/test/tapioca_test.dart b/test/tapioca_test.dart index 7bbe413..653eb05 100644 --- a/test/tapioca_test.dart +++ b/test/tapioca_test.dart @@ -11,7 +11,7 @@ void main() { final List log = []; final fileName = 'sample.mp4'; Directory tempDirectory; - String path; + late String path; TestWidgetsFlutterBinding.ensureInitialized(); @@ -47,7 +47,7 @@ void main() { TapiocaBall.imageOverlay(Uint8List(10), 10, 10), ]; final cup = Cup(Content(path), tapiocaBalls); - cup.suckUp(); + // cup.suckUp(path); expect(log, [ isMethodCall( 'writeVideofile',