"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import { X, ChevronLeft, ChevronRight, List, Mic, MicOff, Play, Square, HelpCircle, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useLocale } from "@/lib/i18n/provider"; import { hasQuantity } from "@/lib/fractions"; import { useWakeLock } from "@/lib/hooks/use-wake-lock"; type Step = { id: string; instruction: string; timerSeconds: number | null }; type Ingredient = { rawName: string; quantity: string | null; unit: string | null }; type TimerState = { remaining: number; running: boolean; initial: number; }; const VOICE_COMMANDS: Record> = { en: { next: ["next", "continue"], prev: ["previous", "back", "go back"], timer: ["timer", "start"], stop: ["stop", "pause"], reset: ["reset"], repeat: ["repeat", "again"], }, fr: { next: ["suivant", "continuer", "prochain"], prev: ["précédent", "retour", "reculer"], timer: ["minuteur", "démarrer", "lancer"], stop: ["arrêter", "pause", "stop"], reset: ["réinitialiser", "recommencer"], repeat: ["répéter", "encore"], }, }; const SPEECH_LANG: Record = { en: "en-US", fr: "fr-FR", }; function beep(freq = 880, duration = 0.15, vol = 0.3) { try { const ctx = new AudioContext(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = freq; gain.gain.setValueAtTime(vol, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + duration); } catch {} } function formatTime(s: number) { const m = Math.floor(s / 60); const sec = s % 60; return m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`; } export function CookingMode({ recipeId, recipeTitle, steps, ingredients, }: { recipeId: string; recipeTitle: string; steps: Step[]; ingredients: Ingredient[]; }) { const router = useRouter(); const t = useTranslations("cookingMode"); const { locale } = useLocale(); const [current, setCurrent] = useState(0); const [showIngredients, setShowIngredients] = useState(false); const [voiceActive, setVoiceActive] = useState(false); const [voiceStatus, setVoiceStatus] = useState(""); const [showHelp, setShowHelp] = useState(false); // Parallel timers: keyed by step index const [timers, setTimers] = useState>(() => new Map()); const recognitionRef = useRef | null>(null); const step = steps[current]!; const isLast = current === steps.length - 1; const isFirst = current === 0; const currentTimer = timers.get(current); const otherRunningTimers = [...timers.entries()].filter(([idx, t]) => idx !== current && t.running); useWakeLock(); // Master tick: decrement all running timers once per second useEffect(() => { const id = setInterval(() => { setTimers((prev) => { let changed = false; const next = new Map(prev); for (const [stepIdx, timer] of next) { if (!timer.running) continue; changed = true; const rem = timer.remaining - 1; if (rem <= 3 && rem > 0) beep(660 + (3 - rem) * 110, 0.1); if (rem <= 0) { next.set(stepIdx, { ...timer, remaining: 0, running: false }); beep(880, 0.3); setTimeout(() => beep(1100, 0.3), 350); setTimeout(() => beep(1320, 0.5), 700); } else { next.set(stepIdx, { ...timer, remaining: rem }); } } return changed ? next : prev; }); }, 1000); return () => clearInterval(id); }, []); function startTimer(stepIdx = current) { const s = steps[stepIdx]; if (!s?.timerSeconds) return; setTimers((prev) => { const existing = prev.get(stepIdx); const remaining = existing && existing.remaining > 0 ? existing.remaining : s.timerSeconds!; if (existing?.running) return prev; const next = new Map(prev); next.set(stepIdx, { remaining, running: true, initial: s.timerSeconds! }); return next; }); } function stopTimer(stepIdx = current) { setTimers((prev) => { const t = prev.get(stepIdx); if (!t || !t.running) return prev; const next = new Map(prev); next.set(stepIdx, { ...t, running: false }); return next; }); } function resetTimer(stepIdx = current) { setTimers((prev) => { const s = steps[stepIdx]; if (!s?.timerSeconds) return prev; const next = new Map(prev); next.delete(stepIdx); return next; }); } function dismissTimer(stepIdx: number) { setTimers((prev) => { const next = new Map(prev); next.delete(stepIdx); return next; }); } const goNext = useCallback(() => { if (!isLast) setCurrent((c) => c + 1); }, [isLast]); const goPrev = useCallback(() => { if (!isFirst) setCurrent((c) => c - 1); }, [isFirst]); // Keyboard navigation useEffect(() => { function onKey(e: KeyboardEvent) { if (e.target !== document.body) return; if (e.key === "?") { setShowHelp((v) => !v); return; } if (e.key === "Escape") { if (showHelp) { setShowHelp(false); return; } router.push(`/recipes/${recipeId}`); } if (showHelp) return; if (e.key === "ArrowRight" || e.key === " ") { e.preventDefault(); goNext(); } if (e.key === "ArrowLeft") goPrev(); if (e.key === "t" || e.key === "T") startTimer(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [goNext, goPrev, recipeId, showHelp]); // Voice recognition function toggleVoice() { const SR = window.SpeechRecognition ?? window.webkitSpeechRecognition; if (!SR) { setVoiceStatus(t("voiceNotSupported")); return; } if (voiceActive) { recognitionRef.current?.stop(); recognitionRef.current = null; setVoiceActive(false); setVoiceStatus(""); return; } const rec = new SR(); rec.continuous = true; rec.interimResults = false; rec.lang = SPEECH_LANG[locale] ?? "en-US"; rec.onresult = (e: SpeechRecognitionEvent) => { const transcript = e.results[e.results.length - 1]![0]!.transcript.toLowerCase().trim(); setVoiceStatus(`"${transcript}"`); const cmds = VOICE_COMMANDS[locale] ?? VOICE_COMMANDS.en!; if (cmds.next!.some(k => transcript.includes(k))) goNext(); else if (cmds.prev!.some(k => transcript.includes(k))) goPrev(); else if (cmds.timer!.some(k => transcript.includes(k))) startTimer(); else if (cmds.stop!.some(k => transcript.includes(k))) stopTimer(); else if (cmds.reset!.some(k => transcript.includes(k))) resetTimer(); else if (cmds.repeat!.some(k => transcript.includes(k))) setVoiceStatus(t("stepRepeatPrefix", { n: current + 1 }) + step.instruction.slice(0, 60) + "…"); setTimeout(() => setVoiceStatus(""), 3000); }; rec.onerror = () => setVoiceStatus(t("voiceError")); rec.onend = () => { if (voiceActive) rec.start(); }; rec.start(); recognitionRef.current = rec; setVoiceActive(true); } return (
{/* Top bar */}

