'use client' import { useState, useCallback, useRef, useEffect } from 'react' import { Language, SlideId } from '../types' import { PresenterState } from '../presenter/types' import { PRESENTER_SCRIPT } from '../presenter/presenter-script' import { SLIDE_ORDER } from './useSlideNavigation' interface UsePresenterModeConfig { goToSlide: (index: number) => void currentSlide: number totalSlides: number language: Language speechRate?: number // 0.5–2.0, default 1.0 ttsEnabled?: boolean // default true } interface UsePresenterModeReturn { state: PresenterState currentParagraph: number start: () => void stop: () => void pause: () => void resume: () => void skipSlide: () => void toggle: () => void displayText: string progress: number isSpeaking: boolean ttsAvailable: boolean ttsEnabled: boolean setTtsEnabled: (enabled: boolean) => void } export function usePresenterMode({ goToSlide, currentSlide, totalSlides, language, speechRate = 1.0, ttsEnabled: initialTtsEnabled = true, }: UsePresenterModeConfig): UsePresenterModeReturn { const [state, setState] = useState('idle') const [currentParagraph, setCurrentParagraph] = useState(0) const [displayText, setDisplayText] = useState('') const [isSpeaking, setIsSpeaking] = useState(false) const [ttsEnabled, setTtsEnabled] = useState(initialTtsEnabled) const [ttsAvailable, setTtsAvailable] = useState(false) const timerRef = useRef(null) const slideIndexRef = useRef(currentSlide) const paragraphIndexRef = useRef(0) const stateRef = useRef('idle') const utteranceRef = useRef(null) const voicesRef = useRef([]) // Refs for recursive functions to avoid circular useCallback dependencies const advanceRef = useRef<() => void>(() => {}) const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => void>(() => {}) // Initialize Web Speech API voices useEffect(() => { if (typeof window === 'undefined' || !window.speechSynthesis) return setTtsAvailable(true) const loadVoices = () => { voicesRef.current = window.speechSynthesis.getVoices() } loadVoices() window.speechSynthesis.addEventListener('voiceschanged', loadVoices) return () => { window.speechSynthesis.removeEventListener('voiceschanged', loadVoices) } }, []) const getVoice = useCallback((lang: Language): SpeechSynthesisVoice | null => { const voices = voicesRef.current if (!voices.length) return null const langCode = lang === 'de' ? 'de' : 'en' // Prefer high-quality voices const premium = voices.find(v => v.lang.startsWith(langCode) && /premium|enhanced|neural|google|microsoft/i.test(v.name) ) if (premium) return premium return voices.find(v => v.lang.startsWith(langCode)) || null }, []) const cancelSpeech = useCallback(() => { if (typeof window !== 'undefined' && window.speechSynthesis) { window.speechSynthesis.cancel() } utteranceRef.current = null setIsSpeaking(false) }, []) // Keep refs in sync useEffect(() => { slideIndexRef.current = currentSlide }, [currentSlide]) useEffect(() => { stateRef.current = state }, [state]) const clearTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } }, []) const getScriptForIndex = useCallback((index: number) => { const slideId = SLIDE_ORDER[index] return PRESENTER_SCRIPT.find(s => s.slideId === slideId) }, []) const showParagraph = useCallback((slideIdx: number, paraIdx: number) => { const script = getScriptForIndex(slideIdx) if (!script || paraIdx >= script.paragraphs.length) return null const para = script.paragraphs[paraIdx] const text = language === 'de' ? para.text_de : para.text_en setDisplayText(text) setCurrentParagraph(paraIdx) paragraphIndexRef.current = paraIdx return para }, [language, getScriptForIndex]) // Update speakAndAdvance ref whenever dependencies change useEffect(() => { speakAndAdvanceRef.current = (text: string, pauseAfter: number, onDone: () => void) => { const canSpeak = ttsAvailable && ttsEnabled && typeof window !== 'undefined' if (canSpeak) { // Chrome bug: speechSynthesis can get stuck window.speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(text) const voice = getVoice(language) if (voice) utterance.voice = voice utterance.lang = language === 'de' ? 'de-DE' : 'en-US' utterance.rate = speechRate utterance.pitch = 1.0 const handleEnd = () => { setIsSpeaking(false) utteranceRef.current = null if (pauseAfter > 0) { timerRef.current = setTimeout(onDone, pauseAfter) } else { onDone() } } utterance.onstart = () => setIsSpeaking(true) utterance.onend = handleEnd utterance.onerror = (e) => { if (e.error !== 'canceled') console.warn('TTS error:', e.error) setIsSpeaking(false) utteranceRef.current = null handleEnd() } utteranceRef.current = utterance window.speechSynthesis.speak(utterance) } else { // No TTS — use word-count-based timer const wordCount = text.split(/\s+/).length const readingTime = Math.max(wordCount * 150, 2000) timerRef.current = setTimeout(onDone, readingTime + pauseAfter) } } }, [ttsAvailable, ttsEnabled, language, speechRate, getVoice]) // Update advancePresentation ref whenever dependencies change useEffect(() => { advanceRef.current = () => { if (stateRef.current !== 'presenting') return const slideIdx = slideIndexRef.current const script = getScriptForIndex(slideIdx) if (!script) { if (slideIdx < totalSlides - 1) { goToSlide(slideIdx + 1) paragraphIndexRef.current = 0 timerRef.current = setTimeout(() => advanceRef.current(), 2000) } else { cancelSpeech() setState('idle') setDisplayText('') } return } const nextPara = paragraphIndexRef.current + 1 if (nextPara < script.paragraphs.length) { const para = showParagraph(slideIdx, nextPara) if (para) { const text = language === 'de' ? para.text_de : para.text_en speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current()) } } else { // All paragraphs done — transition hint then next slide const hint = language === 'de' ? (script.transition_hint_de || '') : (script.transition_hint_en || '') const goNext = () => { if (slideIdx < totalSlides - 1) { timerRef.current = setTimeout(() => { if (stateRef.current !== 'presenting') return goToSlide(slideIdx + 1) paragraphIndexRef.current = -1 timerRef.current = setTimeout(() => { if (stateRef.current !== 'presenting') return const nextScript = getScriptForIndex(slideIdx + 1) if (nextScript && nextScript.paragraphs.length > 0) { const para = showParagraph(slideIdx + 1, 0) if (para) { const text = language === 'de' ? para.text_de : para.text_en speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current()) } } else { advanceRef.current() } }, 1500) }, 1000) } else { timerRef.current = setTimeout(() => { cancelSpeech() setState('idle') setDisplayText('') }, 3000) } } if (hint) { setDisplayText(hint) speakAndAdvanceRef.current(hint, 0, () => { if (stateRef.current !== 'presenting') return goNext() }) } else { goNext() } } } }, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech]) const start = useCallback(() => { clearTimer() cancelSpeech() setState('presenting') const slideIdx = slideIndexRef.current const script = getScriptForIndex(slideIdx) if (script && script.paragraphs.length > 0) { const para = showParagraph(slideIdx, 0) if (para) { const text = language === 'de' ? para.text_de : para.text_en // Small delay so state is set before speaking setTimeout(() => { speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current()) }, 100) } } else { timerRef.current = setTimeout(() => advanceRef.current(), 1000) } }, [clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph]) const stop = useCallback(() => { clearTimer() cancelSpeech() setState('idle') setDisplayText('') setCurrentParagraph(0) paragraphIndexRef.current = 0 }, [clearTimer, cancelSpeech]) const pause = useCallback(() => { clearTimer() cancelSpeech() setState('paused') }, [clearTimer, cancelSpeech]) const resume = useCallback(() => { setState('resuming') timerRef.current = setTimeout(() => { setState('presenting') advanceRef.current() }, 2000) }, []) const skipSlide = useCallback(() => { clearTimer() cancelSpeech() const nextIdx = slideIndexRef.current + 1 if (nextIdx < totalSlides) { goToSlide(nextIdx) paragraphIndexRef.current = -1 if (stateRef.current === 'presenting') { timerRef.current = setTimeout(() => { const script = getScriptForIndex(nextIdx) if (script && script.paragraphs.length > 0) { const para = showParagraph(nextIdx, 0) if (para) { const text = language === 'de' ? para.text_de : para.text_en speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current()) } } }, 1500) } } }, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph]) const toggle = useCallback(() => { if (stateRef.current === 'idle') { start() } else { stop() } }, [start, stop]) // Calculate overall progress const progress = (() => { if (state === 'idle') return 0 const totalScripts = PRESENTER_SCRIPT.length const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide]) if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100 const script = PRESENTER_SCRIPT[currentScriptIdx] const slideProgress = script.paragraphs.length > 0 ? currentParagraph / script.paragraphs.length : 0 return ((currentScriptIdx + slideProgress) / totalScripts) * 100 })() // Cleanup on unmount useEffect(() => { return () => { clearTimer() cancelSpeech() } }, [clearTimer, cancelSpeech]) // Chrome workaround: speechSynthesis pauses after ~15s without interaction useEffect(() => { if (state !== 'presenting' || !ttsEnabled || !ttsAvailable) return const keepAlive = setInterval(() => { if (typeof window !== 'undefined' && window.speechSynthesis?.speaking) { window.speechSynthesis.pause() window.speechSynthesis.resume() } }, 10000) return () => clearInterval(keepAlive) }, [state, ttsEnabled, ttsAvailable]) return { state, currentParagraph, start, stop, pause, resume, skipSlide, toggle, displayText, progress, isSpeaking, ttsAvailable, ttsEnabled, setTtsEnabled, } }