“Lights, Camera, Action: Building a Short Video Recorder App with Flutter and Bloc Pattern”

Aniket Raj
11 min readAug 9, 2023

--

Hi there! Welcome to this blog where we’ll explore the exciting journey of creating a short video recorder app like tic-tok and Instagram using Flutter and Bloc.

By the time we’re done, you’ll have a complete app that empowers you to:

  • Choose from flexible recording lengths (15, 30, 60, and 90 seconds).
  • Experience press-and-hold recording with a sleek timer.
  • Seamlessly switch between front and back cameras.
  • Enjoy instant playback right after recording.
  • Automatically off the camera when it’s not in use.

In this tutorial, our main focus will be on implementing the core camera functionality using the Bloc pattern. We’ll break down each event and state, explaining their purpose and interactions within the Bloc.

Note: If you’re not yet familiar with the Bloc pattern or Flutter’s camera implementation, consider checking out the flutter_bloc and camera on Pub.dev.

Now, let’s dive right into the code and start building your very own video recording app!

1. Setting Up the Project

To begin, we must add the required dependency in your pubspec.yaml file

2. Implementing Bloc

Defining Camera Events

We’ll start by defining the events. we have crafted a total of seven events, each serving a distinct purpose:

  • CameraInitialize : Event to request permission and initialize the camera
  • CameraSwitch : Event to switch between front and back cameras.
  • CameraRecordingStart : Event to start video recording
  • CameraRecordingStop : Event to stop video recording
  • CameraEnable : Event to enable the camera when in use
  • CameraDisable : Event to disable the camera when not in use
  • CameraReset : Event to reset the camera BLoC to its initial state

Defining Camera States

Based on the above events bloc can emit 4 states.

  • CameraInitial : Represents the initial state when the camera has not been initialized.
  • CameraReady : Indicates the camera is initialized and ready for usage, along with flags for recording status, errors, and button deactivation.
  • CameraRecordingSuccess : Reflects the state when the video recording completes successfully, encapsulating the recorded video file.
  • CameraError : state when an error occurs during camera operations. This state includes a CameraErrorType parameter that specifies the type of camera error that occurred.
enum CameraErrorType { permission, other }

Defining Camera Bloc

Now, let’s look into the CameraBloc It’s equipped with two crucial dependencies — cameraUtils and permissionUtils – which we'll inject via the constructor this will help us in mocking the camera controller during the unit test.
CameraUtils and PermissionUtils classes are responsible for initiating the camera controller and asking for camera permission.

The CameraBloc will be the heart of our project, managing the camera-related states and events. This is where we'll handle camera initialization, switching between front and back cameras, starting and stopping video recording, and more.

Take an overview of the camera bloc class, we’ll break down each event and state in detail, explaining their purpose and how they interact to manage camera functionality effectively.

// A BLoC class that handles camera-related operations
class CameraBloc extends Bloc<CameraEvent, CameraState> {
//....... Dependencies ..............
final CameraUtils cameraUtils;
final PermissionUtils permissionUtils;

//....... Internal variables ........
int recordDurationLimit = 15;
CameraController? _cameraController;
CameraLensDirection currentLensDirection = CameraLensDirection.back;
Timer? recordingTimer;
ValueNotifier<int> recordingDuration = ValueNotifier(0);

//....... Getters ..........
CameraController? getController() => _cameraController;
bool isInitialized() => _cameraController?.value.isInitialized ?? false;
bool isRecording() => _cameraController?.value.isRecordingVideo ?? false;

//setters
set setRecordDurationLimit(int val) {
recordDurationLimit = val;
}

//....... Constructor ........
CameraBloc({required this.cameraUtils, required this.permissionUtils}) : super(CameraInitial()) {
on<CameraReset>(_onCameraReset);
on<CameraInitialize>(_onCameraInitialize);
on<CameraSwitch>(_onCameraSwitch);
on<CameraRecordingStart>(_onCameraRecordingStart);
on<CameraRecordingStop>(_onCameraRecordingStop);
on<CameraEnable>(_onCameraEnable);
on<CameraDisable>(_onCameraDisable);
}

}
  1. Event: CameraReset

The CameraReset event is triggered to reset the CameraBloc to its initial state. Inside the _onCameraReset method, we handle the reset logic.
mainly we will be using it when we would be disposing of the camera page.

