feat(site): sync naming typewriter to last subtitle cue
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled

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.
This commit is contained in:
Tom Boullay
2026-06-03 01:56:14 +02:00
parent 62d0dcf531
commit c2f55e3a2f
+103 -48
View File
@@ -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<HTMLInputElement>(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<typeof setTimeout> | 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<HTMLInputElement>): 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
</h2>
<input
ref={inputRef}
type="text"
value={displayValue}
onChange={handleNameChange}
placeholder="Écrivez votre prénom ici"
readOnly
tabIndex={-1}
aria-labelledby="player-name-label"
aria-describedby="player-name-hint"
aria-live="polite"
autoComplete="off"
style={{
display: "flex",
@@ -122,30 +195,12 @@ export function SiteNamingScreen(): React.JSX.Element {
background: "#D9D9D9",
outline: "none",
color: "#333",
caretColor: "#333",
fontFamily: "Inter, system-ui, sans-serif",
fontSize: "clamp(16px, 2.5vw, 20px)",
textAlign: "left",
boxSizing: "border-box",
}}
/>
<span
id="player-name-hint"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Votre personnage s&apos;appelle {presetPlayerName}. Tapez{" "}
{presetPlayerName.length} caractères pour révéler son nom.
</span>
</div>
<SiteButton