import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/config/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../small_spinner.dart'; import 'editor_common.dart'; import 'photo_hero.dart'; import 'video_edit.dart'; class VideoStill extends StatelessWidget { final ui.Image frame; final VideoGeometry geometry; final ColorAdjust adjust; final List marks; final Size marksCanvas; const VideoStill({ super.key, required this.frame, required this.geometry, required this.adjust, this.marks = const [], this.marksCanvas = Size.zero, }); @override Widget build(BuildContext context) { final local = geometry.withSource( Size(frame.width.toDouble(), frame.height.toDouble()), ); return ColorFiltered( colorFilter: ColorFilter.matrix(adjust.matrix()), child: Stack( fit: StackFit.expand, children: [ CustomPaint(painter: _StillPainter(frame, local)), if (adjust.vignette > 0) DecoratedBox( decoration: BoxDecoration(gradient: adjust.vignetteGradient()), ), if (marks.isNotEmpty && !marksCanvas.isEmpty) FittedBox( fit: BoxFit.fill, child: SizedBox( width: marksCanvas.width, height: marksCanvas.height, child: CustomPaint(painter: DrawingPainter(marks: marks)), ), ), ], ), ); } } class _StillPainter extends CustomPainter { final ui.Image frame; final VideoGeometry geometry; _StillPainter(this.frame, this.geometry); @override void paint(Canvas canvas, Size size) { final out = geometry.naturalOutput; if (out.isEmpty || size.isEmpty) return; canvas.clipRect(Offset.zero & size); canvas.save(); canvas.scale(size.width / out.width, size.height / out.height); canvas.transform(geometry.sourceToOutput().storage); canvas.drawImage( frame, Offset.zero, Paint()..filterQuality = FilterQuality.medium, ); canvas.restore(); } @override bool shouldRepaint(covariant _StillPainter old) => old.frame != frame || old.geometry != geometry; } Future composeVideoStill( ui.Image frame, VideoGeometry geometry, ColorAdjust adjust, { List marks = const [], Size marksCanvas = Size.zero, }) async { final local = geometry.withSource( Size(frame.width.toDouble(), frame.height.toDouble()), ); final out = local.naturalOutput; final width = out.width.round(); final height = out.height.round(); if (width <= 0 || height <= 0) return null; try { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); final rect = Rect.fromLTWH(0, 0, out.width, out.height); canvas.saveLayer( rect, Paint()..colorFilter = ColorFilter.matrix(adjust.matrix()), ); canvas.save(); canvas.clipRect(rect); canvas.transform(local.sourceToOutput().storage); canvas.drawImage( frame, Offset.zero, Paint()..filterQuality = FilterQuality.medium, ); canvas.restore(); canvas.restore(); if (adjust.vignette > 0) { canvas.drawRect( rect, Paint()..shader = adjust.vignetteGradient().createShader(rect), ); } if (marks.isNotEmpty && !marksCanvas.isEmpty) { canvas.save(); canvas.scale( out.width / marksCanvas.width, out.height / marksCanvas.height, ); DrawingPainter(marks: marks).paintMarks(canvas, marksCanvas); canvas.restore(); } final picture = recorder.endRecording(); final image = await picture.toImage(width, height); picture.dispose(); return image; } catch (_) { return null; } } class VideoCropEditor extends StatelessWidget { final ui.Image frame; final ColorAdjust adjust; final VideoCropEdit? initial; final Future Function(VideoCropResult result)? onPreview; const VideoCropEditor({ super.key, required this.frame, required this.adjust, this.initial, this.onPreview, }); Future _apply( CropState state, Size viewport, bool changed, bool identity, ) async { if (!changed && !identity) return null; final result = identity ? const VideoCropResult(null) : VideoCropResult(VideoCropEdit(state: state, viewport: viewport)); await onPreview?.call(result); return result; } @override Widget build(BuildContext context) { return CropWorkspace( imageSize: Size(frame.width.toDouble(), frame.height.toDouble()), initialState: initial?.state, onApply: _apply, imageBuilder: (context, matrix) => ColorFiltered( colorFilter: ColorFilter.matrix(adjust.matrix()), child: CustomPaint(painter: MatrixImagePainter(frame, matrix)), ), ); } } class VideoCropResult { final VideoCropEdit? crop; const VideoCropResult(this.crop); } class VideoMarksResult { final List marks; final Size canvas; const VideoMarksResult(this.marks, this.canvas); } class VideoDrawEditor extends StatelessWidget { final ui.Image frame; final VideoGeometry geometry; final ColorAdjust adjust; final List initialMarks; final Future Function(VideoMarksResult result)? onPreview; const VideoDrawEditor({ super.key, required this.frame, required this.geometry, required this.adjust, this.initialMarks = const [], this.onPreview, }); @override Widget build(BuildContext context) { final out = geometry.naturalOutput; return MarkupEditor( aspectRatio: out.height > 0 ? out.width / out.height : 1.0, initialMarks: initialMarks, background: VideoStill(frame: frame, geometry: geometry, adjust: adjust), onApply: (marks, canvas) async { final result = VideoMarksResult([...marks], canvas); await onPreview?.call(result); return result; }, ); } } class VideoAdjustEditor extends StatefulWidget { final ui.Image frame; final VideoGeometry geometry; final ColorAdjust initial; final List marks; final Size marksCanvas; final Future Function(ColorAdjust result)? onPreview; const VideoAdjustEditor({ super.key, required this.frame, required this.geometry, required this.initial, this.marks = const [], this.marksCanvas = Size.zero, this.onPreview, }); @override State createState() => _VideoAdjustEditorState(); } class _VideoAdjustEditorState extends State { late final ColorAdjust _adjust = widget.initial.copy(); final ValueNotifier _rev = ValueNotifier(0); bool _busy = false; Future _done() async { if (_busy) return; setState(() => _busy = true); await widget.onPreview?.call(_adjust); if (!mounted) return; Navigator.of(context).pop(_adjust); } @override void dispose() { _rev.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final out = widget.geometry.naturalOutput; return Scaffold( backgroundColor: Colors.black, body: SafeArea( child: Stack( children: [ Column( children: [ Expanded( child: Center( child: AspectRatio( aspectRatio: out.height > 0 ? out.width / out.height : 1.0, child: PhotoHeroTarget( child: ValueListenableBuilder( valueListenable: _rev, builder: (context, _, _) => VideoStill( frame: widget.frame, geometry: widget.geometry, adjust: _adjust, marks: widget.marks, marksCanvas: widget.marksCanvas, ), ), ), ), ), ), ValueListenableBuilder( valueListenable: _rev, builder: (context, _, _) => AdjustSliders( adjust: _adjust, onChanged: () => _rev.value++, ), ), Container( color: kEditorPanel, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( l10n.photoEditorCancel, style: const TextStyle( color: Colors.white, fontSize: 15, ), ), ), const Spacer(), Icon(Symbols.tune, color: MediaAccent.of(context)), const Spacer(), TextButton( onPressed: _busy ? null : _done, child: Text( l10n.photoEditorDone, style: TextStyle( color: _busy ? Colors.white38 : MediaAccent.of(context), fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ], ), ), ], ), if (_busy) const BusyOverlay(), ], ), ), ); } } class VideoQualityEditor extends StatefulWidget { final ui.Image frame; final VideoGeometry geometry; final ColorAdjust adjust; final List marks; final Size marksCanvas; final List options; final int selected; final double fps; final Duration duration; final String? title; final Future Function(int result)? onPreview; const VideoQualityEditor({ super.key, required this.frame, required this.geometry, required this.adjust, required this.options, required this.selected, this.marks = const [], this.marksCanvas = Size.zero, required this.fps, required this.duration, this.title, this.onPreview, }); @override State createState() => _VideoQualityEditorState(); } class _VideoQualityEditorState extends State { late int _index = math.max(0, widget.options.indexOf(widget.selected)); bool _busy = false; Future _done() async { if (_busy) return; final value = widget.options[_index]; setState(() => _busy = true); await widget.onPreview?.call(value); if (!mounted) return; Navigator.of(context).pop(value); } Size get _outputSize => widget.geometry.outputSize(widget.options[_index]); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final out = widget.geometry.naturalOutput; final inset = _QualitySlider.insetFor(context); return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, surfaceTintColor: Colors.transparent, foregroundColor: Colors.white, elevation: 0, leading: IconButton( icon: const Icon(Symbols.arrow_back), onPressed: () => Navigator.of(context).maybePop(), ), title: VideoHeaderTitle( title: widget.title, size: _outputSize, duration: widget.duration, bytes: estimateVideoSizeBytes( _outputSize, widget.fps, widget.duration, ), ), ), body: Stack( children: [ Column( children: [ Expanded( child: Center( child: AspectRatio( aspectRatio: out.height > 0 ? out.width / out.height : 1.0, child: PhotoHeroTarget( child: VideoStill( frame: widget.frame, geometry: widget.geometry, adjust: widget.adjust, marks: widget.marks, marksCanvas: widget.marksCanvas, ), ), ), ), ), SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.fromLTRB(inset, 8, inset, 0), child: Row( children: [ Expanded( child: Text( l10n.videoEditorQualityLow, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white70, fontSize: 14, ), ), ), const SizedBox(width: 12), Expanded( child: Text( l10n.videoEditorQualityHigh, textAlign: TextAlign.end, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white70, fontSize: 14, ), ), ), ], ), ), _QualitySlider( count: widget.options.length, index: _index, inset: inset, onChanged: (value) => setState(() => _index = value), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( l10n.photoEditorCancel, style: const TextStyle( color: Colors.white, fontSize: 15, ), ), ), TextButton( onPressed: _busy ? null : _done, child: Text( l10n.photoEditorDone, style: TextStyle( color: _busy ? Colors.white38 : MediaAccent.of(context), fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ], ), ], ), ), ], ), if (_busy) const BusyOverlay(), ], ), ); } } class _QualitySlider extends StatelessWidget { final int count; final int index; final double inset; final ValueChanged onChanged; const _QualitySlider({ required this.count, required this.index, required this.inset, required this.onChanged, }); static const double _minInset = 16.0; static const double _height = 48.0; // #***! концы дорожки уводим из зоны системных жестов, иначе крайние // значения на телефоне с жестовой навигацией нечем зацепить static double insetFor(BuildContext context) { final gesture = MediaQuery.of(context).systemGestureInsets; return math.max(_minInset, math.max(gesture.left, gesture.right) + 8); } @override Widget build(BuildContext context) { final accent = MediaAccent.of(context); return SizedBox( height: _height, child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final span = math.max(1.0, width - inset * 2); void pick(double dx) { if (count <= 1) return; final t = ((dx - inset) / span).clamp(0.0, 1.0); final next = (t * (count - 1)).round(); if (next != index) onChanged(next); } return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (d) => pick(d.localPosition.dx), onHorizontalDragUpdate: (d) => pick(d.localPosition.dx), // #***! без явного размера CustomPaint схлопывается в ноль ширины, // дорожка рисуется мимо и по ней нечем попасть child: CustomPaint( size: Size(width, _height), painter: _QualitySliderPainter( count: count, index: index, accent: accent, inset: inset, ), ), ); }, ), ); } } class _QualitySliderPainter extends CustomPainter { final int count; final int index; final Color accent; final double inset; _QualitySliderPainter({ required this.count, required this.index, required this.accent, required this.inset, }); @override void paint(Canvas canvas, Size size) { final y = size.height / 2; final left = inset; final right = size.width - inset; final track = Paint() ..strokeWidth = 3 ..strokeCap = StrokeCap.round; canvas.drawLine( Offset(left, y), Offset(right, y), track..color = Colors.white24, ); final step = count <= 1 ? 0.0 : (right - left) / (count - 1); final active = left + step * index; canvas.drawLine(Offset(left, y), Offset(active, y), track..color = accent); for (var i = 0; i < count; i++) { final x = left + step * i; canvas.drawCircle( Offset(x, y), 4, Paint()..color = i <= index ? accent : Colors.white38, ); } canvas.drawCircle(Offset(active, y), 9, Paint()..color = accent); } @override bool shouldRepaint(covariant _QualitySliderPainter old) => old.index != index || old.count != count || old.accent != accent; } class VideoHeaderTitle extends StatelessWidget { final String? title; final Size size; final Duration duration; final int bytes; const VideoHeaderTitle({ super.key, required this.size, required this.duration, required this.bytes, this.title, }); @override Widget build(BuildContext context) { final name = title; return Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (name != null && name.isNotEmpty) Text( name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis, ), Text( '${size.width.round()}x${size.height.round()}, ' '${_duration(duration)}, ~${_bytes(bytes)}', style: const TextStyle(fontSize: 13, color: Colors.white70), overflow: TextOverflow.ellipsis, ), ], ); } static String _duration(Duration value) { final minutes = value.inMinutes; final seconds = value.inSeconds % 60; return '$minutes:${seconds.toString().padLeft(2, '0')}'; } static String _bytes(int value) { if (value >= 1024 * 1024) { return '${(value / (1024 * 1024)).toStringAsFixed(1)} MB'; } return '${(value / 1024).toStringAsFixed(1)} KB'; } }