- Integrate Web Speech API into usePresenterMode for text-to-speech - Speech-driven paragraph advancement (falls back to timer if TTS unavailable) - TTS toggle button (Volume2/VolumeX) in PresenterOverlay - Chrome keepAlive workaround for long speeches - Voice selection: prefers premium/neural voices, falls back to any matching lang - Fix all German umlauts across presenter-script, presenter-faq, i18n, route.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
'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<PresenterState>('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<NodeJS.Timeout | null>(null)
|
||
const slideIndexRef = useRef(currentSlide)
|
||
const paragraphIndexRef = useRef(0)
|
||
const stateRef = useRef<PresenterState>('idle')
|
||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null)
|
||
const voicesRef = useRef<SpeechSynthesisVoice[]>([])
|
||
|
||
// 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,
|
||
}
|
||
}
|