Flutter Background Video Recorder

A Flutter plugin for Android that records video or audio from a foreground service, so recording continues while the app is in the background, closed, or the screen is off or locked.

  • Android 12 (API 31) up to Android 17 (API 37)
  • Video with the front or rear camera. Pick the size, aspect ratio and frame rate from what the device really supports (default 1080p at 60 fps), and the codec (H.264, or HEVC for smaller files)
  • Audio only recordings that use only the microphone
  • Live preview in your app, before and during a recording. Hiding it is just removing a widget
  • Choose the camera (wide, ultra-wide, telephoto, ...) and zoom, also while recording
  • Raw streams for apps that process the data themselves: camera frames (YUV, JPEG or RGBA) and microphone samples (PCM), each readable by any number of listeners, in the background
  • Optional location stored in the video's metadata
  • Recordings are saved with MediaStore (videos in Movies/<folder>, audio in Recordings/<folder>) and show up in the gallery and audio apps
  • A notification (required by Android) is shown while recording, with a Stop button
  • If something goes wrong mid-recording (camera taken, disk full, device overheating) what was recorded is saved and the user is told
  • Not available on iOS

Usage

import 'package:flutter_background_video_recorder/flutter_background_video_recorder.dart';

final recorder = FlutterBackgroundVideoRecorder();

// Follow the state of the recorder.
recorder.recorderState.listen((status) => print(status)); // initializing, recording, stopped, error
recorder.recorderErrors.listen((error) => print(error));  // e.g. the camera was taken by another app
recorder.recorderWarnings.listen((warning) => print(warning)); // e.g. the device is getting hot

// Starts recording. Asks for permissions first if they are missing.
final result = await recorder.startVideoRecording(
  folderName: 'My Videos',
  cameraFacing: CameraFacing.rearCamera,
  notificationTitle: 'Recording video',
  notificationText: 'Tap to return to the app',
);
switch (result) {
  case StartRecordingResult.started:
    break;
  case StartRecordingResult.permissionDenied:
    // The user said no. Explain why you need it; starting again asks again.
    break;
  case StartRecordingResult.permissionPermanentlyDenied:
    // Android will not show the dialog any more. Explain, then send the user to the settings.
    await recorder.openAppSettings();
}

// Stops recording and returns what was saved.
final media = await recorder.stopRecording();
print(media.uri);  // content://media/external/video/media/... (use this one)
print(media.path); // /storage/emulated/0/Movies/My Videos/... (best effort, may be null)

startVideoRecording and stopRecording throw a RecorderException with a RecorderErrorCode when they fail, for example alreadyRecording or notRecording. When the app starts, call getRecordingStatus() to find out whether a recording from an earlier session is still running.

See the example for a complete app.

Size, frame rate and codec

Ask the device what it can do, then choose from that:

final capabilities = await recorder.getVideoCapabilities(CameraFacing.rearCamera);

capabilities.byAspectRatio;        // {'16:9': [1920x1080, 1280x720, ...], '4:3': [...]}
capabilities.modes.first.frameRates; // [24, 30, 60]
capabilities.defaultResolution;    // what a recording uses if you set nothing, e.g. 1920x1080
capabilities.defaultFrameRate;     // e.g. 60
capabilities.modes.first.resolution.portraitAspectRatioLabel; // '9:16', for a '16:9' size
capabilities.hevc.supported;       // can this device encode HEVC?
capabilities.hevc.hardwareAccelerated; // and is it cheap on battery and heat?

