Flutter Screen Recording Tutorial: Widget Capture and Native APIs

A practical Flutter screen recording tutorial: record widgets with FFmpeg or use native APIs (MediaProjection, ReplayKit), with code and performance tips.

ASOasis
8 min read
Flutter Screen Recording Tutorial: Widget Capture and Native APIs

Image used for representation purposes only.

Overview

Want to add screen recording to your Flutter app? This tutorial walks you through two practical paths:

  • Record any Flutter widget tree to a high‑quality MP4 using 100% Dart code and FFmpeg.
  • Record the full device screen using native platform APIs (Android MediaProjection, iOS ReplayKit) via platform channels.

You’ll learn the trade‑offs, performance tips, and how to add microphone audio. By the end, you can ship a robust recording feature tailored to your app’s needs.

Choosing the right approach

  • Widget capture (Dart + FFmpeg)

    • Pros: Single codebase, predictable visuals, works on emulators, no special OS permissions, captures exactly your UI.
    • Cons: Does not capture other apps or system overlays; CPU/memory heavy if you record the whole screen at high DPI.
  • Full device screen (native APIs)

    • Pros: Records everything visible on screen (your app or even other apps, depending on platform and consent flow).
    • Cons: Requires platform channels and native code, OS prompts/UX caveats, extension targets on iOS for broadcast, more QA.

Pick widget capture if you’re building tutorials, replays, or exports of your own UI. Pick native APIs if you truly need full device capture.

Prerequisites

  • Flutter 3.x+
  • Basic familiarity with Dart async/isolates
  • A real device for performance checks (simulators are fine for basic testing)

Approach A: Record a Flutter widget tree to video (Dart‑only)

This method repeatedly snapshots a RepaintBoundary at a target frame rate, saves frames to disk, and stitches them into MP4 with FFmpeg.

1) Add dependencies

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  path_provider: ^2.x.x   # check pub.dev for the latest version
  path: ^1.x.x            # check pub.dev for the latest version
  ffmpeg_kit_flutter_min_gpl: ^x.y.z  # check pub.dev for the latest

Why FFmpeg Kit? It’s a maintained successor to older FFmpeg bindings and runs fully on‑device.

2) Wrap the UI with a RepaintBoundary

You’ll capture everything inside this boundary. You can target a full screen Scaffold or just a specific widget.

import 'package:flutter/material.dart';

class RecorderHost extends StatelessWidget {
  const RecorderHost({super.key, required this.child, required this.boundaryKey});
  final Widget child;
  final GlobalKey boundaryKey;

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      key: boundaryKey,
      child: child,
    );
  }
}

3) Implement the screen recorder service

This service captures PNG frames at a fixed FPS and then calls FFmpeg to assemble an MP4.

import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:ffmpeg_kit_flutter_min_gpl/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_min_gpl/return_code.dart';

class WidgetScreenRecorder {
  WidgetScreenRecorder(this.boundaryKey);
  final GlobalKey boundaryKey;

  Timer? _timer;
  Directory? _workDir;
  int _frame = 0;

  Future<void> start({int fps = 30, double pixelRatio = 1.0}) async {
    _workDir = await getTemporaryDirectory();
    _frame = 0;
    final interval = Duration(milliseconds: (1000 / fps).round());

    // Wait for at least one frame to paint.
    await Future.delayed(const Duration(milliseconds: 50));

    _timer = Timer.periodic(interval, (_) async {
      try {
        final boundary = boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
        if (boundary == null) return;
        final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
        final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
        if (byteData == null) return;
        final file = File(p.join(
          _workDir!.path,
          'frame_${_frame.toString().padLeft(6, '0')}.png',
        ));
        await file.writeAsBytes(byteData.buffer.asUint8List(), flush: true);
        _frame++;
      } catch (_) {
        // Ignore dropped frames; you may log for diagnostics.
      }
    });
  }

  Future<File> stop({int fps = 30}) async {
    _timer?.cancel();

    final outPath = p.join(
      _workDir!.path,
      'capture_${DateTime.now().millisecondsSinceEpoch}.mp4',
    );

    // Assemble frames into an MP4 using H.264.
    final cmd = [
      '-y',
      '-framerate', '$fps',
      '-i', p.join(_workDir!.path, 'frame_%06d.png'),
      '-pix_fmt', 'yuv420p',
      '-r', '$fps',
      outPath,
    ].join(' ');

    final session = await FFmpegKit.execute(cmd);
    final rc = await session.getReturnCode();
    if (!ReturnCode.isSuccess(rc)) {
      throw Exception('FFmpeg failed with code: $rc');
    }

    // Optional: clean up frames to save space.
    try {
      for (final f in Directory(_workDir!.path)
          .listSync()
          .whereType<File>()
          .where((f) => f.path.endsWith('.png'))) {
        f.deleteSync();
      }
    } catch (_) {}

    return File(outPath);
  }

  void dispose() {
    _timer?.cancel();
  }
}

4) Wire it up in your app

final recorderKey = GlobalKey();
late final WidgetScreenRecorder recorder;

@override
void initState() {
  super.initState();
  recorder = WidgetScreenRecorder(recorderKey);
}

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

// UI example
Scaffold(
  appBar: AppBar(title: const Text('Recorder Demo')),
  body: RecorderHost(
    boundaryKey: recorderKey,
    child: YourFancyDemo(),
  ),
  floatingActionButton: Row(
    mainAxisAlignment: MainAxisAlignment.end,
    children: [
      FloatingActionButton(
        heroTag: 'start',
        onPressed: () async => recorder.start(fps: 30, pixelRatio: 1.0),
        child: const Icon(Icons.fiber_manual_record),
      ),
      const SizedBox(width: 12),
      FloatingActionButton(
        heroTag: 'stop',
        onPressed: () async {
          final file = await recorder.stop(fps: 30);
          // Present a snackbar, share the file, etc.
        },
        child: const Icon(Icons.stop),
      ),
    ],
  ),
);

