bw-expo-app/components/video/video-player.tsx

442 lines
12 KiB
TypeScript
Raw 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 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;