Skip to main content

flutter_video_editor_plugin 1.0.1 copy "flutter_video_editor_plugin: ^1.0.1" to clipboard
flutter_video_editor_plugin: ^1.0.1 copied to clipboard

Timeline-based multi-clip video editor: trim, split, stickers, text, color filters, transitions and audio. Preview and export share one native composition.

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;
  List<VideoEditorDraft> _drafts = const [];

  // dart-define이 iOS 26 beta에서 EXC_BAD_ACCESS 유발.
  // 테스트용 하드코딩 (프로덕션 사용 금지).
  static const _giphyApiKey = 'RLm06zs0imxFCeWYaRLILoFEoMNUSquN';
  /// 예제 앱의 라이선스 키. `server/cli` 로 발급해 붙인다 — 비어 있으면 설정을 만들 때 거부한다.
  static const _licenseKey = 've_live_ge7y5gywjua57qj99sk6gaugs87c599g';

  VideoEditorConfig get _config => VideoEditorConfig(
    // 라이선스 키 — 서버(`license.infinive.dev`)에 이 앱(iOS 번들 ID · Android 패키지명)이 매여 있어야
    // 열린다 (docs/license-spec.md). 서명 인증서는 보지 않는다. 이 키는
    // `show.infini.ve.flutterexample` 에 매여 있다.
    licenseKey: _licenseKey,
    giphyApiKey: _giphyApiKey,
    // 내보낼 수 있는 길이. 편집은 안 막고 "다음" 을 누른 순간에만 본다.
    minDurationSeconds: 3,
    maxDurationSeconds: 120,
    draftsEnabled: _draftsEnabled,
  );

  @override
  void initState() {
    super.initState();
    _reloadDrafts();
  }

  /// 목록·삭제는 `draftsEnabled` 와 무관하게 언제나 된다 — 꺼 둔 채로도 남은 드래프트가 보인다.
  Future<void> _reloadDrafts() async {
    final drafts = await VideoEditor.drafts();
    if (mounted) setState(() => _drafts = drafts);
  }

  /// 편집기가 스스로 갤러리를 여는 기본 흐름. 화면은 네이티브가 띄우고, 결과는
  /// future 로 온다 — 라우트를 밀어 올리는 것이 아니다.
  Future<void> _openEditor({String? initialSource, String? draftId}) async {
    final result = await VideoEditor.present(
      initialSource: initialSource,
      draftId: draftId,
      config: _config,
      // 렌더 실패 · 소스 못 열음 · 커버 없음 · 드래프트 못 열음. 화면은 그대로 남고 편집기가
      // 스스로 안내하므로 호스트는 기록만 한다 (네이티브 샘플과 같다).
      onFailure: (failure) => debugPrint('[example] editor failed: $failure'),
    );
    if (!mounted) return;
    // 취소는 null 이다 — 실패가 아니다. 드래프트가 켜져 있으면 나간 것이 곧 저장이라 목록이
    // 달라졌을 수 있고, 렌더가 끝났으면 드래프트가 사라졌다 — 어느 쪽이든 다시 읽는다.
    await _reloadDrafts();
    if (result == null || !mounted) return;
    setState(() {
      _lastResult = result;
      _saved = _SaveState.saving;
      _saveError = null;
    });
    // 결과 파일은 편집기의 캐시에 있다. 어디에 둘지는 호스트의 일이라 이 예제는
    // 갤러리에 넣는다 (iOS 사진 앱 · Android MediaStore — 두 네이티브 샘플과 짝).
    await _saveToGallery(result);
  }

  /// **추가 권한만** 청한다 — 읽을 일이 없다. 거절하면 결과는 그대로 보여 주고 저장만 건너뛴다.
  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;
    });
  }

  /// 호스트가 먼저 고르고 그 영상으로 편집기를 연다 — `initialSource` 시연.
  ///
  /// 촬영 직후나 자기 앨범 화면에서 넘어오는 흐름이면 편집기가 갤러리를 또 여는
  /// 것이 군더더기다.
  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 (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 _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),
                width: 120,
                height: 200,
                fit: BoxFit.cover,
              ),
            ),
          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: 8),
          Text(
            saveLabel,
            style: TextStyle(
              fontSize: 12,
              color: saved == _SaveState.failed ? Colors.redAccent : null,
            ),
          ),
        ],
      ),
    );
  }
}
8
likes
0
points
1.66k
downloads

Documentation

Documentation

Publisher

verified publisherinfinive.dev

Weekly Downloads

Timeline-based multi-clip video editor: trim, split, stickers, text, color filters, transitions and audio. Preview and export share one native composition.

Homepage

License

unknown (license)

Dependencies

flutter

More

Packages that depend on flutter_video_editor_plugin

Packages that implement flutter_video_editor_plugin