// Handle CameraReset event
void _onCameraReset(CameraReset event, Emitter<CameraState> emit) async {
await _disposeCamera(); // Dispose of the camera before resetting
_resetCameraBloc(); // Reset the camera BLoC state
emit(CameraInitial()); // Emit the initial state
}

2. Event: CameraInitialize

The CameraInitialize event is triggered when the camera needs to be initialized. This event includes an optional recordingLimit parameter that specifies the maximum duration of a video recording.

Inside the _onCameraInitialize method, we manage the initialization process. We check camera permissions and handle camera setup. If permission is granted, we initialize the camera and emit the CameraReady state. In case of an error, we emit the appropriate CameraError state.

// Handle CameraInitialize event
void _onCameraInitialize(CameraInitialize event, Emitter<CameraState> emit) async {
recordDurationLimit = event.recordingLimit;
try {
await _checkPermissionAndInitializeCamera(); // checking and asking for camera permission and initializing camera
emit(CameraReady(isRecordingVideo: false));
} catch (e) {
emit(CameraError(error: e == CameraErrorType.permission ? CameraErrorType.permission : CameraErrorType.other));
}
}

The _checkPermissionAndInitializeCamera method is a critical step in this process. It checks for camera and microphone permissions, initializes the camera if permissions are granted, and handles permission requests if not.

// Check and ask for camera permission and initialize camera
Future<void> _checkPermissionAndInitializeCamera() async {
if (await permissionUtils.getCameraAndMicrophonePermissionStatus()) {
await _initializeCamera();
} else {
if (await permissionUtils.askForPermission()) {
await _initializeCamera();
} else {
return Future.error(CameraErrorType.permission); // Throw the specific error type for permission denial
}
}
}

The _initializeCamera method, on the other hand, is responsible for setting up the camera controller and handling any potential exceptions. We listen to the camera controller for recording status changes and start a timer to keep track of the recording duration.


// Initialize the camera controller
Future<void> _initializeCamera() async {
_cameraController = await cameraUtils.getCameraController(lensDirection: currentLensDirection);
try {
await _cameraController?.initialize();
_cameraController?.addListener(() {
if (_cameraController!.value.isRecordingVideo) {
_startTimer();
}
});
} on CameraException catch (error) {
Future.error(error);
} catch (e) {
Future.error(e);
}
}

// Start the recording timer
void _startTimer() async {
recordingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
recordingDuration.value++;
if (recordingDuration.value == recordDurationLimit) {
add(CameraRecordingStop());
}
});
}

// Stop the recording timer and reset the duration
void _stopTimerAndResetDuration() async {
recordingTimer?.cancel();
recordingDuration.value = 0;
}

3. Event: CameraSwitch

The CameraSwitch event is triggered to switch between the front and back cameras.

Within the _onCameraSwitch method, we initiate the camera switch. We emit the CameraInitial state to reset the camera view, then proceed with the camera switch operation. Subsequently, we emit the CameraReady state to indicate that the camera is now ready for use.

// Handle CameraSwitch event
void _onCameraSwitch(CameraSwitch event, Emitter<CameraState> emit) async {
emit(CameraInitial()); // Reset camera view
await _switchCamera(); // Switch the camera
emit(CameraReady(isRecordingVideo: false)); // Camera is ready
}

The _switchCamera method manage the camera switch operation. It toggles between the front and back camera lenses and reinitializes the camera setup to reflect the change.

// Switch between front and back cameras
Future<void> _switchCamera() async {
currentLensDirection = currentLensDirection == CameraLensDirection.back ? CameraLensDirection.front : CameraLensDirection.back;
await _reInitialize();
}

The _reInitialize the method takes care of reinitializing the camera setup. It disposes of the existing camera controller and then initializes it again with the new lens direction configuration.

// Reinitialize the camera
Future<void> _reInitialize() async {
await _disposeCamera();
await _initializeCamera();
}

4. Event: CameraRecordingStart

The CameraRecordingStart event, a crucial element that initiates video recording within our app.

In the method _onCameraRecordingStart, we respond to the user's intent to begin recording. If the camera is not already recording, we emit the CameraReady state with the isRecordingVideo flag set to true. Subsequently, we trigger the _startRecording method to commence the recording process. By emitting CameraReady just before the recording start we are ensuring that the user doesn’t see any lag on the UI as _cameraController!.startVideoRecording() is a future that may take some time to complete.

