feat: persönlichere Profilseite + iOS-App-Setup

Profil: Begrüßung in Zielsprache, Kategorie-Punkte-Übersicht,
ruhigerer Header (kein rotierender Avatar/Online-Dot), Notch-Fix
und kompaktere Aktivitäts-Heatmap. Außerdem Capacitor-iOS-Projekt
und diverse Auth/Feed/Audio-Verbesserungen aus dem Premium-Redesign.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:55:13 +02:00
parent 712f9a243c
commit e7b4ec571e
49 changed files with 3221 additions and 210 deletions

View File

@@ -3,25 +3,55 @@ import { useEffect, useRef, useState } from 'react'
// Nur eine Stimme gleichzeitig im Feed
let currentAudio = null
// Reines Vorlesen wird gedrosselt (User-Wunsch). Pitch bleibt durch den
// Browser-Default `preservesPitch` erhalten.
const PLAYBACK_RATE = 0.7
export default function usePairAudio(url) {
const audioRef = useRef(null)
const rafRef = useRef(null)
const failedRef = useRef(false)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [failed, setFailed] = useState(false)
const markFailed = () => { failedRef.current = true; setFailed(true) }
useEffect(() => {
if (!url) { audioRef.current = null; return }
failedRef.current = false
setFailed(false)
const audio = new Audio(url)
audio.preload = 'auto'
const onPlay = () => setPlaying(true)
const onStop = () => setPlaying(false)
audio.playbackRate = PLAYBACK_RATE
// currentTime ~30fps mitschreiben, solange das Audio läuft (für Karaoke-Sync).
let lastTick = 0
const tick = (ts) => {
if (ts - lastTick >= 33) { lastTick = ts; setCurrentTime(audio.currentTime) }
rafRef.current = requestAnimationFrame(tick)
}
const stopLoop = () => {
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
}
const onPlay = () => { setPlaying(true); stopLoop(); rafRef.current = requestAnimationFrame(tick) }
const onStop = () => { setPlaying(false); stopLoop(); setCurrentTime(audio.currentTime) }
const onEnded = () => { setPlaying(false); stopLoop(); setCurrentTime(0) }
// Netz-/Decode-Fehler eines vorhandenen Files → File gilt als nicht abspielbar,
// damit play() den TTS-Fallback der Karte greifen lässt.
const onError = () => { setPlaying(false); stopLoop(); markFailed() }
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onStop)
audio.addEventListener('ended', onStop)
audio.addEventListener('ended', onEnded)
audio.addEventListener('error', onError)
audioRef.current = audio
return () => {
stopLoop()
audio.pause()
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onStop)
audio.removeEventListener('ended', onStop)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('error', onError)
if (currentAudio === audio) currentAudio = null
audio.src = ''
audioRef.current = null
@@ -30,13 +60,16 @@ export default function usePairAudio(url) {
function play() {
const audio = audioRef.current
if (!audio) return false
if (!audio || failedRef.current) return false
if (currentAudio && currentAudio !== audio) currentAudio.pause()
currentAudio = audio
audio.currentTime = 0
audio.play().catch(() => {})
audio.playbackRate = PLAYBACK_RATE
// AbortError = von einem pause() unterbrochen (harmlos); echte Quell-/Decode-Fehler
// markieren das File als nicht abspielbar → Karte fällt auf TTS zurück.
audio.play().catch((err) => { if (err?.name !== 'AbortError') markFailed() })
return true
}
return { play, playing }
return { play, playing, currentTime, failed }
}