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:
@@ -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;
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
42
src/hooks/usePairAudio.js
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user