Initial commit: expo-popcore project
This commit is contained in:
285
components/video/fullscreen-video-modal.tsx
Normal file
285
components/video/fullscreen-video-modal.tsx
Normal 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;
|
||||
392
components/video/video-player.tsx
Normal file
392
components/video/video-player.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user