BlazorBasics.Camera 1.4.6

dotnet add package BlazorBasics.Camera --version 1.4.6
                    
NuGet\Install-Package BlazorBasics.Camera -Version 1.4.6
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="BlazorBasics.Camera" Version="1.4.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="BlazorBasics.Camera" Version="1.4.6" />
                    
Directory.Packages.props
<PackageReference Include="BlazorBasics.Camera" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add BlazorBasics.Camera --version 1.4.6
                    
#r "nuget: BlazorBasics.Camera, 1.4.6"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package BlazorBasics.Camera@1.4.6
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=BlazorBasics.Camera&version=1.4.6
                    
Install as a Cake Addin
#tool nuget:?package=BlazorBasics.Camera&version=1.4.6
                    
Install as a Cake Tool

Nuget Nuget

BlazorBasics.Camera

A Blazor camera component built on getUserMedia. Full screen viewfinder, front/back switch, torch, optional zoom and quality presets, a self timer, JPEG capture.

It has no opinion about what you do with the photo. It streams, it captures, it hands you a CameraPhoto and it tells you when something went wrong. Uploading, storing, counting, geotagging, localizing and gating are the application's business, so each one is a parameter or a callback.

Only dependency: Microsoft.AspNetCore.Components.Web. No icon font, no localization stack, no CSS framework.

Install

dotnet add package BlazorBasics.Camera
// Program.cs
builder.Services.AddBlazorCamera();

The bare minimum

@page "/"

<button @onclick="() => camera!.OpenAsync()">Open camera</button>

<CameraView @ref="camera" OnPhotoCaptured="OnPhoto" />

@code {
    private CameraView? camera;

    private Task OnPhoto(CameraPhoto photo)
    {
        // photo.Bytes / photo.OpenStreamAsync() / photo.TakenAt
        return Task.CompletedTask;
    }
}

That is a working camera: prewarmed stream, shutter, close, front/back switch. No zoom bar and no quality bar are rendered, because you passed no presets.

Opening and closing

Two ways, pick one:

  • Bound: <CameraView @bind-IsOpen="isOpen" /> and flip isOpen.
  • Imperative: @ref plus OpenAsync() / CloseAsync(), and never pass IsOpen.

Do not pass IsOpen one-way: a parent re-render would close a camera you opened imperatively.

IsReadyChanged fires when the stream becomes usable, so your own button can stay disabled until there is something to show.

Zoom and quality presets

Both bars are data driven and both disappear when there is no data:

<CameraView ZoomOptions="zooms" QualityOptions="qualities" @bind-Quality="quality" ... />

@code {
    // Only the levels the camera can actually reach are shown, and the bar stays hidden unless at
    // least two survive — a single choice is not a choice. 0.6× needs an ultra-wide lens.
    private readonly CameraZoomOption[] zooms = [new(0.6), new(1.0), new(1.5), new(2.0)];

    // Labels come from you, already localized. Disabled is yours too (e.g. low on storage).
    private readonly CameraQualityOption[] qualities =
    [
        new("Low", 0.5), new("Med", 0.75), new("Std", 0.93), new("Max", 1.0)
    ];

    private double quality = 0.93;
}

CameraZoomOption formats its own label from the factor ("0.6×", "2×") unless you give one.

Pinch to zoom

Two fingers over the viewfinder move the zoom continuously, from the minimum of the lens that is streaming up to the maximum the device can actually do, and a floating pill shows where it is ("1×", "2.3×", "6×") for as long as the gesture lasts plus a moment after. The presets stay exactly as they were, as shortcuts; a pill lights up only while the zoom sits on its level.

It is on by default and needs nothing from you — not even ZoomOptions, so a camera with no bar at all still zooms. EnablePinchZoom="false" turns it off.

<CameraView EnablePinchZoom="true" MaxDigitalZoom="8" ... />

