flutter_background_video_recorder 2.1.0
flutter_background_video_recorder: ^2.1.0 copied to clipboard
Record video or audio on Android from a foreground service, with camera and zoom selection, plus raw camera frames and microphone audio streams for your own processing.
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 inMovies/<folder>, audio inRecordings/<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.
defaultResolutionanddefaultFrameRatetell 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:9size is a9:16video when the phone is held upright; show both if that helps your users. - Anything you set must be supported, otherwise starting throws
RecorderErrorCode.unsupportedConfigurationwith the reason. A recording is never silently different from what you asked for. VideoCodec.h264(default) plays everywhere.VideoCodec.hevcmakes files roughly 40% smaller but some players and upload services handle it poorly.VideoCodec.autouses HEVC only when the device has a hardware encoder.- The bitrate suits the device, size, frame rate and codec; set
VideoSettings.bitrateto 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,RecorderPreviewshows itsplaceholderduring 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.previewUnavailableis 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, seeRawFramePlane.bytesPerRow),jpeg(one image per frame) andrgba(four bytes a pixel). Frames are not rotated; turn them byrotationDegreesto 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 readingframe.planesthen throws aStateError. To work on a frame later (after anawait, or in another isolate) callframe.retain()inside the listener andframe.release()when done, or take a copy withframe.toOwned(). A few frames are in flight at a time: if you hold on to them, new pictures are dropped (seeRawFrame.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.audioOverrunwarning) 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; likewisemicrophoneBusyfor the microphone. A frame session and an audio stream can run together. - An error such as a lost camera arrives as a
RecorderExceptionon 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
cameraBusyormicrophoneBusyuntil 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,
recorderStateemitsRecorderStatus.errorandrecorderErrorsemits aRecorderException(cameraDisconnectedorcameraError) whosesavedMediais 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(typicallyRecorderErrorCode.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/RawAudioSessionyou held but not the session itself. The camera or microphone stays taken, opening a new session fails withRecorderErrorCode.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 athermalWarningfirst.
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.
