Files
expo-popcore-app/app/(tabs)/video.tsx
imeepos fce99a57bf fix: 修复所有 TypeScript 类型错误
- 修复 useTemplates hook 中缺失的必需参数 (page, limit)
- 修复 searchResults 中 execute 和 refetch 的参数类型
- 修复 aspectRatio 类型从 string 转换为 number
- 修复 loadTags 中缺失的必需参数
- 移除 useTags 中不存在的 isActive 属性
- 修复 useTemplateActions 返回值类型

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 14:14:05 +08:00

297 lines
8.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 { useState, useRef, useEffect, useCallback, memo } from 'react'
import {
View,
Text,
StyleSheet,
Dimensions,
FlatList,
Pressable,
StatusBar as RNStatusBar,
ActivityIndicator,
RefreshControl,
} 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 [isPlaying, setIsPlaying] = useState(false)
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)
return (
<View style={[styles.videoContainer, { height: videoHeight }]}>
<View style={styles.videoWrapper}>
<Image
source={{ uri: item.previewUrl }}
style={imageStyle}
contentFit="contain"
onLoad={handleImageLoad}
/>
{!isPlaying && (
<Pressable style={styles.playButton} onPress={() => setIsPlaying(true)}>
<View style={styles.playIcon}>
<View style={styles.playTriangle} />
</View>
</Pressable>
)}
<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 { templates, loading, error, execute, refetch, loadMore, hasMore } = useTemplates({
sortBy: 'likeCount',
sortOrder: 'desc',
page: 1,
limit: 20,
})
const [refreshing, setRefreshing] = useState(false)
useEffect(() => {
execute()
}, [execute])
const handleRefresh = useCallback(async () => {
setRefreshing(true)
await refetch()
setRefreshing(false)
}, [refetch])
const handleLoadMore = useCallback(() => {
if (!hasMore || loading) return
loadMore()
}, [hasMore, loading, loadMore])
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.5}
ListFooterComponent={loading ? <FooterLoading /> : null}
/>
</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',
},
playButton: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
playIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
alignItems: 'center',
justifyContent: 'center',
},
playTriangle: {
width: 0,
height: 0,
borderLeftWidth: 18,
borderTopWidth: 11,
borderBottomWidth: 11,
borderLeftColor: '#000000',
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
marginLeft: 3,
},
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',
},
})