expo-popcore-old/components/video/video-player.tsx

393 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ThemedView } from '@/components/themed-view';
import { 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;