Skip to content

Commit 8a2ab3c

Browse files
CameraView fix for iOS: orientation and video recording (#3167)
* CameraView update for iOS: orientation, video recording * Address Copilot review * Update mirroring handle * Address Copilot review * Reduce scope of `OnLoaded` and `IsInitialized` * Use `ICommand` for `StopCameraPreviewCommand` * Refactor `TryConfigureAVCaptureConnection` * Update CameraManager.windows.cs * Update CameraManager.macios.cs * Update XML docs * `dotnet format` --------- Co-authored-by: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com>
1 parent 6a0cca4 commit 8a2ab3c

5 files changed

Lines changed: 137 additions & 64 deletions

File tree

src/CommunityToolkit.Maui.Camera/CameraManager.android.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ public async partial ValueTask UpdateCaptureResolution(Size resolution, Cancella
171171
// According to the Android docs, `ResolutionSelector.Builder.setResolutionFilter(ResolutionFilter)` returns a `NonNull` object
172172
// `ResolutionSelector.Builder.SetResolutionFilter(ResolutionFilter)` returning a nullable object in .NET for Android is likely a C# Binding mistake
173173

174-
if (IsInitialized)
174+
if (isInitialized)
175175
{
176176
await StartUseCase(token);
177177
}
@@ -262,8 +262,8 @@ private async partial Task PlatformStartCameraPreview(CancellationToken token)
262262
var action = new FocusMeteringAction.Builder(point).Build();
263263
camera.CameraControl?.StartFocusAndMetering(action);
264264

265-
IsInitialized = true;
266-
OnLoaded.Invoke();
265+
isInitialized = true;
266+
onLoaded.Invoke();
267267
}
268268

269269
private partial void PlatformStopCameraPreview()
@@ -274,7 +274,7 @@ private partial void PlatformStopCameraPreview()
274274
}
275275

276276
processCameraProvider.UnbindAll();
277-
IsInitialized = false;
277+
isInitialized = false;
278278
}
279279

280280
private partial void PlatformDisconnect()

src/CommunityToolkit.Maui.Camera/CameraManager.macios.cs

Lines changed: 117 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using System.Diagnostics;
2+
using System.Diagnostics.CodeAnalysis;
23
using AVFoundation;
34
using CommunityToolkit.Maui.Extensions;
45
using CoreMedia;
6+
using CoreMotion;
57
using Foundation;
68
using ObjCRuntime;
79
using UIKit;
@@ -14,7 +16,7 @@ partial class CameraManager
1416
readonly NSDictionary<NSString, NSObject> codecSettings = new([AVVideo.CodecKey], [new NSString("jpeg")]);
1517
AVCaptureDeviceInput? audioInput;
1618
AVCaptureDevice? captureDevice;
17-
AVCaptureInput? captureInput;
19+
AVCaptureDeviceInput? captureInput;
1820

1921
AVCaptureSession? captureSession;
2022

@@ -24,12 +26,13 @@ partial class CameraManager
2426
AVCapturePhotoOutput? photoOutput;
2527
PreviewView? previewView;
2628

27-
AVCaptureDeviceInput? videoInput;
2829
AVCaptureVideoOrientation videoOrientation;
2930
AVCaptureMovieFileOutput? videoOutput;
31+
AVCaptureDeviceRotationCoordinator? rotationCoordinator;
3032
string? videoRecordingFileName;
3133
TaskCompletionSource? videoRecordingFinalizeTcs;
3234
Stream? videoRecordingStream;
35+
CMMotionManager? motionManager;
3336

3437
/// <inheritdoc />
3538
public void Dispose()
@@ -56,6 +59,13 @@ public void Dispose()
5659

5760
videoRecordingStream?.Dispose();
5861
videoRecordingStream = null;
62+
63+
rotationCoordinator?.Dispose();
64+
rotationCoordinator = null;
65+
66+
motionManager?.StopAccelerometerUpdates();
67+
motionManager?.Dispose();
68+
motionManager = null;
5969
}
6070

6171
public NativePlatformCameraPreviewView CreatePlatformView()
@@ -70,6 +80,12 @@ public NativePlatformCameraPreviewView CreatePlatformView()
7080
Session = captureSession
7181
};
7282

83+
// use CMMotionManager to get device orientation on iOS 16 or lower, since AVCaptureDeviceRotationCoordinator is unavailable
84+
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
85+
{
86+
motionManager ??= new();
87+
motionManager.StartAccelerometerUpdates();
88+
}
7389
orientationDidChangeObserver = UIDevice.Notifications.ObserveOrientationDidChange((_, _) => UpdateVideoOrientation());
7490
UpdateVideoOrientation();
7591

@@ -83,7 +99,7 @@ public partial void UpdateFlashMode(CameraFlashMode flashMode)
8399

