biometry-angular-components
Version:
Angular UI component library for capturing biometric data
698 lines (690 loc) • 72.6 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, EventEmitter, Component, ChangeDetectionStrategy, Input, Output, ViewChild } from '@angular/core';
import * as i3 from '@angular/common';
import { CommonModule } from '@angular/common';
class PermissionsService {
async requestCamera(options) {
const { width = 640, height = 480, audio = false } = options;
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width, height },
audio
});
return { stream, granted: true };
}
catch {
return { granted: false };
}
}
async requestMicrophone() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
return { stream, granted: true };
}
catch {
return { granted: false };
}
}
stopStream(stream) {
if (!stream)
return;
stream.getTracks().forEach(track => track.stop());
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: PermissionsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: PermissionsService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: PermissionsService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
class DocScanComponent {
perms;
zone;
cdr;
rectWidth = 640;
rectHeight = 400;
noShadow = false;
capture = new EventEmitter();
isConfirming = false;
capturedUrl = null;
capturedFile = null;
stream;
QUALITY_MULTIPLIER = 3;
constructor(perms, zone, cdr) {
this.perms = perms;
this.zone = zone;
this.cdr = cdr;
}
ngOnInit() {
this.initStream();
}
ngOnDestroy() {
this.stopStream();
this.revokeBlob();
}
async initStream() {
const { stream, granted } = await this.perms.requestCamera({
width: this.rectWidth,
height: this.rectHeight,
});
if (!granted || !stream)
return;
this.zone.run(() => {
this.stream = stream;
this.cdr.markForCheck();
});
}
stopStream() {
if (!this.stream)
return;
this.perms.stopStream(this.stream);
this.stream = undefined;
}
handleCapture(videoEl) {
if (!videoEl)
return;
const canvas = document.createElement('canvas');
canvas.width = this.rectWidth * this.QUALITY_MULTIPLIER;
canvas.height = this.rectHeight * this.QUALITY_MULTIPLIER;
const ctx = canvas.getContext('2d');
if (!ctx)
return;
const videoAspect = videoEl.videoWidth / videoEl.videoHeight;
const targetAspect = this.rectWidth / this.rectHeight;
let sx = 0, sy = 0, sw = videoEl.videoWidth, sh = videoEl.videoHeight;
if (videoAspect > targetAspect) {
sw = videoEl.videoHeight * targetAspect;
sx = (videoEl.videoWidth - sw) / 2;
}
else {
sh = videoEl.videoWidth / targetAspect;
sy = (videoEl.videoHeight - sh) / 2;
}
ctx.drawImage(videoEl, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => {
if (!blob)
return;
const file = new File([blob], 'document.jpg', { type: 'image/jpeg' });
this.zone.run(() => {
this.capturedFile = file;
this.capturedUrl = URL.createObjectURL(blob);
this.isConfirming = true;
this.cdr.markForCheck();
});
}, 'image/jpeg', 0.98);
}
handleConfirm() {
if (!this.capturedFile)
return;
this.capture.emit(this.capturedFile);
}
handleDecline() {
this.revokeBlob();
this.capturedFile = null;
this.isConfirming = false;
this.initStream();
}
revokeBlob() {
if (!this.capturedUrl)
return;
URL.revokeObjectURL(this.capturedUrl);
this.capturedUrl = null;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DocScanComponent, deps: [{ token: PermissionsService }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: DocScanComponent, isStandalone: true, selector: "bio-doc-scan", inputs: { rectWidth: "rectWidth", rectHeight: "rectHeight", noShadow: "noShadow" }, outputs: { capture: "capture" }, ngImport: i0, template: "<div class=\"docscan-wrapper\"\n [ngStyle]=\"{ width: rectWidth + 'px', height: rectHeight + 64 + 'px', boxShadow: noShadow ? 'none' : '0 4px 24px rgba(0,0,0,0.12)' }\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"docscan-camera-area\">\n <video #video [width]=\"rectWidth\" [height]=\"rectHeight\" autoplay muted playsinline\n [style.border-radius.px]=\"noShadow ? 0 : 8\" class=\"docscan-video\" [srcObject]=\"stream\">\n </video>\n <div class=\"docscan-dark-overlay\"></div>\n <div class=\"docscan-dashed-area\">\n <span class=\"docscan-guidance-text\">Place your document here</span>\n </div>\n </div>\n <button class=\"docscan-capture-btn\" (click)=\"handleCapture(video)\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"docscan-preview\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured document\" />\n </div>\n <div class=\"docscan-confirm-text\">Do you want to use this photo?</div>\n <div class=\"docscan-confirm-btns\">\n <button class=\"docscan-icon-btn\" (click)=\"handleDecline()\">\n <!-- SVG Retake -->\n </button>\n <button class=\"docscan-icon-btn\" (click)=\"handleConfirm()\">\n <!-- SVG Confirm -->\n </button>\n </div>\n </ng-template>\n</div>", styles: [".docscan-wrapper{background:#fff;border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:0 auto}.docscan-wrapper .docscan-camera-area{position:relative;width:100%;height:100%;margin-bottom:20px}.docscan-wrapper .docscan-camera-area video{object-fit:cover;position:absolute;top:0;left:0;z-index:0;width:100%;height:100%}.docscan-wrapper .docscan-camera-area .docscan-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;border-radius:8px;z-index:1}.docscan-wrapper .docscan-camera-area .docscan-dashed-area{position:absolute;top:10%;left:10%;width:80%;height:80%;border:2.5px dashed #fff;border-radius:16px;z-index:2;display:flex;align-items:center;justify-content:center}.docscan-wrapper .docscan-camera-area .docscan-dashed-area .docscan-guidance-text{color:#fff;font-size:12px;text-shadow:0 1px 4px rgba(0,0,0,.25)}.docscan-wrapper .docscan-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a}.docscan-wrapper .docscan-preview img{max-width:100%;max-height:100%;border-radius:8px}.docscan-wrapper .docscan-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px}.docscan-wrapper .docscan-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DocScanComponent, decorators: [{
type: Component,
args: [{ selector: 'bio-doc-scan', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"docscan-wrapper\"\n [ngStyle]=\"{ width: rectWidth + 'px', height: rectHeight + 64 + 'px', boxShadow: noShadow ? 'none' : '0 4px 24px rgba(0,0,0,0.12)' }\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"docscan-camera-area\">\n <video #video [width]=\"rectWidth\" [height]=\"rectHeight\" autoplay muted playsinline\n [style.border-radius.px]=\"noShadow ? 0 : 8\" class=\"docscan-video\" [srcObject]=\"stream\">\n </video>\n <div class=\"docscan-dark-overlay\"></div>\n <div class=\"docscan-dashed-area\">\n <span class=\"docscan-guidance-text\">Place your document here</span>\n </div>\n </div>\n <button class=\"docscan-capture-btn\" (click)=\"handleCapture(video)\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"docscan-preview\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured document\" />\n </div>\n <div class=\"docscan-confirm-text\">Do you want to use this photo?</div>\n <div class=\"docscan-confirm-btns\">\n <button class=\"docscan-icon-btn\" (click)=\"handleDecline()\">\n <!-- SVG Retake -->\n </button>\n <button class=\"docscan-icon-btn\" (click)=\"handleConfirm()\">\n <!-- SVG Confirm -->\n </button>\n </div>\n </ng-template>\n</div>", styles: [".docscan-wrapper{background:#fff;border-radius:10px;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:0 auto}.docscan-wrapper .docscan-camera-area{position:relative;width:100%;height:100%;margin-bottom:20px}.docscan-wrapper .docscan-camera-area video{object-fit:cover;position:absolute;top:0;left:0;z-index:0;width:100%;height:100%}.docscan-wrapper .docscan-camera-area .docscan-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;border-radius:8px;z-index:1}.docscan-wrapper .docscan-camera-area .docscan-dashed-area{position:absolute;top:10%;left:10%;width:80%;height:80%;border:2.5px dashed #fff;border-radius:16px;z-index:2;display:flex;align-items:center;justify-content:center}.docscan-wrapper .docscan-camera-area .docscan-dashed-area .docscan-guidance-text{color:#fff;font-size:12px;text-shadow:0 1px 4px rgba(0,0,0,.25)}.docscan-wrapper .docscan-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a}.docscan-wrapper .docscan-preview img{max-width:100%;max-height:100%;border-radius:8px}.docscan-wrapper .docscan-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px}.docscan-wrapper .docscan-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%}\n"] }]
}], ctorParameters: () => [{ type: PermissionsService }, { type: i0.NgZone }, { type: i0.ChangeDetectorRef }], propDecorators: { rectWidth: [{
type: Input
}], rectHeight: [{
type: Input
}], noShadow: [{
type: Input
}], capture: [{
type: Output
}] } });
class FaceCaptureComponent {
perms;
ngZone;
cdr;
rectWidth = 360;
rectHeight = 576;
noShadow = false;
capture = new EventEmitter();
videoEl;
isConfirming = false;
capturedUrl = null;
capturedFile = null;
stream;
QUALITY_MULTIPLIER = 3;
constructor(perms, ngZone, cdr) {
this.perms = perms;
this.ngZone = ngZone;
this.cdr = cdr;
}
async ngOnInit() {
await this.initStream();
}
ngOnDestroy() {
this.stopStream();
if (this.capturedUrl)
URL.revokeObjectURL(this.capturedUrl);
}
async initStream() {
try {
const { stream, granted } = await this.perms.requestCamera({
width: this.rectWidth,
height: this.rectHeight,
});
if (!granted) {
return;
}
this.stream = stream;
console.log('this.stream = stream');
this.ngZone.runOutsideAngular(() => {
if (this.videoEl?.nativeElement) {
console.log('nativeElement');
this.videoEl.nativeElement.srcObject = this.stream;
this.videoEl.nativeElement.onloadedmetadata = () => this.videoEl.nativeElement.play();
}
});
}
catch (err) {
console.error('Camera access error:', err);
}
}
stopStream() {
this.stream?.getTracks().forEach(track => track.stop());
this.stream = undefined;
}
handleCapture() {
const video = this.videoEl?.nativeElement;
if (!video)
return;
const canvas = document.createElement('canvas');
canvas.width = this.rectWidth * this.QUALITY_MULTIPLIER;
canvas.height = this.rectHeight * this.QUALITY_MULTIPLIER;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, this.rectWidth, this.rectHeight, 0, 0, this.rectWidth * this.QUALITY_MULTIPLIER, this.rectHeight * this.QUALITY_MULTIPLIER);
canvas.toBlob(blob => {
if (blob) {
const file = new File([blob], 'face.jpg', { type: 'image/jpeg' });
this.capturedFile = file;
this.capturedUrl = URL.createObjectURL(blob);
this.isConfirming = true;
this.stopStream();
this.cdr.markForCheck();
}
}, 'image/jpeg', 0.98);
}
}
handleConfirm() {
if (this.capturedFile) {
this.capture.emit(this.capturedFile);
}
}
handleDecline() {
if (this.capturedUrl) {
URL.revokeObjectURL(this.capturedUrl);
}
this.capturedUrl = null;
this.capturedFile = null;
this.isConfirming = false;
this.initStream();
this.cdr.markForCheck();
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceCaptureComponent, deps: [{ token: PermissionsService }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: FaceCaptureComponent, isStandalone: true, selector: "bio-face-capture", inputs: { rectWidth: "rectWidth", rectHeight: "rectHeight", noShadow: "noShadow" }, outputs: { capture: "capture" }, viewQueries: [{ propertyName: "videoEl", first: true, predicate: ["videoEl"], descendants: true }], ngImport: i0, template: "<div class=\"facecapture-wrapper\" [ngClass]=\"{ 'no-shadow': noShadow }\" [style.width.px]=\"rectWidth\"\n [style.height.px]=\"rectHeight + 64\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"facecapture-camera-area\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\">\n <video #videoEl class=\"facecapture-video\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\"\n playsinline muted autoplay></video>\n <div class=\"facecapture-dark-overlay\" [ngClass]=\"{ 'no-shadow': noShadow }\"></div>\n <div class=\"facecapture-oval-area\">\n <span class=\"facecapture-guidance-text\">Place your face in the oval</span>\n <div class=\"facecapture-oval-dashed\"></div>\n </div>\n </div>\n <button type=\"button\" class=\"facecapture-capture-btn\" (click)=\"handleCapture()\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"facecapture-preview\" [style.width.px]=\"rectWidth - 25\" [style.height.px]=\"rectHeight - 25\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured face\" class=\"facecapture-preview-img\" />\n </div>\n <div class=\"facecapture-confirm-text\">Do you want to use this photo?</div>\n <div class=\"facecapture-confirm-btns\">\n <button type=\"button\" aria-label=\"Retake\" class=\"facecapture-icon-btn decline\" (click)=\"handleDecline()\">\n \u2716\n </button>\n <button type=\"button\" aria-label=\"Confirm\" class=\"facecapture-icon-btn confirm\" (click)=\"handleConfirm()\">\n \u2714\n </button>\n </div>\n </ng-template>\n</div>", styles: [".facecapture-wrapper{background:#fff;border-radius:10px;box-shadow:0 4px 24px #0000001f;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center}.facecapture-wrapper.no-shadow{border-radius:0;box-shadow:none}.facecapture-camera-area{position:relative;margin-bottom:20px;display:flex;align-items:center;justify-content:center}.facecapture-video{object-fit:cover;border-radius:8px;z-index:0}.facecapture-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;z-index:1;border-radius:8px}.facecapture-dark-overlay.no-shadow{border-radius:0}.facecapture-oval-area{position:absolute;top:18%;left:50%;transform:translate(-50%);width:72%;height:68%;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;pointer-events:none}.facecapture-guidance-text{color:#fff;font-size:12px;font-weight:400;margin-bottom:4px;letter-spacing:.2px;text-shadow:0 1px 4px rgba(0,0,0,.25);-webkit-user-select:none;user-select:none}.facecapture-oval-dashed{width:100%;height:100%;border:2.5px dashed #fff;border-radius:50%;box-sizing:border-box;background:transparent}.facecapture-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a;transition:background .2s}.facecapture-capture-btn:hover{background:#125a9e}.facecapture-preview{display:flex;align-items:center;justify-content:center;background:#111;border-radius:8px;margin:0 auto}.facecapture-preview .facecapture-preview-img{max-width:100%;max-height:100%;border-radius:8px}.facecapture-confirm-text{text-align:center;margin-top:16px;color:#111;font-size:17px;font-weight:500;text-shadow:0 1px 4px rgba(255,255,255,.12)}.facecapture-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px;width:100%}.facecapture-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%;transition:background .2s}.facecapture-icon-btn.decline:hover{background:#d32f2f1f}.facecapture-icon-btn.confirm:hover{background:#388e3c1f}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceCaptureComponent, decorators: [{
type: Component,
args: [{ selector: 'bio-face-capture', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"facecapture-wrapper\" [ngClass]=\"{ 'no-shadow': noShadow }\" [style.width.px]=\"rectWidth\"\n [style.height.px]=\"rectHeight + 64\">\n <ng-container *ngIf=\"!isConfirming; else confirmTpl\">\n <div class=\"facecapture-camera-area\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\">\n <video #videoEl class=\"facecapture-video\" [style.width.px]=\"rectWidth - 40\" [style.height.px]=\"rectHeight - 40\"\n playsinline muted autoplay></video>\n <div class=\"facecapture-dark-overlay\" [ngClass]=\"{ 'no-shadow': noShadow }\"></div>\n <div class=\"facecapture-oval-area\">\n <span class=\"facecapture-guidance-text\">Place your face in the oval</span>\n <div class=\"facecapture-oval-dashed\"></div>\n </div>\n </div>\n <button type=\"button\" class=\"facecapture-capture-btn\" (click)=\"handleCapture()\">Capture</button>\n </ng-container>\n\n <ng-template #confirmTpl>\n <div class=\"facecapture-preview\" [style.width.px]=\"rectWidth - 25\" [style.height.px]=\"rectHeight - 25\">\n <img *ngIf=\"capturedUrl\" [src]=\"capturedUrl\" alt=\"Captured face\" class=\"facecapture-preview-img\" />\n </div>\n <div class=\"facecapture-confirm-text\">Do you want to use this photo?</div>\n <div class=\"facecapture-confirm-btns\">\n <button type=\"button\" aria-label=\"Retake\" class=\"facecapture-icon-btn decline\" (click)=\"handleDecline()\">\n \u2716\n </button>\n <button type=\"button\" aria-label=\"Confirm\" class=\"facecapture-icon-btn confirm\" (click)=\"handleConfirm()\">\n \u2714\n </button>\n </div>\n </ng-template>\n</div>", styles: [".facecapture-wrapper{background:#fff;border-radius:10px;box-shadow:0 4px 24px #0000001f;padding:20px;display:flex;flex-direction:column;align-items:center;justify-content:center}.facecapture-wrapper.no-shadow{border-radius:0;box-shadow:none}.facecapture-camera-area{position:relative;margin-bottom:20px;display:flex;align-items:center;justify-content:center}.facecapture-video{object-fit:cover;border-radius:8px;z-index:0}.facecapture-dark-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:#0006;z-index:1;border-radius:8px}.facecapture-dark-overlay.no-shadow{border-radius:0}.facecapture-oval-area{position:absolute;top:18%;left:50%;transform:translate(-50%);width:72%;height:68%;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;pointer-events:none}.facecapture-guidance-text{color:#fff;font-size:12px;font-weight:400;margin-bottom:4px;letter-spacing:.2px;text-shadow:0 1px 4px rgba(0,0,0,.25);-webkit-user-select:none;user-select:none}.facecapture-oval-dashed{width:100%;height:100%;border:2.5px dashed #fff;border-radius:50%;box-sizing:border-box;background:transparent}.facecapture-capture-btn{padding:12px 32px;font-size:18px;border-radius:8px;border:none;background:#1976d2;color:#fff;cursor:pointer;box-shadow:0 2px 8px #0000001a;transition:background .2s}.facecapture-capture-btn:hover{background:#125a9e}.facecapture-preview{display:flex;align-items:center;justify-content:center;background:#111;border-radius:8px;margin:0 auto}.facecapture-preview .facecapture-preview-img{max-width:100%;max-height:100%;border-radius:8px}.facecapture-confirm-text{text-align:center;margin-top:16px;color:#111;font-size:17px;font-weight:500;text-shadow:0 1px 4px rgba(255,255,255,.12)}.facecapture-confirm-btns{display:flex;justify-content:center;gap:32px;margin-top:16px;width:100%}.facecapture-icon-btn{background:none;border:none;cursor:pointer;padding:8px;border-radius:50%;transition:background .2s}.facecapture-icon-btn.decline:hover{background:#d32f2f1f}.facecapture-icon-btn.confirm:hover{background:#388e3c1f}\n"] }]
}], ctorParameters: () => [{ type: PermissionsService }, { type: i0.NgZone }, { type: i0.ChangeDetectorRef }], propDecorators: { rectWidth: [{
type: Input
}], rectHeight: [{
type: Input
}], noShadow: [{
type: Input
}], capture: [{
type: Output
}], videoEl: [{
type: ViewChild,
args: ['videoEl', { static: false }]
}] } });
class RecorderService {
chunks = [];
mediaRecorder;
start(stream, mimeType = 'video/webm') {
this.chunks = [];
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
this.mediaRecorder.ondataavailable = e => { if (e.data.size)
this.chunks.push(e.data); };
this.mediaRecorder.start();
}
stop() {
return new Promise(resolve => {
if (!this.mediaRecorder)
return resolve(new Blob());
this.mediaRecorder.onstop = () => resolve(new Blob(this.chunks, { type: this.mediaRecorder?.mimeType }));
this.mediaRecorder.stop();
});
}
cancel() {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
this.chunks = [];
this.mediaRecorder = undefined;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RecorderService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RecorderService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: RecorderService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
function numbersToPhrase$1(digits) {
const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
return digits.map(d => words[d]).join(' ');
}
function pickSupportedMimeType() {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4;codecs=h264,aac',
'video/mp4',
];
for (const type of candidates) {
// @ts-ignore - isTypeSupported exists in browsers that implement MediaRecorder
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(type)) {
return type;
}
}
return 'video/webm';
}
class FaceRecorderComponent {
permissions;
recorder;
cdr;
videoRef;
countdownSeconds = 3;
recordingSeconds = 10;
/** Prefer vertical 9:16; request 720x1280 from camera */
videoWidth = 720;
videoHeight = 1280;
capture = new EventEmitter();
state = 'preparation';
digits = [];
maskedDigits = [];
countdownLeft = 0;
recordLeft = 0;
countdownTimer;
recordTimer;
stream;
videoBlob = null;
videoUrl = null;
isConfirming = false;
permissionsGranted = null;
mimeType = pickSupportedMimeType();
constructor(permissions, recorder, cdr) {
this.permissions = permissions;
this.recorder = recorder;
this.cdr = cdr;
}
ngOnInit() {
this.generateDigits();
this.requestPermissions();
}
ngAfterViewInit() {
if (this.stream) {
this.attachStream();
}
}
ngOnDestroy() {
this.clearAllTimers();
this.cleanupPlayback();
this.stopTracks();
this.recorder.cancel();
}
/** === Flow control === */
async requestPermissions() {
this.permissionsGranted = null;
this.cdr.markForCheck();
const { stream, granted } = await this.permissions.requestCamera({
width: this.videoWidth,
height: this.videoHeight,
audio: true,
});
this.permissionsGranted = granted;
if (granted && stream) {
this.stream = stream;
this.attachStream();
}
else {
this.stream = undefined;
}
this.cdr.markForCheck();
}
attachStream() {
const video = this.videoRef.nativeElement;
video.srcObject = this.stream ?? null;
video.src = '';
video.controls = false;
video.muted = true;
video.play().catch(() => { });
}
startFlow() {
if (!this.stream || this.state !== 'preparation')
return;
this.startCountdown();
}
startCountdown() {
this.state = 'countdown';
this.countdownLeft = this.countdownSeconds;
this.cdr.markForCheck();
this.clearCountdown();
this.countdownTimer = setInterval(() => {
this.countdownLeft -= 1;
this.cdr.markForCheck();
if (this.countdownLeft <= 0) {
this.clearCountdown();
this.startRecording();
}
}, 1000);
}
startRecording() {
if (!this.stream)
return;
// Keep preview running; MediaRecorder records from the same stream
this.state = 'recording';
this.recordLeft = this.recordingSeconds;
try {
this.recorder.start(this.stream, this.mimeType);
}
catch (e) {
console.error('Failed to start recorder', e);
this.cancelRecording();
return;
}
this.clearRecordTimer();
this.recordTimer = setInterval(async () => {
this.recordLeft -= 1;
this.cdr.markForCheck();
if (this.recordLeft <= 0) {
await this.finishRecording();
}
}, 1000);
this.cdr.markForCheck();
}
async finishRecording() {
if (this.state !== 'recording')
return;
this.state = 'processing';
this.clearRecordTimer();
this.cdr.markForCheck();
try {
this.videoBlob = await this.recorder.stop();
}
catch {
this.videoBlob = null;
}
if (this.videoBlob && this.videoBlob.size) {
if (this.videoUrl)
URL.revokeObjectURL(this.videoUrl);
this.videoUrl = URL.createObjectURL(this.videoBlob);
// Switch the element from live preview to the recorded video
const video = this.videoRef.nativeElement;
video.srcObject = null;
video.src = this.videoUrl;
video.controls = true;
video.muted = false;
await video.play().catch(() => { });
this.state = 'result';
}
else {
// No data recorded: return to preparation with live preview
this.state = 'preparation';
this.attachStream();
}
this.cdr.markForCheck();
}
cancelRecording() {
if (this.state !== 'countdown' && this.state !== 'recording')
return;
this.clearCountdown();
this.clearRecordTimer();
this.recorder.cancel();
if (this.videoUrl)
URL.revokeObjectURL(this.videoUrl);
this.videoUrl = null;
this.videoBlob = null;
// Return to preparation and keep preview alive
this.state = 'preparation';
this.attachStream();
this.cdr.markForCheck();
}
handleDecline() {
if (this.videoUrl)
URL.revokeObjectURL(this.videoUrl);
this.videoUrl = null;
this.videoBlob = null;
this.state = 'preparation';
this.generateDigits();
this.requestPermissions();
this.cdr.markForCheck();
}
handleConfirm() {
if (!this.videoBlob)
return;
const file = new File([this.videoBlob], 'face-video.webm', {
type: this.videoBlob.type,
});
this.capture.emit({ file, phrase: numbersToPhrase$1(this.digits) });
if (this.videoUrl)
URL.revokeObjectURL(this.videoUrl);
this.videoUrl = null;
this.videoBlob = null;
this.isConfirming = false;
this.state = 'preparation';
this.requestPermissions();
this.cdr.markForCheck();
}
/** === Helpers === */
generateDigits() {
this.digits = Array.from({ length: 10 }, () => Math.floor(Math.random() * 10));
this.maskedDigits = Array(this.digits.length).fill('*');
}
clearCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
}
clearRecordTimer() {
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
}
clearAllTimers() {
this.clearCountdown();
this.clearRecordTimer();
}
cleanupPlayback() {
const v = this.videoRef?.nativeElement;
if (v) {
v.pause();
v.srcObject = null;
v.src = '';
v.removeAttribute('src');
}
if (this.videoUrl)
URL.revokeObjectURL(this.videoUrl);
this.videoUrl = null;
this.videoBlob = null;
}
stopTracks() {
this.stream?.getTracks().forEach(t => t.stop());
this.stream = undefined;
}
get displayDigits() {
return (this.state === 'recording' ? this.digits : this.maskedDigits).map(d => d.toString());
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceRecorderComponent, deps: [{ token: PermissionsService }, { token: RecorderService }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: FaceRecorderComponent, isStandalone: true, selector: "bio-face-recorder", inputs: { countdownSeconds: "countdownSeconds", recordingSeconds: "recordingSeconds", videoWidth: "videoWidth", videoHeight: "videoHeight" }, outputs: { capture: "capture" }, viewQueries: [{ propertyName: "videoRef", first: true, predicate: ["videoEl"], descendants: true, static: true }], ngImport: i0, template: "<div class=\"recorder-wrapper\" [class.result]=\"state === 'result'\">\n <!-- Video frame (9:16) -->\n <div class=\"frame\">\n <video #videoEl class=\"preview\" autoplay playsinline muted preload=\"metadata\"></video>\n\n <!-- Oval dashed overlay -->\n <div class=\"oval-area\" *ngIf=\"state !== 'result' && state !== 'processing'\">\n <span class=\"guidance-text\">Place your face in the oval</span>\n <div class=\"oval-dashed\"></div>\n </div>\n\n <!-- HUD: digits to read -->\n <!-- <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div> -->\n\n <!-- HUD: countdown -->\n <div class=\"overlay center\" *ngIf=\"state === 'countdown'\">\n <div class=\"countdown\">{{ countdownLeft }}</div>\n <button class=\"btn ghost\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: recording timer + cancel -->\n <div class=\"overlay top\" *ngIf=\"state === 'recording'\">\n <div class=\"timer\">\n <span class=\"dot\"></span>\n <span>{{ recordLeft }}s</span>\n </div>\n <button class=\"btn danger\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: processing -->\n <div class=\"overlay center\" *ngIf=\"state === 'processing'\">\n <div class=\"spinner\"></div>\n <div class=\"processing-label\">Processing\u2026</div>\n </div>\n </div>\n\n <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div>\n\n <!-- Controls / CTA -->\n <div class=\"controls\">\n <!-- Preparation -->\n <ng-container *ngIf=\"state === 'preparation'\">\n <div class=\"hint\" *ngIf=\"permissionsGranted === false\">\n Camera access denied. Please allow camera & audio and try again.\n </div>\n <button class=\"btn primary\" type=\"button\" (click)=\"startFlow()\" [disabled]=\"!permissionsGranted\">\n Start recording\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"requestPermissions()\">Retry camera</button>\n </ng-container>\n\n <!-- Result -->\n <ng-container *ngIf=\"state === 'result'\">\n <div class=\"result-actions\">\n <button class=\"btn success\" type=\"button\" [disabled]=\"isConfirming\" (click)=\"handleConfirm()\">\n {{ isConfirming ? 'Confirming\u2026' : 'Use this video' }}\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"handleDecline()\">Retake</button>\n </div>\n </ng-container>\n </div>\n</div>", styles: [".recorder-wrapper{display:grid;gap:12px;justify-items:center;width:100%}.frame{position:relative;width:100%;max-width:320px;aspect-ratio:9/16;background:#000;border-radius:20px;overflow:hidden;box-shadow:0 6px 20px #00000040}.preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.oval-area{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}.guidance-text{position:absolute;top:13%;left:50%;transform:translate(-50%);text-wrap:nowrap;color:#fff;background:#00000059;padding:6px 10px;text-align:center;border-radius:30px;font-size:12px;letter-spacing:.2px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.oval-dashed{width:70%;height:58%;border:3px dashed rgba(255,255,255,.8);border-radius:50%;box-shadow:0 0 0 9999px #00000026 inset}.digits{width:min(100%,420px);display:grid;gap:8px;justify-items:center;margin-top:4px}.digits-label{font-size:13px;opacity:.8;text-align:center}.digits-row{display:flex;justify-content:center;gap:8px;flex-wrap:nowrap;overflow-x:auto}.digit-box{width:28px;height:36px;background:#f5f5f5;border:2px solid #333;border-radius:6px;font-size:18px;font-weight:600;color:#333;display:flex;align-items:center;justify-content:center;box-shadow:0 1px 3px #00000014;transition:background .3s;flex-shrink:0}.overlay{position:absolute;inset-inline:0;display:grid;justify-items:center;gap:10px}.overlay.center{inset-block:0;place-content:center}.overlay.top{top:10px}.countdown{font-size:72px;font-weight:800;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.35)}.timer{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:#00000059;color:#fff;font-weight:600}.timer .dot{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 1s infinite}.processing-label{color:#fff;font-weight:600}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid rgba(255,255,255,.4);border-top-color:#fff;animation:spin .9s linear infinite}.controls{width:min(100%,420px);display:grid;gap:10px}.result-actions{display:grid;grid-auto-flow:column;gap:10px}.hint{font-size:13px;color:#a00;text-align:center}.btn{appearance:none;border:none;padding:10px 14px;border-radius:12px;font-weight:600;cursor:pointer;background:#eee}.btn.primary{background:#2b6ef2;color:#fff}.btn.success{background:#16a34a;color:#fff}.btn.danger{background:#ef4444;color:#fff}.btn.ghost{background:#00000059;color:#fff}.btn:disabled{opacity:.6;cursor:not-allowed}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FaceRecorderComponent, decorators: [{
type: Component,
args: [{ selector: 'bio-face-recorder', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"recorder-wrapper\" [class.result]=\"state === 'result'\">\n <!-- Video frame (9:16) -->\n <div class=\"frame\">\n <video #videoEl class=\"preview\" autoplay playsinline muted preload=\"metadata\"></video>\n\n <!-- Oval dashed overlay -->\n <div class=\"oval-area\" *ngIf=\"state !== 'result' && state !== 'processing'\">\n <span class=\"guidance-text\">Place your face in the oval</span>\n <div class=\"oval-dashed\"></div>\n </div>\n\n <!-- HUD: digits to read -->\n <!-- <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div> -->\n\n <!-- HUD: countdown -->\n <div class=\"overlay center\" *ngIf=\"state === 'countdown'\">\n <div class=\"countdown\">{{ countdownLeft }}</div>\n <button class=\"btn ghost\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: recording timer + cancel -->\n <div class=\"overlay top\" *ngIf=\"state === 'recording'\">\n <div class=\"timer\">\n <span class=\"dot\"></span>\n <span>{{ recordLeft }}s</span>\n </div>\n <button class=\"btn danger\" type=\"button\" (click)=\"cancelRecording()\">Cancel</button>\n </div>\n\n <!-- HUD: processing -->\n <div class=\"overlay center\" *ngIf=\"state === 'processing'\">\n <div class=\"spinner\"></div>\n <div class=\"processing-label\">Processing\u2026</div>\n </div>\n </div>\n\n <div class=\"digits\" *ngIf=\"state !== 'processing'\">\n <div class=\"digits-label\">Read these digits aloud</div>\n <div class=\"digits-row\">\n <div class=\"digit-box\" *ngFor=\"let d of displayDigits\">\n {{ d }}\n </div>\n </div>\n </div>\n\n <!-- Controls / CTA -->\n <div class=\"controls\">\n <!-- Preparation -->\n <ng-container *ngIf=\"state === 'preparation'\">\n <div class=\"hint\" *ngIf=\"permissionsGranted === false\">\n Camera access denied. Please allow camera & audio and try again.\n </div>\n <button class=\"btn primary\" type=\"button\" (click)=\"startFlow()\" [disabled]=\"!permissionsGranted\">\n Start recording\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"requestPermissions()\">Retry camera</button>\n </ng-container>\n\n <!-- Result -->\n <ng-container *ngIf=\"state === 'result'\">\n <div class=\"result-actions\">\n <button class=\"btn success\" type=\"button\" [disabled]=\"isConfirming\" (click)=\"handleConfirm()\">\n {{ isConfirming ? 'Confirming\u2026' : 'Use this video' }}\n </button>\n <button class=\"btn\" type=\"button\" (click)=\"handleDecline()\">Retake</button>\n </div>\n </ng-container>\n </div>\n</div>", styles: [".recorder-wrapper{display:grid;gap:12px;justify-items:center;width:100%}.frame{position:relative;width:100%;max-width:320px;aspect-ratio:9/16;background:#000;border-radius:20px;overflow:hidden;box-shadow:0 6px 20px #00000040}.preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.oval-area{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}.guidance-text{position:absolute;top:13%;left:50%;transform:translate(-50%);text-wrap:nowrap;color:#fff;background:#00000059;padding:6px 10px;text-align:center;border-radius:30px;font-size:12px;letter-spacing:.2px;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.oval-dashed{width:70%;height:58%;border:3px dashed rgba(255,255,255,.8);border-radius:50%;box-shadow:0 0 0 9999px #00000026 inset}.digits{width:min(100%,420px);display:grid;gap:8px;justify-items:center;margin-top:4px}.digits-label{font-size:13px;opacity:.8;text-align:center}.digits-row{display:flex;justify-content:center;gap:8px;flex-wrap:nowrap;overflow-x:auto}.digit-box{width:28px;height:36px;background:#f5f5f5;border:2px solid #333;border-radius:6px;font-size:18px;font-weight:600;color:#333;display:flex;align-items:center;justify-content:center;box-shadow:0 1px 3px #00000014;transition:background .3s;flex-shrink:0}.overlay{position:absolute;inset-inline:0;display:grid;justify-items:center;gap:10px}.overlay.center{inset-block:0;place-content:center}.overlay.top{top:10px}.countdown{font-size:72px;font-weight:800;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.35)}.timer{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:#00000059;color:#fff;font-weight:600}.timer .dot{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 1s infinite}.processing-label{color:#fff;font-weight:600}.spinner{width:28px;height:28px;border-radius:50%;border:3px solid rgba(255,255,255,.4);border-top-color:#fff;animation:spin .9s linear infinite}.controls{width:min(100%,420px);display:grid;gap:10px}.result-actions{display:grid;grid-auto-flow:column;gap:10px}.hint{font-size:13px;color:#a00;text-align:center}.btn{appearance:none;border:none;padding:10px 14px;border-radius:12px;font-weight:600;cursor:pointer;background:#eee}.btn.primary{background:#2b6ef2;color:#fff}.btn.success{background:#16a34a;color:#fff}.btn.danger{background:#ef4444;color:#fff}.btn.ghost{background:#00000059;color:#fff}.btn:disabled{opacity:.6;cursor:not-allowed}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:.5;transform:scale(1)}50%{opacity:1;transform:scale(1.2)}}\n"] }]
}], ctorParameters: () => [{ type: PermissionsService }, { type: RecorderService }, { type: i0.ChangeDetectorRef }], propDecorators: { videoRef: [{
type: ViewChild,
args: ['videoEl', { static: true }]
}], countdownSeconds: [{
type: Input
}], recordingSeconds: [{
type: Input
}], videoWidth: [{
type: Input
}], videoHeight: [{
type: Input
}], capture: [{
type: Output
}] } });
function numbersToPhrase(digits) {
const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
return digits.map(d => (d >= 0 && d <= 9 ? words[d] : String(d))).join(' ');
}
function generateRandomDigits(count) {
const digits = Array.from({ length: 10 }, (_, i) => i);
for (let i = digits.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[digits[i], digits[j]] = [digits[j], digits[i]];
}
return digits.slice(0, count);
}
const RECORD_SECONDS = 10;
const PREP_SECONDS = 3;
class VoiceRecorderComponent {
perms;
recorder;
ngZone;
cdr;
rectWidth = 360;
rectHeight;
noShadow = false;
className;
style;
capture = new EventEmitter();
audioRef;
permission = false;
isLoading = true;
stream;
state = 'preparation';
prepTimer = PREP_SECONDS;
recordTimer = RECORD_SECONDS;
// digits & phrase
digits = generateRandomDigits(10);
maskedDigits = Array(10).fill('*');
// visualization
audioLevel = 0;
waveformData = [];
// blobs/urls
audioBlob = null;
audioUrl = null;
isConfirming = false;
// timers / raf
countdownTimeout;
recordInterval;
rafId;
// audio context nodes
audioCtx;
analyser;
sourceNode;
constructor(perms, recorder, ngZone, cdr) {
this.perms = perms;
this.recorder = recorder;
this.ngZone = ngZone;
this.cdr = cdr;
}
async ngOnInit() {
try {
const status = await (navigator.permissions?.query?.({ name: 'microphone' }) ?? Promise.reject());
this.ngZone.run(() => {
this.permission = status.state === 'granted';
this.isLoading = false;
this.cdr.detectChanges();
});
if (this.permission)
await this.requestPermissions();
status.onchange = async () => {
this.ngZone.run(async () => {
this.permission = status.state === 'granted';
if (this.permission && !this.stream)
await this.requestPermissions();
this.cdr.detectChanges();
});
};
}
catch {
this.ngZone.run(() => {
this.permission = false;
this.isLoading = false;
this.cdr.detectChanges();
});
}
}
ngOnDestroy() {
this.clearAllTimers();
this.teardownVisualizer();
this.perms.stopStream(this.stream);
if (this.audioUrl)
URL.revokeObjectURL(this.audioUrl);
}
async requestPermissions() {
this.ngZone.run(() => {
this.isLoading = true;
this.cdr.detectChanges();
});
const { stream, granted } = await this.perms.requestMicrophone();
this.ngZone.run(() => {
this.permission = !!granted;
this.isLoading = false;
if (granted && stream)
this.stream = stream;
this.cdr.detectChanges();
});
}
startRecording() {
this.digits = generateRandomDigits(10);
this.recordTimer = RECORD_SECONDS;
this.prepTimer = PREP_SECONDS;
this.state = 'countdown';
this.tickCountdown();
}
tickCountdown() {
if (this.prepTimer > 0) {
this.countdownTimeout = setTimeout(() => {
this.ngZone.run(() => {
this.prepTimer -= 1;
this.cdr.detectChanges();
});
this.tickCountdown();
}, 1000);
return;
}
this.ngZone.run(() => {
this.state = 'recording';
this.prepTimer = PREP_SECONDS;
this.cdr.detectChanges();
});
this.beginRecording();
}
async beginRecording() {
if (!this.stream) {
const { stream, granted } = await this.perms.requestMicrophone();
if (!granted || !stream)
return;