393 lines
9.9 KiB
TypeScript
393 lines
9.9 KiB
TypeScript
import { ThemedView } from '@/components/themed-view';
|
||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||
import {
|
||
calculateOptimalVideoSize,
|
||
getVideoFileType,
|
||
isValidVideoUrl
|
||
} from '@/utils/video-utils';
|
||
import { Image } from 'expo-image';
|
||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Dimensions,
|
||
Platform,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import * as Device from 'expo-device';
|
||
|
||
const isEmulator = !Device.isDevice;
|
||
|
||
// ResizeMode 兼容映射
|
||
const ResizeMode = {
|
||
CONTAIN: 'contain' as const,
|
||
COVER: 'cover' as const,
|
||
STRETCH: 'stretch' as const,
|
||
};
|
||
|
||
// 兼容性状态类型
|
||
interface AVPlaybackStatus {
|
||
isLoaded: boolean;
|
||
isPlaying?: boolean;
|
||
durationMillis?: number;
|
||
naturalSize?: {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
positionMillis?: number;
|
||
}
|
||
|
||
const { width: screenWidth } = Dimensions.get('window');
|
||
|
||
export interface VideoPlayerProps {
|
||
source: { uri: string };
|
||
poster?: string;
|
||
style?: any;
|
||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||
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) {
|
||
// 使用useMemo稳定source对象,避免不必要的重新渲染
|
||
const stableSource = useMemo(() => ({ uri: source.uri }), [source.uri]);
|
||
|
||
// 使用useRef存储player实例,避免无限循环
|
||
const playerRef = useRef<any>(null);
|
||
|
||
// 始终调用useVideoPlayer,但只在source变化时更新player
|
||
const newPlayer = useVideoPlayer(
|
||
stableSource,
|
||
(player) => {
|
||
// 空初始化回调
|
||
}
|
||
);
|
||
|
||
// 只有当newPlayer变化时才更新player
|
||
useEffect(() => {
|
||
playerRef.current = newPlayer;
|
||
}, [newPlayer]);
|
||
|
||
// 单独处理播放属性变化
|
||
useEffect(() => {
|
||
const player = playerRef.current;
|
||
if (player) {
|
||
player.loop = isLooping;
|
||
player.muted = isMuted;
|
||
}
|
||
}, [isLooping, isMuted]);
|
||
|
||
// 处理自动播放
|
||
useEffect(() => {
|
||
const player = playerRef.current;
|
||
if (player) {
|
||
if (autoPlay && shouldPlay) {
|
||
player.play();
|
||
} else if (!shouldPlay) {
|
||
player.pause();
|
||
}
|
||
}
|
||
}, [autoPlay, shouldPlay]);
|
||
|
||
// 获取当前的player实例
|
||
const player = playerRef.current || newPlayer;
|
||
|
||
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
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);
|
||
|
||
const shouldDisableVideoOnEmulator = Platform.OS === 'android' && isEmulator;
|
||
|
||
// 如果URL看起来不是视频,或者在Android模拟器上,显示为图片
|
||
useEffect(() => {
|
||
if (!isValidUrl || !videoFileType) {
|
||
setShouldShowAsImage(true);
|
||
setHasVideoError(true);
|
||
setIsLoading(false);
|
||
} else if (shouldDisableVideoOnEmulator) {
|
||
setShouldShowAsImage(true);
|
||
setHasVideoError(true);
|
||
setIsLoading(false);
|
||
}
|
||
}, [isValidUrl, videoFileType, source.uri, shouldDisableVideoOnEmulator]);
|
||
|
||
// 计算视频容器的最佳尺寸
|
||
const calculateVideoDimensions = () => {
|
||
const containerWidth = screenWidth - 40;
|
||
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 contentFit = resizeMode === ResizeMode.COVER ? 'cover' : 'contain';
|
||
return calculateOptimalVideoSize(
|
||
containerWidth,
|
||
maxHeight || defaultHeight,
|
||
videoMetadata,
|
||
contentFit as any
|
||
);
|
||
}
|
||
|
||
return {
|
||
width: containerWidth,
|
||
height: defaultHeight,
|
||
};
|
||
};
|
||
|
||
const videoSize = calculateVideoDimensions();
|
||
|
||
// 处理视频加载完成
|
||
const handleVideoReady = () => {
|
||
setIsLoading(false);
|
||
onReady?.({
|
||
isLoaded: true,
|
||
} as AVPlaybackStatus);
|
||
};
|
||
|
||
// 处理视频加载错误
|
||
const handleVideoError = (error: any) => {
|
||
setIsLoading(false);
|
||
setHasVideoError(true);
|
||
console.warn('视频加载失败:', error);
|
||
|
||
const isImageUrl = source.uri.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i);
|
||
if (isImageUrl) {
|
||
setShouldShowAsImage(true);
|
||
}
|
||
|
||
onError?.(error);
|
||
};
|
||
|
||
// 处理容器点击
|
||
const handleContainerPress = () => {
|
||
if (onPress) {
|
||
onPress();
|
||
return;
|
||
}
|
||
|
||
if (fullscreenMode) {
|
||
return;
|
||
}
|
||
|
||
if (Platform.OS === 'web') {
|
||
return;
|
||
}
|
||
|
||
if (player) {
|
||
try {
|
||
if (player.playing) {
|
||
player.pause();
|
||
} else {
|
||
player.play().catch((e: any) => {
|
||
console.warn('视频播放失败:', e);
|
||
handleVideoError(e);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('视频控制失败:', e);
|
||
handleVideoError(e);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 如果应该显示为图片,显示图片而不是视频
|
||
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 }]}>
|
||
{/* 视频播放器 */}
|
||
{!hasVideoError && (
|
||
<>
|
||
{Platform.OS === 'web' ? (
|
||
<video
|
||
src={source.uri}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: resizeMode === ResizeMode.CONTAIN ? 'contain' : '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?.();
|
||
}}
|
||
onError={handleVideoError}
|
||
/>
|
||
) : (
|
||
<VideoView
|
||
player={player}
|
||
style={styles.video}
|
||
contentFit={resizeMode as any}
|
||
nativeControls={useNativeControls}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 错误状态下显示为图片 */}
|
||
{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);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 加载指示器 */}
|
||
{isLoading && (
|
||
<View style={styles.loadingOverlay}>
|
||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||
</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%',
|
||
},
|
||
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,
|
||
},
|
||
});
|
||
|
||
export default VideoPlayer;
|