Record video widget

A widget created to record videos from the camera. It allows camera selection, zooming and has a timer that will stop the recording after a x seconds.

Dependencies

Add the following packages in the pubspec.yaml first.

  1. camera: ^version

and run the pub get command from the terminal.

Usage

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: _recordingState == RecordingState.preview
            ? PreviewWidget(videoFile, onPreviewDone)
            : RecordWidget(widget.question, fileCallback, onExit),
      ),
    );
  }

fileCallback(file) {
    if (file == null) {
      ScaffoldMessenger.of(context)
          .showSnackBar(SnackBar(content: Text(Strings.errorVideoFile)));
      ExtendedNavigator.of(context).pop();
    } else {
      setState(() {
        // preview the video or send to the backend
      });
    }
  }

  onExit(exit) {
    if (exit) ExtendedNavigator.of(context).pop();
  }

Parameters

@question : Mandatory. A text overlaying the camera preview @fileCallback : Mandatory. Callback called when a file is created from the camera @exitCallback : Mandatory. Callback called when the user exits without a recording

Widget

/// Returns a suitable camera icon for [direction].
IconData getCameraLensIcon(CameraLensDirection direction) {
  switch (direction) {
    case CameraLensDirection.back:
      return Icons.camera_rear;
    case CameraLensDirection.front:
      return Icons.camera_front;
    case CameraLensDirection.external:
      return Icons.camera;
  }
  throw ArgumentError('Unknown lens direction');
}

class RecordWidget extends StatefulWidget {
  RecordWidget(this.question, this.fileCallback, this.exitCallback,
      {Key key, scaffoldKey})
      : super(key: key);

  final String question;
  final Function(XFile) fileCallback;
  final Function(bool) exitCallback;

  @override
  _RecordWidgetState createState() => _RecordWidgetState();
}

