import React, { useState, useEffect, useRef, useMemo } from 'react'; import { View, Text, Pressable, ActivityIndicator, Alert, Animated, ScrollView, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useAudio } from '~/hooks/useAudio'; import { Text as TextType, AudioVersion } from '~/types/database'; import { useStore } from '~/store/store'; import { useTheme } from '~/hooks/useTheme'; import { Dropdown } from '~/components/dropdown'; import { Voice, ALL_VOICES, getVoiceById, GERMAN_VOICES, PROVIDER_LABELS, QUALITY_LABELS, } from '~/constants/voices'; import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration'; interface AudioPlayerProps { text: TextType; onAudioGenerated?: () => void; } export const AudioPlayer: React.FC = ({ text, onAudioGenerated }) => { const [isGenerating, setIsGenerating] = useState(false); const [showSpeedControl, setShowSpeedControl] = useState(false); const [selectedVoice, setSelectedVoice] = useState(''); const [showVersions, setShowVersions] = useState(false); const progressBarRef = useRef(null); const pulseAnim = useRef(new Animated.Value(1)).current; const { settings, updateSettings } = useStore(); const { colors } = useTheme(); // Use useMemo to prevent re-migration on every render const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]); const audioVersions = migratedData.audioVersions || []; const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]); // Initialize selectedVersionId with current version const [selectedVersionId, setSelectedVersionId] = useState(currentVersion?.id || ''); // Initialize selected voice useEffect(() => { setSelectedVoice(settings.voice); }, [settings.voice]); const { audioState, generationProgress, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio, seekTo, seekForward, seekBackward, setPlaybackSpeed, clearCache, } = useAudio(); // Pulsating animation for loading state useEffect(() => { if (audioState.isLoading) { Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.2, duration: 600, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true, }), ]) ).start(); } else { pulseAnim.setValue(1); } }, [audioState.isLoading, pulseAnim]); const handleGenerateAudio = async () => { try { setIsGenerating(true); await generateAudio(text.id, text.content, selectedVoice, settings.speed, text); onAudioGenerated?.(); Alert.alert( 'Audio generiert!', 'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.' ); } catch (error) { Alert.alert( 'Fehler', error instanceof Error ? error.message : 'Fehler beim Generieren des Audios' ); } finally { setIsGenerating(false); } }; const handleVoiceChange = (newVoice: string) => { setSelectedVoice(newVoice); // Update the global settings updateSettings({ voice: newVoice }); }; const handlePlayPause = async () => { if (!selectedVersion?.chunks) return; try { if (audioState.isPlaying) { await pauseAudio(); } else if (audioState.sound) { await resumeAudio(); } else { // Play directly from Supabase Storage await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0); } } catch (error) { Alert.alert( 'Wiedergabe-Fehler', error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios' ); } }; const handleStop = async () => { await stopAudio(); }; const formatTime = (milliseconds: number): string => { const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; const formatSize = (bytes: number): string => { const mb = bytes / (1024 * 1024); return `${mb.toFixed(1)} MB`; }; const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2]; const handleSpeedChange = async (speed: number) => { await setPlaybackSpeed(speed); setShowSpeedControl(false); }; // Use duration from audio state if available, otherwise calculate from chunks const totalDuration = audioState.duration || (selectedVersion?.chunks ? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000 : 0); // Handle progress bar press const handleProgressPress = async (event: any) => { if (progressBarRef.current && totalDuration > 0) { progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => { const touchX = event.nativeEvent.pageX - pageX; const progress = Math.max(0, Math.min(1, touchX / width)); const newPosition = progress * totalDuration; // If audio hasn't been started yet, start it at the desired position if (!audioState.sound) { await playAudio(text.id, text.data.audio!.chunks, newPosition); } else { await seekTo(newPosition); } }); } }; // Get the selected audio version const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion; const hasAudio = selectedVersion && selectedVersion.chunks.length > 0; return ( {/* Voice selection and generate button - always visible */} Sprachauswahl { const provider = voice.provider; const quality = voice.quality; if (!groups[provider]) { groups[provider] = {}; } if (!groups[provider][quality]) { groups[provider][quality] = []; } groups[provider][quality].push(voice); return groups; }, {} as Record> ) ).map(([provider, qualityGroups]) => ({ title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS], options: Object.entries(qualityGroups).flatMap(([quality, voices]) => voices.map((voice) => ({ label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`, value: voice.value, })) ), }))} /> {isGenerating ? ( {generationProgress?.currentChunk || 'Generiere Audio...'} ) : ( {hasAudio ? 'Audio neu generieren' : 'Audio generieren'} )} {generationProgress && ( {generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks )} {/* Audio versions - only shown when audio exists */} {audioVersions.length > 0 && ( setShowVersions(!showVersions)} className="flex-row items-center justify-between" > Audio-Versionen ({audioVersions.length}) {showVersions && ( {audioVersions.map((version) => { const voice = getVoiceById(version.settings.voice); const isActive = version.id === selectedVersionId; const date = new Date(version.createdAt); return ( setSelectedVersionId(version.id)} className={`mb-2 rounded-lg p-3 ${ isActive ? 'bg-blue-600' : colors.surfaceSecondary }`} > {date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', })} {voice?.label || version.settings.voice} • {version.settings.speed}x {isActive && Aktiv} ); })} )} )} {/* Audio player - only shown when audio exists */} {hasAudio && ( {/* closing tag moved to end */} {/* Progress bar and time info - full width */} {/* Progress Bar with touch gestures */} 0 ? `${(audioState.currentPosition / totalDuration) * 100}%` : '0%', }} /> {/* Scrubber indicator */} {totalDuration > 0 && ( )} {/* Time display */} {formatTime(audioState.currentPosition)} {formatTime(totalDuration)} {/* Controls row */} {/* Stop button */} {/* Backward 15s button */} seekBackward(15)} disabled={audioState.isLoading || !audioState.sound} className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`} > 15 {/* Play/Pause button */} {audioState.isLoading ? ( ) : ( )} {/* Forward 15s button */} seekForward(15)} disabled={audioState.isLoading || !audioState.sound} className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`} > 15 {/* Speed control button */} setShowSpeedControl(!showSpeedControl)} className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`} > {audioState.playbackRate}x {/* Speed options dropdown */} {showSpeedControl && ( {speedOptions.map((speed) => ( handleSpeedChange(speed)} className={`mx-1 rounded px-3 py-1 ${ audioState.playbackRate === speed ? colors.primary : '' }`} > {speed}x ))} )} )} ); };