5) Add microphone audio (optional)

Record mic audio in parallel with a simple recording plugin, then mux it into the video using FFmpeg. After you have video.mp4 and audio.m4a:

# copy video stream, re-encode audio if needed, and trim to the shorter stream
ffmpeg -y -i video.mp4 -i audio.m4a -c:v copy -c:a aac -shortest out_with_audio.mp4

Integrate this second pass in your Dart code using FFmpeg Kit just like the first command.

Performance tips

  • Capture only what you need: wrap the smallest subtree that demonstrates the action.
  • Reduce pixelRatio to 1.0 (or even 0.75) for large devices.
  • Prefer 24–30 fps; 60 fps significantly increases CPU and I/O.
  • Run on a release build for realistic performance and thermal behavior.
  • Keep memory pressure in check; periodically delete frames or encode in chunks.
  • Consider dynamic frame sampling: if UI is idle, skip frames to save work.

Export and sharing

  • Store the final MP4 in your app’s documents directory and expose it via the system share sheet.
  • For long recordings, stream frames to storage instead of holding them in RAM.
  • Show a visible recording indicator and estimated remaining time (based on free space).

Approach B: Full device screen recording with platform channels

If you must capture outside your Flutter view (system UI or other apps), use platform APIs.

Android: MediaProjection overview (Kotlin)

Key pieces:

  • Request user consent via MediaProjectionManager.createScreenCaptureIntent().
  • Create a MediaProjection and attach a VirtualDisplay.
  • Encode via MediaRecorder or MediaCodec Surface.
  • Forward lifecycle events; stop and release resources on pause/stop.

Minimal flow (activity code):

private lateinit var projectionManager: MediaProjectionManager
private var mediaProjection: MediaProjection? = null

private val REQUEST_CAPTURE = 1001

fun startScreenCapture() {
    projectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
    startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CAPTURE)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CAPTURE && resultCode == Activity.RESULT_OK && data != null) {
        mediaProjection = projectionManager.getMediaProjection(resultCode, data)
        // Configure MediaRecorder/MediaCodec and VirtualDisplay surface here
        // Start encoding
    }
}

fun stopScreenCapture() {
    // Stop recorder/codec first, then release projection
    mediaProjection?.stop()
}

You’ll expose startScreenCapture/stopScreenCapture through a Flutter MethodChannel and return the recorded file path. Remember:

  • API 21+ is required; different devices vary in encoder/bitrate quality.
  • Use a Foreground Service for long recordings.
  • Show a persistent notification while recording.

iOS: ReplayKit overview (Swift)

Two modes:

  • In‑app recording using RPScreenRecorder (captures your app’s screen, mic, and app audio with consent).
  • Broadcast Upload Extension for system‑level capture (e.g., live streaming), launched from Control Center with a custom extension target.

In‑app sample:

import ReplayKit

class RecorderHelper {
    let recorder = RPScreenRecorder.shared()
    var isRecording = false

    func start(completion: @escaping (Error?) -> Void) {
        guard !isRecording else { completion(nil); return }
        recorder.isMicrophoneEnabled = true
        recorder.startRecording { error in
            self.isRecording = (error == nil)
            completion(error)
        }
    }

    func stop(completion: @escaping (URL?, Error?) -> Void) {
        guard isRecording else { completion(nil, nil); return }
        recorder.stopRecording { previewVC, error in
            self.isRecording = false
            // Export the movie file from previewVC or use RPPreviewViewControllerDelegate
            completion(nil, error)
        }
    }
}

Expose start and stop via a MethodChannel, and surface either the preview UI or a saved file URL back to Flutter. For broadcast, implement a Broadcast Upload Extension that receives CMSampleBuffers and encodes/uploads them; your Flutter host app can coordinate start/stop via channels or app groups.

Platform‑specific UX and policies

  • Always display a conspicuous recording indicator.
  • Obtain user consent and explain what’s captured (screen, mic, app audio).
  • Respect privacy: never capture sensitive fields without masking.
  • Handle interruptions (calls, PiP, split screen) gracefully.

Testing checklist

  • Record short and long sessions; verify A/V sync and file sizes.
  • Test on low‑end and high‑end devices; adjust bitrate and resolution if using native encoders.
  • Verify background/lock screen behavior (many platforms pause or stop recording).
  • Ensure sufficient free space; report errors early when disk is low.
  • Confirm output plays on common players (yuv420p pixel format improves compatibility).

Troubleshooting

  • Stutters or dropped frames (Dart capture): lower pixelRatio and FPS; record only a subtree; use release mode.
  • FFmpeg errors: print the full command/session logs; verify frame naming pattern and folder permissions.
  • Green/pink videos (Android hardware encoders): switch to software codec or adjust color formats if using MediaCodec.
  • No audio (iOS in‑app): ensure isMicrophoneEnabled = true and handle system prompts; some routes mute app audio.
  • Very large files: reduce FPS/bitrate or downscale; consider HEVC (H.265) where supported.

When to combine both approaches

A common hybrid is to use widget capture for predictable UI exports (e.g., tutorial clips) and reserve native screen recording for power users or QA builds. The code paths can coexist; present a toggle in developer settings.

Wrap‑up

  • For most Flutter apps, widget capture with FFmpeg is the fastest path to a shippable, cross‑platform recording feature.
  • If you need true system‑level capture, invest in platform channels with MediaProjection (Android) and ReplayKit (iOS).
  • Prioritize UX, consent, and performance from day one to avoid regressions later.

Ship it, measure it, then tune FPS, resolution, and bitrates for your audience and devices.

Related Posts