84100
public partial void UpdateZoom(float zoomLevel)
85101
{
86-
if (!IsInitialized || captureDevice is null)
102+
if (!isInitialized || captureDevice is null)
87103
{
88104
return;
89105
}
@@ -162,8 +178,30 @@ private async partial Task PlatformStartCameraPreview(CancellationToken token)
162178
cameraView.SelectedCamera ??= cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
163179

164180
captureDevice = cameraView.SelectedCamera.CaptureDevice ?? throw new CameraException($"No Camera found");
165-
captureInput = new AVCaptureDeviceInput(captureDevice, out _);
166-
captureSession.AddInput(captureInput);
181+
captureInput = new AVCaptureDeviceInput(captureDevice, out NSError? error);
182+
183+
if (error is null && captureSession.CanAddInput(captureInput))
184+
{
185+
captureSession.AddInput(captureInput);
186+
}
187+
else
188+
{
189+
var errorMessage = error is not null
190+
? $"Error creating capture device input: {error.LocalizedDescription}"
191+
: "Unable to add capture device input to capture session.";
192+
193+
captureInput.Dispose();
194+
captureInput = null;
195+
captureSession.CommitConfiguration();
196+
throw new CameraException(errorMessage);
197+
}
198+
199+
// On iOS 17+, create a new instance of AVCaptureDeviceRotationCoordinator when switching to a new camera
200+
if (UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
201+
{
202+
rotationCoordinator?.Dispose();
203+
rotationCoordinator = new(captureDevice, previewView?.Layer);
204+
}
167205

168206
if (photoOutput is null)
169207
{
@@ -175,8 +213,8 @@ private async partial Task PlatformStartCameraPreview(CancellationToken token)
175213

176214
captureSession.CommitConfiguration();
177215
captureSession.StartRunning();
178-
IsInitialized = true;
179-
OnLoaded.Invoke();
216+
isInitialized = true;
217+
onLoaded.Invoke();
180218
}
181219

182220
private partial void PlatformStopCameraPreview()
@@ -191,7 +229,7 @@ private partial void PlatformStopCameraPreview()
191229
captureSession.StopRunning();
192230
}
193231

194-
IsInitialized = false;
232+
isInitialized = false;
195233
}
196234

