diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md
index 41077b35b449..2fa09eaf79c2 100644
--- a/packages/camera/camera/CHANGELOG.md
+++ b/packages/camera/camera/CHANGELOG.md
@@ -1,5 +1,6 @@
-## NEXT
+## 0.13.0
+* Adds `videoOutputPath` support to `startVideoRecording`.
* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10.
## 0.12.0+1
diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md
index 0a39b9a7478f..f61dfa7ca753 100644
--- a/packages/camera/camera/README.md
+++ b/packages/camera/camera/README.md
@@ -6,9 +6,9 @@
A Flutter plugin for iOS, Android and Web allowing access to the device cameras.
-| | Android | iOS | Web |
-|----------------|---------|-----------|------------------------|
-| **Support** | SDK 24+ | iOS 13.0+ | [See `camera_web `][1] |
+| | Android | iOS | Web |
+| ----------- | ------- | --------- | ---------------------- |
+| **Support** | SDK 24+ | iOS 13.0+ | [See `camera_web `][1] |
## Features
@@ -92,6 +92,58 @@ Here is a list of all permission error codes that can be thrown:
- `AudioAccessRestricted`: iOS only for now. Thrown when audio access is restricted and users cannot grant permission (parental control).
+### Custom Video Recording Path
+
+You can optionally specify a `videoOutputPath` when calling `startVideoRecording()` to save the recorded video directly to a custom absolute file path on the device.
+
+```dart
+// Always ensure the path ends with the .mp4 extension
+await controller.startVideoRecording(
+ videoOutputPath: '/path/to/your/custom_video.mp4',
+);
+```
+
+#### Platform-Specific Considerations
+
+**Android**
+
+Although it is possible to use an absolute path like `/storage/emulated/0/Download/video.mp4`, this is a fragile practice and may fail on many devices or Android versions due to **Scoped Storage** restrictions.
+
+- **Best Practice:** Always use the [path_provider](https://pub.dev/packages/path_provider) package to fetch a safe, writable directory.
+- **Recommended Directory:** Use `getTemporaryDirectory()` or `getApplicationDocumentsDirectory()`.
+
+```dart
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+
+final directory = await getTemporaryDirectory();
+final videoPath = p.join(directory.path, 'my_video.mp4');
+
+await controller.startVideoRecording(videoOutputPath: videoPath);
+```
+
+**iOS**
+
+By default, files saved within the application sandbox are private. If you want the recorded videos to be visible and manageable by the user inside the native iOS **Files app**:
+
+1. Open your `ios/Runner/Info.plist` file.
+2. Add the following keys set to `true`:
+
+```xml
+LSSupportsOpeningDocumentsInPlace
+
+UISupportsDocumentBrowser
+
+```
+
+**Windows**
+
+Similar to Android, ensure you use the `path_provider` package to resolve a valid system path (such as `getApplicationDocumentsDirectory()` or `getApplicationSupportDirectory()`). This helps avoid OS permission issues (`Access Denied`) when writing files directly to protected directories like the root drive.
+
+### Video Recording Example
+
+For a complete, interactive demonstration of video recording with custom file output paths, error handling, and playback, please see the [Video Recording Example](https://github.com/flutter/packages/blob/main/packages/camera/camera/example/lib/video_recording_example.dart) in our example app.
+
### Example
Here is a small example flutter app displaying a full screen camera preview.
diff --git a/packages/camera/camera/example/ios/Runner/Info.plist b/packages/camera/camera/example/ios/Runner/Info.plist
index 745494da50a6..42c3f394b0e0 100644
--- a/packages/camera/camera/example/ios/Runner/Info.plist
+++ b/packages/camera/camera/example/ios/Runner/Info.plist
@@ -2,6 +2,12 @@
+ LSSupportsOpeningDocumentsInPlace
+
+ UISupportsDocumentBrowser
+
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -28,6 +34,8 @@
Can I use the camera please? Only for demo purpose of the app
NSMicrophoneUsageDescription
Only for demo purpose of the app
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -46,9 +54,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/packages/camera/camera/example/lib/video_recording_example.dart b/packages/camera/camera/example/lib/video_recording_example.dart
new file mode 100644
index 000000000000..20edbc56427d
--- /dev/null
+++ b/packages/camera/camera/example/lib/video_recording_example.dart
@@ -0,0 +1,909 @@
+// Copyright 2013 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import 'dart:async';
+import 'dart:io';
+
+import 'package:camera/camera.dart';
+import 'package:flutter/material.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:video_player/video_player.dart';
+
+void main() {
+ runApp(const VideoRecordingExampleApp());
+}
+
+/// Video Recording feature with custom output paths (`videoOutputPath`)
+class VideoRecordingExampleApp extends StatefulWidget {
+ /// Default Constructor.
+ const VideoRecordingExampleApp({super.key});
+
+ @override
+ State createState() =>
+ _VideoRecordingExampleAppState();
+}
+
+class _VideoRecordingExampleAppState extends State {
+ ThemeMode _themeMode = ThemeMode.light;
+
+ void _toggleTheme() {
+ setState(() {
+ _themeMode = _themeMode == ThemeMode.dark
+ ? ThemeMode.light
+ : ThemeMode.dark;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Vivid Video Recorder',
+ themeMode: _themeMode,
+ theme: ThemeData(
+ brightness: Brightness.light,
+ scaffoldBackgroundColor: const Color(0xFFF5F5FA),
+ colorScheme: const ColorScheme.light(
+ primary: Color(0xFF6C63FF),
+ secondary: Color(0xFF00E676),
+ error: Color(0xFFFF1744),
+ ),
+ useMaterial3: true,
+ ),
+ darkTheme: ThemeData(
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: const Color(0xFF0F0F1A),
+ colorScheme: const ColorScheme.dark(
+ primary: Color(0xFF6C63FF),
+ secondary: Color(0xFF00E676),
+ error: Color(0xFFFF1744),
+ surface: Color(0xFF1E1E30),
+ ),
+ useMaterial3: true,
+ ),
+ home: VideoRecordingHome(
+ themeMode: _themeMode,
+ onThemeToggle: _toggleTheme,
+ ),
+ );
+ }
+}
+
+/// Home widget hosting the video recording demo.
+class VideoRecordingHome extends StatefulWidget {
+ /// Default Constructor.
+ const VideoRecordingHome({
+ super.key,
+ required this.themeMode,
+ required this.onThemeToggle,
+ });
+
+ /// The active theme mode.
+ final ThemeMode themeMode;
+
+ /// Callback to toggle between light and dark themes.
+ final VoidCallback onThemeToggle;
+
+ @override
+ State createState() => _VideoRecordingHomeState();
+}
+
+class _VideoRecordingHomeState extends State
+ with SingleTickerProviderStateMixin {
+ List _cameras = [];
+ CameraController? _controller;
+ XFile? _recordedVideo;
+ VideoPlayerController? _videoPlayerController;
+
+ bool _isInitializing = true;
+ bool _useCustomPath = false;
+ bool _audioEnabled = true;
+
+ // Custom path options
+ String _customFileName = 'custom_video_recording.mp4';
+ late TextEditingController _customFileNameController;
+ String? _resolvedCustomPath;
+
+ // SnackBar / Error messaging helper
+ void _showNotification(String message, {bool isError = false}) {
+ if (!mounted) {
+ return;
+ }
+ final isDark = widget.themeMode == ThemeMode.dark;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ Icon(
+ isError ? Icons.error_outline : Icons.info_outline,
+ color: isError ? Colors.redAccent : Colors.greenAccent,
+ ),
+ const SizedBox(width: 12),
+ Expanded(child: Text(message)),
+ ],
+ ),
+ backgroundColor: isDark ? const Color(0xFF1A1A2E) : Colors.white,
+ behavior: SnackBarBehavior.floating,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ );
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _customFileNameController = TextEditingController(text: _customFileName);
+ _bootstrapCameras();
+ }
+
+ Future _bootstrapCameras() async {
+ try {
+ _cameras = await availableCameras();
+ if (_cameras.isNotEmpty) {
+ await _initializeCameraController(_cameras.first);
+ } else {
+ _showNotification('No cameras detected on this device.', isError: true);
+ }
+ } catch (e) {
+ _showNotification('Failed to detect cameras: $e', isError: true);
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isInitializing = false;
+ });
+ }
+ }
+ }
+
+ Future _initializeCameraController(
+ CameraDescription description,
+ ) async {
+ final CameraController? oldController = _controller;
+ if (oldController != null) {
+ _controller = null;
+ await oldController.dispose();
+ }
+
+ final controller = CameraController(
+ description,
+ ResolutionPreset.high,
+ enableAudio: _audioEnabled,
+ );
+
+ _controller = controller;
+
+ try {
+ await controller.initialize();
+ if (!mounted) {
+ await controller.dispose();
+ return;
+ }
+ } on CameraException catch (e) {
+ _showNotification('Camera error: ${e.description}', isError: true);
+ }
+
+ if (mounted) {
+ setState(() {});
+ }
+ }
+
+ /// Calculates the target custom output path where the video will be saved.
+ Future _getDestinationPath() async {
+ final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
+ final file = File(_customFileName);
+
+ if (file.isAbsolute) {
+ return _customFileName;
+ }
+
+ final String uniqueName = _customFileName.replaceFirst(
+ '.mp4',
+ '_$timestamp.mp4',
+ );
+
+ // Although it is possible to use an absolute path like '/storage/emulated/0/Download/',
+ // this is a fragile practice and may fail on many devices or Android versions due to
+ // Scoped Storage restrictions. It is recommended to use path_provider to get a valid
+ // and writable directory.
+ if (Platform.isAndroid) {
+ final Directory? externalDir = await getExternalStorageDirectory();
+ if (externalDir != null) {
+ return '${externalDir.path}/$uniqueName';
+ }
+
+ final Directory fallbackDir = await getApplicationDocumentsDirectory();
+ return '${fallbackDir.path}/$uniqueName';
+ }
+
+ // (Save to "On My iPhone" visible app folder)
+ // Ensure 'LSSupportsOpeningDocumentsInPlace' and 'UISupportsDocumentBrowser'
+ // are set to true in your ios/Runner/Info.plist to make this folder visible in the Files app.
+
+ if (Platform.isIOS) {
+ final Directory appDocDir = await getApplicationDocumentsDirectory();
+
+ // Setup the custom directory structure within the app's documents container
+ final customDirPath = '${appDocDir.path}/Movies/flutter_test';
+ final destinationDir = Directory(customDirPath);
+
+ if (!destinationDir.existsSync()) {
+ destinationDir.createSync(recursive: true);
+ }
+
+ return '${destinationDir.path}/$uniqueName';
+ }
+
+ // Default fallback general path
+ final Directory fallbackDir = await getApplicationDocumentsDirectory();
+ return '${fallbackDir.path}/$uniqueName';
+ }
+
+ Future _startRecording() async {
+ final CameraController? controller = _controller;
+ if (controller == null || !controller.value.isInitialized) {
+ _showNotification('Camera is not ready yet.', isError: true);
+ return;
+ }
+
+ if (controller.value.isRecordingVideo) {
+ return;
+ }
+
+ try {
+ String? outputPath;
+ if (_useCustomPath) {
+ outputPath = await _getDestinationPath();
+ setState(() {
+ _resolvedCustomPath = outputPath;
+ });
+ } else {
+ setState(() {
+ _resolvedCustomPath = null;
+ });
+ }
+
+ // Invokes startVideoRecording with the custom path options!
+ await controller.startVideoRecording(videoOutputPath: outputPath);
+
+ _showNotification(
+ _useCustomPath
+ ? 'Recording started with custom output path!'
+ : 'Recording started using auto-generated path.',
+ );
+ } on CameraException catch (e) {
+ _showNotification(
+ 'Failed to start recording: ${e.description}',
+ isError: true,
+ );
+ } catch (e) {
+ _showNotification('Unexpected error: $e', isError: true);
+ } finally {
+ if (mounted) {
+ setState(() {});
+ }
+ }
+ }
+
+ Future _stopRecording() async {
+ final CameraController? controller = _controller;
+ if (controller == null || !controller.value.isRecordingVideo) {
+ return;
+ }
+
+ try {
+ final XFile file = await controller.stopVideoRecording();
+ setState(() {
+ _recordedVideo = file;
+ });
+
+ _showNotification('Recording finished successfully!');
+
+ await _playRecordedVideo(file);
+ } on CameraException catch (e) {
+ _showNotification(
+ 'Failed to stop recording: ${e.description}',
+ isError: true,
+ );
+ } catch (e) {
+ _showNotification(
+ 'Unexpected error stopping recording: $e',
+ isError: true,
+ );
+ } finally {
+ if (mounted) {
+ setState(() {});
+ }
+ }
+ }
+
+ Future _pauseRecording() async {
+ final CameraController? controller = _controller;
+ if (controller == null || !controller.value.isRecordingVideo) {
+ return;
+ }
+
+ try {
+ await controller.pauseVideoRecording();
+ _showNotification('Video recording paused.');
+ } on CameraException catch (e) {
+ _showNotification('Pause failed: ${e.description}', isError: true);
+ } finally {
+ if (mounted) {
+ setState(() {});
+ }
+ }
+ }
+
+ Future _resumeRecording() async {
+ final CameraController? controller = _controller;
+ if (controller == null || !controller.value.isRecordingVideo) {
+ return;
+ }
+
+ try {
+ await controller.resumeVideoRecording();
+ _showNotification('Video recording resumed.');
+ } on CameraException catch (e) {
+ _showNotification('Resume failed: ${e.description}', isError: true);
+ } finally {
+ if (mounted) {
+ setState(() {});
+ }
+ }
+ }
+
+ Future _playRecordedVideo(XFile file) async {
+ if (_videoPlayerController != null) {
+ await _videoPlayerController!.dispose();
+ }
+
+ final playerController = VideoPlayerController.file(File(file.path));
+
+ _videoPlayerController = playerController;
+
+ try {
+ await playerController.initialize();
+ if (!mounted) {
+ await playerController.dispose();
+ return;
+ }
+ await playerController.setLooping(true);
+ await playerController.play();
+ } catch (e) {
+ _showNotification(
+ 'Failed to load recorded video in player: $e',
+ isError: true,
+ );
+ }
+
+ if (mounted) {
+ setState(() {});
+ }
+ }
+
+ @override
+ void dispose() {
+ _customFileNameController.dispose();
+ _controller?.dispose();
+ _videoPlayerController?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final double screenWidth = MediaQuery.of(context).size.width;
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final Color textPrimary = isDark ? Colors.white : const Color(0xFF1E1E30);
+
+ return Scaffold(
+ body: SafeArea(
+ child: _isInitializing
+ ? const Center(
+ child: CircularProgressIndicator(color: Color(0xFF6C63FF)),
+ )
+ : SingleChildScrollView(
+ padding: const EdgeInsets.all(20.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildHeader(textPrimary),
+ const SizedBox(height: 20),
+ _buildCameraView(isDark),
+ const SizedBox(height: 20),
+ _buildPathSettingsCard(isDark, textPrimary),
+ const SizedBox(height: 20),
+ _buildControlsPanel(isDark),
+ if (_recordedVideo != null) ...[
+ const SizedBox(height: 24),
+ _buildVideoPlayerCard(screenWidth, isDark, textPrimary),
+ ],
+ const SizedBox(height: 30),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHeader(Color textPrimary) {
+ final isDark = widget.themeMode == ThemeMode.dark;
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'VIVID VIDEO RECORDER',
+ style: TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w900,
+ letterSpacing: 1.5,
+ color: textPrimary,
+ ),
+ ),
+ const SizedBox(height: 4),
+ const Text(
+ 'Demonstrating custom output path validation & video recording.',
+ style: TextStyle(fontSize: 12, color: Color(0xFF8F8FA0)),
+ ),
+ ],
+ ),
+ ),
+ IconButton.filledTonal(
+ icon: Icon(isDark ? Icons.light_mode : Icons.dark_mode),
+ style: IconButton.styleFrom(
+ backgroundColor: isDark ? const Color(0xFF1E1E30) : Colors.white,
+ foregroundColor: const Color(0xFF6C63FF),
+ ),
+ onPressed: widget.onThemeToggle,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildCameraView(bool isDark) {
+ final CameraController? controller = _controller;
+ final bool isRecording = controller?.value.isRecordingVideo ?? false;
+ final bool isPaused = controller?.value.isRecordingPaused ?? false;
+
+ return Container(
+ height: 380,
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(
+ color: isRecording
+ ? (isPaused ? Colors.amber : const Color(0xFFFF1744))
+ : (isDark ? const Color(0xFF2C2C40) : const Color(0xFFE2E2EC)),
+ width: 2,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: isRecording
+ ? (isPaused
+ ? Colors.amber.withOpacity(0.15)
+ : const Color(0xFFFF1744).withOpacity(0.15))
+ : Colors.transparent,
+ blurRadius: 16,
+ spreadRadius: 4,
+ ),
+ ],
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (controller != null)
+ CameraPreview(controller)
+ else
+ const Center(
+ child: Text(
+ 'Camera pipeline is uninitialized.',
+ style: TextStyle(color: Colors.grey),
+ ),
+ ),
+ // Recording Status Overlay
+ if (isRecording)
+ Positioned(
+ top: 16,
+ left: 16,
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 6,
+ ),
+ decoration: BoxDecoration(
+ color: (isDark ? const Color(0xFF1E1E30) : Colors.white)
+ .withOpacity(0.85),
+ borderRadius: BorderRadius.circular(30),
+ border: Border.all(
+ color: isPaused ? Colors.amber : const Color(0xFFFF1744),
+ width: 1.5,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: isPaused
+ ? Colors.amber
+ : const Color(0xFFFF1744),
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ isPaused ? 'RECORDING PAUSED' : 'LIVE RECORDING',
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ color: isPaused
+ ? Colors.amber
+ : const Color(0xFFFF1744),
+ letterSpacing: 1.0,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPathSettingsCard(bool isDark, Color textPrimary) {
+ final Color cardBgColor = isDark ? const Color(0xFF161626) : Colors.white;
+ final borderBgColor = isDark
+ ? const Color(0xFF2C2C40)
+ : const Color(0xFFE2E2EC);
+ final subBgColor = isDark
+ ? const Color(0xFF0F0F1A)
+ : const Color(0xFFF5F5FA);
+ final textSecondary = isDark
+ ? const Color(0xFF8F8FA0)
+ : const Color(0xFF6E6E7E);
+
+ return Container(
+ decoration: BoxDecoration(
+ color: cardBgColor,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: borderBgColor),
+ boxShadow: isDark
+ ? const []
+ : [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.05),
+ blurRadius: 10,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ const Icon(
+ Icons.folder_open,
+ color: Color(0xFF6C63FF),
+ size: 20,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ 'Custom Output Path',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 15,
+ color: textPrimary,
+ ),
+ ),
+ ],
+ ),
+ Switch.adaptive(
+ value: _useCustomPath,
+ activeColor: const Color(0xFF6C63FF),
+ onChanged: (bool value) {
+ setState(() {
+ _useCustomPath = value;
+ });
+ },
+ ),
+ ],
+ ),
+ if (_useCustomPath) ...[
+ const SizedBox(height: 12),
+ TextField(
+ style: TextStyle(color: textPrimary),
+ decoration: InputDecoration(
+ labelText: 'Filename or absolute path',
+ labelStyle: TextStyle(color: textSecondary, fontSize: 13),
+ hintText: 'my_video.mp4 or /storage/...',
+ prefixIcon: Icon(
+ Icons.description,
+ size: 18,
+ color: textSecondary,
+ ),
+ filled: true,
+ fillColor: subBgColor,
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide(color: borderBgColor),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(
+ color: Color(0xFF6C63FF),
+ width: 1.5,
+ ),
+ ),
+ ),
+ controller: _customFileNameController,
+ onChanged: (String val) {
+ _customFileName = val;
+ },
+ ),
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: subBgColor,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'VALIDATION ENFORCEMENT RULES',
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ color: textSecondary,
+ letterSpacing: 0.5,
+ ),
+ ),
+ const SizedBox(height: 6),
+ _buildValidationBullet(
+ 'Must end with supported extension (.mp4)',
+ isDark,
+ ),
+ _buildValidationBullet(
+ 'Cannot be an existing directory',
+ isDark,
+ ),
+ _buildValidationBullet(
+ 'Parent folder must exist on device storage',
+ isDark,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _buildValidationBullet(String text, bool isDark) {
+ final textSecondary = isDark
+ ? const Color(0xFFB0B0C0)
+ : const Color(0xFF5A5A6A);
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 4.0),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Icon(Icons.check_circle, size: 12, color: Color(0xFF00E676)),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ text,
+ style: TextStyle(fontSize: 11, color: textSecondary),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildControlsPanel(bool isDark) {
+ final CameraController? controller = _controller;
+ final bool isRecording = controller?.value.isRecordingVideo ?? false;
+ final bool isPaused = controller?.value.isRecordingPaused ?? false;
+ final Color controlBgColor = isDark
+ ? const Color(0xFF1E1E30)
+ : Colors.white;
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ // Audio Toggle
+ IconButton.filledTonal(
+ icon: Icon(_audioEnabled ? Icons.mic : Icons.mic_off),
+ style: IconButton.styleFrom(
+ backgroundColor: controlBgColor,
+ foregroundColor: _audioEnabled
+ ? const Color(0xFF00E676)
+ : Colors.grey,
+ padding: const EdgeInsets.all(14),
+ side: isDark
+ ? BorderSide.none
+ : const BorderSide(color: Color(0xFFE2E2EC)),
+ ),
+ onPressed: isRecording
+ ? null
+ : () {
+ setState(() {
+ _audioEnabled = !_audioEnabled;
+ if (_controller != null) {
+ _initializeCameraController(_controller!.description);
+ }
+ });
+ },
+ ),
+
+ // Primary Record / Stop Button
+ GestureDetector(
+ onTap: isRecording ? _stopRecording : _startRecording,
+ child: Container(
+ height: 76,
+ width: 76,
+ decoration: BoxDecoration(
+ color: isRecording
+ ? const Color(0xFFFF1744)
+ : const Color(0xFF6C63FF),
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color:
+ (isRecording
+ ? const Color(0xFFFF1744)
+ : const Color(0xFF6C63FF))
+ .withOpacity(0.3),
+ blurRadius: 12,
+ spreadRadius: 2,
+ ),
+ ],
+ ),
+ child: Icon(
+ isRecording ? Icons.stop : Icons.videocam,
+ size: 32,
+ color: Colors.white,
+ ),
+ ),
+ ),
+
+ // Pause / Resume Toggle
+ IconButton.filledTonal(
+ icon: Icon(isPaused ? Icons.play_arrow : Icons.pause),
+ style: IconButton.styleFrom(
+ backgroundColor: controlBgColor,
+ foregroundColor: const Color(0xFF6C63FF),
+ padding: const EdgeInsets.all(14),
+ side: isDark
+ ? BorderSide.none
+ : const BorderSide(color: Color(0xFFE2E2EC)),
+ ),
+ onPressed: isRecording
+ ? (isPaused ? _resumeRecording : _pauseRecording)
+ : null,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildVideoPlayerCard(
+ double screenWidth,
+ bool isDark,
+ Color textPrimary,
+ ) {
+ final VideoPlayerController? playerController = _videoPlayerController;
+ final Color cardBgColor = isDark ? const Color(0xFF161626) : Colors.white;
+ final borderBgColor = isDark
+ ? const Color(0xFF2C2C40)
+ : const Color(0xFFE2E2EC);
+
+ return Container(
+ decoration: BoxDecoration(
+ color: cardBgColor,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: borderBgColor),
+ boxShadow: isDark
+ ? const []
+ : [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.05),
+ blurRadius: 10,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ const Icon(
+ Icons.video_library,
+ color: Color(0xFF00E676),
+ size: 20,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ 'Recorded Video Output',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 15,
+ color: textPrimary,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ if (playerController != null && playerController.value.isInitialized)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(12),
+ child: AspectRatio(
+ aspectRatio: playerController.value.aspectRatio,
+ child: VideoPlayer(playerController),
+ ),
+ )
+ else
+ const Center(
+ child: Padding(
+ padding: EdgeInsets.symmetric(vertical: 24.0),
+ child: CircularProgressIndicator(color: Color(0xFF00E676)),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'Saved Location Path:',
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ color: Colors.grey[600],
+ ),
+ ),
+ const SizedBox(height: 4),
+ SelectableText(
+ _recordedVideo?.path ?? 'Unknown',
+ style: const TextStyle(
+ fontSize: 11,
+ fontFamily: 'Courier',
+ color: Color(0xFF00E676),
+ ),
+ ),
+ if (_resolvedCustomPath != null) ...[
+ const SizedBox(height: 10),
+ Text(
+ 'Requested Custom Path:',
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ color: Colors.grey[600],
+ ),
+ ),
+ const SizedBox(height: 4),
+ SelectableText(
+ _resolvedCustomPath!,
+ style: const TextStyle(
+ fontSize: 11,
+ fontFamily: 'Courier',
+ color: Color(0xFF6C63FF),
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/camera/camera/example/pubspec.yaml b/packages/camera/camera/example/pubspec.yaml
index d94d8bef8cfa..ee8615db2247 100644
--- a/packages/camera/camera/example/pubspec.yaml
+++ b/packages/camera/camera/example/pubspec.yaml
@@ -31,3 +31,10 @@ dev_dependencies:
flutter:
uses-material-design: true
+# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE.
+# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins
+dependency_overrides:
+ camera_android_camerax: {path: ../../../../packages/camera/camera_android_camerax}
+ camera_avfoundation: {path: ../../../../packages/camera/camera_avfoundation}
+ camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface}
+ camera_web: {path: ../../../../packages/camera/camera_web}
diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart
index d941c2e85f66..38de580e2200 100644
--- a/packages/camera/camera/lib/src/camera_controller.dart
+++ b/packages/camera/camera/lib/src/camera_controller.dart
@@ -576,6 +576,7 @@ class CameraController extends ValueNotifier {
Future startVideoRecording({
onLatestImageAvailable? onAvailable,
bool enablePersistentRecording = true,
+ String? videoOutputPath,
}) async {
_throwIfNotInitialized('startVideoRecording');
if (value.isRecordingVideo) {
@@ -585,6 +586,16 @@ class CameraController extends ValueNotifier {
);
}
+ if (videoOutputPath != null) {
+ final String lowerPath = videoOutputPath.toLowerCase();
+ if (!lowerPath.endsWith('.mp4')) {
+ throw CameraException(
+ 'InvalidFilePath',
+ 'Invalid video extension. Supported: .mp4',
+ );
+ }
+ }
+
void Function(CameraImageData image)? streamCallback;
if (onAvailable != null) {
streamCallback = (CameraImageData imageData) {
@@ -598,6 +609,7 @@ class CameraController extends ValueNotifier {
_cameraId,
streamCallback: streamCallback,
enablePersistentRecording: enablePersistentRecording,
+ videoOutputPath: videoOutputPath,
),
);
value = value.copyWith(
diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml
index f2cecaf6a7bc..eae0a04481aa 100644
--- a/packages/camera/camera/pubspec.yaml
+++ b/packages/camera/camera/pubspec.yaml
@@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing
Dart.
repository: https://github.com/flutter/packages/tree/main/packages/camera/camera
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
-version: 0.12.0+1
+version: 0.13.0
environment:
sdk: ^3.10.0
@@ -23,7 +23,7 @@ flutter:
dependencies:
camera_android_camerax: ^0.7.0
camera_avfoundation: ^0.10.0
- camera_platform_interface: ^2.12.0
+ camera_platform_interface: ^2.14.0
camera_web: ^0.3.3
flutter:
sdk: flutter
@@ -38,3 +38,10 @@ dev_dependencies:
topics:
- camera
+# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE.
+# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins
+dependency_overrides:
+ camera_android_camerax: {path: ../../../packages/camera/camera_android_camerax}
+ camera_avfoundation: {path: ../../../packages/camera/camera_avfoundation}
+ camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface}
+ camera_web: {path: ../../../packages/camera/camera_web}
diff --git a/packages/camera/camera/test/camera_image_stream_test.dart b/packages/camera/camera/test/camera_image_stream_test.dart
index 0a9644fcd8df..f6ef7c29e827 100644
--- a/packages/camera/camera/test/camera_image_stream_test.dart
+++ b/packages/camera/camera/test/camera_image_stream_test.dart
@@ -261,10 +261,17 @@ class MockStreamingCameraPlatform extends MockCameraPlatform {
}
@override
- Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) {
+ Future startVideoRecording(
+ int cameraId, {
+ Duration? maxVideoDuration,
+ String? videoOutputPath,
+ }) {
streamCallLog.add('startVideoRecording');
// Ignore maxVideoDuration, as it is unimplemented and deprecated.
- return super.startVideoRecording(cameraId);
+ return super.startVideoRecording(
+ cameraId,
+ videoOutputPath: videoOutputPath,
+ );
}
@override
diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart
index 912a583e9255..f87cd036cd70 100644
--- a/packages/camera/camera/test/camera_preview_test.dart
+++ b/packages/camera/camera/test/camera_preview_test.dart
@@ -113,6 +113,7 @@ class FakeController extends ValueNotifier
Future startVideoRecording({
onLatestImageAvailable? onAvailable,
bool enablePersistentRecording = true,
+ String? videoOutputPath,
}) async {}
@override
diff --git a/packages/camera/camera/test/camera_test.dart b/packages/camera/camera/test/camera_test.dart
index 913d3391cd9e..d7594ac08410 100644
--- a/packages/camera/camera/test/camera_test.dart
+++ b/packages/camera/camera/test/camera_test.dart
@@ -4019,9 +4019,15 @@ class MockCameraPlatform extends Mock
super.noSuchMethod(Invocation.method(#prepareForVideoRecording, null));
@override
- Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) {
+ Future startVideoRecording(
+ int cameraId, {
+ Duration? maxVideoDuration,
+ String? videoOutputPath,
+ }) {
// Ignore maxVideoDuration, as it is unimplemented and deprecated.
- return startVideoCapturing(VideoCaptureOptions(cameraId));
+ return startVideoCapturing(
+ VideoCaptureOptions(cameraId, videoOutputPath: videoOutputPath),
+ );
}
@override
diff --git a/packages/camera/camera_android/CHANGELOG.md b/packages/camera/camera_android/CHANGELOG.md
index b7bb3e538c37..a33d15ac0fca 100644
--- a/packages/camera/camera_android/CHANGELOG.md
+++ b/packages/camera/camera_android/CHANGELOG.md
@@ -1,5 +1,6 @@
-## NEXT
+## 0.10.11
+* Adds support for custom video output path in video recording.
* Updates minimum supported SDK version to Flutter 3.38/Dart 3.10.
## 0.10.10+17
diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java
index 1d543bd86559..39f85ad08625 100644
--- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java
+++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java
@@ -869,8 +869,9 @@ void unlockAutoFocus() {
dartMessenger.error(flutterResult, errorCode, errorMessage, null));
}
- public void startVideoRecording(@Nullable EventChannel imageStreamChannel) {
- prepareRecording();
+ public void startVideoRecording(
+ @Nullable EventChannel imageStreamChannel, @Nullable String videoOutputPath) {
+ prepareRecording(videoOutputPath);
if (imageStreamChannel != null) {
setStreamHandler(imageStreamChannel);
@@ -1296,13 +1297,19 @@ public void onError(@NonNull String errorCode, @NonNull String errorMessage) {
}
@VisibleForTesting
- void prepareRecording() {
- final File outputDir = applicationContext.getCacheDir();
- try {
- captureFile = File.createTempFile("REC", ".mp4", outputDir);
- } catch (IOException | SecurityException e) {
- throw new Messages.FlutterError("cannotCreateFile", e.getMessage(), null);
+ void prepareRecording(@Nullable String videoOutputPath) {
+ if (videoOutputPath != null) {
+ validateOutputPath(videoOutputPath);
+ captureFile = new File(videoOutputPath);
+ } else {
+ final File outputDir = applicationContext.getCacheDir();
+ try {
+ captureFile = File.createTempFile("REC", ".mp4", outputDir);
+ } catch (IOException | SecurityException e) {
+ throw new Messages.FlutterError("cannotCreateFile", e.getMessage(), null);
+ }
}
+
try {
prepareMediaRecorder(captureFile.getAbsolutePath());
} catch (IOException e) {
@@ -1317,6 +1324,23 @@ void prepareRecording() {
setFpsCameraFeatureForRecording(cameraProperties);
}
+ private void validateOutputPath(String path) {
+ File file = new File(path);
+ if (file.isDirectory()) {
+ throw new Messages.FlutterError("IOError", "The output path is a directory: " + path, null);
+ }
+ File parent = file.getParentFile();
+ if (parent != null && !parent.exists()) {
+ throw new Messages.FlutterError(
+ "IOError", "The parent directory does not exist: " + parent.getAbsolutePath(), null);
+ }
+
+ String lowerPath = path.toLowerCase(Locale.ROOT);
+ if (!lowerPath.endsWith(".mp4")) {
+ throw new Messages.FlutterError("IOError", "Invalid video extension. Supported: .mp4", null);
+ }
+ }
+
private void setStreamHandler(EventChannel imageStreamChannel) {
imageStreamChannel.setStreamHandler(
new EventChannel.StreamHandler() {
diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java
index b3b04d5309e5..95a8d210b0af 100644
--- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java
+++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/CameraApiImpl.java
@@ -175,8 +175,8 @@ public void takePicture(@NonNull Messages.Result result) {
}
@Override
- public void startVideoRecording(@NonNull Boolean enableStream) {
- camera.startVideoRecording(enableStream ? imageStreamChannel : null);
+ public void startVideoRecording(@NonNull Boolean enableStream, @Nullable String videoOutputPath) {
+ camera.startVideoRecording(enableStream ? imageStreamChannel : null, videoOutputPath);
}
@NonNull
diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Messages.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Messages.java
index e61e0c48c199..ce916c0161b2 100644
--- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Messages.java
+++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Messages.java
@@ -1,7 +1,7 @@
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-// Autogenerated from Pigeon (v26.1.0), do not edit directly.
+// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
package io.flutter.plugins.camera;
@@ -21,13 +21,171 @@
import java.lang.annotation.Target;
import java.nio.ByteBuffer;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
-import java.util.Objects;
+import java.util.Map;
/** Generated class from Pigeon. */
@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression", "serial"})
public class Messages {
+ static boolean pigeonDoubleEquals(double a, double b) {
+ // Normalize -0.0 to 0.0 and handle NaN equality.
+ return (a == 0.0 ? 0.0 : a) == (b == 0.0 ? 0.0 : b) || (Double.isNaN(a) && Double.isNaN(b));
+ }
+
+ static boolean pigeonFloatEquals(float a, float b) {
+ // Normalize -0.0 to 0.0 and handle NaN equality.
+ return (a == 0.0f ? 0.0f : a) == (b == 0.0f ? 0.0f : b) || (Float.isNaN(a) && Float.isNaN(b));
+ }
+
+ static int pigeonDoubleHashCode(double d) {
+ // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
+ if (d == 0.0) {
+ d = 0.0;
+ }
+ long bits = Double.doubleToLongBits(d);
+ return (int) (bits ^ (bits >>> 32));
+ }
+
+ static int pigeonFloatHashCode(float f) {
+ // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
+ if (f == 0.0f) {
+ f = 0.0f;
+ }
+ return Float.floatToIntBits(f);
+ }
+
+ static boolean pigeonDeepEquals(Object a, Object b) {
+ if (a == b) {
+ return true;
+ }
+ if (a == null || b == null) {
+ return false;
+ }
+ if (a instanceof byte[] && b instanceof byte[]) {
+ return Arrays.equals((byte[]) a, (byte[]) b);
+ }
+ if (a instanceof int[] && b instanceof int[]) {
+ return Arrays.equals((int[]) a, (int[]) b);
+ }
+ if (a instanceof long[] && b instanceof long[]) {
+ return Arrays.equals((long[]) a, (long[]) b);
+ }
+ if (a instanceof double[] && b instanceof double[]) {
+ double[] da = (double[]) a;
+ double[] db = (double[]) b;
+ if (da.length != db.length) {
+ return false;
+ }
+ for (int i = 0; i < da.length; i++) {
+ if (!pigeonDoubleEquals(da[i], db[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+ if (a instanceof List && b instanceof List) {
+ List> listA = (List>) a;
+ List> listB = (List>) b;
+ if (listA.size() != listB.size()) {
+ return false;
+ }
+ for (int i = 0; i < listA.size(); i++) {
+ if (!pigeonDeepEquals(listA.get(i), listB.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+ if (a instanceof Map && b instanceof Map) {
+ Map, ?> mapA = (Map, ?>) a;
+ Map, ?> mapB = (Map, ?>) b;
+ if (mapA.size() != mapB.size()) {
+ return false;
+ }
+ for (Map.Entry, ?> entryA : mapA.entrySet()) {
+ Object keyA = entryA.getKey();
+ Object valueA = entryA.getValue();
+ boolean found = false;
+ for (Map.Entry, ?> entryB : mapB.entrySet()) {
+ Object keyB = entryB.getKey();
+ if (pigeonDeepEquals(keyA, keyB)) {
+ Object valueB = entryB.getValue();
+ if (pigeonDeepEquals(valueA, valueB)) {
+ found = true;
+ break;
+ } else {
+ return false;
+ }
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+ return true;
+ }
+ if (a instanceof Double && b instanceof Double) {
+ return pigeonDoubleEquals((double) a, (double) b);
+ }
+ if (a instanceof Float && b instanceof Float) {
+ return pigeonFloatEquals((float) a, (float) b);
+ }
+ return a.equals(b);
+ }
+
+ static int pigeonDeepHashCode(Object value) {
+ if (value == null) {
+ return 0;
+ }
+ if (value instanceof byte[]) {
+ return Arrays.hashCode((byte[]) value);
+ }
+ if (value instanceof int[]) {
+ return Arrays.hashCode((int[]) value);
+ }
+ if (value instanceof long[]) {
+ return Arrays.hashCode((long[]) value);
+ }
+ if (value instanceof double[]) {
+ double[] da = (double[]) value;
+ int result = 1;
+ for (double d : da) {
+ result = 31 * result + pigeonDoubleHashCode(d);
+ }
+ return result;
+ }
+ if (value instanceof List) {
+ int result = 1;
+ for (Object item : (List>) value) {
+ result = 31 * result + pigeonDeepHashCode(item);
+ }
+ return result;
+ }
+ if (value instanceof Map) {
+ int result = 0;
+ for (Map.Entry, ?> entry : ((Map, ?>) value).entrySet()) {
+ result +=
+ ((pigeonDeepHashCode(entry.getKey()) * 31) ^ pigeonDeepHashCode(entry.getValue()));
+ }
+ return result;
+ }
+ if (value instanceof Object[]) {
+ int result = 1;
+ for (Object item : (Object[]) value) {
+ result = 31 * result + pigeonDeepHashCode(item);
+ }
+ return result;
+ }
+ if (value instanceof Double) {
+ return pigeonDoubleHashCode((double) value);
+ }
+ if (value instanceof Float) {
+ return pigeonFloatHashCode((float) value);
+ }
+ return value.hashCode();
+ }
/** Error class for passing custom error details to Flutter via a thrown PlatformException. */
public static class FlutterError extends RuntimeException {
@@ -224,14 +382,15 @@ public boolean equals(Object o) {
return false;
}
PlatformCameraDescription that = (PlatformCameraDescription) o;
- return name.equals(that.name)
- && lensDirection.equals(that.lensDirection)
- && sensorOrientation.equals(that.sensorOrientation);
+ return pigeonDeepEquals(name, that.name)
+ && pigeonDeepEquals(lensDirection, that.lensDirection)
+ && pigeonDeepEquals(sensorOrientation, that.sensorOrientation);
}
@Override
public int hashCode() {
- return Objects.hash(name, lensDirection, sensorOrientation);
+ Object[] fields = new Object[] {getClass(), name, lensDirection, sensorOrientation};
+ return pigeonDeepHashCode(fields);
}
public static final class Builder {
@@ -373,17 +532,25 @@ public boolean equals(Object o) {
return false;
}
PlatformCameraState that = (PlatformCameraState) o;
- return previewSize.equals(that.previewSize)
- && exposureMode.equals(that.exposureMode)
- && focusMode.equals(that.focusMode)
- && exposurePointSupported.equals(that.exposurePointSupported)
- && focusPointSupported.equals(that.focusPointSupported);
+ return pigeonDeepEquals(previewSize, that.previewSize)
+ && pigeonDeepEquals(exposureMode, that.exposureMode)
+ && pigeonDeepEquals(focusMode, that.focusMode)
+ && pigeonDeepEquals(exposurePointSupported, that.exposurePointSupported)
+ && pigeonDeepEquals(focusPointSupported, that.focusPointSupported);
}
@Override
public int hashCode() {
- return Objects.hash(
- previewSize, exposureMode, focusMode, exposurePointSupported, focusPointSupported);
+ Object[] fields =
+ new Object[] {
+ getClass(),
+ previewSize,
+ exposureMode,
+ focusMode,
+ exposurePointSupported,
+ focusPointSupported
+ };
+ return pigeonDeepHashCode(fields);
}
public static final class Builder {
@@ -510,12 +677,13 @@ public boolean equals(Object o) {
return false;
}
PlatformSize that = (PlatformSize) o;
- return width.equals(that.width) && height.equals(that.height);
+ return pigeonDeepEquals(width, that.width) && pigeonDeepEquals(height, that.height);
}
@Override
public int hashCode() {
- return Objects.hash(width, height);
+ Object[] fields = new Object[] {getClass(), width, height};
+ return pigeonDeepHashCode(fields);
}
public static final class Builder {
@@ -606,12 +774,13 @@ public boolean equals(Object o) {
return false;
}
PlatformPoint that = (PlatformPoint) o;
- return x.equals(that.x) && y.equals(that.y);
+ return pigeonDeepEquals(x, that.x) && pigeonDeepEquals(y, that.y);
}
@Override
public int hashCode() {
- return Objects.hash(x, y);
+ Object[] fields = new Object[] {getClass(), x, y};
+ return pigeonDeepHashCode(fields);
}
public static final class Builder {
@@ -732,16 +901,18 @@ public boolean equals(Object o) {
return false;
}
PlatformMediaSettings that = (PlatformMediaSettings) o;
- return resolutionPreset.equals(that.resolutionPreset)
- && Objects.equals(fps, that.fps)
- && Objects.equals(videoBitrate, that.videoBitrate)
- && Objects.equals(audioBitrate, that.audioBitrate)
- && enableAudio.equals(that.enableAudio);
+ return pigeonDeepEquals(resolutionPreset, that.resolutionPreset)
+ && pigeonDeepEquals(fps, that.fps)
+ && pigeonDeepEquals(videoBitrate, that.videoBitrate)
+ && pigeonDeepEquals(audioBitrate, that.audioBitrate)
+ && pigeonDeepEquals(enableAudio, that.enableAudio);
}
@Override
public int hashCode() {
- return Objects.hash(resolutionPreset, fps, videoBitrate, audioBitrate, enableAudio);
+ Object[] fields =
+ new Object[] {getClass(), resolutionPreset, fps, videoBitrate, audioBitrate, enableAudio};
+ return pigeonDeepHashCode(fields);
}
public static final class Builder {
@@ -993,7 +1164,7 @@ void create(
void takePicture(@NonNull Result result);
/** Starts recording a video on the camera with the given ID. */
- void startVideoRecording(@NonNull Boolean enableStream);
+ void startVideoRecording(@NonNull Boolean enableStream, @Nullable String videoOutputPath);
/**
* Ends video recording on the camera with the given ID and returns the path to the resulting
@@ -1285,8 +1456,9 @@ public void error(Throwable error) {
ArrayList