279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, memo } from 'react'
|
||
import {
|
||
View,
|
||
Text,
|
||
StyleSheet,
|
||
Dimensions,
|
||
FlatList,
|
||
Pressable,
|
||
StatusBar as RNStatusBar,
|
||
ActivityIndicator,
|
||
RefreshControl,
|
||
Platform,
|
||
} from 'react-native'
|
||
import { StatusBar } from 'expo-status-bar'
|
||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||
import { Image, ImageLoadEventData } from 'expo-image'
|
||
import { useTranslation } from 'react-i18next'
|
||
|
||
import { SameStyleIcon, WhiteStarIcon } from '@/components/icon'
|
||
import { useRouter } from 'expo-router'
|
||
import { useTemplates, type TemplateDetail } from '@/hooks'
|
||
|
||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
|
||
const TAB_BAR_HEIGHT = 83
|
||
|
||
// 统一的布局组件
|
||
const Layout = ({ children }: { children: React.ReactNode }) => (
|
||
<SafeAreaView style={styles.container} edges={[]}>
|
||
<StatusBar style="light" />
|
||
<RNStatusBar barStyle="light-content" />
|
||
{children}
|
||
</SafeAreaView>
|
||
)
|
||
|
||
const LoadingState = () => (
|
||
<Layout>
|
||
<View style={styles.centerContainer}>
|
||
<ActivityIndicator size="large" color="#FFE500" />
|
||
<Text style={styles.messageText}>加载中...</Text>
|
||
</View>
|
||
</Layout>
|
||
)
|
||
|
||
const ErrorState = () => (
|
||
<Layout>
|
||
<View style={styles.centerContainer}>
|
||
<Text style={styles.messageText}>加载失败,请下拉刷新重试</Text>
|
||
</View>
|
||
</Layout>
|
||
)
|
||
|
||
const FooterLoading = () => (
|
||
<View style={styles.footerLoading}>
|
||
<ActivityIndicator size="small" color="#FFE500" />
|
||
</View>
|
||
)
|
||
|
||
// 计算图片显示尺寸
|
||
const calculateImageSize = (
|
||
imageSize: { width: number; height: number } | null,
|
||
videoHeight: number
|
||
) => {
|
||
if (!imageSize) return { width: screenWidth, height: videoHeight }
|
||
|
||
const imageAspectRatio = imageSize.width / imageSize.height
|
||
const screenAspectRatio = screenWidth / videoHeight
|
||
|
||
return imageAspectRatio > screenAspectRatio
|
||
? { width: screenWidth, height: screenWidth / imageAspectRatio }
|
||
: { width: videoHeight * imageAspectRatio, height: videoHeight }
|
||
}
|
||
|
||
const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; videoHeight: number }) => {
|
||
const { t } = useTranslation()
|
||
const router = useRouter()
|
||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
|
||
|
||
const handleImageLoad = useCallback((event: ImageLoadEventData) => {
|
||
const { source } = event
|
||
if (source?.width && source?.height) {
|
||
setImageSize({ width: source.width, height: source.height })
|
||
}
|
||
}, [])
|
||
|
||
const handlePress = useCallback(() => {
|
||
router.push({
|
||
pathname: '/generateVideo' as any,
|
||
params: { template: JSON.stringify(item) },
|
||
})
|
||
}, [router, item])
|
||
|
||
const imageStyle = calculateImageSize(imageSize, videoHeight)
|
||
|
||
// 优先使用 WebP 格式(支持动画),回退到普通预览图
|
||
const displayUrl = item.webpPreviewUrl || item.previewUrl
|
||
|
||
return (
|
||
<View style={[styles.videoContainer, { height: videoHeight }]}>
|
||
<View style={styles.videoWrapper}>
|
||
{displayUrl && (
|
||
<Image
|
||
source={{ uri: displayUrl }}
|
||
style={imageStyle}
|
||
contentFit="contain"
|
||
onLoad={handleImageLoad}
|
||
/>
|
||
)}
|
||
<View style={styles.thumbnailContainer}>
|
||
<Image source={{ uri: item.coverImageUrl }} style={styles.thumbnail} contentFit="cover" />
|
||
</View>
|
||
</View>
|
||
<Pressable style={styles.actionButtonLeft}>
|
||
<WhiteStarIcon />
|
||
<Text style={styles.actionButtonTextTitle}>{item.title}</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.actionButtonRight} onPress={handlePress}>
|
||
<SameStyleIcon />
|
||
<Text style={styles.actionButtonText}>{t('video.makeSame')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
)
|
||
})
|
||
|
||
export default function VideoScreen() {
|
||
const flatListRef = useRef<FlatList>(null)
|
||
const videoHeight = screenHeight - TAB_BAR_HEIGHT
|
||
const PAGE_SIZE = 10 // 每页10个数据
|
||
const { templates, loading, error, execute, refetch, loadMore, hasMore } = useTemplates({
|
||
sortBy: 'likeCount',
|
||
sortOrder: 'desc',
|
||
page: 1,
|
||
limit: PAGE_SIZE,
|
||
})
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
useEffect(() => {
|
||
execute()
|
||
}, [execute])
|
||
|
||
const handleRefresh = useCallback(async () => {
|
||
setRefreshing(true)
|
||
await refetch()
|
||
setRefreshing(false)
|
||
}, [refetch])
|
||
|
||
const handleLoadMore = useCallback(() => {
|
||
console.log('onEndReached triggered', { hasMore, loading, templatesLength: templates.length })
|
||
if (!hasMore || loading) {
|
||
console.log('LoadMore blocked', { hasMore, loading })
|
||
return
|
||
}
|
||
console.log('Loading more...')
|
||
loadMore()
|
||
}, [hasMore, loading, loadMore, templates.length])
|
||
|
||
const renderItem = useCallback(({ item }: { item: TemplateDetail }) => (
|
||
<VideoItem item={item} videoHeight={videoHeight} />
|
||
), [videoHeight])
|
||
|
||
const keyExtractor = useCallback((item: TemplateDetail) => item.id, [])
|
||
|
||
const getItemLayout = useCallback((_: any, index: number) => ({
|
||
length: videoHeight,
|
||
offset: videoHeight * index,
|
||
index,
|
||
}), [videoHeight])
|
||
|
||
if (loading && templates.length === 0) return <LoadingState />
|
||
if (error && templates.length === 0) return <ErrorState />
|
||
|
||
return (
|
||
<Layout>
|
||
<FlatList
|
||
ref={flatListRef}
|
||
data={templates}
|
||
renderItem={renderItem}
|
||
keyExtractor={keyExtractor}
|
||
getItemLayout={getItemLayout}
|
||
pagingEnabled
|
||
showsVerticalScrollIndicator={false}
|
||
snapToInterval={videoHeight}
|
||
snapToAlignment="start"
|
||
decelerationRate="fast"
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={refreshing}
|
||
onRefresh={handleRefresh}
|
||
tintColor="#FFE500"
|
||
colors={['#FFE500']}
|
||
/>
|
||
}
|
||
onEndReached={handleLoadMore}
|
||
onEndReachedThreshold={0.1}
|
||
ListFooterComponent={loading ? <FooterLoading /> : null}
|
||
maxToRenderPerBatch={5}
|
||
windowSize={7}
|
||
initialNumToRender={3}
|
||
/>
|
||
</Layout>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: '#090A0B',
|
||
},
|
||
centerContainer: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 12,
|
||
},
|
||
messageText: {
|
||
color: '#FFFFFF',
|
||
fontSize: 14,
|
||
textAlign: 'center',
|
||
},
|
||
footerLoading: {
|
||
paddingVertical: 20,
|
||
alignItems: 'center',
|
||
},
|
||
videoContainer: {
|
||
width: screenWidth,
|
||
position: 'relative',
|
||
},
|
||
videoWrapper: {
|
||
flex: 1,
|
||
position: 'relative',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
thumbnailContainer: {
|
||
position: 'absolute',
|
||
left: 12,
|
||
bottom: 80,
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
borderWidth: 2,
|
||
borderColor: '#FFFFFF',
|
||
},
|
||
thumbnail: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
actionButtonLeft: {
|
||
position: 'absolute',
|
||
left: 12,
|
||
bottom: 16,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 8,
|
||
backgroundColor: '#191B1F',
|
||
borderRadius: 8,
|
||
borderWidth: 1,
|
||
borderColor: '#2F3134',
|
||
},
|
||
actionButtonRight: {
|
||
position: 'absolute',
|
||
right: 13,
|
||
bottom: 13,
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
},
|
||
actionButtonTextTitle: {
|
||
color: '#F5F5F5',
|
||
fontSize: 11,
|
||
},
|
||
actionButtonText: {
|
||
color: '#CCCCCC',
|
||
fontSize: 10,
|
||
fontWeight: '500',
|
||
},
|
||
})
|