108 lines
3.1 KiB
TypeScript
108 lines
3.1 KiB
TypeScript
import type { ReactElement } from 'react';
|
|
import { Modal, Pressable, StyleSheet, View } from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import { useVideoPlayer, VideoView } from 'expo-video';
|
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
import Animated, {
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from 'react-native-reanimated';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { Icon } from '@/components';
|
|
|
|
export interface ViewerSource {
|
|
url: string;
|
|
isVideo: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
source: ViewerSource | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const ZoomableImage = ({ url }: { url: string }): ReactElement => {
|
|
const scale = useSharedValue(1);
|
|
const savedScale = useSharedValue(1);
|
|
const translateX = useSharedValue(0);
|
|
const translateY = useSharedValue(0);
|
|
|
|
const pinch = Gesture.Pinch()
|
|
.onUpdate((event) => {
|
|
scale.value = Math.max(1, savedScale.value * event.scale);
|
|
})
|
|
.onEnd(() => {
|
|
savedScale.value = scale.value;
|
|
if (scale.value <= 1) {
|
|
translateX.value = withTiming(0);
|
|
translateY.value = withTiming(0);
|
|
}
|
|
});
|
|
const pan = Gesture.Pan()
|
|
.onUpdate((event) => {
|
|
if (scale.value > 1) {
|
|
translateX.value = event.translationX;
|
|
translateY.value = event.translationY;
|
|
}
|
|
});
|
|
const composed = Gesture.Simultaneous(pinch, pan);
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
transform: [
|
|
{ translateX: translateX.value },
|
|
{ translateY: translateY.value },
|
|
{ scale: scale.value },
|
|
],
|
|
}));
|
|
|
|
return (
|
|
<GestureDetector gesture={composed}>
|
|
<Animated.View style={[styles.fill, animatedStyle]}>
|
|
<Image source={{ uri: url }} style={styles.fill} contentFit="contain" />
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
);
|
|
};
|
|
|
|
const ViewerVideo = ({ url }: { url: string }): ReactElement => {
|
|
const player = useVideoPlayer({ uri: url }, (instance) => {
|
|
instance.play();
|
|
});
|
|
return <VideoView player={player} style={styles.fill} contentFit="contain" />;
|
|
};
|
|
|
|
// Full-screen image (pinch-zoom) / video overlay opened from a media bubble.
|
|
export const MediaViewer = ({ source, onClose }: Props): ReactElement => {
|
|
const insets = useSafeAreaInsets();
|
|
return (
|
|
<Modal visible={source !== null} transparent animationType="fade" onRequestClose={onClose}>
|
|
<View style={styles.backdrop}>
|
|
{source !== null ? (
|
|
source.isVideo ? (
|
|
<ViewerVideo url={source.url} />
|
|
) : (
|
|
<ZoomableImage url={source.url} />
|
|
)
|
|
) : null}
|
|
<Pressable style={[styles.close, { top: insets.top + 8 }]} onPress={onClose} hitSlop={8}>
|
|
<Icon name="close" size={26} color="#fff" />
|
|
</Pressable>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
backdrop: { flex: 1, backgroundColor: '#000' },
|
|
fill: { flex: 1, width: '100%', height: '100%' },
|
|
close: {
|
|
position: 'absolute',
|
|
right: 16,
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
});
|