418 lines
13 KiB
TypeScript
418 lines
13 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 liked = useTemplateLiked(item.id) ?? false
|
|
const favorited = useTemplateFavorited(item.id) ?? false
|
|
const likeCount = useTemplateLikeCount(item.id) ?? item.likeCount
|
|
const favoriteCount = useTemplateFavoriteCount(item.id) ?? item.favoriteCount
|
|
|
|
// 使用 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',
|
|
},
|
|
})
|