Skip to content
Merged
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
7 changes: 3 additions & 4 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
Settings as SettingsIcon,
} from '@/components/ui/icons';
import { useTranslate } from '@/lib/i18n';
import { restoreUnfinishedDownloads } from '@/lib/stores/active-downloads-store';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
import { useTabConfigStore } from '@/lib/stores/tab-config-store';

Expand All @@ -25,10 +24,10 @@ export default function TabLayout(): React.ReactElement {
const hydrateDownloads = useDownloadedStore((s) => s.hydrate);

React.useEffect(() => {
// Hydrates the downloaded list AND restores unfinished download tasks
// (interrupted ones become retryable; their partial files are spared by
// the orphan cleanup inside).
hydrateDownloads();
// Bring back downloads that failed or were interrupted by an app restart,
// so they can be retried from the Downloads tab instead of being lost.
restoreUnfinishedDownloads();
}, [hydrateDownloads]);

React.useEffect(() => {
Expand Down
92 changes: 60 additions & 32 deletions src/app/(app)/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { FocusAwareStatusBar } from '@/components/ui';
import { Trash } from '@/components/ui/icons';
import { VideoTile } from '@/components/video-tile';
import type { DownloadMetadata } from '@/lib/download';
import { cancelDownload, retryDownload } from '@/lib/download/download-video';
import { cancelDownload, pauseDownload, retryDownload } from '@/lib/download/download-video';
import { useColumns } from '@/lib/hooks/use-columns';
import { useTranslate } from '@/lib/i18n';
import { type ActiveDownload, useActiveDownloadsStore } from '@/lib/stores/active-downloads-store';
Expand Down Expand Up @@ -138,25 +138,35 @@ function DownloadRow({

function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactElement {
const t = useTranslate();
const [cancelling, setCancelling] = React.useState(false);
const [retrying, setRetrying] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const indeterminate = task.progress < 0;
const pct = indeterminate ? 0 : Math.round(task.progress * 100);
const barWidth = indeterminate ? 40 : pct;
const isFailed = task.status === 'error';
// A retry is only meaningful while the task is still in its error state —
// once retryDownload flips it to preparing/downloading the button goes away.
const canRetry = isFailed && !retrying && Boolean(task.videoUrl || task.slug);
const isPaused = task.status === 'paused';
// A retry is only meaningful while the task is still error/paused — once
// retryDownload flips it to preparing/downloading the button goes away.
const canRetry = (isFailed || isPaused) && !busy && Boolean(task.videoUrl || task.slug);

const onCancel = async () => {
setCancelling(true);
setBusy(true);
// Cancelling a failed/paused task removes it together with its partial
// file (there is no transfer left to abort).
await cancelDownload(task.baseId);
};

const onRetry = async () => {
setRetrying(true);
await retryDownload(task.baseId);
setRetrying(false);
setBusy(true);
// false = still offline / source gone; the task stays errored so the
// user can delete it instead.
const restarted = await retryDownload(task.baseId);
if (!restarted) setBusy(false);
};

const onPause = async () => {
setBusy(true);
await pauseDownload(task.baseId);
setBusy(false);
};

return (
Expand All @@ -176,17 +186,25 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme
{task.title}
</Text>
<Text className="text-neutral-500 dark:text-neutral-400 text-xs">
{task.status === 'error'
{isFailed
? `${t('library.status_failed', { error: task.error ? `: ${task.error}` : '' })}${t('library.tap_retry_hint')}`
: task.status === 'cancelled'
? t('library.status_cancelled')
: indeterminate
? t('library.status_downloading_indeterminate')
: t('library.status_downloading', { progress: pct })}
: isPaused
? t('library.status_paused')
: task.status === 'cancelled'
? t('library.status_cancelled')
: indeterminate
? t('library.status_downloading_indeterminate')
: t('library.status_downloading', { progress: pct })}
</Text>
<View className="mt-1 h-1.5 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700">
<View
className={isFailed ? 'h-full bg-red-500' : 'h-full bg-sky-500'}
className={
isFailed
? 'h-full bg-red-500'
: isPaused
? 'h-full bg-neutral-400 dark:bg-neutral-500'
: 'h-full bg-sky-500'
}
style={{ width: `${barWidth}%` }}
/>
</View>
Expand All @@ -198,32 +216,42 @@ function ActiveDownloadRow({ task }: { task: ActiveDownload }): React.ReactEleme
className="items-center justify-center rounded-full bg-primary-500 px-3 py-1.5"
testID="active-download-retry"
>
<Text className="text-xs font-medium text-white">
{retrying ? '…' : t('library.retry')}
<Text className="text-xs font-medium text-white">{t('common.resume')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onCancel}
disabled={busy}
className="items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-delete"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{t('common.delete')}
</Text>
</TouchableOpacity>
</View>
) : (
<View className="ml-2 flex-row items-center gap-2">
<TouchableOpacity
onPress={onPause}
disabled={busy || task.status !== 'downloading'}
className="items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-pause"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{t('common.pause')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onCancel}
disabled={cancelling}
disabled={busy || task.status === 'cancelled'}
className="items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-cancel"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{cancelling ? '…' : t('common.cancel')}
{busy ? '…' : t('common.cancel')}
</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
onPress={onCancel}
disabled={cancelling || task.status === 'cancelled'}
className="ml-2 items-center justify-center rounded-full bg-neutral-200 px-3 py-1.5 dark:bg-neutral-700"
testID="active-download-cancel"
>
<Text className="text-xs font-medium text-neutral-600 dark:text-neutral-300">
{cancelling ? '…' : t('common.cancel')}
</Text>
</TouchableOpacity>
)}
</View>
);
Expand Down
89 changes: 88 additions & 1 deletion src/components/video-tile.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { endVideoOpen, resetOpenGuard } from '@/lib/navigation/open-guard';
import { useActiveDownloadsStore } from '@/lib/stores/active-downloads-store';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
import { useFavoritesStore } from '@/lib/stores/favorites-store';
import { cleanup, fireEvent, render } from '@/lib/test-utils';
Expand All @@ -11,7 +12,7 @@ import { VideoTile } from './video-tile';
// jest's mock factory can reference it.
const mockMenu = {
lastProps: {} as {
actions?: { id: string; title: string }[];
actions?: { id: string; title: string; attributes?: { disabled?: boolean } }[];
onPressAction?: (e: { nativeEvent: { event: string } }) => void;
},
};
Expand All @@ -25,6 +26,14 @@ jest.mock('@react-native-menu/menu', () => {
return { MenuView };
});

const mockRetryDownload = jest.fn().mockResolvedValue(true);
jest.mock('@/lib/download/download-video', () => ({
downloadVideo: jest.fn(),
cancelDownload: jest.fn(),
pauseDownload: jest.fn(),
retryDownload: (...args: unknown[]) => mockRetryDownload(...args),
}));

const mockPush = jest.fn();
jest.mock('expo-router', () => ({
useRouter: () => ({ push: mockPush }),
Expand Down Expand Up @@ -61,6 +70,8 @@ beforeEach(() => {
entries: [],
loaded: false,
});
useActiveDownloadsStore.setState({ tasks: {} });
mockRetryDownload.mockClear();
resetOpenGuard();
});

Expand Down Expand Up @@ -205,4 +216,80 @@ describe('VideoTile — context menu actions', () => {

expect(useFavoritesStore.getState().favorites.some((f) => f.id === '1')).toBe(true);
});

it('offers Resume download (enabled) when the task is errored or paused', () => {
useActiveDownloadsStore.setState({
tasks: {
'1': {
baseId: '1',
title: 'My Video',
thumbnail: '',
progress: 0.4,
status: 'error',
startedAt: 0,
},
},
});
render(<VideoTile item={item} />);
const errored = mockMenu.lastProps.actions?.find((a) => a.id === 'download');
expect(errored?.title).toBe('Resume download');
expect(errored?.attributes?.disabled).toBeUndefined();

useActiveDownloadsStore.setState({
tasks: {
'1': {
baseId: '1',
title: 'My Video',
thumbnail: '',
progress: 0.4,
status: 'paused',
startedAt: 0,
},
},
});
render(<VideoTile item={item} />);
const paused = mockMenu.lastProps.actions?.find((a) => a.id === 'download');
expect(paused?.title).toBe('Resume download');
expect(paused?.attributes?.disabled).toBeUndefined();
});

it('disables Download while the task is actively downloading', () => {
useActiveDownloadsStore.setState({
tasks: {
'1': {
baseId: '1',
title: 'My Video',
thumbnail: '',
progress: 0.4,
status: 'downloading',
startedAt: 0,
},
},
});
render(<VideoTile item={item} />);

const downloadAction = mockMenu.lastProps.actions?.find((a) => a.id === 'download');
expect(downloadAction?.title).toBe('Downloading…');
expect(downloadAction?.attributes?.disabled).toBe(true);
});

it("resumes (retries) instead of starting fresh when a paused task's download is dispatched", () => {
useActiveDownloadsStore.setState({
tasks: {
'1': {
baseId: '1',
title: 'My Video',
thumbnail: '',
progress: 0.4,
status: 'paused',
startedAt: 0,
},
},
});
render(<VideoTile item={item} />);

mockMenu.lastProps.onPressAction?.({ nativeEvent: { event: 'download' } });

expect(mockRetryDownload).toHaveBeenCalledWith('1');
});
});
23 changes: 20 additions & 3 deletions src/components/video-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';
import { Platform } from 'react-native';

import { Image, Pressable, Text, View } from '@/components/ui';
import { retryDownload } from '@/lib/download/download-video';
import { useVideoActions } from '@/lib/hooks/use-video-actions';
import { tryBeginVideoOpen } from '@/lib/navigation/open-guard';
import { useActiveDownload } from '@/lib/stores/active-downloads-store';
Expand Down Expand Up @@ -62,7 +63,13 @@ function VideoTileImpl({ item, children }: Props): React.ReactElement {
break;
}
case 'download': {
void toggleDownload();
// An errored/paused task continues (resuming from the bytes already
// on disk when the previous attempt stopped mid-transfer).
if (active?.status === 'error' || active?.status === 'paused') {
void retryDownload(item.id);
} else {
void toggleDownload();
}
break;
}
case 'follow': {
Expand All @@ -80,9 +87,19 @@ function VideoTileImpl({ item, children }: Props): React.ReactElement {
},
{
id: 'download',
title: isActive ? 'Downloading…' : isDownloaded ? 'Downloaded' : 'Download',
title:
active?.status === 'error' || active?.status === 'paused'
? 'Resume download'
: isActive
? 'Downloading…'
: isDownloaded
? 'Downloaded'
: 'Download',
state: (isDownloaded ? 'on' : 'off') as 'on' | 'off',
attributes: isActive || isDownloaded ? { disabled: true } : undefined,
attributes:
isActive && active?.status !== 'error' && active?.status !== 'paused'
? { disabled: true }
: undefined,
},
{ id: 'follow', title: 'Follow author' },
];
Expand Down
Loading
Loading