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:
imeepos
2025-10-14 10:32:52 +08:00
commit 7e3f94bae3
71 changed files with 20800 additions and 0 deletions

View 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;