424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
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',
|
||
},
|
||
})
|