await recorder.startVideoRecording(
  folderName: 'My Videos',
  cameraFacing: CameraFacing.rearCamera,
  notificationTitle: 'Recording video',
  notificationText: 'Tap to return to the app',
  settings: const VideoSettings(
    resolution: VideoResolution(1920, 1080),
    frameRate: 30,
    codec: VideoCodec.hevc,
  ),
);
  • With no settings the recorder uses 1080p at 60 fps, falling back to 1080p at 30 fps and then the best size up to 1080p when the camera cannot do that. defaultResolution and defaultFrameRate tell you which of these it will be on this camera, so a settings screen can show real values instead of a vague "default".
  • Videos are stored landscape and rotated on playback, so a 16:9 size is a 9:16 video when the phone is held upright; show both if that helps your users.
  • Anything you set must be supported, otherwise starting throws RecorderErrorCode.unsupportedConfiguration with the reason. A recording is never silently different from what you asked for.
  • VideoCodec.h264 (default) plays everywhere. VideoCodec.hevc makes files roughly 40% smaller but some players and upload services handle it poorly. VideoCodec.auto uses HEVC only when the device has a hardware encoder.
  • The bitrate suits the device, size, frame rate and codec; set VideoSettings.bitrate to override.
  • Frame rates above 60 (slow motion) are not supported.

Audio only

await recorder.startAudioRecording(
  folderName: 'My Notes',
  notificationTitle: 'Recording audio',
  notificationText: 'Tap to return to the app',
);
final audio = await recorder.stopRecording(); // audio.kind == RecordedMediaKind.audio

It needs only the microphone permission (not the camera one), the camera is never opened, and Android shows only the microphone privacy indicator. Audio is saved as AAC (.m4a, 48 kHz, 128 kbps) in Recordings/<folder>.

Location in the video

settings: const VideoSettings(embedLocation: true)

The position when recording started is stored in the video's metadata (RecordedMedia.hasLocation tells whether that worked). It is opt-in on purpose: the plugin declares no location permission, so apps that never use this feature do not ship with one. To use it, add this to your app's AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

Without it, starting throws RecorderErrorCode.locationPermissionNotDeclared. The permission is asked for together with the others and is optional: if the user denies it, or no position is found within a few seconds, the video is recorded without one.

Preview

// Before recording it is a viewfinder; during a recording it shows the live picture.
RecorderPreview(
  cameraFacing: CameraFacing.rearCamera,
  resolution: selectedResolution, // so it has the same shape as the video
)

To keep the picture available while recording, start with VideoSettings(preview: true).

Hiding the preview is removing the RecorderPreview widget, for example if (showPreview) RecorderPreview(...). The recording is not affected, and adding the widget again brings the live picture back. When nothing is being recorded, removing it also releases the camera.

Things to know:

  • Android fixes the camera's outputs when a recording starts, so a preview can only be shown during a recording that was started with preview: true. Without it, RecorderPreview shows its placeholder during the recording.
  • A preview during recording costs a little GPU work. On a device that cannot deliver a second stream next to the recording, the recording goes ahead without a preview and RecorderWarningCode.previewUnavailable is reported.
  • The widget sizes itself to the picture's aspect ratio (upright, whichever way the device is held), and mirrors the front camera like a mirror unless mirrorFront: false. The recorded video is never mirrored.
  • The viewfinder asks for the camera permission if it is missing. When the app goes to the background it stops, and it starts again on return.

Choosing a camera and zoom

final cameras = await recorder.listCameras(); // wide, ultra-wide, telephoto, front, ...
final telephoto = cameras.firstWhere((c) => c.lensType == CameraLensType.telephoto);

await recorder.startVideoRecording(
  folderName: 'Clips',
  cameraFacing: CameraFacing.rearCamera, // used when no cameraId is given
  cameraId: telephoto.id,
  notificationTitle: 'Recording',
  notificationText: 'Tap to return',
  settings: const VideoSettings(initialZoom: 2),
);

await recorder.setZoom(3.5); // also while recording; returns the ratio that was applied

cameraId also works with getVideoCapabilities, openFrameSession and RecorderPreview, so the sizes and frame rates you offer are those of the camera you use. CameraInfo.zoomRange is what the camera supports (for example 0.6 to 10). lensType is a guess from the focal length and focus distance; use focalLengthEquivalentMm and minimumFocusDistance if you need to be sure.

Zoom changes what the camera delivers, so the recording, the preview and the raw frames all zoom together. setZoom throws RecorderErrorCode.cameraNotActive when no camera is open.

Raw frames and audio