// Handle CameraRecordingStart event  
void _onCameraRecordingStart(CameraRecordingStart event, Emitter<CameraState> emit) async {
if (!isRecording()) {
try {
emit(CameraReady(isRecordingVideo: true));
await _startRecording();
} catch (e) {
await _reInitialize();
emit(CameraReady(isRecordingVideo: false));
}
}
}

The _startRecording method is responsible for starting the video recording process. It calls the _cameraController!.startVideoRecording() method to begin recording. In case of any exceptions, we handle the error and reinitialize the camera.

Future<void> _startRecording() async {
try {
await _cameraController!.startVideoRecording();
} catch (e) {
return Future.error(e);
}
}

5. Event: CameraRecordingStop

The CameraRecordingStop event is triggered when the user stops video recording.

Inside the _onCameraRecordingStop method, we manage the process of stopping video recording. If the camera is currently recording, we check whether the recorded video duration is less than 2 seconds to prevent corrupt file issues with very short videos. We emit the CameraReady state accordingly, signaling the recording status and potential errors.

// Handle CameraRecordingStop event  
void _onCameraRecordingStop(CameraRecordingStop event, Emitter<CameraState> emit) async {
if (isRecording()) {
// Check if the recorded video duration is less than 2 seconds to prevent
// potential issues with very short videos resulting in corrupt files.
bool hasRecordingLimitError = recordingDuration.value < 2 ? true : false;
emit(CameraReady(isRecordingVideo: false, hasRecordingError: hasRecordingLimitError, decativateRecordButton: true));
File? videoFile;
try {
videoFile = await _stopRecording(); // Stop video recording and get the recorded video file
if (hasRecordingLimitError) {
await Future.delayed(
const Duration(milliseconds: 1500), () {}); // To prevent rapid consecutive clicks, we introduce a debounce delay of 2 seconds,
emit(CameraReady(isRecordingVideo: false, hasRecordingError: false, decativateRecordButton: false));
} else {
emit(CameraRecordingSuccess(file: videoFile));
}
} catch (e) {
await _reInitialize(); // On Camera Exception, initialize the camera again
emit(CameraReady(isRecordingVideo: false));
}
}
}

The _stopRecording method plays a crucial role in halting video recording. It calls _cameraController!.stopVideoRecording() to stop recording, retrieves the recorded video file, and manages the timer.

// Stop video recording and return the recorded video file
Future<File> _stopRecording() async {
try {
XFile video = await _cameraController!.stopVideoRecording();
_stopTimerAndResetDuration();
return File(video.path);
} catch (e) {
return Future.error(e);
}
}

6. Event: CameraDisable and CameraEnable

The CameraDisable and CameraEnable events are triggered to disable and enable the camera, respectively when the camera is not in use.

In the _onCameraEnable method, we handle the event when the camera needs to be re-enabled, typically when the app resumes from the background. If the camera is not initialized and a camera controller instance exists, we check for camera and microphone permissions. If granted, we proceed to initialize the camera and emit the CameraReady state. If permissions are not granted, we emit the CameraError state with the appropriate error type.

// Handle CameraEnable event on app resume
void _onCameraEnable(CameraEnable event, Emitter<CameraState> emit) async {
if (!isInitialized() && _cameraController != null) {
if (await permissionUtils.getCameraAndMicrophonePermissionStatus()) {
await _initializeCamera();
emit(CameraReady(isRecordingVideo: false));
} else {
emit(CameraError(error: CameraErrorType.permission));
}
}
}

In the _onCameraDisable method, we respond to the event of disabling the camera, typically when the camera is not in active use. If the camera is initialized and currently recording, we first stop the recording to ensure that any ongoing recording session is concluded properly. We introduce a brief delay after stopping recording to handle scenarios where the app is minimized during recording. Afterward, we dispose of the camera, emit the CameraInitial state to reset the camera setup, and signify that the camera is not in use.

  // Handle CameraDisable event when camera is not in use
void _onCameraDisable(CameraDisable event, Emitter<CameraState> emit) async {
if (isInitialized() && isRecording()) {
// if app minimize while recording then save the the video then disable the camera
add(CameraRecordingStop());
await Future.delayed(const Duration(seconds: 2));
}
await _disposeCamera();
emit(CameraInitial());
}

Putting It All Together

The CameraBloc logic, as explained above, forms the core of our camera management system. By handling different events and emitting corresponding states, we ensure that the camera-related functionality is properly managed and presented to the user.

