Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0e59dbc
Fixes flutter/flutter#148013: setDescriptionWhileRecording with andro…
blackorbs-dev Aug 24, 2025
fcc11be
Fixes flutter/flutter#148013: updated Version and Changelog
blackorbs-dev Aug 24, 2025
971ebc8
Address review feedback: code fix
blackorbs-dev Aug 25, 2025
87c985e
Address review feedback: implement code fixes and suggestions
blackorbs-dev Aug 25, 2025
80d3c61
Merge branch 'main' into fix-issue-148013
blackorbs-dev Aug 27, 2025
0461e63
Update camera_android_camerax readme, revert changes for unaffected c…
blackorbs-dev Aug 27, 2025
f4a076d
Merge branch 'main' into fix-issue-148013
blackorbs-dev Aug 27, 2025
1a8397e
Merge branch 'main' into fix-issue-148013
blackorbs-dev Aug 28, 2025
a631f15
fix merge issue with main
blackorbs-dev Aug 28, 2025
4eece12
update version and changelog
blackorbs-dev Aug 28, 2025
3ac789c
Implement code fixes and suggestions
blackorbs-dev Aug 29, 2025
53e3aa3
Merge branch 'main' into fix-issue-148013
blackorbs-dev Aug 29, 2025
8fbb01c
Address some nits
blackorbs-dev Sep 3, 2025
41aac10
Merge branch 'main' into fix-issue-148013
blackorbs-dev Sep 3, 2025
b1c2ac1
Reformat merge from main
blackorbs-dev Sep 3, 2025
1ddc209
Merge branch 'main' into fix-issue-148013
blackorbs-dev Sep 9, 2025
3b2164f
checkout camera_avfoundation to upstream main
blackorbs-dev Sep 9, 2025
d73bccb
update camera_android_camerax readme
blackorbs-dev Sep 9, 2025
69eb480
Merge branch 'main' into fix-issue-148013
blackorbs-dev Sep 10, 2025
3e799c2
implement version bump for camera_platform_interface
blackorbs-dev Sep 11, 2025
5520c89
implement changes for platform consistency
blackorbs-dev Sep 11, 2025
8614a35
format code changes
blackorbs-dev Sep 11, 2025
073f64e
checkout camera_platform_interface to upstream main
blackorbs-dev Sep 17, 2025
864e607
remove camera_platform_interface dependency overrides
blackorbs-dev Sep 17, 2025
441b79f
implement setDescriptionWhileRecording
blackorbs-dev Sep 17, 2025
91539b6
checkout affected files to main
blackorbs-dev Sep 18, 2025
408339b
remove dependency overrides in example
blackorbs-dev Sep 18, 2025
5d22e57
update platform package dependency version
blackorbs-dev Sep 18, 2025
9b3ea2a
add fix for paused preview when switching camera
blackorbs-dev Sep 22, 2025
e1af62c
implement code changes for updating camera state
blackorbs-dev Sep 22, 2025
0239cdb
Merge branch 'main' into split-camera-androidx
blackorbs-dev Sep 23, 2025
f427a37
Merge branch 'main' into split-camera-androidx
blackorbs-dev Sep 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/camera/camera_android_camerax/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.6.22

* Implements `setDescriptionWhileRecording`.

## 0.6.21+2

* Bumps com.google.guava:guava from 33.4.8-android to 33.5.0-android.
Expand Down
10 changes: 6 additions & 4 deletions packages/camera/camera_android_camerax/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ use cases, the plugin behaves according to the following:
video recording and image streaming is supported, but concurrent video recording, image
streaming, and image capture is not supported.

### `setDescriptionWhileRecording` is unimplemented [Issue #148013][148013]
`setDescriptionWhileRecording`, used to switch cameras while recording video, is currently unimplemented
due to this not currently being supported by CameraX.

### 240p resolution configuration for video recording

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

### Notes on video capture

#### Setting description while recording
To avoid cancelling any active recording when calling `setDescriptionWhileRecording`,
you must start the recording with `startVideoCapturing` with `enablePersistentRecording` set to `true`.

### Notes on image streaming

#### Allowing image streaming in the background
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3652,6 +3652,22 @@ abstract class PigeonApiPendingRecording(
initialMuted: Boolean
): androidx.camera.video.PendingRecording

/**
* Configures the recording to be a persistent recording.
*
* A persistent recording will only be stopped by explicitly calling [Recording.stop] or
* [Recording.close] and will ignore events that would normally cause recording to stop, such as
* lifecycle events or explicit unbinding of a [VideoCapture] use case that the recording's
* Recorder is attached to.
*
* To switch to a different camera stream while a recording is in progress, first create the
* recording as persistent recording, then rebind the [VideoCapture] it's associated with to a
* different camera.
*/
abstract fun asPersistentRecording(
pigeon_instance: androidx.camera.video.PendingRecording
): androidx.camera.video.PendingRecording

