import { useEffect, useRef, useState } from "react"; import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react"; import type { DialogueDefinition, DialogueManifest, DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { parseSrt } from "@/utils/subtitles/parseSrt"; const DEFAULT_VOICE: DialogueVoiceId = "narrateur"; type DialoguePatch = Partial> & { timecode?: number | undefined; }; function createDialogue( index: number, manifest: DialogueManifest, voice: DialogueVoiceId, ): DialogueDefinition { return { id: `new_dialogue_${index}`, voice, audio: `/sounds/dialogue/new_dialogue_${index}.mp3`, subtitleCueIndex: getNextCueIndex(manifest, voice), }; } function getNextCueIndex( manifest: DialogueManifest, voice: DialogueVoiceId, ): number { const cueIndexes = manifest.dialogues .filter((dialogue) => dialogue.voice === voice) .map((dialogue) => dialogue.subtitleCueIndex); return Math.max(0, ...cueIndexes) + 1; } function getVoiceSpeaker( manifest: DialogueManifest, voice: DialogueVoiceId, ): DialogueSpeaker { return ( manifest.voices.find((item) => item.id === voice)?.speaker ?? "Narrateur" ); } function getFrenchSrtPath(voice: DialogueVoiceId): string { return `/sounds/dialogue/subtitles/fr/${voice}.srt`; } function createSrtCueBlock(cueIndex: number, speaker: DialogueSpeaker): string { return `${cueIndex}\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre ${cueIndex} a definir`; } function appendSrtCueIfMissing( content: string, cueIndex: number, speaker: DialogueSpeaker, ): string { const cues = parseSrt(content); if (cues.some((cue) => cue.index === cueIndex)) return content; const trimmedContent = content.trim(); const cueBlock = createSrtCueBlock(cueIndex, speaker); return trimmedContent ? `${trimmedContent}\n\n${cueBlock}\n` : `${cueBlock}\n`; } async function saveSrtFile( voice: DialogueVoiceId, content: string, ): Promise { const response = await fetch("/api/save-srt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ voice, language: "fr", content }), }); if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string; } | null; throw new Error(body?.error ?? "Sauvegarde SRT impossible"); } } async function createFrenchSrtCue( manifest: DialogueManifest, dialogue: DialogueDefinition, ): Promise { const srtPath = getFrenchSrtPath(dialogue.voice); const response = await fetch(srtPath); const content = response.ok ? await response.text() : ""; const nextContent = appendSrtCueIfMissing( content, dialogue.subtitleCueIndex, getVoiceSpeaker(manifest, dialogue.voice), ); await saveSrtFile(dialogue.voice, nextContent); } function getManifestErrors(manifest: DialogueManifest | null): string[] { if (!manifest) return ["Manifeste absent."]; const errors: string[] = []; const ids = new Set(); manifest.dialogues.forEach((dialogue, index) => { const label = dialogue.id || `Dialogue ${index + 1}`; if (!dialogue.id.trim()) errors.push(`${label}: id obligatoire.`); if (ids.has(dialogue.id)) errors.push(`${label}: id duplique.`); ids.add(dialogue.id); if (!dialogue.audio.startsWith("/sounds/dialogue/")) { errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`); } if (!Number.isInteger(dialogue.subtitleCueIndex)) { errors.push(`${label}: cue SRT invalide.`); } if ( dialogue.timecode !== undefined && (!Number.isFinite(dialogue.timecode) || dialogue.timecode < 0) ) { errors.push(`${label}: timecode invalide.`); } }); return errors; } async function saveDialogueManifest(manifest: DialogueManifest): Promise { const response = await fetch("/api/save-dialogues", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(manifest), }); if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string; } | null; throw new Error(body?.error ?? "Sauvegarde du manifeste impossible"); } } function getPatchedDialogue( dialogue: DialogueDefinition, patch: DialoguePatch, ): DialogueDefinition { const nextDialogue: DialogueDefinition = { id: patch.id ?? dialogue.id, voice: patch.voice ?? dialogue.voice, audio: patch.audio ?? dialogue.audio, subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex, }; if ("timecode" in patch) { if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode; } else if (dialogue.timecode !== undefined) { nextDialogue.timecode = dialogue.timecode; } return nextDialogue; } export function EditorDialogueManifestPanel(): React.JSX.Element { const previewAudioRef = useRef(null); const [manifest, setManifest] = useState(null); const [selectedDialogueId, setSelectedDialogueId] = useState(""); const [status, setStatus] = useState("Chargement du manifeste..."); const [isSaving, setIsSaving] = useState(false); const [isPreviewing, setIsPreviewing] = useState(false); const [isCreatingSrtCue, setIsCreatingSrtCue] = useState(false); const errors = getManifestErrors(manifest); const selectedDialogue = manifest?.dialogues.find( (dialogue) => dialogue.id === selectedDialogueId, ) ?? manifest?.dialogues[0] ?? null; const voices = manifest?.voices ?? []; async function handleLoad(): Promise { setStatus("Chargement du manifeste..."); try { const loadedManifest = await loadDialogueManifest(); setManifest(loadedManifest); setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? ""); setStatus( loadedManifest ? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.` : "Manifeste introuvable ou invalide.", ); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); setManifest(null); } } async function handleSave(): Promise { if (!manifest) return; if (errors.length > 0) { setStatus("Corrige les erreurs avant de sauvegarder."); return; } setIsSaving(true); setStatus("Sauvegarde du manifeste..."); try { await saveDialogueManifest(manifest); setStatus( "Manifeste sauvegarde dans public/sounds/dialogue/dialogues.json.", ); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); } finally { setIsSaving(false); } } async function handleAddDialogue(): Promise { if (!manifest) return; const voice = selectedDialogue?.voice ?? DEFAULT_VOICE; const dialogue = createDialogue( manifest.dialogues.length + 1, manifest, voice, ); const nextManifest = { ...manifest, dialogues: [...manifest.dialogues, dialogue], }; setManifest(nextManifest); setSelectedDialogueId(dialogue.id); setIsCreatingSrtCue(true); setStatus("Nouveau dialogue ajoute localement. Creation de la cue FR..."); try { await createFrenchSrtCue(nextManifest, dialogue); setStatus( `Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`, ); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus( `Dialogue ajoute localement, mais cue FR non creee: ${message}`, ); } finally { setIsCreatingSrtCue(false); } } function handleRemoveDialogue(dialogueId: string): void { if (!manifest) return; const nextDialogues = manifest.dialogues.filter( (dialogue) => dialogue.id !== dialogueId, ); setManifest({ ...manifest, dialogues: nextDialogues }); setSelectedDialogueId(nextDialogues[0]?.id ?? ""); setStatus("Dialogue supprime localement."); } function updateSelectedDialogue( patch: DialoguePatch, nextId = selectedDialogueId, ): void { if (!manifest || !selectedDialogue) return; setManifest({ ...manifest, dialogues: manifest.dialogues.map((dialogue) => dialogue.id === selectedDialogue.id ? getPatchedDialogue(dialogue, patch) : dialogue, ), }); setSelectedDialogueId(nextId); } async function handlePreviewDialogue(): Promise { if (!manifest || !selectedDialogue) return; if (errors.length > 0) { setStatus("Corrige les erreurs avant de lancer la preview."); return; } previewAudioRef.current?.pause(); previewAudioRef.current = null; setIsPreviewing(true); setStatus(`Preview dialogue: ${selectedDialogue.id}`); try { const audio = await playDialogueById(manifest, selectedDialogue.id); previewAudioRef.current = audio; if (!audio) { setStatus("Dialogue introuvable pour la preview."); return; } const handleFinish = (): void => { audio.removeEventListener("ended", handleFinish); audio.removeEventListener("pause", handleFinish); if (previewAudioRef.current === audio) previewAudioRef.current = null; setIsPreviewing(false); }; audio.addEventListener("ended", handleFinish); audio.addEventListener("pause", handleFinish); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); setIsPreviewing(false); } } async function handleCreateFrenchSrtCue(): Promise { if (!manifest || !selectedDialogue) return; setIsCreatingSrtCue(true); setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`); try { await createFrenchSrtCue(manifest, selectedDialogue); setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); } finally { setIsCreatingSrtCue(false); } } useEffect(() => { let mounted = true; void loadDialogueManifest() .then((loadedManifest) => { if (!mounted) return; setManifest(loadedManifest); setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? ""); setStatus( loadedManifest ? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.` : "Manifeste introuvable ou invalide.", ); }) .catch((err: unknown) => { if (!mounted) return; const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); setManifest(null); }); return () => { mounted = false; previewAudioRef.current?.pause(); previewAudioRef.current = null; }; }, []); return (

Dialogues

{manifest?.dialogues.length ?? 0} items
{manifest && ( )} {selectedDialogue && (
)}

{status}

{errors.length === 0 ? "Manifeste local valide." : `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`} {errors.length > 0 && (
    {errors.map((error) => (
  • {error}
  • ))}
)}
); }