Files
breakpilot-core/pitch-deck/lib/hooks/usePresenterMode.ts
Benjamin Admin 3a2567b44d
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
feat(pitch-deck): add AI Presenter mode with LiteLLM migration and FAQ system
- Migrate chat API from Ollama to LiteLLM (OpenAI-compatible SSE)
- Add 15-min presenter storyline with bilingual scripts for all 20 slides
- Add FAQ system (30 entries) with keyword matching for instant answers
- Add IntroPresenterSlide with avatar placeholder and start button
- Add PresenterOverlay (progress bar, subtitle text, play/pause/stop)
- Add AvatarPlaceholder with pulse animation during speaking
- Add usePresenterMode hook (state machine: idle→presenting→paused→answering→resuming)
- Add 'P' keyboard shortcut to toggle presenter mode
- Support [GOTO:slide-id] markers in chat responses
- Dynamic slide count (was hardcoded 13, now from SLIDE_ORDER)
- TTS stub prepared for future Piper integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:45:55 +01:00

255 lines
7.8 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
}
interface UsePresenterModeReturn {
state: PresenterState
currentParagraph: number
start: () => void
stop: () => void
pause: () => void
resume: () => void
skipSlide: () => void
toggle: () => void
displayText: string
progress: number
}
export function usePresenterMode({
goToSlide,
currentSlide,
totalSlides,
language,
}: UsePresenterModeConfig): UsePresenterModeReturn {
const [state, setState] = useState<PresenterState>('idle')
const [currentParagraph, setCurrentParagraph] = useState(0)
const [displayText, setDisplayText] = useState('')
const timerRef = useRef<NodeJS.Timeout | null>(null)
const slideIndexRef = useRef(currentSlide)
const paragraphIndexRef = useRef(0)
const stateRef = useRef<PresenterState>('idle')
// 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])
const advancePresentation = useCallback(() => {
if (stateRef.current !== 'presenting') return
const slideIdx = slideIndexRef.current
const script = getScriptForIndex(slideIdx)
if (!script) {
// No script for this slide, advance to next
if (slideIdx < totalSlides - 1) {
goToSlide(slideIdx + 1)
paragraphIndexRef.current = 0
// Schedule next after slide transition
timerRef.current = setTimeout(() => advancePresentation(), 2000)
} else {
setState('idle')
setDisplayText('')
}
return
}
const nextPara = paragraphIndexRef.current + 1
if (nextPara < script.paragraphs.length) {
// Show next paragraph
const para = showParagraph(slideIdx, nextPara)
if (para) {
// Calculate display time: ~150ms per word + pause
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
}
} else {
// All paragraphs done for this slide
// Show transition hint briefly
if (script.transition_hint_de || script.transition_hint_en) {
const hint = language === 'de'
? (script.transition_hint_de || '')
: (script.transition_hint_en || '')
setDisplayText(hint)
}
// Move to next slide
if (slideIdx < totalSlides - 1) {
timerRef.current = setTimeout(() => {
if (stateRef.current !== 'presenting') return
goToSlide(slideIdx + 1)
paragraphIndexRef.current = -1 // Will be incremented to 0
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 wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
}
} else {
advancePresentation()
}
}, 1500)
}, 2000)
} else {
// Last slide — done
timerRef.current = setTimeout(() => {
setState('idle')
setDisplayText('')
}, 3000)
}
}
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph])
const start = useCallback(() => {
clearTimer()
setState('presenting')
const slideIdx = slideIndexRef.current
const script = getScriptForIndex(slideIdx)
if (script && script.paragraphs.length > 0) {
const para = showParagraph(slideIdx, 0)
if (para) {
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
}
} else {
// No script, advance immediately
timerRef.current = setTimeout(() => advancePresentation(), 1000)
}
}, [clearTimer, language, getScriptForIndex, showParagraph, advancePresentation])
const stop = useCallback(() => {
clearTimer()
setState('idle')
setDisplayText('')
setCurrentParagraph(0)
paragraphIndexRef.current = 0
}, [clearTimer])
const pause = useCallback(() => {
clearTimer()
setState('paused')
}, [clearTimer])
const resume = useCallback(() => {
setState('resuming')
// Brief pause before continuing
timerRef.current = setTimeout(() => {
setState('presenting')
// Continue from where we left off
advancePresentation()
}, 2000)
}, [advancePresentation])
const skipSlide = useCallback(() => {
clearTimer()
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 wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
}
}
}, 1500)
}
}
}, [clearTimer, totalSlides, goToSlide, language, getScriptForIndex, showParagraph, advancePresentation])
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()
}, [clearTimer])
return {
state,
currentParagraph,
start,
stop,
pause,
resume,
skipSlide,
toggle,
displayText,
progress,
}
}