bw-expo-app/app/result.tsx

580 lines
15 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 { ThemedView } from '@/components/themed-view';
import { VideoView, useVideoPlayer } from 'expo-video';
import * as MediaLibrary from 'expo-media-library';
import { Platform } from 'react-native';
import { ThemedText } from '@/components/themed-text';
// ResizeMode 兼容映射 - 移除 'stretch'expo-video 不支持
const ResizeMode = {
CONTAIN: 'contain' as const,
COVER: 'cover' as const,
STRETCH: 'fill' as const, // 使用 'fill' 替代 'stretch'
};
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Copy,
Download,
Maximize2,
Plus,
X,
ArrowLeft,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import {
Alert,
Dimensions,
Image,
ImageStyle,
Modal,
ScrollView,
StyleSheet,
StyleProp,
Text,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
const { width: screenWidth } = Dimensions.get('window');
const HERO_HEIGHT = screenWidth * 0.58;
const DETAIL_HEIGHT = screenWidth * 0.95;
interface ResultWithTemplate {
id: string;
userId: string;
templateId: string;
type: 'TEXT' | 'IMAGE' | 'VIDEO';
resultUrl: string[];
status: 'pending' | 'running' | 'completed' | 'failed';
creditsCost?: number;
creditsTransactionId?: string;
createdAt: string;
updatedAt: string;
templateName?: string;
templateThumbnail?: string;
templateDescription?: string;
}
export default function ResultPage() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const [result, setResult] = useState<ResultWithTemplate | null>(null);
const [fullscreenVisible, setFullscreenVisible] = useState(false);
const [fullscreenContent, setFullscreenContent] = useState<{
type: 'image' | 'video';
url: string;
} | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadResult();
}, [id]);
const loadResult = async () => {
try {
const typeParam = typeof id === 'string' ? id.split('_')[1] : 'text';
const resultId = typeof id === 'string' ? id : 'res1_text';
const getResultByType = () => {
switch (typeParam) {
case 'image':
return {
id: resultId,
userId: 'user1',
templateId: 'tpl2',
type: 'IMAGE' as const,
resultUrl: ['https://picsum.photos/800/600?random=1'],
status: 'completed' as const,
creditsCost: 20,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateName: 'AI图片生成',
templateThumbnail: 'https://picsum.photos/60/60?random=2',
templateDescription: '使用AI生成高质量图片',
};
case 'video':
return {
id: resultId,
userId: 'user1',
templateId: 'tpl3',
type: 'VIDEO' as const,
resultUrl: ['https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'],
status: 'completed' as const,
creditsCost: 50,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateName: 'AI视频生成',
templateThumbnail: 'https://picsum.photos/60/60?random=3',
templateDescription: '基于文本生成视频内容',
};
default:
return {
id: resultId,
userId: 'user1',
templateId: 'tpl1',
type: 'TEXT' as const,
resultUrl: [
'这是一段AI生成的营销文案专门为您的产品精心打造。它突出了产品的核心优势采用了吸引人的语言风格能够有效提升用户的购买欲望和品牌认知度。通过深入分析目标受众的需求和痛点我们精心设计了这份文案旨在最大化品牌影响力和转化效果。',
],
status: 'completed' as const,
creditsCost: 10,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateName: 'AI文案生成',
templateThumbnail: 'https://picsum.photos/60/60?random=1',
templateDescription: '基于关键词生成营销文案',
};
}
};
setResult(getResultByType());
} catch (error) {
Alert.alert('错误', '无法加载结果详情');
}
};
const handleRerun = () => {
router.push(`/templates/${result?.templateId}/form`);
};
const handleSaveToGallery = async () => {
if (!result || result.type === 'TEXT') return;
try {
setSaving(true);
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
return;
}
Alert.alert('保存成功', `${result.type === 'IMAGE' ? '图片' : '视频'}已保存到相册`);
} catch (error) {
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
} finally {
setSaving(false);
}
};
const handleCopyText = async () => {
if (!result || result.type !== 'TEXT') return;
try {
await navigator.clipboard?.writeText?.(result.resultUrl[0]);
Alert.alert('复制成功', '文本已复制到剪贴板');
} catch (error) {
Alert.alert('复制失败', '无法复制文本');
}
};
const openFullscreen = (type: 'image' | 'video', url: string) => {
setFullscreenContent({ type, url });
setFullscreenVisible(true);
};
const handleDownloadPress = () => {
if (!result) return;
if (result.type === 'TEXT') {
handleCopyText();
return;
}
handleSaveToGallery();
};
const renderVisualMedia = (
url: string,
mediaStyles: { image: StyleProp<ImageStyle>; video: StyleProp<ViewStyle> }
) => {
if (!url) return null;
if (result?.type === 'VIDEO') {
// 简化实现:使用原生 HTML5 video 或 Image 作为回退
if (Platform.OS === 'web') {
return (
<video
src={url}
style={{
width: '100%',
height: '100%',
objectFit: 'cover' as any
}}
muted
playsInline
/>
);
}
// 原生平台使用简化的 VideoView 实现
return (
<View style={mediaStyles.video}>
<ThemedText>Video: {url}</ThemedText>
</View>
);
}
return (
<Image
source={{ uri: url }}
style={mediaStyles.image}
resizeMode="cover"
/>
);
};
if (!result) {
return (
<ThemedView style={styles.screen}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
</ThemedView>
);
}
const isTextResult = result.type === 'TEXT';
const primaryPreviewUrl = result.resultUrl[0] ?? '';
return (
<ThemedView style={styles.screen}>
<View style={[styles.safeGuard, { paddingTop: insets.top || 16 }]} />
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<TouchableOpacity
onPress={() => router.back()}
activeOpacity={0.7}
style={styles.backButton}
>
<ArrowLeft size={22} color="#F6F6F8" />
</TouchableOpacity>
</View>
<Text style={styles.title}>
INSEAR YOUR PRODUCT INTO AN ASMR VIDEO
</Text>
<Text style={styles.subtitle}>
ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.
</Text>
<View style={styles.heroCard}>
{isTextResult ? (
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
<Text style={styles.generatedText}>{primaryPreviewUrl}</Text>
</ScrollView>
) : (
<>
{renderVisualMedia(primaryPreviewUrl, {
image: styles.heroMedia,
video: styles.heroMedia,
})}
{result.templateThumbnail && (
<View style={styles.referencePreview}>
<Image
source={{ uri: result.templateThumbnail }}
style={styles.referenceImage}
/>
</View>
)}
</>
)}
</View>
{!isTextResult && (
<TouchableOpacity
activeOpacity={0.85}
style={styles.detailCard}
onPress={() => openFullscreen(result.type === 'VIDEO' ? 'video' : 'image', primaryPreviewUrl)}
>
<View style={styles.expandIcon}>
<Maximize2 size={18} color="#F5F5F7" />
</View>
<View style={styles.detailMediaWrapper}>
{renderVisualMedia(primaryPreviewUrl, {
image: styles.detailMedia,
video: styles.detailMedia,
})}
</View>
</TouchableOpacity>
)}
</ScrollView>
<Modal
visible={fullscreenVisible}
transparent={false}
animationType="fade"
onRequestClose={() => setFullscreenVisible(false)}
>
<View style={styles.fullscreenContainer}>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setFullscreenVisible(false)}
>
<X size={30} color="#FFFFFF" />
</TouchableOpacity>
{fullscreenContent && (
<View style={styles.fullscreenMedia}>
{fullscreenContent.type === 'image' ? (
<Image
source={{ uri: fullscreenContent.url }}
style={styles.fullscreenMediaContent}
resizeMode="contain"
/>
) : (
<>
{Platform.OS === 'web' ? (
<video
src={fullscreenContent.url}
style={styles.fullscreenMediaContent as any}
autoPlay
controls
/>
) : (
<View style={styles.fullscreenMediaContent}>
<ThemedText>Video Player</ThemedText>
</View>
)}
</>
)}
</View>
)}
</View>
</Modal>
<View
style={[
styles.bottomBar,
{
paddingBottom: Math.max(insets.bottom, 20),
},
]}
>
<TouchableOpacity
activeOpacity={0.8}
style={styles.secondaryAction}
onPress={handleRerun}
>
<Plus size={20} color="#F6F6F8" />
<Text style={styles.secondaryText}>Regenerate</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.primaryAction,
saving && { opacity: 0.7 },
]}
onPress={handleDownloadPress}
disabled={saving}
>
{isTextResult ? (
<Copy size={20} color="#1C1C1E" />
) : (
<Download size={20} color="#1C1C1E" />
)}
<Text style={styles.primaryText}>
{isTextResult ? 'Copy Text' : 'Download'}
</Text>
</TouchableOpacity>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#09090B',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#09090B',
},
loadingText: {
color: '#F5F5F7',
fontSize: 16,
},
safeGuard: {
backgroundColor: 'transparent',
},
scrollContent: {
paddingHorizontal: 24,
paddingBottom: 160,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
marginBottom: 24,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(32, 32, 36, 0.6)',
},
title: {
color: '#F5F5F7',
fontSize: 22,
fontWeight: '700',
lineHeight: 30,
marginBottom: 12,
letterSpacing: 0.4,
},
subtitle: {
color: '#9A9AA2',
fontSize: 14,
lineHeight: 20,
},
heroCard: {
marginTop: 32,
borderRadius: 32,
backgroundColor: '#131317',
padding: 24,
position: 'relative',
overflow: 'hidden',
},
heroMedia: {
width: '100%',
height: HERO_HEIGHT,
borderRadius: 28,
},
textScroll: {
maxHeight: HERO_HEIGHT,
},
generatedText: {
color: '#F5F5F7',
fontSize: 16,
lineHeight: 24,
},
referencePreview: {
position: 'absolute',
bottom: 24,
left: 24,
width: 74,
height: 74,
borderRadius: 20,
padding: 4,
backgroundColor: '#050506',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
referenceImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
detailCard: {
marginTop: 28,
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: '#101012',
padding: 20,
position: 'relative',
},
detailMediaWrapper: {
borderRadius: 24,
overflow: 'hidden',
},
detailMedia: {
width: '100%',
height: DETAIL_HEIGHT,
},
expandIcon: {
position: 'absolute',
right: 20,
top: 20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(24, 24, 28, 0.86)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
fullscreenContainer: {
flex: 1,
backgroundColor: '#000000',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
top: 50,
right: 20,
zIndex: 1,
width: 50,
height: 50,
justifyContent: 'center',
alignItems: 'center',
},
fullscreenMedia: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
fullscreenMediaContent: {
width: '100%',
height: '100%',
},
bottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 24,
paddingTop: 16,
backgroundColor: 'rgba(6, 6, 7, 0.94)',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
secondaryAction: {
flex: 1,
height: 60,
borderRadius: 30,
backgroundColor: '#151519',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
marginRight: 12,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
secondaryText: {
color: '#F5F5F7',
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
primaryAction: {
flex: 1,
height: 60,
borderRadius: 30,
backgroundColor: '#D9FF3F',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
primaryText: {
color: '#1C1C1E',
fontSize: 16,
fontWeight: '700',
marginLeft: 10,
},
});