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 stateRef = useRef<PresenterState>('idle')
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const audioUnlockedRef = useRef(false)
|
||||||
|
|
||||||
// Refs for recursive functions to avoid circular useCallback dependencies
|
// Refs for recursive functions to avoid circular useCallback dependencies
|
||||||
const advanceRef = useRef<() => void>(() => {})
|
const advanceRef = useRef<() => void>(() => {})
|
||||||
const speakAndAdvanceRef = useRef<(text: string, pauseAfter: number, onDone: () => void) => 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
|
// Check TTS service availability on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/presenter/tts', {
|
fetch('/api/presenter/tts', {
|
||||||
@@ -147,6 +166,7 @@ export function usePresenterMode({
|
|||||||
let blobUrl = audioCache.get(key)
|
let blobUrl = audioCache.get(key)
|
||||||
|
|
||||||
if (!blobUrl) {
|
if (!blobUrl) {
|
||||||
|
console.log('[TTS] Fetching audio for:', text.slice(0, 50) + '...')
|
||||||
const res = await fetch('/api/presenter/tts', {
|
const res = await fetch('/api/presenter/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -157,8 +177,11 @@ export function usePresenterMode({
|
|||||||
if (!res.ok) throw new Error(`TTS error: ${res.status}`)
|
if (!res.ok) throw new Error(`TTS error: ${res.status}`)
|
||||||
|
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
|
console.log('[TTS] Audio received:', blob.size, 'bytes')
|
||||||
blobUrl = URL.createObjectURL(blob)
|
blobUrl = URL.createObjectURL(blob)
|
||||||
audioCache.set(key, blobUrl)
|
audioCache.set(key, blobUrl)
|
||||||
|
} else {
|
||||||
|
console.log('[TTS] Cache hit for:', text.slice(0, 50) + '...')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
@@ -167,6 +190,7 @@ export function usePresenterMode({
|
|||||||
audioRef.current = audio
|
audioRef.current = audio
|
||||||
|
|
||||||
audio.onended = () => {
|
audio.onended = () => {
|
||||||
|
console.log('[TTS] Audio playback ended')
|
||||||
setIsSpeaking(false)
|
setIsSpeaking(false)
|
||||||
audioRef.current = null
|
audioRef.current = null
|
||||||
if (pauseAfter > 0) {
|
if (pauseAfter > 0) {
|
||||||
@@ -176,22 +200,21 @@ export function usePresenterMode({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.onerror = () => {
|
audio.onerror = (e) => {
|
||||||
console.warn('Audio playback error')
|
console.warn('[TTS] Audio playback error:', e)
|
||||||
setIsSpeaking(false)
|
setIsSpeaking(false)
|
||||||
audioRef.current = null
|
audioRef.current = null
|
||||||
// Fallback to timer
|
|
||||||
const wordCount = text.split(/\s+/).length
|
const wordCount = text.split(/\s+/).length
|
||||||
const readingTime = Math.max(wordCount * 150, 2000)
|
const readingTime = Math.max(wordCount * 150, 2000)
|
||||||
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
||||||
}
|
}
|
||||||
|
|
||||||
await audio.play()
|
await audio.play()
|
||||||
|
console.log('[TTS] Audio playing')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'AbortError') return
|
if (err.name === 'AbortError') return
|
||||||
console.warn('TTS fetch error:', err)
|
console.warn('[TTS] Error:', err.name, err.message)
|
||||||
setIsSpeaking(false)
|
setIsSpeaking(false)
|
||||||
// Fallback to timer
|
|
||||||
const wordCount = text.split(/\s+/).length
|
const wordCount = text.split(/\s+/).length
|
||||||
const readingTime = Math.max(wordCount * 150, 2000)
|
const readingTime = Math.max(wordCount * 150, 2000)
|
||||||
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
timerRef.current = setTimeout(onDone, readingTime + pauseAfter)
|
||||||
@@ -281,6 +304,9 @@ export function usePresenterMode({
|
|||||||
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech])
|
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph, cancelSpeech])
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
|
// Unlock audio playback immediately in user gesture context
|
||||||
|
unlockAudio()
|
||||||
|
|
||||||
clearTimer()
|
clearTimer()
|
||||||
cancelSpeech()
|
cancelSpeech()
|
||||||
setState('presenting')
|
setState('presenting')
|
||||||
@@ -292,15 +318,12 @@ export function usePresenterMode({
|
|||||||
const para = showParagraph(slideIdx, 0)
|
const para = showParagraph(slideIdx, 0)
|
||||||
if (para) {
|
if (para) {
|
||||||
const text = language === 'de' ? para.text_de : para.text_en
|
const text = language === 'de' ? para.text_de : para.text_en
|
||||||
// Small delay so state is set before speaking
|
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
|
||||||
setTimeout(() => {
|
|
||||||
speakAndAdvanceRef.current(text, para.pause_after, () => advanceRef.current())
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
timerRef.current = setTimeout(() => advanceRef.current(), 1000)
|
timerRef.current = setTimeout(() => advanceRef.current(), 1000)
|
||||||
}
|
}
|
||||||
}, [clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
|
}, [unlockAudio, clearTimer, cancelSpeech, language, getScriptForIndex, showParagraph])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
clearTimer()
|
clearTimer()
|
||||||
@@ -349,12 +372,13 @@ export function usePresenterMode({
|
|||||||
}, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph])
|
}, [clearTimer, cancelSpeech, totalSlides, goToSlide, language, getScriptForIndex, showParagraph])
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
|
unlockAudio()
|
||||||
if (stateRef.current === 'idle') {
|
if (stateRef.current === 'idle') {
|
||||||
start()
|
start()
|
||||||
} else {
|
} else {
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}, [start, stop])
|
}, [unlockAudio, start, stop])
|
||||||
|
|
||||||
// Calculate overall progress
|
// Calculate overall progress
|
||||||
const progress = (() => {
|
const progress = (() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user