📄 app/src/Services/CameraService.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using FlashCap;
using SkiaSharp;

namespace BookScanner.App.Services;

public static class CameraService
{
    public static IEnumerable<CameraInfo> Cameras
    {
        get
        {
            var devices = new CaptureDevices();
            return devices.EnumerateDescriptors().Select(d => new CameraInfo(d, d.Name ?? d.Description));
        }
    }

    private static Dictionary<object, DeviceManager> devices = [];

    public static void Subscribe(CameraInfo cameraInfo, Action<SKBitmap> handler)
    {
        if (!devices.TryGetValue(cameraInfo.Descriptor.Identity, out var device))
        {
            devices[cameraInfo.Descriptor.Identity] = device = new(cameraInfo.Descriptor);
        }

        device.OnFrame += handler;
    }

    public static void Unsubscribe(CameraInfo cameraInfo, Action<SKBitmap> handler)
    {
        if (!devices.TryGetValue(cameraInfo.Descriptor.Identity, out var device))
        {
            return;
        }

        device.OnFrame -= handler;
    }

    public static void Close()
    {
        foreach (var device in devices.Values)
        {
            device.Dispose();
        }
    }
}

public sealed record CameraInfo(CaptureDeviceDescriptor Descriptor, string Label);

public sealed class DeviceManager(CaptureDeviceDescriptor descriptor) : IDisposable
{
    private readonly Lock watchLock = new();
    private CaptureDevice? captureDevice;
    private SKBitmap? previousBitmap;
    private Action<SKBitmap>? onFrame;
    public event Action<SKBitmap> OnFrame
    {
        add
        {
            watchLock.Enter();
            if (onFrame is null)
            {
                var characteristics = descriptor.Characteristics.MaxBy(c => c.Width * c.Height)!;
                descriptor
                    .OpenAsync(characteristics, TranscodeFormats.Auto, OnPixelBufferArrived)
                    .ContinueWith(t =>
                    {
                        captureDevice = t.Result;
                        captureDevice.StartAsync();
                        watchLock.Exit();
                    });
            }
            onFrame += value;
        }
        remove
        {
            watchLock.Enter();
            onFrame -= value;
            if (onFrame is null)
            {
                captureDevice?.Dispose();
            }
        }
    }

    private void OnPixelBufferArrived(PixelBufferScope bufferScope)
    {
        var buffer = bufferScope.Buffer.ReferImage();
        var bitmap = SKBitmap.Decode(buffer);
        bufferScope.ReleaseNow();
        onFrame?.Invoke(bitmap);
        previousBitmap?.Dispose();
        previousBitmap = bitmap;
    }

    public void Dispose()
    {
        previousBitmap?.Dispose();
        captureDevice?.Dispose();
    }
}