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:
@@ -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 = (() => {
|
||||
|
||||
Reference in New Issue
Block a user