'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 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 } // Client-side audio cache: text key → blob URL const audioCache = new Map() function cacheKey(text: string, lang: string): string { // Simple string hash — no crypto.subtle needed (works on HTTP too) let hash = 0 const str = text + '|' + lang for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0 } return 'tts_' + (hash >>> 0).toString(36) } export function usePresenterMode({ goToSlide, currentSlide, totalSlides, language, 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 audioRef = useRef(null) const abortRef = useRef(null) const audioUnlockedRef = useRef(false) // Refs for recursive functions to avoid circular useCallback dependencies const advanceRef = useRef<() => void>(() => {}) const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => void>(() => {}) // Unlock browser audio playback — must be called from a user gesture (click) const unlockAudio = useCallback(() => { if (audioUnlockedRef.current) return try { // Create and play a silent audio to unlock the Audio API const ctx = new (window.AudioContext || (window as any).webkitAudioContext)() const buffer = ctx.createBuffer(1, 1, 22050) const source = ctx.createBufferSource() source.buffer = buffer source.connect(ctx.destination) source.start(0) audioUnlockedRef.current = true console.log('Audio playback unlocked') } catch (e) { console.warn('Audio unlock failed:', e) } }, []) // Check TTS service availability on mount useEffect(() => { fetch('/api/presenter/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Test', language: 'de' }), signal: AbortSignal.timeout(5000), }) .then(res => { setTtsAvailable(res.ok) if (res.ok) console.log('Piper TTS available') else console.warn('Piper TTS not available:', res.status) }) .catch(() => { setTtsAvailable(false) console.warn('Piper TTS service not reachable') }) }, []) const cancelSpeech = useCallback(() => { if (audioRef.current) { audioRef.current.pause() audioRef.current.currentTime = 0 audioRef.current = null } if (abortRef.current) { abortRef.current.abort() abortRef.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) => { if (!ttsAvailable || !ttsEnabled) { // 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) return } // Piper TTS via API setIsSpeaking(true) const controller = new AbortController() abortRef.current = controller const playAudio = async () => { try { const key = cacheKey(text, language) let blobUrl = audioCache.get(key) if (!blobUrl) { console.log('[TTS] Fetching audio for:', text.slice(0, 50) + '...') const res = await fetch('/api/presenter/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, language }), signal: controller.signal, }) if (!res.ok) throw new Error(`TTS error: ${res.status}`) const blob = await res.blob() console.log('[TTS] Audio received:', blob.size, 'bytes') blobUrl = URL.createObjectURL(blob) audioCache.set(key, blobUrl) } else { console.log('[TTS] Cache hit for:', text.slice(0, 50) + '...') } if (controller.signal.aborted) return const audio = new Audio(blobUrl) audioRef.current = audio audio.onended = () => { console.log('[TTS] Audio playback ended') setIsSpeaking(false) audioRef.current = null if (pauseAfter > 0) { timerRef.current = setTimeout(onDone, pauseAfter) } else { onDone() } } audio.onerror = (e) => { console.warn('[TTS] Audio playback error:', e) setIsSpeaking(false) audioRef.current = null const wordCount = text.split(/\s+/).length const readingTime = Math.max(wordCount * 150, 2000) timerRef.current = setTimeout(onDone, readingTime + pauseAfter) } await audio.play() console.log('[TTS] Audio playing') } catch (err: any) { if (err.name === 'AbortError') return console.warn('[TTS] Error:', err.name, err.message) setIsSpeaking(false) const wordCount = text.split(/\s+/).length const readingTime = Math.max(wordCount * 150, 2000) timerRef.current = setTimeout(onDone, readingTime + pauseAfter) } } playAudio() } }, [ttsAvailable, ttsEnabled, language]) // 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(() => { // Unlock audio playback immediately in user gesture context unlockAudio() clearTimer() cancelSpeech() setState('presenting') stateRef.current = '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 speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current()) } } else { timerRef.current = setTimeout(() => advanceRef.current(), 1000) } }, [unlockAudio, 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') stateRef.current = 'resuming' timerRef.current = setTimeout(() => { setState('presenting') stateRef.current = 'presenting' // Sync ref immediately before calling advance 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(() => { unlockAudio() if (stateRef.current === 'idle') { start() } else { stop() } }, [unlockAudio, 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]) return { state, currentParagraph, start, stop, pause, resume, skipSlide, toggle, displayText, progress, isSpeaking, ttsAvailable, ttsEnabled, setTtsEnabled, } }