197235
private partial void PlatformDisconnect()
@@ -213,22 +251,7 @@ private async partial Task PlatformStartVideoRecording(Stream stream, Cancellati
213251

214252
CleanupVideoRecordingResources();
215253

216-
var videoDevice = AVCaptureDevice.GetDefaultDevice(AVMediaTypes.Video) ?? throw new CameraException("Unable to get video device");
217-
218-
videoInput = new AVCaptureDeviceInput(videoDevice, out NSError? error);
219-
if (error is not null)
220-
{
221-
throw new CameraException($"Error creating video input: {error.LocalizedDescription}");
222-
}
223-
224-
if (!captureSession.CanAddInput(videoInput))
225-
{
226-
videoInput?.Dispose();
227-
throw new CameraException("Unable to add video input to capture session.");
228-
}
229-
230254
captureSession.BeginConfiguration();
231-
captureSession.AddInput(videoInput);
232255

233256
try
234257
{
@@ -256,15 +279,13 @@ private async partial Task PlatformStartVideoRecording(Stream stream, Cancellati
256279

257280
if (!captureSession.CanAddOutput(videoOutput))
258281
{
259-
captureSession.RemoveInput(videoInput);
260282
if (audioInput is not null)
261283
{
262284
captureSession.RemoveInput(audioInput);
263285
audioInput.Dispose();
264286
audioInput = null;
265287
}
266288

267-
videoInput?.Dispose();
268289
videoOutput?.Dispose();
269290
captureSession.CommitConfiguration();
270291
throw new CameraException("Unable to add video output to capture session.");
@@ -273,6 +294,11 @@ private async partial Task PlatformStartVideoRecording(Stream stream, Cancellati
273294
captureSession.AddOutput(videoOutput);
274295
captureSession.CommitConfiguration();
275296

297+
if (!TryConfigureAVCaptureConnection(videoOutput, out var error))
298+
{
299+
Trace.TraceWarning(error);
300+
}
301+
276302
videoRecordingStream = stream;
277303
videoRecordingFinalizeTcs = new TaskCompletionSource();
278304
videoRecordingFileName = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mov");
@@ -285,7 +311,6 @@ private async partial Task<Stream> PlatformStopVideoRecording(CancellationToken
285311
{
286312
if (captureSession is null
287313
|| videoRecordingFileName is null
288-
|| videoInput is null
289314
|| videoOutput is null
290315
|| videoRecordingStream is null
291316
|| videoRecordingFinalizeTcs is null)
@@ -318,25 +343,22 @@ void CleanupVideoRecordingResources()
318343
{
319344
captureSession.BeginConfiguration();
320345

321-
foreach (var input in captureSession.Inputs)
346+
if (audioInput is not null)
322347
{
323-
captureSession.RemoveInput(input);
324-
input.Dispose();
348+
captureSession.RemoveInput(audioInput);
349+
audioInput.Dispose();
325350
}
326351

327-
foreach (var output in captureSession.Outputs)
352+
if (videoOutput is not null)
328353
{
329-
captureSession.RemoveOutput(output);
330-
output.Dispose();
354+
captureSession.RemoveOutput(videoOutput);
355+
videoOutput.Dispose();
331356
}
332357

333-
// Restore to photo preset for preview after video recording
334-
captureSession.SessionPreset = AVCaptureSession.PresetPhoto;
335358
captureSession.CommitConfiguration();
336359
}
337360

338361
videoOutput = null;
339-
videoInput = null;
340362
audioInput = null;
341363

342364
// Clean up temporary file
@@ -360,13 +382,9 @@ private async partial ValueTask PlatformTakePicture(CancellationToken token)
360382
var capturePhotoSettings = AVCapturePhotoSettings.FromFormat(codecSettings);
361383
capturePhotoSettings.FlashMode = photoOutput.SupportedFlashModes.Contains(flashMode) ? flashMode : photoOutput.SupportedFlashModes.First();
362384

363-
if (AVMediaTypes.Video.GetConstant() is NSString avMediaTypeVideo)
385+
if (!TryConfigureAVCaptureConnection(photoOutput, out var errorMessage))
364386
{
365-
var photoOutputConnection = photoOutput.ConnectionFromMediaType(avMediaTypeVideo);
366-
if (photoOutputConnection is not null)
367-
{
368-
photoOutputConnection.VideoOrientation = videoOrientation;
369-
}
387+
Trace.TraceWarning(errorMessage);
370388
}
371389

372390
var wrapper = new AVCapturePhotoCaptureDelegateWrapper();
@@ -408,6 +426,20 @@ private async partial ValueTask PlatformTakePicture(CancellationToken token)
408426
}
409427
}
410428

429+
static AVCaptureVideoOrientation GetVideoOrientationFromAccelerometer(double x, double y)
430+
{
431+
// Absolute values help determine which axis is dominant
432+
if (Math.Abs(y) >= Math.Abs(x))
433+
{
434+
return y > 0 ? AVCaptureVideoOrientation.PortraitUpsideDown : AVCaptureVideoOrientation.Portrait;
435+
}
436+
else
437+
{
438+
// x > 0 is LandscapeRight for device, which is LandscapeLeft for Video
439+
return x > 0 ? AVCaptureVideoOrientation.LandscapeLeft : AVCaptureVideoOrientation.LandscapeRight;
440+
}
441+
}
442+
411443
static AVCaptureVideoOrientation GetVideoOrientation()
412444
{
413445
IEnumerable<UIScene> scenes = UIApplication.SharedApplication.ConnectedScenes;
@@ -436,6 +468,50 @@ static AVCaptureVideoOrientation GetVideoOrientation()
436468
};
437469
}
438470

471+
bool TryConfigureAVCaptureConnection(in AVCaptureOutput captureOutput, [NotNullWhen(false)] out string? errorMessage)
472+
{
473+
errorMessage = null;
474+
475+
if (AVMediaTypes.Video.GetConstant() is not NSString avMediaTypeVideo)
476+
{
477+
errorMessage = "Unable to determine video format.";
478+
return false;
479+
}
480+
481+
if (captureOutput.ConnectionFromMediaType(avMediaTypeVideo) is not AVCaptureConnection captureConnection)
482+
{
483+
errorMessage = "Unable to determine video connection from media type.";
484+
return false;
485+
}
486+
487+
// use AVCaptureDeviceRotationCoordinator to set captured photo and video orientation on iOS 17+
488+
if (UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
489+
{
490+
if (rotationCoordinator is not null)
491+
{
492+
captureConnection.VideoRotationAngle = rotationCoordinator.VideoRotationAngleForHorizonLevelCapture;
493+
}
494+
}
495+
// use CMMotionManager to set captured photo and video orientation on iOS 16 and lower
496+
else
497+
{
498+
var data = motionManager?.AccelerometerData;
499+
if (data is not null)
500+
{
501+
var orientation = GetVideoOrientationFromAccelerometer(data.Acceleration.X, data.Acceleration.Y);
502+
captureConnection.VideoOrientation = orientation;
503+
}
504+
}
505+
506+
if (captureConnection.SupportsVideoMirroring)
507+
{
508+
captureConnection.AutomaticallyAdjustsVideoMirroring = false;
509+
captureConnection.VideoMirrored = cameraView.SelectedCamera?.Position is CameraPosition.Front;
510+
}
511+
512+
return true;
513+
}
514+
439515
void UpdateVideoOrientation()
440516
{
441517
videoOrientation = GetVideoOrientation();

src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ sealed partial class CameraManager(
1818
ICameraProvider cameraProvider,
1919
Action onLoaded) : IDisposable
2020
{
21-
internal Action OnLoaded { get; } = onLoaded;
21+
readonly Action onLoaded = onLoaded;
2222

23-
internal bool IsInitialized { get; private set; }
23+
bool isInitialized;
2424

2525
/// <summary>
2626
/// Connects to the camera.
@@ -94,7 +94,7 @@ public async ValueTask UpdateCurrentCamera(CameraInfo? cameraInfo, CancellationT
9494
return;
9595
}
9696

97-
if (IsInitialized)
97+
if (isInitialized)
9898
{
9999
PlatformStopCameraPreview();
100100
await PlatformStartCameraPreview(token);

0 commit comments

Comments
 (0)