dpv007 commited on
Commit
9b8a4e6
·
1 Parent(s): b763d9e

fix: restore standard gallery viewer for video, optimize caching of cloud only files, improve video preview handler, and fix select/deselect grey tiles

Browse files
keystone-app/app/(tabs)/index.tsx CHANGED
@@ -97,12 +97,10 @@ const GalleryRow = React.memo(({
97
  activeOpacity={0.85}
98
  >
99
  <Image
100
- source={{ uri: asset.isCloudOnly && asset.cloudThumbUri ? asset.cloudThumbUri : asset.uri }}
101
  style={styles.image}
102
  contentFit="cover"
103
  cachePolicy="memory-disk"
104
- recyclingKey={asset.id}
105
- transition={0}
106
  />
107
  {isVideo && (
108
  <View style={styles.videoOverlay}>
@@ -288,42 +286,52 @@ export default function PhotosScreen() {
288
  if (cached) {
289
  const parsedAssets: UnifiedAsset[] = JSON.parse(cached);
290
 
291
- // Refresh stale token URLs for cloud-only assets so they load instantly
292
- const freshToken = await getToken();
293
- if (freshToken) {
294
- for (const asset of parsedAssets) {
295
- if (asset.isCloudOnly || asset.cloudUri) {
296
- // Rebuild URLs with fresh token (strip old token, add new)
297
- const refreshUrl = (url: string | undefined) => {
298
- if (!url) return url;
299
- const base = url.split('?token=')[0];
300
- return `${base}?token=${freshToken}`;
301
- };
302
- asset.uri = refreshUrl(asset.uri) || asset.uri;
303
- asset.cloudUri = refreshUrl(asset.cloudUri);
304
- asset.cloudThumbUri = refreshUrl(asset.cloudThumbUri);
305
- }
306
- }
307
- }
308
-
309
  setAllAssets(parsedAssets);
310
  setSections(groupAssetsByDate(parsedAssets));
311
  setIsLoading(false); // hide spinner right away
312
  hasCached = true;
313
 
314
- // Prefetch cloud-only thumbnails in background
315
- const cloudThumbUrls = parsedAssets
316
- .filter(a => a.isCloudOnly && a.cloudThumbUri)
317
- .slice(0, 30) // first 30 cloud thumbnails
318
- .map(a => a.cloudThumbUri!);
319
- if (cloudThumbUrls.length > 0) {
320
- Image.prefetch(cloudThumbUrls).catch(() => {});
321
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  }
323
  } catch (e) { console.warn('Cache load error', e); }
324
 
325
  // Only show spinner when there is truly no cached data
326
  if (!hasCached) setIsLoading(true);
 
 
 
327
  // 1. Fetch Local
328
  const media = await MediaLibrary.getAssetsAsync({
329
  first: 800,
@@ -334,7 +342,6 @@ export default function PhotosScreen() {
334
  // 2. Fetch Cloud
335
  let cloudAssets: any[] = [];
336
  try {
337
- const token = await getToken();
338
  if (token) {
339
  const res = await fetch(`${BACKEND_URL}/photos?token=${token}`);
340
  const data = await res.json();
@@ -372,8 +379,8 @@ export default function PhotosScreen() {
372
  height: a.height,
373
  isSynced: isSynced || !!matchCloud,
374
  isCloudOnly: false,
375
- cloudUri: matchCloud ? `${BACKEND_URL}${matchCloud.uri}?token=${await getToken()}` : undefined,
376
- cloudThumbUri: matchCloud && matchCloud.thumbUri ? `${BACKEND_URL}${matchCloud.thumbUri}?token=${await getToken()}` : undefined,
377
  cloudId: matchCloud ? matchCloud.id : undefined,
378
  });
379
  }
@@ -382,9 +389,9 @@ export default function PhotosScreen() {
382
  if (!localFilenames.has(c.filename) && !trashedIds.includes(c.id)) {
383
  unified.push({
384
  id: c.id,
385
- uri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
386
- cloudUri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
387
- cloudThumbUri: c.thumbUri ? `${BACKEND_URL}${c.thumbUri}?token=${await getToken()}` : undefined,
388
  cloudId: c.id,
389
  filename: c.filename,
390
  mediaType: c.mediaType,
@@ -777,48 +784,55 @@ export default function PhotosScreen() {
777
  </View>
778
  </TouchableWithoutFeedback>
779
 
780
- {/* Swipeable viewer — FlatList for smooth swiping, photos get ImageViewer zoom */}
781
- <FlatList
782
- ref={viewerRef}
783
- data={allAssets}
784
- horizontal
785
- pagingEnabled
786
- initialScrollIndex={viewerIndex ?? 0}
787
- getItemLayout={(_, index) => ({ length: width, offset: width * index, index })}
788
- keyExtractor={item => item.id}
789
- showsHorizontalScrollIndicator={false}
790
- onMomentumScrollEnd={(e) => {
791
- const newIndex = Math.round(e.nativeEvent.contentOffset.x / width);
792
- setViewerIndex(newIndex);
793
- setIsPlaying(true);
794
  }}
795
- renderItem={({ item, index }) => (
796
- <View style={{ width, height, backgroundColor: '#000' }}>
797
- {item.mediaType === 'video' ? (
798
- <View style={{ flex: 1, paddingBottom: insets.bottom + 72 }}>
 
 
 
 
 
 
 
 
 
 
 
799
  <Video
800
- source={{ uri: item.uri }}
801
- style={{ flex: 1 }}
802
  resizeMode={ResizeMode.CONTAIN}
803
  useNativeControls
804
- shouldPlay={index === viewerIndex && isPlaying}
805
  isLooping={false}
806
  onPlaybackStatusUpdate={(status) => {
807
  if (status.isLoaded && status.didJustFinish) setIsPlaying(false);
808
  }}
809
  />
810
  </View>
811
- ) : (
812
- <Image
813
- source={{ uri: item.uri }}
814
- style={{ flex: 1, width: '100%' }}
815
- contentFit="contain"
816
- cachePolicy="memory-disk"
817
- transition={0}
818
- />
819
- )}
820
- </View>
821
- )}
 
822
  />
823
 
824
  {/* Viewer action bar */}
 
97
  activeOpacity={0.85}
98
  >
99
  <Image
100
+ source={asset.isCloudOnly && isVideo && asset.cloudThumbUri ? asset.cloudThumbUri : asset.uri}
101
  style={styles.image}
102
  contentFit="cover"
103
  cachePolicy="memory-disk"
 
 
104
  />
105
  {isVideo && (
106
  <View style={styles.videoOverlay}>
 
286
  if (cached) {
287
  const parsedAssets: UnifiedAsset[] = JSON.parse(cached);
288
 
289
+ // Paint cached assets immediately for instant UI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  setAllAssets(parsedAssets);
291
  setSections(groupAssetsByDate(parsedAssets));
292
  setIsLoading(false); // hide spinner right away
293
  hasCached = true;
294
 
295
+ // Refresh stale token URLs for cloud-only assets in background
296
+ getToken().then((freshToken) => {
297
+ if (freshToken) {
298
+ const refreshed = parsedAssets.map(asset => {
299
+ if (asset.isCloudOnly || asset.cloudUri) {
300
+ const refreshUrl = (url: string | undefined) => {
301
+ if (!url) return url;
302
+ const base = url.split('?token=')[0];
303
+ return `${base}?token=${freshToken}`;
304
+ };
305
+ return {
306
+ ...asset,
307
+ uri: refreshUrl(asset.uri) || asset.uri,
308
+ cloudUri: refreshUrl(asset.cloudUri),
309
+ cloudThumbUri: refreshUrl(asset.cloudThumbUri),
310
+ };
311
+ }
312
+ return asset;
313
+ });
314
+ setAllAssets(refreshed);
315
+ setSections(groupAssetsByDate(refreshed));
316
+
317
+ // Prefetch cloud-only thumbnails in background
318
+ const cloudThumbUrls = refreshed
319
+ .filter(a => a.isCloudOnly && a.cloudThumbUri)
320
+ .slice(0, 30)
321
+ .map(a => a.cloudThumbUri!);
322
+ if (cloudThumbUrls.length > 0) {
323
+ Image.prefetch(cloudThumbUrls).catch(() => {});
324
+ }
325
+ }
326
+ }).catch(() => {});
327
  }
328
  } catch (e) { console.warn('Cache load error', e); }
329
 
330
  // Only show spinner when there is truly no cached data
331
  if (!hasCached) setIsLoading(true);
332
+
333
+ const token = await getToken();
334
+
335
  // 1. Fetch Local
336
  const media = await MediaLibrary.getAssetsAsync({
337
  first: 800,
 
342
  // 2. Fetch Cloud
343
  let cloudAssets: any[] = [];
344
  try {
 
345
  if (token) {
346
  const res = await fetch(`${BACKEND_URL}/photos?token=${token}`);
347
  const data = await res.json();
 
379
  height: a.height,
380
  isSynced: isSynced || !!matchCloud,
381
  isCloudOnly: false,
382
+ cloudUri: matchCloud ? `${BACKEND_URL}${matchCloud.uri}?token=${token}` : undefined,
383
+ cloudThumbUri: matchCloud && matchCloud.thumbUri ? `${BACKEND_URL}${matchCloud.thumbUri}?token=${token}` : undefined,
384
  cloudId: matchCloud ? matchCloud.id : undefined,
385
  });
386
  }
 
389
  if (!localFilenames.has(c.filename) && !trashedIds.includes(c.id)) {
390
  unified.push({
391
  id: c.id,
392
+ uri: `${BACKEND_URL}${c.uri}?token=${token}`,
393
+ cloudUri: `${BACKEND_URL}${c.uri}?token=${token}`,
394
+ cloudThumbUri: c.thumbUri ? `${BACKEND_URL}${c.thumbUri}?token=${token}` : undefined,
395
  cloudId: c.id,
396
  filename: c.filename,
397
  mediaType: c.mediaType,
 
784
  </View>
785
  </TouchableWithoutFeedback>
786
 
787
+ {/* Swiping and Zooming via ImageViewer */}
788
+ <ImageViewer
789
+ imageUrls={allAssets.map(a => ({ url: a.uri }))}
790
+ index={viewerIndex ?? 0}
791
+ onChange={(idx) => {
792
+ if (idx !== undefined) {
793
+ setViewerIndex(idx);
794
+ setIsPlaying(true);
795
+ }
 
 
 
 
 
796
  }}
797
+ enableSwipeDown={true}
798
+ onSwipeDown={() => setViewerIndex(null)}
799
+ renderIndicator={() => <View />}
800
+ saveToLocalByLongPress={false}
801
+ renderImage={(props) => {
802
+ const uri = props.source.uri;
803
+ const asset = allAssets.find(a => a.uri === uri);
804
+
805
+ if (asset?.mediaType === 'video') {
806
+ return (
807
+ <View
808
+ style={{ width, height, paddingBottom: insets.bottom + 80, justifyContent: 'center' }}
809
+ onStartShouldSetResponder={() => true}
810
+ onResponderTerminationRequest={() => false}
811
+ >
812
  <Video
813
+ source={{ uri: asset.uri }}
814
+ style={{ width: '100%', height: '100%' }}
815
  resizeMode={ResizeMode.CONTAIN}
816
  useNativeControls
817
+ shouldPlay={allAssets[viewerIndex ?? 0]?.id === asset.id && isPlaying}
818
  isLooping={false}
819
  onPlaybackStatusUpdate={(status) => {
820
  if (status.isLoaded && status.didJustFinish) setIsPlaying(false);
821
  }}
822
  />
823
  </View>
824
+ );
825
+ }
826
+
827
+ return (
828
+ <Image
829
+ source={uri}
830
+ style={{ width: '100%', height: '100%' }}
831
+ contentFit="contain"
832
+ cachePolicy="memory-disk"
833
+ />
834
+ );
835
+ }}
836
  />
837
 
838
  {/* Viewer action bar */}
keystone-backend/main.py CHANGED
@@ -237,7 +237,16 @@ def generate_video_thumbnail(video_path: str, thumb_path: str):
237
  cap = cv2.VideoCapture(video_path)
238
  if not cap.isOpened():
239
  return False
240
- # Read the first frame
 
 
 
 
 
 
 
 
 
241
  ret, frame = cap.read()
242
  cap.release()
243
  if ret:
 
237
  cap = cv2.VideoCapture(video_path)
238
  if not cap.isOpened():
239
  return False
240
+
241
+ # Seek to 1 second or 10% of total frames to avoid black opening frame
242
+ fps = cap.get(cv2.CAP_PROP_FPS)
243
+ frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
244
+ target_frame = 0
245
+ if fps > 0 and frame_count > 0:
246
+ target_frame = min(int(fps * 1.0), int(frame_count * 0.1))
247
+ if target_frame > 0:
248
+ cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
249
+
250
  ret, frame = cap.read()
251
  cap.release()
252
  if ret: