managarten/apps-archived/memoro/apps/mobile/components/organisms/TranscriptDisplay.tsx
Till-JS 61d181fbc2 chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:03:59 +01:00

918 lines
27 KiB
TypeScript

import React, { useState } from 'react';
import {
View,
StyleSheet,
ScrollView as RNScrollView,
Alert,
Share,
TextInput,
Pressable,
Platform,
} from 'react-native';
import { useTheme } from '~/features/theme/ThemeProvider';
import Text from '~/components/atoms/Text';
import Button from '~/components/atoms/Button';
import Icon from '~/components/atoms/Icon';
import * as Clipboard from 'expo-clipboard';
import SpeakerLabelModal from '~/components/molecules/SpeakerLabelModal';
import HighlightedText from '~/components/atoms/HighlightedText';
import { getLanguageDisplayName } from '~/utils/languageMapping';
import { useTranslation } from 'react-i18next';
interface TranscriptData {
audio_path?: string;
type?: string;
speakers?: Record<string, string>;
utterances?: Array<{ speakerId: string; text: string; offset: number; duration: number }>;
transcription_parts?: Array<{
text: string;
speaker?: string;
start_time?: number;
end_time?: number;
// For combined memos:
memo_id?: string;
title?: string;
transcript?: string;
created_at?: string;
index?: number;
original_source?: any;
}>;
languages?: string[];
primary_language?: string;
transcript?: string;
}
interface TranscriptDisplayProps {
data: TranscriptData;
defaultExpanded?: boolean;
title?: string;
speakerLabels?: Record<string, string>;
onNameSpeakersPress?: () => void;
onCopyPress?: () => void;
onSharePress?: () => void;
onUpdateSpeakerLabels?: (speakerMappings: Array<{ id: string; label: string }>) => void;
onCopySuccess?: () => void;
// Search highlighting props
searchQuery?: string;
isSearchMode?: boolean;
currentResultIndex?: number;
searchResults?: Array<{
id: string;
type: string;
text: string;
index: number;
matchIndex: number;
}>;
// Edit mode props
isEditing?: boolean;
onTranscriptChange?: (newTranscript: string) => void;
onUtteranceChange?: (index: number, newText: string) => void;
}
function TranscriptDisplay({
data,
defaultExpanded = true,
title = 'Transkript',
speakerLabels = {},
onNameSpeakersPress,
onCopyPress,
onSharePress,
onUpdateSpeakerLabels,
onCopySuccess,
searchQuery = '',
isSearchMode = false,
currentResultIndex,
searchResults,
isEditing = false,
onTranscriptChange,
onUtteranceChange,
}: TranscriptDisplayProps) {
const { isDark, themeVariant } = useTheme();
const { t } = useTranslation();
const [isSpeakerModalVisible, setIsSpeakerModalVisible] = useState(false);
const [editableSpeakerLabels, setEditableSpeakerLabels] = useState<Record<string, string>>({
...speakerLabels,
});
const [showStatistics, setShowStatistics] = useState(false);
// Local state for editing
const [localUtterances, setLocalUtterances] = useState<Array<any>>([]);
const [localTranscript, setLocalTranscript] = useState<string>('');
const [isLocalStateInitialized, setIsLocalStateInitialized] = useState(false);
// Check if this is a combined memo
const isCombinedMemo = data.type === 'combined';
// Convert transcription_parts to utterances format if needed
const getUtterancesFromData = () => {
if (data.utterances && data.utterances.length > 0) {
return data.utterances;
}
if (data.transcription_parts && data.transcription_parts.length > 0) {
// Handle combined memos differently
if (isCombinedMemo) {
// For combined memos, flatten all utterances from all parts
const allUtterances: any[] = [];
data.transcription_parts.forEach((part, partIndex) => {
// Add a separator utterance for each memo part
allUtterances.push({
speakerId: '__separator__',
text: part.title || `Memo ${partIndex + 1}`,
offset: 0,
duration: 0,
memoId: part.memo_id,
createdAt: part.created_at,
isSeparator: true,
});
// Add utterances from this part
if (part.utterances && Array.isArray(part.utterances)) {
part.utterances.forEach((utterance: any) => {
allUtterances.push({
speakerId: utterance.speakerId || 'unknown',
text: utterance.text || '',
offset: utterance.offset || 0,
duration: utterance.duration || 0,
partIndex: partIndex,
speakers: part.speakers || {},
});
});
} else if (part.transcript) {
// If no utterances but has transcript text, create a single utterance
allUtterances.push({
speakerId: 'default',
text: part.transcript,
offset: 0,
duration: 0,
partIndex: partIndex,
});
}
});
return allUtterances;
} else {
// Handle normal transcription parts
return data.transcription_parts.map((part, index) => ({
speakerId: part.speaker || `speaker${index + 1}`,
text: part.text,
offset: part.start_time ? part.start_time * 1000 : 0, // Convert to ms
duration: part.end_time && part.start_time ? (part.end_time - part.start_time) * 1000 : 0,
}));
}
}
return [];
};
// Initialize local state when entering edit mode ONLY - ignore data changes while editing
React.useEffect(() => {
if (isEditing && !isLocalStateInitialized) {
const utterancesFromData = getUtterancesFromData();
setLocalUtterances(utterancesFromData);
setLocalTranscript(data.transcript || '');
setIsLocalStateInitialized(true);
console.debug('TranscriptDisplay: Local state initialized for editing', {
utterances: utterancesFromData.length,
transcript: data.transcript?.length || 0,
});
} else if (!isEditing && isLocalStateInitialized) {
// Reset when leaving edit mode
setLocalUtterances([]);
setLocalTranscript('');
setIsLocalStateInitialized(false);
console.debug('TranscriptDisplay: Local state reset - leaving edit mode');
}
}, [isEditing]); // Removed 'data' dependency to prevent overwrites during editing
// Use local state when editing, original data when not
const utterances =
isEditing && isLocalStateInitialized ? localUtterances : getUtterancesFromData();
const transcriptText =
isEditing && isLocalStateInitialized ? localTranscript : data.transcript || '';
const hasUtterances = utterances.length > 0;
// Funktion zum Formatieren des Transkripts
const getFormattedTranscript = (): string => {
let formattedText = '';
if (hasUtterances && utterances) {
if (isCombinedMemo) {
// Spezielle Formatierung für kombinierte Memos
let currentMemoSection = '';
utterances.forEach((utterance, index) => {
if (utterance.isSeparator) {
// Add separator header
if (currentMemoSection) {
formattedText += '\n\n';
}
const createdAt = utterance.createdAt
? new Date(utterance.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '';
const header = createdAt
? `=== ${utterance.text} (${createdAt}) ===`
: `=== ${utterance.text} ===`;
formattedText += header + '\n\n';
currentMemoSection = utterance.text;
} else {
// Add regular utterance
const speakerName =
utterance.speakers && utterance.speakers[utterance.speakerId]
? utterance.speakers[utterance.speakerId]
: getSpeakerDisplayName(utterance.speakerId);
formattedText += `${speakerName}: ${utterance.text}\n\n`;
}
});
formattedText = formattedText.trim();
} else {
// Normale Formatierung mit Sprechernamen
formattedText = utterances
.map((utterance) => {
const speakerName = getSpeakerDisplayName(utterance.speakerId);
return `${speakerName}: ${utterance.text}`;
})
.join('\n\n');
}
} else if (data.transcript) {
// Wenn keine Äußerungen vorhanden sind, verwende den einfachen Transkripttext
formattedText = data.transcript;
}
return formattedText;
};
// Funktion zum Kopieren des Transkripts in die Zwischenablage
const handleCopyPress = async () => {
try {
const textToCopy = getFormattedTranscript();
if (textToCopy) {
await Clipboard.setStringAsync(textToCopy);
// Use onCopySuccess callback if provided, otherwise fallback to system alert
if (onCopySuccess) {
onCopySuccess();
} else {
Alert.alert('Erfolg', 'Transkript wurde in die Zwischenablage kopiert');
}
if (onCopyPress) {
onCopyPress();
}
}
} catch (error) {
console.debug('Fehler beim Kopieren:', error);
Alert.alert('Fehler', 'Das Transkript konnte nicht kopiert werden');
}
};
// Funktion zum Teilen des Transkripts über den nativen Share-Dialog
const handleSharePress = async () => {
try {
const textToShare = getFormattedTranscript();
if (textToShare) {
await Share.share({
message: textToShare,
title: title || 'Transkript',
});
if (onSharePress) {
onSharePress();
}
}
} catch (error) {
console.debug('Fehler beim Teilen:', error);
Alert.alert('Fehler', 'Das Transkript konnte nicht geteilt werden');
}
};
// Funktion zum Öffnen des Modals für die Benennung von Sprechern
const handleNameSpeakersPress = () => {
if (onNameSpeakersPress) {
onNameSpeakersPress();
} else {
setIsSpeakerModalVisible(true);
}
};
// Funktion zum Schließen des Modals für die Benennung von Sprechern
const handleCloseSpeakerModal = () => {
setIsSpeakerModalVisible(false);
};
// Funktion zum Speichern der Sprechernamen
const handleSubmitSpeakerLabels = (speakerMappings: Array<{ id: string; label: string }>) => {
// Konvertiere das Array von Mappings in ein Record-Objekt
const updatedLabels: Record<string, string> = {};
speakerMappings.forEach((mapping) => {
updatedLabels[mapping.id] = mapping.label;
});
// Aktualisiere den lokalen State
setEditableSpeakerLabels(updatedLabels);
// Rufe die übergebene Callback-Funktion auf, falls vorhanden
if (onUpdateSpeakerLabels) {
onUpdateSpeakerLabels(speakerMappings);
}
// Schließe das Modal
setIsSpeakerModalVisible(false);
};
// Format timestamp (offset in ms to mm:ss format)
const formatTimestamp = (offsetMs?: number): string => {
if (offsetMs === undefined) return '';
const totalSeconds = Math.floor(offsetMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// Statistics calculation functions
const calculateStatistics = () => {
let totalWords = 0;
const speakerWordCounts: Record<string, number> = {};
let totalDurationMs = 0;
if (hasUtterances && utterances) {
utterances.forEach((utterance) => {
const words = utterance.text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
const wordCount = words.length;
totalWords += wordCount;
// Count words per speaker
const speakerName = getSpeakerDisplayName(utterance.speakerId);
speakerWordCounts[speakerName] = (speakerWordCounts[speakerName] || 0) + wordCount;
// Calculate total duration (use duration if available, otherwise estimate)
if (utterance.duration && utterance.duration > 0) {
totalDurationMs += utterance.duration;
}
});
} else if (transcriptText) {
// For plain transcript without utterances
const words = transcriptText
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
totalWords = words.length;
speakerWordCounts['Total'] = totalWords;
}
// Calculate words per minute
const totalDurationMinutes = totalDurationMs > 0 ? totalDurationMs / (1000 * 60) : 0;
const wordsPerMinute =
totalDurationMinutes > 0 ? Math.round(totalWords / totalDurationMinutes) : 0;
return {
totalWords,
speakerWordCounts,
wordsPerMinute: totalDurationMinutes > 0 ? wordsPerMinute : null,
totalDurationMinutes: Math.round(totalDurationMinutes * 10) / 10, // Round to 1 decimal
};
};
// Determine language for display - use primary_language if available, otherwise fallback to first language in array
const languageCode =
data.primary_language ||
(data.languages && data.languages.length > 0 ? data.languages[0] : 'unknown');
const language = getLanguageDisplayName(languageCode);
// Theme colors
const textColor = isDark ? '#FFFFFF' : '#000000';
const secondaryTextColor = isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
const backgroundColor = isDark ? 'rgba(30, 30, 30, 0.8)' : 'rgba(245, 245, 245, 0.8)';
const borderColor = isDark
? `var(--color-dark-${themeVariant}-border)`
: `var(--color-${themeVariant}-border)`;
const speakerColors = {
speaker1: isDark ? '#64B5F6' : '#2196F3', // Blue
speaker2: isDark ? '#81C784' : '#4CAF50', // Green
speaker3: isDark ? '#FFB74D' : '#FF9800', // Orange
speaker4: isDark ? '#E57373' : '#F44336', // Red
default: isDark ? '#B39DDB' : '#673AB7', // Purple
};
const styles = StyleSheet.create({
container: {
borderRadius: 8,
overflow: 'hidden',
marginVertical: 8,
width: '100%',
maxWidth: 720,
alignSelf: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 12,
paddingHorizontal: 20, // Horizontales Padding für interne Ausrichtung
borderBottomWidth: 1,
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
gap: 12, // Add gap between title and language container
},
languageContainer: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0, // Prevent language container from shrinking
},
title: {
fontSize: 16,
fontWeight: 'bold',
color: textColor,
flex: 1, // Allow title to take available space but not push language container out
marginRight: 12, // Add some space between title and language container
},
languageTag: {
fontSize: 12,
color: secondaryTextColor,
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
marginRight: 8,
},
infoIcon: {
padding: 4,
},
statisticsSection: {
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)',
borderBottomWidth: 1,
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
padding: 12,
paddingHorizontal: 20, // Horizontales Padding für interne Ausrichtung
},
statisticsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
},
statisticsLabel: {
fontSize: 13,
color: secondaryTextColor,
},
statisticsValue: {
fontSize: 13,
fontWeight: '600',
color: textColor,
},
speakerStatsContainer: {
marginTop: 8,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
},
speakerStatsTitle: {
fontSize: 12,
fontWeight: '600',
color: secondaryTextColor,
marginBottom: 6,
},
actionButtonsContainer: {
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
// Kein marginHorizontal - volle Breite für Buttons
},
buttonScrollContainer: {
flexDirection: 'row',
paddingHorizontal: 20, // Padding für korrekte Ausrichtung mit anderem Content
},
buttonContainer: {
marginRight: 8,
},
content: {
padding: 12,
paddingHorizontal: 20, // Horizontales Padding für interne Ausrichtung
width: '100%',
},
plainTranscript: {
fontSize: 16,
lineHeight: 24,
color: textColor,
},
speakerItem: {
marginBottom: 16,
width: '100%',
},
speakerHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
speakerName: {
fontSize: 14,
fontWeight: 'bold',
marginRight: 8,
},
timestamp: {
fontSize: 12,
color: secondaryTextColor,
},
speakerText: {
fontSize: 16,
lineHeight: 24,
color: textColor,
},
speakerTextInput: {
fontSize: 16,
lineHeight: 24,
color: textColor,
borderBottomWidth: 1,
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
paddingBottom: 4,
minHeight: 24,
textAlignVertical: 'top',
},
plainTranscriptInput: {
fontSize: 16,
lineHeight: 24,
color: textColor,
borderBottomWidth: 1,
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
paddingBottom: 4,
minHeight: 24,
textAlignVertical: 'top',
},
memoSeparator: {
marginVertical: 24,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 0,
},
memoSeparatorLine: {
flex: 1,
height: 1,
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
},
memoSeparatorContent: {
paddingHorizontal: 16,
alignItems: 'center',
},
memoSeparatorTitle: {
fontSize: 14,
fontWeight: '600',
color: isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)',
marginBottom: 4,
},
memoSeparatorDate: {
fontSize: 12,
color: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)',
},
});
// Helper function to get speaker name display
const getSpeakerDisplayName = (speakerId: string): string => {
// 1. Priorität: Benutzerdefinierte Labels aus den Metadaten
if (speakerLabels && speakerLabels[speakerId]) {
return speakerLabels[speakerId];
}
// 2. Priorität: Namen aus dem speakers-Objekt - aber überprüfe ob es ein Standard-Speaker-Name ist
if (data.speakers && data.speakers[speakerId]) {
const speakerName = data.speakers[speakerId];
// Ensure speakerName is a string before processing
if (typeof speakerName === 'string' && speakerName) {
// Check if it's a default "Speaker X" format that needs translation
const defaultSpeakerMatch = speakerName.match(/^Speaker\s+(\d+)$/i);
if (defaultSpeakerMatch) {
const number = defaultSpeakerMatch[1];
return t('memo.speaker_default', { number });
}
// Otherwise return the custom name
return speakerName;
}
}
// 3. Priorität: Übersetze Speaker-ID
const match = speakerId.match(/([a-zA-Z]+)(\d+)/i);
if (match) {
const prefix = match[1].toLowerCase();
const number = match[2];
if (prefix === 'speaker') {
return t('memo.speaker_default', { number });
}
}
// Check if speakerId itself is already formatted like "Speaker 1"
const formattedMatch = speakerId.match(/^Speaker\s+(\d+)$/i);
if (formattedMatch) {
const number = formattedMatch[1];
return t('memo.speaker_default', { number });
}
// Fallback: Formatierte Speaker-ID
return speakerId.replace(
/([a-zA-Z]+)(\d+)/i,
(_, text, num) => `${text.charAt(0).toUpperCase()}${text.slice(1)} ${num}`
);
};
// Helper function to get speaker text color
const getSpeakerColor = (speakerId: string): string => {
return speakerColors[speakerId as keyof typeof speakerColors] || speakerColors.default;
};
// Extrahiere die Sprecher-IDs aus den Äußerungen oder Sprechern
const getSpeakerIds = (): string[] => {
if (utterances.length > 0) {
// Extrahiere eindeutige Sprecher-IDs aus den Äußerungen
const speakerIds = new Set<string>();
utterances.forEach((utterance) => {
speakerIds.add(utterance.speakerId);
});
return Array.from(speakerIds);
} else if (data.speakers) {
// Verwende die Schlüssel aus dem speakers-Objekt
return Object.keys(data.speakers);
}
return [];
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1} ellipsizeMode="tail">
{title}
</Text>
{language !== 'unknown' && (
<View style={styles.languageContainer}>
<View style={styles.languageTag}>
<Text style={{ color: secondaryTextColor }}>{language}</Text>
</View>
<Pressable style={styles.infoIcon} onPress={() => setShowStatistics(!showStatistics)}>
<Icon
name={showStatistics ? 'information-circle' : 'information-circle-outline'}
size={24}
color={
showStatistics
? `var(--color-${isDark ? 'dark-' : ''}${themeVariant}-primary)`
: secondaryTextColor
}
/>
</Pressable>
</View>
)}
</View>
{/* Statistics Section */}
{showStatistics && language !== 'unknown' && (
<View style={styles.statisticsSection}>
{(() => {
const stats = calculateStatistics();
return (
<>
<View style={styles.statisticsRow}>
<Text style={styles.statisticsLabel}>
{t('transcript.total_words', 'Total Words')}
</Text>
<Text style={styles.statisticsValue}>{stats.totalWords}</Text>
</View>
{stats.wordsPerMinute !== null && (
<View style={styles.statisticsRow}>
<Text style={styles.statisticsLabel}>
{t('transcript.words_per_minute', 'Words per Minute')}
</Text>
<Text style={styles.statisticsValue}>{stats.wordsPerMinute}</Text>
</View>
)}
{stats.totalDurationMinutes > 0 && (
<View style={styles.statisticsRow}>
<Text style={styles.statisticsLabel}>
{t('transcript.duration', 'Duration')}
</Text>
<Text style={styles.statisticsValue}>
{stats.totalDurationMinutes} {t('transcript.minutes_short', 'min')}
</Text>
</View>
)}
{Object.keys(stats.speakerWordCounts).length > 1 && (
<View style={styles.speakerStatsContainer}>
<Text style={styles.speakerStatsTitle}>
{t('transcript.words_per_speaker', 'Words per Speaker')}
</Text>
{Object.entries(stats.speakerWordCounts).map(([speaker, count]) => (
<View key={speaker} style={styles.statisticsRow}>
<Text style={styles.statisticsLabel}>{speaker}</Text>
<Text style={styles.statisticsValue}>{count}</Text>
</View>
))}
</View>
)}
</>
);
})()}
</View>
)}
{!isEditing && (
<View style={styles.actionButtonsContainer}>
<RNScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.buttonScrollContainer}
>
<View style={styles.buttonContainer}>
<Button
variant="secondary"
title={t('memo.name_speakers')}
onPress={handleNameSpeakersPress}
leftIcon={() => (
<View style={{ marginRight: 8 }}>
<Icon name="person-outline" size={18} color={textColor} />
</View>
)}
/>
</View>
<View style={styles.buttonContainer}>
<Button
variant="secondary"
title={t('memo.copy')}
onPress={handleCopyPress}
leftIcon={() => (
<View style={{ marginRight: 8 }}>
<Icon name="copy-outline" size={18} color={textColor} />
</View>
)}
/>
</View>
<View style={styles.buttonContainer}>
<Button
variant="secondary"
title={t('common.share')}
onPress={handleSharePress}
leftIcon={() => (
<View style={{ marginRight: 8 }}>
<Icon
name={Platform.OS === 'android' ? 'share-social-outline' : 'share-outline'}
size={18}
color={textColor}
/>
</View>
)}
/>
</View>
</RNScrollView>
</View>
)}
<View style={styles.content}>
{hasUtterances ? (
// Zeige Äußerungen chronologisch an
utterances.map((utterance, index) => {
// Special handling for separator utterances in combined memos
if (utterance.isSeparator) {
return (
<View key={`separator-${index}`} style={styles.memoSeparator}>
<View style={styles.memoSeparatorLine} />
<View style={styles.memoSeparatorContent}>
<Text style={styles.memoSeparatorTitle}>{utterance.text}</Text>
{utterance.createdAt && (
<Text style={styles.memoSeparatorDate}>
{new Date(utterance.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
)}
</View>
<View style={styles.memoSeparatorLine} />
</View>
);
}
// Regular utterance handling
const speakerName =
utterance.speakers && utterance.speakers[utterance.speakerId]
? utterance.speakers[utterance.speakerId]
: getSpeakerDisplayName(utterance.speakerId);
return (
<View key={`utterance-${index}`} style={styles.speakerItem}>
<View style={styles.speakerHeader}>
<Text
style={[styles.speakerName, { color: getSpeakerColor(utterance.speakerId) }]}
>
{speakerName}
</Text>
<Text style={styles.timestamp}>{formatTimestamp(utterance.offset)}</Text>
</View>
{isEditing ? (
<TextInput
style={styles.speakerTextInput}
value={utterance.text}
onChangeText={(newText) => {
console.debug('TranscriptDisplay: onChangeText called', {
index,
newText,
hasCallback: !!onUtteranceChange,
});
// Update local state immediately for instant feedback
setLocalUtterances((prev) =>
prev.map((utt, i) => (i === index ? { ...utt, text: newText } : utt))
);
// Call the parent callback
onUtteranceChange?.(index, newText);
}}
placeholder="Äußerung eingeben"
placeholderTextColor={
isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'
}
multiline
scrollEnabled={false}
textAlignVertical="top"
editable={true}
/>
) : isSearchMode && searchQuery ? (
<HighlightedText
text={utterance.text}
searchQuery={searchQuery}
style={styles.speakerText}
currentResultIndex={currentResultIndex}
searchResults={searchResults}
textType="transcript"
/>
) : (
<Text style={styles.speakerText}>{utterance.text}</Text>
)}
</View>
);
})
) : // Kein strukturiertes Transkript: Zeige einfachen Text
isEditing ? (
<TextInput
style={styles.plainTranscriptInput}
value={transcriptText}
onChangeText={(newText) => {
console.debug('TranscriptDisplay: plain transcript onChangeText called', {
newText,
hasCallback: !!onTranscriptChange,
});
// Update local state immediately for instant feedback
setLocalTranscript(newText);
// Call the parent callback
onTranscriptChange?.(newText);
}}
placeholder="Transkript eingeben"
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
multiline
scrollEnabled={false}
textAlignVertical="top"
editable={true}
/>
) : isSearchMode && searchQuery ? (
<HighlightedText
text={transcriptText}
searchQuery={searchQuery}
style={styles.plainTranscript}
currentResultIndex={currentResultIndex}
searchResults={searchResults}
textType="transcript"
/>
) : (
<Text style={styles.plainTranscript}>{transcriptText}</Text>
)}
</View>
{/* SpeakerLabelModal */}
<SpeakerLabelModal
visible={isSpeakerModalVisible}
onClose={handleCloseSpeakerModal}
onSubmit={handleSubmitSpeakerLabels}
speakers={getSpeakerIds()}
initialMappings={speakerLabels}
/>
</View>
);
}
export default TranscriptDisplay;