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

424 lines
14 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,
Platform,
Animated,
} 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 { Ionicons } from '@expo/vector-icons'
import { SameStyleIcon, WhiteStarIcon } from '@/components/icon'
import { useRouter } from 'expo-router'
import { useTemplates, type TemplateDetail } from '@/hooks'
import { VideoSocialButton } from '@/components/blocks/ui/VideoSocialButton'
import { useTemplateLike } from '@/hooks/use-template-like'
import { useTemplateFavorite } from '@/hooks/use-template-favorite'
import {
useTemplateSocialStore,
useTemplateLiked,
useTemplateFavorited,
useTemplateLikeCount,
useTemplateFavoriteCount,
} from '@/stores/templateSocialStore'
import LoadingState from '@/components/LoadingState'
import ErrorState from '@/components/ErrorState'
import PaginationLoader from '@/components/PaginationLoader'
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 EmptyState = () => (
<Layout>
<View style={styles.centerContainer}>
<Text style={styles.messageText}></Text>
</View>
</Layout>
)
// 计算图片显示尺寸
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 [showHeartAnimation, setShowHeartAnimation] = useState(false)
const heartScale = useRef(new Animated.Value(0)).current
const heartOpacity = useRef(new Animated.Value(0)).current
const lastTap = useRef(0)
// 从 store 获取状态(用于乐观更新)
const storeLiked = useTemplateLiked(item.id)
const storeFavorited = useTemplateFavorited(item.id)
const storeLikeCount = useTemplateLikeCount(item.id)
const storeFavoriteCount = useTemplateFavoriteCount(item.id)
// 合并策略Store 优先(乐观更新),否则使用 API 数据
const liked = storeLiked ?? item.isLiked ?? false
const favorited = storeFavorited ?? item.isFavorited ?? false
const likeCount = storeLikeCount ?? item.likeCount ?? 0
const favoriteCount = storeFavoriteCount ?? item.favoriteCount ?? 0
// 使用 hooks 获取操作函数
const { like: onLike, unlike: onUnlike, loading: likeLoading } = useTemplateLike(item.id, item.likeCount)
const { favorite: onFavorite, unfavorite: onUnfavorite, loading: favoriteLoading } = useTemplateFavorite(item.id, false, item.favoriteCount)
const handleImageLoad = useCallback((event: ImageLoadEventData) => {
const { source } = event
if (source?.width && source?.height) {
setImageSize({ width: source.width, height: source.height })
}
}, [])
// 双击处理
const handleDoubleTap = useCallback(() => {
const now = Date.now()
if (now - lastTap.current < 300) {
// 双击检测成功
lastTap.current = 0
// 显示心形动画
setShowHeartAnimation(true)
heartScale.setValue(0)
heartOpacity.setValue(1)
Animated.parallel([
Animated.timing(heartScale, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(heartOpacity, {
toValue: 0,
duration: 800,
delay: 200,
useNativeDriver: true,
}),
]).start(() => {
setShowHeartAnimation(false)
})
// 触发点赞/取消点赞
if (liked) {
onUnlike()
} else {
onLike()
}
} else {
lastTap.current = now
}
}, [liked, onLike, onUnlike, heartScale, heartOpacity])
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} testID="video-wrapper">
<Pressable onPress={handleDoubleTap} style={styles.videoPressable}>
{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>
</Pressable>
{/* 双击心形动画 */}
{showHeartAnimation && (
<Animated.View
testID="double-tap-heart"
style={[
styles.heartAnimation,
{
transform: [{ scale: heartScale }],
opacity: heartOpacity,
},
]}
>
<Ionicons name="heart" size={80} color="#FF3B30" />
</Animated.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>
{/* 社交按钮 */}
<VideoSocialButton
templateId={item.id}
liked={liked}
favorited={favorited}
likeCount={likeCount}
favoriteCount={favoriteCount}
loading={likeLoading || favoriteLoading}
onLike={onLike}
onUnlike={onUnlike}
onFavorite={onFavorite}
onUnfavorite={onUnfavorite}
testID="video-social-button"
/>
</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,
})
useEffect(() => {
execute()
}, [execute])
const handleRefresh = useCallback(async () => {
await refetch()
}, [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(`[VIDEO PAGE] Filtering ${item.title}:`, {
webpHighPreviewUrl: item.webpHighPreviewUrl,
webpPreviewUrl: item.webpPreviewUrl,
previewUrl: item.previewUrl,
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 (
<Layout>
<LoadingState color="#FFE500" message="加载中..." />
</Layout>
)
}
if (error && templates.length === 0) {
return (
<Layout>
<ErrorState message="加载失败,请下拉刷新重试" onRetry={handleRefresh} />
</Layout>
)
}
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"
onEndReached={handleLoadMore}
onEndReachedThreshold={0.1}
ListFooterComponent={loading ? <PaginationLoader color="#FFE500" /> : 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',
},
videoContainer: {
width: screenWidth,
position: 'relative',
},
videoWrapper: {
flex: 1,
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
},
videoPressable: {
flex: 1,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
},
heartAnimation: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -40,
marginLeft: -40,
width: 80,
height: 80,
justifyContent: 'center',
alignItems: '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',
},
})