From acfd57ee87dc89ca9eaa9a4a3ccffe4e91d7fa1a Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 12 Jun 2026 21:49:21 +0200 Subject: [PATCH] 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 --- src/components/PairCards.css | 6 +++++ src/components/PairSentenceCard.jsx | 14 +++++++--- src/components/PairWordCard.jsx | 30 +++++++++++++++------ src/components/PairYesNoCard.jsx | 30 +++++++++++++++------ src/hooks/usePairAudio.js | 42 +++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 src/hooks/usePairAudio.js diff --git a/src/components/PairCards.css b/src/components/PairCards.css index 66593de..5627b8f 100644 --- a/src/components/PairCards.css +++ b/src/components/PairCards.css @@ -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; diff --git a/src/components/PairSentenceCard.jsx b/src/components/PairSentenceCard.jsx index 770edff..95e4270 100644 --- a/src/components/PairSentenceCard.jsx +++ b/src/components/PairSentenceCard.jsx @@ -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 }) {
-

+

{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}

{hint && ( @@ -196,8 +202,8 @@ export default function PairSentenceCard({ card, onComplete }) {
- {/* TTS — playing unlocks "Verstanden" */} - +
{options.map((opt) => { diff --git a/src/components/PairYesNoCard.jsx b/src/components/PairYesNoCard.jsx index feebdcf..e1301ff 100644 --- a/src/components/PairYesNoCard.jsx +++ b/src/components/PairYesNoCard.jsx @@ -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,14 +129,25 @@ export default function PairYesNoCard({ card, onComplete }) {
e.stopPropagation()} style={{ paddingTop: 18 }}> -

- {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)} -

- {hint && !result && ( -

- {resolveSentence(hint, card.placeholders, null, null)} -

- )} +
+
+

play()}> + {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)} +

+ {hint && !result && ( +

+ {resolveSentence(hint, card.placeholders, null, null)} +

+ )} +
+ +
{result && (

diff --git a/src/hooks/usePairAudio.js b/src/hooks/usePairAudio.js new file mode 100644 index 0000000..66b7a25 --- /dev/null +++ b/src/hooks/usePairAudio.js @@ -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 } +}