Skip to main content

Video Recorder

Last updated: July 15, 2026 | 7 minutes read

This example demonstrates how to build a Flutter app using the Maps SDK to record video (in chunks) with audio and display the user's track on the map.

How it works

The example app highlights the following features:

  • Initializing a map.
  • Requesting and handling camera, microphone, and location permissions.
  • Starting and stopping video recording (with configurable chunk duration).
  • Pausing and resuming audio recording during the session.
  • Displaying the recorded path on the map once recording stops.
Initial map
Recording video + audio
Stopped recording

UI and Map Integration

The following code builds the UI with a GemMap widget and an app bar that includes buttons for starting/stopping video recording, controlling audio recording, and following the user's position.

class MyApp extends StatelessWidget {
const MyApp({super.key});


Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Video Recorder',
home: MyHomePage(),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});


State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
late GemMapController _mapController;
late Recorder _recorder;

PermissionStatus _locationPermissionStatus = PermissionStatus.denied;
bool _hasLiveDataSource = false;
bool _isRecording = false;
bool _isAudioRecording = false;


void dispose() {
GemKit.release();
super.dispose();
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.deepPurple[900],
title: const Text(
'Video Recorder',
style: TextStyle(color: Colors.white),
),
actions: [
if (_hasLiveDataSource && _isRecording == false)
IconButton(
onPressed: _onRecordButtonPressed,
icon: Icon(Icons.radio_button_on, color: Colors.white),
),
if (_isRecording)
IconButton(
onPressed: _onStopRecordingButtonPressed,
icon: Icon(Icons.stop_circle, color: Colors.white),
),
if (_isRecording)
IconButton(
onPressed: _startAudioRecording,
icon: Icon(
Icons.mic,
color: _isAudioRecording ? Colors.green : Colors.white,
),
),
if (_isRecording)
IconButton(
onPressed: _stopAudioRecording,
icon: Icon(
Icons.mic_off,
color: _isAudioRecording ? Colors.white : Colors.grey,
),
),
IconButton(
onPressed: _onFollowPositionButtonPressed,
icon: const Icon(
Icons.location_searching_sharp,
color: Colors.white,
),
),
],
),
body: Stack(
children: [
GemMap(
key: ValueKey("GemMap"),
onMapCreated: (controller) => _onMapCreated(controller),
appAuthorization: projectApiToken,
),
],
),
);
}

// The callback for when map is ready to use.
void _onMapCreated(GemMapController controller) async {
// Save controller for further usage.
_mapController = controller;
}

Future<void> _onFollowPositionButtonPressed() async {
if (kIsWeb) {
// On web platform permission are handled differently than other platforms.
// The SDK handles the request of permission for location.
final locationPermssionWeb =
await PositionService.requestLocationPermission();
if (locationPermssionWeb == true) {
_locationPermissionStatus = PermissionStatus.granted;
} else {
_locationPermissionStatus = PermissionStatus.denied;
}
} else {
// For Android & iOS platforms, permission_handler package is used to ask for permissions.
_locationPermissionStatus = await Permission.locationWhenInUse.request();
}

if (_locationPermissionStatus == PermissionStatus.granted) {
// After the permission was granted, we can set the live data source (in most cases the GPS).
// The data source should be set only once, otherwise we'll get -5 error.
if (!_hasLiveDataSource) {
PositionService.setLiveDataSource();
_hasLiveDataSource = true;
}

// Optionally, we can set an animation
final animation = GemAnimation(type: AnimationType.linear);

// Calling the start following position SDK method.
_mapController.startFollowingPosition(animation: animation);

setState(() {});
}
}

Requesting Permissions

The following code requests location permission (and storage permission on web) and then camera & microphone permissions before starting a recording.

Future<void> _onFollowPositionButtonPressed() async {
if (kIsWeb) {
// On web platform permission are handled differently than other platforms.
// The SDK handles the request of permission for location.
final locationPermssionWeb =
await PositionService.requestLocationPermission();
if (locationPermssionWeb == true) {
_locationPermissionStatus = PermissionStatus.granted;
} else {
_locationPermissionStatus = PermissionStatus.denied;
}
} else {
// For Android & iOS platforms, permission_handler package is used to ask for permissions.
_locationPermissionStatus = await Permission.locationWhenInUse.request();
}

if (_locationPermissionStatus == PermissionStatus.granted) {
// After the permission was granted, we can set the live data source (in most cases the GPS).
// The data source should be set only once, otherwise we'll get -5 error.
if (!_hasLiveDataSource) {
PositionService.setLiveDataSource();
_hasLiveDataSource = true;
}

// Optionally, we can set an animation
final animation = GemAnimation(type: AnimationType.linear);

// Calling the start following position SDK method.
_mapController.startFollowingPosition(animation: animation);

setState(() {});
}
}

Starting and Stopping Recording

Future<void> _onRecordButtonPressed() async {
final hasCamMicPermission = await requestCameraAndMicPermissions();
if (!hasCamMicPermission) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Camera or microphone permission not granted.'),
duration: Duration(seconds: 3),
),
);
}
return;
}