If you want the camera pictures or the sound yourself (to analyse, stream or encode them) open a session. It is independent of recording: nothing is saved, and the recorder is not involved.

final session = await recorder.openFrameSession(
  cameraFacing: CameraFacing.rearCamera,
  notificationTitle: 'Camera is on',
  notificationText: 'Tap to return',
  settings: const RawFrameSettings(format: RawFrameFormat.yuv420, maxFrameRate: 15),
);

session.frames.listen((frame) {
  // frame.planes, frame.width, frame.height, frame.rotationDegrees, frame.timestampNs
  // Read the pixels here, in place. They are native memory that is reused, see below.
});
session.frames.listen(sendToTheCloud); // any number of listeners can read the same session

await session.close();
final audio = await recorder.openAudioStream(
  notificationTitle: 'Microphone is on',
  notificationText: 'Tap to return',
  settings: const RawAudioSettings(sampleRate: 44100, channels: 1),
);
audio.chunks.listen((chunk) => process(chunk.bytes)); // little-endian PCM
await audio.close();
  • Formats: yuv420 (the camera's own format, the cheapest; the planes may have row padding, see RawFramePlane.bytesPerRow), jpeg (one image per frame) and rgba (four bytes a pixel). Frames are not rotated; turn them by rotationDegrees to make them upright.
  • Frames are shared memory, not copies. Large frames (a 1080p YUV frame is about 3 MB) are written once into native memory and read by your app in place through dart:ffi, so nothing is serialized or copied per frame. The price is that a frame is valid only while your listener runs: afterwards its memory is reused for a later picture, and reading frame.planes then throws a StateError. To work on a frame later (after an await, or in another isolate) call frame.retain() inside the listener and frame.release() when done, or take a copy with frame.toOwned(). A few frames are in flight at a time: if you hold on to them, new pictures are dropped (see RawFrame.droppedFrames), so release promptly.
  • Both sessions run from a foreground service with a notification and keep going when the app is in the background. Like recording, open them while the app is visible. They end when the Flutter engine goes away.
  • While nobody listens to a session, the camera or microphone stays on but no frames or sound are prepared, so it costs next to nothing. If the app reads too slowly, frames are dropped (and audio is dropped with a RecorderWarningCode.audioOverrun warning) instead of piling up. Audio chunks are small (about 88 KB a second) and are delivered as ordinary copies.
  • The camera and the microphone cannot be shared with recording: opening a frame session while recording a video or showing a preview, or the other way around, fails with RecorderErrorCode.cameraBusy; likewise microphoneBusy for the microphone. A frame session and an audio stream can run together.
  • An error such as a lost camera arrives as a RecorderException on the stream, after which the session is over.
  • In development, stop and restart the app rather than hot restarting it. The session keeps running natively across a hot restart, so opening it again fails with cameraBusy or microphoneBusy until the app is stopped (see "Things to know").

The example app's Streams mode shows all of this: subscribe to either stream, add and remove listeners, and watch the frames and the audio level arrive.

Setup

Nothing to add to your AndroidManifest.xml (except for location, if you use it): the permissions and the services are merged in from the plugin. You need minSdk 31 or higher and Flutter 3.29 or newer.

The plugin includes a small native library (for the raw frame stream) that is compiled when your app is built, so the Android NDK and CMake must be installed. Android Studio installs them with the SDK, and Gradle downloads them on its own when it is allowed to.

Permission Why
CAMERA, RECORD_AUDIO Required for video. Audio-only needs just RECORD_AUDIO. Requested when recording starts.
POST_NOTIFICATIONS Android 13+. Requested with the others. Recording still works if it is denied, but the notification (and its Stop button) is hidden.
FOREGROUND_SERVICE, FOREGROUND_SERVICE_CAMERA, FOREGROUND_SERVICE_MICROPHONE Install-time permissions for the recording service.
ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION Only if you use embedLocation, and then declared by you.

Blocked permissions

Android stops showing the permission dialog once the user has denied it twice. From then on startVideoRecording returns StartRecordingResult.permissionPermanentlyDenied immediately, and the only way forward is the system settings. The plugin does not show any UI of its own: explain to the user why the camera and microphone are needed, then call openAppSettings() to open your app's settings page. Permissions are checked again on every startVideoRecording, so recording works as soon as the user comes back with them granted. The example app shows a dialog that does this.

If the camera is lost while recording

Another app can take the camera (for example when the user opens the camera app), and it can be disconnected or fail. The recording then ends and the video recorded up to that point is saved.

  • Android shows a "Recording stopped" notification explaining what happened, so the user knows even if your app is closed. Tapping it opens your app. It is only shown if notifications are allowed.
  • While your app is alive, recorderState emits RecorderStatus.error and recorderErrors emits a RecorderException (cameraDisconnected or cameraError) whose savedMedia is the saved video.
  • You can start a new recording afterwards.

The notification text can be changed by overriding the fbvr_alert_* string resources in your app.

Things to know

  • Start recording while your app is visible. Android 11+ does not let an app that is in the background open the camera or microphone, and Android 12+ does not let it start a foreground service. Once recording has started, it keeps going in the background. Starting from the background is refused by Android and surfaces as a RecorderException (typically RecorderErrorCode.foregroundServiceNotAllowed).
  • Recording is owned by the service, not the Activity. It survives the app being swiped away as long as Android keeps the process alive, which it does while the foreground service runs.
  • Android shows its camera/microphone privacy indicators while recording. This cannot be disabled.
  • While developing, stop the app and run it again; do not rely on hot restart if you use a raw frame session or audio stream. A session runs in a foreground service that outlives your Dart code, so a hot restart throws away the RawFrameSession / RawAudioSession you held but not the session itself. The camera or microphone stays taken, opening a new session fails with RecorderErrorCode.cameraBusy / microphoneBusy (the error message says so), and frames the old code held stay reserved. Stopping the app ends the service and clears all of it. Hot reload is not affected.
  • Only one recording at a time is supported. A raw frame session or audio stream takes the camera or microphone away from recording (see "Raw frames and audio").
  • Recording while the phone is in a pocket or the screen is off works, but Android limits what a background app may start: begin the recording while your app is visible.
  • If the device runs low on storage the recording stops cleanly with what was recorded so far (RecorderErrorCode.storageFull); if it overheats badly it does the same (RecorderErrorCode.thermalShutdown), after a thermalWarning first.

Migrating from 1.x

1.x 2.0
<service android:name="services.VideoRecorderService" .../> in your manifest Remove it. The plugin declares its own service.
import '.../flutter_bvr.dart' and '.../flutter_bvr_platform_interface.dart' import 'package:flutter_background_video_recorder/flutter_background_video_recorder.dart'
startVideoRecording(...) -> Future<bool?> (false meant "asking for permissions") Returns StartRecordingResult after the permission dialogs are answered
stopVideoRecording() -> Future<String?> (file path) stopRecording() returns RecordedMedia (uri, optional path); stopVideoRecording() still works as an alias
recorderState and getVideoRecordingStatus() returned int RecorderStatus enum, getRecordingStatus()
showToast Removed. Toasts do not work from the background on modern Android; react to recorderState instead.
Failures were reported as PlatformExceptions with numeric codes RecorderException with a RecorderErrorCode
Supported Android 9 and up, Flutter 2.5 and up Requires Android 12 (minSdk 31) and Flutter 3.29

Native code

Frames travel through a small C++ library (android/src/main/cpp) built with the NDK and CMake for each ABI, and aligned for 16 KB memory pages. Its ring of memory slots and pixel conversions have their own tests, which run on the development machine:

scripts/test_native.sh          # the C++ tests (SANITIZE=1 adds the address/undefined checkers)
scripts/test_native.sh --jni    # also builds the library for this machine and prints the Gradle
                                # command that runs the JNI tests against it

Contributing

Contributions are welcome, including an iOS implementation.

Libraries

flutter_background_video_recorder
Record video or audio on Android from a foreground service, so recording continues while the app is in the background, closed, or the screen is off.