import {
afterNextRender,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
inject,
signal,
viewChild,
} from '@angular/core';
import { AfricaniesIconComponent } from '@africanies/africanies-icons';
import { ButtonComponent } from '../../button/button.component';
import { AfricaniesOverlayRef } from '../../overlay/africanies-overlay-ref';
type CameraFacing = 'environment' | 'user';
/**
* Live webcam capture dialog for {@link FileUploadComponent}.
*
* Uses `navigator.mediaDevices.getUserMedia` so desktop playgrounds and
* HTTPS apps can take a photo without relying on the mobile-only
* `` attribute. Closes with a JPEG {@link File}, or
* `undefined` when dismissed.
*
* Prefer opening via {@link ModalService} so the stream is owned by this
* dialog and torn down on close / destroy.
*/
@Component({
selector: 'africanies-camera-capture-dialog',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AfricaniesIconComponent, ButtonComponent],
template: `
Take a photo
Allow camera access, then capture when ready.
@if (error(); as err) {
{{ err }}
} @else {
@if (starting()) {
Starting camera…
}
}
`,
})
/**
* Live webcam capture dialog for {@link FileUploadComponent}.
*/
export class CameraCaptureDialogComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly ref = inject(AfricaniesOverlayRef);
private readonly preview =
viewChild>('preview');
private readonly snapshot =
viewChild>('snapshot');
protected readonly titleId = `africanies-camera-title-${Math.random().toString(36).slice(2, 10)}`;
protected readonly hintId = `${this.titleId}-hint`;
protected readonly errorId = `${this.titleId}-error`;
protected readonly starting = signal(true);
protected readonly capturing = signal(false);
protected readonly streamActive = signal(false);
protected readonly error = signal(null);
private stream: MediaStream | null = null;
private facing: CameraFacing = 'environment';
constructor() {
this.destroyRef.onDestroy(() => this.stopStream());
afterNextRender(() => {
void this.startStream();
});
}
/** Dismiss without a file. */
protected cancel(): void {
this.stopStream();
this.ref.close();
}
/** Flip between rear and front cameras when the device exposes both. */
protected switchFacing(): void {
this.facing = this.facing === 'environment' ? 'user' : 'environment';
void this.startStream();
}
/** Freeze the current frame into a JPEG {@link File} and close. */
protected capture(): void {
const video = this.preview()?.nativeElement;
const canvas = this.snapshot()?.nativeElement;
if (!video || !canvas || !this.streamActive() || this.capturing()) {
return;
}
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
this.error.set('Camera is not ready yet. Try again in a moment.');
return;
}
this.capturing.set(true);
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
this.capturing.set(false);
this.error.set('Could not capture this frame. Try another browser.');
return;
}
ctx.drawImage(video, 0, 0, width, height);
canvas.toBlob(
(blob) => {
this.capturing.set(false);
if (!blob) {
this.error.set('Could not create the photo. Try again.');
return;
}
const file = new File([blob], `camera-${Date.now()}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
this.stopStream();
this.ref.close(file);
},
'image/jpeg',
0.92,
);
}
private async startStream(): Promise {
this.starting.set(true);
this.error.set(null);
this.streamActive.set(false);
this.stopStream();
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
) {
this.starting.set(false);
this.error.set(
'Camera capture is not available in this browser. Use Choose files instead.',
);
return;
}
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: this.facing },
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
const video = this.preview()?.nativeElement;
if (!video) {
this.stopStream();
this.starting.set(false);
this.error.set('Camera preview failed to start.');
return;
}
video.srcObject = this.stream;
await video.play().catch(() => undefined);
this.streamActive.set(true);
this.starting.set(false);
} catch {
this.starting.set(false);
this.streamActive.set(false);
this.error.set(
'Could not access the camera. Check permissions and try again.',
);
}
}
private stopStream(): void {
const video = this.preview()?.nativeElement;
if (video) {
video.srcObject = null;
}
if (this.stream) {
for (const track of this.stream.getTracks()) {
track.stop();
}
this.stream = null;
}
this.streamActive.set(false);
}
}