expo-popcore-app/app/generateVideo.tsx

427 lines
14 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
TextInput,
StatusBar as RNStatusBar,
Platform,
KeyboardAvoidingView,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { LinearGradient } from 'expo-linear-gradient'
import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon'
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
import { StartGeneratingNotification } from '@/components/ui'
import { useTemplateActions } from '@/hooks/use-template-actions'
import { useTemplateDetail } from '@/hooks/use-template-detail'
import { uploadFile } from '@/lib/uploadFile'
import Toast from '@/components/ui/Toast'
const { height: screenHeight } = Dimensions.get('window')
export default function GenerateVideoScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const { runTemplate, loading } = useTemplateActions()
const { data: templateDetail, loading: templateLoading, execute: fetchTemplate } = useTemplateDetail()
const [description, setDescription] = useState('')
const [uploadedImageUrl, setUploadedImageUrl] = useState('')
const [previewImageUri, setPreviewImageUri] = useState('')
const [drawerVisible, setDrawerVisible] = useState(false)
const [showNotification, setShowNotification] = useState(false)
useEffect(() => {
if (params.templateId && typeof params.templateId === 'string') {
fetchTemplate({ id: params.templateId })
}
}, [params.templateId, fetchTemplate])
useEffect(() => {
if (templateDetail) {
if (templateDetail.thumbnailUrl) {
setPreviewImageUri(templateDetail.thumbnailUrl)
}
}
}, [templateDetail])
const startNodes = useMemo(() => templateDetail?.formSchema?.startNodes || [], [templateDetail])
const hasImageNode = useMemo(() => startNodes.some((node) => node.type === 'image'), [startNodes])
const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
try {
setPreviewImageUri(imageUri)
const url = await uploadFile({ uri: imageUri, mimeType, fileName })
setUploadedImageUrl(url)
} catch (error) {
console.error('Upload failed:', error)
} finally {
setDrawerVisible(false)
}
}, [])
const handleGenerate = useCallback(async () => {
if (!templateDetail) return
if (hasImageNode && !uploadedImageUrl) {
Toast.show({ title: t('generateVideo.pleaseUploadImage') || '请上传参考图片' })
return
}
Toast.showLoading()
try {
const data: Record<string, string> = {}
startNodes.forEach((node) => {
data[node.id] = node.type === 'text' ? description : uploadedImageUrl
})
const { generationId, error } = await runTemplate({
templateId: templateDetail.id,
data,
})
Toast.hideLoading()
if (error) {
Toast.show({ title: error.message || t('generateVideo.generateFailed') || '生成失败' })
return
}
if (generationId) {
setShowNotification(true)
setTimeout(() => {
setShowNotification(false)
router.back()
}, 3000)
}
} catch (error) {
Toast.hideLoading()
Toast.show({ title: t('generateVideo.generateFailed') || '生成失败' })
}
}, [templateDetail, uploadedImageUrl, description, runTemplate, router, t, hasImageNode, startNodes])
return (
<SafeAreaView
style={styles.container}
edges={Platform.OS === 'ios' ? ['top'] : ['top', 'bottom']}
>
<StatusBar style="light" />
{Platform.OS === 'android' && (
<RNStatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
)}
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* 顶部导航栏 */}
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
>
<LeftArrowIcon />
</Pressable>
</View>
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.title}> {templateDetail?.title}</Text>
<Text style={styles.subtitle}>
{templateDetail?.title }
</Text>
</View>
<View style={styles.uploadContainer}>
{previewImageUri ? (
<Image
source={previewImageUri}
style={styles.uploadedImage}
contentFit="cover"
/>
) : (
<View style={styles.avatarPlaceholder}>
<View style={styles.avatarCircle} />
</View>
)}
<View style={styles.thumbnailContainer}>
<Image
source={templateDetail?.thumbnailUrl}
style={styles.thumbnail}
contentFit="cover"
/>
</View>
</View>
{/* 上传区域 */}
<Pressable
style={styles.uploadReferenceButton}
onPress={() => {
setDrawerVisible(true)
}}
>
<UploadIcon />
<Text style={styles.uploadReferenceText}>{t('generateVideo.uploadReference')}</Text>
</Pressable>
{/* 描述输入区域 */}
<TextInput
style={styles.descriptionInput}
value={description}
onChangeText={setDescription}
placeholder={t('generateVideo.descriptionPlaceholder')}
placeholderTextColor="#8A8A8A"
multiline
numberOfLines={4}
textAlignVertical="top"
/>
{/* 底部生成按钮 */}
<View style={styles.generateButtonContainer}>
<Pressable
onPress={handleGenerate}
disabled={loading}
>
<LinearGradient
colors={['#9966FF', '#FF6699', '#FF9966']}
locations={[0.0015, 0.4985, 0.9956]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={[styles.generateButton, loading && styles.generateButtonDisabled]}
>
<Text style={styles.generateButtonText}>{loading ? (t('generateVideo.generating') || '生成中...') : (t('generateVideo.generate') || '生成')}</Text>
<View style={styles.pointsBadge}>
<WhitePointsIcon />
<Text style={styles.pointsText}>{templateDetail?.price || 10}</Text>
</View>
</LinearGradient>
</Pressable>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<UploadReferenceImageDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onSelectImage={handleSelectImage}
/>
{/* 通知组件 */}
{showNotification && (
<View style={styles.notificationContainer}>
<StartGeneratingNotification
count={1}
visible={showNotification}
title={t('generateVideo.startGenerating')}
message={t('generateVideo.generatingMessage')}
onPress={() => setShowNotification(false)}
style={styles.notification}
/>
</View>
)}
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
flexGrow: 1,
backgroundColor: '#090A0B',
},
generateButtonContainer: {
marginTop: 'auto',
paddingTop: 12,
paddingBottom: Platform.select({
ios: 10,
android: 10,
default: 10,
}),
},
content: {
paddingHorizontal: 12,
paddingTop: 16,
backgroundColor: '#1C1E20',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
gap: 8,
minHeight: Platform.select({
ios: screenHeight - 100,
android: screenHeight - 100,
default: screenHeight - 60,
}),
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: Platform.select({
ios: 17,
android: 12,
default: 17,
}),
paddingHorizontal: 12,
paddingBottom: 20,
},
backButton: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
},
titleSection: {
paddingHorizontal:4,
marginBottom: 11,
},
title: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '600',
marginBottom: 5,
marginLeft: -4,
},
subtitle: {
color: '#CCCCCC',
fontSize: 12,
},
uploadContainer: {
height: 140,
borderRadius: 12,
backgroundColor: '#262A31',
overflow: 'hidden',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
},
thumbnailContainer: {
position: 'absolute',
left: 8,
bottom: 5,
width: 56,
height: 56,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#FFFFFF',
backgroundColor: '#090A0B',
},
thumbnail: {
width: '100%',
height: '100%',
},
avatarPlaceholder: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#2F3134',
alignItems: 'center',
justifyContent: 'center',
},
avatarCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#4A4C4F',
},
uploadedImage: {
width: '100%',
height: '100%',
},
uploadReferenceButton: {
height: 110,
backgroundColor: '#262A31',
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
uploadReferenceText: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
},
descriptionInput: {
minHeight: 150,
backgroundColor: '#262A31',
borderRadius: 12,
padding: 12,
color: '#F5F5F5',
fontSize: 14,
lineHeight: 20,
...Platform.select({
android: {
textAlignVertical: 'top',
},
}),
},
generateButton: {
width: '100%',
height: 48,
backgroundColor: 'red',
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
generateButtonText: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '500',
},
pointsBadge: {
flexDirection: 'row',
alignItems: 'center',
},
pointsText: {
color: '#f5f5f5',
fontSize: 14,
fontWeight: '500',
},
notificationContainer: {
position: 'absolute',
top: Platform.select({
ios: 60,
android: 50,
default: 60,
}),
left: 0,
right: 0,
paddingHorizontal: 8,
zIndex: 1000,
},
notification: {
width: '100%',
},
generateButtonDisabled: {
opacity: 0.6,
},
})