//Helper function that returns path to the Tracks directory
final logsDir = await getDirectoryPath("Tracks");

final recorder = Recorder.create(
RecorderConfiguration(
dataSource: DataSource.createLiveDataSource()!,
logsDir: logsDir,
recordedTypes: [
DataType.position, // GPS position data
DataType.camera, // Video data from the camera sensor
],
enableAudio:
true, // Enable audio recording (requires microphone permission)
minDurationSeconds: 5,
videoQuality: Resolution
.hd720p, // Define the video resolution/quality (requires camera sensor)
chunkDurationSeconds:
180, // Length of each recorded video chunk in seconds
),
);

setState(() {
_isRecording = true;
_recorder = recorder;
});

final error = await _recorder.startRecording();

if (error != GemError.success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Recording failed: $error'),
duration: Duration(seconds: 5),
),
);
}
setState(() {
_isRecording = false;
});
return;
}

// Clear displayed paths
_mapController.preferences.paths.clear();
_mapController.deactivateAllHighlights();
}

Future<void> _onStopRecordingButtonPressed() async {
final endErr = await _recorder.stopRecording();

if (endErr == GemError.success) {
await _presentRecordedRoute();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Recording failed: $endErr'),
duration: Duration(seconds: 5),
),
);
}
}

setState(() {
_isRecording = false;
});
}
note

The resulting video recordings are saved as .mp4 files in the Data/Tracks directory specified in the recorder configuration.

Starting and Stopping Audio Recording

void _startAudioRecording() {
// Start audio recording using the recorder instance
_recorder.startAudioRecording();

setState(() {
_isAudioRecording = true;
});
}

void _stopAudioRecording() {
// Stop audio recording using the recorder instance
_recorder.stopAudioRecording();

setState(() {
_isAudioRecording = false;
});
}

Presenting the Recorded Track on the Map

This code loads the last recorded track from device memory, retrieves the coordinates, builds a Path entity, and adds it to the MapViewPathCollection.

Future<void> _presentRecordedRoute() async {
final logsDir = await getDirectoryPath("Tracks");

// It loads all .gm and .mp4 files at logsDir
final bookmarks = RecorderBookmarks.create(logsDir);

// Get all recordings path
final logList = bookmarks?.getLogsList();

// Get the LogMetadata to obtain details about recorded session
LogMetadata? meta = bookmarks!.getLogMetadata(logList!.last);
final recorderCoordinates = meta!.preciseRoute;
final duration = convertDurationMillis(meta.durationMillis);

// Create a path entity from coordinates
final path = Path.fromCoordinates(recorderCoordinates);

Landmark beginLandmark = Landmark.withCoordinates(
recorderCoordinates.first,
);
Landmark endLandmark = Landmark.withCoordinates(recorderCoordinates.last);

beginLandmark.setImageFromIcon(GemIcon.waypointStart);
endLandmark.setImageFromIcon(GemIcon.waypointFinish);

HighlightRenderSettings renderSettings = HighlightRenderSettings(
options: {HighlightOptions.showLandmark},
);

_mapController.activateHighlight(
[beginLandmark, endLandmark],
renderSettings: renderSettings,
highlightId: 1,
);

// Show the path immediately after stopping recording
_mapController.preferences.paths.add(path);

// Center on recorder path
_mapController.centerOnAreaRect(
path.area,
viewRc: Rectangle<int>(
_mapController.viewport.width ~/ 3,
_mapController.viewport.height ~/ 3,
_mapController.viewport.width ~/ 3,
_mapController.viewport.height ~/ 3,
),
);

if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Duration: $duration'),
duration: Duration(seconds: 5),
),
);
}
}

Utility Functions

The getDirectoryPath function retrieves the root directory path for the app and returns the desired directory path inside the "Data" folder.

utils.dartView on Github
import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:path/path.dart' as path;

import 'dart:io';

Future<String> getDirectoryPath(String dirName) async {
final docDirectory = Platform.isAndroid
? await path_provider.getExternalStorageDirectory()
: await path_provider.getApplicationDocumentsDirectory();

String absPath = docDirectory!.path;

final expectedPath = path.joinAll([absPath, "Data", dirName]);
return expectedPath;
}

// Utility function to convert the milliseconds duration into a suitable format
String convertDurationMillis(int milliseconds) {
if (milliseconds < 1000) return '$milliseconds ms';

int totalSeconds = milliseconds ~/ 1000;
int hours = totalSeconds ~/ 3600;
int minutes = (totalSeconds % 3600) ~/ 60;
int remainingSeconds = totalSeconds % 60;

String hoursText = (hours > 0) ? '$hours h ' : '';
String minutesText = (minutes > 0) ? '$minutes min ' : '';
String secondsText = '$remainingSeconds sec';

return (hoursText + minutesText + secondsText).trim();
}

Required Permissions
To ensure this example functions correctly, the necessary permissions must be added to the project's Android and iOS configuration files:

Add the following code to the android/app/src/main/AndroidManifest.xml file, within the <manifest> block:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

This example uses the Permission Handler package. Be sure to follow the setup guide.