Initial commit: Learning Lab Express
Includes fixes found while reviewing the app: - Results: open each result by a type+agent+id key, so a video and a material draft with the same id no longer open each other's popup - AuthGate: clear the query cache during render, before pages build their queries, and remount pages on organization switch (pages no longer stay on skeletons for up to a minute after sign-in or refresh) - Instruction: keep only failed rules after a partial approve, so retrying does not create the successful schedules twice; drop the monthly note once count or frequency is edited - Parser/schedules: never schedule a count below one - Audio: ignore the end event of a cancelled utterance when changing speed - Downloads: revoke the object URL later so Safari/Firefox can read it - Results: refetch SCORM content when any part's revision changes; show a failed video as failed instead of "still rendering" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
114
src/components/AudioPlayer.tsx
Normal file
114
src/components/AudioPlayer.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// Plays an audio lesson's narration script with the device's own voice (Web Speech API).
|
||||
// The engine has no audio output for agents yet, so there is no MP3: the script is the deliverable,
|
||||
// read aloud here and downloadable as text for recording or a TTS tool.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { t } from '../i18n'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
// Long utterances stop silently in some browsers (Chrome after ~15 s), so the script is read
|
||||
// one sentence at a time.
|
||||
export function sentencesOf(text: string): string[] {
|
||||
return text
|
||||
.replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes
|
||||
.replace(/[*_`]+/g, '')
|
||||
.split(/(?<=[.!?…])\s+|\n+/)
|
||||
.map(s => s.replace(/\s+/g, ' ').trim())
|
||||
.filter(s => /\p{L}/u.test(s))
|
||||
}
|
||||
|
||||
// The script's language decides the voice; the UI language may differ from the content's.
|
||||
const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi
|
||||
const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi
|
||||
export const scriptLang = (text: string) => ((text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en')
|
||||
|
||||
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window
|
||||
|
||||
const BARS = [16, 29, 45, 22, 37, 56, 31, 19, 43]
|
||||
|
||||
export function AudioPlayer({ title, text }: { title: string; text: string }) {
|
||||
const sentences = useMemo(() => sentencesOf(text), [text])
|
||||
const lang = useMemo(() => scriptLang(text), [text])
|
||||
const [state, setState] = useState<'idle' | 'playing' | 'paused'>('idle')
|
||||
const [index, setIndex] = useState(0)
|
||||
const [rate, setRate] = useState(1)
|
||||
// The sentence to speak next; a ref so the utterance callbacks see the latest value.
|
||||
const at = useRef(0)
|
||||
const playing = useRef(false)
|
||||
// The utterance being spoken: cancel() also fires "end" on the one it stops, which must not advance.
|
||||
const current = useRef<SpeechSynthesisUtterance | null>(null)
|
||||
|
||||
const voice = () => speechSynthesis.getVoices().find(v => v.lang.toLowerCase().startsWith(lang)) ?? null
|
||||
|
||||
function speak(i: number) {
|
||||
if (i >= sentences.length) { stop(); return }
|
||||
at.current = i
|
||||
setIndex(i)
|
||||
const u = new SpeechSynthesisUtterance(sentences[i])
|
||||
u.lang = lang === 'id' ? 'id-ID' : 'en-US'
|
||||
u.voice = voice()
|
||||
u.rate = rate
|
||||
u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) }
|
||||
current.current = u
|
||||
speechSynthesis.speak(u)
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (state === 'paused') { speechSynthesis.resume(); setState('playing'); playing.current = true; return }
|
||||
current.current = null
|
||||
speechSynthesis.cancel()
|
||||
playing.current = true
|
||||
setState('playing')
|
||||
speak(at.current)
|
||||
}
|
||||
function pause() { speechSynthesis.pause(); playing.current = false; setState('paused') }
|
||||
function stop() {
|
||||
playing.current = false
|
||||
current.current = null
|
||||
speechSynthesis.cancel()
|
||||
at.current = 0
|
||||
setIndex(0)
|
||||
setState('idle')
|
||||
}
|
||||
|
||||
// Leaving the preview must not leave the device talking.
|
||||
useEffect(() => () => { playing.current = false; if (supported) speechSynthesis.cancel() }, [])
|
||||
|
||||
// A new speed applies from the current sentence.
|
||||
useEffect(() => {
|
||||
if (!playing.current) return
|
||||
current.current = null
|
||||
speechSynthesis.cancel()
|
||||
speak(at.current)
|
||||
}, [rate])
|
||||
|
||||
const progress = sentences.length ? Math.round(((index + (state === 'idle' ? 0 : 1)) / sentences.length) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="audio-preview">
|
||||
<span className="audio-orb"><Icon name="audio" /></span>
|
||||
<div className={`waveform${state === 'playing' ? ' playing' : ''}`} aria-hidden="true">
|
||||
{Array.from({ length: 36 }, (_, i) => <i key={i} style={{ height: BARS[i % BARS.length], animationDelay: `${(i % 9) * 0.08}s` }} />)}
|
||||
</div>
|
||||
<h2>{title}</h2>
|
||||
<p>{supported ? t('audio.copy') : t('audio.unsupported')}</p>
|
||||
{supported && sentences.length > 0 && (
|
||||
<>
|
||||
<div className="head-actions">
|
||||
{state === 'playing'
|
||||
? <button className="btn primary" onClick={pause}>{t('audio.pause')}</button>
|
||||
: <button className="btn primary" onClick={play}><Icon name="audio" />{state === 'paused' ? t('audio.resume') : t('audio.play')}</button>}
|
||||
<button className="btn" disabled={state === 'idle'} onClick={stop}><Icon name="close" />{t('audio.stop')}</button>
|
||||
<select aria-label={t('audio.speed')} value={rate} onChange={e => setRate(Number(e.target.value))} className="audio-rate">
|
||||
{[0.85, 1, 1.15, 1.3].map(r => <option key={r} value={r}>{r}×</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="audio-progress"><i style={{ width: `${progress}%` }} /></div>
|
||||
<small role="status">
|
||||
{state === 'idle' ? t('audio.ready', { n: sentences.length }) : t('audio.sentence', { n: index + 1, total: sentences.length })}
|
||||
</small>
|
||||
{state !== 'idle' && <blockquote className="audio-now">{sentences[index]}</blockquote>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user