amanpay / web /src /components /SampleCapture.tsx
MHamdan's picture
CI deploy e9bfd26
004f460 verified
Raw
History Blame Contribute Delete
5.98 kB
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>
)
}