The gesture is resolved in the browser and only reports the factor it arrives at: moving the zoom through .NET on every finger movement is what would make it feel sticky in WebAssembly. What the camera cannot do natively is cropped — MaxDigitalZoom is how far that crop may go (the ceiling of the gesture is the larger of that and the device's real maximum), which is also what gives a laptop webcam or an emulator a working gesture. Native zoom always goes first and only the remainder is cropped, so nothing is thrown away where the camera does have zoom of its own.

The gesture never changes lens. Reaching below 1× means reopening the stream on another camera (see below), which with two fingers on the screen would freeze the preview and lose the pinch — so the gesture stops at the minimum of the active lens, and going wider stays a preset.

Below 1×: the ultra-wide lens is a different camera

A "0.6×" preset is not a zoom. On Android every lens is its own videoinput and the main rear camera reports a minimum zoom of exactly 1, so zooming can never reach it — which is why sub-1× presets used to appear on iOS (Safari fuses the rear lenses into one virtual camera whose zoom starts at 0.5) and silently vanish on Android.

The camera now enumerates the lenses and, when a preset below 1× is picked and zoom cannot honour it, reopens the stream on the ultra-wide lens; going back to 1× or above returns to the main one. It is automatic — the only thing you do is offer the preset. Set EnableLensSwitching="false" to keep the stream on the device it started with.

Identifying the ultra-wide is best effort, because no browser exposes field of view: a descriptive label is used when there is one (Back Ultra Wide Camera on iOS), otherwise the Camera2 ordering on Android (camera2 0 is the main rear camera and the first extra rear camera is the ultra-wide). When neither says anything, nothing is guessed and the preset is simply not offered.

To see what a particular device reports, call the diagnostic dump — it prints a table of every camera with its capabilities to the console:

@inject ICameraCapture Camera
...
string report = await Camera.DescribeCamerasAsync();

Self timer

The shutter can wait before it fires. Like both bars above, the presets are the switch: pass none and there is no timer button at all.

<CameraView TimerOptions="timers" @bind-TimerSeconds="timerSeconds" ... />

@code {
    // Include an "off" preset (zero seconds): the button cycles through the list, and that is how
    // the user disarms it again.
    private readonly CameraTimerOption[] timers = [new(0), new(3), new(10)];

    private int timerSeconds;      // bind it if you want the delay remembered across opens
}

With a delay armed, pressing the shutter starts a countdown instead of taking the photo: the seconds count down big in the middle of the viewfinder and the shutter becomes the cancel button — that is where the finger already is, and it is the only way out that does not mean closing the camera. The delay stays armed after the shot, so a series of self-timed photos costs one tap each.

OnBeforeCapture is raised when the shutter actually fires, not when the countdown is armed: whatever you check there (storage left, quota, anything) has to be true at the moment the photo is taken. The countdown is also cancelled by closing the viewfinder, switching camera or changing the delay, so it can never fire into a camera the user has already left.

It is pure overlay — nothing is asked of ICameraCapture, so a custom backend gets the timer for free.

Recording video

The shutter can record as well as shoot. Like every other bar, the list is the switch: pass no modes and there is no selector, no microphone is ever requested, and nothing about the camera changes.

<CameraView CaptureModes="modes"
            @bind-CaptureMode="mode"
            MaxVideoDuration="TimeSpan.FromSeconds(60)"
            OnRecordingStarted="OpenDestination"
            OnVideoChunk="WriteSlice"
            OnVideoPosterCaptured="KeepPoster"
            OnVideoRecorded="SealRecording"
            ... />

@code {
    private readonly CameraCaptureMode[] modes = [CameraCaptureMode.Photo, CameraCaptureMode.Video];
    private CameraCaptureMode mode = CameraCaptureMode.Photo;
}

With video mode selected, the pills above the shutter say what the button does; pressing it starts recording, the shutter turns red and becomes a stop button, and an indicator over the viewfinder shows the elapsed time and how much of the limit is left. The self timer still works — it delays the start, it does not decide what is started.

The component does not hold the recording. A photo is handed over whole because a photo is small; a minute of video is tens of megabytes, and a component that accumulated it would be deciding, on your behalf, that all of it fits in memory. So the bytes leave in slices through OnVideoChunk as they are produced, and OnVideoRecorded closes with the totals and the reason it stopped. Whether those slices become a file, rows in IndexedDB or blocks pushed to storage is yours to decide — the same division of labour as with a photo.

Slices arrive in order, one at a time: the next one is not produced until your handler completes, so a slow consumer slows the delivery down instead of piling megabytes up behind it.

A few things worth knowing:

  • MP4 only. Where the browser cannot encode it, CaptureModes silently drops video and you get a VideoUnsupported error — a missing mode is better than one that fails when pressed.
  • The microphone is requested when the user picks video mode, not when the camera loads, and it is released on the way back to photo. A refusal is reported as MicrophoneDenied and records a silent video: losing the whole recording over the sound would be trading a small problem for a big one.
  • OnVideoPosterCaptured hands you the first frame as an ordinary CameraPhoto — same JPEG, same baked-in rotation — with no flash and no torch. It is the poster of the video: the frame the user was looking at when they pressed record.
  • A photo can be taken while recording, from a button beside the shutter (where the camera switch sits, which is off-limits mid-recording anyway). It is an ordinary photo through OnPhotoCaptured, unrelated to the video: the frame worth keeping is rarely the frame the recording ends on. Set AllowPhotoWhileRecording="false" where the backend cannot do both at once, and SnapshotContent replaces the icon.
  • Digital zoom is suspended while recording. The crop is a CSS transform over the preview and never reaches the recorded track, so a preset beyond the camera's own zoom would promise a framing the file does not have. Native zoom and the lenses keep working.
  • MaxVideoDuration (a minute by default, null for none) is reported rather than enforced in the browser, so ending by time and ending by the shutter run the very same path.
  • Closing the viewfinder, or disposing the component, ends a running recording properly: you get your OnVideoRecorded with Closed instead of orphan slices.

Vetoing a capture

The host decides whether a photo may be taken at all — no storage left, quota exceeded, subscription expired:

<CameraView OnBeforeCapture="ctx => { if (NoRoomLeft) { ctx.Cancel = true; ctx.Reason = "quota"; } }" ... />

Errors

The camera never renders a message. It reports and you decide:

<CameraView OnError="ShowToast" ... />

CameraError.Kind is one of Initialization, Start, Stop, Busy, Recovered, Unavailable, Capture, Switch, Torch, Zoom, Focus, Recording, MicrophoneDenied, VideoUnsupported. Busy means the stream could not start (permission denied, another app holds the camera, insecure context, no device) — the component then polls in the background, and reports Recovered if it comes back on its own or Unavailable once it gives up. Tune it with RetryAttempts and RetryDelayMs.

Customization

Slot Replaces
ShutterContent the shutter's inner disc
CloseContent the close icon
SwitchContent the front/back icon
TorchContent the torch icon (bool = armed)
TimerContent the self-timer button (int = armed delay, 0 = off)
ModeContent a photo/video mode pill (CameraCaptureMode = the mode it selects)
RecordingContent the recording indicator (CameraRecordingState = elapsed, limit, bytes)
LoadingContent the spinner shown while starting
OverlayTop free slot, centered at the top
OverlayBottom free slot, centered above the zoom bar

ShowSwitchCamera, ShowTorch, ShowCloseButton and CanCapture hide controls; Texts (a CameraTexts record) carries every label the camera renders, so a localized app builds one from its own resources.

Naming captures

The camera does not name photos. CameraPhoto gives you Bytes, Quality, ContentType and TakenAt, and you build the name — because only you know whether it becomes a download prompt, a blob key or a primary key in an upload queue:

Make it unique, not just timestamped. Captures are pipelined, so two shots in a burst can share the same millisecond — if that name is a key anywhere, the second photo overwrites the first. This is not hypothetical: it is why the camera stopped suggesting a name at all in 2.0.

If you have no opinion, BuildFileName() gives you one that is safe — photo_yyyyMMddHHmmssfff_{12 hex}.jpg:

private Task OnPhoto(CameraPhoto photo)
{
    var name = photo.BuildFileName();      // photo_20260815143012487_9f2c41ab77de.jpg
    ...
}

It is a helper, not a default: you ask for it. The timestamp keeps names sortable and records when the shot was actually taken (an upload pipeline stamps its own arrival time, which for a photo queued offline can be hours later), and the 48 random bits next to it put a collision at around 1 in 10^14 — so a full 32-character GUID would buy nothing and cost 20 characters, which matters on Android where each path component is capped at 255 bytes.

CameraPhotoNaming.BuildFileName(takenAt) is the same thing without a CameraPhoto in hand, and both take an extension argument if you are not writing .jpg.

Theming goes through CSS custom properties on the overlay root, so no ::deep is needed:

.my-camera {
    --bbcam-accent: #2563eb;      /* shutter disc */
    --bbcam-shutter-size: 84px;
    --bbcam-control-size: 56px;
    --bbcam-bg: #000;
    --bbcam-control-bg: rgba(255, 255, 255, .14);
    --bbcam-control-border: rgba(255, 255, 255, .55);
    --bbcam-control-color: #fff;
    --bbcam-pill-bg: rgba(0, 0, 0, .28);
    --bbcam-pill-border: rgba(255, 255, 255, .18);
    --bbcam-z-index: 1400;
}
<CameraView Class="my-camera" ... />

Style does the same thing inline, which is handy when the values come from the host's own design tokens rather than from a stylesheet:

<CameraView Style="--bbcam-accent: #e53935; --bbcam-shutter-size: 98px;" ... />

Notes from the field

  • Torch is pulsed, not held. Keeping it on blinds the preview and drains the battery, so it is switched on around the capture and off again — what a phone's camera app does.
  • No torch? The screen flashes. Front cameras and torchless devices get a white overlay pulse instead, which is enough to light a face at arm's length.
  • The front preview is mirrored, the file is not. The capture undoes the flip.
  • The overlay is always in the DOM, only hidden. The stream is prewarmed behind it so opening is instant; remounting a <video> would throw that away. Set Prewarm="false" to only touch the device when the user asks.

Advanced

ICameraCapture is the whole browser surface (start, capture, switch, torch, zoom, lenses, body lock). Register your own implementation instead of calling AddBlazorCamera() to fake the camera in tests or back it with something else.

Coming from 1.0.x with your own ICameraCapture? The interface grew three members — GetLensesAsync, UseLensAsync and DescribeCamerasAsync. Returning an empty list, false and "[]" gives you exactly the previous behaviour (zoom only, no lens switching).

Coming from 1.1.x with your own ICameraCapture? Three more — AttachPinchAsync, UpdatePinchConfigAsync and DetachPinchAsync. Returning false from the first and doing nothing in the other two leaves the camera with presets only, exactly as before: the component asks for the gesture, the backend decides whether it can serve it. A backend that does serve it reports where the gesture lands through ICameraPinchListener.OnPinchZoomChanged.

License

Free — see LICENSE.md.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.4.6 59 9/25/2026
1.4.5 72 9/23/2026
1.3.4 104 9/20/2026
1.2.3 140 9/8/2026
1.1.2 189 8/22/2026
1.1.1 143 8/15/2026
1.0.0 218 8/3/2026

Fix: photos matched the viewfinder again. The frame rate floor introduced with the sharp photos change
was applied with applyConstraints on its own, and applyConstraints replaces every constraint on the
track instead of adding to it: the requested frame size was dropped, the browser switched to another
format (square on some Android phones) and the photo stopped matching what the preview showed, in
portrait and in landscape alike. The floor now restates the same resolution the stream was opened with.