In the next part of our exploration, we’ll shift our focus to the UI components and widgets that interact with the CameraBloc to create a seamless user experience for camera recording and playback.

3. User Interface: Building the CameraPage

In this section, we will only talk about key components and their interactions with the CameraBloc.

The CameraPage is the main screen where users can interact with the camera. It provides the camera preview, recording controls, camera switching, and timer settings.

Initialization

Inside the CameraPage initState, we initialize the CameraBloc and add an observer to handle app lifecycle changes. This ensures that the camera is enabled when the app is resumed and visible, and disabled when the app is inactive or not visible.

The VisibilityDetector widget wraps the entire CameraPage and detects changes in visibility. When the page becomes invisible, we disable the camera, and when it becomes visible again, we enable the camera.

VisibilityDetector(
key: const Key("my_camera"),
onVisibilityChanged: _handleVisibilityChanged,
child: BlocConsumer<CameraBloc, CameraState>(
listener: _cameraBlocListener,
builder: _cameraBlocBuilder,
),
),

The _handleVisibilityChanged method updates the camera's state based on visibility changes.

void _handleVisibilityChanged(VisibilityInfo info) {
if (info.visibleFraction == 0.0) {
// Camera page is not visible, disable the camera.
if (mounted) {
cameraBloc.add(CameraDisable());
isThisPageVisible = false;
}
} else {
// Camera page is visible, enable the camera.
isThisPageVisible = true;
cameraBloc.add(CameraEnable());
}
}

Let’s dive deeper into the CameraPage's _cameraBlocListener and _cameraBlocBuilder methods to understand their roles in handling camera events and building the UI.

_cameraBlocListener

The _cameraBlocListener method is responsible for reacting to changes CameraState and taking appropriate actions based on those changes. Here's the breakdown of what's happening within this method:

  1. Recording Success: If the current CameraState is CameraRecordingSuccess, it means that a video recording has been successfully completed. In this case, we navigate to the VideoPage, passing the recorded video file to the constructor. The VideoPage will handle the playback of this video.
  2. Recording Error: If the current CameraState is CameraReady and the hasRecordingError flag is true, it indicates that there was an error during video recording, likely due to recording for less than 2 seconds. We show a snack bar to notify the user to record for a longer duration.

_cameraBlocBuilder

The _cameraBlocBuilder method constructs the user interface based on the current CameraState. It handles everything from displaying the camera preview to showing error messages and recording controls.

AnimatedSwitcher is used to toggle between showing the live camera preview and displaying a blurred screenshot image as a transition while the camera is initializing or switching between cameras. This is done to provide a smoother visual experience to the user and avoid sudden flickers or black screens during these transitions. This screenshot serves as a temporary visual placeholder to avoid showing a black screen during the transition.

The takeCameraScreenshot function is a utility that allows you to capture a screenshot of any widget in your Flutter app. In the context of the, it is specifically used to capture a screenshot of the camera preview

The animatedProgressButton widget is used to display the recording controls. The UI responds to tap and long-press gestures, allowing the user to start and stop video recording. The UI also includes buttons for switching between cameras and adjusting the timer settings.

The RecordingProgressIndicator is a custom widget that visually represents the progress of a video recording. It displays a radial gauge-like design that fills up as the video recording progresses.

If the current CameraState is CameraError, the errorWidget the function is called to display an error message. This message informs the user that camera and microphone permissions are needed to proceed.

4. Playback: Building the VideoPage

The VideoPage is a simple page that displays a video player for playback. This page utilizes the VideoPlayer package to create a seamless and interactive playback experience.

5. Conclusion

🎉 Congratulations!, we have successfully completed building a Short Video Recorder App using Flutter and Bloc. 📹 We’ve explored essential components like CameraBloc, CameraPage, and VideoPage, gaining insights into camera management, video recording, and playback. You now have a solid foundation to create your unique short video app, adding your own creative features and ideas. 🚀

🎉 Thank you for reading this article! 🙏 You can find the source code on this GitHub repository.

💻 Feel free to contribute, share your improvements, or use it as a reference for your projects.

If you loved it, give it a clap! 👏

you can connect with me on LinkedIn and Twitter for more amazing content! 🚀

Keep coding and stay curious! 🚀 Happy Fluttering! 😊

--

--

Aniket Raj

📱 Mobile Developer | Flutter Developer | Android | IOS