Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/lang/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ const translations = {
copyError: 'Could not copy item',
saveError: 'Could not save to gallery',
saveErrorNoPermission: 'Gallery permission denied',
saveErrorNoPermissionAction: 'Settings',
trashError: 'Could not move to trash',
restoreError: 'Could not start upload',
},
Expand Down Expand Up @@ -1283,6 +1284,7 @@ const translations = {
copyError: 'No se pudo copiar el elemento',
saveError: 'No se pudo guardar en la galería',
saveErrorNoPermission: 'Permiso de galería denegado',
saveErrorNoPermissionAction: 'Ajustes',
trashError: 'No se pudo mover a la papelera',
restoreError: 'No se pudo iniciar la subida',
},
Expand Down
65 changes: 16 additions & 49 deletions src/screens/PhotoPreviewScreen/components/PreviewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, View, useWindowDimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { useTailwind } from 'tailwind-rn';
import { VideoViewer } from '../../../components/photos/VideoViewer/VideoViewer';
import { ImageViewer } from '../../../components/ui-kit/view/ImageViewer/ImageViewer';
Expand Down Expand Up @@ -83,12 +81,10 @@ interface PreviewPageProps {
isScrubbing: boolean;
onTap: () => void;
onZoomChange: (zoomed: boolean) => void;
onSwipeDown: () => void;
onVideoPlay?: () => void;
onVideoPause?: () => void;
onVideoEnd?: () => void;
videoResetKey?: number;
hasVideoStarted?: boolean;
}

