214 lines
5.3 KiB
TypeScript
214 lines
5.3 KiB
TypeScript
import { Image } from 'expo-image';
|
|
import { useVideoPlayer, VideoView } from 'expo-video';
|
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
|
import { Platform, View, ActivityIndicator } from 'react-native';
|
|
import type { VideoPlayerProps } from './types';
|
|
import * as Device from 'expo-device';
|
|
import { useSharedVisibility } from './useSharedVisibility';
|
|
import { playerPool } from './player-pool';
|
|
import { getVideoThumbnail } from '@/lib/utils/media';
|
|
|
|
const isEmulator = !Device.isDevice;
|
|
|
|
export default function VideoPlayer({
|
|
source,
|
|
originalImage,
|
|
style,
|
|
dwellTime = 800,
|
|
threshold = 0.2
|
|
}: VideoPlayerProps) {
|
|
const { ref: viewRef, isVisible, shouldPlay } = useSharedVisibility(threshold, dwellTime);
|
|
const [videoReady, setVideoReady] = useState(false);
|
|
const [hasSlot, setHasSlot] = useState(false);
|
|
const [playerCreated, setPlayerCreated] = useState(false);
|
|
const [imageError, setImageError] = useState(false);
|
|
const isMountedRef = useRef(true);
|
|
const lastSourceRef = useRef<string | undefined>(undefined);
|
|
|
|
const url = useMemo(() => {
|
|
const uri = typeof source === 'object' ? source?.uri : source;
|
|
return uri ? `${uri}` : '';
|
|
}, [source]);
|
|
|
|
const isVideo = useMemo(() =>
|
|
/\.(mp4|webm|ogg|mov|avi)$/i.test(url || ''),
|
|
[url]
|
|
);
|
|
|
|
const thumbnailUrl = useMemo(() => {
|
|
if (!isVideo) return url;
|
|
return getVideoThumbnail(url, { time: '0s', width: 600, height: 600, fit: 'cover' });
|
|
}, [url, isVideo]);
|
|
|
|
const shouldUseVideo = isVideo && (Platform.OS !== 'android' || !isEmulator);
|
|
const shouldRequestSlot = shouldUseVideo && isVisible && shouldPlay;
|
|
const shouldCreatePlayer = shouldRequestSlot && hasSlot && playerCreated;
|
|
|
|
const player = useVideoPlayer(shouldCreatePlayer ? source : null, (p) => {
|
|
p.loop = true;
|
|
p.muted = true;
|
|
});
|
|
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!shouldRequestSlot) {
|
|
if (hasSlot) {
|
|
playerPool.release();
|
|
setHasSlot(false);
|
|
setPlayerCreated(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
|
|
playerPool.acquire().then(() => {
|
|
if (active && isMountedRef.current) {
|
|
setHasSlot(true);
|
|
setTimeout(() => {
|
|
if (active && isMountedRef.current) {
|
|
setPlayerCreated(true);
|
|
}
|
|
}, 100);
|
|
} else {
|
|
playerPool.release();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
if (hasSlot) {
|
|
playerPool.release();
|
|
setHasSlot(false);
|
|
setPlayerCreated(false);
|
|
}
|
|
};
|
|
}, [shouldRequestSlot]);
|
|
|
|
useEffect(() => {
|
|
if (!shouldCreatePlayer || !player) {
|
|
setVideoReady(false);
|
|
return;
|
|
}
|
|
|
|
if (lastSourceRef.current !== url) {
|
|
setVideoReady(false);
|
|
lastSourceRef.current = url;
|
|
}
|
|
|
|
const statusListener = player.addListener('statusChange', (status) => {
|
|
if (status.status === 'readyToPlay' && isMountedRef.current) {
|
|
setVideoReady(true);
|
|
}
|
|
});
|
|
|
|
try {
|
|
player.play();
|
|
} catch (error) {
|
|
if (__DEV__) {
|
|
console.warn('[VideoPlayer] Play failed:', error);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
statusListener.remove();
|
|
};
|
|
}, [shouldCreatePlayer, player, url]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (player) {
|
|
try {
|
|
player.pause();
|
|
player.replace(null);
|
|
} catch (error) {
|
|
if (__DEV__) {
|
|
console.warn('[VideoPlayer] Cleanup failed:', error);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}, [player]);
|
|
|
|
const renderContent = () => {
|
|
if (shouldUseVideo && player && videoReady) {
|
|
return (
|
|
<>
|
|
<VideoView
|
|
player={player}
|
|
contentFit="cover"
|
|
nativeControls={false}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%'
|
|
}}
|
|
/>
|
|
{originalImage && (
|
|
<Image
|
|
source={{ uri: originalImage }}
|
|
style={{
|
|
width: 64,
|
|
height: 64,
|
|
position: 'absolute',
|
|
bottom: 8,
|
|
left: 8,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: '#ffffff',
|
|
zIndex: 99
|
|
}}
|
|
contentFit="cover"
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (thumbnailUrl && (!isVisible || !shouldPlay || !videoReady)) {
|
|
return (
|
|
<Image
|
|
source={{ uri: thumbnailUrl }}
|
|
style={{ width: '100%', height: '100%' }}
|
|
contentFit="cover"
|
|
contentPosition="center"
|
|
cachePolicy="memory-disk"
|
|
priority="high"
|
|
transition={200}
|
|
onError={(error) => {
|
|
if (__DEV__) {
|
|
console.warn('[VideoPlayer] Thumbnail failed to load:', thumbnailUrl, error);
|
|
}
|
|
setImageError(true);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (imageError) {
|
|
return (
|
|
<View style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
backgroundColor: '#1A1A1A',
|
|
justifyContent: 'center',
|
|
alignItems: 'center'
|
|
}} />
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<View ref={viewRef as any} style={[{ width: '100%', backgroundColor: '#1A1A1A' }, style]}>
|
|
{renderContent()}
|
|
</View>
|
|
);
|
|
}
|