Files
Qlyra/lib/frontend/widgets/attachment/video_preview_screen.dart
T

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),
),
),
),
);
}
}