flutter_video_editor_plugin 1.2.1
flutter_video_editor_plugin: ^1.2.1 copied to clipboard
Native timeline video editor for Flutter apps on iOS and Android. Presents the editor and returns the rendered MP4.
example/lib/main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_video_editor_plugin/flutter_video_editor_plugin.dart';
import 'package:gal/gal.dart';
import 'package:image_picker/image_picker.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(theme: ThemeData.dark(), home: const HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
enum _SaveState { idle, saving, saved, failed }
class _HomePageState extends State<HomePage> {
VideoEditorResult? _lastResult;
_SaveState _saved = _SaveState.idle;
String? _saveError;
bool _draftsEnabled = false;
bool _coverPickerEnabled = true;
bool _detachedRender = false;
// Render after closing: the running job, and the draft to reopen after a failure.
VideoEditorRenderJob? _job;
double _progress = 0;
bool _renderFailed = false;
String? _failedDraftId;
RenderAspectRatio _aspectRatio = RenderAspectRatio.ratio9x16;
int _bitrate = 7_000_000;
int _maxFrameRate = 30;
RenderVideoCodec _codec = RenderVideoCodec.h264;
List<VideoEditorDraft> _drafts = const [];
// Demo keys. Do not ship these.
static const _giphyApiKey = 'RLm06zs0imxFCeWYaRLILoFEoMNUSquN';
static const _licenseKey = 've_live_ge7y5gywjua57qj99sk6gaugs87c599g';
VideoEditorConfig get _config => VideoEditorConfig(
licenseKey: _licenseKey,
giphyApiKey: _giphyApiKey,
minDurationSeconds: 3,
maxDurationSeconds: 120,
draftsEnabled: _draftsEnabled,
coverPickerEnabled: _coverPickerEnabled,
detachedRenderEnabled: _detachedRender,
);
RenderOutputConfig get _output => RenderOutputConfig(
aspectRatio: _aspectRatio,
bitrate: _bitrate,
maxFrameRate: _maxFrameRate,
codec: _codec,
);
@override
void initState() {
super.initState();
_reloadDrafts();
}
Future<void> _reloadDrafts() async {
final drafts = await VideoEditor.drafts();
if (mounted) setState(() => _drafts = drafts);
}
Future<void> _openEditor({String? initialSource, String? draftId}) async {
final result = await VideoEditor.present(
initialSource: initialSource,
draftId: draftId,
config: _config,
output: _output,
onFailure: (failure) => debugPrint('[example] editor failed: $failure'),
onRenderStarted: _track,
);
if (!mounted) return;
await _reloadDrafts();
if (result == null || !mounted) return;
await _accept(result);
}
Future<void> _accept(VideoEditorResult result) async {
setState(() {
_lastResult = result;
_saved = _SaveState.saving;
_saveError = null;
});
await _saveToGallery(result);
}
void _track(VideoEditorRenderJob job) {
setState(() {
_job = job;
_progress = job.latestProgress;
_renderFailed = false;
});
job.progress.listen((value) {
if (mounted) setState(() => _progress = value);
});
job.outcome.then((outcome) async {
if (!mounted) return;
setState(() => _job = null);
await _reloadDrafts();
switch (outcome) {
case RenderCompleted(:final result):
await _accept(result);
case RenderFailed(:final failure):
debugPrint('[example] render failed: $failure');
setState(() {
_renderFailed = true;
_failedDraftId = job.draftId;
});
case RenderCancelled():
break;
}
});
}
Future<void> _saveToGallery(VideoEditorResult result) async {
try {
if (!await Gal.hasAccess() && !await Gal.requestAccess()) {
_saveFailed('access denied');
return;
}
await Gal.putVideo(result.videoPath);
if (mounted) setState(() => _saved = _SaveState.saved);
} on GalException catch (e) {
debugPrint('[example] save failed: $e');
_saveFailed(e.type.name);
}
}
void _saveFailed(String why) {
if (!mounted) return;
setState(() {
_saved = _SaveState.failed;
_saveError = why;
});
}
Future<void> _openEditorWithPicked() async {
final picked = await ImagePicker().pickVideo(source: ImageSource.gallery);
if (picked == null || !mounted) return;
await _openEditor(initialSource: picked.path);
}
Future<void> _openDraft(VideoEditorDraft draft) async {
setState(() => _draftsEnabled = true);
await _openEditor(draftId: draft.id);
}
Future<void> _deleteDraft(VideoEditorDraft draft) async {
await VideoEditor.deleteDraft(draft.id);
await _reloadDrafts();
}
String get _saveLabel => switch (_saved) {
_SaveState.idle => '',
_SaveState.saving => 'Saving to gallery…',
_SaveState.saved => 'Saved to gallery',
_SaveState.failed => 'Could not save to gallery${_saveError == null ? '' : ' — $_saveError'}',
};
@override
Widget build(BuildContext context) {
final result = _lastResult;
return Scaffold(
appBar: AppBar(title: const Text('Video Editor Demo')),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 24),
children: [
Center(
child: Column(
children: [
ElevatedButton(
onPressed: _openEditor,
child: const Text('Open Video Editor'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _openEditorWithPicked,
child: const Text('Pick first, then edit'),
),
],
),
),
SwitchListTile(
title: const Text('Drafts'),
subtitle: const Text('Leaving the editor saves a draft'),
value: _draftsEnabled,
onChanged: (value) => setState(() => _draftsEnabled = value),
),
if (_job != null) _RenderingTile(progress: _progress, onCancel: _job!.cancel),
if (_renderFailed)
ListTile(
leading: const Icon(Icons.error_outline, color: Colors.redAccent),
title: const Text('Render failed'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_failedDraftId != null)
TextButton(
onPressed: () {
final id = _failedDraftId!;
setState(() => _renderFailed = false);
_openEditor(draftId: id);
},
child: const Text('Open draft'),
),
TextButton(
onPressed: () => setState(() => _renderFailed = false),
child: const Text('Dismiss'),
),
],
),
),
SwitchListTile(
title: const Text('Render after closing'),
subtitle: const Text('The editor closes on Next; progress shows here'),
value: _detachedRender,
onChanged: (value) => setState(() => _detachedRender = value),
),
SwitchListTile(
title: const Text('Cover picker'),
subtitle: const Text('Choose a cover frame or photo before rendering'),
value: _coverPickerEnabled,
onChanged: (value) => setState(() => _coverPickerEnabled = value),
),
_ChoiceTile<RenderAspectRatio>(
title: 'Aspect ratio',
value: _aspectRatio,
options: {for (final ratio in RenderAspectRatio.values) ratio: ratio.wireName},
onChanged: (value) => setState(() => _aspectRatio = value),
),
_ChoiceTile<int>(
title: 'Bitrate cap',
value: _bitrate,
options: {
for (final mbps in const [3, 5, 7, 10, 15]) mbps * 1_000_000: '$mbps Mbps',
},
onChanged: (value) => setState(() => _bitrate = value),
),
_ChoiceTile<int>(
title: 'Frame rate cap',
value: _maxFrameRate,
options: {for (final fps in const [24, 30, 60]) fps: '$fps fps'},
onChanged: (value) => setState(() => _maxFrameRate = value),
),
_ChoiceTile<RenderVideoCodec>(
title: 'Codec',
value: _codec,
options: const {RenderVideoCodec.h264: 'H.264', RenderVideoCodec.hevc: 'HEVC'},
onChanged: (value) => setState(() => _codec = value),
),
if (result != null) _ResultSection(result: result, saveLabel: _saveLabel, saved: _saved),
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
'Drafts (${_drafts.length})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
if (_drafts.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('No drafts', style: TextStyle(fontSize: 12)),
),
for (final draft in _drafts)
_DraftTile(
draft: draft,
onOpen: () => _openDraft(draft),
onDelete: () => _deleteDraft(draft),
),
],
),
);
}
}
class _RenderingTile extends StatelessWidget {
const _RenderingTile({required this.progress, required this.onCancel});
final double progress;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text('Rendering… ${(progress * 100).round()}%'),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: LinearProgressIndicator(value: progress),
),
trailing: TextButton(onPressed: onCancel, child: const Text('Cancel')),
);
}
}
class _ChoiceTile<T> extends StatelessWidget {
const _ChoiceTile({
required this.title,
required this.value,
required this.options,
required this.onChanged,
});
final String title;
final T value;
final Map<T, String> options;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
trailing: DropdownButton<T>(
value: value,
items: [
for (final entry in options.entries)
DropdownMenuItem(value: entry.key, child: Text(entry.value)),
],
onChanged: (value) {
if (value != null) onChanged(value);
},
),
);
}
}
class _DraftTile extends StatelessWidget {
const _DraftTile({required this.draft, required this.onOpen, required this.onDelete});
final VideoEditorDraft draft;
final VoidCallback onOpen;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final hasCover = draft.coverPath.isNotEmpty && File(draft.coverPath).existsSync();
return ListTile(
onTap: onOpen,
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 36,
height: 64,
child: hasCover
? Image.file(File(draft.coverPath), fit: BoxFit.cover)
: const ColoredBox(color: Colors.white12),
),
),
title: Text(
'${draft.clipCount} clip${draft.clipCount == 1 ? '' : 's'} · '
'${draft.durationSec.toStringAsFixed(1)}s',
),
subtitle: Text(
'Updated ${draft.updatedAt.toLocal().toString().substring(0, 16)}',
style: const TextStyle(fontSize: 12),
),
trailing: IconButton(icon: const Icon(Icons.delete_outline), onPressed: onDelete),
);
}
}
class _ResultSection extends StatelessWidget {
const _ResultSection({required this.result, required this.saveLabel, required this.saved});
final VideoEditorResult result;
final String saveLabel;
final _SaveState saved;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Last Result:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
if (result.coverPath.isNotEmpty && File(result.coverPath).existsSync())
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(File(result.coverPath), height: 200),
),
const SizedBox(height: 8),
Text('Video: ${result.videoPath}', style: const TextStyle(fontSize: 12)),
const SizedBox(height: 4),
Text('Cover: ${result.coverPath}', style: const TextStyle(fontSize: 12)),
const SizedBox(height: 4),
Text(
'Duration: ${result.durationSec.toStringAsFixed(1)}s',
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 4),
Text('Audio: ${result.audioPaths.isEmpty ? '-' : result.audioPaths.join(', ')}', style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text(
saveLabel,
style: TextStyle(
fontSize: 12,
color: saved == _SaveState.failed ? Colors.redAccent : null,
),
),
],
),
);
}
}