From c2f55e3a2fea9e2ff8d510322c3ff759d635b49b Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 01:56:14 +0200 Subject: [PATCH] feat(site): sync naming typewriter to last subtitle cue Replace the audio "ended"-based trigger with an SRT-driven one: the typewriter now starts (lastCue.endTime - typewriterDuration) seconds into the dialogue so the final letter lands at the moment the narrator finishes speaking. Char delay shortened from 110ms to 70ms for a snappier reveal. Fallbacks: audio "ended" when no SRT, 8s absolute timer otherwise. --- src/components/site/SiteNamingScreen.tsx | 151 ++++++++++++++++------- 1 file changed, 103 insertions(+), 48 deletions(-) diff --git a/src/components/site/SiteNamingScreen.tsx b/src/components/site/SiteNamingScreen.tsx index 1f04212..b4e1bdf 100644 --- a/src/components/site/SiteNamingScreen.tsx +++ b/src/components/site/SiteNamingScreen.tsx @@ -1,59 +1,133 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useSiteStore } from "@/managers/stores/useSiteStore"; +import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { SiteButton } from "@/components/site/SiteButton"; import { SITE_CONFIG } from "@/data/site/siteConfig"; import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds"; -import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { + loadDialogueManifest, + loadDialogueSubtitleCues, +} from "@/utils/dialogues/loadDialogueManifest"; import { playDialogueById, stopCurrentDialogue, } from "@/utils/dialogues/playDialogue"; +const TYPEWRITER_CHAR_DELAY_MS = 70; +// Fallback in case nothing else triggers the typewriter (audio failed to +// load, no subtitles, "ended" never fires). Long enough not to fire +// before the narration on a slow load. +const AUDIO_END_FALLBACK_MS = 8000; + /** - * Screen 3: Name input - * The displayed name is forced to SITE_CONFIG.presetPlayerName — the - * field reveals one letter per keystroke until the preset name is complete. + * Screen 3: Name reveal + * The player's preset name is revealed letter-by-letter inside the input + * once the naming dialogue finishes playing. The confirm button stays + * locked until the reveal completes. No user typing — the input is + * read-only and just acts as a typewriter target. */ export function SiteNamingScreen(): React.JSX.Element { const setStep = useSiteStore((state) => state.setStep); const setPlayerName = useGameStore((state) => state.setPlayerName); - const [charIndex, setCharIndex] = useState(0); - const inputRef = useRef(null); + const [revealedChars, setRevealedChars] = useState(0); + const [typewriterStarted, setTypewriterStarted] = useState(false); const presetPlayerName = SITE_CONFIG.presetPlayerName; - const displayValue = presetPlayerName.slice(0, charIndex); - const isComplete = charIndex >= presetPlayerName.length; + const displayValue = presetPlayerName.slice(0, revealedChars); + const isComplete = revealedChars >= presetPlayerName.length; + // Play the dialogue, then trigger the typewriter so it FINISHES at the + // same moment the narration ends. We compute that moment from the SRT + // cues: the last cue's endTime is where the narrator stops speaking, + // so we start typing `typewriterDuration` before that. useEffect(() => { let cancelled = false; + let audioElement: HTMLAudioElement | null = null; + let onTimeUpdate: (() => void) | null = null; + let fallbackTimer: ReturnType | null = null; + + const start = (): void => { + if (cancelled) return; + setTypewriterStarted(true); + }; + + const typewriterDurationSec = + (TYPEWRITER_CHAR_DELAY_MS * presetPlayerName.length) / 1000; void (async () => { const manifest = await loadDialogueManifest(); - if (cancelled || !manifest) return; - await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming); + if (cancelled) return; + if (!manifest) { + start(); + return; + } + + // Resolve the dialogue + its SRT cues for the active subtitle language. + const dialogue = manifest.dialogues.find( + (item) => item.id === SITE_DIALOGUE_IDS.naming, + ); + const language = useSettingsStore.getState().subtitleLanguage; + const subtitleData = dialogue + ? await loadDialogueSubtitleCues(manifest, dialogue, language) + : null; + if (cancelled) return; + + audioElement = await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming); + if (cancelled) return; + if (!audioElement) { + start(); + return; + } + + const lastCue = subtitleData?.cues[subtitleData.cues.length - 1]; + if (lastCue) { + // Trigger so the typewriter ends at the narration's end. + const audio = audioElement; + const triggerAt = Math.max(0, lastCue.endTime - typewriterDurationSec); + onTimeUpdate = (): void => { + if (audio.currentTime >= triggerAt) { + audio.removeEventListener("timeupdate", onTimeUpdate!); + start(); + } + }; + audio.addEventListener("timeupdate", onTimeUpdate); + } else { + // No SRT data — fall back to the audio "ended" event. + audioElement.addEventListener("ended", start, { once: true }); + } + + fallbackTimer = setTimeout(start, AUDIO_END_FALLBACK_MS); })(); return () => { cancelled = true; + if (fallbackTimer !== null) clearTimeout(fallbackTimer); + if (audioElement) { + if (onTimeUpdate) { + audioElement.removeEventListener("timeupdate", onTimeUpdate); + } + audioElement.removeEventListener("ended", start); + } stopCurrentDialogue(); }; - }, []); + }, [presetPlayerName.length]); + // Reveal the preset name one character at a time once the typewriter + // has been triggered. useEffect(() => { - inputRef.current?.focus(); - }, []); - - const handleNameChange = useCallback( - (event: React.ChangeEvent): void => { - const nextLength = Math.min( - event.target.value.length, - presetPlayerName.length, - ); - setCharIndex(nextLength); - }, - [presetPlayerName.length], - ); + if (!typewriterStarted) return; + const interval = setInterval(() => { + setRevealedChars((current) => { + if (current >= presetPlayerName.length) { + clearInterval(interval); + return current; + } + return current + 1; + }); + }, TYPEWRITER_CHAR_DELAY_MS); + return () => clearInterval(interval); + }, [typewriterStarted, presetPlayerName.length]); const handleConfirm = (): void => { if (isComplete) { @@ -98,17 +172,16 @@ export function SiteNamingScreen(): React.JSX.Element { margin: 0, }} > - Quel est votre prénom ? + Je suis… - - Votre personnage s'appelle {presetPlayerName}. Tapez{" "} - {presetPlayerName.length} caractères pour révéler son nom. -