Skip to content
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dd28128
Fix CameraView crash when switching camera on Windows
zhitaop Apr 17, 2025
b345a7f
Merge branch 'main' into fix/camera-provider-refresh
ne0rrmatrix May 5, 2025
bc41901
Fix torch still in use after image capture on Android
zhitaop May 26, 2025
2dc4463
Merge branch 'main' into fix/camera-provider-refresh
zhitaop Jun 26, 2025
4476ad6
Move Permission and SelectedCamera to ConnectCamera
zhitaop Jun 26, 2025
35d859f
Refactor CameraProvider initialization and refresh logic, improve thr…
zhitaop Jun 27, 2025
e881807
Merge branch 'main' into fix/camera-provider-refresh
TheCodeTraveler Jun 27, 2025
79da15b
Rename refreshLock and refreshTask
zhitaop Jul 16, 2025
dbd3221
Merge branch 'main' into fix/camera-provider-refresh
zhitaop Jul 16, 2025
04d09d2
Simplify camera refresh task
zhitaop Jul 16, 2025
b618521
Merge branch 'main' into fix/camera-provider-refresh
zhitaop Aug 20, 2025
ccf2e83
Merge branch 'main' into fix/camera-provider-refresh
bijington Aug 28, 2025
4a15f4a
Merge branch 'main' into fix/camera-provider-refresh
TheCodeTraveler Aug 28, 2025
05ad3f5
Use `ObservableCollection`
TheCodeTraveler Aug 28, 2025
c3ecbf1
Update `ICameraProvider`, Use `SemaphoreSlim` in `CameraProvider` to …
TheCodeTraveler Aug 28, 2025
61bd8ac
Change method access modifier to private
TheCodeTraveler Aug 28, 2025
de245ce
Update CameraProvider.tizen.cs
TheCodeTraveler Aug 28, 2025
c452a81
Merge branch 'fix/camera-provider-refresh' of https://github.com/zhit…
TheCodeTraveler Aug 28, 2025
8953ce9
Refactor CameraViewPage and ViewModel: Remove unused event handlers a…
zhitaop Sep 1, 2025
c0d810b
Refactor CameraProvider: Improve camera refresh logic and ensure prop…
zhitaop Sep 1, 2025
b0fa9a8
Merge branch 'main' into fix/camera-provider-refresh
ne0rrmatrix Oct 2, 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
x:TypeArguments="viewModels:CameraViewViewModel"
x:DataType="viewModels:CameraViewViewModel">

<Grid RowDefinitions="200,*,Auto,Auto" ColumnDefinitions="3*,*">
<Grid RowDefinitions="Auto,*,Auto,Auto" ColumnDefinitions="3*,*">
<toolkit:CameraView
x:Name="Camera"
Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="2" Grid.RowSpan="3"
Expand All @@ -36,13 +36,13 @@
</VerticalStackLayout>
</Grid>

<ContentView Grid.Column="1" Grid.Row="0" ZIndex="100" BackgroundColor="#80CCCCCC">
<Grid Grid.Column="1" Grid.Row="0" ZIndex="100" BackgroundColor="#80CCCCCC">
<Image x:Name="image" VerticalOptions="Fill" HorizontalOptions="Fill">
<Image.GestureRecognizers>
<TapGestureRecognizer Tapped="OnImageTapped" />
</Image.GestureRecognizers>
</Image>
</ContentView>
</Grid>

<Grid ColumnDefinitions="Auto,*,Auto" Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="2" ColumnSpacing="10" Margin="10"
BackgroundColor="#00000000">
Expand All @@ -61,6 +61,12 @@

<FlexLayout Margin="5" JustifyContent="SpaceBetween" Wrap="Wrap">

<Picker
Title="Cameras"
ItemsSource="{Binding Cameras}"
ItemDisplayBinding="{Binding Name}"
SelectedItem="{Binding SelectedCamera}" />

<Picker
Title="Flash"
IsVisible="{Binding Path=SelectedCamera.IsFlashSupported, FallbackValue=false}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ protected override async void OnAppearing()
{
base.OnAppearing();

var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await BindingContext.RefreshCamerasCommand.ExecuteAsync(cancellationTokenSource.Token);
await BindingContext.InitializeAsync();
}

