Skip to main content

video_trimmer 6.0.0 copy "video_trimmer: ^6.0.0" to clipboard
video_trimmer: ^6.0.0 copied to clipboard

Trim videos in Flutter on Android, iOS, macOS, and the web, with a customizable trim viewer, playback controls, and video or GIF output.

Awesome Flutter Pub Version GitHub stars GitHub license

Video Trimmer

A Flutter package for trimming videos

Features #

  • Trim videos natively on Android, iOS, and macOS, and in the browser on the web, with no FFmpeg.
  • Two trim viewers: a fixed one that shows the whole video, and a scrollable one for long videos, with smooth auto-scroll and a draggable timeline.
  • Limit the selection with maxVideoLength and minVideoLength, or set them equal for a fixed-length selection.
  • Restore a saved selection with initialStartValue / initialEndValue.
  • Playback confined to the selection, which honors looping and playback speed and keeps the scrubber in sync with the player.
  • Save the result as a video or a GIF.
  • Line or dot drag handles, with the line height in pixels or as a percentage of the strip.
  • Highly customizable: the trim frame, handles, thumbnails, durations, and scrolling behavior.

Upgrading from 5.x? 6.0.0 has a few breaking changes. See Migrating to 6.0.0.

The TrimViewer consists of the Duration row on top (start, scrubber and end time), the TrimArea of thumbnails, and the TrimEditor overlay you drag to select a portion of the video.

Parts of the TrimViewer: Duration, TrimEditor, handles, scrubber and TrimArea

Example #

The example app on an iPhone 18 Pro simulator. It is a playground for every option the package offers.

Trimming a video: playback, auto-scroll and looping

Installation #

Add video_trimmer to your pubspec.yaml:

dependencies:
  video_trimmer: ^6.0.0

Android: no setup needed.

iOS and macOS: no setup needed. The package works with both CocoaPods and Swift Package Manager, and the two platforms share the same AVFoundation code. It needs no Info.plist keys of its own. If you let users pick videos from their library with a plugin such as file_picker or image_picker, add the usage description that plugin asks for (usually NSPhotoLibraryUsageDescription).

macOS: a sandboxed app can only read files the user picks if it has the com.apple.security.files.user-selected.read-only entitlement (read-write if you also save next to the source). Add it to macos/Runner/DebugProfile.entitlements and Release.entitlements. Trimmed videos are saved inside the app container by default.

Web: no setup needed. Trimming runs in the browser with Mediabunny on top of WebCodecs, so it needs a browser with WebCodecs (current Chrome, Edge, and Safari, and Firefox 130 or later). Mediabunny is bundled into web builds only; Android, iOS, and macOS apps don't carry it. A few things differ from the other platforms:

  • loadVideo takes a blob: URL. A picker's XFile.path is one, and XFile.fromData(bytes).path turns bytes into one.
  • onSave receives a blob: URL instead of a file path, since there is no file system. Play it with VideoPlayerController.networkUrl(Uri.parse(url)), read it with XFile(url).readAsBytes(), or offer it as a download.
  • Output is always MP4 (or GIF), and storageDir and videoFolderName are ignored.
  • If your site sets a Content Security Policy, allow blob: in script-src: the bundled Mediabunny module is loaded from a blob: URL.

Usage #

Load a video #

Create one Trimmer per screen, load a video into it, and dispose of it when the screen goes away. The viewer widgets don't dispose of it for you.

loadVideo takes the video's path: a file path on Android, iOS, and macOS, and a blob: URL on the web. A picker's XFile.path is exactly that on every platform, so loadVideo(videoPath: picked.xFile.path) works everywhere.

final Trimmer _trimmer = Trimmer();

@override
void initState() {
  super.initState();
  _trimmer.loadVideo(videoPath: widget.videoPath);
}

@override
void dispose() {
  _trimmer.dispose();
  super.dispose();
}

Show the video #

VideoViewer(trimmer: _trimmer)

Show the trimmer #

TrimViewer(
  trimmer: _trimmer,
  viewerHeight: 50.0,
  viewerWidth: MediaQuery.of(context).size.width,
  maxVideoLength: const Duration(seconds: 10),
  onChangeStart: (value) => _startValue = value,
  onChangeEnd: (value) => _endValue = value,
  onChangePlaybackState: (value) => setState(() => _isPlaying = value),
)

