feat: echte Pair-Audios statt TTS, Klick auf Satz spielt Audio (preloaded)

- usePairAudio-Hook: preload beim Kachel-Mount, nur eine Stimme gleichzeitig
- PairSentenceCard: audio_url statt speechSynthesis (TTS nur noch Fallback)
- PairYesNoCard/PairWordCard: Frage klickbar + Speaker-Button (vorher kein Audio)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:49:21 +02:00
parent e7dbb9d0a7
commit acfd57ee87
5 changed files with 102 additions and 20 deletions

View File

@@ -215,6 +215,12 @@
flex: 1;
}
/* Clickable sentence — tap to play audio */
.pair-sentence-clickable {
cursor: pointer;
user-select: none;
}
/* Word chips inside sentences — underline italic style */
.pair-word-chip {
display: inline;

View File

@@ -1,5 +1,6 @@
import { useState, useRef } from 'react'
import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import './PairCards.css'
function triggerConfetti() {
@@ -112,6 +113,8 @@ export default function PairSentenceCard({ card, onComplete }) {
const hint = stmt?.[`sentence_${native}`] || null
const pic = card.picture?.url
const { play, playing } = usePairAudio(stmt?.audio_url)
const isWord = activeChip && activeChip.type === 'word'
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
@@ -129,7 +132,9 @@ export default function PairSentenceCard({ card, onComplete }) {
setTimeout(() => onComplete('correct'), 900)
}
function handleTTS() {
function handlePlay() {
if (play()) { setUnlocked(true); return }
// Fallback: Browser-TTS, falls kein Audio-File vorhanden
if (!window.speechSynthesis || !sentence) return
window.speechSynthesis.cancel()
const utt = new SpeechSynthesisUtterance(toPlainText(sentence))
@@ -178,7 +183,8 @@ export default function PairSentenceCard({ card, onComplete }) {
<div className="pair-sentence-row">
<div className="pair-sentence-text">
<p className="pair-sentence" style={{ opacity: showTranslation ? 0 : 1, transition: 'opacity 0.18s', margin: 0 }}>
<p className="pair-sentence pair-sentence-clickable" onClick={handlePlay}
style={{ opacity: showTranslation ? 0 : 1, transition: 'opacity 0.18s', margin: 0 }}>
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p>
{hint && (
@@ -196,8 +202,8 @@ export default function PairSentenceCard({ card, onComplete }) {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
{/* TTS — playing unlocks "Verstanden" */}
<button className={`pair-icon-btn${unlocked ? ' active' : ''}`} onClick={handleTTS} title="Vorlesen">
{/* Vorlesen — playing unlocks "Verstanden" */}
<button className={`pair-icon-btn${unlocked || playing ? ' active' : ''}`} onClick={handlePlay} title="Vorlesen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react'
import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import './PairCards.css'
function triggerConfetti() {
@@ -103,6 +104,8 @@ export default function PairWordCard({ card, onComplete }) {
const hint = q?.[`sentence_${native}`] || null
const pic = card.picture?.url
const { play, playing } = usePairAudio(q?.audio_url)
const options = useMemo(() => {
const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true }))
const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false }))
@@ -155,7 +158,9 @@ export default function PairWordCard({ card, onComplete }) {
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-question">
<div className="pair-sentence-row">
<div className="pair-sentence-text">
<p className="pair-question pair-sentence-clickable" onClick={() => play()}>
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p>
{hint && !confirmed && (
@@ -163,6 +168,15 @@ export default function PairWordCard({ card, onComplete }) {
{resolveSentence(hint, card.placeholders, null, null)}
</p>
)}
</div>
<button className={`pair-icon-btn${playing ? ' active' : ''}`} onClick={() => play()} title="Vorlesen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
</button>
</div>
<div className="pair-options">
{options.map((opt) => {

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import './PairCards.css'
function triggerConfetti() {
@@ -91,6 +92,8 @@ export default function PairYesNoCard({ card, onComplete }) {
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
const hint = q?.[`sentence_${native}`] || null
const { play, playing } = usePairAudio(q?.audio_url)
function handleChipClick(id, entry) {
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
}
@@ -126,7 +129,9 @@ export default function PairYesNoCard({ card, onComplete }) {
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-question">
<div className="pair-sentence-row">
<div className="pair-sentence-text">
<p className="pair-question pair-sentence-clickable" onClick={() => play()}>
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p>
{hint && !result && (
@@ -134,6 +139,15 @@ export default function PairYesNoCard({ card, onComplete }) {
{resolveSentence(hint, card.placeholders, null, null)}
</p>
)}
</div>
<button className={`pair-icon-btn${playing ? ' active' : ''}`} onClick={() => play()} title="Vorlesen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
</button>
</div>
{result && (
<p className={`pair-feedback ${result}`}>

42
src/hooks/usePairAudio.js Normal file
View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from 'react'
// Nur eine Stimme gleichzeitig im Feed
let currentAudio = null
export default function usePairAudio(url) {
const audioRef = useRef(null)
const [playing, setPlaying] = useState(false)
useEffect(() => {
if (!url) { audioRef.current = null; return }
const audio = new Audio(url)
audio.preload = 'auto'
const onPlay = () => setPlaying(true)
const onStop = () => setPlaying(false)
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onStop)
audio.addEventListener('ended', onStop)
audioRef.current = audio
return () => {
audio.pause()
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onStop)
audio.removeEventListener('ended', onStop)
if (currentAudio === audio) currentAudio = null
audio.src = ''
audioRef.current = null
}
}, [url])
function play() {
const audio = audioRef.current
if (!audio) return false
if (currentAudio && currentAudio !== audio) currentAudio.pause()
currentAudio = audio
audio.currentTime = 0
audio.play().catch(() => {})
return true
}
return { play, playing }
}