📄 app/src/Controls/CameraPageControl.axaml.cs
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using BookScanner.App.Services;
using SkiaSharp;

namespace BookScanner.App.Controls;

public partial class CameraPageControl : UserControl
{
    public static readonly DirectProperty<CameraPageControl, CameraPageSide> SideProperty =
        AvaloniaProperty.RegisterDirect<CameraPageControl, CameraPageSide>(
            nameof(Side),
            o => o.Side,
            (o, v) => o.Side = v
        );

    public CameraPageSide Side
    {
        get;
        set
        {
            image.HorizontalAlignment =
                value is CameraPageSide.Left
                    ? Avalonia.Layout.HorizontalAlignment.Right
                    : Avalonia.Layout.HorizontalAlignment.Left;
            SetAndRaise(SideProperty, ref field, value);
        }
    }

    private int rotation = 0;
    private SKBitmap? rotatedBitmap;
    private CameraInfo? subscribedCamera;

    public CameraPageControl()
    {
        InitializeComponent();

        cameraSelector.ItemsSource = CameraService.Cameras;
    }

    private void OnCameraSelectionChange(object? sender, SelectionChangedEventArgs args)
    {
        if (subscribedCamera is not null)
        {
            CameraService.Unsubscribe(subscribedCamera, OnFrame);
        }

        if (cameraSelector.SelectedItem is not CameraInfo cameraInfo)
        {
            return;
        }

        subscribedCamera = cameraInfo;
        CameraService.Subscribe(subscribedCamera, OnFrame);
    }

    private void OnFrame(SKBitmap bitmap)
    {
        rotatedBitmap?.Dispose();
        rotatedBitmap = null;
        if (rotation is not 0)
        {
            var (width, height) = rotation is 2 ? (bitmap.Width, bitmap.Height) : (bitmap.Height, bitmap.Width);
            rotatedBitmap = new SKBitmap(width, height);
            using var surface = new SKCanvas(rotatedBitmap);
            surface.RotateDegrees(90 * rotation, width / 2, height / 2);
            surface.DrawBitmap(bitmap, -(bitmap.Width - width) / 2, -(bitmap.Height - height) / 2);
            bitmap = rotatedBitmap;
        }

        Dispatcher.Invoke(() =>
        {
            image.Source = bitmap;
        });
    }

    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
    {
        if (subscribedCamera is not null)
        {
            CameraService.Unsubscribe(subscribedCamera, OnFrame);
        }

        base.OnDetachedFromVisualTree(e);
    }

    private void OnRotateClick(object? sender, RoutedEventArgs args)
    {
        rotation = (rotation + 1) % 4;
    }
}

public enum CameraPageSide
{
    Left,
    Right,
}