bw-expo-app/components/profile/content-gallery.tsx

332 lines
8.0 KiB
TypeScript

import React, { memo } from 'react';
import {
View,
FlatList,
ActivityIndicator,
RefreshControl,
StyleSheet,
Image,
TouchableOpacity,
Platform
} from 'react-native';
import { VideoPlayer } from '@/components/video/video-player';
import { ThemedText } from '@/components/themed-text';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { router } from 'expo-router';
import type { TemplateGeneration } from '@/lib/api/template-generations';
/**
* 根据文件 URL 后缀名判断媒体类型
*/
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
if (!url) return 'unknown';
const extension = url.split('.').pop()?.toLowerCase() || '';
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
if (imageExts.includes(extension)) return 'image';
if (videoExts.includes(extension)) return 'video';
return 'unknown';
}
export function ContentGallery({
generations,
isRefreshing,
onRefresh,
isLoadingMore,
hasMore,
onLoadMore,
ListHeaderComponent,
}: {
generations: TemplateGeneration[];
isRefreshing: boolean;
onRefresh: () => void;
isLoadingMore: boolean;
hasMore: boolean;
onLoadMore: () => void;
ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
}) {
const colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
const renderItem = ({ item }: { item: TemplateGeneration }) => (
<ContentItem palette={palette} generation={item} />
);
const renderFooter = () => {
if (isLoadingMore) {
return (
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
<ActivityIndicator size="small" color={palette.accent} />
<ThemedText style={[styles.loadingMoreText, { color: palette.textSecondary }]}>
...
</ThemedText>
</View>
);
}
if (!hasMore && generations.length > 0) {
return (
<View style={[styles.noMoreContainer, { backgroundColor: palette.background }]}>
<ThemedText style={[styles.noMoreText, { color: palette.textSecondary }]}>
</ThemedText>
</View>
);
}
return null;
};
return (
<FlatList
data={generations}
renderItem={renderItem}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={renderFooter}
numColumns={2}
keyExtractor={(item) => item.id}
columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={palette.accent}
colors={[palette.accent]}
/>
}
onEndReached={onLoadMore}
onEndReachedThreshold={0.5}
contentContainerStyle={styles.contentContainer}
removeClippedSubviews={true}
maxToRenderPerBatch={6}
updateCellsBatchingPeriod={50}
windowSize={10}
/>
);
}
const ContentItem = memo(function ContentItem({
palette,
generation,
}: {
palette: any;
generation: TemplateGeneration;
}) {
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
const renderMedia = () => {
const mediaUrl = generation.resultUrl[0];
if (!mediaUrl) {
return (
<View style={[styles.mediaContainer, styles.placeholderContainer]}>
<ThemedText style={styles.placeholderText}>
{generation.type === 'VIDEO' ? '🎬' : generation.type === 'IMAGE' ? '🖼️' : '📝'}
</ThemedText>
</View>
);
}
const mediaType = getMediaType(mediaUrl);
if (mediaType === 'video') {
if (Platform.OS === 'web') {
return (
<View style={styles.mediaContainer}>
<video
src={mediaUrl}
style={{
width: '100%',
height: '100%',
objectFit: 'cover' as any,
}}
muted
playsInline
preload="metadata"
/>
</View>
);
} else {
return (
<View style={styles.mediaContainer}>
<VideoPlayer
source={{ uri: mediaUrl }}
style={styles.mediaVideo}
shouldPlay={false}
isLooping={true}
isMuted={true}
useNativeControls={false}
showPoster={true}
maxHeight={300}
/>
</View>
);
}
}
return (
<View style={styles.mediaContainer}>
<Image
source={{ uri: mediaUrl }}
style={styles.mediaImage}
resizeMode="cover"
/>
</View>
);
};
return (
<TouchableOpacity
style={[
styles.contentItem,
{
backgroundColor: palette.surface,
},
]}
onPress={() => router.push(`/result?generationId=${generation.id}`)}
activeOpacity={0.9}
>
{renderMedia()}
<View style={styles.contentInfo}>
<ThemedText
style={[styles.contentTitle, { color: palette.textPrimary }]}
numberOfLines={1}
>
{generation.template.title}
</ThemedText>
<View style={styles.contentMeta}>
<View style={styles.metaItem}>
<MaterialIcons name="schedule" size={14} color={palette.textSecondary} />
<ThemedText style={[styles.metaText, { color: palette.textSecondary }]}>
{formatDate(generation.createdAt)}
</ThemedText>
</View>
{generation.status !== 'completed' && (
<View style={styles.statusBadge}>
<ThemedText style={[styles.statusText, { color: palette.textSecondary }]}>
{generation.status}
</ThemedText>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
});
const darkPalette = {
background: '#050505',
surface: '#121216',
border: '#1D1E24',
textPrimary: '#F6F7FA',
textSecondary: '#8E9098',
accent: '#B7FF2F',
};
const lightPalette = {
background: '#F7F8FB',
surface: '#FFFFFF',
border: '#E2E5ED',
textPrimary: '#0F1320',
textSecondary: '#5E6474',
accent: '#405CFF',
};
const styles = StyleSheet.create({
contentContainer: {
paddingBottom: 120,
paddingHorizontal: 12,
},
columnWrapper: {
justifyContent: 'space-between',
},
contentItem: {
flex: 1,
borderRadius: 12,
marginBottom: 12,
overflow: 'hidden',
marginHorizontal: 6,
},
mediaContainer: {
width: '100%',
aspectRatio: 1,
backgroundColor: '#000',
},
placeholderContainer: {
backgroundColor: '#1A1A1A',
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: {
fontSize: 48,
},
mediaImage: {
width: '100%',
height: '100%',
},
mediaVideo: {
width: '100%',
height: '100%',
},
contentInfo: {
padding: 12,
},
contentTitle: {
fontSize: 15,
fontWeight: '600',
marginBottom: 8,
},
contentMeta: {
flexDirection: 'row',
alignItems: 'center',
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 12,
},
metaText: {
fontSize: 12,
marginLeft: 4,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
statusText: {
fontSize: 11,
fontWeight: '600',
textTransform: 'capitalize' as const,
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 12,
},
loadingMoreText: {
fontSize: 14,
},
noMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
},
noMoreText: {
fontSize: 14,
},
});