diff --git a/auth/assets/simple-icons b/auth/assets/simple-icons index 8a3731352a..8e7701d6a4 160000 --- a/auth/assets/simple-icons +++ b/auth/assets/simple-icons @@ -1 +1 @@ -Subproject commit 8a3731352af133a02223a6c7b1f37c4abb096af0 +Subproject commit 8e7701d6a40462733043f54b3849faf35af70a83 diff --git a/mobile/assets/video-editor/video-crop-free-action.svg b/mobile/assets/video-editor/video-crop-free-action.svg new file mode 100644 index 0000000000..059c02153e --- /dev/null +++ b/mobile/assets/video-editor/video-crop-free-action.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-original-action.svg b/mobile/assets/video-editor/video-crop-original-action.svg new file mode 100644 index 0000000000..9d754363f7 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-original-action.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_16_9-action.svg b/mobile/assets/video-editor/video-crop-ratio_16_9-action.svg new file mode 100644 index 0000000000..82f8ca975f --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_16_9-action.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_1_1-action.svg b/mobile/assets/video-editor/video-crop-ratio_1_1-action.svg new file mode 100644 index 0000000000..5785c5e547 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_1_1-action.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_3_4-action.svg b/mobile/assets/video-editor/video-crop-ratio_3_4-action.svg new file mode 100644 index 0000000000..d6ffd9a02f --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_3_4-action.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_4_3-action.svg b/mobile/assets/video-editor/video-crop-ratio_4_3-action.svg new file mode 100644 index 0000000000..ab173fb7d1 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_4_3-action.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/mobile/assets/video-editor/video-crop-ratio_9_16-action.svg b/mobile/assets/video-editor/video-crop-ratio_9_16-action.svg new file mode 100644 index 0000000000..207e906646 --- /dev/null +++ b/mobile/assets/video-editor/video-crop-ratio_9_16-action.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mobile/assets/video-editor/video-editor-crop-action.svg b/mobile/assets/video-editor/video-editor-crop-action.svg new file mode 100644 index 0000000000..0b713cb641 --- /dev/null +++ b/mobile/assets/video-editor/video-editor-crop-action.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/assets/video-editor/video-editor-rotate-action.svg b/mobile/assets/video-editor/video-editor-rotate-action.svg new file mode 100644 index 0000000000..d143654d20 --- /dev/null +++ b/mobile/assets/video-editor/video-editor-rotate-action.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/assets/video-editor/video-editor-trim-action.svg b/mobile/assets/video-editor/video-editor-trim-action.svg new file mode 100644 index 0000000000..c59bc03a90 --- /dev/null +++ b/mobile/assets/video-editor/video-editor-trim-action.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile/lib/ente_theme_data.dart b/mobile/lib/ente_theme_data.dart index d00655437b..0715ba8d19 100644 --- a/mobile/lib/ente_theme_data.dart +++ b/mobile/lib/ente_theme_data.dart @@ -220,6 +220,8 @@ TextTheme _buildTextTheme(Color textColor) { } extension CustomColorScheme on ColorScheme { + Color get videoPlayerPrimaryColor => const Color.fromRGBO(1, 222, 77, 1); + Color get defaultBackgroundColor => brightness == Brightness.light ? backgroundBaseLight : backgroundBaseDark; @@ -392,7 +394,9 @@ ElevatedButtonThemeData buildElevatedButtonThemeData({ }) { return ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - foregroundColor: onPrimary, backgroundColor: primary, elevation: elevation, + foregroundColor: onPrimary, + backgroundColor: primary, + elevation: elevation, alignment: Alignment.center, textStyle: const TextStyle( fontWeight: FontWeight.w600, diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index aab7f47bd8..0c5893c8c3 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -1193,6 +1193,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Save your recovery key if you haven\'t already"), "saving": MessageLookupByLibrary.simpleMessage("Saving..."), + "savingEdits": MessageLookupByLibrary.simpleMessage("Saving Edits!"), "scanCode": MessageLookupByLibrary.simpleMessage("Scan code"), "scanThisBarcodeWithnyourAuthenticatorApp": MessageLookupByLibrary.simpleMessage( diff --git a/mobile/lib/generated/intl/messages_pt.dart b/mobile/lib/generated/intl/messages_pt.dart index e17cb674e8..6f9724193d 100644 --- a/mobile/lib/generated/intl/messages_pt.dart +++ b/mobile/lib/generated/intl/messages_pt.dart @@ -587,7 +587,7 @@ class MessageLookup extends MessageLookupByLibrary { "descriptions": MessageLookupByLibrary.simpleMessage("Descrições"), "deselectAll": MessageLookupByLibrary.simpleMessage("Desmarcar todos"), "designedToOutlive": - MessageLookupByLibrary.simpleMessage("Feito para ter logenvidade"), + MessageLookupByLibrary.simpleMessage("Feito para ter longevidade"), "details": MessageLookupByLibrary.simpleMessage("Detalhes"), "devAccountChanged": MessageLookupByLibrary.simpleMessage( "A conta de desenvolvedor que usamos para publicar o Ente na App Store foi alterada. Por esse motivo, você precisará fazer entrar novamente.\n\nPedimos desculpas pelo inconveniente, mas isso era inevitável."), diff --git a/mobile/lib/generated/intl/messages_zh.dart b/mobile/lib/generated/intl/messages_zh.dart index db60c5e0b2..a906a081fb 100644 --- a/mobile/lib/generated/intl/messages_zh.dart +++ b/mobile/lib/generated/intl/messages_zh.dart @@ -543,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary { "emailVerificationToggle": MessageLookupByLibrary.simpleMessage("电子邮件验证"), "emailYourLogs": MessageLookupByLibrary.simpleMessage("通过电子邮件发送您的日志"), - "empty": MessageLookupByLibrary.simpleMessage("空的"), + "empty": MessageLookupByLibrary.simpleMessage("清空"), "emptyTrash": MessageLookupByLibrary.simpleMessage("要清空回收站吗?"), "enableMaps": MessageLookupByLibrary.simpleMessage("启用地图"), "enableMapsDesc": MessageLookupByLibrary.simpleMessage( diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 4c7679154f..b9fcb10b00 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -8703,6 +8703,16 @@ class S { args: [], ); } + + /// `Saving Edits!` + String get savingEdits { + return Intl.message( + 'Saving Edits!', + name: 'savingEdits', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index 6bc8b59269..00c2de3f5d 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -1226,5 +1226,6 @@ "stopCastingBody": "Do you want to stop casting?", "castIPMismatchTitle": "Failed to cast album", "castIPMismatchBody": "Please make sure you are on the same network as the TV.", - "pairingComplete": "Pairing complete" + "pairingComplete": "Pairing complete", + "savingEdits": "Saving Edits!" } \ No newline at end of file diff --git a/mobile/lib/ui/tools/editor/crop_video_page.dart b/mobile/lib/ui/tools/editor/crop_video_page.dart deleted file mode 100644 index c81a101974..0000000000 --- a/mobile/lib/ui/tools/editor/crop_video_page.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fraction/fraction.dart'; -import 'package:video_editor/video_editor.dart'; - -class CropPage extends StatelessWidget { - const CropPage({super.key, required this.controller}); - - final VideoEditorController controller; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: IconButton( - onPressed: () => - controller.rotate90Degrees(RotateDirection.left), - icon: const Icon(Icons.rotate_left), - ), - ), - Expanded( - child: IconButton( - onPressed: () => - controller.rotate90Degrees(RotateDirection.right), - icon: const Icon(Icons.rotate_right), - ), - ), - ], - ), - const SizedBox(height: 15), - Expanded( - child: CropGridViewer.edit( - controller: controller, - rotateCropArea: false, - margin: const EdgeInsets.symmetric(horizontal: 20), - ), - ), - const SizedBox(height: 15), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - flex: 2, - child: IconButton( - onPressed: () => Navigator.pop(context), - icon: const Center( - child: Text( - "cancel", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - ), - Expanded( - flex: 4, - child: AnimatedBuilder( - animation: controller, - builder: (_, __) => Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () => - controller.preferredCropAspectRatio = - controller.preferredCropAspectRatio - ?.toFraction() - .inverse() - .toDouble(), - icon: controller.preferredCropAspectRatio != - null && - controller.preferredCropAspectRatio! < 1 - ? const Icon( - Icons.panorama_vertical_select_rounded, - ) - : const Icon( - Icons.panorama_vertical_rounded, - ), - ), - IconButton( - onPressed: () => - controller.preferredCropAspectRatio = - controller.preferredCropAspectRatio - ?.toFraction() - .inverse() - .toDouble(), - icon: controller.preferredCropAspectRatio != - null && - controller.preferredCropAspectRatio! > 1 - ? const Icon( - Icons - .panorama_horizontal_select_rounded, - ) - : const Icon( - Icons.panorama_horizontal_rounded, - ), - ), - ], - ), - Row( - children: [ - _buildCropButton(context, null), - _buildCropButton(context, 1.toFraction()), - _buildCropButton( - context, - Fraction.fromString("9/16"), - ), - _buildCropButton( - context, - Fraction.fromString("3/4"), - ), - ], - ), - ], - ), - ), - ), - Expanded( - flex: 2, - child: IconButton( - onPressed: () { - // WAY 1: validate crop parameters set in the crop view - controller.applyCacheCrop(); - // WAY 2: update manually with Offset values - // controller.updateCrop(const Offset(0.2, 0.2), const Offset(0.8, 0.8)); - Navigator.pop(context); - }, - icon: Center( - child: Text( - "done", - style: TextStyle( - color: - const CropGridStyle().selectedBoundariesColor, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildCropButton(BuildContext context, Fraction? f) { - if (controller.preferredCropAspectRatio != null && - controller.preferredCropAspectRatio! > 1) f = f?.inverse(); - - return Flexible( - child: TextButton( - style: ElevatedButton.styleFrom( - elevation: 0, - backgroundColor: controller.preferredCropAspectRatio == f?.toDouble() - ? Colors.grey.shade800 - : null, - foregroundColor: controller.preferredCropAspectRatio == f?.toDouble() - ? Colors.white - : null, - textStyle: Theme.of(context).textTheme.bodySmall, - ), - onPressed: () => controller.preferredCropAspectRatio = f?.toDouble(), - child: Text(f == null ? 'free' : '${f.numerator}:${f.denominator}'), - ), - ); - } -} diff --git a/mobile/lib/ui/tools/editor/video_crop_page.dart b/mobile/lib/ui/tools/editor/video_crop_page.dart new file mode 100644 index 0000000000..980b10f1fa --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_crop_page.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import "package:flutter_svg/flutter_svg.dart"; +import "package:photos/ui/tools/editor/video_editor/crop_value.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_bottom_action.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_main_actions.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import 'package:video_editor/video_editor.dart'; + +class VideoCropPage extends StatefulWidget { + const VideoCropPage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + State createState() => _VideoCropPageState(); +} + +class _VideoCropPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.edit( + controller: widget.controller, + rotateCropArea: false, + margin: const EdgeInsets.symmetric(horizontal: 20), + ), + ), + ), + VideoEditorPlayerControl( + controller: widget.controller, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + flex: 4, + child: AnimatedBuilder( + animation: widget.controller, + builder: (_, __) => Column( + children: [ + VideoEditorMainActions( + children: [ + // _buildCropButton(context, CropValue.original), + // const SizedBox(width: 40), + _buildCropButton(context, CropValue.free), + const SizedBox(width: 40), + _buildCropButton(context, CropValue.ratio_1_1), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_9_16, + ), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_16_9, + ), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_3_4, + ), + const SizedBox(width: 40), + _buildCropButton( + context, + CropValue.ratio_4_3, + ), + ], + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + secondaryText: "Done", + onSecondaryPressed: () { + // WAY 1: validate crop parameters set in the crop view + widget.controller.applyCacheCrop(); + // WAY 2: update manually with Offset values + // controller.updateCrop(const Offset(0.2, 0.2), const Offset(0.8, 0.8)); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + Widget _buildCropButton(BuildContext context, CropValue value) { + final f = value.getFraction(); + + return VideoEditorBottomAction( + label: value.displayName, + isSelected: value != CropValue.original && + widget.controller.preferredCropAspectRatio == f?.toDouble(), + onPressed: () { + if (value == CropValue.original) { + widget.controller.updateCrop(Offset.zero, const Offset(1.0, 1.0)); + widget.controller.cropAspectRatio(null); + setState(() {}); + } else { + widget.controller.preferredCropAspectRatio = f?.toDouble(); + } + }, + child: SvgPicture.asset( + "assets/video-editor/video-crop-${value.name}-action.svg", + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/crop_value.dart b/mobile/lib/ui/tools/editor/video_editor/crop_value.dart new file mode 100644 index 0000000000..96c00736c7 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/crop_value.dart @@ -0,0 +1,51 @@ +import "package:fraction/fraction.dart"; + +enum CropValue { + original, + free, + ratio_1_1, + ratio_9_16, + ratio_16_9, + ratio_3_4, + ratio_4_3; + + getFraction() { + switch (this) { + case CropValue.original: + return null; + case CropValue.free: + return null; + case CropValue.ratio_1_1: + return 1.toFraction(); + case CropValue.ratio_9_16: + return Fraction.fromString("9/16"); + case CropValue.ratio_16_9: + return Fraction.fromString("16/9"); + case CropValue.ratio_3_4: + return Fraction.fromString("3/4"); + case CropValue.ratio_4_3: + return Fraction.fromString("4/3"); + default: + return null; + } + } + + String get displayName { + switch (this) { + case CropValue.original: + return "Original"; + case CropValue.free: + return "Free"; + case CropValue.ratio_1_1: + return "1:1"; + case CropValue.ratio_9_16: + return "9:16"; + case CropValue.ratio_16_9: + return "16:9"; + case CropValue.ratio_3_4: + return "3:4"; + case CropValue.ratio_4_3: + return "4:3"; + } + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_bottom_action.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_bottom_action.dart new file mode 100644 index 0000000000..c3f6beed8e --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_bottom_action.dart @@ -0,0 +1,57 @@ +import "package:flutter/material.dart"; + +class VideoEditorBottomAction extends StatelessWidget { + const VideoEditorBottomAction({ + super.key, + required this.label, + this.icon, + this.child, + required this.onPressed, + this.isSelected = false, + }) : assert(icon != null || child != null); + + final String label; + final IconData? icon; + final Widget? child; + final VoidCallback onPressed; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.translucent, + child: Column( + children: [ + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: const Color(0xFF252525), + shape: BoxShape.circle, + border: Border.all( + color: + isSelected ? const Color(0xFFFFFFFF) : Colors.transparent, + width: 1, + ), + ), + child: icon != null + ? Icon(icon!) + : Padding( + padding: const EdgeInsets.all(2), + child: child!, + ), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_main_actions.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_main_actions.dart new file mode 100644 index 0000000000..57fc21780d --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_main_actions.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; + +class VideoEditorMainActions extends StatelessWidget { + const VideoEditorMainActions({ + super.key, + required this.children, + }); + + final List children; + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + height: 76, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 36), + scrollDirection: Axis.horizontal, + shrinkWrap: true, + children: children, + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_navigation_options.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_navigation_options.dart new file mode 100644 index 0000000000..6a1d98cde7 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_navigation_options.dart @@ -0,0 +1,41 @@ +import "package:flutter/material.dart"; + +class VideoEditorNavigationOptions extends StatelessWidget { + const VideoEditorNavigationOptions({ + super.key, + this.primaryText, + this.onPrimaryPressed, + required this.secondaryText, + required this.onSecondaryPressed, + }); + + final String? primaryText; + final VoidCallback? onPrimaryPressed; + final String secondaryText; + final VoidCallback? onSecondaryPressed; + + @override + Widget build(BuildContext context) { + return Hero( + tag: "video-editor-navigation-options", + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + const SizedBox(width: 28), + TextButton( + onPressed: onPrimaryPressed?.call ?? Navigator.of(context).pop, + child: Text(primaryText ?? "Cancel"), + ), + const Spacer(), + TextButton( + onPressed: onSecondaryPressed, + child: Text(secondaryText), + ), + const SizedBox(width: 28), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_editor/video_editor_player_control.dart b/mobile/lib/ui/tools/editor/video_editor/video_editor_player_control.dart new file mode 100644 index 0000000000..7b488e21a5 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_editor/video_editor_player_control.dart @@ -0,0 +1,77 @@ +import "package:flutter/material.dart"; +import "package:video_editor/video_editor.dart"; + +class VideoEditorPlayerControl extends StatelessWidget { + const VideoEditorPlayerControl({ + super.key, + required this.controller, + }); + + final VideoEditorController controller; + + @override + Widget build(BuildContext context) { + return Hero( + tag: "video_editor_player_control", + child: AnimatedBuilder( + animation: Listenable.merge([ + controller, + controller.video, + ]), + builder: (_, __) { + final duration = controller.trimmedDuration; + final pos = Duration( + seconds: (controller.videoPosition.inSeconds - + controller.startTrim.inSeconds), + ); + final isPlaying = controller.isPlaying; + + return GestureDetector( + onTap: () { + if (controller.isPlaying) { + controller.video.pause(); + } else { + controller.video.play(); + } + }, + child: Container( + height: 28, + margin: const EdgeInsets.only(top: 24, bottom: 28), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFF252525), + borderRadius: BorderRadius.circular(56), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + !isPlaying ? Icons.play_arrow : Icons.pause, + size: 21, + ), + const SizedBox(width: 4), + Text( + "${formatter(pos)} / ${formatter(duration)}", + // ignore: prefer_const_constructors + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + String formatter(Duration duration) => [ + duration.inMinutes.remainder(60).toString().padLeft(2, '0'), + duration.inSeconds.remainder(60).toString().padLeft(2, '0'), + ].join(":"); +} diff --git a/mobile/lib/ui/tools/editor/video_editor_page.dart b/mobile/lib/ui/tools/editor/video_editor_page.dart index c84dfec09b..ed8a96b2c4 100644 --- a/mobile/lib/ui/tools/editor/video_editor_page.dart +++ b/mobile/lib/ui/tools/editor/video_editor_page.dart @@ -1,11 +1,31 @@ import 'dart:io'; +import "dart:math"; import 'package:flutter/material.dart'; +import "package:flutter_svg/flutter_svg.dart"; +import "package:logging/logging.dart"; +import 'package:path/path.dart' as path; +import "package:pedantic/pedantic.dart"; +import "package:photo_manager/photo_manager.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/db/files_db.dart"; +import "package:photos/ente_theme_data.dart"; +import "package:photos/events/local_photos_updated_event.dart"; +import "package:photos/generated/l10n.dart"; import "package:photos/models/file/file.dart"; -import 'package:photos/ui/tools/editor/crop_video_page.dart'; -import 'package:photos/ui/tools/editor/export_video_result.dart'; -import 'package:photos/ui/tools/editor/export_video_service.dart'; +import "package:photos/services/sync_service.dart"; +import "package:photos/ui/tools/editor/export_video_service.dart"; +import 'package:photos/ui/tools/editor/video_crop_page.dart'; +import "package:photos/ui/tools/editor/video_editor/video_editor_bottom_action.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_main_actions.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import "package:photos/ui/tools/editor/video_rotate_page.dart"; +import "package:photos/ui/tools/editor/video_trim_page.dart"; import "package:photos/ui/viewer/file/detail_page.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; +import "package:photos/utils/toast_util.dart"; import "package:video_editor/video_editor.dart"; class VideoEditorPage extends StatefulWidget { @@ -27,7 +47,7 @@ class VideoEditorPage extends StatefulWidget { class _VideoEditorPageState extends State { final _exportingProgress = ValueNotifier(0.0); final _isExporting = ValueNotifier(false); - final double height = 60; + final _logger = Logger("VideoEditor"); late final VideoEditorController _controller; @@ -37,6 +57,10 @@ class _VideoEditorPageState extends State { _controller = VideoEditorController.file( widget.ioFile, minDuration: const Duration(seconds: 1), + cropStyle: CropGridStyle( + selectedBoundariesColor: + const ColorScheme.dark().videoPlayerPrimaryColor, + ), ); _controller.initialize().then((_) => setState(() {})).catchError( (error) { @@ -56,21 +80,107 @@ class _VideoEditorPageState extends State { super.dispose(); } - void _showErrorSnackBar(String message) => - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 1), - ), - ); + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: Colors.black, + body: _controller.initialized + ? SafeArea( + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.preview( + controller: _controller, + ), + ), + ), + VideoEditorPlayerControl( + controller: _controller, + ), + VideoEditorMainActions( + children: [ + VideoEditorBottomAction( + label: "Trim", + child: SvgPicture.asset( + "assets/video-editor/video-editor-trim-action.svg", + ), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoTrimPage( + controller: _controller, + ), + ), + ), + ), + const SizedBox(width: 40), + VideoEditorBottomAction( + label: "Crop", + child: SvgPicture.asset( + "assets/video-editor/video-editor-crop-action.svg", + ), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoCropPage( + controller: _controller, + ), + ), + ), + ), + const SizedBox(width: 40), + VideoEditorBottomAction( + label: "Rotate", + child: SvgPicture.asset( + "assets/video-editor/video-editor-rotate-action.svg", + ), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoRotatePage( + controller: _controller, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + secondaryText: "Save copy", + onSecondaryPressed: () { + exportVideo(); + }, + ), + ], + ), + ), + ], + ), + ], + ), + ) + : const Center(child: CircularProgressIndicator()), + ), + ); + } - void _exportVideo() async { + void exportVideo() async { _exportingProgress.value = 0; _isExporting.value = true; final config = VideoFFmpegVideoEditorConfig( _controller, - // format: VideoExportFormat.gif, + format: VideoExportFormat.mp4, // commandBuilder: (config, videoPath, outputPath) { // final List filters = config.getExportFilters(); // filters.add('hflip'); // add horizontal flip @@ -85,283 +195,60 @@ class _VideoEditorPageState extends State { _exportingProgress.value = config.getFFmpegProgress(stats.getTime().toInt()); }, - onError: (e, s) => _showErrorSnackBar("Error on export video :("), - onCompleted: (file) { + onError: (e, s) => _logger.severe("Error exporting video", e, s), + onCompleted: (result) async { _isExporting.value = false; + final dialog = createProgressDialog(context, S.of(context).savingEdits); + await dialog.show(); if (!mounted) return; - showDialog( - context: context, - builder: (_) => VideoResultPopup(video: file), + final fileName = path.basenameWithoutExtension(widget.file.title!) + + "_edited_" + + DateTime.now().microsecondsSinceEpoch.toString() + + ".mp4"; + //Disabling notifications for assets changing to insert the file into + //files db before triggering a sync. + await PhotoManager.stopChangeNotify(); + final AssetEntity? newAsset = + await (PhotoManager.editor.saveVideo(result, title: fileName)); + result.deleteSync(); + final newFile = await EnteFile.fromAsset( + widget.file.deviceFolder ?? '', + newAsset!, ); - }, - ); - } - void _exportCover() async { - final config = CoverFFmpegVideoEditorConfig(_controller); - final execute = await config.getExecuteConfig(); - if (execute == null) { - _showErrorSnackBar("Error on cover exportation initialization."); - return; - } - - await ExportService.runFFmpegCommand( - execute, - onError: (e, s) => _showErrorSnackBar("Error on cover exportation :("), - onCompleted: (cover) { - if (!mounted) return; - - showDialog( - context: context, - builder: (_) => CoverResultPopup(cover: cover), + newFile.generatedID = await FilesDB.instance.insert(widget.file); + Bus.instance + .fire(LocalPhotosUpdatedEvent([newFile], source: "editSave")); + unawaited(SyncService.instance.sync()); + showShortToast(context, S.of(context).editsSaved); + _logger.info("Original file " + widget.file.toString()); + _logger.info("Saved edits to file " + newFile.toString()); + final existingFiles = widget.detailPageConfig.files; + final files = (await widget.detailPageConfig.asyncLoader!( + existingFiles[existingFiles.length - 1].creationTime!, + existingFiles[0].creationTime!, + )) + .files; + // the index could be -1 if the files fetched doesn't contain the newly + // edited files + int selectionIndex = + files.indexWhere((file) => file.generatedID == newFile.generatedID); + if (selectionIndex == -1) { + files.add(newFile); + selectionIndex = files.length - 1; + } + replacePage( + context, + DetailPage( + widget.detailPageConfig.copyWith( + files: files, + selectedIndex: min(selectionIndex, files.length - 1), + ), + ), ); + await dialog.hide(); }, ); } - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async => false, - child: Scaffold( - backgroundColor: Colors.black, - body: _controller.initialized - ? SafeArea( - child: Stack( - children: [ - Column( - children: [ - _topNavBar(), - Expanded( - child: DefaultTabController( - length: 2, - child: Column( - children: [ - Expanded( - child: TabBarView( - physics: - const NeverScrollableScrollPhysics(), - children: [ - Stack( - alignment: Alignment.center, - children: [ - CropGridViewer.preview( - controller: _controller, - ), - AnimatedBuilder( - animation: _controller.video, - builder: (_, __) => AnimatedOpacity( - opacity: - _controller.isPlaying ? 0 : 1, - duration: kThemeAnimationDuration, - child: GestureDetector( - onTap: _controller.video.play, - child: Container( - width: 40, - height: 40, - decoration: - const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow, - color: Colors.black, - ), - ), - ), - ), - ), - ], - ), - CoverViewer(controller: _controller), - ], - ), - ), - Container( - height: 200, - margin: const EdgeInsets.only(top: 10), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: _trimSlider(), - ), - ), - ValueListenableBuilder( - valueListenable: _isExporting, - builder: (_, bool export, Widget? child) => - AnimatedSize( - duration: kThemeAnimationDuration, - child: export ? child : null, - ), - child: AlertDialog( - title: ValueListenableBuilder( - valueListenable: _exportingProgress, - builder: (_, double value, __) => Text( - "Exporting video ${(value * 100).ceil()}%", - style: const TextStyle(fontSize: 12), - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ) - : const Center(child: CircularProgressIndicator()), - ), - ); - } - - Widget _topNavBar() { - return SafeArea( - child: SizedBox( - height: height, - child: Row( - children: [ - Expanded( - child: IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.exit_to_app), - tooltip: 'Leave editor', - ), - ), - const VerticalDivider(endIndent: 22, indent: 22), - Expanded( - child: IconButton( - onPressed: () => - _controller.rotate90Degrees(RotateDirection.left), - icon: const Icon(Icons.rotate_left), - tooltip: 'Rotate unclockwise', - ), - ), - Expanded( - child: IconButton( - onPressed: () => - _controller.rotate90Degrees(RotateDirection.right), - icon: const Icon(Icons.rotate_right), - tooltip: 'Rotate clockwise', - ), - ), - Expanded( - child: IconButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CropPage(controller: _controller), - ), - ), - icon: const Icon(Icons.crop), - tooltip: 'Open crop screen', - ), - ), - const VerticalDivider(endIndent: 22, indent: 22), - Expanded( - child: PopupMenuButton( - tooltip: 'Open export menu', - icon: const Icon(Icons.save), - itemBuilder: (context) => [ - PopupMenuItem( - onTap: _exportCover, - child: const Text('Export cover'), - ), - PopupMenuItem( - onTap: _exportVideo, - child: const Text('Export video'), - ), - ], - ), - ), - ], - ), - ), - ); - } - - String formatter(Duration duration) => [ - duration.inMinutes.remainder(60).toString().padLeft(2, '0'), - duration.inSeconds.remainder(60).toString().padLeft(2, '0'), - ].join(":"); - - List _trimSlider() { - return [ - AnimatedBuilder( - animation: Listenable.merge([ - _controller, - _controller.video, - ]), - builder: (_, __) { - final int duration = _controller.videoDuration.inSeconds; - final double pos = _controller.trimPosition * duration; - - return Padding( - padding: EdgeInsets.symmetric(horizontal: height / 4), - child: Row( - children: [ - Text(formatter(Duration(seconds: pos.toInt()))), - const Expanded(child: SizedBox()), - AnimatedOpacity( - opacity: _controller.isTrimming ? 1 : 0, - duration: kThemeAnimationDuration, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(formatter(_controller.startTrim)), - const SizedBox(width: 10), - Text(formatter(_controller.endTrim)), - ], - ), - ), - ], - ), - ); - }, - ), - Container( - width: MediaQuery.of(context).size.width, - margin: EdgeInsets.symmetric(vertical: height / 4), - child: TrimSlider( - controller: _controller, - height: height, - horizontalMargin: height / 4, - child: TrimTimeline( - controller: _controller, - padding: const EdgeInsets.only(top: 10), - ), - ), - ), - ]; - } - - Widget _coverSelection() { - return SingleChildScrollView( - child: Center( - child: Container( - margin: const EdgeInsets.all(15), - child: CoverSelection( - controller: _controller, - size: height + 10, - quantity: 8, - selectedCoverBuilder: (cover, size) { - return Stack( - alignment: Alignment.center, - children: [ - cover, - Icon( - Icons.check_circle, - color: const CoverSelectionStyle().selectedBorderColor, - ), - ], - ); - }, - ), - ), - ), - ); - } } diff --git a/mobile/lib/ui/tools/editor/video_rotate_page.dart b/mobile/lib/ui/tools/editor/video_rotate_page.dart new file mode 100644 index 0000000000..c2149e7306 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_rotate_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import "package:photos/ui/tools/editor/video_editor/video_editor_bottom_action.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_main_actions.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import 'package:video_editor/video_editor.dart'; + +class VideoRotatePage extends StatelessWidget { + const VideoRotatePage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + Widget build(BuildContext context) { + final rotation = controller.rotation; + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.preview( + controller: controller, + ), + ), + ), + VideoEditorPlayerControl( + controller: controller, + ), + VideoEditorMainActions( + children: [ + VideoEditorBottomAction( + label: "Left", + onPressed: () => + controller.rotate90Degrees(RotateDirection.left), + icon: Icons.rotate_left, + ), + const SizedBox(width: 40), + VideoEditorBottomAction( + label: "Right", + onPressed: () => + controller.rotate90Degrees(RotateDirection.right), + icon: Icons.rotate_right, + ), + ], + ), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + secondaryText: "Done", + onPrimaryPressed: () { + while (controller.rotation != rotation) { + controller.rotate90Degrees(RotateDirection.left); + } + Navigator.pop(context); + }, + onSecondaryPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/ui/tools/editor/video_trim_page.dart b/mobile/lib/ui/tools/editor/video_trim_page.dart new file mode 100644 index 0000000000..44e5d9cbb1 --- /dev/null +++ b/mobile/lib/ui/tools/editor/video_trim_page.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import "package:photos/ui/tools/editor/video_editor/video_editor_navigation_options.dart"; +import "package:photos/ui/tools/editor/video_editor/video_editor_player_control.dart"; +import 'package:video_editor/video_editor.dart'; + +class VideoTrimPage extends StatefulWidget { + const VideoTrimPage({super.key, required this.controller}); + + final VideoEditorController controller; + + @override + State createState() => _VideoTrimPageState(); +} + +class _VideoTrimPageState extends State { + final double height = 60; + + @override + Widget build(BuildContext context) { + final minTrim = widget.controller.minTrim; + final maxTrim = widget.controller.maxTrim; + + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Hero( + tag: "video-editor-preview", + child: CropGridViewer.preview( + controller: widget.controller, + ), + ), + ), + VideoEditorPlayerControl( + controller: widget.controller, + ), + ..._trimSlider(), + const SizedBox(height: 40), + VideoEditorNavigationOptions( + secondaryText: "Done", + onPrimaryPressed: () { + // reset trim + widget.controller.updateTrim(minTrim, maxTrim); + Navigator.pop(context); + }, + onSecondaryPressed: () { + // WAY 1: validate crop parameters set in the crop view + widget.controller.applyCacheCrop(); + // WAY 2: update manually with Offset values + // controller.updateCrop(const Offset(0.2, 0.2), const Offset(0.8, 0.8)); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } + + List _trimSlider() { + return [ + Container( + width: MediaQuery.of(context).size.width, + margin: EdgeInsets.symmetric(vertical: height / 4, horizontal: 20), + child: TrimSlider( + controller: widget.controller, + height: height, + horizontalMargin: height / 4, + ), + ), + ]; + } + + String formatter(Duration duration) => [ + duration.inMinutes.remainder(60).toString().padLeft(2, '0'), + duration.inSeconds.remainder(60).toString().padLeft(2, '0'), + ].join(":"); +} diff --git a/mobile/lib/ui/viewer/file/file_bottom_bar.dart b/mobile/lib/ui/viewer/file/file_bottom_bar.dart index b7a31f00d6..5e9442f089 100644 --- a/mobile/lib/ui/viewer/file/file_bottom_bar.dart +++ b/mobile/lib/ui/viewer/file/file_bottom_bar.dart @@ -151,66 +151,59 @@ class FileBottomBarState extends State { return ValueListenableBuilder( valueListenable: widget.enableFullScreenNotifier, builder: (BuildContext context, bool isFullScreen, _) { - return IgnorePointer( - ignoring: isFullScreen, - child: AnimatedOpacity( - opacity: isFullScreen ? 0 : 1, - duration: const Duration(milliseconds: 150), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(0.72), - ], - stops: const [0, 0.8, 1], + return Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.6), + Colors.black.withOpacity(0.72), + ], + stops: const [0, 0.8, 1], + ), + ), + child: Padding( + padding: EdgeInsets.only(bottom: safeAreaBottomPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + widget.file.caption?.isNotEmpty ?? false + ? Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 12, + 16, + 0, + ), + child: GestureDetector( + onTap: () async { + await _displayDetails(widget.file); + await Future.delayed( + const Duration(milliseconds: 500), + ); //Waiting for some time till the caption gets updated in db if the user closes the bottom sheet without pressing 'done' + safeRefresh(); + }, + child: Text( + widget.file.caption!, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: getEnteTextTheme(context) + .mini + .copyWith(color: textBaseDark), + textAlign: TextAlign.center, + ), + ), + ) + : const SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children, ), - ), - child: Padding( - padding: EdgeInsets.only(bottom: safeAreaBottomPadding), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - widget.file.caption?.isNotEmpty ?? false - ? Padding( - padding: const EdgeInsets.fromLTRB( - 16, - 12, - 16, - 0, - ), - child: GestureDetector( - onTap: () async { - await _displayDetails(widget.file); - await Future.delayed( - const Duration(milliseconds: 500), - ); //Waiting for some time till the caption gets updated in db if the user closes the bottom sheet without pressing 'done' - safeRefresh(); - }, - child: Text( - widget.file.caption!, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: getEnteTextTheme(context) - .mini - .copyWith(color: textBaseDark), - textAlign: TextAlign.center, - ), - ), - ) - : const SizedBox.shrink(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: children, - ), - ], - ), - ), + ], ), ), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5057efbce0..ad05638ed0 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -928,6 +928,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" + url: "https://pub.dev" + source: hosted + version: "2.0.10+1" flutter_test: dependency: "direct dev" description: flutter @@ -2476,6 +2484,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" vector_math: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index aa53b0756c..f127d4feda 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -90,6 +90,7 @@ dependencies: flutter_secure_storage: ^8.0.0 flutter_sodium: ^0.2.0 flutter_staggered_grid_view: ^0.6.2 + flutter_svg: ^2.0.10+1 fluttertoast: ^8.0.6 fraction: ^5.0.2 freezed_annotation: ^2.4.1 @@ -238,6 +239,7 @@ flutter: - assets/models/mobilenet/ - assets/models/scenes/ - assets/models/clip/ + - assets/video-editor/ fonts: - family: Inter fonts: