Some checks are pending
CI / quality (push) Waiting to run
- Add T1 state machine (8 states, presenting ⇄ interrupted) - Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg) - Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator - Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live - Extract shared t1Questionnaire.ts for batch/live DRY - Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a) - Simplified flow: card → preparation → dialogue - FTD-44 frozen (cross-feature audio hooks, Sprint 7.5) - FTD-45/46 frozen (Gemini relance quality + transcription) - Tests: 301/301 green
237 lines
8.8 KiB
TypeScript
237 lines
8.8 KiB
TypeScript
/**
|
||
* Page /simulation/eo/t1/live/dialogue — phase de dialogue live T1 (Sprint 7b).
|
||
*
|
||
* Démarre la session WS au mount, pilote l'UI selon l'état machine T1, affiche le
|
||
* timer 3:00 et l'indicateur d'état. Spécificité T1 : l'examinateur peut
|
||
* INTERROMPRE le monologue de façon NON DÉTERMINISTE (état `interrupted`) — l'UI
|
||
* ne suppose JAMAIS qu'une relance suit. À la fin (REPORT_READY), écran terminal
|
||
* avec « Télécharger l'audio » + « Voir le rapport ».
|
||
*/
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Mic, Volume2, Download, FileText, Loader2 } from 'lucide-react'
|
||
import { Button } from '@/shared/ui/Button'
|
||
import { Card } from '@/shared/ui/Card'
|
||
import { T1SpeakingIndicator } from '../components/T1SpeakingIndicator'
|
||
import { useT1LiveSession } from '../hooks/useT1LiveSession'
|
||
|
||
const DIALOGUE_SECONDS = 180 // 3:00
|
||
|
||
function formatMmSs(totalSeconds: number): string {
|
||
const remaining = Math.max(0, totalSeconds)
|
||
const m = Math.floor(remaining / 60)
|
||
const s = remaining % 60
|
||
return `${m}:${s.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
export function T1DialoguePage() {
|
||
const navigate = useNavigate()
|
||
const [autoStarted, setAutoStarted] = useState(false)
|
||
|
||
const session = useT1LiveSession()
|
||
|
||
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
|
||
useEffect(() => {
|
||
if (autoStarted) return
|
||
setAutoStarted(true)
|
||
void session.startDialogue()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [autoStarted])
|
||
|
||
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
|
||
const stateLabel = (() => {
|
||
switch (session.state) {
|
||
case 'idle':
|
||
case 'connecting':
|
||
return 'Connexion à l’examinateur…'
|
||
case 'presenting':
|
||
return 'À vous — présentez-vous.'
|
||
case 'interrupted':
|
||
return 'L’examinateur vous interrompt — répondez-lui.'
|
||
case 'processing':
|
||
return 'Évaluation en cours…'
|
||
case 'ended':
|
||
return 'Session terminée.'
|
||
case 'error':
|
||
return 'Erreur.'
|
||
case 'preparing':
|
||
return 'Préparation…'
|
||
}
|
||
})()
|
||
|
||
function handleDownload() {
|
||
const blob = session.recording.exportWAV()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `expria-t1-${new Date().toISOString().slice(0, 10)}.wav`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function handleViewReport() {
|
||
if (!session.simulationId) return
|
||
navigate(`/rapport/${session.simulationId}`)
|
||
}
|
||
|
||
function handleRestart() {
|
||
navigate('/simulation/eo/t1/live/preparation')
|
||
}
|
||
|
||
// Abandon : ferme la session sans évaluation (cancelDialogue ne déclenche ni
|
||
// correction ni persistance), puis sortie du flux.
|
||
function handleCancel() {
|
||
session.cancelDialogue()
|
||
navigate('/simulation/eo')
|
||
}
|
||
|
||
// « Annuler » / « Terminer » n'ont de sens que pendant la session active
|
||
// (connexion, présentation ou interruption), pas en évaluation.
|
||
const canCancel =
|
||
session.state === 'connecting' ||
|
||
session.state === 'presenting' ||
|
||
session.state === 'interrupted'
|
||
|
||
// ── État terminal : rapport prêt ─────────────────────────────────────────
|
||
if (session.state === 'ended') {
|
||
return (
|
||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||
<main className="mx-auto max-w-2xl space-y-5">
|
||
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
|
||
<Card variant="default" className="space-y-3 p-5">
|
||
<p className="text-sm text-ink-primary">
|
||
Votre présentation a été évaluée. Vous pouvez télécharger l'enregistrement audio avant
|
||
de consulter le rapport.
|
||
</p>
|
||
<p className="text-xs text-ink-secondary">
|
||
Durée enregistrée :{' '}
|
||
<span className="font-semibold tabular-nums text-ink-primary">
|
||
{session.recording.durationSeconds.toFixed(1)} s
|
||
</span>
|
||
</p>
|
||
</Card>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button
|
||
variant="secondary"
|
||
icon={<Download className="size-4" aria-hidden="true" />}
|
||
onClick={handleDownload}
|
||
disabled={session.recording.durationSeconds === 0}
|
||
>
|
||
Télécharger l'audio
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
icon={<FileText className="size-4" aria-hidden="true" />}
|
||
onClick={handleViewReport}
|
||
disabled={!session.simulationId}
|
||
>
|
||
Voir le rapport
|
||
</Button>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── État erreur ──────────────────────────────────────────────────────────
|
||
if (session.state === 'error') {
|
||
return (
|
||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||
<main className="mx-auto max-w-2xl space-y-5">
|
||
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
|
||
<div
|
||
role="alert"
|
||
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
|
||
>
|
||
{session.errorMessage ?? 'Une erreur est survenue.'}
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<Button variant="secondary" onClick={handleRestart}>
|
||
Recommencer
|
||
</Button>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── État dialogue actif ──────────────────────────────────────────────────
|
||
return (
|
||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||
<main className="mx-auto max-w-2xl space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-ink-primary">Présentation en cours</h2>
|
||
<span
|
||
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
|
||
aria-label="Temps restant de présentation"
|
||
>
|
||
{formatMmSs(remaining)}
|
||
</span>
|
||
</div>
|
||
|
||
{session.warning && (
|
||
<div
|
||
role="status"
|
||
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
|
||
>
|
||
⏱ 30 secondes restantes.
|
||
</div>
|
||
)}
|
||
|
||
<Card variant="default" className="space-y-4 p-5">
|
||
<div className="flex items-center gap-3">
|
||
{session.state === 'processing' ? (
|
||
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
|
||
) : session.state === 'interrupted' ? (
|
||
<Volume2 className="size-5 text-brand-text" aria-hidden="true" />
|
||
) : (
|
||
<Mic
|
||
className={
|
||
session.state === 'presenting'
|
||
? 'size-5 text-success'
|
||
: 'size-5 text-ink-secondary'
|
||
}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
|
||
</div>
|
||
{/* Indicateur : 'presenting' = amplitude micro réelle (analyser lu par
|
||
ref en rAF) ; 'interrupted' = animation décorative (uplink coupé). */}
|
||
{canCancel && (
|
||
<T1SpeakingIndicator analyserRef={session.analyserRef} state={session.state} />
|
||
)}
|
||
<p className="text-xs text-ink-secondary">
|
||
Présentez-vous naturellement. L'examinateur peut vous interrompre à tout moment.
|
||
</p>
|
||
</Card>
|
||
|
||
<div className="flex items-center justify-center gap-3">
|
||
{canCancel && (
|
||
<Button
|
||
variant="ghost"
|
||
className="text-danger hover:bg-danger-soft hover:text-danger"
|
||
onClick={handleCancel}
|
||
title="Abandonner sans générer de rapport"
|
||
>
|
||
Annuler
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => session.endDialogue()}
|
||
disabled={session.state === 'processing'}
|
||
>
|
||
Terminer
|
||
</Button>
|
||
</div>
|
||
<p className="text-center text-xs text-ink-tertiary">
|
||
« Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
|
||
</p>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|