Skip to content

Commit 34eec78

Browse files
[camera_android_camerax] Implement setDescriptionWhileRecording (flutter#10030)
setDescriptionWhileRecording allows switching camera while a video recording is in progress Fixes [#148013](flutter/flutter#148013) ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 58c3312 commit 34eec78

File tree

14 files changed

+1093
-182
lines changed

14 files changed

+1093
-182
lines changed

packages/camera/camera_android_camerax/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.6.22
2+
3+
* Implements `setDescriptionWhileRecording`.
4+
15
## 0.6.21+2
26

37
* Bumps com.google.guava:guava from 33.4.8-android to 33.5.0-android.

packages/camera/camera_android_camerax/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ use cases, the plugin behaves according to the following:
3434
video recording and image streaming is supported, but concurrent video recording, image
3535
streaming, and image capture is not supported.
3636

37-
### `setDescriptionWhileRecording` is unimplemented [Issue #148013][148013]
38-
`setDescriptionWhileRecording`, used to switch cameras while recording video, is currently unimplemented
39-
due to this not currently being supported by CameraX.
40-
4137
### 240p resolution configuration for video recording
4238

4339
240p resolution configuration for video recording is unsupported by CameraX, and thus,
@@ -73,6 +69,12 @@ in the merged Android manifest of your app, then take the following steps to rem
7369
tools:node="remove" />
7470
```
7571

72+
### Notes on video capture
73+
74+
#### Setting description while recording
75+
To avoid cancelling any active recording when calling `setDescriptionWhileRecording`,
76+
you must start the recording with `startVideoCapturing` with `enablePersistentRecording` set to `true`.
77+
7678
### Notes on image streaming
7779

7880
#### Allowing image streaming in the background

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3652,6 +3652,22 @@ abstract class PigeonApiPendingRecording(
36523652
initialMuted: Boolean
36533653
): androidx.camera.video.PendingRecording
36543654

3655+
/**
3656+
* Configures the recording to be a persistent recording.
3657+
*
3658+
* A persistent recording will only be stopped by explicitly calling [Recording.stop] or
3659+
* [Recording.close] and will ignore events that would normally cause recording to stop, such as
3660+
* lifecycle events or explicit unbinding of a [VideoCapture] use case that the recording's
3661+
* Recorder is attached to.
3662+
*
3663+
* To switch to a different camera stream while a recording is in progress, first create the
3664+
* recording as persistent recording, then rebind the [VideoCapture] it's associated with to a
3665+
* different camera.
3666+
*/
3667+
abstract fun asPersistentRecording(
3668+
pigeon_instance: androidx.camera.video.PendingRecording
3669+
): androidx.camera.video.PendingRecording
3670+
36553671
/** Starts the recording, making it an active recording. */
36563672
abstract fun start(
36573673
pigeon_instance: androidx.camera.video.PendingRecording,
@@ -3685,6 +3701,28 @@ abstract class PigeonApiPendingRecording(
36853701
channel.setMessageHandler(null)
36863702
}
36873703
}
3704+
run {
3705+
val channel =
3706+
BasicMessageChannel<Any?>(
3707+
binaryMessenger,
3708+
"dev.flutter.pigeon.camera_android_camerax.PendingRecording.asPersistentRecording",
3709+
codec)
3710+
if (api != null) {
3711+
channel.setMessageHandler { message, reply ->
3712+
val args = message as List<Any?>
3713+
val pigeon_instanceArg = args[0] as androidx.camera.video.PendingRecording
3714+
val wrapped: List<Any?> =
3715+
try {
3716+
listOf(api.asPersistentRecording(pigeon_instanceArg))
3717+
} catch (exception: Throwable) {
3718+
CameraXLibraryPigeonUtils.wrapError(exception)
3719+
}
3720+
reply.reply(wrapped)
3721+
}
3722+
} else {
3723+
channel.setMessageHandler(null)
3724+
}
3725+
}
36883726
run {
36893727
val channel =
36903728
BasicMessageChannel<Any?>(

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingProxyApi.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import android.Manifest;
88
import android.content.pm.PackageManager;
99
import androidx.annotation.NonNull;
10+
import androidx.camera.video.ExperimentalPersistentRecording;
1011
import androidx.camera.video.PendingRecording;
1112
import androidx.camera.video.Recording;
1213
import androidx.core.content.ContextCompat;
@@ -27,6 +28,13 @@ public ProxyApiRegistrar getPigeonRegistrar() {
2728
return (ProxyApiRegistrar) super.getPigeonRegistrar();
2829
}
2930

31+
@ExperimentalPersistentRecording
32+
@NonNull
33+
@Override
34+
public PendingRecording asPersistentRecording(PendingRecording pigeonInstance) {
35+
return pigeonInstance.asPersistentRecording();
36+
}
37+
3038
@NonNull
3139
@Override
3240
public PendingRecording withAudioEnabled(PendingRecording pigeonInstance, boolean initialMuted) {

packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PendingRecordingTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ public void withAudioEnabled_doesNotEnableAudioWhenAudioNotRequested() {
8383
verify(instance).withAudioEnabled(true);
8484
}
8585

86+
@Test
87+
public void asPersistentRecording_returnsPersistentRecordingInstance() {
88+
final PigeonApiPendingRecording api =
89+
new TestProxyApiRegistrar().getPigeonApiPendingRecording();
90+
final PendingRecording instance = mock(PendingRecording.class);
91+
final PendingRecording persistentInstance = mock(PendingRecording.class);
92+
93+
when(instance.asPersistentRecording()).thenReturn(persistentInstance);
94+
95+
assertEquals(persistentInstance, api.asPersistentRecording(instance));
96+
verify(instance).asPersistentRecording();
97+
}
98+
8699
@Test
87100
public void start_callsStartOnInstance() {
88101
final PigeonApiPendingRecording api =

packages/camera/camera_android_camerax/example/integration_test/integration_test.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,49 @@ void main() {
226226

227227
expect(duration, lessThan(recordingTime - timePaused));
228228
}, skip: skipFor157181);
229+
230+
testWidgets('Set description while recording captures full video', (
231+
WidgetTester tester,
232+
) async {
233+
final List<CameraDescription> cameras = await availableCameras();
234+
if (cameras.length < 2) {
235+
return;
236+
}
237+
238+
final CameraController controller = CameraController(
239+
cameras[0],
240+
mediaSettings: const MediaSettings(
241+
resolutionPreset: ResolutionPreset.medium,
242+
enableAudio: true,
243+
),
244+
);
245+
await controller.initialize();
246+
await controller.prepareForVideoRecording();
247+
248+
await controller.startVideoRecording();
249+
250+
await controller.setDescription(cameras[1]);
251+
252+
await tester.pumpAndSettle(const Duration(seconds: 4));
253+
254+
await controller.setDescription(cameras[0]);
255+
256+
await tester.pumpAndSettle(const Duration(seconds: 1));
257+
258+
final XFile file = await controller.stopVideoRecording();
259+
260+
final File videoFile = File(file.path);
261+
final VideoPlayerController videoController = VideoPlayerController.file(
262+
videoFile,
263+
);
264+
await videoController.initialize();
265+
final int duration = videoController.value.duration.inMilliseconds;
266+
await videoController.dispose();
267+
268+
expect(
269+
duration,
270+
greaterThanOrEqualTo(const Duration(seconds: 4).inMilliseconds),
271+
);
272+
await controller.dispose();
273+
});
229274
}

packages/camera/camera_android_camerax/example/lib/camera_controller.dart

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,15 @@ class CameraValue {
4949
required this.exposurePointSupported,
5050
required this.focusPointSupported,
5151
required this.deviceOrientation,
52+
required this.description,
5253
this.lockedCaptureOrientation,
5354
this.recordingOrientation,
5455
this.isPreviewPaused = false,
5556
this.previewPauseOrientation,
5657
}) : _isRecordingPaused = isRecordingPaused;
5758

5859
/// Creates a new camera controller state for an uninitialized controller.
59-
const CameraValue.uninitialized()
60+
const CameraValue.uninitialized(CameraDescription description)
6061
: this(
6162
isInitialized: false,
6263
isRecordingVideo: false,
@@ -70,6 +71,7 @@ class CameraValue {
7071
focusPointSupported: false,
7172
deviceOrientation: DeviceOrientation.portraitUp,
7273
isPreviewPaused: false,
74+
description: description,
7375
);
7476

7577
/// True after [CameraController.initialize] has completed successfully.
@@ -143,6 +145,9 @@ class CameraValue {
143145
/// The orientation of the currently running video recording.
144146
final DeviceOrientation? recordingOrientation;
145147

148+
/// The properties of the camera device controlled by this controller.
149+
final CameraDescription description;
150+
146151
/// Creates a modified copy of the object.
147152
///
148153
/// Explicitly specified fields get the specified value, all other fields get
@@ -164,6 +169,7 @@ class CameraValue {
164169
Optional<DeviceOrientation>? lockedCaptureOrientation,
165170
Optional<DeviceOrientation>? recordingOrientation,
166171
bool? isPreviewPaused,
172+
CameraDescription? description,
167173
Optional<DeviceOrientation>? previewPauseOrientation,
168174
}) {
169175
return CameraValue(
@@ -190,6 +196,7 @@ class CameraValue {
190196
? this.recordingOrientation
191197
: recordingOrientation.orNull,
192198
isPreviewPaused: isPreviewPaused ?? this.isPreviewPaused,
199+
description: description ?? this.description,
193200
previewPauseOrientation:
194201
previewPauseOrientation == null
195202
? this.previewPauseOrientation
@@ -214,7 +221,8 @@ class CameraValue {
214221
'lockedCaptureOrientation: $lockedCaptureOrientation, '
215222
'recordingOrientation: $recordingOrientation, '
216223
'isPreviewPaused: $isPreviewPaused, '
217-
'previewPausedOrientation: $previewPauseOrientation)';
224+
'previewPausedOrientation: $previewPauseOrientation, '
225+
'description: $description)';
218226
}
219227
}
220228

@@ -228,13 +236,13 @@ class CameraValue {
228236
class CameraController extends ValueNotifier<CameraValue> {
229237
/// Creates a new camera controller in an uninitialized state.
230238
CameraController(
231-
this.description, {
239+
CameraDescription description, {
232240
this.mediaSettings,
233241
this.imageFormatGroup,
234-
}) : super(const CameraValue.uninitialized());
242+
}) : super(CameraValue.uninitialized(description));
235243

236244
/// The properties of the camera device controlled by this controller.
237-
final CameraDescription description;
245+
CameraDescription get description => value.description;
238246

239247
/// The media settings this controller is targeting.
240248
///
@@ -273,7 +281,12 @@ class CameraController extends ValueNotifier<CameraValue> {
273281
/// Initializes the camera on the device.
274282
///
275283
/// Throws a [CameraException] if the initialization fails.
276-
Future<void> initialize() async {
284+
Future<void> initialize() => _initializeWithDescription(description);
285+
286+
/// Initializes the camera on the device with the specified description.
287+
///
288+
/// Throws a [CameraException] if the initialization fails.
289+
Future<void> _initializeWithDescription(CameraDescription description) async {
277290
if (_isDisposed) {
278291
throw CameraException(
279292
'Disposed CameraController',
@@ -489,8 +502,14 @@ class CameraController extends ValueNotifier<CameraValue> {
489502
///
490503
/// The video is returned as a [XFile] after calling [stopVideoRecording].
491504
/// Throws a [CameraException] if the capture fails.
505+
///
506+
/// `enablePersistentRecording` parameter configures the recording to be a persistent recording.
507+
/// A persistent recording will only be stopped by explicitly calling [stopVideoRecording]
508+
/// and will ignore events that would normally cause recording to stop,
509+
/// such as lifecycle events or explicit calls to [setDescription] while recording is in progress.
492510
Future<void> startVideoRecording({
493511
onLatestImageAvailable? onAvailable,
512+
bool enablePersistentRecording = true,
494513
}) async {
495514
_throwIfNotInitialized('startVideoRecording');
496515
if (value.isRecordingVideo) {
@@ -509,7 +528,11 @@ class CameraController extends ValueNotifier<CameraValue> {
509528

510529
try {
511530
await CameraPlatform.instance.startVideoCapturing(
512-
VideoCaptureOptions(_cameraId, streamCallback: streamCallback),
531+
VideoCaptureOptions(
532+
_cameraId,
533+
streamCallback: streamCallback,
534+
enablePersistentRecording: enablePersistentRecording,
535+
),
513536
);
514537
value = value.copyWith(
515538
isRecordingVideo: true,
@@ -592,6 +615,21 @@ class CameraController extends ValueNotifier<CameraValue> {
592615
}
593616
}
594617

618+
/// Sets the description of the camera.
619+
///
620+
/// To avoid cancelling any active recording when calling this method,
621+
/// start the recording with [startVideoRecording] with `enablePersistentRecording` to `true`.
622+
///
623+
/// Throws a [CameraException] if setting the description fails.
624+
Future<void> setDescription(CameraDescription description) async {
625+
if (value.isRecordingVideo) {
626+
await CameraPlatform.instance.setDescriptionWhileRecording(description);
627+
value = value.copyWith(description: description);
628+
} else {
629+
await _initializeWithDescription(description);
630+
}
631+
}
632+
595633
/// Returns a widget showing a live camera preview.
596634
Widget buildPreview() {
597635
_throwIfNotInitialized('buildPreview');

packages/camera/camera_android_camerax/example/lib/main.dart

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
123123
if (state == AppLifecycleState.inactive) {
124124
cameraController.dispose();
125125
} else if (state == AppLifecycleState.resumed) {
126-
onNewCameraSelected(cameraController.description);
126+
_initializeCameraController(cameraController.description);
127127
}
128128
}
129129
// #enddocregion AppLifecycle
@@ -611,10 +611,7 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
611611
title: Icon(getCameraLensIcon(cameraDescription.lensDirection)),
612612
groupValue: controller?.description,
613613
value: cameraDescription,
614-
onChanged:
615-
controller != null && controller!.value.isRecordingVideo
616-
? null
617-
: onChanged,
614+
onChanged: onChanged,
618615
),
619616
),
620617
);
@@ -648,17 +645,16 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
648645
}
649646

650647
Future<void> onNewCameraSelected(CameraDescription cameraDescription) async {
651-
final CameraController? oldController = controller;
652-
if (oldController != null) {
653-
// `controller` needs to be set to null before getting disposed,
654-
// to avoid a race condition when we use the controller that is being
655-
// disposed. This happens when camera permission dialog shows up,
656-
// which triggers `didChangeAppLifecycleState`, which disposes and
657-
// re-creates the controller.
658-
controller = null;
659-
await oldController.dispose();
648+
if (controller != null) {
649+
return controller!.setDescription(cameraDescription);
650+
} else {
651+
return _initializeCameraController(cameraDescription);
660652
}
653+
}
661654

655+
Future<void> _initializeCameraController(
656+
CameraDescription cameraDescription,
657+
) async {
662658
final CameraController cameraController = CameraController(
663659
cameraDescription,
664660
mediaSettings: MediaSettings(

0 commit comments

Comments
 (0)