995 lines
28 KiB
Dart
995 lines
28 KiB
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:komet/core/media/gallery_source.dart';
|
|
import 'package:komet/core/media/video_transcoder.dart';
|
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
|
import 'package:komet/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;
|
|
|
|
VideoEditState get _edit => widget.edit;
|
|
|
|
@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);
|
|
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);
|
|
}
|
|
|
|
void _onTick() {
|
|
final controller = _controller;
|
|
if (controller == null || !controller.value.isInitialized) return;
|
|
if (_scrubbing) return;
|
|
final position = controller.value.position;
|
|
if (position >= _edit.end && _edit.end > Duration.zero) {
|
|
controller.seekTo(_edit.start);
|
|
if (!controller.value.isPlaying) unawaited(controller.play());
|
|
} else if (position < _edit.start - const Duration(milliseconds: 120)) {
|
|
controller.seekTo(_edit.start);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
void _togglePlay() {
|
|
final controller = _controller;
|
|
if (controller == null) return;
|
|
if (controller.value.isPlaying) {
|
|
controller.pause();
|
|
} else {
|
|
if (controller.value.position >= _edit.end) {
|
|
controller.seekTo(_edit.start);
|
|
}
|
|
controller.play();
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
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 {
|
|
_controller?.pause();
|
|
setState(() => _busy = true);
|
|
final frame = await _grabFrame();
|
|
ui.Image? flight;
|
|
if (frame != null) {
|
|
flight = await composeVideoStill(
|
|
frame,
|
|
_geometry,
|
|
_edit.adjust,
|
|
marks: withMarks ? _edit.marks : const [],
|
|
marksCanvas: withMarks ? _edit.marksCanvas : Size.zero,
|
|
);
|
|
}
|
|
if (!mounted) {
|
|
frame?.dispose();
|
|
flight?.dispose();
|
|
return null;
|
|
}
|
|
setState(() => _busy = false);
|
|
if (frame == null || flight == null) {
|
|
frame?.dispose();
|
|
flight?.dispose();
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!controller.value.isPlaying)
|
|
const IgnorePointer(child: Center(child: _PlayBadge())),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bottomBar() {
|
|
final controller = _controller;
|
|
return SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 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)
|
|
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 _onScrub(Duration position, bool active) {
|
|
_scrubbing = active;
|
|
final controller = _controller;
|
|
if (controller == null) return;
|
|
if (active && controller.value.isPlaying) controller.pause();
|
|
controller.seekTo(position);
|
|
}
|
|
|
|
void _onTrim(Duration start, Duration end, bool active) {
|
|
_scrubbing = active;
|
|
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;
|
|
|
|
int _target = -1;
|
|
|
|
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) {
|
|
final startX = _fraction(widget.start) * width;
|
|
final endX = _fraction(widget.end) * width;
|
|
final toStart = (pos.dx - startX).abs();
|
|
final toEnd = (pos.dx - endX).abs();
|
|
if (toStart <= toEnd && toStart < 28) {
|
|
_target = 0;
|
|
} else if (toEnd < 28) {
|
|
_target = 1;
|
|
} else {
|
|
_target = 2;
|
|
widget.onScrub(_clamp(_at(pos.dx / width)), true);
|
|
}
|
|
}
|
|
|
|
Duration _clamp(Duration value) {
|
|
if (value < widget.start) return widget.start;
|
|
if (widget.end > Duration.zero && value > widget.end) return widget.end;
|
|
return value;
|
|
}
|
|
|
|
void _move(Offset pos, double width) {
|
|
if (width <= 0) return;
|
|
final value = _at(pos.dx / width);
|
|
switch (_target) {
|
|
case 0:
|
|
final limit = widget.end - kMinTrimDuration;
|
|
widget.onTrim(
|
|
value > limit
|
|
? (limit > Duration.zero ? limit : Duration.zero)
|
|
: value,
|
|
widget.end,
|
|
true,
|
|
);
|
|
widget.onScrub(value, true);
|
|
case 1:
|
|
final limit = widget.start + kMinTrimDuration;
|
|
widget.onTrim(widget.start, value < limit ? limit : value, true);
|
|
widget.onScrub(value < limit ? limit : value, true);
|
|
case 2:
|
|
widget.onScrub(_clamp(value), true);
|
|
}
|
|
}
|
|
|
|
void _up() {
|
|
if (_target < 0) return;
|
|
if (_target != 2) widget.onTrim(widget.start, widget.end, false);
|
|
widget.onScrub(_clamp(widget.controller.value.position), false);
|
|
_target = -1;
|
|
}
|
|
|
|
@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(widget.start) * width;
|
|
final endX = _fraction(widget.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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|