This commit is contained in:
imeepos
2025-10-21 10:21:45 +08:00
parent 7e3f94bae3
commit 3a99ff96d5
28 changed files with 6981 additions and 176 deletions

View File

@@ -0,0 +1,247 @@
import { ThemedText } from '@/components/themed-text';
import { Image } from 'expo-image';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Modal,
PanResponder,
Platform,
StatusBar,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export interface FullscreenImageModalProps {
visible: boolean;
onClose: () => void;
imageUrl: string;
onPrevious?: () => void;
onNext?: () => void;
hasNext?: boolean;
hasPrevious?: boolean;
}
export function FullscreenImageModal({
visible,
onClose,
imageUrl,
onPrevious,
onNext,
hasNext = false,
hasPrevious = false,
}: FullscreenImageModalProps) {
const [isLoading, setIsLoading] = useState(true);
const [showControls, setShowControls] = useState(true);
// 重置状态当模态框打开/关闭时
useEffect(() => {
if (visible) {
setIsLoading(true);
setShowControls(true);
}
}, [visible]);
// 处理图片加载完成
const handleImageLoad = () => {
setIsLoading(false);
};
// 处理图片加载错误
const handleImageError = (error: any) => {
console.error('图片加载错误:', error);
setIsLoading(false);
};
// 图片点击处理(直接关闭)
const handleImagePress = () => {
handleClose();
};
// 关闭模态框
const handleClose = () => {
onClose();
};
// 创建手势处理器
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, gestureState) => {
// 只有水平移动时才响应
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
},
onPanResponderRelease: (_, gestureState) => {
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
if (gestureState.dx > threshold && hasPrevious) {
// 向右滑动,显示上一个
onPrevious?.();
} else if (gestureState.dx < -threshold && hasNext) {
// 向左滑动,显示下一个
onNext?.();
}
},
})
).current;
// 处理返回键Android
useEffect(() => {
const backHandler = () => {
if (visible) {
handleClose();
return true;
}
return false;
};
// 在实际应用中,这里需要添加返回键监听
// 对于演示,我们只做概念性实现
return () => {
// 清理返回键监听
};
}, [visible]);
if (!visible) return null;
return (
<Modal
visible={visible}
transparent={false}
animationType="fade"
onRequestClose={handleClose}
statusBarTranslucent={true}
>
<View style={styles.container}>
{/* 状态栏处理 */}
{Platform.OS === 'android' && <StatusBar hidden />}
{/* 图片容器 */}
<TouchableOpacity
style={styles.imageContainer}
onPress={handleImagePress}
activeOpacity={1}
>
<View
style={styles.imageWrapper}
{...panResponder.panHandlers}
>
{/* 图片显示 */}
<Image
source={{ uri: imageUrl }}
style={styles.image}
contentFit="contain"
onLoad={handleImageLoad}
onError={handleImageError}
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
transition={300}
/>
{/* 加载指示器 */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#4ECDC4" />
</View>
)}
{/* 左右导航指示器 */}
{hasPrevious && (
<TouchableOpacity
style={styles.leftIndicator}
onPress={(e) => {
e.stopPropagation();
onPrevious?.();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
{hasNext && (
<TouchableOpacity
style={styles.rightIndicator}
onPress={(e) => {
e.stopPropagation();
onNext?.();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
imageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
imageWrapper: {
flex: 1,
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: screenWidth,
height: screenHeight,
backgroundColor: '#000',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
leftIndicator: {
position: 'absolute',
left: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
rightIndicator: {
position: 'absolute',
right: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
indicatorIcon: {
fontSize: 24,
color: '#fff',
fontWeight: 'bold',
},
});
export default FullscreenImageModal;

View File

@@ -0,0 +1,309 @@
import { ThemedText } from '@/components/themed-text';
import { VideoPlayer } from '@/components/video/video-player';
import { useThemeColor } from '@/hooks/use-theme-color';
import { Template } from '@/lib/types/template';
import { Image } from 'expo-image';
import { ResizeMode } from 'expo-av';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Modal,
PanResponder,
Platform,
StatusBar,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import { isVideoTemplate, canLoadMedia, getEffectiveMediaUrl } from '@/utils/media-utils';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
currentIndex: number;
templates: Template[];
onIndexChanged: (index: number) => void;
}
export function FullscreenMediaModal({
visible,
onClose,
currentIndex,
templates,
onIndexChanged,
}: FullscreenMediaModalProps) {
const [isLoading, setIsLoading] = useState(true);
const backgroundColor = useThemeColor({}, 'background');
// 获取当前模板
const currentTemplate = templates[currentIndex];
const isCurrentVideo = currentTemplate ? isVideoTemplate(currentTemplate) : false;
const canLoadCurrentMedia = currentTemplate ? canLoadMedia(currentTemplate) : false;
// 重置状态当模态框打开/关闭时
useEffect(() => {
if (visible) {
setIsLoading(true);
// 添加备用机制3秒后自动隐藏loading防止卡住
const timer = setTimeout(() => {
setIsLoading(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [visible, currentIndex]);
// 处理媒体加载完成(移动平台)
const handleMediaLoad = () => {
setIsLoading(false);
};
// 处理Web平台视频加载完成
const handleWebVideoLoad = () => {
setIsLoading(false);
};
// 处理媒体加载错误
const handleMediaError = (error: any) => {
console.error('媒体加载错误:', {
template: currentTemplate?.title,
mediaType: isCurrentVideo ? 'video' : 'image',
url: getEffectiveMediaUrl(currentTemplate),
error: error
});
setIsLoading(false);
};
// 媒体点击处理(直接关闭)
const handleMediaPress = () => {
handleClose();
};
// 关闭模态框
const handleClose = () => {
onClose();
};
// 统一的切换处理函数
const handlePrevious = () => {
if (currentIndex > 0) {
const newIndex = currentIndex - 1;
onIndexChanged(newIndex);
}
};
const handleNext = () => {
if (currentIndex < templates.length - 1) {
const newIndex = currentIndex + 1;
onIndexChanged(newIndex);
}
};
// 创建手势处理器
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, gestureState) => {
// 只有水平移动时才响应
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
},
onPanResponderRelease: (_, gestureState) => {
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
if (gestureState.dx > threshold && currentIndex > 0) {
// 向右滑动,显示上一个
handlePrevious();
} else if (gestureState.dx < -threshold && currentIndex < templates.length - 1) {
// 向左滑动,显示下一个
handleNext();
}
},
})
).current;
// 处理返回键Android
useEffect(() => {
const backHandler = () => {
if (visible) {
handleClose();
return true;
}
return false;
};
return () => {
// 清理返回键监听
};
}, [visible]);
if (!visible || !currentTemplate) return null;
return (
<Modal
visible={visible}
transparent={false}
animationType="fade"
onRequestClose={handleClose}
statusBarTranslucent={true}
>
<View style={styles.container}>
{/* 状态栏处理 */}
{Platform.OS === 'android' && <StatusBar hidden />}
{/* 媒体容器 */}
<TouchableOpacity
style={styles.mediaContainer}
onPress={handleMediaPress}
activeOpacity={1}
pointerEvents="box-none"
>
<View
style={styles.mediaWrapper}
pointerEvents="auto"
{...panResponder.panHandlers}
>
{/* 根据模板类型显示对应内容 */}
{isCurrentVideo && canLoadCurrentMedia ? (
// 视频显示
<VideoPlayer
source={{ uri: currentTemplate.previewUrl }}
poster={currentTemplate.coverImageUrl}
style={styles.video}
resizeMode={ResizeMode.COVER}
shouldPlay={true}
isLooping={true}
isMuted={false}
useNativeControls={false}
showPoster={false}
autoPlay={true}
fullscreenMode={true}
onPress={handleMediaPress}
onReady={handleMediaLoad}
onWebLoadedData={handleWebVideoLoad}
onError={handleMediaError}
/>
) : (
// 图片显示(包括视频无法加载时的降级显示)
<Image
source={{ uri: currentTemplate.coverImageUrl || '' }}
style={styles.image}
contentFit="contain"
onLoad={handleMediaLoad}
onError={handleMediaError}
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
transition={300}
/>
)}
{/* 加载指示器 */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#4ECDC4" />
</View>
)}
{/* 左右导航指示器 */}
{currentIndex > 0 && (
<TouchableOpacity
style={styles.leftIndicator}
onPress={(e) => {
e.stopPropagation();
handlePrevious();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
{currentIndex < templates.length - 1 && (
<TouchableOpacity
style={styles.rightIndicator}
onPress={(e) => {
e.stopPropagation();
handleNext();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
mediaContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
mediaWrapper: {
flex: 1,
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: screenWidth,
height: screenHeight,
backgroundColor: '#000',
},
video: {
width: screenWidth,
height: screenHeight,
backgroundColor: '#000',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
leftIndicator: {
position: 'absolute',
left: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
rightIndicator: {
position: 'absolute',
right: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
indicatorIcon: {
fontSize: 24,
color: '#fff',
fontWeight: 'bold',
},
});
export default FullscreenMediaModal;

View File

@@ -1,27 +1,40 @@
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { Image } from 'expo-image';
import { Video, ResizeMode } from 'expo-av';
import { VideoPlayer } from '@/components/video/video-player';
import { FullscreenMediaModal } from '@/components/media/fullscreen-media-modal';
import { useThemeColor } from '@/hooks/use-theme-color';
import { Template } from '@/lib/types/template';
import { ResizeMode } from 'expo-av';
import { Image } from 'expo-image';
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';
import { Template } from '@/lib/types/template';
import { useThemeColor } from '@/hooks/use-theme-color';
import { useMemo } from 'react';
import { useRouter } from 'expo-router';
import { isVideoTemplate, canLoadMedia } from '@/utils/media-utils';
interface TemplateCardProps {
template: Template;
onPress?: (template: Template) => void;
allTemplates?: Template[];
currentIndex?: number;
onVideoChange?: (template: Template, index: number) => void;
}
export function TemplateCard({ template, onPress }: TemplateCardProps) {
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 /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.previewUrl || '');
}, [template.previewUrl]);
return isVideoTemplate(template);
}, [template]);
const getImageHeight = () => {
if (template.aspectRatio) {
@@ -39,23 +52,36 @@ export function TemplateCard({ template, onPress }: TemplateCardProps) {
router.push(`/template/${template.id}/run`);
};
const handleCardPress = () => {
if (onPress) {
onPress(template);
} else {
// 默认行为:跳转到运行页面
handleUseTemplate();
}
const handleFullscreenMedia = () => {
setIsMediaFullscreenVisible(true);
};
const handleMediaIndexChanged = (newIndex: number) => {
setCurrentMediaIndex(newIndex);
const newTemplate = allTemplates[newIndex];
onVideoChange?.(newTemplate, newIndex);
};
const handleCardPress = () => {
// 统一处理:打开媒体全屏预览
handleFullscreenMedia();
};
// 获取当前媒体模板
const getCurrentMediaTemplate = () => {
return allTemplates[currentMediaIndex] || template;
};
const currentTemplate = getCurrentMediaTemplate();
return (
<View style={[styles.card, { backgroundColor: cardColor }]}>
<View style={styles.mediaContainer}>
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
{isVideo ? (
<VideoPlayer
source={{ uri: template.previewUrl }}
poster={template.coverImageUrl}
source={{ uri: currentTemplate.previewUrl }}
poster={currentTemplate.coverImageUrl}
style={[styles.videoPlayer, styles.videoCover]}
resizeMode={ResizeMode.COVER}
shouldPlay={true}
@@ -120,6 +146,15 @@ export function TemplateCard({ template, onPress }: TemplateCardProps) {
<ThemedText style={styles.useButtonText}>使</ThemedText>
</TouchableOpacity>
</View>
{/* 统一全屏媒体模态框 */}
<FullscreenMediaModal
visible={isMediaFullscreenVisible}
onClose={() => setIsMediaFullscreenVisible(false)}
currentIndex={currentMediaIndex}
templates={allTemplates}
onIndexChanged={handleMediaIndexChanged}
/>
</View>
);
}
@@ -158,6 +193,11 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
// React Native中object-fit对应contentFit属性但在VideoPlayer中已经处理了
...Platform.select({
web: {
objectFit: 'cover',
},
}),
},
imageContainer: {
width: '100%',
@@ -181,6 +221,7 @@ const styles = StyleSheet.create({
color: 'rgba(255, 255, 255, 0.85)',
fontSize: 10,
fontWeight: '400',
lineHeight: 6
},
tagOverlay: {
position: 'absolute',
@@ -205,6 +246,7 @@ const styles = StyleSheet.create({
color: '#fff',
fontSize: 11,
fontWeight: '500',
lineHeight: 4
},
actionArea: {
padding: 12,

View File

@@ -14,6 +14,7 @@ interface TemplateListProps {
onRefresh?: () => void;
onLoadMore?: () => void;
onTemplatePress?: (template: Template) => void;
onVideoChange?: (template: Template, index: number) => void;
error?: string | null;
}
@@ -26,6 +27,7 @@ export function TemplateList({
onRefresh,
onLoadMore,
onTemplatePress,
onVideoChange,
error,
}: TemplateListProps) {
const tintColor = useThemeColor({}, 'tint');
@@ -89,8 +91,14 @@ export function TemplateList({
<FlatList
data={templates}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TemplateCard template={item} onPress={onTemplatePress} />
renderItem={({ item, index }) => (
<TemplateCard
template={item}
onPress={onTemplatePress}
allTemplates={templates}
currentIndex={index}
onVideoChange={onVideoChange}
/>
)}
numColumns={2}
columnWrapperStyle={styles.row}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
import { IconSymbol } from '@/components/ui/icon-symbol';
interface BalanceCardProps {
balance: number;
onRecharge: () => void;
onHistory?: () => void;
}
export function BalanceCard({ balance, onRecharge, onHistory }: BalanceCardProps) {
const tintColor = useThemeColor({}, 'tint');
const cardColor = useThemeColor({}, 'card');
const borderColor = useThemeColor({}, 'cardBorder');
return (
<ThemedView style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<IconSymbol name="dollarsign.circle" size={24} color={tintColor} />
<ThemedText style={styles.title}></ThemedText>
</View>
{onHistory && (
<TouchableOpacity onPress={onHistory} style={styles.historyButton}>
<ThemedText style={[styles.historyText, { color: tintColor }]}>
</ThemedText>
</TouchableOpacity>
)}
</View>
<View style={styles.balanceContainer}>
<ThemedText style={styles.currency}>¥</ThemedText>
<ThemedText style={styles.balance}>{balance.toFixed(2)}</ThemedText>
</View>
<TouchableOpacity
style={[styles.rechargeButton, { backgroundColor: tintColor }]}
onPress={onRecharge}
activeOpacity={0.8}
>
<IconSymbol name="plus.circle.fill" size={20} color="white" />
<ThemedText style={styles.rechargeText}></ThemedText>
</TouchableOpacity>
<View style={styles.tips}>
<IconSymbol name="info.circle" size={14} color="#999" />
<ThemedText style={styles.tipsText}>
</ThemedText>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
padding: 20,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
title: {
fontSize: 16,
fontWeight: '600',
},
historyButton: {
paddingHorizontal: 12,
paddingVertical: 6,
},
historyText: {
fontSize: 14,
fontWeight: '500',
},
balanceContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'center',
marginVertical: 24,
},
currency: {
fontSize: 24,
fontWeight: '600',
marginBottom: 4,
},
balance: {
fontSize: 48,
fontWeight: 'bold',
lineHeight: 48,
},
rechargeButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 24,
gap: 8,
alignSelf: 'center',
},
rechargeText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
tips: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
},
tipsText: {
fontSize: 12,
color: '#666',
flex: 1,
},
});

View File

@@ -0,0 +1,319 @@
import React, { useState } from 'react';
import { StyleSheet, ScrollView, TouchableOpacity, RefreshControl, Alert, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { GenerationRecord } from '@/lib/auth/types';
interface GenerationRecordsProps {
userId?: string;
}
const mockRecords: GenerationRecord[] = [
{
id: '1',
userId: 'user1',
type: 'image',
title: '夏日风景插画',
description: '生成了一张充满夏日气息的风景插画',
thumbnail: undefined,
mediaUrl: undefined,
status: 'completed',
cost: 0.5,
createdAt: new Date('2024-01-15T10:30:00'),
updatedAt: new Date('2024-01-15T10:35:00'),
},
{
id: '2',
userId: 'user1',
type: 'video',
title: '产品介绍视频',
description: '为新产品制作了一个介绍视频',
thumbnail: undefined,
mediaUrl: undefined,
status: 'pending',
cost: 2.0,
createdAt: new Date('2024-01-14T15:20:00'),
updatedAt: new Date('2024-01-14T15:20:00'),
},
];
export function GenerationRecords({ userId }: GenerationRecordsProps) {
const [records, setRecords] = useState<GenerationRecord[]>(mockRecords);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
const tintColor = useThemeColor({}, 'tint');
const textColor = useThemeColor({}, 'text');
const getTypeIcon = (type: string) => {
switch (type) {
case 'image':
return 'photo';
case 'video':
return 'video';
case 'text':
return 'doc.text';
default:
return 'doc';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return '#34C759';
case 'pending':
return '#FF9500';
case 'failed':
return '#FF3B30';
default:
return '#999';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'completed':
return '已完成';
case 'pending':
return '生成中';
case 'failed':
return '失败';
default:
return '未知';
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
// TODO: 实现刷新逻辑
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('刷新失败:', error);
} finally {
setRefreshing(false);
}
};
const handleDelete = (recordId: string) => {
Alert.alert(
'删除记录',
'确定要删除这条生成记录吗?此操作无法撤销。',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => {
setRecords(prev => prev.filter(record => record.id !== recordId));
}
}
]
);
};
const handlePress = (record: GenerationRecord) => {
if (record.status === 'completed') {
// TODO: 跳转到详情页面
console.log('查看记录详情:', record.id);
}
};
if (records.length === 0 && !loading) {
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.header}>
<IconSymbol name="clock" size={20} color={tintColor} />
<ThemedText style={styles.title}></ThemedText>
</ThemedView>
<ThemedView style={styles.emptyContainer}>
<IconSymbol name="doc.text" size={48} color="#ccc" />
<ThemedText style={styles.emptyText}></ThemedText>
<ThemedText style={styles.emptySubText}></ThemedText>
</ThemedView>
</ThemedView>
);
}
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.header}>
<IconSymbol name="clock" size={20} color={tintColor} />
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.count}>({records.length})</ThemedText>
</ThemedView>
<ScrollView
style={styles.scrollView}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
>
{records.map((record) => (
<TouchableOpacity
key={record.id}
style={styles.recordItem}
onPress={() => handlePress(record)}
activeOpacity={0.7}
>
<ThemedView style={styles.recordLeft}>
<ThemedView style={styles.typeIcon}>
<IconSymbol
name={getTypeIcon(record.type)}
size={20}
color={tintColor}
/>
</ThemedView>
<ThemedView style={styles.recordContent}>
<ThemedText style={styles.recordTitle}>{record.title}</ThemedText>
{record.description && (
<ThemedText style={styles.recordDescription} numberOfLines={2}>
{record.description}
</ThemedText>
)}
<ThemedView style={styles.recordMeta}>
<ThemedText style={styles.recordTime}>
{new Date(record.createdAt).toLocaleDateString()}
</ThemedText>
<ThemedText style={styles.recordCost}>¥{record.cost}</ThemedText>
</ThemedView>
</ThemedView>
</ThemedView>
<ThemedView style={styles.recordRight}>
<ThemedView
style={[
styles.statusBadge,
{ backgroundColor: getStatusColor(record.status) + '20' }
]}
>
<ThemedText
style={[
styles.statusText,
{ color: getStatusColor(record.status) }
]}
>
{getStatusText(record.status)}
</ThemedText>
</ThemedView>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDelete(record.id)}
>
<IconSymbol name="trash" size={16} color="#FF3B30" />
</TouchableOpacity>
</ThemedView>
</TouchableOpacity>
))}
</ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 16,
},
title: {
fontSize: 18,
fontWeight: '600',
},
count: {
fontSize: 14,
color: '#666',
},
scrollView: {
flex: 1,
},
recordItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
recordLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: 12,
},
typeIcon: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
},
recordContent: {
flex: 1,
},
recordTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
recordDescription: {
fontSize: 14,
color: '#666',
marginBottom: 4,
},
recordMeta: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
recordTime: {
fontSize: 12,
color: '#999',
},
recordCost: {
fontSize: 12,
color: '#666',
fontWeight: '500',
},
recordRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 12,
fontWeight: '500',
},
deleteButton: {
padding: 8,
},
emptyContainer: {
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
marginTop: 16,
marginBottom: 8,
},
emptySubText: {
fontSize: 14,
color: '#666',
},
});

View File

@@ -0,0 +1,165 @@
import React from 'react';
import { StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { router } from 'expo-router';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
import { IconSymbol } from '@/components/ui/icon-symbol';
interface SettingsListProps {
onLogout: () => void;
}
interface SettingItem {
id: string;
title: string;
icon: string;
onPress?: () => void;
destructive?: boolean;
}
export function SettingsList({ onLogout }: SettingsListProps) {
const tintColor = useThemeColor({}, 'tint');
const textColor = useThemeColor({}, 'text');
const destructiveColor = '#FF3B30';
const settings: SettingItem[] = [
{
id: 'account',
title: '账户设置',
icon: 'person.circle',
onPress: () => router.push('/settings/account'),
},
{
id: 'notification',
title: '通知设置',
icon: 'bell',
onPress: () => router.push('/settings/notification'),
},
{
id: 'privacy',
title: '隐私设置',
icon: 'lock',
onPress: () => router.push('/settings/privacy'),
},
{
id: 'about',
title: '关于我们',
icon: 'info.circle',
onPress: () => router.push('/settings/about'),
},
{
id: 'feedback',
title: '意见反馈',
icon: 'exclamationmark.bubble',
onPress: () => router.push('/settings/feedback'),
},
{
id: 'logout',
title: '退出登录',
icon: 'arrow.right.square',
onPress: onLogout,
destructive: true,
},
];
const handleSettingPress = (item: SettingItem) => {
if (item.onPress) {
if (item.destructive) {
Alert.alert(
'退出登录',
'确定要退出登录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: item.onPress,
}
]
);
} else {
item.onPress();
}
}
};
return (
<ThemedView style={styles.container}>
<ThemedView style={styles.header}>
<IconSymbol name="gearshape" size={20} color={tintColor} />
<ThemedText style={styles.title}></ThemedText>
</ThemedView>
<ThemedView style={styles.settingsList}>
{settings.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.settingItem}
onPress={() => handleSettingPress(item)}
activeOpacity={0.7}
>
<ThemedView style={styles.settingLeft}>
<IconSymbol
name={item.icon}
size={20}
color={item.destructive ? destructiveColor : tintColor}
/>
<ThemedText
style={[
styles.settingTitle,
{ color: item.destructive ? destructiveColor : textColor }
]}
>
{item.title}
</ThemedText>
</ThemedView>
<IconSymbol
name="chevron.right"
size={16}
color="#ccc"
/>
</TouchableOpacity>
))}
</ThemedView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 16,
},
title: {
fontSize: 18,
fontWeight: '600',
},
settingsList: {
borderRadius: 8,
overflow: 'hidden',
},
settingItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 4,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
settingLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
settingTitle: {
fontSize: 16,
},
});

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { ThemedView } from '@/components/themed-view';
import { ThemedText } from '@/components/themed-text';
import { useThemeColor } from '@/hooks/use-theme-color';
interface UserAvatarProps {
image?: string;
name: string;
size?: number;
showBorder?: boolean;
}
export function UserAvatar({
image,
name,
size = 40,
showBorder = true
}: UserAvatarProps) {
const borderColor = useThemeColor({}, 'cardBorder');
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
const textColor = useThemeColor({}, 'text');
const avatarSize = size;
const borderRadius = avatarSize / 2;
const fontSize = avatarSize / 3;
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<View
style={[
styles.container,
{
width: avatarSize,
height: avatarSize,
borderRadius,
borderColor: showBorder ? borderColor : 'transparent',
}
]}
>
{image ? (
<Image
source={{ uri: image }}
style={[
styles.image,
{
width: avatarSize - 4,
height: avatarSize - 4,
borderRadius: (avatarSize - 4) / 2,
}
]}
resizeMode="cover"
/>
) : (
<ThemedView
style={[
styles.placeholder,
{
width: avatarSize - 4,
height: avatarSize - 4,
borderRadius: (avatarSize - 4) / 2,
backgroundColor: placeholderColor,
}
]}
>
<ThemedText
style={[
styles.initials,
{
fontSize,
color: textColor,
}
]}
>
{getInitials(name)}
</ThemedText>
</ThemedView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderWidth: 2,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
image: {
backgroundColor: 'transparent',
},
placeholder: {
justifyContent: 'center',
alignItems: 'center',
},
initials: {
fontWeight: '600',
textAlign: 'center',
},
});

View File

@@ -0,0 +1,294 @@
import { ThemedText } from '@/components/themed-text';
import { AVPlaybackStatus, ResizeMode, Video } from 'expo-av';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Modal,
PanResponder,
Platform,
StatusBar,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export interface FullscreenVideoModalProps {
visible: boolean;
onClose: () => void;
videoUrl: string;
poster?: string;
autoPlay?: boolean;
isLooping?: boolean;
isMuted?: boolean;
onPrevious?: () => void;
onNext?: () => void;
hasNext?: boolean;
hasPrevious?: boolean;
}
export function FullscreenVideoModal({
visible,
onClose,
videoUrl,
poster,
autoPlay = true,
isLooping = true,
isMuted = false,
onPrevious,
onNext,
hasNext = false,
hasPrevious = false,
}: FullscreenVideoModalProps) {
const videoRef = useRef<Video>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [showControls, setShowControls] = useState(true);
// 重置状态当模态框打开/关闭时
useEffect(() => {
if (visible) {
setIsLoading(true);
setIsPlaying(autoPlay);
setShowControls(true);
} else {
// 停止播放当关闭时
if (videoRef.current) {
videoRef.current.stopAsync();
}
}
}, [visible, autoPlay]);
// 处理视频加载完成
const handleVideoReady = (status: AVPlaybackStatus) => {
if (status.isLoaded) {
setIsLoading(false);
if (autoPlay) {
videoRef.current?.playAsync();
}
}
};
// 处理视频播放状态变化
const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (status.isLoaded) {
setIsPlaying(status.isPlaying);
}
};
// 处理视频错误
const handleVideoError = (error: any) => {
console.error('视频播放错误:', error);
setIsLoading(false);
};
// 视频点击处理(直接关闭)
const handleVideoPress = () => {
handleClose();
};
// 关闭模态框
const handleClose = () => {
onClose();
};
// 创建手势处理器
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, gestureState) => {
// 只有水平移动时才响应
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
},
onPanResponderRelease: (_, gestureState) => {
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
if (gestureState.dx > threshold && hasPrevious) {
// 向右滑动,显示上一个
onPrevious?.();
} else if (gestureState.dx < -threshold && hasNext) {
// 向左滑动,显示下一个
onNext?.();
}
},
})
).current;
// 处理返回键Android
useEffect(() => {
const backHandler = () => {
if (visible) {
handleClose();
return true;
}
return false;
};
// 在实际应用中,这里需要添加返回键监听
// 对于演示,我们只做概念性实现
return () => {
// 清理返回键监听
};
}, [visible]);
if (!visible) return null;
return (
<Modal
visible={visible}
transparent={false}
animationType="fade"
onRequestClose={handleClose}
statusBarTranslucent={true}
>
<View style={styles.container}>
{/* 状态栏处理 */}
{Platform.OS === 'android' && <StatusBar hidden />}
{/* 视频容器 */}
<TouchableOpacity
style={styles.videoContainer}
onPress={handleVideoPress}
activeOpacity={1}
>
<View
style={styles.videoWrapper}
{...panResponder.panHandlers}
>
{/* 视频播放器 */}
{Platform.OS === 'web' ? (
<video
ref={videoRef as any}
src={videoUrl}
style={styles.video}
autoPlay={autoPlay}
loop={isLooping}
muted={isMuted}
playsInline
onLoadedData={() => setIsLoading(false)}
onError={handleVideoError}
/>
) : (
<Video
ref={videoRef}
source={{ uri: videoUrl }}
style={styles.video}
resizeMode={ResizeMode.CONTAIN}
shouldPlay={autoPlay}
isLooping={isLooping}
isMuted={isMuted}
useNativeControls={false}
onReadyForDisplay={handleVideoReady}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onError={handleVideoError}
/>
)}
{/* 加载指示器 */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#4ECDC4" />
</View>
)}
{/* 左右导航指示器 */}
{hasPrevious && (
<TouchableOpacity
style={styles.leftIndicator}
onPress={(e) => {
e.stopPropagation();
onPrevious?.();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
{hasNext && (
<TouchableOpacity
style={styles.rightIndicator}
onPress={(e) => {
e.stopPropagation();
onNext?.();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
videoContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
videoWrapper: {
flex: 1,
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
video: {
width: screenWidth,
height: screenHeight,
backgroundColor: '#000',
objectFit: 'cover'
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
leftIndicator: {
position: 'absolute',
left: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
rightIndicator: {
position: 'absolute',
right: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
indicatorIcon: {
fontSize: 24,
color: '#fff',
fontWeight: 'bold',
},
});
export default FullscreenVideoModal;

View File

@@ -5,6 +5,7 @@ import {
TouchableOpacity,
Dimensions,
ActivityIndicator,
Platform,
} from 'react-native';
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { Image } from 'expo-image';
@@ -33,8 +34,10 @@ export interface VideoPlayerProps {
maxHeight?: number;
aspectRatio?: number;
onReady?: (status: AVPlaybackStatus) => void;
onWebLoadedData?: () => void;
onError?: (error: any) => void;
onPress?: () => void;
fullscreenMode?: boolean; // 新增:全屏模式标志
}
export function VideoPlayer({
@@ -51,14 +54,18 @@ export function VideoPlayer({
maxHeight,
aspectRatio,
onReady,
onWebLoadedData,
onError,
onPress,
fullscreenMode = false, // 新增:默认非全屏模式
}: VideoPlayerProps) {
const videoRef = useRef<Video>(null);
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
const [videoMetadata, setVideoMetadata] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [showPosterOverlay, setShowPosterOverlay] = useState(showPoster);
const [hasVideoError, setHasVideoError] = useState(false);
const [shouldShowAsImage, setShouldShowAsImage] = useState(false);
const backgroundColor = useThemeColor({}, 'background');
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
@@ -67,6 +74,16 @@ export function VideoPlayer({
const isValidUrl = isValidVideoUrl(source.uri);
const videoFileType = getVideoFileType(source.uri);
// 如果URL看起来不是视频显示为图片
useEffect(() => {
if (!isValidUrl || !videoFileType) {
console.log('URL不是有效的视频格式将作为图片显示:', source.uri);
setShouldShowAsImage(true);
setHasVideoError(true);
setIsLoading(false);
}
}, [isValidUrl, videoFileType, source.uri]);
// 计算视频容器的最佳尺寸
const calculateVideoDimensions = () => {
const containerWidth = screenWidth - 40; // 减去padding
@@ -132,7 +149,16 @@ export function VideoPlayer({
// 处理视频加载错误
const handleVideoError = (error: any) => {
setIsLoading(false);
setHasVideoError(true);
console.error('视频加载失败:', error);
// 尝试作为图片显示
const isImageUrl = source.uri.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i);
if (isImageUrl) {
console.log('视频加载失败,尝试作为图片显示:', source.uri);
setShouldShowAsImage(true);
}
onError?.(error);
};
@@ -153,6 +179,11 @@ export function VideoPlayer({
return;
}
// 全屏模式下不处理视频播放逻辑,只传递点击事件
if (fullscreenMode) {
return;
}
// 如果视频已加载但未播放,开始播放
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
videoRef.current?.playAsync();
@@ -166,19 +197,50 @@ export function VideoPlayer({
}
}, [autoPlay, videoStatus]);
const isVideoLoaded = videoStatus?.isLoaded;
const isVideoPlaying = videoStatus?.isLoaded && videoStatus.isPlaying;
const isVideoLoaded = Platform.OS === 'web' ? !isLoading : videoStatus?.isLoaded;
const isVideoPlaying = Platform.OS === 'web' ? true : (videoStatus?.isLoaded && videoStatus.isPlaying);
// 如果应该显示为图片,显示图片而不是视频
if (shouldShowAsImage) {
return (
<TouchableOpacity
style={[styles.container, { height: videoSize.height }, style]}
onPress={handleContainerPress}
activeOpacity={0.9}
>
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
<Image
source={{ uri: source.uri }}
style={[
styles.poster,
{
width: '100%',
height: '100%',
backgroundColor: placeholderColor,
},
]}
contentFit="cover"
onLoad={() => setIsLoading(false)}
onError={(error) => {
console.error('图片加载失败:', error);
setIsLoading(false);
}}
/>
</ThemedView>
</TouchableOpacity>
);
}
return (
<TouchableOpacity
style={[styles.container, { height: videoSize.height }, style]}
onPress={handleContainerPress}
activeOpacity={0.9}
disabled={!onPress && useNativeControls}
activeOpacity={fullscreenMode ? 0.8 : 0.9}
disabled={fullscreenMode ? false : (!onPress && useNativeControls)}
>
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
{/* 封面图 */}
{poster && showPosterOverlay && (
{/* 封面图 - 只在非 Web 平台显示 */}
{poster && showPosterOverlay && Platform.OS !== 'web' && !hasVideoError && (
<Image
source={{ uri: poster }}
style={[
@@ -194,35 +256,87 @@ export function VideoPlayer({
)}
{/* 视频播放器 */}
<Video
ref={videoRef}
source={source}
style={[
styles.video,
{
width: '100%',
height: '100%',
},
]}
resizeMode={resizeMode}
shouldPlay={shouldPlay}
isLooping={isLooping}
isMuted={isMuted}
useNativeControls={useNativeControls}
onReadyForDisplay={handleVideoReady}
onError={handleVideoError}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
/>
{!hasVideoError && (
<>
{Platform.OS === 'web' ? (
<video
ref={videoRef}
src={source.uri}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
overflow: 'hidden',
cursor: fullscreenMode ? 'pointer' : 'default',
}}
autoPlay={autoPlay}
loop={isLooping}
muted={isMuted}
playsInline
onClick={(e) => {
// 全屏模式下,点击视频直接触发关闭
if (fullscreenMode && onPress) {
e.stopPropagation();
onPress();
}
}}
onLoadedData={() => {
setIsLoading(false);
onWebLoadedData?.(); // 通知父组件Web平台视频已加载
}}
onError={handleVideoError}
/>
) : (
<Video
ref={videoRef}
source={source}
style={styles.video}
resizeMode={resizeMode}
shouldPlay={shouldPlay}
isLooping={isLooping}
isMuted={isMuted}
useNativeControls={useNativeControls}
onReadyForDisplay={handleVideoReady}
onError={handleVideoError}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
/>
)}
</>
)}
{/* 加载指示器 */}
{isLoading && (
{/* 错误状态下显示为图片 */}
{hasVideoError && !shouldShowAsImage && (
<Image
source={{ uri: source.uri }}
style={[
styles.poster,
{
width: '100%',
height: '100%',
backgroundColor: placeholderColor,
},
]}
contentFit="cover"
onLoad={() => setIsLoading(false)}
onError={(error) => {
console.error('错误回退:图片也加载失败:', error);
setIsLoading(false);
}}
/>
)}
{/* 加载指示器 - 只在非 Web 平台显示 */}
{isLoading && Platform.OS !== 'web' && !hasVideoError && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#4ECDC4" />
</View>
)}
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) */}
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && (
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) - 全屏模式下不显示 */}
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && !hasVideoError && !fullscreenMode && (
<View style={styles.playButtonOverlay}>
<View style={styles.playButton}>
<ThemedView style={styles.playIcon}>
@@ -232,8 +346,8 @@ export function VideoPlayer({
</View>
)}
{/* 错误状态 */}
{!isLoading && !isVideoLoaded && (
{/* 错误状态 - 只在非 Web 平台显示 */}
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
<View style={styles.errorOverlay}>
<ThemedView style={styles.errorMessage}>
{'❌'}
@@ -265,8 +379,10 @@ const styles = StyleSheet.create({
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
overflow: 'hidden',
},
poster: {
poster: {
position: 'absolute',
top: 0,
left: 0,