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 +81 -67
- keystone-backend/main.py +10 -1
keystone-app/app/(tabs)/index.tsx
CHANGED
|
@@ -97,12 +97,10 @@ const GalleryRow = React.memo(({
|
|
| 97 |
activeOpacity={0.85}
|
| 98 |
>
|
| 99 |
<Image
|
| 100 |
-
source={
|
| 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 |
-
//
|
| 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 |
-
//
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 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=${
|
| 376 |
-
cloudThumbUri: matchCloud && matchCloud.thumbUri ? `${BACKEND_URL}${matchCloud.thumbUri}?token=${
|
| 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=${
|
| 386 |
-
cloudUri: `${BACKEND_URL}${c.uri}?token=${
|
| 387 |
-
cloudThumbUri: c.thumbUri ? `${BACKEND_URL}${c.thumbUri}?token=${
|
| 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 |
-
{/*
|
| 781 |
-
<
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
showsHorizontalScrollIndicator={false}
|
| 790 |
-
onMomentumScrollEnd={(e) => {
|
| 791 |
-
const newIndex = Math.round(e.nativeEvent.contentOffset.x / width);
|
| 792 |
-
setViewerIndex(newIndex);
|
| 793 |
-
setIsPlaying(true);
|
| 794 |
}}
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
<Video
|
| 800 |
-
source={{ uri:
|
| 801 |
-
style={{
|
| 802 |
resizeMode={ResizeMode.CONTAIN}
|
| 803 |
useNativeControls
|
| 804 |
-
shouldPlay={
|
| 805 |
isLooping={false}
|
| 806 |
onPlaybackStatusUpdate={(status) => {
|
| 807 |
if (status.isLoaded && status.didJustFinish) setIsPlaying(false);
|
| 808 |
}}
|
| 809 |
/>
|
| 810 |
</View>
|
| 811 |
-
)
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|