onChangeStart and onChangeEnd report milliseconds. onChangePlaybackState fires when playback starts or stops. type picks the viewer: ViewerType.auto (the default) uses the scrollable viewer when the video is longer than the trim area, and the fixed one otherwise.

For portrait videos, areaProperties: TrimAreaProperties(thumbnailFit: BoxFit.cover) fills the thumbnail strip without gaps.

Play the selection #

videoPlaybackControl toggles playback and returns true while playing. Playback starts from the trim start when the playhead is outside the selection, and stops at the trim end.

final playing = await _trimmer.videoPlaybackControl(
  startValue: _startValue,
  endValue: _endValue,
);

Looping and speed are set on the underlying player and are respected inside the selection:

_trimmer.videoPlayerController?.setLooping(true);     // wraps from end to start
_trimmer.videoPlayerController?.setPlaybackSpeed(2.0); // the scrubber keeps up

Save the trimmed video #

saveTrimmedVideo writes the selection to disk and passes the output path to onSave (a blob: URL on the web), or null if it failed.

await _trimmer.saveTrimmedVideo(
  startValue: _startValue,
  endValue: _endValue,
  onSave: (outputPath) => debugPrint('Saved to $outputPath'),
);

Optional parameters:

  • outputType: OutputType.gif saves a GIF instead, tuned with fpsGIF, scaleGIF and qualityGIF.
  • storageDir picks the directory (StorageDir.temporaryDirectory, applicationDocumentsDirectory, or Android-only externalStorageDirectory).
  • videoFolderName and videoFileName name the output. The default name is <source>_trimmed_<yyyyMMdd_HHmmss>.

Limit the selection length #

maxVideoLength caps how long the selection can be, and minVideoLength sets how short it can get. Set them equal for a fixed-length selection that can only be moved.

TrimViewer(
  trimmer: _trimmer,
  maxVideoLength: const Duration(seconds: 30),
  minVideoLength: const Duration(seconds: 3),
)

Restore a saved selection #

Pass back the values that onChangeStart / onChangeEnd reported (in milliseconds) to open the trimmer on that range again. The scrollable viewer scrolls it into view, and the preview starts on its first frame. Either value can be given alone: the other is derived from maxVideoLength. So initialEndValue: 3000 opens with the first 3 seconds selected, even when maxVideoLength is longer.

TrimViewer(
  trimmer: _trimmer,
  maxVideoLength: const Duration(seconds: 30),
  initialStartValue: saved.startMs, // e.g. 4000.0
  initialEndValue: saved.endMs,     // e.g. 9500.0
  onChangeStart: (value) => saved.startMs = value,
  onChangeEnd: (value) => saved.endMs = value,
)

Customize #

TrimViewer(
  trimmer: _trimmer,
  durationStyle: DurationStyle.FORMAT_MM_SS,
  editorProperties: TrimEditorProperties(
    borderPaintColor: Colors.yellow,
    borderWidth: 4,
    borderRadius: 5,
    circlePaintColor: Colors.white,           // handle color
    handleStyle: TrimHandleStyle.line,        // or TrimHandleStyle.circle
    handleHeight: TrimHandleHeight.percent(50), // or TrimHandleHeight.pixels(24)
  ),
  areaProperties: TrimAreaProperties.edgeBlur(
    thumbnailQuality: 10,
    // Scrollable viewer only:
    autoScrollEdgeZone: 48,       // px from each edge where auto-scroll kicks in
    maxAutoScrollVelocity: 1200,  // px per second at the very edge
    allowManualScroll: true,      // drag the strip itself
  ),
)

The example app exposes every option on a settings screen, which is the quickest way to try them.

Migrating to 6.0.0 #

  • Dispose the Trimmer yourself. VideoViewer and TrimViewer no longer dispose it or its VideoPlayerController. Call _trimmer.dispose() from the State that created it.
  • loadVideo takes a path instead of a dart:io File, so the same code works on the web: change loadVideo(videoFile: file) to loadVideo(videoPath: file.path). On the web, pass a blob: URL, such as a picker's XFile.path. Trimmer.currentVideoFile is now currentVideoPath.
  • loadVideo throws a FileSystemException when the file doesn't exist (on native platforms), instead of doing nothing.
  • Default output names changed from <name>_trimmed:<localized date> to <name>_trimmed_<yyyyMMdd_HHmmss>, which every platform and transcoder accepts.
  • DurationStyle keeps the largest unit. For example, FORMAT_MM_SS renders 1h30m15s as 90:15 (it used to be 30:15).
  • onChangePlaybackState fires on changes only, not on every position update.
  • Handles are lines by default. Set handleStyle: TrimHandleStyle.circle in TrimEditorProperties to keep the dots.
  • StorageDir is a real enum. Usage is unchanged, but it can no longer be subclassed.
  • video_trimmer is now a plugin. It generates thumbnails and trims with its own native code instead of get_thumbnail_video and flutter_native_video_trimmer, which fixes the iOS launch crash with video_thumbnail installed. Run flutter clean and pod install (iOS and macOS) after upgrading if your build complains.
  • Android minSdk is 24, which video_player already requires.
  • Trimmed videos are .mp4 on Android, and on iOS and macOS unless the source is .mov or .m4v. The extension used to be copied from the source.

