expo-popcore-old/app/result.tsx

677 lines
18 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 { getTemplateGeneration } from '@/lib/api/template-runs';
import * as Clipboard from 'expo-clipboard';
import { documentDirectory, downloadAsync } from 'expo-file-system/legacy';
import * as MediaLibrary from 'expo-media-library';
import { router, useLocalSearchParams } from 'expo-router';
import { VideoView, useVideoPlayer } from 'expo-video';
import {
ArrowLeft,
Copy,
Download,
Maximize2,
Plus,
X,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Dimensions,
Image, Modal, Platform, ScrollView,
StyleSheet, Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// ResizeMode 兼容映射 - 移除 'stretch'expo-video 不支持
const ResizeMode = {
CONTAIN: 'contain' as const,
COVER: 'cover' as const,
STRETCH: 'fill' as const, // 使用 'fill' 替代 'stretch'
};
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
const HERO_HEIGHT = screenWidth * 0.58;
const VIDEO_HEIGHT = screenHeight * 0.6;
// 原生视频播放组件
const NativeVideoPlayer: React.FC<{ url: string; style?: any }> = ({ url, style }) => {
const player = useVideoPlayer(url, player => {
player.loop = false;
});
return (
<VideoView
player={player}
style={style}
contentFit="contain"
allowsFullscreen
allowsPictureInPicture
/>
);
};
interface ResultWithTemplate {
id: string;
userId: string;
templateId: string;
type: 'UNKNOWN' | 'TEXT' | 'IMAGE' | 'VIDEO';
resultUrl: string[];
status: 'pending' | 'running' | 'completed' | 'failed';
creditsCost?: number;
creditsTransactionId?: string;
createdAt: string;
updatedAt: string;
template?: {
id: string;
title: string;
titleEn: string;
};
}
/**
* 根据文件 URL 后缀名判断媒体类型
*/
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
const extension = url.split('.').pop()?.toLowerCase() || '';
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
if (imageExts.includes(extension)) return 'image';
if (videoExts.includes(extension)) return 'video';
return 'unknown';
}
/**
* 安全获取媒体类型,过滤掉 unknown
*/
function getSafeMediaType(url: string): 'image' | 'video' {
const type = getMediaType(url);
return type === 'unknown' ? 'image' : type;
}
export default function ResultPage() {
const { generationId } = useLocalSearchParams<{ generationId: string }>();
const insets = useSafeAreaInsets();
const [result, setResult] = useState<ResultWithTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [fullscreenVisible, setFullscreenVisible] = useState(false);
const [fullscreenContent, setFullscreenContent] = useState<{
type: 'image' | 'video';
url: string;
} | null>(null);
const [saving, setSaving] = useState(false);
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
useEffect(() => {
if (generationId) {
loadResult();
}
}, [generationId]);
const loadResult = async () => {
if (!generationId) return;
try {
setLoading(true);
const response = await getTemplateGeneration(generationId);
if (response?.success && response.data) {
setResult(response.data as any);
} else {
Alert.alert('错误', '无法加载结果详情');
}
} catch (error) {
console.error('Failed to load result:', error);
Alert.alert('错误', '加载结果失败,请稍后重试');
} finally {
setLoading(false);
}
};
const handleRerun = () => {
router.push(`/templates/${result?.templateId}/form`);
};
const handleSaveToGallery = async () => {
if (!result || result.type === 'TEXT') {
return;
}
try {
setSaving(true);
// Web 平台处理
if (Platform.OS === 'web') {
for (const url of result.resultUrl) {
try {
const response = await fetch(url);
const blob = await response.blob();
// 类型安全的处理方式
if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) {
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = url.split('/').pop() || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
}
// 添加延迟,避免浏览器阻止多个下载
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error('Web download failed:', url, error);
}
}
const count = result.resultUrl.length;
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
Alert.alert(
'下载开始',
count > 1 ? `正在下载${count}${mediaType}` : `正在下载${mediaType}`
);
return;
}
// 原生平台处理
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
return;
}
// 批量下载所有媒体文件
let successCount = 0;
for (const url of result.resultUrl) {
try {
// 生成临时文件名
const filename = url.split('/').pop() || `download_${Date.now()}`;
if (!documentDirectory) {
console.error('Document directory not available');
continue;
}
const fileUri = `${documentDirectory}${filename}`;
// 下载文件到本地
const downloadResult = await downloadAsync(url, fileUri);
if (downloadResult.status === 200) {
// 保存到相册
const asset = await MediaLibrary.createAssetAsync(downloadResult.uri);
successCount++;
} else {
console.error('Download failed with status:', downloadResult.status);
}
} catch (error) {
console.error('Failed to download and save:', url, error);
}
}
const count = result.resultUrl.length;
const mediaType = result.type === 'IMAGE' ? '图片' : '视频';
if (successCount === count) {
Alert.alert(
'保存成功',
count > 1 ? `${count}${mediaType}已保存到相册` : `${mediaType}已保存到相册`
);
} else if (successCount > 0) {
Alert.alert(
'部分保存成功',
`${successCount}/${count}${mediaType}已保存到相册`
);
} else {
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
}
} catch (error) {
console.error('Save to gallery error:', error);
Alert.alert('保存失败', '无法保存文件,请稍后重试');
} finally {
setSaving(false);
}
};
const handleCopyText = async () => {
if (!result || result.type !== 'TEXT') return;
try {
await Clipboard.setStringAsync(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();
};
if (loading) {
return (
<ThemedView style={styles.screen}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#D1FF00" />
<Text style={styles.loadingText}>...</Text>
</View>
</ThemedView>
);
}
if (!result) {
return (
<ThemedView style={styles.screen}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}></Text>
</View>
</ThemedView>
);
}
const isTextResult = result.type === 'TEXT';
const mediaUrls = result.resultUrl || [];
const hasMultipleMedia = mediaUrls.length > 1;
const renderMediaItem = (url: string, index: number) => {
const mediaType = getMediaType(url);
return (
<View key={`media-${index}`} style={{ width: screenWidth, backgroundColor: '#000' }}>
{mediaType === 'video' ? (
Platform.OS === 'web' ? (
<video
src={url}
style={{
width: '100%',
height: VIDEO_HEIGHT,
objectFit: 'contain' as any,
}}
controls
playsInline
/>
) : (
<NativeVideoPlayer
url={url}
style={{ width: '100%', height: VIDEO_HEIGHT }}
/>
)
) : (
<Image
source={{ uri: url }}
style={{ width: '100%', height: VIDEO_HEIGHT }}
resizeMode="contain"
/>
)}
</View>
);
};
const handleScroll = (event: any) => {
const offsetX = event.nativeEvent.contentOffset.x;
const index = Math.round(offsetX / screenWidth);
setCurrentMediaIndex(index);
};
return (
<ThemedView style={styles.screen}>
<View style={[styles.safeGuard, { paddingTop: insets.top || 16 }]} />
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.contentPadding}>
<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}>
{result.template?.title || '生成结果'}
</Text>
<Text style={styles.subtitle}>
{result.template?.titleEn || ''}
</Text>
</View>
{isTextResult ? (
<View style={styles.heroCard}>
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
<Text style={styles.generatedText}>{mediaUrls[0]}</Text>
</ScrollView>
</View>
) : (
<View style={styles.mediaContainer}>
<ScrollView
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
contentContainerStyle={styles.carouselContent}
>
{mediaUrls.map((url, index) => renderMediaItem(url, index))}
</ScrollView>
{hasMultipleMedia && (
<View style={styles.pagination}>
{mediaUrls.map((_, index) => (
<View
key={`dot-${index}`}
style={[
styles.paginationDot,
index === currentMediaIndex && styles.paginationDotActive,
]}
/>
))}
</View>
)}
<TouchableOpacity
activeOpacity={0.85}
style={styles.expandButton}
onPress={() =>
openFullscreen(
getSafeMediaType(mediaUrls[currentMediaIndex]),
mediaUrls[currentMediaIndex]
)
}
>
<Maximize2 size={18} color="#F5F5F7" />
</TouchableOpacity>
</View>
)}
</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
/>
) : (
<NativeVideoPlayer
url={fullscreenContent.url}
style={styles.fullscreenMediaContent}
/>
)}
</>
)}
</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: {
paddingBottom: 160,
},
contentPadding: {
paddingHorizontal: 24,
},
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,
marginHorizontal: 24,
borderRadius: 32,
backgroundColor: '#131317',
padding: 24,
position: 'relative',
overflow: 'hidden',
},
mediaContainer: {
marginTop: 32,
position: 'relative',
backgroundColor: '#09090B',
},
carouselContent: {
gap: 0,
},
pagination: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginTop: 20,
marginBottom: 12,
gap: 8,
},
paginationDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
paginationDotActive: {
backgroundColor: '#D9FF3F',
width: 24,
},
expandButton: {
position: 'absolute',
right: 16,
top: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
textScroll: {
maxHeight: HERO_HEIGHT,
},
generatedText: {
color: '#F5F5F7',
fontSize: 16,
lineHeight: 24,
},
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,
},
});