dpv007 commited on
Commit
e179c31
·
1 Parent(s): 2c5bbee

fix: resolve 404 on thumbnail serve, scrubber pan responder, zooming issues, and swiper alignment

Browse files
keystone-app/app/_layout.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { Stack } from 'expo-router';
 
2
  import { SafeAreaProvider } from 'react-native-safe-area-context';
3
  import * as BackgroundFetch from 'expo-background-fetch';
4
  import { useEffect } from 'react';
@@ -50,8 +51,10 @@ function AppContent() {
50
 
51
  export default function Layout() {
52
  return (
53
- <AuthProvider>
54
- <AppContent />
55
- </AuthProvider>
 
 
56
  );
57
  }
 
1
  import { Stack } from 'expo-router';
2
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
3
  import { SafeAreaProvider } from 'react-native-safe-area-context';
4
  import * as BackgroundFetch from 'expo-background-fetch';
5
  import { useEffect } from 'react';
 
51
 
52
  export default function Layout() {
53
  return (
54
+ <GestureHandlerRootView style={{ flex: 1 }}>
55
+ <AuthProvider>
56
+ <AppContent />
57
+ </AuthProvider>
58
+ </GestureHandlerRootView>
59
  );
60
  }
keystone-app/components/CustomVideoPlayer.tsx CHANGED
@@ -5,11 +5,11 @@ import {
5
  TouchableOpacity,
6
  Text,
7
  TouchableWithoutFeedback,
 
8
  } from 'react-native';
9
  import { useVideoPlayer, VideoView } from 'expo-video';
10
  import { MaterialIcons } from '@expo/vector-icons';
11
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
12
- import { Gesture, GestureDetector } from 'react-native-gesture-handler';
13
 
14
  interface CustomVideoPlayerProps {
15
  uri: string;
@@ -114,25 +114,30 @@ export default function CustomVideoPlayer({
114
  const scrubberViewRef = useRef<View>(null);
115
 
116
  /* ── Scrubber gesture ──
117
- .runOnJS(true) callbacks run on JS thread (no worklet needed)
118
- .minDistance(0) activates immediately on touch (tap-to-seek works)
119
- This gesture is inside the FlatList item, and RNGH's FlatList
120
- properly yields to nested gesture handlers.
121
  */
122
- const scrubGesture = Gesture.Pan()
123
- .runOnJS(true)
124
- .minDistance(0)
125
- .onStart((e) => {
126
- onScrubStart?.();
127
- seekFromAbsoluteX(e.absoluteX);
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  })
129
- .onUpdate((e) => {
130
- seekFromAbsoluteX(e.absoluteX);
131
- })
132
- .onEnd(() => {
133
- onScrubEnd?.();
134
- resetControlsTimeout();
135
- });
136
 
137
  const formatTime = (secs: number) => {
138
  const m = Math.floor(secs / 60);
@@ -170,20 +175,18 @@ export default function CustomVideoPlayer({
170
  <View style={[styles.bottomBar, { paddingBottom: insets.bottom + 120 }]}>
171
  <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
172
 
173
- {/* Scrubber — wrapped in GestureDetector so RNGH handles
174
- touch negotiation with the parent FlatList */}
175
- <GestureDetector gesture={scrubGesture}>
176
- <View
177
- ref={scrubberViewRef}
178
- style={styles.scrubberContainer}
179
- onLayout={handleScrubberLayout}
180
- >
181
- <View style={styles.scrubberTrack}>
182
- <View style={[styles.scrubberFill, { width: `${progressPercent}%` }]} />
183
- <View style={[styles.scrubberThumb, { left: `${progressPercent}%` }]} />
184
- </View>
185
  </View>
186
- </GestureDetector>
187
 
188
  <Text style={styles.timeText}>{formatTime(duration)}</Text>
189
  </View>
 
5
  TouchableOpacity,
6
  Text,
7
  TouchableWithoutFeedback,
8
+ PanResponder,
9
  } from 'react-native';
10
  import { useVideoPlayer, VideoView } from 'expo-video';
11
  import { MaterialIcons } from '@expo/vector-icons';
12
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
 
13
 
14
  interface CustomVideoPlayerProps {
15
  uri: string;
 
114
  const scrubberViewRef = useRef<View>(null);
115
 
116
  /* ── Scrubber gesture ──
117
+ Uses standard React Native PanResponder to forcefully capture
118
+ touches and prevent the parent FlatList from scrolling horizontally.
 
 
119
  */
120
+ const panResponder = useRef(
121
+ PanResponder.create({
122
+ onStartShouldSetPanResponder: () => true,
123
+ onMoveShouldSetPanResponder: () => true,
124
+ onPanResponderGrant: (e) => {
125
+ onScrubStart?.();
126
+ seekFromAbsoluteX(e.nativeEvent.pageX);
127
+ },
128
+ onPanResponderMove: (e) => {
129
+ seekFromAbsoluteX(e.nativeEvent.pageX);
130
+ },
131
+ onPanResponderRelease: () => {
132
+ onScrubEnd?.();
133
+ resetControlsTimeout();
134
+ },
135
+ onPanResponderTerminate: () => {
136
+ onScrubEnd?.();
137
+ resetControlsTimeout();
138
+ },
139
  })
140
+ ).current;
 
 
 
 
 
 
141
 
142
  const formatTime = (secs: number) => {
143
  const m = Math.floor(secs / 60);
 
175
  <View style={[styles.bottomBar, { paddingBottom: insets.bottom + 120 }]}>
176
  <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
177
 
178
+ {/* Scrubber — Using PanResponder ensures FlatList stops scrolling */}
179
+ <View
180
+ {...panResponder.panHandlers}
181
+ ref={scrubberViewRef}
182
+ style={styles.scrubberContainer}
183
+ onLayout={handleScrubberLayout}
184
+ >
185
+ <View style={styles.scrubberTrack}>
186
+ <View style={[styles.scrubberFill, { width: `${progressPercent}%` }]} />
187
+ <View style={[styles.scrubberThumb, { left: `${progressPercent}%` }]} />
 
 
188
  </View>
189
+ </View>
190
 
191
  <Text style={styles.timeText}>{formatTime(duration)}</Text>
192
  </View>
keystone-app/components/FullScreenSwiper.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useRef, useState, useEffect } from 'react';
2
- import { View, StyleSheet, Platform, useWindowDimensions } from 'react-native';
3
  import { Image } from 'expo-image';
4
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
5
  import { FlatList } from 'react-native-gesture-handler';
@@ -27,7 +27,7 @@ interface FullScreenSwiperProps {
27
  function ZoomableImage({
28
  uri, thumbUri, width, height, onSwipeDown, onZoomChange,
29
  }: {
30
- uri: string; thumbUri?: string; width: number; height: number;
31
  onSwipeDown: () => void; onZoomChange: (zooming: boolean) => void;
32
  }) {
33
  const scale = useSharedValue(1);
@@ -90,7 +90,7 @@ function ZoomableImage({
90
  <GestureDetector gesture={composedGesture}>
91
  <Animated.View
92
  style={[
93
- { width, height, justifyContent: 'center', alignItems: 'center' },
94
  animatedStyle,
95
  ]}
96
  >
@@ -117,7 +117,7 @@ function ZoomableImage({
117
  export default function FullScreenSwiper({
118
  assets, initialIndex, onClose, onIndexChange,
119
  }: FullScreenSwiperProps) {
120
- const { width, height } = useWindowDimensions();
121
  const [activeIndex, setActiveIndex] = useState(initialIndex);
122
  const [scrollEnabled, setScrollEnabled] = useState(true);
123
 
@@ -141,7 +141,7 @@ export default function FullScreenSwiper({
141
 
142
  if (isVideo) {
143
  return (
144
- <View style={{ width, height, justifyContent: 'center', backgroundColor: '#000' }}>
145
  <CustomVideoPlayer
146
  uri={item.uri}
147
  shouldPlay={isActive}
@@ -157,7 +157,6 @@ export default function FullScreenSwiper({
157
  uri={item.uri}
158
  thumbUri={item.cloudThumbUri}
159
  width={width}
160
- height={height}
161
  onSwipeDown={onClose}
162
  onZoomChange={(zooming) => setScrollEnabled(!zooming)}
163
  />
@@ -167,7 +166,7 @@ export default function FullScreenSwiper({
167
  return (
168
  <View style={styles.container}>
169
  <FlatList
170
- key={`swiper-${width}-${height}`}
171
  data={assets}
172
  keyExtractor={(item) => item.id}
173
  renderItem={renderItem}
 
1
  import React, { useRef, useState, useEffect } from 'react';
2
+ import { View, StyleSheet, Platform, useWindowDimensions, PanResponder } from 'react-native';
3
  import { Image } from 'expo-image';
4
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
5
  import { FlatList } from 'react-native-gesture-handler';
 
27
  function ZoomableImage({
28
  uri, thumbUri, width, height, onSwipeDown, onZoomChange,
29
  }: {
30
+ uri: string; thumbUri?: string; width: number;
31
  onSwipeDown: () => void; onZoomChange: (zooming: boolean) => void;
32
  }) {
33
  const scale = useSharedValue(1);
 
90
  <GestureDetector gesture={composedGesture}>
91
  <Animated.View
92
  style={[
93
+ { width, height: '100%', justifyContent: 'center', alignItems: 'center' },
94
  animatedStyle,
95
  ]}
96
  >
 
117
  export default function FullScreenSwiper({
118
  assets, initialIndex, onClose, onIndexChange,
119
  }: FullScreenSwiperProps) {
120
+ const { width } = useWindowDimensions();
121
  const [activeIndex, setActiveIndex] = useState(initialIndex);
122
  const [scrollEnabled, setScrollEnabled] = useState(true);
123
 
 
141
 
142
  if (isVideo) {
143
  return (
144
+ <View style={{ width, height: '100%', justifyContent: 'center', backgroundColor: '#000' }}>
145
  <CustomVideoPlayer
146
  uri={item.uri}
147
  shouldPlay={isActive}
 
157
  uri={item.uri}
158
  thumbUri={item.cloudThumbUri}
159
  width={width}
 
160
  onSwipeDown={onClose}
161
  onZoomChange={(zooming) => setScrollEnabled(!zooming)}
162
  />
 
166
  return (
167
  <View style={styles.container}>
168
  <FlatList
169
+ key={`swiper-${width}`}
170
  data={assets}
171
  keyExtractor={(item) => item.id}
172
  renderItem={renderItem}
keystone-app/components/MediaViewer.tsx CHANGED
@@ -1,7 +1,7 @@
1
- import React, { useEffect } from 'react';
2
- import { View, StyleSheet } from 'react-native';
3
  import { Image } from 'expo-image';
4
- import { useVideoPlayer, VideoView } from 'expo-video';
5
 
6
  interface MediaViewerProps {
7
  asset: any;
@@ -17,14 +17,14 @@ export default function MediaViewer({ asset, shouldPlay = true, isActive = true
17
  asset.filename?.toLowerCase().match(/\.(mp4|mov|avi|webm)$/i);
18
 
19
  if (isVideo && isActive) {
20
- return <VideoWrapper uri={asset.uri} shouldPlay={shouldPlay} />;
21
  }
22
 
23
  // expo-image natively supports GIFs and handles caching perfectly.
24
  return (
25
  <Image
26
  source={{ uri: asset.uri }}
27
- placeholder={{ uri: asset.cloudThumbUri }}
28
  style={{ flex: 1, width: '100%', height: '100%' }}
29
  contentFit="contain"
30
  cachePolicy="memory-disk"
@@ -34,44 +34,3 @@ export default function MediaViewer({ asset, shouldPlay = true, isActive = true
34
  />
35
  );
36
  }
37
-
38
- function VideoWrapper({ uri, shouldPlay }: { uri: string; shouldPlay: boolean }) {
39
- const player = useVideoPlayer(uri, (p) => {
40
- p.loop = true;
41
- if (shouldPlay) p.play();
42
- });
43
-
44
- useEffect(() => {
45
- if (shouldPlay) {
46
- player.play();
47
- } else {
48
- player.pause();
49
- }
50
- }, [shouldPlay, player]);
51
-
52
- return (
53
- <View style={styles.videoContainer}>
54
- <VideoView
55
- style={styles.video}
56
- player={player}
57
- allowsFullscreen
58
- allowsPictureInPicture
59
- nativeControls={true}
60
- />
61
- </View>
62
- );
63
- }
64
-
65
- const styles = StyleSheet.create({
66
- videoContainer: {
67
- flex: 1,
68
- width: '100%',
69
- height: '100%',
70
- justifyContent: 'center',
71
- alignItems: 'center',
72
- },
73
- video: {
74
- width: '100%',
75
- height: '100%',
76
- },
77
- });
 
1
+ import React from 'react';
2
+ import { StyleSheet } from 'react-native';
3
  import { Image } from 'expo-image';
4
+ import CustomVideoPlayer from './CustomVideoPlayer';
5
 
6
  interface MediaViewerProps {
7
  asset: any;
 
17
  asset.filename?.toLowerCase().match(/\.(mp4|mov|avi|webm)$/i);
18
 
19
  if (isVideo && isActive) {
20
+ return <CustomVideoPlayer uri={asset.uri} shouldPlay={shouldPlay} />;
21
  }
22
 
23
  // expo-image natively supports GIFs and handles caching perfectly.
24
  return (
25
  <Image
26
  source={{ uri: asset.uri }}
27
+ placeholder={asset.cloudThumbUri ? { uri: asset.cloudThumbUri } : undefined}
28
  style={{ flex: 1, width: '100%', height: '100%' }}
29
  contentFit="contain"
30
  cachePolicy="memory-disk"
 
34
  />
35
  );
36
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
keystone-backend/main.py CHANGED
@@ -258,16 +258,6 @@ async def list_photos(username: str = Depends(verify_jwt_token)):
258
 
259
  return {"assets": photos}
260
 
261
- @app.get("/photos/serve/{file_path:path}")
262
- async def serve_photo(
263
- file_path: str,
264
- username: str = Depends(verify_jwt_token)
265
- ):
266
- user_dir = get_user_dir(username)
267
- full_path = os.path.join(user_dir, file_path)
268
- if not os.path.exists(full_path):
269
- raise HTTPException(status_code=404, detail="File not found")
270
- return FileResponse(full_path)
271
 
272
  def generate_video_thumbnail(video_path: str, thumb_path: str):
273
  try:
@@ -346,6 +336,17 @@ async def serve_photo_thumb(
346
  # Fallback to original
347
  return FileResponse(original_path)
348
 
 
 
 
 
 
 
 
 
 
 
 
349
  @app.delete("/photos/delete/{file_path:path}")
350
  async def delete_photo(file_path: str, username: str = Depends(verify_jwt_token)):
351
  clean_path = os.path.normpath(file_path)
 
258
 
259
  return {"assets": photos}
260
 
 
 
 
 
 
 
 
 
 
 
261
 
262
  def generate_video_thumbnail(video_path: str, thumb_path: str):
263
  try:
 
336
  # Fallback to original
337
  return FileResponse(original_path)
338
 
339
+ @app.get("/photos/serve/{file_path:path}")
340
+ async def serve_photo(
341
+ file_path: str,
342
+ username: str = Depends(verify_jwt_token)
343
+ ):
344
+ user_dir = get_user_dir(username)
345
+ full_path = os.path.join(user_dir, file_path)
346
+ if not os.path.exists(full_path):
347
+ raise HTTPException(status_code=404, detail="File not found")
348
+ return FileResponse(full_path)
349
+
350
  @app.delete("/photos/delete/{file_path:path}")
351
  async def delete_photo(file_path: str, username: str = Depends(verify_jwt_token)):
352
  clean_path = os.path.normpath(file_path)