/** Starts the recording, making it an active recording. */
abstract fun start(
pigeon_instance: androidx.camera.video.PendingRecording,
Expand Down Expand Up @@ -3685,6 +3701,28 @@ abstract class PigeonApiPendingRecording(
channel.setMessageHandler(null)
}
}
run {
val channel =
BasicMessageChannel<Any?>(
binaryMessenger,
"dev.flutter.pigeon.camera_android_camerax.PendingRecording.asPersistentRecording",
codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val pigeon_instanceArg = args[0] as androidx.camera.video.PendingRecording
val wrapped: List<Any?> =
try {
listOf(api.asPersistentRecording(pigeon_instanceArg))
} catch (exception: Throwable) {
CameraXLibraryPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel =
BasicMessageChannel<Any?>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import android.Manifest;
import android.content.pm.PackageManager;
import androidx.annotation.NonNull;
import androidx.camera.video.ExperimentalPersistentRecording;
import androidx.camera.video.PendingRecording;
import androidx.camera.video.Recording;
import androidx.core.content.ContextCompat;
Expand All @@ -27,6 +28,13 @@ public ProxyApiRegistrar getPigeonRegistrar() {
return (ProxyApiRegistrar) super.getPigeonRegistrar();
}

@ExperimentalPersistentRecording
@NonNull
@Override
public PendingRecording asPersistentRecording(PendingRecording pigeonInstance) {
return pigeonInstance.asPersistentRecording();
}

@NonNull
@Override
public PendingRecording withAudioEnabled(PendingRecording pigeonInstance, boolean initialMuted) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ public void withAudioEnabled_doesNotEnableAudioWhenAudioNotRequested() {
verify(instance).withAudioEnabled(true);
}

@Test
public void asPersistentRecording_returnsPersistentRecordingInstance() {
final PigeonApiPendingRecording api =
new TestProxyApiRegistrar().getPigeonApiPendingRecording();
final PendingRecording instance = mock(PendingRecording.class);
final PendingRecording persistentInstance = mock(PendingRecording.class);

when(instance.asPersistentRecording()).thenReturn(persistentInstance);

assertEquals(persistentInstance, api.asPersistentRecording(instance));
verify(instance).asPersistentRecording();
}

@Test
public void start_callsStartOnInstance() {
final PigeonApiPendingRecording api =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,49 @@ void main() {

expect(duration, lessThan(recordingTime - timePaused));
}, skip: skipFor157181);

testWidgets('Set description while recording captures full video', (
WidgetTester tester,
) async {
final List<CameraDescription> cameras = await availableCameras();
if (cameras.length < 2) {
return;
}

final CameraController controller = CameraController(
cameras[0],
mediaSettings: const MediaSettings(
resolutionPreset: ResolutionPreset.medium,
enableAudio: true,
),
);
await controller.initialize();
await controller.prepareForVideoRecording();

await controller.startVideoRecording();

await controller.setDescription(cameras[1]);

await tester.pumpAndSettle(const Duration(seconds: 4));

await controller.setDescription(cameras[0]);

await tester.pumpAndSettle(const Duration(seconds: 1));

final XFile file = await controller.stopVideoRecording();

final File videoFile = File(file.path);
final VideoPlayerController videoController = VideoPlayerController.file(
videoFile,
);
await videoController.initialize();
final int duration = videoController.value.duration.inMilliseconds;
await videoController.dispose();

expect(
duration,
greaterThanOrEqualTo(const Duration(seconds: 4).inMilliseconds),
);
await controller.dispose();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ class CameraValue {
required this.exposurePointSupported,
required this.focusPointSupported,
required this.deviceOrientation,
required this.description,
this.lockedCaptureOrientation,
this.recordingOrientation,
this.isPreviewPaused = false,
this.previewPauseOrientation,
}) : _isRecordingPaused = isRecordingPaused;

/// Creates a new camera controller state for an uninitialized controller.
const CameraValue.uninitialized()
const CameraValue.uninitialized(CameraDescription description)
: this(
isInitialized: false,
isRecordingVideo: false,
Expand All @@ -70,6 +71,7 @@ class CameraValue {
focusPointSupported: false,
deviceOrientation: DeviceOrientation.portraitUp,
isPreviewPaused: false,
description: description,
);

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

/// The properties of the camera device controlled by this controller.
final CameraDescription description;

/// Creates a modified copy of the object.
///
/// Explicitly specified fields get the specified value, all other fields get
Expand All @@ -164,6 +169,7 @@ class CameraValue {
Optional<DeviceOrientation>? lockedCaptureOrientation,
Optional<DeviceOrientation>? recordingOrientation,
bool? isPreviewPaused,
CameraDescription? description,
Optional<DeviceOrientation>? previewPauseOrientation,
}) {
return CameraValue(
Expand All @@ -190,6 +196,7 @@ class CameraValue {
? this.recordingOrientation
: recordingOrientation.orNull,
isPreviewPaused: isPreviewPaused ?? this.isPreviewPaused,
description: description ?? this.description,
previewPauseOrientation:
previewPauseOrientation == null
? this.previewPauseOrientation
Expand All @@ -214,7 +221,8 @@ class CameraValue {
'lockedCaptureOrientation: $lockedCaptureOrientation, '
'recordingOrientation: $recordingOrientation, '
'isPreviewPaused: $isPreviewPaused, '
'previewPausedOrientation: $previewPauseOrientation)';
'previewPausedOrientation: $previewPauseOrientation, '
'description: $description)';
}
}

Expand All @@ -228,13 +236,13 @@ class CameraValue {
class CameraController extends ValueNotifier<CameraValue> {
/// Creates a new camera controller in an uninitialized state.
CameraController(
this.description, {
CameraDescription description, {
this.mediaSettings,
this.imageFormatGroup,
}) : super(const CameraValue.uninitialized());
}) : super(CameraValue.uninitialized(description));

/// The properties of the camera device controlled by this controller.
final CameraDescription description;
CameraDescription get description => value.description;

/// The media settings this controller is targeting.
///
Expand Down Expand Up @@ -273,7 +281,12 @@ class CameraController extends ValueNotifier<CameraValue> {
/// Initializes the camera on the device.
///
/// Throws a [CameraException] if the initialization fails.
Future<void> initialize() async {
Future<void> initialize() => _initializeWithDescription(description);

/// Initializes the camera on the device with the specified description.
///
/// Throws a [CameraException] if the initialization fails.
Future<void> _initializeWithDescription(CameraDescription description) async {
if (_isDisposed) {
throw CameraException(
'Disposed CameraController',
Expand Down Expand Up @@ -489,8 +502,14 @@ class CameraController extends ValueNotifier<CameraValue> {
///
/// The video is returned as a [XFile] after calling [stopVideoRecording].
/// Throws a [CameraException] if the capture fails.
///
/// `enablePersistentRecording` parameter configures the recording to be a persistent recording.
/// A persistent recording will only be stopped by explicitly calling [stopVideoRecording]
/// and will ignore events that would normally cause recording to stop,
/// such as lifecycle events or explicit calls to [setDescription] while recording is in progress.
Future<void> startVideoRecording({
onLatestImageAvailable? onAvailable,
bool enablePersistentRecording = true,
}) async {
_throwIfNotInitialized('startVideoRecording');
if (value.isRecordingVideo) {
Expand All @@ -509,7 +528,11 @@ class CameraController extends ValueNotifier<CameraValue> {

try {
await CameraPlatform.instance.startVideoCapturing(
VideoCaptureOptions(_cameraId, streamCallback: streamCallback),
VideoCaptureOptions(
_cameraId,
streamCallback: streamCallback,
enablePersistentRecording: enablePersistentRecording,
),
);
value = value.copyWith(
isRecordingVideo: true,
Expand Down Expand Up @@ -592,6 +615,21 @@ class CameraController extends ValueNotifier<CameraValue> {
}
}

/// Sets the description of the camera.
///
/// To avoid cancelling any active recording when calling this method,
/// start the recording with [startVideoRecording] with `enablePersistentRecording` to `true`.
///
/// Throws a [CameraException] if setting the description fails.
Future<void> setDescription(CameraDescription description) async {
if (value.isRecordingVideo) {
await CameraPlatform.instance.setDescriptionWhileRecording(description);
value = value.copyWith(description: description);
} else {
await _initializeWithDescription(description);
}
}

/// Returns a widget showing a live camera preview.
Widget buildPreview() {
_throwIfNotInitialized('buildPreview');
Expand Down
24 changes: 10 additions & 14 deletions packages/camera/camera_android_camerax/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
if (state == AppLifecycleState.inactive) {
cameraController.dispose();
} else if (state == AppLifecycleState.resumed) {
onNewCameraSelected(cameraController.description);
_initializeCameraController(cameraController.description);
}
}
// #enddocregion AppLifecycle
Expand Down Expand Up @@ -611,10 +611,7 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
title: Icon(getCameraLensIcon(cameraDescription.lensDirection)),
groupValue: controller?.description,
value: cameraDescription,
onChanged:
controller != null && controller!.value.isRecordingVideo
? null
: onChanged,
onChanged: onChanged,
),
),
);
Expand Down Expand Up @@ -648,17 +645,16 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
}

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

Future<void> _initializeCameraController(
CameraDescription cameraDescription,
) async {
final CameraController cameraController = CameraController(
cameraDescription,
mediaSettings: MediaSettings(
Expand Down
Loading