Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Adapt video editing, decrypted message actions, custom themes and gestures from PR 166. Track visible server presence per session and preserve delivery for invisible clients.
1119 lines
32 KiB
Dart
1119 lines
32 KiB
Dart
import '../../../core/utils/logger.dart';
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
import 'package:ikar/core/media/gallery_source.dart';
|
|
import 'package:ikar/core/media/video_transcoder.dart';
|
|
import 'package:ikar/frontend/widgets/custom_notification.dart';
|
|
import 'package:ikar/frontend/widgets/lottie_slash_icon.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 'preview_chrome.dart';
|
|
import 'video_edit.dart';
|
|
import 'video_editor.dart';
|
|
|
|
const int _kStripFrames = 12;
|
|
|
|
class VideoPreviewScreen extends StatefulWidget {
|
|
final GalleryItem item;
|
|
final PhotoHeroController hero;
|
|
final String? title;
|
|
final ValueListenable<Set<String>> selectedIds;
|
|
final VoidCallback onToggleSelection;
|
|
final VoidCallback onSend;
|
|
final VideoEditState edit;
|
|
final bool editable;
|
|
final VoidCallback? onEditChanged;
|
|
final String initialCaption;
|
|
final ValueChanged<String>? onCaptionChanged;
|
|
|
|
const VideoPreviewScreen({
|
|
super.key,
|
|
required this.item,
|
|
required this.hero,
|
|
required this.selectedIds,
|
|
required this.onToggleSelection,
|
|
required this.onSend,
|
|
required this.edit,
|
|
this.editable = true,
|
|
this.title,
|
|
this.onEditChanged,
|
|
this.initialCaption = '',
|
|
this.onCaptionChanged,
|
|
});
|
|
|
|
@override
|
|
State<VideoPreviewScreen> createState() => _VideoPreviewScreenState();
|
|
}
|
|
|
|
class _VideoPreviewScreenState extends State<VideoPreviewScreen> {
|
|
late final TextEditingController _caption = TextEditingController(
|
|
text: widget.initialCaption,
|
|
);
|
|
|
|
final GlobalKey _stageKey = GlobalKey();
|
|
final List<ui.Image> _flightImages = [];
|
|
|
|
PhotoHeroController? _activeHero;
|
|
ui.Image? _editorFrame;
|
|
|
|
File? _file;
|
|
VideoInfo? _info;
|
|
VideoPlayerController? _controller;
|
|
List<Uint8List?> _strip = const [];
|
|
int _sourceBytes = 0;
|
|
bool _busy = false;
|
|
bool _scrubbing = false;
|
|
bool _wantsPlay = false;
|
|
bool _rewinding = false;
|
|
bool _resumeAfterScrub = false;
|
|
|
|
VideoEditState get _edit => widget.edit;
|
|
|
|
// #***! длительность из пробы и длительность плеера расходятся на кадр-другой,
|
|
// поэтому границы воспроизведения всегда сводим к тому, что знает плеер:
|
|
// иначе после подрезки позиция никогда не дотягивает до конца окна, петля не
|
|
// срабатывает и видео залипает на последнем кадре
|
|
Duration get _playbackEnd {
|
|
final total = _controller?.value.duration ?? Duration.zero;
|
|
final end = _edit.end > Duration.zero ? _edit.end : total;
|
|
return end > total ? total : end;
|
|
}
|
|
|
|
Duration get _playbackStart {
|
|
final end = _playbackEnd;
|
|
final start = _edit.start;
|
|
if (start < Duration.zero) return Duration.zero;
|
|
return start >= end ? Duration.zero : start;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_caption.addListener(() => widget.onCaptionChanged?.call(_caption.text));
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_releaseFlights();
|
|
_caption.dispose();
|
|
final controller = _controller;
|
|
_controller = null;
|
|
controller?.removeListener(_onTick);
|
|
controller?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final file = widget.item.localFile ?? await widget.item.originFile();
|
|
if (file == null || !mounted) return;
|
|
_file = file;
|
|
_sourceBytes = await file.length().catchError((_) => 0);
|
|
_info = await VideoTranscoder.probe(file.path);
|
|
if (!mounted) return;
|
|
final controller = VideoPlayerController.file(file);
|
|
try {
|
|
await controller.initialize();
|
|
} catch (_) {
|
|
controller.dispose();
|
|
if (mounted) {
|
|
showCustomNotification(
|
|
context,
|
|
AppLocalizations.of(context)!.videoEditorFrameFailed,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (!mounted) {
|
|
controller.dispose();
|
|
return;
|
|
}
|
|
final duration = _duration(controller);
|
|
if (_edit.end <= Duration.zero) {
|
|
_edit.sourceDuration = duration;
|
|
_edit.end = duration;
|
|
}
|
|
controller.addListener(_onTick);
|
|
await controller.setVolume(_edit.muted ? 0 : 1);
|
|
setState(() => _controller = controller);
|
|
_wantsPlay = true;
|
|
unawaited(controller.play());
|
|
unawaited(_loadStrip(file, duration));
|
|
}
|
|
|
|
Duration _duration(VideoPlayerController controller) {
|
|
final info = _info;
|
|
if (info != null && info.durationMs > 0) {
|
|
return Duration(milliseconds: info.durationMs);
|
|
}
|
|
return controller.value.duration;
|
|
}
|
|
|
|
Future<void> _loadStrip(File file, Duration duration) async {
|
|
if (duration <= Duration.zero) return;
|
|
final step = duration.inMilliseconds / _kStripFrames;
|
|
final times = List<int>.generate(
|
|
_kStripFrames,
|
|
(i) => (step * (i + 0.5)).round(),
|
|
);
|
|
final frames = await VideoTranscoder.frames(file.path, times, size: 160);
|
|
if (!mounted) return;
|
|
setState(() => _strip = frames);
|
|
}
|
|
|
|
static const Duration _seekSlack = Duration(milliseconds: 120);
|
|
|
|
void _onTick() {
|
|
final controller = _controller;
|
|
if (controller == null || !controller.value.isInitialized) return;
|
|
if (_scrubbing || _rewinding || !_wantsPlay) return;
|
|
final value = controller.value;
|
|
final position = value.position;
|
|
final end = _playbackEnd;
|
|
if (end <= Duration.zero) return;
|
|
if (value.isCompleted || position >= end) {
|
|
unawaited(_rewindAndPlay());
|
|
} else if (position < _playbackStart - _seekSlack) {
|
|
unawaited(controller.seekTo(_playbackStart));
|
|
}
|
|
}
|
|
|
|
Future<void> _rewindAndPlay() async {
|
|
final controller = _controller;
|
|
if (controller == null || _rewinding) return;
|
|
_rewinding = true;
|
|
try {
|
|
await controller.seekTo(_playbackStart);
|
|
if (!mounted || !identical(_controller, controller) || !_wantsPlay) {
|
|
return;
|
|
}
|
|
await controller.play();
|
|
} finally {
|
|
_rewinding = false;
|
|
}
|
|
}
|
|
|
|
Size get _sourceSize {
|
|
final info = _info;
|
|
if (info != null && info.width > 0 && info.height > 0) {
|
|
return Size(info.width.toDouble(), info.height.toDouble());
|
|
}
|
|
final size = _controller?.value.size ?? Size.zero;
|
|
return size.isEmpty ? const Size(16, 9) : size;
|
|
}
|
|
|
|
double get _fps => _info?.fps ?? 30;
|
|
|
|
VideoGeometry get _geometry => VideoGeometry.resolve(_edit.crop, _sourceSize);
|
|
|
|
Size get _outputSize => _geometry.outputSize(_edit.maxShortSide);
|
|
|
|
int get _naturalShortSide {
|
|
final natural = _geometry.naturalOutput;
|
|
return math.max(2, math.min(natural.width, natural.height).round());
|
|
}
|
|
|
|
void _changed() {
|
|
setState(() {});
|
|
widget.onEditChanged?.call();
|
|
}
|
|
|
|
Future<void> _togglePlay() async {
|
|
final controller = _controller;
|
|
if (controller == null || !controller.value.isInitialized) return;
|
|
if (controller.value.isPlaying) {
|
|
_wantsPlay = false;
|
|
await controller.pause();
|
|
return;
|
|
}
|
|
_wantsPlay = true;
|
|
final value = controller.value;
|
|
final end = _playbackEnd;
|
|
if (end > Duration.zero &&
|
|
(value.isCompleted ||
|
|
value.position >= end ||
|
|
value.position < _playbackStart)) {
|
|
await _rewindAndPlay();
|
|
return;
|
|
}
|
|
await controller.play();
|
|
}
|
|
|
|
void _toggleMute() {
|
|
_edit.muted = !_edit.muted;
|
|
_controller?.setVolume(_edit.muted ? 0 : 1);
|
|
_changed();
|
|
}
|
|
|
|
void _send() {
|
|
Navigator.of(context).pop();
|
|
widget.onSend();
|
|
}
|
|
|
|
Future<T?> _pushEditor<T>(
|
|
(ui.Image, ui.Image) prepared,
|
|
Widget Function() builder,
|
|
) async {
|
|
final (frame, flight) = prepared;
|
|
final hero = PhotoHeroController(
|
|
origin: () => photoHeroRect(_stageKey),
|
|
image: RawImageProvider(flight),
|
|
);
|
|
_activeHero = hero;
|
|
_editorFrame = frame;
|
|
_flightImages.add(flight);
|
|
try {
|
|
return await Navigator.of(
|
|
context,
|
|
).push<T>(PhotoHeroRoute<T>(hero: hero, builder: (_) => builder()));
|
|
} finally {
|
|
_activeHero = null;
|
|
_editorFrame = null;
|
|
frame.dispose();
|
|
_releaseFlights();
|
|
}
|
|
}
|
|
|
|
void _releaseFlights() {
|
|
if (_flightImages.isEmpty) return;
|
|
final images = List<ui.Image>.of(_flightImages);
|
|
_flightImages.clear();
|
|
for (final image in images) {
|
|
unawaited(RawImageProvider(image).evict());
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
for (final image in images) {
|
|
image.dispose();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _preview(
|
|
void Function() apply, {
|
|
required bool withMarks,
|
|
}) async {
|
|
apply();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
widget.onEditChanged?.call();
|
|
final frame = _editorFrame;
|
|
final hero = _activeHero;
|
|
if (frame == null || hero == null) return;
|
|
final image = await composeVideoStill(
|
|
frame,
|
|
_geometry,
|
|
_edit.adjust,
|
|
marks: withMarks ? _edit.marks : const [],
|
|
marksCanvas: withMarks ? _edit.marksCanvas : Size.zero,
|
|
);
|
|
if (image == null) return;
|
|
if (!mounted || !identical(_activeHero, hero)) {
|
|
image.dispose();
|
|
return;
|
|
}
|
|
_flightImages.add(image);
|
|
hero.image.value = RawImageProvider(image);
|
|
}
|
|
|
|
Future<ui.Image?> _grabFrame() async {
|
|
final file = _file;
|
|
if (file == null) return null;
|
|
final position = _controller?.value.position ?? _edit.start;
|
|
final frames = await VideoTranscoder.frames(
|
|
file.path,
|
|
[
|
|
position.inMilliseconds.clamp(
|
|
_edit.start.inMilliseconds,
|
|
math.max(_edit.start.inMilliseconds, _edit.end.inMilliseconds),
|
|
),
|
|
],
|
|
size: 1280,
|
|
precise: true,
|
|
);
|
|
final data = frames.isEmpty ? null : frames.first;
|
|
if (data == null) return null;
|
|
try {
|
|
final codec = await ui.instantiateImageCodec(data);
|
|
final frame = await codec.getNextFrame();
|
|
codec.dispose();
|
|
return frame.image;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<(ui.Image, ui.Image)?> _prepare({required bool withMarks}) async {
|
|
_wantsPlay = false;
|
|
_resumeAfterScrub = false;
|
|
unawaited(_controller?.pause());
|
|
setState(() => _busy = true);
|
|
ui.Image? frame;
|
|
ui.Image? flight;
|
|
try {
|
|
frame = await _grabFrame();
|
|
if (frame != null) {
|
|
flight = await composeVideoStill(
|
|
frame,
|
|
_geometry,
|
|
_edit.adjust,
|
|
marks: withMarks ? _edit.marks : const [],
|
|
marksCanvas: withMarks ? _edit.marksCanvas : Size.zero,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
logger.w('_prepare: кадр не подготовлен: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
if (frame == null || flight == null || !mounted) {
|
|
frame?.dispose();
|
|
flight?.dispose();
|
|
if (mounted) {
|
|
showCustomNotification(
|
|
context,
|
|
AppLocalizations.of(context)!.videoEditorFrameFailed,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
return (frame, flight);
|
|
}
|
|
|
|
Future<void> _openCrop() async {
|
|
final prepared = await _prepare(withMarks: false);
|
|
if (prepared == null) return;
|
|
await _pushEditor<VideoCropResult>(
|
|
prepared,
|
|
() => VideoCropEditor(
|
|
frame: prepared.$1,
|
|
adjust: _edit.adjust,
|
|
initial: _edit.crop,
|
|
onPreview: (result) =>
|
|
_preview(() => _edit.crop = result.crop, withMarks: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openDraw() async {
|
|
final prepared = await _prepare(withMarks: true);
|
|
if (prepared == null) return;
|
|
await _pushEditor<VideoMarksResult>(
|
|
prepared,
|
|
() => VideoDrawEditor(
|
|
frame: prepared.$1,
|
|
geometry: _geometry,
|
|
adjust: _edit.adjust,
|
|
initialMarks: _edit.marks,
|
|
onPreview: (result) => _preview(() {
|
|
_edit.marks = result.marks;
|
|
_edit.marksCanvas = result.canvas;
|
|
}, withMarks: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openAdjust() async {
|
|
final prepared = await _prepare(withMarks: true);
|
|
if (prepared == null) return;
|
|
await _pushEditor<ColorAdjust>(
|
|
prepared,
|
|
() => VideoAdjustEditor(
|
|
frame: prepared.$1,
|
|
geometry: _geometry,
|
|
initial: _edit.adjust,
|
|
marks: _edit.marks,
|
|
marksCanvas: _edit.marksCanvas,
|
|
onPreview: (result) =>
|
|
_preview(() => _edit.adjust = result, withMarks: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openQuality() async {
|
|
final prepared = await _prepare(withMarks: true);
|
|
if (prepared == null) return;
|
|
final natural = _naturalShortSide;
|
|
final options = videoQualityOptions(natural);
|
|
await _pushEditor<int>(
|
|
prepared,
|
|
() => VideoQualityEditor(
|
|
frame: prepared.$1,
|
|
geometry: _geometry,
|
|
adjust: _edit.adjust,
|
|
marks: _edit.marks,
|
|
marksCanvas: _edit.marksCanvas,
|
|
options: options,
|
|
selected: _edit.maxShortSide ?? natural,
|
|
fps: _fps,
|
|
duration: _edit.duration,
|
|
title: widget.title,
|
|
onPreview: (result) => _preview(
|
|
() => _edit.maxShortSide = result >= natural ? null : result,
|
|
withMarks: true,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext 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: _edit.duration,
|
|
bytes: _edit.hasEdits
|
|
? estimateVideoSizeBytes(_outputSize, _fps, _edit.duration)
|
|
: _sourceBytes,
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 14),
|
|
child: PreviewSelectionToggle(
|
|
selectedIds: widget.selectedIds,
|
|
id: widget.item.id,
|
|
onTap: widget.onToggleSelection,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: PhotoHeroTarget(child: Center(child: _stage())),
|
|
),
|
|
_bottomBar(),
|
|
],
|
|
),
|
|
if (_busy) const BusyOverlay(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _stage() {
|
|
final controller = _controller;
|
|
if (controller == null || !controller.value.isInitialized) {
|
|
return const SmallSpinner(size: 36, color: Colors.white24);
|
|
}
|
|
final output = _geometry.naturalOutput;
|
|
if (output.isEmpty) return const SizedBox.shrink();
|
|
return AspectRatio(
|
|
key: _stageKey,
|
|
aspectRatio: output.width / output.height,
|
|
child: GestureDetector(
|
|
onTap: _togglePlay,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final scale = constraints.maxWidth / output.width;
|
|
final matrix = Matrix4.diagonal3Values(scale, scale, 1)
|
|
..multiply(_geometry.sourceToOutput());
|
|
final source = _sourceSize;
|
|
return ClipRect(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ColorFiltered(
|
|
colorFilter: ColorFilter.matrix(_edit.adjust.matrix()),
|
|
child: OverflowBox(
|
|
alignment: Alignment.topLeft,
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
maxWidth: double.infinity,
|
|
maxHeight: double.infinity,
|
|
child: Transform(
|
|
alignment: Alignment.topLeft,
|
|
transform: matrix,
|
|
child: SizedBox(
|
|
width: source.width,
|
|
height: source.height,
|
|
child: VideoPlayer(controller),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_edit.adjust.vignette > 0)
|
|
IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: _edit.adjust.vignetteGradient(),
|
|
),
|
|
),
|
|
),
|
|
if (_edit.marks.isNotEmpty && !_edit.marksCanvas.isEmpty)
|
|
IgnorePointer(
|
|
child: FittedBox(
|
|
fit: BoxFit.fill,
|
|
child: SizedBox(
|
|
width: _edit.marksCanvas.width,
|
|
height: _edit.marksCanvas.height,
|
|
child: CustomPaint(
|
|
painter: DrawingPainter(marks: _edit.marks),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ValueListenableBuilder<VideoPlayerValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, _) => value.isPlaying
|
|
? const SizedBox.shrink()
|
|
: const IgnorePointer(
|
|
child: Center(child: _PlayBadge()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static const double _barInset = 8;
|
|
static const double _trimMinInset = 16;
|
|
|
|
Widget _bottomBar() {
|
|
final controller = _controller;
|
|
final gesture = MediaQuery.of(context).systemGestureInsets;
|
|
final trimInset = math.max(
|
|
_trimMinInset,
|
|
math.max(gesture.left, gesture.right) + 8 - _barInset,
|
|
);
|
|
return SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(_barInset, 0, _barInset, 8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (widget.editable) ...[
|
|
Row(
|
|
children: [
|
|
_MuteButton(muted: _edit.muted, onTap: _toggleMute),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
if (widget.editable &&
|
|
controller != null &&
|
|
controller.value.isInitialized)
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: trimInset),
|
|
child: TrimBar(
|
|
frames: _strip,
|
|
controller: controller,
|
|
duration: _edit.sourceDuration,
|
|
start: _edit.start,
|
|
end: _edit.end,
|
|
onScrub: _onScrub,
|
|
onTrim: _onTrim,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_captionField(),
|
|
const SizedBox(height: 10),
|
|
_toolbar(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _beginScrub() {
|
|
if (_scrubbing) return;
|
|
_scrubbing = true;
|
|
_resumeAfterScrub = _wantsPlay;
|
|
_wantsPlay = false;
|
|
}
|
|
|
|
void _onScrub(Duration position, bool active) {
|
|
final controller = _controller;
|
|
if (active) {
|
|
_beginScrub();
|
|
if (controller == null) return;
|
|
if (controller.value.isPlaying) unawaited(controller.pause());
|
|
unawaited(controller.seekTo(position));
|
|
return;
|
|
}
|
|
_scrubbing = false;
|
|
if (controller == null) {
|
|
_resumeAfterScrub = false;
|
|
return;
|
|
}
|
|
unawaited(_settleScrub(controller, position));
|
|
}
|
|
|
|
Future<void> _settleScrub(
|
|
VideoPlayerController controller,
|
|
Duration position,
|
|
) async {
|
|
final resume = _resumeAfterScrub;
|
|
_resumeAfterScrub = false;
|
|
await controller.seekTo(position);
|
|
if (!mounted || !identical(_controller, controller) || !resume) return;
|
|
_wantsPlay = true;
|
|
final end = _playbackEnd;
|
|
if (end > Duration.zero && controller.value.position >= end) {
|
|
await _rewindAndPlay();
|
|
} else {
|
|
await controller.play();
|
|
}
|
|
}
|
|
|
|
void _onTrim(Duration start, Duration end, bool active) {
|
|
if (active) {
|
|
_beginScrub();
|
|
} else {
|
|
_scrubbing = false;
|
|
}
|
|
setState(() {
|
|
_edit.start = start;
|
|
_edit.end = end;
|
|
});
|
|
if (!active) widget.onEditChanged?.call();
|
|
}
|
|
|
|
Widget _captionField() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: kEditorBar,
|
|
borderRadius: BorderRadius.circular(28),
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _caption,
|
|
style: const TextStyle(color: Colors.white, fontSize: 15),
|
|
cursorColor: Colors.white,
|
|
decoration: InputDecoration(
|
|
isCollapsed: true,
|
|
border: InputBorder.none,
|
|
hintText: l10n.videoEditorCaptionHint,
|
|
hintStyle: const TextStyle(color: Colors.white54, fontSize: 15),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ValueListenableBuilder<Set<String>>(
|
|
valueListenable: widget.selectedIds,
|
|
builder: (context, selected, _) => PreviewCountBadge(
|
|
count: selected.isEmpty ? 1 : selected.length,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _toolbar() {
|
|
final ready = _controller?.value.isInitialized == true;
|
|
if (!widget.editable) {
|
|
return Row(
|
|
children: [
|
|
const Spacer(),
|
|
PreviewSendButton(onTap: _send),
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: kEditorBar,
|
|
borderRadius: BorderRadius.circular(28),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
PreviewToolIcon(
|
|
icon: Symbols.crop_rotate,
|
|
onTap: ready ? _openCrop : () {},
|
|
),
|
|
PreviewToolIcon(
|
|
icon: Symbols.brush,
|
|
onTap: ready ? _openDraw : () {},
|
|
),
|
|
_QualityBadge(
|
|
shortSide: _edit.maxShortSide ?? _naturalShortSide,
|
|
onTap: ready ? _openQuality : () {},
|
|
),
|
|
PreviewToolIcon(
|
|
icon: Symbols.tune,
|
|
onTap: ready ? _openAdjust : () {},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
PreviewSendButton(onTap: _send),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlayBadge extends StatelessWidget {
|
|
const _PlayBadge();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 62,
|
|
height: 62,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.black.withValues(alpha: 0.45),
|
|
),
|
|
child: const Icon(
|
|
Symbols.play_arrow,
|
|
color: Colors.white,
|
|
size: 34,
|
|
fill: 1,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MuteButton extends StatelessWidget {
|
|
final bool muted;
|
|
final VoidCallback onTap;
|
|
|
|
const _MuteButton({required this.muted, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IconButton(
|
|
onPressed: onTap,
|
|
tooltip: AppLocalizations.of(context)!.videoEditorMuteTooltip,
|
|
icon: LottieSlashIcon(
|
|
asset: 'assets/lottie/ic_volume_on_to_off.json',
|
|
slashed: muted,
|
|
color: muted ? MediaAccent.of(context) : Colors.white,
|
|
size: 26,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QualityBadge extends StatelessWidget {
|
|
final int shortSide;
|
|
final VoidCallback onTap;
|
|
|
|
const _QualityBadge({required this.shortSide, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IconButton(
|
|
onPressed: onTap,
|
|
tooltip: AppLocalizations.of(context)!.videoEditorQualityTooltip,
|
|
icon: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'$shortSide',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 9,
|
|
height: 1,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const Icon(Symbols.hd, color: Colors.white, size: 22, fill: 1),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class TrimBar extends StatefulWidget {
|
|
final List<Uint8List?> frames;
|
|
final VideoPlayerController controller;
|
|
final Duration duration;
|
|
final Duration start;
|
|
final Duration end;
|
|
final void Function(Duration position, bool active) onScrub;
|
|
final void Function(Duration start, Duration end, bool active) onTrim;
|
|
|
|
const TrimBar({
|
|
super.key,
|
|
required this.frames,
|
|
required this.controller,
|
|
required this.duration,
|
|
required this.start,
|
|
required this.end,
|
|
required this.onScrub,
|
|
required this.onTrim,
|
|
});
|
|
|
|
@override
|
|
State<TrimBar> createState() => _TrimBarState();
|
|
}
|
|
|
|
class _TrimBarState extends State<TrimBar> {
|
|
static const double _handle = 13;
|
|
static const double _height = 48;
|
|
static const double _grabRadius = 28;
|
|
|
|
// #***! пока палец на дорожке, ручки живут здесь: onPanUpdate и onPanEnd
|
|
// прилетают одним пакетом раньше перестройки, и widget.start/widget.end в
|
|
// этот момент ещё хранят значения до жеста — подрезка откатывалась бы назад
|
|
late Duration _start = widget.start;
|
|
late Duration _end = widget.end;
|
|
int _target = -1;
|
|
|
|
@override
|
|
void didUpdateWidget(TrimBar old) {
|
|
super.didUpdateWidget(old);
|
|
if (_target < 0) {
|
|
_start = widget.start;
|
|
_end = widget.end;
|
|
}
|
|
}
|
|
|
|
double _fraction(Duration value) {
|
|
final total = widget.duration.inMilliseconds;
|
|
if (total <= 0) return 0;
|
|
return (value.inMilliseconds / total).clamp(0.0, 1.0);
|
|
}
|
|
|
|
Duration _at(double fraction) => Duration(
|
|
milliseconds: (fraction.clamp(0.0, 1.0) * widget.duration.inMilliseconds)
|
|
.round(),
|
|
);
|
|
|
|
void _down(Offset pos, double width) {
|
|
if (width <= 0) return;
|
|
final startX = _fraction(_start) * width;
|
|
final endX = _fraction(_end) * width;
|
|
final toStart = (pos.dx - startX).abs();
|
|
final toEnd = (pos.dx - endX).abs();
|
|
if (toStart <= toEnd && toStart < _grabRadius) {
|
|
_target = 0;
|
|
} else if (toEnd < _grabRadius) {
|
|
_target = 1;
|
|
} else {
|
|
_target = 2;
|
|
widget.onScrub(_clamp(_at(pos.dx / width)), true);
|
|
}
|
|
}
|
|
|
|
Duration _clamp(Duration value) {
|
|
if (value < _start) return _start;
|
|
if (_end > Duration.zero && value > _end) return _end;
|
|
return value;
|
|
}
|
|
|
|
void _move(Offset pos, double width) {
|
|
if (width <= 0 || _target < 0) return;
|
|
final value = _at(pos.dx / width);
|
|
switch (_target) {
|
|
case 0:
|
|
final limit = _end - kMinTrimDuration;
|
|
final start = value > limit
|
|
? (limit > Duration.zero ? limit : Duration.zero)
|
|
: value;
|
|
setState(() => _start = start);
|
|
widget.onTrim(_start, _end, true);
|
|
widget.onScrub(start, true);
|
|
case 1:
|
|
final limit = _start + kMinTrimDuration;
|
|
final end = value < limit ? limit : value;
|
|
setState(() => _end = end);
|
|
widget.onTrim(_start, _end, true);
|
|
widget.onScrub(end, true);
|
|
case 2:
|
|
widget.onScrub(_clamp(value), true);
|
|
}
|
|
}
|
|
|
|
void _up() {
|
|
final target = _target;
|
|
_target = -1;
|
|
if (target < 0) return;
|
|
if (target != 2) widget.onTrim(_start, _end, false);
|
|
widget.onScrub(_clamp(widget.controller.value.position), false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final accent = MediaAccent.of(context);
|
|
return SizedBox(
|
|
height: _height,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final startX = _fraction(_start) * width;
|
|
final endX = _fraction(_end) * width;
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPanDown: (d) => _down(d.localPosition, width),
|
|
onPanUpdate: (d) => _move(d.localPosition, width),
|
|
onPanEnd: (_) => _up(),
|
|
onPanCancel: _up,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(child: _filmstrip()),
|
|
Positioned(
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: math.max(0, startX),
|
|
child: const ColoredBox(color: Color(0x99000000)),
|
|
),
|
|
Positioned(
|
|
left: endX,
|
|
top: 0,
|
|
bottom: 0,
|
|
right: 0,
|
|
child: const ColoredBox(color: Color(0x99000000)),
|
|
),
|
|
Positioned(
|
|
left: startX,
|
|
right: math.max(0, width - endX),
|
|
top: 0,
|
|
bottom: 0,
|
|
child: IgnorePointer(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border.symmetric(
|
|
horizontal: BorderSide(color: accent, width: 2),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
ValueListenableBuilder<VideoPlayerValue>(
|
|
valueListenable: widget.controller,
|
|
child: const IgnorePointer(child: _Playhead()),
|
|
builder: (context, value, child) => Positioned(
|
|
left: (_fraction(value.position) * width - 1.5).clamp(
|
|
0.0,
|
|
math.max(0, width - 3),
|
|
),
|
|
top: 2,
|
|
bottom: 2,
|
|
width: 3,
|
|
child: child!,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: (startX - _handle).clamp(0.0, width),
|
|
top: 0,
|
|
bottom: 0,
|
|
width: _handle,
|
|
child: _Handle(color: accent, leading: true),
|
|
),
|
|
Positioned(
|
|
left: endX.clamp(0.0, math.max(0, width - _handle)),
|
|
top: 0,
|
|
bottom: 0,
|
|
width: _handle,
|
|
child: _Handle(color: accent, leading: false),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _filmstrip() {
|
|
if (widget.frames.isEmpty) {
|
|
return const ColoredBox(color: Color(0xFF1E1E1E));
|
|
}
|
|
return Row(
|
|
children: [
|
|
for (final frame in widget.frames)
|
|
Expanded(
|
|
child: frame == null
|
|
? const ColoredBox(color: Color(0xFF1E1E1E))
|
|
: Image.memory(
|
|
frame,
|
|
fit: BoxFit.cover,
|
|
height: double.infinity,
|
|
gaplessPlayback: true,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Playhead extends StatelessWidget {
|
|
const _Playhead();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(2),
|
|
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Handle extends StatelessWidget {
|
|
final Color color;
|
|
final bool leading;
|
|
|
|
const _Handle({required this.color, required this.leading});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final radius = leading
|
|
? const BorderRadius.horizontal(left: Radius.circular(8))
|
|
: const BorderRadius.horizontal(right: Radius.circular(8));
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(color: color, borderRadius: radius),
|
|
child: Center(
|
|
child: Container(
|
|
width: 2,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(1),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|