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; flex: 1;
} }
/* Clickable sentence — tap to play audio */
.pair-sentence-clickable {
cursor: pointer;
user-select: none;
}
/* Word chips inside sentences — underline italic style */ /* Word chips inside sentences — underline italic style */
.pair-word-chip { .pair-word-chip {
display: inline; display: inline;

View File

@@ -1,5 +1,6 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import confetti from 'canvas-confetti' import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import './PairCards.css' import './PairCards.css'
function triggerConfetti() { function triggerConfetti() {
@@ -112,6 +113,8 @@ export default function PairSentenceCard({ card, onComplete }) {
const hint = stmt?.[`sentence_${native}`] || null const hint = stmt?.[`sentence_${native}`] || null
const pic = card.picture?.url const pic = card.picture?.url
const { play, playing } = usePairAudio(stmt?.audio_url)
const isWord = activeChip && activeChip.type === 'word' const isWord = activeChip && activeChip.type === 'word'
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
@@ -129,7 +132,9 @@ export default function PairSentenceCard({ card, onComplete }) {
setTimeout(() => onComplete('correct'), 900) 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 if (!window.speechSynthesis || !sentence) return
window.speechSynthesis.cancel() window.speechSynthesis.cancel()
const utt = new SpeechSynthesisUtterance(toPlainText(sentence)) 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-row">
<div className="pair-sentence-text"> <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)} {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p> </p>
{hint && ( {hint && (
@@ -196,8 +202,8 @@ export default function PairSentenceCard({ card, onComplete }) {
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
{/* TTS — playing unlocks "Verstanden" */} {/* Vorlesen — playing unlocks "Verstanden" */}
<button className={`pair-icon-btn${unlocked ? ' active' : ''}`} onClick={handleTTS} title="Vorlesen"> <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"> <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"/> <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="M15.54 8.46a5 5 0 0 1 0 7.07"/>

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import confetti from 'canvas-confetti' import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import './PairCards.css' import './PairCards.css'
function triggerConfetti() { function triggerConfetti() {
@@ -103,6 +104,8 @@ export default function PairWordCard({ card, onComplete }) {
const hint = q?.[`sentence_${native}`] || null const hint = q?.[`sentence_${native}`] || null
const pic = card.picture?.url const pic = card.picture?.url
const { play, playing } = usePairAudio(q?.audio_url)
const options = useMemo(() => { const options = useMemo(() => {
const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true })) const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true }))
const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false })) const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false }))
@@ -155,14 +158,25 @@ export default function PairWordCard({ card, onComplete }) {
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}> <div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-question"> <div className="pair-sentence-row">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)} <div className="pair-sentence-text">
</p> <p className="pair-question pair-sentence-clickable" onClick={() => play()}>
{hint && !confirmed && ( {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
<p className="pair-hint"> </p>
{resolveSentence(hint, card.placeholders, null, null)} {hint && !confirmed && (
</p> <p className="pair-hint">
)} {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"> <div className="pair-options">
{options.map((opt) => { {options.map((opt) => {

View File

@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import confetti from 'canvas-confetti' import confetti from 'canvas-confetti'
import usePairAudio from '../hooks/usePairAudio'
import './PairCards.css' import './PairCards.css'
function triggerConfetti() { function triggerConfetti() {
@@ -91,6 +92,8 @@ export default function PairYesNoCard({ card, onComplete }) {
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
const hint = q?.[`sentence_${native}`] || null const hint = q?.[`sentence_${native}`] || null
const { play, playing } = usePairAudio(q?.audio_url)
function handleChipClick(id, entry) { function handleChipClick(id, entry) {
setActiveChip(prev => prev?.id === id ? null : { id, ...entry }) setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
} }
@@ -126,14 +129,25 @@ export default function PairYesNoCard({ card, onComplete }) {
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}> <div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-question"> <div className="pair-sentence-row">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)} <div className="pair-sentence-text">
</p> <p className="pair-question pair-sentence-clickable" onClick={() => play()}>
{hint && !result && ( {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
<p className="pair-hint"> </p>
{resolveSentence(hint, card.placeholders, null, null)} {hint && !result && (
</p> <p className="pair-hint">
)} {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 && ( {result && (
<p className={`pair-feedback ${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 }
}