metag-verify 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm2022/lib/screens/selfie/selfie.component.mjs +81 -9
- package/esm2022/lib/services/camera.mjs +120 -0
- package/esm2022/lib/version.mjs +2 -2
- package/fesm2022/metag-verify.mjs +200 -9
- package/fesm2022/metag-verify.mjs.map +1 -1
- package/lib/screens/selfie/selfie.component.d.ts +22 -0
- package/lib/services/camera.d.ts +15 -0
- package/lib/version.d.ts +1 -1
- package/package.json +1 -1
- package/metag-verify-0.1.0.tgz +0 -0
|
@@ -145,7 +145,7 @@ async function isVirtualCamera(stream) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/** Versión que se envía en el header sdk-version (el backend no la valida). */
|
|
148
|
-
const METAG_VERIFY_VERSION = '0.1.
|
|
148
|
+
const METAG_VERIFY_VERSION = '0.1.1';
|
|
149
149
|
|
|
150
150
|
// ARCHIVO GENERADO por scripts/brand.mjs (npm run brand). No editar a mano.
|
|
151
151
|
// Recursos de marca incrustados para que el SDK no dependa de archivos externos.
|
|
@@ -277,6 +277,126 @@ class BrightnessBoost {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// Apertura de la cámara. La trasera se elige con la misma regla que el SDK actual (captura de DPI):
|
|
281
|
+
// 1) el lente "camera2 0" (el principal en Android, con enfoque automático), 2) la trasera con el número
|
|
282
|
+
// más bajo ("camera 0, facing back"), 3) cualquiera "back/rear", 4) la que entregue facingMode "environment".
|
|
283
|
+
const VIDEO_SIZE = { width: { ideal: 1920 }, height: { ideal: 1080 } };
|
|
284
|
+
const REAR_LABEL = /back|rear|environment/i;
|
|
285
|
+
const MAIN_REAR_LABEL = /camera2\s?0/i;
|
|
286
|
+
const CAMERA_INDEX = /camera2?\s*(\d+)/i;
|
|
287
|
+
async function openCamera(facing) {
|
|
288
|
+
if (facing === 'user') {
|
|
289
|
+
return getStream({ ...VIDEO_SIZE, facingMode: 'user' });
|
|
290
|
+
}
|
|
291
|
+
// Si ya hay permiso, los nombres están disponibles y se abre directo el lente principal.
|
|
292
|
+
const known = await preferredRearCamera();
|
|
293
|
+
let stream = await getStream(known ? { ...VIDEO_SIZE, deviceId: { exact: known } } : { ...VIDEO_SIZE, facingMode: { ideal: 'environment' } });
|
|
294
|
+
// Primera vez: los nombres aparecen después del permiso; si hay un lente mejor, se cambia a ese.
|
|
295
|
+
if (!known) {
|
|
296
|
+
const preferred = await preferredRearCamera();
|
|
297
|
+
const current = stream.getVideoTracks()[0]?.getSettings().deviceId;
|
|
298
|
+
if (preferred && preferred !== current) {
|
|
299
|
+
stopStream(stream);
|
|
300
|
+
stream = await getStream({ ...VIDEO_SIZE, deviceId: { exact: preferred } });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return stream;
|
|
304
|
+
}
|
|
305
|
+
/** Indica si el dispositivo tiene más de una cámara (para mostrar el botón de cambiar). */
|
|
306
|
+
async function hasMultipleCameras() {
|
|
307
|
+
try {
|
|
308
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
309
|
+
return devices.filter(d => d.kind === 'videoinput').length > 1;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Android reporta los modos de enfoque unos instantes después de abrir la cámara: se reintenta. */
|
|
316
|
+
const CAPABILITY_ATTEMPTS = 6;
|
|
317
|
+
const CAPABILITY_RETRY_MS = 250;
|
|
318
|
+
async function getCameraControls(stream) {
|
|
319
|
+
const caps = await readCapabilities(stream);
|
|
320
|
+
// Enfoque automático continuo cuando el lente lo soporta.
|
|
321
|
+
if (caps?.focusMode?.includes('continuous'))
|
|
322
|
+
await applyAdvanced(stream, { focusMode: 'continuous' });
|
|
323
|
+
return { tapFocus: caps?.focusMode?.includes('single-shot') === true };
|
|
324
|
+
}
|
|
325
|
+
/** Enfoca en un punto del cuadro; x e y van de 0 a 1 sobre la imagen real del lente. */
|
|
326
|
+
function focusAt(stream, x, y) {
|
|
327
|
+
return applyAdvanced(stream, { focusMode: 'single-shot', pointsOfInterest: [{ x, y }] });
|
|
328
|
+
}
|
|
329
|
+
/** Vuelve al enfoque automático continuo después de enfocar al tocar. */
|
|
330
|
+
function resumeAutofocus(stream) {
|
|
331
|
+
return applyAdvanced(stream, { focusMode: 'continuous' });
|
|
332
|
+
}
|
|
333
|
+
async function applyAdvanced(stream, constraint) {
|
|
334
|
+
const track = stream?.getVideoTracks()[0];
|
|
335
|
+
if (!track)
|
|
336
|
+
return false;
|
|
337
|
+
try {
|
|
338
|
+
await track.applyConstraints({ advanced: [constraint] });
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function stopStream(stream) {
|
|
346
|
+
stream?.getTracks().forEach(track => track.stop());
|
|
347
|
+
}
|
|
348
|
+
function getStream(video) {
|
|
349
|
+
return navigator.mediaDevices.getUserMedia({ video, audio: false });
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Lee las capacidades del lente esperando a que Android las reporte. ImageCapture obliga a Chrome
|
|
353
|
+
* a cargarlas; luego se reintenta unas veces hasta que aparezcan los modos de enfoque.
|
|
354
|
+
*/
|
|
355
|
+
async function readCapabilities(stream) {
|
|
356
|
+
const track = stream?.getVideoTracks()[0];
|
|
357
|
+
if (!track?.getCapabilities)
|
|
358
|
+
return undefined;
|
|
359
|
+
const ImageCaptureCtor = globalThis.ImageCapture;
|
|
360
|
+
if (ImageCaptureCtor) {
|
|
361
|
+
try {
|
|
362
|
+
await new ImageCaptureCtor(track).getPhotoCapabilities();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// Sin soporte: se sigue con los reintentos.
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
let caps = track.getCapabilities();
|
|
369
|
+
for (let i = 1; i < CAPABILITY_ATTEMPTS && !caps.focusMode?.length; i++) {
|
|
370
|
+
await new Promise(resolve => setTimeout(resolve, CAPABILITY_RETRY_MS));
|
|
371
|
+
if (track.readyState !== 'live')
|
|
372
|
+
return undefined;
|
|
373
|
+
caps = track.getCapabilities();
|
|
374
|
+
}
|
|
375
|
+
return caps;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Android numera las cámaras ("camera 0, facing back", "camera2 2, facing back"...) y la principal,
|
|
379
|
+
* la que tiene flash y enfoque automático, es la trasera con el número más bajo.
|
|
380
|
+
*/
|
|
381
|
+
function lowestIndexed(cams) {
|
|
382
|
+
const indexed = cams
|
|
383
|
+
.map(d => ({ d, n: Number(CAMERA_INDEX.exec(d.label)?.[1]) }))
|
|
384
|
+
.filter(c => Number.isFinite(c.n));
|
|
385
|
+
indexed.sort((a, b) => a.n - b.n);
|
|
386
|
+
return indexed[0]?.d;
|
|
387
|
+
}
|
|
388
|
+
async function preferredRearCamera() {
|
|
389
|
+
try {
|
|
390
|
+
const cams = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput' && d.label);
|
|
391
|
+
const rear = cams.filter(d => REAR_LABEL.test(d.label));
|
|
392
|
+
const chosen = rear.find(d => MAIN_REAR_LABEL.test(d.label)) ?? lowestIndexed(rear) ?? rear.find(d => /back/i.test(d.label)) ?? rear[0];
|
|
393
|
+
return chosen?.deviceId || null;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
280
400
|
const JPEG_QUALITY = 0.75;
|
|
281
401
|
/** Cuenta regresiva antes de tomar la foto: 3, 2, 1. */
|
|
282
402
|
const COUNTDOWN_FROM = 3;
|
|
@@ -294,6 +414,9 @@ const TIPS = [
|
|
|
294
414
|
const EMPTY_POSTER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
295
415
|
/** Tiempo que se muestra "Verificación completada" antes de cerrar. */
|
|
296
416
|
const SUCCESS_HOLD_MS = 1200;
|
|
417
|
+
/** Tiempo que se muestra el círculo de enfoque y tras el cual vuelve el enfoque continuo. */
|
|
418
|
+
const FOCUS_RING_MS = 800;
|
|
419
|
+
const FOCUS_HOLD_MS = 3000;
|
|
297
420
|
const CAMERA_ERROR_MESSAGE = 'No pudimos acceder a la cámara. Revisa los permisos e intenta de nuevo.';
|
|
298
421
|
/**
|
|
299
422
|
* Foto Selfie, diseño A: cámara al 100% de la pantalla y botón rectangular "Tomar Fotografía".
|
|
@@ -310,6 +433,12 @@ class SelfieComponent {
|
|
|
310
433
|
this.emptyPoster = EMPTY_POSTER;
|
|
311
434
|
/** La cámara ya está transmitiendo imagen. */
|
|
312
435
|
this.cameraReady = signal(false);
|
|
436
|
+
/** Por defecto la trasera; la frontal queda disponible con el botón de cambiar cámara. */
|
|
437
|
+
this.facing = signal('environment');
|
|
438
|
+
this.canSwitchCamera = signal(false);
|
|
439
|
+
this.tapFocus = false;
|
|
440
|
+
/** Punto donde se tocó para enfocar (px dentro de la pantalla); el id reinicia la animación. */
|
|
441
|
+
this.focusPoint = signal(null);
|
|
313
442
|
this.tips = TIPS;
|
|
314
443
|
this.tipIndex = signal(0);
|
|
315
444
|
this.tipLeaving = signal(false);
|
|
@@ -323,6 +452,8 @@ class SelfieComponent {
|
|
|
323
452
|
this.tipTimer = null;
|
|
324
453
|
this.tipLeaveTimer = null;
|
|
325
454
|
this.successTimer = null;
|
|
455
|
+
this.focusRingTimer = null;
|
|
456
|
+
this.focusHoldTimer = null;
|
|
326
457
|
this.destroyed = false;
|
|
327
458
|
}
|
|
328
459
|
async ngAfterViewInit() {
|
|
@@ -334,6 +465,7 @@ class SelfieComponent {
|
|
|
334
465
|
this.stopTips();
|
|
335
466
|
if (this.successTimer)
|
|
336
467
|
clearTimeout(this.successTimer);
|
|
468
|
+
this.clearFocusTimers();
|
|
337
469
|
this.stopCountdown();
|
|
338
470
|
this.stopCamera();
|
|
339
471
|
}
|
|
@@ -352,6 +484,29 @@ class SelfieComponent {
|
|
|
352
484
|
void this.captureAndSend();
|
|
353
485
|
}, 1000);
|
|
354
486
|
}
|
|
487
|
+
switchCamera() {
|
|
488
|
+
if (this.sending() || this.success() || this.countdown() !== null || !this.cameraReady())
|
|
489
|
+
return;
|
|
490
|
+
this.facing.update(f => (f === 'environment' ? 'user' : 'environment'));
|
|
491
|
+
this.stopCamera();
|
|
492
|
+
void this.startCamera();
|
|
493
|
+
}
|
|
494
|
+
/** Enfocar al tocar: se ignoran los toques sobre botones y textos. */
|
|
495
|
+
async onTap(event) {
|
|
496
|
+
if (!this.tapFocus || !this.cameraReady() || this.sending() || this.success())
|
|
497
|
+
return;
|
|
498
|
+
if (event.target.closest('button, .mv-hint'))
|
|
499
|
+
return;
|
|
500
|
+
const video = this.videoRef.nativeElement;
|
|
501
|
+
const rect = video.getBoundingClientRect();
|
|
502
|
+
const point = this.toVideoPoint(video, rect, event.clientX, event.clientY);
|
|
503
|
+
if (!point || !(await focusAt(this.stream, point.x, point.y)))
|
|
504
|
+
return;
|
|
505
|
+
this.clearFocusTimers();
|
|
506
|
+
this.focusPoint.set({ x: event.clientX - rect.left, y: event.clientY - rect.top, id: Date.now() });
|
|
507
|
+
this.focusRingTimer = setTimeout(() => this.focusPoint.set(null), FOCUS_RING_MS);
|
|
508
|
+
this.focusHoldTimer = setTimeout(() => void resumeAutofocus(this.stream), FOCUS_HOLD_MS);
|
|
509
|
+
}
|
|
355
510
|
close() {
|
|
356
511
|
if (this.sending() || this.success())
|
|
357
512
|
return;
|
|
@@ -371,11 +526,9 @@ class SelfieComponent {
|
|
|
371
526
|
void this.videoRef.nativeElement.play().catch(() => undefined);
|
|
372
527
|
}
|
|
373
528
|
async startCamera() {
|
|
529
|
+
this.cameraReady.set(false);
|
|
374
530
|
try {
|
|
375
|
-
this.stream = await
|
|
376
|
-
video: { width: { ideal: 1920 }, height: { ideal: 1080 }, facingMode: 'user' },
|
|
377
|
-
audio: false,
|
|
378
|
-
});
|
|
531
|
+
this.stream = await openCamera(this.facing());
|
|
379
532
|
}
|
|
380
533
|
catch {
|
|
381
534
|
this.dialog.set({ message: CAMERA_ERROR_MESSAGE, fatal: true });
|
|
@@ -392,8 +545,43 @@ class SelfieComponent {
|
|
|
392
545
|
}
|
|
393
546
|
const video = this.videoRef.nativeElement;
|
|
394
547
|
video.srcObject = this.stream;
|
|
395
|
-
|
|
548
|
+
// El brillo al máximo solo sirve con la frontal: la pantalla ilumina el rostro.
|
|
549
|
+
if (this.facing() === 'user')
|
|
550
|
+
void this.brightness.raise();
|
|
551
|
+
this.canSwitchCamera.set(await hasMultipleCameras());
|
|
396
552
|
await video.play().catch(() => undefined);
|
|
553
|
+
// Las capacidades se leen con el video ya transmitiendo: algunos Android no reportan la linterna antes.
|
|
554
|
+
const stream = this.stream;
|
|
555
|
+
const controls = await getCameraControls(stream);
|
|
556
|
+
// Si mientras tanto se cambió o cerró la cámara, estos controles ya no aplican.
|
|
557
|
+
if (this.destroyed || stream !== this.stream)
|
|
558
|
+
return;
|
|
559
|
+
this.tapFocus = controls.tapFocus;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Convierte el toque en pantalla a coordenadas 0-1 de la imagen real del lente,
|
|
563
|
+
* considerando el recorte de object-fit: cover y el espejo de la frontal.
|
|
564
|
+
*/
|
|
565
|
+
toVideoPoint(video, rect, clientX, clientY) {
|
|
566
|
+
if (!video.videoWidth || !video.videoHeight)
|
|
567
|
+
return null;
|
|
568
|
+
const scale = Math.max(rect.width / video.videoWidth, rect.height / video.videoHeight);
|
|
569
|
+
const shownW = video.videoWidth * scale;
|
|
570
|
+
const shownH = video.videoHeight * scale;
|
|
571
|
+
let x = (clientX - rect.left - (rect.width - shownW) / 2) / shownW;
|
|
572
|
+
const y = (clientY - rect.top - (rect.height - shownH) / 2) / shownH;
|
|
573
|
+
if (this.facing() === 'user')
|
|
574
|
+
x = 1 - x;
|
|
575
|
+
const clamp = (v) => Math.min(1, Math.max(0, v));
|
|
576
|
+
return { x: clamp(x), y: clamp(y) };
|
|
577
|
+
}
|
|
578
|
+
clearFocusTimers() {
|
|
579
|
+
if (this.focusRingTimer)
|
|
580
|
+
clearTimeout(this.focusRingTimer);
|
|
581
|
+
if (this.focusHoldTimer)
|
|
582
|
+
clearTimeout(this.focusHoldTimer);
|
|
583
|
+
this.focusRingTimer = null;
|
|
584
|
+
this.focusHoldTimer = null;
|
|
397
585
|
}
|
|
398
586
|
async captureAndSend() {
|
|
399
587
|
const video = this.videoRef.nativeElement;
|
|
@@ -476,16 +664,19 @@ class SelfieComponent {
|
|
|
476
664
|
this.countdown.set(null);
|
|
477
665
|
}
|
|
478
666
|
stopCamera() {
|
|
479
|
-
this.stream
|
|
667
|
+
stopStream(this.stream);
|
|
480
668
|
this.stream = null;
|
|
669
|
+
this.tapFocus = false;
|
|
670
|
+
this.clearFocusTimers();
|
|
671
|
+
this.focusPoint.set(null);
|
|
481
672
|
void this.brightness.restore();
|
|
482
673
|
}
|
|
483
674
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SelfieComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
484
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SelfieComponent, isStandalone: true, selector: "mv-selfie", inputs: { submit: "submit" }, outputs: { completed: "completed", closed: "closed", blocked: "blocked", failed: "failed" }, viewQueries: [{ propertyName: "videoRef", first: true, predicate: ["video"], descendants: true, static: true }], ngImport: i0, template: "<div class=\"mv-camera\">\n <!-- poster transparente: evita el \u00EDcono gris de \"play\" que Android WebView muestra mientras arranca la c\u00E1mara -->\n <video #video class=\"mv-video\" [class.mv-video--ready]=\"cameraReady()\" [poster]=\"emptyPoster\"\n muted autoplay playsinline (playing)=\"cameraReady.set(true)\"></video>\n <div class=\"mv-scrim\" aria-hidden=\"true\"></div>\n\n <div class=\"mv-ui\">\n <header class=\"mv-head\">\n <div class=\"mv-title-group\">\n <img class=\"mv-title-logo\" [src]=\"gLogo\" alt=\"\" aria-hidden=\"true\" />\n <h2 class=\"mv-title\">Foto Selfie</h2>\n </div>\n <button type=\"button\" class=\"mv-close\" aria-label=\"Cerrar\" [disabled]=\"sending() || success()\" (click)=\"close()\">\n
|
|
675
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SelfieComponent, isStandalone: true, selector: "mv-selfie", inputs: { submit: "submit" }, outputs: { completed: "completed", closed: "closed", blocked: "blocked", failed: "failed" }, viewQueries: [{ propertyName: "videoRef", first: true, predicate: ["video"], descendants: true, static: true }], ngImport: i0, template: "<div class=\"mv-camera\">\n <!-- poster transparente: evita el \u00EDcono gris de \"play\" que Android WebView muestra mientras arranca la c\u00E1mara -->\n <video #video class=\"mv-video\" [class.mv-video--ready]=\"cameraReady()\" [class.mv-video--mirror]=\"facing() === 'user'\" [poster]=\"emptyPoster\"\n muted autoplay playsinline (playing)=\"cameraReady.set(true)\"></video>\n <div class=\"mv-scrim\" aria-hidden=\"true\"></div>\n\n @if (focusPoint(); as p) {\n @for (id of [p.id]; track id) {\n <span class=\"mv-focus-ring\" [style.left.px]=\"p.x\" [style.top.px]=\"p.y\" aria-hidden=\"true\"></span>\n }\n }\n\n <!-- Tocar la imagen enfoca en ese punto (si el lente lo soporta) -->\n <div class=\"mv-ui\" (click)=\"onTap($event)\">\n <header class=\"mv-head\">\n <div class=\"mv-title-group\">\n <img class=\"mv-title-logo\" [src]=\"gLogo\" alt=\"\" aria-hidden=\"true\" />\n <h2 class=\"mv-title\">Foto Selfie</h2>\n </div>\n <!-- La X arriba y debajo los controles en columna, como en las apps de c\u00E1mara -->\n <div class=\"mv-actions\">\n <button type=\"button\" class=\"mv-close\" aria-label=\"Cerrar\" [disabled]=\"sending() || success()\" (click)=\"close()\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M6 6l12 12M18 6L6 18\" /></svg>\n </button>\n @if (!sending() && !success()) {\n <div class=\"mv-tools\">\n @if (canSwitchCamera()) {\n <button type=\"button\" class=\"mv-close mv-tool\" aria-label=\"Cambiar c\u00E1mara\"\n [disabled]=\"!cameraReady() || countdown() !== null\" (click)=\"switchCamera()\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5 M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5 M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6 M18 22l-3-3l3-3 M6 2l3 3l-3 3\" /></svg>\n </button>\n }\n </div>\n }\n </div>\n </header>\n\n <div class=\"mv-spacer\"></div>\n\n <footer class=\"mv-foot\">\n @if (countdown() !== null) {\n <!-- @for recrea el elemento en cada n\u00FAmero para repetir la animaci\u00F3n -->\n @for (n of [countdown()]; track n) {\n <p class=\"mv-hint mv-countdown\" aria-live=\"assertive\">{{ n }}</p>\n }\n } @else if (success()) {\n <p class=\"mv-hint mv-success-text\" role=\"status\" aria-live=\"polite\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></svg>\n Verificaci\u00F3n completada\n </p>\n } @else {\n @if (sending()) {\n <p class=\"mv-hint\" aria-live=\"polite\">Validando foto...</p>\n } @else {\n <!-- Tips rotativos: el recuadro se queda fijo y solo cambia el contenido -->\n <div class=\"mv-hint mv-tips\">\n <span class=\"mv-sr-only\">{{ tips[0].text }}</span>\n @for (i of [tipIndex()]; track i) {\n <span class=\"mv-tip\" [class.mv-tip--leaving]=\"tipLeaving()\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\"><path [attr.d]=\"tips[i].icon\" /></svg>\n {{ tips[i].text }}\n </span>\n }\n </div>\n }\n }\n @if (sending()) {\n <span class=\"mv-sending-bar\" aria-hidden=\"true\"></span>\n } @else if (success()) {\n <span class=\"mv-success-bar\" aria-hidden=\"true\"></span>\n }\n <!-- Oculto mientras se env\u00EDa y al confirmar el \u00E9xito; vuelve a aparecer si hay error para reintentar -->\n @if (!sending() && !success()) {\n <button type=\"button\" class=\"mv-button\" [disabled]=\"!cameraReady() || countdown() !== null\" (click)=\"takePhoto()\">\n Tomar Fotograf\u00EDa\n </button>\n }\n </footer>\n </div>\n\n @if (dialog(); as d) {\n <mv-dialog [message]=\"d.message\" (closed)=\"onDialogClosed()\"></mv-dialog>\n }\n</div>\n", styles: [":host{display:block;height:100%}.mv-camera{position:relative;height:100%;overflow:hidden;background:#000}.mv-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;opacity:0;transition:opacity .3s ease}.mv-video--mirror{transform:scaleX(-1)}.mv-video--ready{opacity:1}.mv-scrim{position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#00000080,#0000 22% 62%,#0009)}.mv-focus-ring{position:absolute;width:64px;height:64px;margin:-32px 0 0 -32px;border-radius:50%;box-shadow:inset 0 0 0 1.5px #fff;pointer-events:none;animation:mv-focus .8s ease-out both}@keyframes mv-focus{0%{opacity:0;transform:scale(1.4)}25%{opacity:1;transform:scale(1)}75%{opacity:1}to{opacity:0;transform:scale(1)}}@media (prefers-reduced-motion: reduce){.mv-focus-ring{animation:none}}.mv-ui{position:absolute;inset:0;display:flex;flex-direction:column;padding:max(22px,env(safe-area-inset-top)) 22px max(22px,env(safe-area-inset-bottom));color:#fff}.mv-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.mv-title-group{display:flex;align-items:center;gap:10px;min-width:0}.mv-title-logo{flex-shrink:0;width:36px;height:36px}.mv-title{margin:0;font-family:var(--mv-font);font-size:20px;font-weight:600;letter-spacing:-.025em;line-height:1.2;font-size:26px;line-height:36px;color:#fff}.mv-close{flex-shrink:0;width:36px;height:36px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:#ffffff2e;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer}.mv-close svg{width:24px;height:24px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round}.mv-close:disabled{opacity:.4;cursor:default}.mv-close:focus-visible{outline:2px solid #ffffff;outline-offset:2px}.mv-actions{display:flex;flex-direction:column;align-items:center;gap:16px}.mv-tools{display:flex;flex-direction:column;gap:12px}.mv-tool svg{width:20px;height:20px;stroke-width:2;stroke-linejoin:round}.mv-spacer{flex:1}.mv-foot{display:flex;flex-direction:column;gap:16px}.mv-hint{margin:0 0 16px;font-family:var(--mv-font);font-size:14px;font-weight:400;line-height:1.5;align-self:center;max-width:100%;padding:10px 18px;border-radius:14px;background:#09090b80;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);font-size:18px;font-weight:500;color:#fff;text-align:center}.mv-tips{width:min(100%,300px);overflow:hidden}.mv-tip{display:inline-flex;align-items:center;justify-content:center;gap:8px;animation:mv-tip-in .3s ease-out backwards;transition:opacity .25s ease,transform .25s ease}.mv-tip svg{width:20px;height:20px;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.mv-tip--leaving{opacity:0;transform:translateY(-6px)}@keyframes mv-tip-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@media (prefers-reduced-motion: reduce){.mv-tip{animation:none;transition:none}}.mv-sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.mv-countdown{min-width:64px;font-size:28px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums;color:#fff;animation:mv-count-pop .35s ease-out both}@keyframes mv-count-pop{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@media (prefers-reduced-motion: reduce){.mv-countdown{animation:none}}.mv-button{height:52px;border:0;border-radius:14px;font-family:var(--mv-font);font-size:15px;font-weight:600;letter-spacing:-.01em;width:100%;background:var(--mv-primary);color:#fff;cursor:pointer;transition:background-color .15s ease,transform .12s ease,opacity .15s ease;font-size:18px}.mv-button:hover{background:var(--mv-primary-hover)}.mv-button:active{transform:scale(.985)}.mv-button:disabled{opacity:.5;cursor:default;transform:none}.mv-button:focus-visible{outline:2px solid var(--mv-primary);outline-offset:3px}.mv-sending-bar{position:relative;height:8px;border-radius:999px;background:#ffffff4d;overflow:hidden}.mv-sending-bar:after{content:\"\";position:absolute;top:0;bottom:0;width:40%;border-radius:999px;background:var(--mv-accent);animation:mv-track-slide 1.1s cubic-bezier(.6,.1,.4,.9) infinite}@keyframes mv-track-slide{0%{left:-40%}to{left:100%}}@media (prefers-reduced-motion: reduce){.mv-sending-bar:after{animation-duration:2.4s}}.mv-success-bar{height:8px;border-radius:999px;background:#ffffff4d;overflow:hidden}.mv-success-bar:after{content:\"\";display:block;height:100%;border-radius:999px;background:var(--mv-success);animation:mv-success-fill .35s ease-out both}@keyframes mv-success-fill{0%{width:40%}to{width:100%}}.mv-success-text{display:inline-flex;align-items:center;justify-content:center;gap:8px;color:#fff;animation:mv-count-pop .3s ease-out both}.mv-success-text svg{width:22px;height:22px;flex-shrink:0;stroke:var(--mv-success);fill:none;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}@media (prefers-reduced-motion: reduce){.mv-success-bar:after,.mv-success-text{animation:none}}@media (min-width: 768px){.mv-ui{padding:28px 32px}.mv-foot{width:100%;max-width:380px;margin:0 auto}}\n"], dependencies: [{ kind: "component", type: DialogComponent, selector: "mv-dialog", inputs: ["title", "message", "buttonLabel"], outputs: ["closed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
485
676
|
}
|
|
486
677
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SelfieComponent, decorators: [{
|
|
487
678
|
type: Component,
|
|
488
|
-
args: [{ selector: 'mv-selfie', standalone: true, imports: [DialogComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"mv-camera\">\n <!-- poster transparente: evita el \u00EDcono gris de \"play\" que Android WebView muestra mientras arranca la c\u00E1mara -->\n <video #video class=\"mv-video\" [class.mv-video--ready]=\"cameraReady()\" [poster]=\"emptyPoster\"\n muted autoplay playsinline (playing)=\"cameraReady.set(true)\"></video>\n <div class=\"mv-scrim\" aria-hidden=\"true\"></div>\n\n <div class=\"mv-ui\">\n <header class=\"mv-head\">\n <div class=\"mv-title-group\">\n <img class=\"mv-title-logo\" [src]=\"gLogo\" alt=\"\" aria-hidden=\"true\" />\n <h2 class=\"mv-title\">Foto Selfie</h2>\n </div>\n <button type=\"button\" class=\"mv-close\" aria-label=\"Cerrar\" [disabled]=\"sending() || success()\" (click)=\"close()\">\n
|
|
679
|
+
args: [{ selector: 'mv-selfie', standalone: true, imports: [DialogComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"mv-camera\">\n <!-- poster transparente: evita el \u00EDcono gris de \"play\" que Android WebView muestra mientras arranca la c\u00E1mara -->\n <video #video class=\"mv-video\" [class.mv-video--ready]=\"cameraReady()\" [class.mv-video--mirror]=\"facing() === 'user'\" [poster]=\"emptyPoster\"\n muted autoplay playsinline (playing)=\"cameraReady.set(true)\"></video>\n <div class=\"mv-scrim\" aria-hidden=\"true\"></div>\n\n @if (focusPoint(); as p) {\n @for (id of [p.id]; track id) {\n <span class=\"mv-focus-ring\" [style.left.px]=\"p.x\" [style.top.px]=\"p.y\" aria-hidden=\"true\"></span>\n }\n }\n\n <!-- Tocar la imagen enfoca en ese punto (si el lente lo soporta) -->\n <div class=\"mv-ui\" (click)=\"onTap($event)\">\n <header class=\"mv-head\">\n <div class=\"mv-title-group\">\n <img class=\"mv-title-logo\" [src]=\"gLogo\" alt=\"\" aria-hidden=\"true\" />\n <h2 class=\"mv-title\">Foto Selfie</h2>\n </div>\n <!-- La X arriba y debajo los controles en columna, como en las apps de c\u00E1mara -->\n <div class=\"mv-actions\">\n <button type=\"button\" class=\"mv-close\" aria-label=\"Cerrar\" [disabled]=\"sending() || success()\" (click)=\"close()\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M6 6l12 12M18 6L6 18\" /></svg>\n </button>\n @if (!sending() && !success()) {\n <div class=\"mv-tools\">\n @if (canSwitchCamera()) {\n <button type=\"button\" class=\"mv-close mv-tool\" aria-label=\"Cambiar c\u00E1mara\"\n [disabled]=\"!cameraReady() || countdown() !== null\" (click)=\"switchCamera()\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5 M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5 M12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6 M18 22l-3-3l3-3 M6 2l3 3l-3 3\" /></svg>\n </button>\n }\n </div>\n }\n </div>\n </header>\n\n <div class=\"mv-spacer\"></div>\n\n <footer class=\"mv-foot\">\n @if (countdown() !== null) {\n <!-- @for recrea el elemento en cada n\u00FAmero para repetir la animaci\u00F3n -->\n @for (n of [countdown()]; track n) {\n <p class=\"mv-hint mv-countdown\" aria-live=\"assertive\">{{ n }}</p>\n }\n } @else if (success()) {\n <p class=\"mv-hint mv-success-text\" role=\"status\" aria-live=\"polite\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></svg>\n Verificaci\u00F3n completada\n </p>\n } @else {\n @if (sending()) {\n <p class=\"mv-hint\" aria-live=\"polite\">Validando foto...</p>\n } @else {\n <!-- Tips rotativos: el recuadro se queda fijo y solo cambia el contenido -->\n <div class=\"mv-hint mv-tips\">\n <span class=\"mv-sr-only\">{{ tips[0].text }}</span>\n @for (i of [tipIndex()]; track i) {\n <span class=\"mv-tip\" [class.mv-tip--leaving]=\"tipLeaving()\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\"><path [attr.d]=\"tips[i].icon\" /></svg>\n {{ tips[i].text }}\n </span>\n }\n </div>\n }\n }\n @if (sending()) {\n <span class=\"mv-sending-bar\" aria-hidden=\"true\"></span>\n } @else if (success()) {\n <span class=\"mv-success-bar\" aria-hidden=\"true\"></span>\n }\n <!-- Oculto mientras se env\u00EDa y al confirmar el \u00E9xito; vuelve a aparecer si hay error para reintentar -->\n @if (!sending() && !success()) {\n <button type=\"button\" class=\"mv-button\" [disabled]=\"!cameraReady() || countdown() !== null\" (click)=\"takePhoto()\">\n Tomar Fotograf\u00EDa\n </button>\n }\n </footer>\n </div>\n\n @if (dialog(); as d) {\n <mv-dialog [message]=\"d.message\" (closed)=\"onDialogClosed()\"></mv-dialog>\n }\n</div>\n", styles: [":host{display:block;height:100%}.mv-camera{position:relative;height:100%;overflow:hidden;background:#000}.mv-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;opacity:0;transition:opacity .3s ease}.mv-video--mirror{transform:scaleX(-1)}.mv-video--ready{opacity:1}.mv-scrim{position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#00000080,#0000 22% 62%,#0009)}.mv-focus-ring{position:absolute;width:64px;height:64px;margin:-32px 0 0 -32px;border-radius:50%;box-shadow:inset 0 0 0 1.5px #fff;pointer-events:none;animation:mv-focus .8s ease-out both}@keyframes mv-focus{0%{opacity:0;transform:scale(1.4)}25%{opacity:1;transform:scale(1)}75%{opacity:1}to{opacity:0;transform:scale(1)}}@media (prefers-reduced-motion: reduce){.mv-focus-ring{animation:none}}.mv-ui{position:absolute;inset:0;display:flex;flex-direction:column;padding:max(22px,env(safe-area-inset-top)) 22px max(22px,env(safe-area-inset-bottom));color:#fff}.mv-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.mv-title-group{display:flex;align-items:center;gap:10px;min-width:0}.mv-title-logo{flex-shrink:0;width:36px;height:36px}.mv-title{margin:0;font-family:var(--mv-font);font-size:20px;font-weight:600;letter-spacing:-.025em;line-height:1.2;font-size:26px;line-height:36px;color:#fff}.mv-close{flex-shrink:0;width:36px;height:36px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:#ffffff2e;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer}.mv-close svg{width:24px;height:24px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round}.mv-close:disabled{opacity:.4;cursor:default}.mv-close:focus-visible{outline:2px solid #ffffff;outline-offset:2px}.mv-actions{display:flex;flex-direction:column;align-items:center;gap:16px}.mv-tools{display:flex;flex-direction:column;gap:12px}.mv-tool svg{width:20px;height:20px;stroke-width:2;stroke-linejoin:round}.mv-spacer{flex:1}.mv-foot{display:flex;flex-direction:column;gap:16px}.mv-hint{margin:0 0 16px;font-family:var(--mv-font);font-size:14px;font-weight:400;line-height:1.5;align-self:center;max-width:100%;padding:10px 18px;border-radius:14px;background:#09090b80;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);font-size:18px;font-weight:500;color:#fff;text-align:center}.mv-tips{width:min(100%,300px);overflow:hidden}.mv-tip{display:inline-flex;align-items:center;justify-content:center;gap:8px;animation:mv-tip-in .3s ease-out backwards;transition:opacity .25s ease,transform .25s ease}.mv-tip svg{width:20px;height:20px;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.mv-tip--leaving{opacity:0;transform:translateY(-6px)}@keyframes mv-tip-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@media (prefers-reduced-motion: reduce){.mv-tip{animation:none;transition:none}}.mv-sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.mv-countdown{min-width:64px;font-size:28px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums;color:#fff;animation:mv-count-pop .35s ease-out both}@keyframes mv-count-pop{0%{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@media (prefers-reduced-motion: reduce){.mv-countdown{animation:none}}.mv-button{height:52px;border:0;border-radius:14px;font-family:var(--mv-font);font-size:15px;font-weight:600;letter-spacing:-.01em;width:100%;background:var(--mv-primary);color:#fff;cursor:pointer;transition:background-color .15s ease,transform .12s ease,opacity .15s ease;font-size:18px}.mv-button:hover{background:var(--mv-primary-hover)}.mv-button:active{transform:scale(.985)}.mv-button:disabled{opacity:.5;cursor:default;transform:none}.mv-button:focus-visible{outline:2px solid var(--mv-primary);outline-offset:3px}.mv-sending-bar{position:relative;height:8px;border-radius:999px;background:#ffffff4d;overflow:hidden}.mv-sending-bar:after{content:\"\";position:absolute;top:0;bottom:0;width:40%;border-radius:999px;background:var(--mv-accent);animation:mv-track-slide 1.1s cubic-bezier(.6,.1,.4,.9) infinite}@keyframes mv-track-slide{0%{left:-40%}to{left:100%}}@media (prefers-reduced-motion: reduce){.mv-sending-bar:after{animation-duration:2.4s}}.mv-success-bar{height:8px;border-radius:999px;background:#ffffff4d;overflow:hidden}.mv-success-bar:after{content:\"\";display:block;height:100%;border-radius:999px;background:var(--mv-success);animation:mv-success-fill .35s ease-out both}@keyframes mv-success-fill{0%{width:40%}to{width:100%}}.mv-success-text{display:inline-flex;align-items:center;justify-content:center;gap:8px;color:#fff;animation:mv-count-pop .3s ease-out both}.mv-success-text svg{width:22px;height:22px;flex-shrink:0;stroke:var(--mv-success);fill:none;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}@media (prefers-reduced-motion: reduce){.mv-success-bar:after,.mv-success-text{animation:none}}@media (min-width: 768px){.mv-ui{padding:28px 32px}.mv-foot{width:100%;max-width:380px;margin:0 auto}}\n"] }]
|
|
489
680
|
}], propDecorators: { submit: [{
|
|
490
681
|
type: Input,
|
|
491
682
|
args: [{ required: true }]
|