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>
This commit is contained in:
Benjamin Admin
2026-03-20 12:38:16 +01:00
parent ddabda6f05
commit aece5f7414

View File

@@ -60,11 +60,30 @@ export function usePresenterMode({
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', {
@@ -147,6 +166,7 @@ export function usePresenterMode({
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' },
@@ -157,8 +177,11 @@ export function usePresenterMode({
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
@@ -167,6 +190,7 @@ export function usePresenterMode({
audioRef.current = audio
audio.onended = () => {
console.log('[TTS] Audio playback ended')
setIsSpeaking(false)
audioRef.current = null
if (pauseAfter > 0) {
@@ -176,22 +200,21 @@ export function usePresenterMode({
}
}
audio.onerror = () => {
console.warn('Audio playback error')
audio.onerror = (e) => {
console.warn('[TTS] Audio playback error:', e)
setIsSpeaking(false)
audioRef.current = null
// Fallback to timer
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 fetch error:', err)
console.warn('[TTS] Error:', err.name, err.message)
setIsSpeaking(false)
// Fallback to timer
const wordCount = text.split(/\s+/).length
const readingTime = Math.max(wordCount * 150, 2000)
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
@@ -281,6 +304,9 @@ export function usePresenterMode({
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech])
const start = useCallback(() => {
// Unlock audio playback immediately in user gesture context
unlockAudio()
clearTimer()
cancelSpeech()
setState('presenting')
@@ -292,15 +318,12 @@ export function usePresenterMode({
const para = showParagraph(slideIdx, 0)
if (para) {
const text = language === 'de' ? para.text_de : para.text_en
// Small delay so state is set before speaking
setTimeout(() => {
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}, 100)
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
}
} else {
timerRef.current = setTimeout(() => advanceRef.current(), 1000)
}
}, [clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
}, [unlockAudio, clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
const stop = useCallback(() => {
clearTimer()
@@ -349,12 +372,13 @@ export function usePresenterMode({
}, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph])
const toggle = useCallback(() => {
unlockAudio()
if (stateRef.current === 'idle') {
start()
} else {
stop()
}
}, [start, stop])
}, [unlockAudio, start, stop])
// Calculate overall progress
const progress = (() => {