refactor: Feature/offline mode rework (#859)

Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
Co-authored-by: Gauvino <uruknarb20@gmail.com>
Co-authored-by: storm1er <le.storm1er@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com>
Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com>
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
Alex
2025-08-16 05:34:22 +10:00
committed by GitHub
parent 4fba558c33
commit ca92f61900
94 changed files with 3325 additions and 3523 deletions

View File

@@ -6,7 +6,6 @@ import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
@@ -15,18 +14,17 @@ import {
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import type { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
interface Props extends ViewProps {}
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload();
if (processes?.length === 0)
@@ -60,32 +58,18 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload } = useDownload();
const { startDownload, removeProcess } = useDownload();
const router = useRouter();
const { removeProcess } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient();
const cancelJobMutation = useMutation({
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
try {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
}
} finally {
await removeProcess(id);
if (settings?.downloadMethod === DownloadMethod.Optimized) {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
}
removeProcess(id);
},
onSuccess: () => {
toast.success(t("home.downloads.toasts.download_cancelled"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
},
onError: (e) => {
console.error(e);
@@ -94,11 +78,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
});
const eta = (p: JobStatus) => {
if (!p.speed || !p.progress) return null;
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const length = p?.item?.RunTimeTicks || 0;
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
return formatTimeString(timeLeft, "tick");
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
@@ -111,8 +98,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{(process.status === "optimizing" ||
process.status === "downloading") && (
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
@@ -152,8 +138,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && (
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>

View File

@@ -13,7 +13,8 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);

View File

@@ -4,18 +4,13 @@ import {
} from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { useCallback, useMemo } from "react";
import {
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { useCallback } from "react";
import { type TouchableOpacityProps, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
@@ -25,24 +20,15 @@ interface EpisodeCardProps extends TouchableOpacityProps {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const _base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, [item]);
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
deleteFile(item.Id, "Episode");
successHapticFeedback();
}
}, [deleteFile, item.Id]);
@@ -73,10 +59,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<TouchableOpacity
onPress={handleOpenFile}
<TouchableItemRouter
item={item}
isOffline={true}
onLongPress={showActionSheet}
key={item.Id}
className='flex flex-col mb-4'
>
<View className='flex flex-row items-start mb-2'>
@@ -100,7 +86,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.Overview}
</Text>
</TouchableOpacity>
</TouchableItemRouter>
);
};

View File

@@ -7,12 +7,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import type React from "react";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { ProgressBar } from "../common/ProgressBar";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
@@ -26,16 +26,10 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
return storage.getString(item?.Id!);
}, []);
/**
@@ -43,8 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
successHapticFeedback();
deleteFile(item.Id, "Movie");
}
}, [deleteFile, item.Id]);
@@ -74,9 +67,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
{base64Image ? (
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -87,22 +80,24 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
resizeMode: "cover",
}}
/>
<ProgressBar item={item} />
</View>
) : (
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons
name='image-outline'
size={24}
color='gray'
className='self-center mt-16'
/>
<ProgressBar item={item} />
</View>
)}
<View className='w-28'>
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />
</TouchableOpacity>
</TouchableItemRouter>
);
};