fix: Google Photos-style caching - stable URLs, recyclingKey, prefetch, proper video controls
Browse files
keystone-app/app/(tabs)/index.tsx
CHANGED
|
@@ -101,6 +101,8 @@ const GalleryRow = React.memo(({
|
|
| 101 |
style={styles.image}
|
| 102 |
contentFit="cover"
|
| 103 |
cachePolicy="memory-disk"
|
|
|
|
|
|
|
| 104 |
/>
|
| 105 |
{isVideo && (
|
| 106 |
<View style={styles.videoOverlay}>
|
|
@@ -284,11 +286,39 @@ export default function PhotosScreen() {
|
|
| 284 |
try {
|
| 285 |
const cached = await AsyncStorage.getItem(CACHED_ASSETS_KEY);
|
| 286 |
if (cached) {
|
| 287 |
-
const parsedAssets = JSON.parse(cached);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
setAllAssets(parsedAssets);
|
| 289 |
setSections(groupAssetsByDate(parsedAssets));
|
| 290 |
setIsLoading(false); // hide spinner right away
|
| 291 |
hasCached = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
| 293 |
} catch (e) { console.warn('Cache load error', e); }
|
| 294 |
|
|
@@ -377,6 +407,15 @@ export default function PhotosScreen() {
|
|
| 377 |
// Save to cache silently
|
| 378 |
AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
const toSync = unified.filter(a => !a.isSynced).length;
|
| 381 |
setItemsToBackup(toSync);
|
| 382 |
setIsLoading(false);
|
|
@@ -738,54 +777,48 @@ export default function PhotosScreen() {
|
|
| 738 |
</View>
|
| 739 |
</TouchableWithoutFeedback>
|
| 740 |
|
| 741 |
-
{/*
|
| 742 |
-
<
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
}}
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
const uri = props.source.uri;
|
| 756 |
-
const asset = allAssets.find(a => a.uri === uri);
|
| 757 |
-
|
| 758 |
-
if (asset?.mediaType === 'video') {
|
| 759 |
-
return (
|
| 760 |
-
<View
|
| 761 |
-
style={{ width, height, paddingBottom: 80, justifyContent: 'center' }}
|
| 762 |
-
onStartShouldSetResponder={() => true}
|
| 763 |
-
onResponderTerminationRequest={() => false}
|
| 764 |
-
>
|
| 765 |
<Video
|
| 766 |
-
source={{ uri:
|
| 767 |
-
style={{
|
| 768 |
resizeMode={ResizeMode.CONTAIN}
|
| 769 |
useNativeControls
|
| 770 |
-
shouldPlay={
|
| 771 |
-
isLooping
|
| 772 |
onPlaybackStatusUpdate={(status) => {
|
| 773 |
if (status.isLoaded && status.didJustFinish) setIsPlaying(false);
|
| 774 |
}}
|
| 775 |
/>
|
| 776 |
</View>
|
| 777 |
-
)
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
}}
|
| 789 |
/>
|
| 790 |
|
| 791 |
{/* Viewer action bar */}
|
|
@@ -926,7 +959,7 @@ const styles = StyleSheet.create({
|
|
| 926 |
row: { flexDirection: 'row', marginBottom: SPACING },
|
| 927 |
tileContainer: {
|
| 928 |
width: TILE_SIZE, height: TILE_SIZE, marginRight: SPACING,
|
| 929 |
-
borderRadius: 6, overflow: 'hidden', backgroundColor: '#
|
| 930 |
},
|
| 931 |
tileSelected: { borderWidth: 2.5, borderColor: '#1a73e8', borderRadius: 6 },
|
| 932 |
tileFiller: { width: TILE_SIZE, height: TILE_SIZE, marginRight: SPACING },
|
|
|
|
| 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}>
|
|
|
|
| 286 |
try {
|
| 287 |
const cached = await AsyncStorage.getItem(CACHED_ASSETS_KEY);
|
| 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 |
|
|
|
|
| 407 |
// Save to cache silently
|
| 408 |
AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
|
| 409 |
|
| 410 |
+
// Prefetch cloud-only thumbnails in background for instant display
|
| 411 |
+
const cloudThumbUrls = unified
|
| 412 |
+
.filter(a => a.isCloudOnly && a.cloudThumbUri)
|
| 413 |
+
.slice(0, 30)
|
| 414 |
+
.map(a => a.cloudThumbUri!);
|
| 415 |
+
if (cloudThumbUrls.length > 0) {
|
| 416 |
+
Image.prefetch(cloudThumbUrls).catch(() => {});
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
const toSync = unified.filter(a => !a.isSynced).length;
|
| 420 |
setItemsToBackup(toSync);
|
| 421 |
setIsLoading(false);
|
|
|
|
| 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 */}
|
|
|
|
| 959 |
row: { flexDirection: 'row', marginBottom: SPACING },
|
| 960 |
tileContainer: {
|
| 961 |
width: TILE_SIZE, height: TILE_SIZE, marginRight: SPACING,
|
| 962 |
+
borderRadius: 6, overflow: 'hidden', backgroundColor: '#f0f0f0',
|
| 963 |
},
|
| 964 |
tileSelected: { borderWidth: 2.5, borderColor: '#1a73e8', borderRadius: 6 },
|
| 965 |
tileFiller: { width: TILE_SIZE, height: TILE_SIZE, marginRight: SPACING },
|