442 lines
12 KiB
TypeScript
442 lines
12 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import {
|
||
View,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
Dimensions,
|
||
ActivityIndicator,
|
||
Platform,
|
||
} from 'react-native';
|
||
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
||
import { Image } from 'expo-image';
|
||
import { ThemedView } from '@/components/themed-view';
|
||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||
import {
|
||
extractVideoMetadata,
|
||
calculateOptimalVideoSize,
|
||
getVideoFileType,
|
||
isValidVideoUrl
|
||
} from '@/utils/video-utils';
|
||
|
||
const { width: screenWidth } = Dimensions.get('window');
|
||
|
||
export interface VideoPlayerProps {
|
||
source: { uri: string };
|
||
poster?: string;
|
||
style?: any;
|
||
resizeMode?: ResizeMode;
|
||
shouldPlay?: boolean;
|
||
isLooping?: boolean;
|
||
isMuted?: boolean;
|
||
useNativeControls?: boolean;
|
||
showPoster?: boolean;
|
||
autoPlay?: boolean;
|
||
maxHeight?: number;
|
||
aspectRatio?: number;
|
||
onReady?: (status: AVPlaybackStatus) => void;
|
||
onWebLoadedData?: () => void;
|
||
onError?: (error: any) => void;
|
||
onPress?: () => void;
|
||
fullscreenMode?: boolean; // 新增:全屏模式标志
|
||
}
|
||
|
||
export function VideoPlayer({
|
||
source,
|
||
poster,
|
||
style,
|
||
resizeMode = ResizeMode.CONTAIN,
|
||
shouldPlay = false,
|
||
isLooping = true,
|
||
isMuted = true,
|
||
useNativeControls = false,
|
||
showPoster = true,
|
||
autoPlay = false,
|
||
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');
|
||
|
||
// 验证视频URL
|
||
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
|
||
const defaultHeight = maxHeight || screenWidth * 0.75;
|
||
|
||
// 如果有预设的宽高比,使用它
|
||
if (aspectRatio) {
|
||
const calculatedHeight = containerWidth / aspectRatio;
|
||
return {
|
||
width: containerWidth,
|
||
height: maxHeight ? Math.min(calculatedHeight, maxHeight) : calculatedHeight,
|
||
};
|
||
}
|
||
|
||
// 如果有视频元数据,使用它来计算最佳尺寸
|
||
if (videoMetadata) {
|
||
const resizeModeEnum = resizeMode === ResizeMode.COVER ? 'cover' : 'contain';
|
||
return calculateOptimalVideoSize(
|
||
containerWidth,
|
||
maxHeight || defaultHeight,
|
||
videoMetadata,
|
||
resizeModeEnum
|
||
);
|
||
}
|
||
|
||
// 默认尺寸
|
||
return {
|
||
width: containerWidth,
|
||
height: defaultHeight,
|
||
};
|
||
};
|
||
|
||
const videoSize = calculateVideoDimensions();
|
||
|
||
// 处理视频加载完成
|
||
const handleVideoReady = (event: any) => {
|
||
const status = event.nativeEvent || event;
|
||
setVideoStatus(status);
|
||
setIsLoading(false);
|
||
|
||
if (status.isLoaded) {
|
||
// 提取视频元数据
|
||
const metadata = extractVideoMetadata(status);
|
||
if (metadata) {
|
||
setVideoMetadata(metadata);
|
||
console.log('视频元数据:', {
|
||
尺寸: `${metadata.width}×${metadata.height}`,
|
||
宽高比: metadata.aspectRatio.toFixed(2),
|
||
时长: metadata.duration.toFixed(2) + '秒',
|
||
方向: metadata.orientation,
|
||
});
|
||
}
|
||
|
||
// 如果有封面图且设置了自动播放,隐藏封面
|
||
if (showPosterOverlay && autoPlay) {
|
||
setShowPosterOverlay(false);
|
||
}
|
||
}
|
||
|
||
onReady?.(status);
|
||
};
|
||
|
||
// 处理视频加载错误
|
||
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);
|
||
};
|
||
|
||
// 处理视频播放状态变化
|
||
const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
|
||
setVideoStatus(status);
|
||
|
||
// 视频开始播放时隐藏封面
|
||
if (status.isLoaded && status.isPlaying && showPosterOverlay) {
|
||
setShowPosterOverlay(false);
|
||
}
|
||
};
|
||
|
||
// 处理容器点击
|
||
const handleContainerPress = () => {
|
||
if (onPress) {
|
||
onPress();
|
||
return;
|
||
}
|
||
|
||
// 全屏模式下不处理视频播放逻辑,只传递点击事件
|
||
if (fullscreenMode) {
|
||
return;
|
||
}
|
||
|
||
// 如果视频已加载但未播放,开始播放
|
||
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
||
videoRef.current?.playAsync();
|
||
}
|
||
};
|
||
|
||
// 自动播放逻辑
|
||
useEffect(() => {
|
||
if (autoPlay && videoRef.current && videoStatus?.isLoaded) {
|
||
videoRef.current.playAsync();
|
||
}
|
||
}, [autoPlay, videoStatus]);
|
||
|
||
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={fullscreenMode ? 0.8 : 0.9}
|
||
disabled={fullscreenMode ? false : (!onPress && useNativeControls)}
|
||
>
|
||
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
||
{/* 封面图 - 只在非 Web 平台显示 */}
|
||
{poster && showPosterOverlay && Platform.OS !== 'web' && !hasVideoError && (
|
||
<Image
|
||
source={{ uri: poster }}
|
||
style={[
|
||
styles.poster,
|
||
{
|
||
width: '100%',
|
||
height: '100%',
|
||
backgroundColor: placeholderColor,
|
||
},
|
||
]}
|
||
contentFit="cover"
|
||
/>
|
||
)}
|
||
|
||
{/* 视频播放器 */}
|
||
{!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}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 错误状态下显示为图片 */}
|
||
{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 && !hasVideoError && !fullscreenMode && (
|
||
<View style={styles.playButtonOverlay}>
|
||
<View style={styles.playButton}>
|
||
<ThemedView style={styles.playIcon}>
|
||
{'▶️'}
|
||
</ThemedView>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 错误状态 - 只在非 Web 平台显示 */}
|
||
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
|
||
<View style={styles.errorOverlay}>
|
||
<ThemedView style={styles.errorMessage}>
|
||
{'❌'}
|
||
</ThemedView>
|
||
</View>
|
||
)}
|
||
</ThemedView>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
width: '100%',
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
},
|
||
videoContainer: {
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '100%',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
overflow: 'hidden',
|
||
},
|
||
video: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
overflow: 'hidden',
|
||
},
|
||
poster: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
zIndex: 1,
|
||
},
|
||
loadingOverlay: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||
zIndex: 2,
|
||
},
|
||
playButtonOverlay: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
zIndex: 2,
|
||
},
|
||
playButton: {
|
||
width: 64,
|
||
height: 64,
|
||
borderRadius: 32,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
playIcon: {
|
||
fontSize: 24,
|
||
color: '#fff',
|
||
textAlign: 'center' as any,
|
||
},
|
||
errorOverlay: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||
zIndex: 2,
|
||
},
|
||
errorMessage: {
|
||
fontSize: 32,
|
||
opacity: 0.5,
|
||
},
|
||
});
|
||
|
||
export default VideoPlayer; |