{t("cooking")}

{recipeTitle}

{/* Progress bar */}
{/* Main area */}
{/* Step content */}
{/* Step number */}
{t("stepOf", { current: current + 1, total: steps.length })}
{/* Instruction */}

{step.instruction}

{/* Timer for current step */} {step.timerSeconds && (
{formatTime(currentTimer?.remaining ?? step.timerSeconds)}
{!currentTimer?.running ? ( ) : ( )} {currentTimer && currentTimer.remaining !== step.timerSeconds && ( )}
)} {/* Voice status */} {voiceStatus && (

{voiceStatus}

)}
{/* Ingredients drawer — stacks above the step on mobile instead of squeezing it horizontally */} {showIngredients && ( )}
{/* Parallel timers dock */} {otherRunningTimers.length > 0 && (
{otherRunningTimers.map(([stepIdx, timer]) => ( ))}
)} {/* Help overlay */} {showHelp && (
setShowHelp(false)} >
e.stopPropagation()} >

{t("shortcutsTitle")}

{t("keyboardSection")}

{[ ["→ / Space", t("shortcuts.nextStep")], ["←", t("shortcuts.prevStep")], ["T", t("shortcuts.startTimer")], ["?", t("shortcuts.toggleHelp")], ["Esc", t("shortcuts.exitMode")], ].map(([key, label]) => (
{key} {label}
))}

{t("voiceSection")}

{[ [t("voiceLabels.nextCmd"), t("voiceLabels.nextLabel")], [t("voiceLabels.prevCmd"), t("voiceLabels.prevLabel")], [t("voiceLabels.timerCmd"), t("voiceLabels.timerLabel")], [t("voiceLabels.stopCmd"), t("voiceLabels.stopLabel")], [t("voiceLabels.resetCmd"), t("voiceLabels.resetLabel")], [t("voiceLabels.repeatCmd"), t("voiceLabels.repeatLabel")], ].map(([cmd, label]) => (
“{cmd}” {label}
))}
{!voiceActive && (

{t("enableMicFirst")}

)}
)} {/* Bottom navigation */}
{/* Step dots */}
{steps.map((_, i) => (
{isLast ? ( ) : ( )}
); }