Initial commit: expo-popcore project

This commit is contained in:
imeepos
2025-12-25 16:31:17 +08:00
commit 0cbb74e03a
188 changed files with 20796 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
import { ThemedText } from '@/components/themed-text';
import { VideoView, useVideoPlayer } from 'expo-video';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Modal,
PanResponder,
Platform,
StatusBar,
StyleSheet,
TouchableOpacity,
View,
BackHandler,
} from 'react-native';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export interface FullscreenVideoModalProps {
visible: boolean;
onClose: () => void;
videoUrl: string;
poster?: string;
autoPlay?: boolean;
isLooping?: boolean;
isMuted?: boolean;
onPrevious?: () => void;
onNext?: () => void;
hasNext?: boolean;
hasPrevious?: boolean;
}
export function FullscreenVideoModal({
visible,
onClose,
videoUrl,
poster,
autoPlay = true,
isLooping = true,
isMuted = false,
onPrevious,
onNext,
hasNext = false,
hasPrevious = false,
}: FullscreenVideoModalProps) {
// 使用useRef存储player实例避免无限循环
const playerRef = useRef<any>(null);
// 稳定videoUrl使用useMemo
const stableVideoUrl = useMemo(() => ({ uri: videoUrl }), [videoUrl]);
// 始终调用useVideoPlayer但只在videoUrl变化时更新player
const newPlayer = useVideoPlayer(
stableVideoUrl,
(player) => {
player.loop = isLooping;
player.muted = isMuted;
if (autoPlay) {
player.play();
}
}
);
// 只有当newPlayer变化时才更新player
useEffect(() => {
playerRef.current = newPlayer;
}, [newPlayer]);
// 获取当前的player实例
const player = playerRef.current || newPlayer;
const [isLoading, setIsLoading] = useState(true);
// 重置状态当模态框打开/关闭时
useEffect(() => {
if (visible) {
setIsLoading(true);
} else {
const currentPlayer = playerRef.current;
if (currentPlayer) {
currentPlayer.pause();
}
}
}, [visible, autoPlay]);
// 处理视频加载完成
const handleVideoReady = () => {
setIsLoading(false);
if (autoPlay) {
const currentPlayer = playerRef.current;
currentPlayer?.play();
}
};
// 处理视频错误
const handleVideoError = (error: any) => {
console.error('视频播放错误:', error);
setIsLoading(false);
};
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
// 视频点击处理(直接关闭)
const handleVideoPress = () => {
handleClose();
};
// 创建手势处理器
const panResponder = React.useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, gestureState) => {
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
},
onPanResponderRelease: (_, gestureState) => {
const threshold = screenWidth / 4;
if (gestureState.dx > threshold && hasPrevious) {
onPrevious?.();
} else if (gestureState.dx < -threshold && hasNext) {
onNext?.();
}
},
})
).current;
// 处理返回键Android
useEffect(() => {
const handleBackPress = () => {
if (visible) {
onClose();
return true;
}
return false;
};
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
return () => subscription.remove();
}, [visible, onClose]);
if (!visible) return null;
return (
<Modal
visible={visible}
transparent={false}
animationType="fade"
onRequestClose={handleClose}
statusBarTranslucent={true}
>
<View style={styles.container}>
{Platform.OS === 'android' && <StatusBar hidden />}
<TouchableOpacity
style={styles.videoContainer}
onPress={handleVideoPress}
activeOpacity={1}
>
<View
style={styles.videoWrapper}
{...panResponder.panHandlers}
>
{Platform.OS === 'web' ? (
<video
src={videoUrl}
style={styles.video}
autoPlay={autoPlay}
loop={isLooping}
muted={isMuted}
playsInline
onLoadedData={() => setIsLoading(false)}
onError={handleVideoError}
/>
) : (
<VideoView
player={player}
style={styles.video}
contentFit="contain"
nativeControls={false}
/>
)}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#4ECDC4" />
</View>
)}
{hasPrevious && (
<TouchableOpacity
style={styles.leftIndicator}
onPress={(e) => {
e.stopPropagation();
onPrevious?.();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
{hasNext && (
<TouchableOpacity
style={styles.rightIndicator}
onPress={(e) => {
e.stopPropagation();
onNext?.();
}}
activeOpacity={0.7}
>
<ThemedText style={styles.indicatorIcon}></ThemedText>
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
videoContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
videoWrapper: {
flex: 1,
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
video: {
width: screenWidth,
height: screenHeight,
backgroundColor: '#000',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
leftIndicator: {
position: 'absolute',
left: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
rightIndicator: {
position: 'absolute',
right: 20,
top: '50%',
marginTop: -20,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
indicatorIcon: {
fontSize: 24,
color: '#fff',
fontWeight: 'bold',
},
});
export default FullscreenVideoModal;

View File

@@ -0,0 +1,392 @@
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;