🐛 修复视频播放器无限循环渲染问题

## 主要修复
- 修复 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>
This commit is contained in:
imeepos
2025-11-10 11:58:56 +08:00
parent af64049c69
commit eee280d9b3
26 changed files with 1425 additions and 885 deletions

View File

@@ -1,6 +1,18 @@
import { router, useLocalSearchParams } from 'expo-router';
import React, { useMemo, useState } from 'react';
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { BackButton } from '@/components/ui/back-button';
import { getTagByName, type TagTemplate } from '@/lib/api/tags';
import { getTemplateById } from '@/lib/api/templates';
import Feather from '@expo/vector-icons/Feather';
import { Stack, router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
type TemplateFrame = {
@@ -10,44 +22,32 @@ type TemplateFrame = {
accent: string;
};
const curatedLooks: TemplateFrame[] = [
{
id: 'earth-zoom',
title: 'Earth Zoom Our',
uri: 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1200&q=80',
accent: '#D1FF00',
},
{
id: 'midnight-atelier',
title: 'Midnight Atelier',
uri: 'https://images.unsplash.com/photo-1500336624523-d727130c3328?auto=format&fit=crop&w=1200&q=80',
accent: '#FF7A45',
},
{
id: 'flora-couture',
title: 'Flora Couture',
uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80',
accent: '#7A8BFF',
},
{
id: 'noir-silhouette',
title: 'Noir Silhouette',
uri: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80',
accent: '#FF5F6D',
},
{
id: 'aurora-thread',
title: 'Aurora Thread',
uri: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=1200&q=80',
accent: '#21D4FD',
},
{
id: 'velvet-echo',
title: 'Velvet Echo',
uri: 'https://images.unsplash.com/photo-1512436991641-6745cdb1723f?auto=format&fit=crop&w=1200&q=80',
accent: '#FFAA00',
},
];
type TemplateLike = {
id: string;
title?: string | null;
titleEn?: string | null;
coverImageUrl?: string | null;
previewUrl?: string | null;
};
const ACCENT_COLORS = ['#D1FF00', '#FF7A45', '#7A8BFF', '#FF5F6D', '#21D4FD', '#FFAA00'];
const FALLBACK_PREVIEW =
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
const createFrame = (template: TemplateLike, index = 0): TemplateFrame => {
const title = (template.titleEn || template.title || '').trim() || 'Untitled Template';
const uri = template.coverImageUrl || template.previewUrl || FALLBACK_PREVIEW;
return {
id: template.id,
title,
uri,
accent: ACCENT_COLORS[index % ACCENT_COLORS.length],
};
};
const mapTagTemplates = (templates: TagTemplate[]): TemplateFrame[] =>
templates.map((template, index) => createFrame(template, index));
const quickActions = [
{ id: 'upscale', label: 'Upscale' },
@@ -57,73 +57,196 @@ const quickActions = [
export default function TemplateDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [selectedId, setSelectedId] = useState<string>(curatedLooks[0].id);
const [menuVisible, setMenuVisible] = useState(true);
const [looks, setLooks] = useState<TemplateFrame[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [menuVisible, setMenuVisible] = useState(false);
const [loadingLooks, setLoadingLooks] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const loadByTagName = async (tagName?: string | null) => {
if (!tagName) {
return [];
}
try {
const response = await getTagByName(tagName);
if (response.success && response.data?.templates?.length) {
return mapTagTemplates(response.data.templates);
}
} catch (tagError) {
console.warn('Failed to load tag templates:', tagError);
}
return [];
};
const hydrateLooks = async () => {
if (!id || typeof id !== 'string') {
setLooks([]);
setSelectedId(null);
setErrorMessage('Missing template identifier');
setLoadingLooks(false);
return;
}
setLoadingLooks(true);
setErrorMessage(null);
let frames = await loadByTagName(id);
let fallbackTemplateId: string | null = id;
if (frames.length === 0) {
try {
const templateResponse = await getTemplateById(id);
if (templateResponse.success && templateResponse.data) {
const template = templateResponse.data;
fallbackTemplateId = template.id;
const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
const relatedFrames = await loadByTagName(primaryTagName);
frames = relatedFrames.length > 0 ? relatedFrames : [createFrame(template, 0)];
}
} catch (templateError) {
console.warn('Failed to fetch template detail:', templateError);
}
}
if (!isMounted) {
return;
}
if (frames.length === 0) {
setErrorMessage('No templates available');
}
setLooks(frames);
const preferredId =
frames.find(frame => frame.id === id)?.id ??
frames[0]?.id ??
fallbackTemplateId ??
null;
setSelectedId(preferredId);
setLoadingLooks(false);
};
hydrateLooks();
return () => {
isMounted = false;
};
}, [id]);
const current = useMemo(
() => curatedLooks.find(look => look.id === selectedId) ?? curatedLooks[0],
[selectedId],
() => {
if (looks.length === 0) {
return null;
}
if (!selectedId) {
return looks[0];
}
return looks.find(look => look.id === selectedId) ?? looks[0];
},
[looks, selectedId],
);
const resolvedTemplateId = typeof id === 'string' && id.length > 0 ? id : current.id;
const resolvedTemplateId = current?.id ?? (typeof id === 'string' ? id : '');
const heroImageUri = current?.uri ?? FALLBACK_PREVIEW;
const infoTitle = current?.title ?? 'Loading template';
const handleGenerate = () => {
if (!resolvedTemplateId) {
return;
}
router.push(`/templates/${resolvedTemplateId}/form`);
};
return (
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.headerBar}>
<BackButton onPress={() => router.back()} />
<Text style={styles.headerTitle} numberOfLines={1}>
{infoTitle}
</Text>
<View style={styles.headerPlaceholder} />
</View>
<ScrollView
style={styles.body}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{loadingLooks && (
<View style={styles.loadingState}>
<ActivityIndicator color="#D1FF00" />
<Text style={styles.loadingLabel}>Loading templates...</Text>
</View>
)}
{!loadingLooks && errorMessage && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{errorMessage}</Text>
</View>
)}
<View style={styles.topCarousel}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.carouselContent}
>
{curatedLooks.map(look => {
const isActive = look.id === selectedId;
return (
<TouchableOpacity
key={look.id}
activeOpacity={0.85}
onPress={() => setSelectedId(look.id)}
style={[
styles.previewFrame,
{ borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
isActive && styles.previewFrameActive,
]}
>
<Image source={{ uri: look.uri }} style={styles.previewImage} />
</TouchableOpacity>
);
})}
</ScrollView>
{looks.length > 0 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.carouselContent}
>
{looks.map(look => {
const isActive = look.id === selectedId;
return (
<TouchableOpacity
key={look.id}
activeOpacity={0.85}
onPress={() => setSelectedId(look.id)}
style={[
styles.previewFrame,
{ borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
isActive && styles.previewFrameActive,
]}
>
<Image source={{ uri: look.uri }} style={styles.previewImage} />
</TouchableOpacity>
);
})}
</ScrollView>
) : (
!loadingLooks && (
<View style={styles.emptyCarousel}>
<Text style={styles.emptyCarouselText}>Nothing to show yet</Text>
</View>
)
)}
</View>
<View style={styles.heroStage}>
<Image source={{ uri: current.uri }} style={styles.heroImage} />
<Image source={{ uri: heroImageUri }} style={styles.heroImage} />
</View>
<View style={styles.bottomBar}>
<View style={styles.bottomInfoRow}>
<View style={styles.infoCard}>
<Image source={{ uri: current.uri }} style={styles.infoThumbnail} />
<Image source={{ uri: heroImageUri }} style={styles.infoThumbnail} />
<Text style={styles.infoTitle} numberOfLines={1}>
{current.title}
{infoTitle}
</Text>
</View>
<TouchableOpacity
style={styles.generateButton}
activeOpacity={0.9}
onPress={handleGenerate}
>
<Text style={styles.generateLabel}>Generate Video</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.menuTrigger}
activeOpacity={0.85}
@@ -136,6 +259,19 @@ export default function TemplateDetailScreen() {
</View>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[
styles.generateButton,
!resolvedTemplateId && styles.generateButtonDisabled,
]}
activeOpacity={0.9}
onPress={handleGenerate}
disabled={!resolvedTemplateId}
>
<Feather name="star" size={20} color="#050505" />
<Text style={styles.generateLabel}>Generate Video</Text>
</TouchableOpacity>
</ScrollView>
{menuVisible && (
@@ -154,6 +290,7 @@ export default function TemplateDetailScreen() {
)}
</SafeAreaView>
</View>
</>
);
}
@@ -165,9 +302,59 @@ const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
body: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 24,
paddingBottom: 120,
paddingBottom: 180,
},
loadingState: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 24,
paddingVertical: 8,
},
loadingLabel: {
fontSize: 13,
color: 'rgba(255, 255, 255, 0.7)',
},
errorBanner: {
marginHorizontal: 24,
marginTop: 8,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 16,
backgroundColor: 'rgba(255, 94, 94, 0.12)',
borderWidth: 1,
borderColor: 'rgba(255, 94, 94, 0.3)',
},
errorText: {
color: '#FF8A8A',
fontSize: 13,
fontWeight: '600',
},
headerBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 24,
paddingTop: 8,
paddingBottom: 8,
gap: 12,
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
letterSpacing: 0.4,
},
headerPlaceholder: {
width: 48,
height: 48,
},
topCarousel: {
marginTop: 12,
@@ -175,6 +362,15 @@ const styles = StyleSheet.create({
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
emptyCarousel: {
alignItems: 'center',
justifyContent: 'center',
height: 80,
},
emptyCarouselText: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 13,
},
carouselContent: {
paddingHorizontal: 12,
alignItems: 'center',
@@ -211,39 +407,48 @@ const styles = StyleSheet.create({
height: 456,
borderRadius: 36,
},
bottomBar: {
bottomInfoRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 32,
},
infoCard: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
gap: 12,
},
infoThumbnail: {
width: 32,
height: 32,
borderRadius: 10,
marginRight: 12,
marginRight: 0,
},
infoTitle: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
maxWidth: 120,
flex: 1,
flexShrink: 1,
},
generateButton: {
flex: 1,
width: '100%',
height: 56,
borderRadius: 28,
backgroundColor: '#D1FF00',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 10,
marginTop: 18,
},
generateButtonDisabled: {
opacity: 0.6,
},
generateLabel: {
fontSize: 16,
@@ -275,7 +480,9 @@ const styles = StyleSheet.create({
menuPanel: {
position: 'absolute',
right: 24,
bottom: 160,
bottom: 200,
zIndex: 50,
elevation: 24,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 20,

View File

@@ -1,3 +1,7 @@
import { Feather } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { UploadCloud } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
@@ -11,12 +15,13 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { ArrowLeft, UploadCloud } from 'lucide-react';
import { Feather } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
export const unstable_settings = {
headerShown: false,
};
import { DynamicForm } from '@/components/forms/dynamic-form';
import { BackButton } from '@/components/ui/back-button';
import { getTemplateById } from '@/lib/api/templates';
import { Template } from '@/lib/types/template';
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
@@ -28,7 +33,7 @@ interface Step1FormData {
script: string;
}
interface Step2FormData extends RunTemplateData {}
interface Step2FormData extends RunTemplateData { }
interface FormData {
step1: Step1FormData;
@@ -250,13 +255,7 @@ export default function TemplateFormScreen() {
showsVerticalScrollIndicator={false}
>
<View style={styles.topBar}>
<TouchableOpacity
activeOpacity={0.85}
onPress={() => router.back()}
style={styles.backButton}
>
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
</TouchableOpacity>
<BackButton onPress={() => router.back()} />
</View>
<Text style={styles.heroTitle}>
@@ -329,13 +328,7 @@ export default function TemplateFormScreen() {
const renderStep2 = () => (
<View style={styles.step2Wrapper}>
<View style={styles.topBar}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleStep2Prev}
style={styles.backButton}
>
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
</TouchableOpacity>
<BackButton onPress={handleStep2Prev} />
<Text style={styles.stepIndicator}>STEP 2 OF 2</Text>
</View>
@@ -406,24 +399,32 @@ export default function TemplateFormScreen() {
if (loading) {
return (
<View style={styles.loadingCanvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#D1FF00" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
</SafeAreaView>
</View>
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.loadingCanvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#D1FF00" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
</SafeAreaView>
</View>
</>
);
}
return (
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
{currentStep === 1 ? renderStep1() : renderStep2()}
</SafeAreaView>
{renderBottomActions()}
</View>
<>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
{currentStep === 1 ? renderStep1() : renderStep2()}
</SafeAreaView>
{renderBottomActions()}
</View>
</>
);
}
@@ -461,17 +462,10 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 28,
},
backButton: {
width: 48,
height: 48,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(18, 18, 18, 0.78)',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 0,
paddingTop: 8,
paddingBottom: 8,
gap: 12,
},
stepIndicator: {
flex: 1,
@@ -512,9 +506,6 @@ const styles = StyleSheet.create({
width: 74,
height: 74,
borderRadius: 24,
borderWidth: 2,
borderColor: '#050505',
backgroundColor: '#050505',
overflow: 'hidden',
},
previewInsetImage: {