See the changelog for the full list.

Full example #

A minimal app that picks a video, trims it, and saves it, on any platform including the web. Add video_trimmer and file_picker to your pubspec.yaml, then replace lib/main.dart with:

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:video_trimmer/video_trimmer.dart';

void main() => runApp(const MaterialApp(home: HomePage()));

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Video Trimmer')),
      body: Center(
        child: ElevatedButton(
          child: const Text('LOAD VIDEO'),
          onPressed: () async {
            final picked = await FilePicker.pickFile(type: FileType.video);
            if (picked == null || !context.mounted) return;
            Navigator.of(context).push(MaterialPageRoute(
              // A file path natively, a blob: URL on the web.
              builder: (_) => TrimmerView(picked.xFile.path),
            ));
          },
        ),
      ),
    );
  }
}

class TrimmerView extends StatefulWidget {
  const TrimmerView(this.videoPath, {super.key});

  final String videoPath;

  @override
  State<TrimmerView> createState() => _TrimmerViewState();
}

class _TrimmerViewState extends State<TrimmerView> {
  final Trimmer _trimmer = Trimmer();

  double _startValue = 0.0;
  double _endValue = 0.0;
  bool _isPlaying = false;
  bool _saving = false;

  @override
  void initState() {
    super.initState();
    _trimmer.loadVideo(videoPath: widget.videoPath);
  }

  @override
  void dispose() {
    _trimmer.dispose();
    super.dispose();
  }

  Future<void> _save() async {
    setState(() => _saving = true);
    await _trimmer.saveTrimmedVideo(
      startValue: _startValue,
      endValue: _endValue,
      onSave: (outputPath) {
        if (!mounted) return;
        setState(() => _saving = false);
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(
          content: Text(outputPath == null ? 'Saving failed' : 'Saved to $outputPath'),
        ));
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        title: const Text('Video Trimmer'),
        actions: [
          IconButton(
            icon: const Icon(Icons.save),
            onPressed: _saving ? null : _save,
          ),
        ],
      ),
      body: Column(
        children: [
          if (_saving) const LinearProgressIndicator(),
          Expanded(child: VideoViewer(trimmer: _trimmer)),
          TrimViewer(
            trimmer: _trimmer,
            viewerHeight: 50.0,
            viewerWidth: MediaQuery.of(context).size.width,
            maxVideoLength: const Duration(seconds: 10),
            onChangeStart: (value) => _startValue = value,
            onChangeEnd: (value) => _endValue = value,
            onChangePlaybackState: (playing) =>
                setState(() => _isPlaying = playing),
          ),
          IconButton(
            iconSize: 64,
            color: Colors.white,
            icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
            onPressed: () async {
              final playing = await _trimmer.videoPlaybackControl(
                startValue: _startValue,
                endValue: _endValue,
              );
              setState(() => _isPlaying = playing);
            },
          ),
        ],
      ),
    );
  }
}

License #

Copyright (c) 2026 Souvik Biswas

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Third-party code #

Web builds include Mediabunny (version in lib/src/web/mediabunny_source.dart), © Vanilagy and contributors, under the Mozilla Public License 2.0. It is embedded unmodified, with its license header, and its source code is available from the linked repository.

505
likes
150
points
12.8k
downloads

Documentation

API reference

Publisher

verified publishersouvikbiswas.com

Weekly Downloads

Trim videos in Flutter on Android, iOS, macOS, and the web, with a customizable trim viewer, playback controls, and video or GIF output.

Repository (GitHub)
View/report issues

Topics

#video #trimmer #video-editing #media

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, image, path, path_provider, video_player, web

More

Packages that depend on video_trimmer

Packages that implement video_trimmer