BlazorBasics.Camera
1.4.6
dotnet add package BlazorBasics.Camera --version 1.4.6
NuGet\Install-Package BlazorBasics.Camera -Version 1.4.6
<PackageReference Include="BlazorBasics.Camera" Version="1.4.6" />
<PackageVersion Include="BlazorBasics.Camera" Version="1.4.6" />
<PackageReference Include="BlazorBasics.Camera" />
paket add BlazorBasics.Camera --version 1.4.6
#r "nuget: BlazorBasics.Camera, 1.4.6"
#:package BlazorBasics.Camera@1.4.6
#addin nuget:?package=BlazorBasics.Camera&version=1.4.6
#tool nuget:?package=BlazorBasics.Camera&version=1.4.6
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 flipisOpen. - Imperative:
@refplusOpenAsync()/CloseAsync(), and never passIsOpen.
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,
CaptureModessilently drops video and you get aVideoUnsupportederror — 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
MicrophoneDeniedand records a silent video: losing the whole recording over the sound would be trading a small problem for a big one. OnVideoPosterCapturedhands you the first frame as an ordinaryCameraPhoto— 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. SetAllowPhotoWhileRecording="false"where the backend cannot do both at once, andSnapshotContentreplaces 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,nullfor 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
OnVideoRecordedwithClosedinstead 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. SetPrewarm="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,UseLensAsyncandDescribeCamerasAsync. Returning an empty list,falseand"[]"gives you exactly the previous behaviour (zoom only, no lens switching).
Coming from 1.1.x with your own
ICameraCapture? Three more —AttachPinchAsync,UpdatePinchConfigAsyncandDetachPinchAsync. Returningfalsefrom 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 throughICameraPinchListener.OnPinchZoomChanged.
License
Free — see LICENSE.md.
| Product | Versions 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. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.12)
-
net8.0
- Microsoft.AspNetCore.Components.Web (>= 8.0.31)
-
net9.0
- Microsoft.AspNetCore.Components.Web (>= 9.0.20)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
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.