Files
breakpilot-core/pitch-deck/lib/hooks/usePresenterMode.ts
Benjamin Admin 9da9b323fc fix(presenter): fix resume after chat interruption + sync stateRef
stateRef was still 'resuming' when advanceRef.current() ran,
causing it to bail out. Now sync stateRef immediately before advance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 14:04:39 +01:00

428 lines
13 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
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<string, string>()
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<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 audioRef = useRef<HTMLAudioElement | null>(null)
const abortRef = useRef<AbortController | null>(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,
}
}