// https://github.com/dotnet/maui/issues/16697
Expand Down Expand Up @@ -75,7 +74,7 @@ void OnMediaCaptured(object? sender, MediaCapturedEventArgs e)
{
// workaround for https://github.com/dotnet/maui/issues/13858
#if ANDROID
image.Source = ImageSource.FromStream(() => File.OpenRead(imagePath));
image.Source = ImageSource.FromStream(() => File.OpenRead(imagePath));
#else
image.Source = ImageSource.FromFile(imagePath);
#endif
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Maui.Core.Primitives;
using System.Collections.ObjectModel;
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

Expand All @@ -9,9 +9,9 @@ public partial class CameraViewViewModel(ICameraProvider cameraProvider) : BaseV
{
readonly ICameraProvider cameraProvider = cameraProvider;

public IReadOnlyList<CameraInfo> Cameras => cameraProvider.AvailableCameras ?? [];

public CancellationToken Token => CancellationToken.None;

public ObservableCollection<CameraInfo> Cameras { get; } = [];

public ICollection<CameraFlashMode> FlashModes { get; } = Enum.GetValues<CameraFlashMode>();

Expand Down Expand Up @@ -42,6 +42,18 @@ public partial class CameraViewViewModel(ICameraProvider cameraProvider) : BaseV
[ObservableProperty]
public partial string ResolutionText { get; set; } = string.Empty;

public async ValueTask InitializeAsync()
{
if (!cameraProvider.IsInitialized)
{
await cameraProvider.InitializeAsync(CancellationToken.None);
foreach (var camera in cameraProvider.AvailableCameras ?? [])
{
Cameras.Add(camera);
}
}
}

[RelayCommand]
async Task RefreshCameras(CancellationToken token) => await cameraProvider.RefreshAvailableCameras(token);

Expand All @@ -60,6 +72,22 @@ partial void OnSelectedResolutionChanged(Size value)
UpdateResolutionText();
}

partial void OnSelectedCameraChanged(CameraInfo? oldValue, CameraInfo? newValue)
{
UpdateCameraInfoText();
}

void UpdateCameraInfoText()
{
if (SelectedCamera is null)
{
return;
}
CameraNameText = $"{SelectedCamera.Name}";
ZoomRangeText = $"Min Zoom: {SelectedCamera.MinimumZoomFactor}, Max Zoom: {SelectedCamera.MaximumZoomFactor}";
UpdateFlashModeText();
}

void UpdateFlashModeText()
{
if (SelectedCamera is null)
Expand Down
26 changes: 5 additions & 21 deletions src/CommunityToolkit.Maui.Camera/CameraManager.android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ protected virtual void Dispose(bool disposing)
previewView?.Dispose();
previewView = null;

processCameraProvider?.UnbindAll();
processCameraProvider?.Dispose();
processCameraProvider = null;

Expand Down Expand Up @@ -158,16 +159,6 @@ protected virtual async partial Task PlatformConnectCamera(CancellationToken tok
{
processCameraProvider = (ProcessCameraProvider)(cameraProviderFuture.Get() ?? throw new CameraException($"Unable to retrieve {nameof(ProcessCameraProvider)}"));

if (cameraProvider.AvailableCameras is null)
{
await cameraProvider.RefreshAvailableCameras(token);

if (cameraProvider.AvailableCameras is null)
{
throw new CameraException("Unable to refresh available cameras");
}
}

await StartUseCase(token);

cameraProviderTCS.SetResult();
Expand Down Expand Up @@ -200,22 +191,14 @@ protected async Task StartUseCase(CancellationToken token)
await StartCameraPreview(token);
}

protected virtual async partial Task PlatformStartCameraPreview(CancellationToken token)
protected virtual partial Task PlatformStartCameraPreview(CancellationToken token)
{
if (previewView is null || processCameraProvider is null || cameraPreview is null || imageCapture is null)
{
return;
return Task.CompletedTask;
}

if (cameraView.SelectedCamera is null)
{
if (cameraProvider.AvailableCameras is null)
{
await cameraProvider.RefreshAvailableCameras(token);
}

cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
}
cameraView.SelectedCamera ??= cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");

var cameraSelector = cameraView.SelectedCamera.CameraSelector ?? throw new CameraException($"Unable to retrieve {nameof(CameraSelector)}");

Expand All @@ -231,6 +214,7 @@ protected virtual async partial Task PlatformStartCameraPreview(CancellationToke

IsInitialized = true;
OnLoaded.Invoke();
return Task.CompletedTask;
}

protected virtual partial void PlatformStopCameraPreview()
Expand Down
31 changes: 6 additions & 25 deletions src/CommunityToolkit.Maui.Camera/CameraManager.macios.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,18 @@ public partial void UpdateZoom(float zoomLevel)
captureDevice.UnlockForConfiguration();
}

public async partial ValueTask UpdateCaptureResolution(Size resolution, CancellationToken token)
public partial ValueTask UpdateCaptureResolution(Size resolution, CancellationToken token)
{
if (captureDevice is null)
if (captureDevice is null || cameraView.SelectedCamera is null)
{
return;
return ValueTask.CompletedTask;
}

captureDevice.LockForConfiguration(out NSError? error);
if (error is not null)
{
Trace.WriteLine(error);
return;
}

if (cameraView.SelectedCamera is null)
{
await cameraProvider.RefreshAvailableCameras(token);
cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
return ValueTask.CompletedTask;
}

var filteredFormatList = cameraView.SelectedCamera.SupportedFormats.Where(f =>
Expand All @@ -116,20 +110,11 @@ public async partial ValueTask UpdateCaptureResolution(Size resolution, Cancella
}

captureDevice.UnlockForConfiguration();
return ValueTask.CompletedTask;
}

protected virtual async partial Task PlatformConnectCamera(CancellationToken token)
{
if (cameraProvider.AvailableCameras is null)
{
await cameraProvider.RefreshAvailableCameras(token);

if (cameraProvider.AvailableCameras is null)
{
throw new CameraException("Unable to refresh cameras");
}
}

await PlatformStartCameraPreview(token);
}

Expand All @@ -148,11 +133,7 @@ protected virtual async partial Task PlatformStartCameraPreview(CancellationToke
input.Dispose();
}

if (cameraView.SelectedCamera is null)
{
await cameraProvider.RefreshAvailableCameras(token);
cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
}
cameraView.SelectedCamera ??= cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");

captureDevice = cameraView.SelectedCamera.CaptureDevice ?? throw new CameraException($"No Camera found");
captureInput = new AVCaptureDeviceInput(captureDevice, out _);
Expand Down
13 changes: 12 additions & 1 deletion src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/// <exception cref="NullReferenceException">Thrown when no <see cref="CameraProvider"/> can be resolved.</exception>
/// <exception cref="InvalidOperationException">Thrown when there are no cameras available.</exception>
partial class CameraManager(
IMauiContext mauiContext,

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Run Benchmarks (macos-15)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Run Benchmarks (windows-latest)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (windows-latest)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-15)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-15)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Library (macos-15)

Parameter 'mauiContext' is unread.

Check warning on line 16 in src/CommunityToolkit.Maui.Camera/CameraManager.shared.cs

View workflow job for this annotation

GitHub Actions / Build Sample App using Latest .NET SDK (windows-latest)

Parameter 'mauiContext' is unread.
ICameraView cameraView,
ICameraProvider cameraProvider,
Action onLoaded) : IDisposable
Expand All @@ -33,7 +33,18 @@
/// Connects to the camera.
/// </summary>
/// <returns>A <see cref="ValueTask"/> that can be awaited.</returns>
public Task ConnectCamera(CancellationToken token) => PlatformConnectCamera(token);
public async Task ConnectCamera(CancellationToken token)
{
if (await ArePermissionsGranted() is false)
{
throw new PermissionException("Camera permissions not granted");
}

await cameraProvider.InitializeAsync(token);
cameraView.SelectedCamera ??= cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");

await PlatformConnectCamera(token);
}

/// <summary>
/// Disconnects from the camera.
Expand Down
32 changes: 9 additions & 23 deletions src/CommunityToolkit.Maui.Camera/CameraManager.windows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,6 @@ protected virtual void Dispose(bool disposing)

protected virtual async partial Task PlatformConnectCamera(CancellationToken token)
{
if (cameraProvider.AvailableCameras is null)
{
await cameraProvider.RefreshAvailableCameras(token);

if (cameraProvider.AvailableCameras is null)
{
throw new CameraException("Unable to refresh cameras");
}
}

await StartCameraPreview(token);
}

Expand All @@ -139,13 +129,9 @@ protected virtual async partial Task PlatformStartCameraPreview(CancellationToke
return;
}

mediaCapture = new MediaCapture();
cameraView.SelectedCamera ??= cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");

if (cameraView.SelectedCamera is null)
{
await cameraProvider.RefreshAvailableCameras(token);
cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
}
mediaCapture = new MediaCapture();

await mediaCapture.InitializeCameraForCameraView(cameraView.SelectedCamera.DeviceId, token);

Expand Down Expand Up @@ -180,22 +166,22 @@ protected virtual partial void PlatformStopCameraPreview()

protected async Task PlatformUpdateResolution(Size resolution, CancellationToken token)
{
if (!IsInitialized || mediaCapture is null)
if (!IsInitialized || mediaCapture is null || cameraView.SelectedCamera is null)
{
return;
}

if (cameraView.SelectedCamera is null)
if (mediaCapture.VideoDeviceController.Id != cameraView.SelectedCamera.DeviceId)
{
await cameraProvider.RefreshAvailableCameras(token);
cameraView.SelectedCamera = cameraProvider.AvailableCameras?.FirstOrDefault() ?? throw new CameraException("No camera available on device");
return;
}

var filteredPropertiesList = cameraView.SelectedCamera.ImageEncodingProperties.Where(p => p.Width <= resolution.Width && p.Height <= resolution.Height).ToList();

filteredPropertiesList = filteredPropertiesList.Count is not 0
? filteredPropertiesList
: [.. cameraView.SelectedCamera.ImageEncodingProperties.OrderByDescending(p => p.Width * p.Height)];
if (filteredPropertiesList.Count is 0)
{
filteredPropertiesList = [.. cameraView.SelectedCamera.ImageEncodingProperties.OrderByDescending(p => p.Width * p.Height)];
}

if (filteredPropertiesList.Count is not 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,7 @@ void Init(ICameraView view)
protected override async void ConnectHandler(NativePlatformCameraPreviewView platformView)
{
base.ConnectHandler(platformView);

await CameraManager.ArePermissionsGranted();
await CameraManager.ConnectCamera(CancellationToken.None);
await cameraProvider.RefreshAvailableCameras(CancellationToken.None);
}

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,30 @@ public interface ICameraProvider
/// Cameras available on device
/// </summary>
/// <remarks>
/// List is initialized using <see cref="RefreshAvailableCameras(CancellationToken)"/>
/// List is initialized using <see cref="InitializeAsync"/>, and can be refreshed using <see cref="RefreshAvailableCameras(CancellationToken)"/>
/// </remarks>
IReadOnlyList<CameraInfo>? AvailableCameras { get; }

/// <summary>
/// Gets a value indicating whether the camera provider has been successfully initialized.
/// </summary>
bool IsInitialized { get; }

/// <summary>
/// Assigns <see cref="AvailableCameras"/> with the cameras available on device
/// Refreshes <see cref="AvailableCameras"/> with the cameras available on device.
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
[MemberNotNull(nameof(AvailableCameras))]
ValueTask RefreshAvailableCameras(CancellationToken token);
Task RefreshAvailableCameras(CancellationToken token);

/// <summary>
/// Initialize the camera provider by refreshing the <see cref="AvailableCameras"/>.
/// </summary>
/// <remarks>
/// If the provider is already initialized, the <see cref="AvailableCameras"/> will not be refreshed again until <see cref="RefreshAvailableCameras(CancellationToken)"/> is called,
/// and this method will return a <see cref="ValueTask.CompletedTask"/>.
/// </remarks>
ValueTask InitializeAsync(CancellationToken token);

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ partial class CameraProvider
{
readonly Context context = Android.App.Application.Context;

public async partial ValueTask RefreshAvailableCameras(CancellationToken token)
private async partial ValueTask PlatformRefreshAvailableCameras(CancellationToken token)
{
var cameraProviderFuture = ProcessCameraProvider.GetInstance(context);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ partial class CameraProvider
{
static readonly AVCaptureDeviceType[] captureDevices = InitializeCaptureDevices();

public partial ValueTask RefreshAvailableCameras(CancellationToken token)
private partial ValueTask PlatformRefreshAvailableCameras(CancellationToken token)
{
var discoverySession = AVCaptureDeviceDiscoverySession.Create(captureDevices, AVMediaTypes.Video, AVCaptureDevicePosition.Unspecified);
var availableCameras = new List<CameraInfo>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace CommunityToolkit.Maui.Core;

partial class CameraProvider
{
public partial ValueTask RefreshAvailableCameras(CancellationToken token) => throw new NotSupportedException();
private partial ValueTask PlatformRefreshAvailableCameras(CancellationToken token) => throw new NotSupportedException();
}
Loading
Loading