Files
bw-expo-app/components/templates/template-card.tsx
imeepos eee280d9b3 🐛 修复视频播放器无限循环渲染问题
## 主要修复
- 修复 FullscreenMediaModal 和 FullscreenVideoModal 中的 useEffect 循环依赖
- 重构 VideoPlayer 组件的视频属性管理逻辑
- 优化 useVideoPlayer 的初始化回调机制

## 新增功能
- 新增标签 API 支持 (lib/api/tags.ts)
- 新增内容骨架屏组件 (components/profile/content-skeleton.tsx)
- 新增返回按钮组件 (components/ui/back-button.tsx)

## 改进优化
- 优化视频播放器的性能,避免重复初始化
- 修复 useEffect 依赖项导致的无限循环更新
- 完善类型定义和 API 接口

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 11:58:56 +08:00

290 lines
7.5 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 { FullscreenMediaModal } from '@/components/media/fullscreen-media-modal';
import { VideoPlayer } from '@/components/video/video-player';
import { useThemeColor } from '@/hooks/use-theme-color';
import { Template } from '@/lib/types/template';
import { isVideoTemplate } from '@/utils/media-utils';
import { Image } from 'expo-image';
// ResizeMode 兼容映射
const ResizeMode = {
CONTAIN: 'contain' as const,
COVER: 'cover' as const,
STRETCH: 'fill' as const,
};
import { useRouter } from 'expo-router';
import { useMemo, useState } from 'react';
import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
import { ThemedText } from '../themed-text';
import { ThemedView } from '../themed-view';
interface TemplateCardProps {
template: Template;
onPress?: (template: Template) => void;
allTemplates?: Template[];
currentIndex?: number;
onVideoChange?: (template: Template, index: number) => void;
}
export function TemplateCard({
template,
onPress,
allTemplates = [],
currentIndex = 0,
onVideoChange
}: TemplateCardProps) {
const router = useRouter();
const cardColor = useThemeColor({}, 'card');
const imagePlaceholderColor = useThemeColor({}, 'imagePlaceholder');
const [isMediaFullscreenVisible, setIsMediaFullscreenVisible] = useState(false);
const [currentMediaIndex, setCurrentMediaIndex] = useState(currentIndex);
const isVideo = useMemo(() => {
return isVideoTemplate(template);
}, [template]);
const getImageHeight = () => {
if (template.aspectRatio) {
const [width, height] = template.aspectRatio.split(':').map(Number);
if (width && height) {
return width > height ? 280 : 360;
}
}
return 280;
};
const mediaHeight = getImageHeight();
const handleUseTemplate = () => {
router.push(`/template/${template.id}/run`);
};
const handleFullscreenMedia = () => {
setIsMediaFullscreenVisible(true);
};
const handleMediaIndexChanged = (newIndex: number) => {
setCurrentMediaIndex(newIndex);
const newTemplate = allTemplates[newIndex];
onVideoChange?.(newTemplate, newIndex);
};
const handleCardPress = () => {
if (onPress) {
onPress(template);
} else {
handleFullscreenMedia();
}
};
// 获取当前媒体模板
const getCurrentMediaTemplate = () => {
return allTemplates[currentMediaIndex] || template;
};
const currentTemplate = getCurrentMediaTemplate();
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: cardColor }]}
onPress={handleCardPress}
activeOpacity={1}
>
<View style={styles.mediaContainer}>
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
{isVideo ? (
<VideoPlayer
source={{ uri: currentTemplate.previewUrl }}
poster={currentTemplate.coverImageUrl}
style={[styles.videoPlayer, styles.videoCover]}
resizeMode={ResizeMode.COVER}
shouldPlay={true}
isLooping={true}
isMuted={true}
useNativeControls={false}
showPoster={true}
autoPlay={true}
maxHeight={mediaHeight}
onPress={handleCardPress}
/>
) : (
<View style={styles.imageContainer}>
<Image
source={{ uri: template.coverImageUrl }}
style={[
styles.image,
{ backgroundColor: imagePlaceholderColor },
]}
contentFit="cover"
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
transition={200}
/>
</View>
)}
{template.category && (
<View style={styles.watermark} className='template-category'>
<View style={styles.categoryBadge}>
<ThemedText style={styles.categoryText}>
{template.category.name}
</ThemedText>
</View>
</View>
)}
{template.tags.length > 0 && (
<View style={styles.tagOverlay} className='template-tags'>
{template.tags.slice(0, 2).map((tag) => (
<View key={tag.id} style={styles.tagBadge}>
<ThemedText style={styles.tagText}>
{tag.name}
</ThemedText>
</View>
))}
</View>
)}
</ThemedView>
</View>
{/* 操作按钮区域 */}
<View style={styles.actionArea}>
<ThemedText style={styles.templateTitle} numberOfLines={1}>
{template.title}
</ThemedText>
<TouchableOpacity
style={styles.useButton}
onPress={handleUseTemplate}
activeOpacity={0.8}
>
<ThemedText style={styles.useButtonText}>使</ThemedText>
</TouchableOpacity>
</View>
{/* 统一全屏媒体模态框 */}
<FullscreenMediaModal
visible={isMediaFullscreenVisible}
onClose={() => setIsMediaFullscreenVisible(false)}
currentIndex={currentMediaIndex}
templates={allTemplates}
onIndexChanged={handleMediaIndexChanged}
/>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
flex: 1,
marginBottom: 14,
marginHorizontal: 5,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 4,
},
mediaContainer: {
width: '100%',
},
mediaContent: {
position: 'relative',
width: '100%',
minHeight: 280,
},
image: {
width: '100%',
height: '100%',
},
videoPlayer: {
width: '100%',
height: '100%',
},
videoCover: {
overflow: 'hidden',
width: '100%',
height: '100%',
// React Native中object-fit对应contentFit属性但在VideoPlayer中已经处理了
...Platform.select({
web: {
objectFit: 'cover',
},
}),
},
imageContainer: {
width: '100%',
height: '100%',
},
watermark: {
position: 'absolute',
top: 12,
left: 14,
zIndex: 10,
},
categoryBadge: {
backgroundColor: 'rgba(0, 0, 0, 0.15)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 100,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
categoryText: {
color: 'rgba(255, 255, 255, 0.85)',
fontSize: 10,
fontWeight: '400',
lineHeight: 6
},
tagOverlay: {
position: 'absolute',
bottom: 16,
left: 24,
right: 24,
zIndex: 10,
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
},
tagBadge: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 100,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
backdropFilter: 'blur(5px)',
},
tagText: {
color: '#fff',
fontSize: 11,
fontWeight: '500',
lineHeight: 4
},
actionArea: {
padding: 12,
backgroundColor: 'rgba(0, 0, 0, 0.02)',
},
templateTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
textAlign: 'center',
},
useButton: {
backgroundColor: '#4ECDC4',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
shadowColor: '#4ECDC4',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
},
useButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
},
});