fix: bug
This commit is contained in:
247
components/image/fullscreen-image-modal.tsx
Normal file
247
components/image/fullscreen-image-modal.tsx
Normal 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;
|
||||
309
components/media/fullscreen-media-modal.tsx
Normal file
309
components/media/fullscreen-media-modal.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
132
components/user/balance-card.tsx
Normal file
132
components/user/balance-card.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
319
components/user/generation-records.tsx
Normal file
319
components/user/generation-records.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
165
components/user/settings-list.tsx
Normal file
165
components/user/settings-list.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
110
components/user/user-avatar.tsx
Normal file
110
components/user/user-avatar.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
294
components/video/fullscreen-video-modal.tsx
Normal file
294
components/video/fullscreen-video-modal.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user