class _RecordWidgetState extends State<RecordWidget>
    with WidgetsBindingObserver {
  var _recordingState = RecordingState.idle;
  CameraController controller;
  XFile videoFile;
  bool enableAudio = true;
  double _minAvailableZoom;
  double _maxAvailableZoom;
  double _currentScale = 1.0;
  double _baseScale = 1.0;
  List<CameraDescription> cameras = [];

  double countDownDurationSec = 30.0;
  double countDownProgress;

  // Counting pointers (number of user fingers on screen)
  int _pointers = 0;

  var _timer;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _getAvailableCameras();
    countDownProgress = countDownDurationSec;
  }

  void onEnd() {
    if (_recordingState == RecordingState.recording) {
      _startStopRecording();
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // App state changed before we got the chance to initialize.
    if (controller == null || !controller.value.isInitialized) {
      return;
    }
    if (state == AppLifecycleState.inactive) {
      controller?.dispose();
    } else if (state == AppLifecycleState.resumed) {
      if (controller != null) {
        onNewCameraSelected(controller.description);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Stack(
          children: [
            _buildCameraPreview(context),
            _buildQuestionBox(context),
            Align(
              alignment: Alignment.bottomCenter,
              child: SizedBox(
                width: MediaQuery.of(context).size.width,
                height: MediaQuery.of(context).size.height * 0.2,
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.end,
                  mainAxisAlignment: MainAxisAlignment.spaceAround,
                  children: [
                    _buildCameraToolbar(context),
                    _recordingState == RecordingState.idle
                        ? _buildRecordBt(context)
                        : _recordingState == RecordingState.recording
                            ? _buildStopRecordBt(context)
                            : Container(),
                    _buildSkipBt(context),
                  ],
                ),
              ),
            ),
            _buildCloseBt(context),
            _buildTimerProgressBar(context),
          ],
        ),
      ),
    );
  }

  _buildRecordBt(BuildContext context) {
    return IconButton(
      icon: Icon(Icons.fiber_manual_record),
      iconSize: 80,
      color: Colors.blue,
      onPressed: () {
        _startStopRecording();
      },
    );
  }

  _buildStopRecordBt(BuildContext context) {
    return IconButton(
      icon: Icon(Icons.stop),
      iconSize: 80,
      color: Colors.blue,
      onPressed: () {
        _startStopRecording();
      },
    );
  }

  _buildCameraPreview(BuildContext context) {
    final size = MediaQuery.of(context).size;
    final deviceRatio = size.width / size.height;

    if (controller == null || !controller.value.isInitialized) {
      return Container(
        width: size.width,
        height: size.height,
        alignment: Alignment.bottomRight,
        child: const Text(
          'Choose a camera',
          style: TextStyle(
            color: Colors.black,
            fontWeight: FontWeight.w900,
          ),
        ),
      );
    } else {
      return Container(
        width: size.width,
        height: size.height,
        child: Center(
          child: Transform.scale(
            scale: controller.value.aspectRatio / deviceRatio,
            child: AspectRatio(
              aspectRatio: controller.value.aspectRatio,
              child: Listener(
                onPointerDown: (_) => _pointers++,
                onPointerUp: (_) => _pointers--,
                child: GestureDetector(
                  onScaleStart: _handleScaleStart,
                  onScaleUpdate: _handleScaleUpdate,
                  child: CameraPreview(controller),
                ),
              ),
            ),
          ),
        ),
      );
    }
  }

  _buildCameraToolbar(BuildContext context) {
    if (cameras.isEmpty) {
      return Container(
        child: const Text('No camera found'),
        alignment: Alignment.bottomLeft,
      );
    } else if (cameras.length == 1) {
      // no other cameras found. Don't show a toggle button
      return Container();
    } else {
      return Padding(
        padding: const EdgeInsets.only(bottom: Dimens.paddingDouble),
        child: GestureDetector(
          child: Icon(Icons.flip_camera_android_outlined,
              size: 40, color: Colors.white),
          onTap: () {
            _toggleCameraLens();
          },
        ),
      );
    }
  }

  _buildQuestionBox(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 150),
      child: Align(
        alignment: Alignment.bottomCenter,
        child: Container(
          child: Align(
            alignment: Alignment.center,
            child: Text(widget.question,
                textAlign: TextAlign.center,
                style: new TextStyle(
                    color: Colors.black,
                    fontSize: 18,
                    fontWeight: FontWeight.w900)),
          ),
          decoration: BoxDecoration(
              color: Colors.white30, borderRadius: BorderRadius.circular(15)),
          padding: new EdgeInsets.all(Dimens.padding),
          width: MediaQuery.of(context).size.width * 0.8,
          height: MediaQuery.of(context).size.width * 0.2,
        ),
      ),
    );
  }

  _buildSkipBt(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: Dimens.paddingDouble),
      child: GestureDetector(
        child: Text(
          Strings.btSkip, style: TextStyle(color: Colors.white, fontSize: 18),),
        onTap: () {
          widget.fileCallback(null);
        },
      ),
    );
  }

  _buildCloseBt(BuildContext context) {
    return Align(
      alignment: Alignment.topRight,
      child: Padding(
          padding: const EdgeInsets.only(right: Dimens.paddingBig, top: 50),
          child: IconButton(
            icon: Icon(Icons.close),
            color: Colors.white,
            iconSize: 40,
            onPressed: () => widget.exitCallback(true),
          )
      ),
    );
  }

  void _toggleCameraLens() {
    // get current lens direction (front / rear)
    final lensDirection = controller.description.lensDirection;
    CameraDescription newDescription;
    if (lensDirection == CameraLensDirection.front) {
      newDescription = cameras.firstWhere((description) =>
      description.lensDirection == CameraLensDirection.back);
    } else {
      newDescription = cameras.firstWhere((description) =>
          description.lensDirection == CameraLensDirection.front);
    }

    if (newDescription != null) {
      onNewCameraSelected(newDescription);
    } else {
      print('Asked camera not available');
    }
  }

  _buildTimerProgressBar(BuildContext context) {
    return Align(
      alignment: Alignment.topCenter,
      child: Padding(
        padding: const EdgeInsets.only(top: 100),
        child: Container(
          child: countDownProgress.toInt() >= 10 ? Text(
              "00:${countDownProgress.toInt()}") : Text(
              "00:0${countDownProgress.toInt()}",
              style: new TextStyle(
                  color: Colors.black, fontWeight: FontWeight.w900)),
          decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(15)),
          padding: new EdgeInsets.only(left: Dimens.paddingBig,
              right: Dimens.paddingBig,
              top: Dimens.padding,
              bottom: Dimens.padding),
        ),
      ),
    );
  }

  _getAvailableCameras() {
    WidgetsFlutterBinding.ensureInitialized();
    availableCameras().then((availableCameras) {
      cameras = availableCameras;
      print('available cameras found: $cameras');
      if (cameras.isNotEmpty) {
        try {
          onNewCameraSelected(cameras.first);
        } catch (e) {
          print(e);
        }
      }
    }).catchError((err) {
      print('Error: $err.code\nError Message: $err.message');
    });
  }

  void logError(String code, String message) =>
      print('Error: $code\nError Message: $message');

  void onNewCameraSelected(CameraDescription cameraDescription) async {
    if (controller != null) {
      await controller.dispose();
    }
    controller = CameraController(
      cameraDescription,
      ResolutionPreset.medium,
      enableAudio: enableAudio,
    );

    // If the controller is updated then update the UI.
    controller.addListener(() {
      if (mounted) setState(() {});
      if (controller.value.hasError) {
        print('Camera error ${controller.value.errorDescription}');
      }
    });

    try {
      await controller.initialize();
      _maxAvailableZoom = await controller.getMaxZoomLevel();
      _minAvailableZoom = await controller.getMinZoomLevel();
    } on CameraException catch (e) {
      _showCameraException(e);
    }

    if (mounted) {
      setState(() {});
    }
  }

  void _showCameraException(CameraException e) {
    logError(e.code, e.description);
  }

  void _handleScaleStart(ScaleStartDetails details) {
    _baseScale = _currentScale;
  }

  Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
    // When there are not exactly two fingers on screen don't scale
    if (_pointers != 2) {
      return;
    }

    _currentScale = (_baseScale * details.scale)
        .clamp(_minAvailableZoom, _maxAvailableZoom);

    await controller.setZoomLevel(_currentScale);
  }

  _startStopRecording() {
    if (mounted) {
      setState(() {
        switch (_recordingState) {
          // Going from idle to recording
          case RecordingState.idle:
            _recordingState = RecordingState.recording;
            startTimer();
            startVideoRecording().then((_) {
              if (mounted) setState(() {});
            });
            break;
          // going from recording to preview (video is stopped)
          case RecordingState.recording:
            stopVideoRecording().then((file) {
              if (mounted) setState(() {});
              if (file != null) {
                // ScaffoldMessenger.of(context).showSnackBar(
                //     SnackBar(content: Text('Video recorded to ${file.path}')));
                videoFile = file;
                widget.fileCallback(videoFile);
              }
            });
            _timer?.cancel();
            countDownProgress = countDownDurationSec; // reset timer
            _recordingState = RecordingState.preview;
            break;
          case RecordingState.preview:
          //no op. Already previewing the video in different widget
            break;
        }
      });
    }
  }

  Future<void> startVideoRecording() async {
    if (!controller.value.isInitialized) {
      print("camera not initialized");
      return;
    }

    if (controller.value.isRecordingVideo) {
      // A recording is already started, do nothing.
      return;
    }

    try {
      await controller.startVideoRecording();
    } on CameraException catch (e) {
      _showCameraException(e);
      return;
    }
  }

  Future<XFile> stopVideoRecording() async {
    if (!controller.value.isRecordingVideo) {
      return null;
    }

    try {
      return controller.stopVideoRecording();
    } on CameraException catch (e) {
      _showCameraException(e);
      return null;
    }
  }

  void startTimer() {
    if (mounted) {
      _timer = new Timer.periodic(
        Duration(seconds: 1),
        (Timer timer) => setState(
          () {
            if (countDownProgress == 0) {
              _startStopRecording();
            } else {
              countDownProgress -= 1;
            }
          },
        ),
      );
    }
  }
}