Initial commit: Expo app with Better Auth integration
- Complete Expo React Native app setup with TypeScript - Better Auth authentication system integration - Secure storage implementation for session tokens - Authentication flow with login/logout functionality - API client configuration for backend communication - Responsive UI components with themed styling - Expo Router navigation setup - Development configuration and scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
326
components/video/video-player.tsx
Normal file
326
components/video/video-player.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} 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;
|
||||
onError?: (error: any) => void;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
source,
|
||||
poster,
|
||||
style,
|
||||
resizeMode = ResizeMode.CONTAIN,
|
||||
shouldPlay = false,
|
||||
isLooping = true,
|
||||
isMuted = true,
|
||||
useNativeControls = false,
|
||||
showPoster = true,
|
||||
autoPlay = false,
|
||||
maxHeight,
|
||||
aspectRatio,
|
||||
onReady,
|
||||
onError,
|
||||
onPress,
|
||||
}: 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 backgroundColor = useThemeColor({}, 'background');
|
||||
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||
|
||||
// 验证视频URL
|
||||
const isValidUrl = isValidVideoUrl(source.uri);
|
||||
const videoFileType = getVideoFileType(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);
|
||||
console.error('视频加载失败:', error);
|
||||
onError?.(error);
|
||||
};
|
||||
|
||||
// 处理视频播放状态变化
|
||||
const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
|
||||
setVideoStatus(status);
|
||||
|
||||
// 视频开始播放时隐藏封面
|
||||
if (status.isLoaded && status.isPlaying && showPosterOverlay) {
|
||||
setShowPosterOverlay(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理容器点击
|
||||
const handleContainerPress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果视频已加载但未播放,开始播放
|
||||
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
||||
videoRef.current?.playAsync();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动播放逻辑
|
||||
useEffect(() => {
|
||||
if (autoPlay && videoRef.current && videoStatus?.isLoaded) {
|
||||
videoRef.current.playAsync();
|
||||
}
|
||||
}, [autoPlay, videoStatus]);
|
||||
|
||||
const isVideoLoaded = videoStatus?.isLoaded;
|
||||
const isVideoPlaying = videoStatus?.isLoaded && videoStatus.isPlaying;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { height: videoSize.height }, style]}
|
||||
onPress={handleContainerPress}
|
||||
activeOpacity={0.9}
|
||||
disabled={!onPress && useNativeControls}
|
||||
>
|
||||
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
||||
{/* 封面图 */}
|
||||
{poster && showPosterOverlay && (
|
||||
<Image
|
||||
source={{ uri: poster }}
|
||||
style={[
|
||||
styles.poster,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: placeholderColor,
|
||||
},
|
||||
]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={source}
|
||||
style={[
|
||||
styles.video,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]}
|
||||
resizeMode={resizeMode}
|
||||
shouldPlay={shouldPlay}
|
||||
isLooping={isLooping}
|
||||
isMuted={isMuted}
|
||||
useNativeControls={useNativeControls}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
onError={handleVideoError}
|
||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||
/>
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) */}
|
||||
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && (
|
||||
<View style={styles.playButtonOverlay}>
|
||||
<View style={styles.playButton}>
|
||||
<ThemedView style={styles.playIcon}>
|
||||
{'▶️'}
|
||||
</ThemedView>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{!isLoading && !isVideoLoaded && (
|
||||
<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%',
|
||||
},
|
||||
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;
|
||||
Reference in New Issue
Block a user