Files
breakpilot-core/pitch-deck/lib/hooks/usePresenterMode.ts
Benjamin Admin bcbceba31c feat(presenter): add browser TTS (Web Speech API) + fix German umlauts
- 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>
2026-03-20 12:11:12 +01:00

374 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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.52.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,
}
}