Files
bw-expo-app/components/video/video-player.tsx
imeepos af64049c69 feat: 完整应用重构 - 优化界面架构与用户体验
主要变更:
- 重构应用界面:优化首页、登录、认证流程
- 新增用户档案模块:包含完整的档案管理组件
- 优化路由结构:重新组织页面布局和导航
- 改进API集成:新增活动、内容分类等API模块
- 删除冗余组件:清理不必要的文件和依赖

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:44:12 +08:00

459 lines
13 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 { ThemedText } from '@/components/themed-text';
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 nativeVideoRef = useRef<Video | null>(null);
const webVideoRef = useRef<HTMLVideoElement | null>(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 (Platform.OS === 'web') {
webVideoRef.current?.play().catch(() => {});
return;
}
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
nativeVideoRef.current?.playAsync().catch(() => {});
}
};
// 自动播放逻辑
useEffect(() => {
if (!autoPlay) {
return;
}
if (Platform.OS === 'web') {
webVideoRef.current?.play().catch(() => {});
return;
}
if (videoStatus?.isLoaded) {
nativeVideoRef.current?.playAsync().catch(() => {});
}
}, [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={webVideoRef}
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={nativeVideoRef}
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}>
<ThemedText style={styles.playIcon}>
{'▶️'}
</ThemedText>
</View>
</View>
)}
{/* 错误状态 - 只在非 Web 平台显示 */}
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
<View style={styles.errorOverlay}>
<ThemedText style={styles.errorMessage}>
{'❌'}
</ThemedText>
</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;