Files
breakpilot-core/pitch-deck/lib/hooks/usePresenterMode.ts
Benjamin Admin aece5f7414 fix(presenter): unlock audio playback via AudioContext on user gesture
Browser autoplay policy blocks audio.play() outside user gesture.
Use AudioContext to unlock audio immediately in click handler.
Add console logging for TTS debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:38:16 +01:00

422 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 hash → blob URL
const audioCache = new Map<string, string>()
async function hashText(text: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(text)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash)).slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('')
}
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 = await hashText(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')
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')
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(() => {
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,
}
}