File size: 5,976 Bytes
004f460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import { useEffect, useRef, useState } from 'react'
import { useCamera } from '../media/useCamera'
import { useRecorder } from '../media/useRecorder'
import { fileToDataUri, validateAudioFile, validateImageFile } from '../media/validate'
import { useI18n } from '../i18n'
import { Button } from './ui'
import type { MessageKey } from '../i18n/en'

export type Modality = 'face' | 'fingerprint' | 'voice'

function toImagePreview(v: string): string {
  return v.startsWith('data:') ? v : `data:image/jpeg;base64,${v}`
}

interface Props {
  modality: Modality
  id: string
  value: string | null
  onChange: (dataUri: string | null) => void
  demoSample?: string
}

/** One modality's sample control. Face = camera + upload + demo; Fingerprint =
 *  upload + demo (optical IMAGE, never a device sensor); Voice = record + upload + demo.
 *  Media tracks are always stopped on capture / replace / unmount. */
export function SampleCapture({ modality, id, value, onChange, demoSample }: Props) {
  const { t } = useI18n()
  const cam = useCamera()
  const rec = useRecorder()
  const [error, setError] = useState<MessageKey | null>(null)
  const [camOpen, setCamOpen] = useState(false)
  const fileRef = useRef<HTMLInputElement | null>(null)

  // Push recorded audio up to the parent when it becomes available.
  useEffect(() => {
    if (modality === 'voice' && rec.dataUri) { onChange(rec.dataUri); setError(null) }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rec.dataUri])

  async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file) return
    const err = modality === 'voice' ? validateAudioFile(file) : validateImageFile(file)
    if (err) { setError(err); return }
    try { onChange(await fileToDataUri(file)); setError(null) } catch { setError('bio.err.readFailed') }
  }

  function useDemo() {
    if (demoSample) { onChange(demoSample); setError(null); setCamOpen(false); cam.stop() }
  }

  function openCamera() { setCamOpen(true); setError(null); void cam.start() }
  function doCapture() {
    const shot = cam.capture()
    if (shot) { onChange(shot); setError(null) }
    setCamOpen(false); cam.stop() // stop tracks immediately after the still is taken
  }
  function clear() { onChange(null); rec.reset(); setError(null) }

  const label = t(`bio.modality.${modality}` as MessageKey)

  return (
    <fieldset className="sample" aria-labelledby={`${id}-lbl`}>
      <legend id={`${id}-lbl`}>{label}</legend>

      {/* Existing sample preview */}
      {value && modality !== 'voice' && (
        <div className="sample-preview">
          <img src={toImagePreview(value)} alt={t('bio.preview', { m: label })} />
          <Button onClick={clear}>{t('bio.retake')}</Button>
        </div>
      )}
      {value && modality === 'voice' && (
        <div className="sample-preview">
          {rec.playbackUrl && <audio src={rec.playbackUrl} controls aria-label={t('bio.playback')} />}
          <Button onClick={clear}>{t('bio.delete')}</Button>
        </div>
      )}

      {/* Capture controls (hidden once a sample exists) */}
      {!value && (
        <div className="sample-controls">
          {modality === 'face' && !camOpen && (
            <Button onClick={openCamera}>{t('bio.useCamera')}</Button>
          )}
          {modality === 'face' && camOpen && (
            <div className="cam">
              {cam.status === 'live' && (
                <>
                  <video ref={cam.videoRef} playsInline muted aria-label={t('bio.cameraPreview')} />
                  <div className="row">
                    <Button onClick={doCapture}>{t('bio.capture')}</Button>
                    <Button onClick={() => { setCamOpen(false); cam.stop() }}>{t('bio.cancel')}</Button>
                    {cam.devices.length > 1 && (
                      <select aria-label={t('bio.switchCamera')} value={cam.activeDeviceId ?? ''}
                              onChange={(e) => cam.switchTo(e.target.value)}>
                        {cam.devices.map((d, i) => <option key={d.deviceId} value={d.deviceId}>{d.label || `Camera ${i + 1}`}</option>)}
                      </select>
                    )}
                  </div>
                </>
              )}
              {cam.status === 'requesting' && <p className="hint">{t('bio.cameraRequesting')}</p>}
              {cam.status === 'denied' && <p className="field-error" role="alert">{t('bio.cameraDenied')}</p>}
              {cam.status === 'nocamera' && <p className="field-error" role="alert">{t('bio.cameraNone')}</p>}
              {cam.status === 'inuse' && <p className="field-error" role="alert">{t('bio.cameraInUse')}</p>}
              {cam.status === 'error' && <p className="field-error" role="alert">{t('bio.cameraError')}</p>}
            </div>
          )}

          {modality === 'voice' && (
            <div className="rec">
              {rec.status !== 'recording'
                ? <Button onClick={() => void rec.start()}>{t('bio.record')}</Button>
                : <Button onClick={rec.stop}>{t('bio.stop')} ({rec.elapsed}s)</Button>}
              {rec.status === 'denied' && <p className="field-error" role="alert">{t('bio.micDenied')}</p>}
              {rec.status === 'unsupported' && <p className="field-error" role="alert">{t('bio.micUnsupported')}</p>}
            </div>
          )}

          <label className="upload-btn">
            {modality === 'voice' ? t('bio.uploadAudio') : t('bio.uploadImage')}
            <input ref={fileRef} type="file" hidden
                   accept={modality === 'voice' ? 'audio/*' : 'image/*'} onChange={onFile} />
          </label>

          {demoSample && <Button onClick={useDemo}>{t('bio.useDemo')}</Button>}
        </div>
      )}

      {modality === 'fingerprint' && (
        <p className="hint">{t('bio.fingerprintNote')}</p>
      )}
      {error && <p className="field-error" role="alert">{t(error)}</p>}
    </fieldset>
  )
}