Files
expo-popcore-app/app/(tabs)/video.tsx
imeepos 1f9b6e22d6 feat: 添加测试框架和优化项目结构
- 添加 Jest 配置和测试设置
- 添加 use-categories hook 的单元测试
- 更新 CLAUDE.md 添加包管理工具说明
- 优化首页、视频页和频道页的组件结构
- 添加 .claude 命令配置文件
- 移除 bun.lock 和 package-lock.json,统一使用 bun
- 更新 package.json 依赖

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 10:53:54 +08:00

291 lines
8.8 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 { 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 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])
// 过滤掉视频类型的 item
const filteredTemplates = templates.filter((item: TemplateDetail) => {
const displayUrl = item.webpPreviewUrl || item.previewUrl
return !(displayUrl && isVideoUrl(displayUrl))
})
if (loading && templates.length === 0) return <LoadingState />
if (error && templates.length === 0) return <ErrorState />
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',
},
})