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
- 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>
255 lines
7.8 KiB
TypeScript
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,
|
|
}
|
|
}
|