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;
|
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;
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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,7 +158,9 @@ 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">
|
||||||
|
<div className="pair-sentence-text">
|
||||||
|
<p className="pair-question pair-sentence-clickable" onClick={() => play()}>
|
||||||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
||||||
</p>
|
</p>
|
||||||
{hint && !confirmed && (
|
{hint && !confirmed && (
|
||||||
@@ -163,6 +168,15 @@ export default function PairWordCard({ card, onComplete }) {
|
|||||||
{resolveSentence(hint, card.placeholders, null, null)}
|
{resolveSentence(hint, card.placeholders, null, null)}
|
||||||
</p>
|
</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) => {
|
||||||
|
|||||||
@@ -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,7 +129,9 @@ 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">
|
||||||
|
<div className="pair-sentence-text">
|
||||||
|
<p className="pair-question pair-sentence-clickable" onClick={() => play()}>
|
||||||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
||||||
</p>
|
</p>
|
||||||
{hint && !result && (
|
{hint && !result && (
|
||||||
@@ -134,6 +139,15 @@ export default function PairYesNoCard({ card, onComplete }) {
|
|||||||
{resolveSentence(hint, card.placeholders, null, null)}
|
{resolveSentence(hint, card.placeholders, null, null)}
|
||||||
</p>
|
</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
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