export const PreviewPage = ({
Expand All @@ -97,18 +93,14 @@ export const PreviewPage = ({
isScrubbing,
onTap,
onZoomChange,
onSwipeDown,
onVideoPlay,
onVideoPause,
onVideoEnd,
videoResetKey,
hasVideoStarted,
}: PreviewPageProps): JSX.Element => {
const tailwind = useTailwind();
const { width: screenWidth } = useWindowDimensions();
const { uri, thumbnailUri, isLoading } = usePreviewSource(item, isScrubbing);
const [zoomed, setZoomed] = useState(false);
const translateY = useSharedValue(0);
const [isVideoActive, setIsVideoActive] = useState(false);

useEffect(() => {
Expand All @@ -121,12 +113,10 @@ export const PreviewPage = ({
}, [isActive]);

const handleZoom = useCallback(() => {
setZoomed(true);
onZoomChange(true);
}, [onZoomChange]);

const handleReset = useCallback(() => {
setZoomed(false);
onZoomChange(false);
}, [onZoomChange]);

Expand All @@ -142,45 +132,22 @@ export const PreviewPage = ({
onVideoEnd?.();
}, [onVideoEnd]);

const swipeDownGesture = Gesture.Pan()
.enabled(!zoomed && !hasVideoStarted)
.activeOffsetY(10)
.failOffsetX([-15, 15])
.onUpdate((e) => {
if (e.translationY > 0) {
translateY.value = e.translationY;
}
})
.onEnd((e) => {
if (e.translationY > 100) {
runOnJS(onSwipeDown)();
} else {
translateY.value = withTiming(0, { duration: 150 });
}
});

const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));

return (
<GestureDetector gesture={swipeDownGesture}>
<Animated.View style={[tailwind('flex-1 bg-black'), { width: screenWidth }, animatedStyle]}>
<PageContent
item={item}
uri={uri}
thumbnailUri={thumbnailUri}
isLoading={isLoading}
isActive={isVideoActive}
onTap={onTap}
onZoom={handleZoom}
onReset={handleReset}
onVideoPlay={playVideo}
onVideoPause={pauseVideo}
onVideoEnd={endVideo}
videoResetKey={videoResetKey}
/>
</Animated.View>
</GestureDetector>
<View style={[tailwind('flex-1 bg-black'), { width: screenWidth }]}>
<PageContent
item={item}
uri={uri}
thumbnailUri={thumbnailUri}
isLoading={isLoading}
isActive={isVideoActive}
onTap={onTap}
onZoom={handleZoom}
onReset={handleReset}
onVideoPlay={playVideo}
onVideoPause={pauseVideo}
onVideoEnd={endVideo}
videoResetKey={videoResetKey}
/>
</View>
);
};
19 changes: 1 addition & 18 deletions src/screens/PhotoPreviewScreen/components/PreviewPager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ interface PreviewPagerProps {
onIndexChange: (index: number) => void;
onTap: () => void;
onZoomChange: (zoomed: boolean) => void;
onSwipeDown: () => void;
onVideoPlay?: () => void;
onVideoPause?: () => void;
onVideoEnd?: () => void;
videoResetKey?: number;
hasVideoStarted?: boolean;
}

export const PreviewPager = ({
Expand All @@ -27,12 +25,10 @@ export const PreviewPager = ({
onIndexChange,
onTap,
onZoomChange,
onSwipeDown,
onVideoPlay,
onVideoPause,
onVideoEnd,
videoResetKey,
hasVideoStarted,
}: PreviewPagerProps): JSX.Element => {
const { width: screenWidth } = useWindowDimensions();
const listRef = useRef<FlatList<TimelinePhotoItem>>(null);
Expand All @@ -58,26 +54,13 @@ export const PreviewPager = ({
isScrubbing={isScrubbing}
onTap={onTap}
onZoomChange={onZoomChange}
onSwipeDown={onSwipeDown}
onVideoPlay={onVideoPlay}
onVideoPause={onVideoPause}
onVideoEnd={onVideoEnd}
videoResetKey={videoResetKey}
hasVideoStarted={hasVideoStarted}
/>
),
[
activeIndex,
isScrubbing,
onTap,
onZoomChange,
onSwipeDown,
onVideoPlay,
onVideoPause,
onVideoEnd,
videoResetKey,
hasVideoStarted,
],
[activeIndex, isScrubbing, onTap, onZoomChange, onVideoPlay, onVideoPause, onVideoEnd, videoResetKey],
);

const getItemLayout = useCallback(
Expand Down
17 changes: 17 additions & 0 deletions src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ describe('usePreviewSource — uri resolution', () => {

expect(mockFetchPlaybackUri).not.toHaveBeenCalled();
});

test('when scrubbing ends on the same item that was already loaded, then the uri is not refetched or cleared', async () => {
const item = makeCloudItem();

const { result, rerender } = renderHook(({ isScrubbing }) => usePreviewSource(item, isScrubbing), {
initialProps: { isScrubbing: false },
});

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockFetchPlaybackUri).toHaveBeenCalledTimes(1);

rerender({ isScrubbing: true });
rerender({ isScrubbing: false });

expect(mockFetchPlaybackUri).toHaveBeenCalledTimes(1);
expect(result.current.uri).toBe('file:///dcim/photo.jpg');
});
});

describe('usePreviewSource — thumbnailUri', () => {
Expand Down
5 changes: 4 additions & 1 deletion src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ export const usePreviewSource = (item: TimelinePhotoItem, isScrubbing: boolean):
const [uri, setUri] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const loadedItemIdRef = useRef<string | null>(null);

useEffect(() => {
if (isScrubbing) {
const isAlreadyLoadedItem = loadedItemIdRef.current === item.id;
if (isScrubbing || isAlreadyLoadedItem) {
return;
}

Expand All @@ -30,6 +32,7 @@ export const usePreviewSource = (item: TimelinePhotoItem, isScrubbing: boolean):

PhotoAssetFetchService.fetchPlaybackUri(item, controller.signal).then((fullUri) => {
if (!controller.signal.aborted) {
loadedItemIdRef.current = item.id;
logger.info(`[usePreviewSource] asset ready — id: ${item.id}, uri: ${fullUri ?? 'null'}`);
setUri(fullUri ?? null);
setIsLoading(false);
Expand Down
101 changes: 64 additions & 37 deletions src/screens/PhotoPreviewScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useNavigation } from '@react-navigation/native';
import strings from 'assets/lang/strings';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { StatusBar, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { ConfirmModal } from 'src/components/modals/ConfirmModal/ConfirmModal';
import { logger } from 'src/services/common';
import { useAppDispatch } from 'src/store/hooks';
Expand Down Expand Up @@ -88,6 +90,29 @@ export const PhotoPreviewScreen = ({ route }: Props): JSX.Element => {
const handleScrubStart = useCallback(() => setIsScrubbing(true), []);
const handleScrubEnd = useCallback(() => setIsScrubbing(false), []);

const translateY = useSharedValue(0);

const swipeDownGesture = Gesture.Pan()
.enabled(!zoomActive && !hasVideoStarted && !isScrubbing)
.activeOffsetY(10)
.failOffsetX([-15, 15])
.onUpdate((e) => {
if (e.translationY > 0) {
translateY.value = e.translationY;
}
})
.onEnd((e) => {
if (e.translationY > 100) {
runOnJS(handleSwipeDown)();
} else {
translateY.value = withTiming(0, { duration: 150 });
}
});

const swipeDownAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));

const currentItem = items[currentIndex];
const actionItems = useMemo(() => (currentItem ? [currentItem] : []), [currentItem]);

Expand Down Expand Up @@ -126,43 +151,45 @@ export const PhotoPreviewScreen = ({ route }: Props): JSX.Element => {
return (
<View style={tailwind('flex-1 bg-black')}>
<StatusBar hidden />
<PreviewPager
items={items}
initialIndex={currentIndex}
activeIndex={currentIndex}
isScrubbing={isScrubbing}
onIndexChange={setCurrentIndex}
onTap={handleTap}
onZoomChange={handleZoomChange}
onSwipeDown={handleSwipeDown}
onVideoPlay={handleVideoPlay}
onVideoPause={handleVideoPause}
onVideoEnd={handleVideoEnd}
videoResetKey={videoResetKey}
hasVideoStarted={hasVideoStarted}
/>
<PreviewHeader
visible={isUiVisible}
item={currentItem}
onClose={handleClose}
onMore={() => setIsMoreActionsOpen(true)}
/>
{showCarousel && (
<PreviewCarousel
items={items}
currentIndex={currentIndex}
onPress={setCurrentIndex}
onScrub={setCurrentIndex}
onScrubStart={handleScrubStart}
onScrubEnd={handleScrubEnd}
visible={isUiVisible}
onExport={handleExport}
onMore={() => setIsMoreActionsOpen(true)}
onDelete={handleDeletePress}
isSynced={isSynced}
/>
)}
<BurstIncompleteBanner visible={isUiVisible && isBurstIncomplete} />
<GestureDetector gesture={swipeDownGesture}>
<Animated.View style={[tailwind('flex-1'), swipeDownAnimatedStyle]}>
<PreviewPager
items={items}
initialIndex={currentIndex}
activeIndex={currentIndex}
isScrubbing={isScrubbing}
onIndexChange={setCurrentIndex}
onTap={handleTap}
onZoomChange={handleZoomChange}
onVideoPlay={handleVideoPlay}
onVideoPause={handleVideoPause}
onVideoEnd={handleVideoEnd}
videoResetKey={videoResetKey}
/>
<PreviewHeader
visible={isUiVisible}
item={currentItem}
onClose={handleClose}
onMore={() => setIsMoreActionsOpen(true)}
/>
{showCarousel && (
<PreviewCarousel
items={items}
currentIndex={currentIndex}
onPress={setCurrentIndex}
onScrub={setCurrentIndex}
onScrubStart={handleScrubStart}
onScrubEnd={handleScrubEnd}
visible={isUiVisible}
onExport={handleExport}
onMore={() => setIsMoreActionsOpen(true)}
onDelete={handleDeletePress}
isSynced={isSynced}
/>
)}
<BurstIncompleteBanner visible={isUiVisible && isBurstIncomplete} />
</Animated.View>
</GestureDetector>
{metadataOpen && currentItem && <MetadataPanel item={currentItem} onClose={() => setMetadataOpen(false)} />}
<MoreActionsBottomSheet
isOpen={isMoreActionsOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
WarningCircleIcon,
WifiSlashIcon,
} from 'phosphor-react-native';
import { ActivityIndicator, TouchableOpacity, View } from 'react-native';
import { useEffect, useRef } from 'react';
import { ActivityIndicator, Animated, Easing, TouchableOpacity, View } from 'react-native';
import AppText from 'src/components/AppText';
import useGetColor from 'src/hooks/useColor';
import { useTailwind } from 'tailwind-rn';
Expand Down Expand Up @@ -60,10 +61,25 @@ export const GroupHeaderScanning = ({ isSticky }: StickyProps): JSX.Element => {

export const GroupHeaderFetching = ({ isSticky }: StickyProps): JSX.Element => {
const tailwind = useTailwind();
const { statusColor } = useGroupHeaderColors(isSticky);
const { statusColor, primaryColor } = useGroupHeaderColors(isSticky);
const pulseAnim = useRef(new Animated.Value(1)).current;

useEffect(() => {
const anim = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 0.3, duration: 1200, easing: Easing.linear, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 1200, easing: Easing.linear, useNativeDriver: true }),
]),
);
anim.start();
return () => anim.stop();
}, []);

return (
<>
<CloudArrowDownIcon size={24} color={statusColor} weight="regular" />
<Animated.View style={{ opacity: pulseAnim }}>
<CloudArrowDownIcon size={24} color={primaryColor} weight="regular" />
</Animated.View>
<AppText medium style={[tailwind('text-base'), { color: statusColor }]}>
{strings.screens.photos.groupHeader.gettingPhotos}
</AppText>
Expand Down
Loading
Loading