dpv007 commited on
Commit
bb27193
Β·
1 Parent(s): f0ba57a
keystone-app/components/CustomVideoPlayer.tsx CHANGED
@@ -5,8 +5,6 @@ import {
5
  TouchableOpacity,
6
  Text,
7
  TouchableWithoutFeedback,
8
- PanResponder,
9
- Animated
10
  } from 'react-native';
11
  import { useVideoPlayer, VideoView } from 'expo-video';
12
  import { MaterialIcons } from '@expo/vector-icons';
@@ -15,24 +13,30 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
15
  interface CustomVideoPlayerProps {
16
  uri: string;
17
  shouldPlay: boolean;
18
- onSingleTap?: () => void;
 
19
  }
20
 
21
- export default function CustomVideoPlayer({ uri, shouldPlay, onSingleTap }: CustomVideoPlayerProps) {
 
 
22
  const insets = useSafeAreaInsets();
23
  const [isPlaying, setIsPlaying] = useState(shouldPlay);
24
  const [currentTime, setCurrentTime] = useState(0);
25
  const [duration, setDuration] = useState(0);
26
  const [showControls, setShowControls] = useState(true);
27
-
28
- const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
 
 
 
29
 
30
  const player = useVideoPlayer(uri, (p) => {
31
  p.loop = true;
32
  if (shouldPlay) p.play();
33
  });
34
 
35
- // Sync external shouldPlay with internal state
36
  useEffect(() => {
37
  if (shouldPlay) {
38
  player.play();
@@ -48,10 +52,7 @@ export default function CustomVideoPlayer({ uri, shouldPlay, onSingleTap }: Cust
48
  const interval = setInterval(() => {
49
  if (player) {
50
  setCurrentTime(player.currentTime);
51
- // expo-video duration is usually available after metadata loads
52
- if (player.duration) {
53
- setDuration(player.duration);
54
- }
55
  }
56
  }, 250);
57
  return () => clearInterval(interval);
@@ -62,12 +63,17 @@ export default function CustomVideoPlayer({ uri, shouldPlay, onSingleTap }: Cust
62
  setShowControls(true);
63
  if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
64
  controlsTimeoutRef.current = setTimeout(() => {
65
- if (isPlaying) setShowControls(false);
66
  }, 3000);
67
  };
68
 
69
  useEffect(() => {
70
- resetControlsTimeout();
 
 
 
 
 
71
  return () => {
72
  if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
73
  };
@@ -81,30 +87,33 @@ export default function CustomVideoPlayer({ uri, shouldPlay, onSingleTap }: Cust
81
  player.play();
82
  setIsPlaying(true);
83
  }
84
- resetControlsTimeout();
85
  };
86
 
87
  const handleScreenTap = () => {
88
  if (showControls) {
89
  setShowControls(false);
90
- if (onSingleTap) onSingleTap();
91
  } else {
92
  resetControlsTimeout();
93
  }
94
  };
95
 
96
- // Custom Scrubber Logic
97
- const scrubberRef = useRef<View>(null);
98
- const [scrubberWidth, setScrubberWidth] = useState(0);
99
-
100
- const handleSeek = (e: any) => {
101
  if (duration <= 0 || scrubberWidth <= 0) return;
102
- const locX = e.nativeEvent.locationX;
103
- const percentage = Math.max(0, Math.min(1, locX / scrubberWidth));
104
- const seekTime = percentage * duration;
105
  player.currentTime = seekTime;
106
  setCurrentTime(seekTime);
107
- resetControlsTimeout();
 
 
 
 
 
 
 
108
  };
109
 
110
  const formatTime = (secs: number) => {
@@ -128,7 +137,7 @@ export default function CustomVideoPlayer({ uri, shouldPlay, onSingleTap }: Cust
128
 
129
  {/* Custom Controls Overlay */}
130
  {showControls && (
131
- <View style={styles.overlay}>
132
 
133
  {/* Center Play/Pause */}
134
  <TouchableOpacity
@@ -143,16 +152,39 @@ export default function CustomVideoPlayer({ uri, shouldPlay, onSingleTap }: Cust
143
  />
144
  </TouchableOpacity>
145
 
146
- {/* Bottom Scrubber */}
147
  <View style={[styles.bottomBar, { paddingBottom: insets.bottom + 120 }]}>
148
  <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
149
 
 
 
 
 
 
 
150
  <View
 
151
  style={styles.scrubberContainer}
152
- onLayout={(e) => setScrubberWidth(e.nativeEvent.layout.width)}
153
  onStartShouldSetResponder={() => true}
154
- onResponderGrant={handleSeek}
155
- onResponderMove={handleSeek}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  >
157
  <View style={styles.scrubberTrack}>
158
  <View style={[styles.scrubberFill, { width: `${progressPercent}%` }]} />
@@ -186,7 +218,7 @@ const styles = StyleSheet.create({
186
  ...StyleSheet.absoluteFillObject,
187
  justifyContent: 'center',
188
  alignItems: 'center',
189
- backgroundColor: 'rgba(0,0,0,0.3)', // Subtle darkening for visibility
190
  },
191
  playPauseBtn: {
192
  width: 80,
@@ -203,7 +235,7 @@ const styles = StyleSheet.create({
203
  right: 0,
204
  flexDirection: 'row',
205
  alignItems: 'center',
206
- paddingHorizontal: 24,
207
  },
208
  timeText: {
209
  color: '#fff',
@@ -214,9 +246,9 @@ const styles = StyleSheet.create({
214
  },
215
  scrubberContainer: {
216
  flex: 1,
217
- height: 40,
218
  justifyContent: 'center',
219
- marginHorizontal: 12,
220
  },
221
  scrubberTrack: {
222
  height: 4,
@@ -232,11 +264,11 @@ const styles = StyleSheet.create({
232
  left: 0,
233
  },
234
  scrubberThumb: {
235
- width: 14,
236
- height: 14,
237
- borderRadius: 7,
238
  backgroundColor: '#1a73e8',
239
  position: 'absolute',
240
- marginLeft: -7,
241
- }
242
  });
 
5
  TouchableOpacity,
6
  Text,
7
  TouchableWithoutFeedback,
 
 
8
  } from 'react-native';
9
  import { useVideoPlayer, VideoView } from 'expo-video';
10
  import { MaterialIcons } from '@expo/vector-icons';
 
13
  interface CustomVideoPlayerProps {
14
  uri: string;
15
  shouldPlay: boolean;
16
+ onScrubStart?: () => void;
17
+ onScrubEnd?: () => void;
18
  }
19
 
20
+ export default function CustomVideoPlayer({
21
+ uri, shouldPlay, onScrubStart, onScrubEnd
22
+ }: CustomVideoPlayerProps) {
23
  const insets = useSafeAreaInsets();
24
  const [isPlaying, setIsPlaying] = useState(shouldPlay);
25
  const [currentTime, setCurrentTime] = useState(0);
26
  const [duration, setDuration] = useState(0);
27
  const [showControls, setShowControls] = useState(true);
28
+ const [scrubberWidth, setScrubberWidth] = useState(0);
29
+
30
+ const scrubberViewRef = useRef<View>(null);
31
+ const scrubberPageXRef = useRef(0);
32
+ const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
33
 
34
  const player = useVideoPlayer(uri, (p) => {
35
  p.loop = true;
36
  if (shouldPlay) p.play();
37
  });
38
 
39
+ // Sync external shouldPlay
40
  useEffect(() => {
41
  if (shouldPlay) {
42
  player.play();
 
52
  const interval = setInterval(() => {
53
  if (player) {
54
  setCurrentTime(player.currentTime);
55
+ if (player.duration) setDuration(player.duration);
 
 
 
56
  }
57
  }, 250);
58
  return () => clearInterval(interval);
 
63
  setShowControls(true);
64
  if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
65
  controlsTimeoutRef.current = setTimeout(() => {
66
+ setShowControls(false);
67
  }, 3000);
68
  };
69
 
70
  useEffect(() => {
71
+ if (isPlaying) {
72
+ resetControlsTimeout();
73
+ } else {
74
+ if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
75
+ setShowControls(true);
76
+ }
77
  return () => {
78
  if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
79
  };
 
87
  player.play();
88
  setIsPlaying(true);
89
  }
 
90
  };
91
 
92
  const handleScreenTap = () => {
93
  if (showControls) {
94
  setShowControls(false);
95
+ if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
96
  } else {
97
  resetControlsTimeout();
98
  }
99
  };
100
 
101
+ // Seek using absolute pageX β€” reliable across nested views
102
+ const seekFromPageX = (pageX: number) => {
 
 
 
103
  if (duration <= 0 || scrubberWidth <= 0) return;
104
+ const relX = pageX - scrubberPageXRef.current;
105
+ const pct = Math.max(0, Math.min(1, relX / scrubberWidth));
106
+ const seekTime = pct * duration;
107
  player.currentTime = seekTime;
108
  setCurrentTime(seekTime);
109
+ };
110
+
111
+ // Measure scrubber's absolute screen position
112
+ const handleScrubberLayout = () => {
113
+ scrubberViewRef.current?.measureInWindow((x, _y, w, _h) => {
114
+ scrubberPageXRef.current = x;
115
+ setScrubberWidth(w);
116
+ });
117
  };
118
 
119
  const formatTime = (secs: number) => {
 
137
 
138
  {/* Custom Controls Overlay */}
139
  {showControls && (
140
+ <View style={styles.overlay} pointerEvents="box-none">
141
 
142
  {/* Center Play/Pause */}
143
  <TouchableOpacity
 
152
  />
153
  </TouchableOpacity>
154
 
155
+ {/* Bottom Scrubber Bar */}
156
  <View style={[styles.bottomBar, { paddingBottom: insets.bottom + 120 }]}>
157
  <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
158
 
159
+ {/*
160
+ Scrubber uses the Responder System with Capture phase
161
+ to intercept ALL touches before FlatList can steal them.
162
+ onResponderTerminationRequest={false} prevents FlatList
163
+ from reclaiming the gesture mid-drag.
164
+ */}
165
  <View
166
+ ref={scrubberViewRef}
167
  style={styles.scrubberContainer}
168
+ onLayout={handleScrubberLayout}
169
  onStartShouldSetResponder={() => true}
170
+ onStartShouldSetResponderCapture={() => true}
171
+ onMoveShouldSetResponder={() => true}
172
+ onMoveShouldSetResponderCapture={() => true}
173
+ onResponderTerminationRequest={() => false}
174
+ onResponderGrant={(e) => {
175
+ onScrubStart?.();
176
+ seekFromPageX(e.nativeEvent.pageX);
177
+ }}
178
+ onResponderMove={(e) => {
179
+ seekFromPageX(e.nativeEvent.pageX);
180
+ }}
181
+ onResponderRelease={() => {
182
+ onScrubEnd?.();
183
+ resetControlsTimeout();
184
+ }}
185
+ onResponderTerminate={() => {
186
+ onScrubEnd?.();
187
+ }}
188
  >
189
  <View style={styles.scrubberTrack}>
190
  <View style={[styles.scrubberFill, { width: `${progressPercent}%` }]} />
 
218
  ...StyleSheet.absoluteFillObject,
219
  justifyContent: 'center',
220
  alignItems: 'center',
221
+ backgroundColor: 'rgba(0,0,0,0.3)',
222
  },
223
  playPauseBtn: {
224
  width: 80,
 
235
  right: 0,
236
  flexDirection: 'row',
237
  alignItems: 'center',
238
+ paddingHorizontal: 16,
239
  },
240
  timeText: {
241
  color: '#fff',
 
246
  },
247
  scrubberContainer: {
248
  flex: 1,
249
+ height: 44,
250
  justifyContent: 'center',
251
+ marginHorizontal: 8,
252
  },
253
  scrubberTrack: {
254
  height: 4,
 
264
  left: 0,
265
  },
266
  scrubberThumb: {
267
+ width: 16,
268
+ height: 16,
269
+ borderRadius: 8,
270
  backgroundColor: '#1a73e8',
271
  position: 'absolute',
272
+ marginLeft: -8,
273
+ },
274
  });
keystone-app/components/FullScreenSwiper.tsx CHANGED
@@ -3,14 +3,13 @@ import {
3
  View,
4
  StyleSheet,
5
  FlatList,
6
- Dimensions,
7
  PanResponder,
8
  Animated,
9
- ScrollView
10
  } from 'react-native';
11
  import { Image } from 'expo-image';
12
  import CustomVideoPlayer from './CustomVideoPlayer';
13
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
14
 
15
  interface FullScreenSwiperProps {
16
  assets: any[];
@@ -19,119 +18,210 @@ interface FullScreenSwiperProps {
19
  onIndexChange: (index: number) => void;
20
  }
21
 
22
- const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
 
 
 
 
 
 
 
 
 
 
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export default function FullScreenSwiper({
25
- assets,
26
- initialIndex,
27
- onClose,
28
- onIndexChange
29
  }: FullScreenSwiperProps) {
30
- const insets = useSafeAreaInsets();
31
  const [activeIndex, setActiveIndex] = useState(initialIndex);
32
-
33
- // Swipe Down to Close Animation
34
- const panY = useRef(new Animated.Value(0)).current;
35
- const panResponder = useRef(
36
- PanResponder.create({
37
- onMoveShouldSetPanResponder: (_, gestureState) => {
38
- // Only take over if pulling down significantly and mostly vertically
39
- return gestureState.dy > 15 && Math.abs(gestureState.dy) > Math.abs(gestureState.dx) * 2;
40
- },
41
- onPanResponderMove: Animated.event([null, { dy: panY }], { useNativeDriver: false }),
42
- onPanResponderRelease: (_, gestureState) => {
43
- if (gestureState.dy > 100) {
44
- onClose(); // Threshold passed, close modal
45
- } else {
46
- // Snap back
47
- Animated.spring(panY, {
48
- toValue: 0,
49
- useNativeDriver: true,
50
- bounciness: 0,
51
- }).start();
52
- }
53
- },
54
- })
55
- ).current;
56
 
57
- // Viewability tracking for FlatList
58
  const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
59
  if (viewableItems.length > 0) {
60
- const newIndex = viewableItems[0].index;
61
- setActiveIndex(newIndex);
62
- onIndexChange(newIndex);
63
  }
64
  }).current;
65
  const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 50 }).current;
66
 
67
- const renderItem = ({ item, index }: { item: any, index: number }) => {
68
- const isVideo = item.mediaType === 'video' || item.filename?.toLowerCase().match(/\.(mp4|mov|avi|webm)$/i);
 
 
69
  const isActive = index === activeIndex;
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return (
72
- <View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT, justifyContent: 'center' }}>
73
- {isVideo ? (
74
- <View style={{ flex: 1, paddingBottom: insets.bottom + 80 }}>
75
- <CustomVideoPlayer
76
- uri={item.uri}
77
- shouldPlay={isActive}
78
- />
79
- </View>
80
- ) : (
81
- <ScrollView
82
- maximumZoomScale={3}
83
- minimumZoomScale={1}
84
- showsHorizontalScrollIndicator={false}
85
- showsVerticalScrollIndicator={false}
86
- contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
87
- bouncesZoom={true}
88
- >
89
- <View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT, paddingBottom: insets.bottom + 80, justifyContent: 'center' }}>
90
- <Image
91
- source={{ uri: item.uri }}
92
- placeholder={{ uri: item.cloudThumbUri }}
93
- style={{ flex: 1, width: '100%', height: '100%' }}
94
- contentFit="contain"
95
- cachePolicy="memory-disk"
96
- transition={200}
97
- backgroundColor="transparent"
98
- allowDownscaling={false}
99
- />
100
- </View>
101
- </ScrollView>
102
- )}
103
- </View>
104
  );
105
  };
106
 
107
  return (
108
- <Animated.View
109
- style={[
110
- styles.container,
111
- { transform: [{ translateY: panY }] }
112
- ]}
113
- {...panResponder.panHandlers}
114
- >
115
  <FlatList
 
116
  data={assets}
117
  keyExtractor={(item) => item.id}
118
  renderItem={renderItem}
119
  horizontal
120
  pagingEnabled
 
121
  showsHorizontalScrollIndicator={false}
122
- initialScrollIndex={initialIndex}
123
  getItemLayout={(_, index) => ({
124
- length: SCREEN_WIDTH,
125
- offset: SCREEN_WIDTH * index,
126
  index,
127
  })}
128
  onViewableItemsChanged={onViewableItemsChanged}
129
  viewabilityConfig={viewabilityConfig}
130
- windowSize={3} // Keep memory footprint small
131
  maxToRenderPerBatch={3}
132
  removeClippedSubviews={Platform.OS === 'android'}
 
133
  />
134
- </Animated.View>
135
  );
136
  }
137
 
@@ -139,5 +229,5 @@ const styles = StyleSheet.create({
139
  container: {
140
  flex: 1,
141
  backgroundColor: '#000',
142
- }
143
  });
 
3
  View,
4
  StyleSheet,
5
  FlatList,
6
+ Platform,
7
  PanResponder,
8
  Animated,
9
+ useWindowDimensions,
10
  } from 'react-native';
11
  import { Image } from 'expo-image';
12
  import CustomVideoPlayer from './CustomVideoPlayer';
 
13
 
14
  interface FullScreenSwiperProps {
15
  assets: any[];
 
18
  onIndexChange: (index: number) => void;
19
  }
20
 
21
+ /* ── ZoomableImage with Pinch-to-Zoom & Swipe-Down-to-Close ── */
22
+ function ZoomableImage({
23
+ uri, thumbUri, width, height, onSwipeDown, onZoomChange
24
+ }: {
25
+ uri: string; thumbUri?: string; width: number; height: number;
26
+ onSwipeDown: () => void; onZoomChange: (zooming: boolean) => void;
27
+ }) {
28
+ const scale = useRef(new Animated.Value(1)).current;
29
+ const translateY = useRef(new Animated.Value(0)).current;
30
+ const baseDistance = useRef(0);
31
+ const currentScale = useRef(1);
32
+ const lastComputedScale = useRef(1);
33
 
34
+ // Refs so PanResponder always sees latest callbacks
35
+ const onSwipeDownRef = useRef(onSwipeDown);
36
+ const onZoomChangeRef = useRef(onZoomChange);
37
+ useEffect(() => { onSwipeDownRef.current = onSwipeDown; }, [onSwipeDown]);
38
+ useEffect(() => { onZoomChangeRef.current = onZoomChange; }, [onZoomChange]);
39
+
40
+ const panResponder = useRef(PanResponder.create({
41
+ onStartShouldSetPanResponder: (e) => e.nativeEvent.touches.length >= 2,
42
+
43
+ onMoveShouldSetPanResponder: (e, gs) => {
44
+ if (e.nativeEvent.touches.length >= 2) return true;
45
+ return Math.abs(gs.dy) > 12 && Math.abs(gs.dy) > Math.abs(gs.dx) * 1.5;
46
+ },
47
+
48
+ // Capture BEFORE FlatList gets it
49
+ onMoveShouldSetPanResponderCapture: (e, gs) => {
50
+ if (e.nativeEvent.touches.length >= 2) return true;
51
+ return Math.abs(gs.dy) > 12 && Math.abs(gs.dy) > Math.abs(gs.dx) * 1.5;
52
+ },
53
+
54
+ onPanResponderGrant: (e) => {
55
+ const { touches } = e.nativeEvent;
56
+ if (touches.length >= 2) {
57
+ const dx = touches[0].pageX - touches[1].pageX;
58
+ const dy = touches[0].pageY - touches[1].pageY;
59
+ baseDistance.current = Math.sqrt(dx * dx + dy * dy);
60
+ onZoomChangeRef.current(true);
61
+ }
62
+ },
63
+
64
+ onPanResponderMove: (e, gs) => {
65
+ const { touches } = e.nativeEvent;
66
+ if (touches.length >= 2) {
67
+ if (baseDistance.current === 0) {
68
+ // Second finger just appeared mid-gesture β€” init pinch
69
+ const dx = touches[0].pageX - touches[1].pageX;
70
+ const dy = touches[0].pageY - touches[1].pageY;
71
+ baseDistance.current = Math.sqrt(dx * dx + dy * dy);
72
+ onZoomChangeRef.current(true);
73
+ Animated.timing(translateY, { toValue: 0, duration: 80, useNativeDriver: true }).start();
74
+ } else {
75
+ const dx = touches[0].pageX - touches[1].pageX;
76
+ const dy = touches[0].pageY - touches[1].pageY;
77
+ const dist = Math.sqrt(dx * dx + dy * dy);
78
+ const newScale = Math.max(0.5, Math.min(5, currentScale.current * (dist / baseDistance.current)));
79
+ scale.setValue(newScale);
80
+ lastComputedScale.current = newScale;
81
+ }
82
+ } else if (touches.length <= 1 && baseDistance.current === 0) {
83
+ // Single-finger vertical drag β†’ swipe-down-to-close
84
+ translateY.setValue(gs.dy);
85
+ }
86
+ },
87
+
88
+ onPanResponderRelease: (_, gs) => {
89
+ if (baseDistance.current > 0) {
90
+ // Was pinch-zooming
91
+ const finalScale = lastComputedScale.current;
92
+ baseDistance.current = 0;
93
+ if (finalScale < 1.15) {
94
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, bounciness: 0 }).start();
95
+ currentScale.current = 1;
96
+ lastComputedScale.current = 1;
97
+ } else {
98
+ currentScale.current = finalScale;
99
+ }
100
+ onZoomChangeRef.current(false);
101
+ } else {
102
+ // Was swiping down
103
+ if (gs.dy > 100) {
104
+ onSwipeDownRef.current();
105
+ } else {
106
+ Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start();
107
+ }
108
+ }
109
+ },
110
+
111
+ onPanResponderTerminate: () => {
112
+ baseDistance.current = 0;
113
+ Animated.parallel([
114
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, bounciness: 0 }),
115
+ Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0 }),
116
+ ]).start();
117
+ currentScale.current = 1;
118
+ lastComputedScale.current = 1;
119
+ onZoomChangeRef.current(false);
120
+ },
121
+ })).current;
122
+
123
+ return (
124
+ <Animated.View
125
+ style={{
126
+ width, height,
127
+ justifyContent: 'center',
128
+ alignItems: 'center',
129
+ transform: [{ scale }, { translateY }],
130
+ }}
131
+ {...panResponder.panHandlers}
132
+ >
133
+ <Image
134
+ source={{ uri }}
135
+ placeholder={thumbUri ? { uri: thumbUri } : undefined}
136
+ style={{ width: '100%', height: '100%' }}
137
+ contentFit="contain"
138
+ cachePolicy="memory-disk"
139
+ transition={200}
140
+ backgroundColor="transparent"
141
+ allowDownscaling={false}
142
+ />
143
+ </Animated.View>
144
+ );
145
+ }
146
+
147
+
148
+ /* ── FullScreenSwiper ──────────────────────────────────────── */
149
  export default function FullScreenSwiper({
150
+ assets, initialIndex, onClose, onIndexChange
 
 
 
151
  }: FullScreenSwiperProps) {
152
+ const { width, height } = useWindowDimensions();
153
  const [activeIndex, setActiveIndex] = useState(initialIndex);
154
+ const [scrollEnabled, setScrollEnabled] = useState(true);
155
+
156
+ // Keep onIndexChange ref fresh for the viewability callback
157
+ const onIndexChangeRef = useRef(onIndexChange);
158
+ useEffect(() => { onIndexChangeRef.current = onIndexChange; }, [onIndexChange]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
 
160
  const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
161
  if (viewableItems.length > 0) {
162
+ const idx = viewableItems[0].index;
163
+ setActiveIndex(idx);
164
+ onIndexChangeRef.current(idx);
165
  }
166
  }).current;
167
  const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 50 }).current;
168
 
169
+ const renderItem = ({ item, index }: { item: any; index: number }) => {
170
+ const isVideo =
171
+ item.mediaType === 'video' ||
172
+ item.filename?.toLowerCase().match(/\.(mp4|mov|avi|webm)$/i);
173
  const isActive = index === activeIndex;
174
 
175
+ if (isVideo) {
176
+ return (
177
+ <View style={{ width, height, justifyContent: 'center', backgroundColor: '#000' }}>
178
+ <CustomVideoPlayer
179
+ uri={item.uri}
180
+ shouldPlay={isActive}
181
+ onScrubStart={() => setScrollEnabled(false)}
182
+ onScrubEnd={() => setScrollEnabled(true)}
183
+ />
184
+ </View>
185
+ );
186
+ }
187
+
188
  return (
189
+ <ZoomableImage
190
+ uri={item.uri}
191
+ thumbUri={item.cloudThumbUri}
192
+ width={width}
193
+ height={height}
194
+ onSwipeDown={onClose}
195
+ onZoomChange={(zooming) => setScrollEnabled(!zooming)}
196
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  );
198
  };
199
 
200
  return (
201
+ <View style={styles.container}>
 
 
 
 
 
 
202
  <FlatList
203
+ key={`swiper-${width}-${height}`}
204
  data={assets}
205
  keyExtractor={(item) => item.id}
206
  renderItem={renderItem}
207
  horizontal
208
  pagingEnabled
209
+ scrollEnabled={scrollEnabled}
210
  showsHorizontalScrollIndicator={false}
211
+ initialScrollIndex={activeIndex}
212
  getItemLayout={(_, index) => ({
213
+ length: width,
214
+ offset: width * index,
215
  index,
216
  })}
217
  onViewableItemsChanged={onViewableItemsChanged}
218
  viewabilityConfig={viewabilityConfig}
219
+ windowSize={3}
220
  maxToRenderPerBatch={3}
221
  removeClippedSubviews={Platform.OS === 'android'}
222
+ extraData={activeIndex}
223
  />
224
+ </View>
225
  );
226
  }
227
 
 
229
  container: {
230
  flex: 1,
231
  backgroundColor: '#000',
232
+ },
233
  });