KeyStone / keystone-app /components /FullScreenSwiper.tsx
dpv007's picture
fix: resolve 404 on thumbnail serve, scrubber pan responder, zooming issues, and swiper alignment
e179c31
Raw
History Blame Contribute Delete
6.41 kB
import React, { useRef, useState, useEffect } from 'react';
import { View, StyleSheet, Platform, useWindowDimensions, PanResponder } from 'react-native';
import { Image } from 'expo-image';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { FlatList } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import CustomVideoPlayer from './CustomVideoPlayer';
interface FullScreenSwiperProps {
assets: any[];
initialIndex: number;
onClose: () => void;
onIndexChange: (index: number) => void;
}
/* ─────────────────────────────────────────────────────────────
ZoomableImage
β€’ Gesture.Pinch() β€” native multi-touch pinch-to-zoom
β€’ Gesture.Pan() β€” single-finger swipe-down-to-close
β€’ Both run on the UI thread via reanimated worklets
───────────────────────────────────────────────────────────── */
function ZoomableImage({
uri, thumbUri, width, height, onSwipeDown, onZoomChange,
}: {
uri: string; thumbUri?: string; width: number;
onSwipeDown: () => void; onZoomChange: (zooming: boolean) => void;
}) {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateY = useSharedValue(0);
const pinchGesture = Gesture.Pinch()
.onStart(() => {
'worklet';
runOnJS(onZoomChange)(true);
})
.onUpdate((e) => {
'worklet';
scale.value = Math.max(0.5, Math.min(5, savedScale.value * e.scale));
})
.onEnd(() => {
'worklet';
runOnJS(onZoomChange)(false);
if (scale.value < 1.15) {
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
savedScale.value = 1;
} else {
savedScale.value = Math.min(5, scale.value);
}
});
const panGesture = Gesture.Pan()
.minPointers(1)
.maxPointers(1)
.activeOffsetY(20)
.failOffsetX([-15, 15])
.onStart(() => {
'worklet';
runOnJS(onZoomChange)(true);
})
.onUpdate((e) => {
'worklet';
translateY.value = e.translationY;
})
.onEnd((e) => {
'worklet';
runOnJS(onZoomChange)(false);
if (e.translationY > 100) {
runOnJS(onSwipeDown)();
} else {
translateY.value = withSpring(0, { damping: 15, stiffness: 150 });
}
});
const composedGesture = Gesture.Simultaneous(pinchGesture, panGesture);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ scale: scale.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={composedGesture}>
<Animated.View
style={[
{ width, height: '100%', justifyContent: 'center', alignItems: 'center' },
animatedStyle,
]}
>
<Image
source={{ uri }}
placeholder={thumbUri ? { uri: thumbUri } : undefined}
style={{ width: '100%', height: '100%' }}
contentFit="contain"
cachePolicy="memory-disk"
transition={200}
backgroundColor="transparent"
allowDownscaling={false}
/>
</Animated.View>
</GestureDetector>
);
}
/* ─────────────────────────────────────────────────────────────
FullScreenSwiper
Uses RNGH's FlatList so gesture handlers inside items
properly negotiate with the scroll gesture on Android.
───────────────────────────────────────────────────────────── */
export default function FullScreenSwiper({
assets, initialIndex, onClose, onIndexChange,
}: FullScreenSwiperProps) {
const { width } = useWindowDimensions();
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollEnabled, setScrollEnabled] = useState(true);
const onIndexChangeRef = useRef(onIndexChange);
useEffect(() => { onIndexChangeRef.current = onIndexChange; }, [onIndexChange]);
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
if (viewableItems.length > 0) {
const idx = viewableItems[0].index;
setActiveIndex(idx);
onIndexChangeRef.current(idx);
}
}).current;
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 50 }).current;
const renderItem = ({ item, index }: { item: any; index: number }) => {
const isVideo =
item.mediaType === 'video' ||
item.filename?.toLowerCase().match(/\.(mp4|mov|avi|webm)$/i);
const isActive = index === activeIndex;
if (isVideo) {
return (
<View style={{ width, height: '100%', justifyContent: 'center', backgroundColor: '#000' }}>
<CustomVideoPlayer
uri={item.uri}
shouldPlay={isActive}
onScrubStart={() => setScrollEnabled(false)}
onScrubEnd={() => setScrollEnabled(true)}
/>
</View>
);
}
return (
<ZoomableImage
uri={item.uri}
thumbUri={item.cloudThumbUri}
width={width}
onSwipeDown={onClose}
onZoomChange={(zooming) => setScrollEnabled(!zooming)}
/>
);
};
return (
<View style={styles.container}>
<FlatList
key={`swiper-${width}`}
data={assets}
keyExtractor={(item) => item.id}
renderItem={renderItem}
horizontal
pagingEnabled
scrollEnabled={scrollEnabled}
showsHorizontalScrollIndicator={false}
initialScrollIndex={activeIndex}
getItemLayout={(_, index) => ({
length: width,
offset: width * index,
index,
})}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
windowSize={3}
maxToRenderPerBatch={3}
removeClippedSubviews={Platform.OS === 'android'}
extraData={activeIndex}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
});