expo-popcore-app/app/(tabs)/video.tsx

322 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { 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
// 检查 URL 是否为视频格式
const isVideoUrl = (url: string): boolean => {
const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m3u8']
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
}
// 统一的布局组件
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 EmptyState = () => (
<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 }
}
export 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: { templateId: item.id },
})
}, [router, item.id])
const imageStyle = calculateImageSize(imageSize, videoHeight)
// 优先使用 WebP 格式(支持动画),回退到普通预览图
const displayUrl = item.webpHighPreviewUrl || 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])
// 过滤掉没有可用预览的 item
const filteredTemplates = templates.filter((item: TemplateDetail) => {
// 优先使用 WebP 格式(支持动画),回退到普通预览图
const displayUrl = item.webpHighPreviewUrl || item.webpPreviewUrl || item.previewUrl
// 只检查最终要显示的 URL 是否为视频格式
const isDisplayVideo = displayUrl && isVideoUrl(displayUrl)
// 有显示URL且不是视频格式才保留
const shouldKeep = !!displayUrl && !isDisplayVideo
console.log(`Filtering ${item.title}:`, {
displayUrl,
isDisplayVideo,
shouldKeep,
})
return shouldKeep
})
console.log('Video page state:', {
templatesLength: templates.length,
filteredTemplatesLength: filteredTemplates.length,
loading,
error,
hasMore,
})
if (loading && templates.length === 0) return <LoadingState />
if (error && templates.length === 0) return <ErrorState />
if (!loading && filteredTemplates.length === 0) return <EmptyState />
return (
<Layout>
<FlatList
ref={flatListRef}
data={filteredTemplates}
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',
},
})