import { Ionicons } from "@expo/vector-icons"; import { useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; import { t } from "i18next"; import { useMemo } from "react"; import { ActivityIndicator, TouchableOpacity, type TouchableOpacityProps, View, } from "react-native"; import { toast } from "sonner-native"; import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator"; import { JobStatus } from "@/providers/Downloads/types"; import { estimateDownloadSize } from "@/utils/download"; import { storage } from "@/utils/mmkv"; import { formatTimeString } from "@/utils/time"; const bytesToMB = (bytes: number) => { return bytes / 1024 / 1024; }; const formatBytes = (bytes: number): string => { if (bytes >= 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; }; interface DownloadCardProps extends TouchableOpacityProps { process: JobStatus; } export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const { cancelDownload } = useDownload(); const router = useRouter(); const queryClient = useQueryClient(); const handleDelete = async (id: string) => { try { await cancelDownload(id); // cancelDownload already shows a toast, so don't show another one queryClient.invalidateQueries({ queryKey: ["downloads"] }); } catch (error) { console.error("Error deleting download:", error); toast.error(t("home.downloads.toasts.could_not_delete_download")); } }; const eta = useMemo(() => { if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) { return null; } const secondsRemaining = calculateSmoothedETA( process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes, ); if (!secondsRemaining || secondsRemaining <= 0) { return null; } return formatTimeString(secondsRemaining, "s"); }, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]); const estimatedSize = useMemo(() => { if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes; // Calculate from bitrate + duration (only if bitrate value is defined) if (process.maxBitrate.value) { return estimateDownloadSize( process.maxBitrate.value, process.item.RunTimeTicks, ); } return undefined; }, [ process.maxBitrate.value, process.item.RunTimeTicks, process.estimatedTotalSizeBytes, ]); const isTranscoding = process.isTranscoding || false; const downloadedAmount = useMemo(() => { if (!process.bytesDownloaded) return null; return formatBytes(process.bytesDownloaded); }, [process.bytesDownloaded]); const base64Image = useMemo(() => { return storage.getString(process.item.Id!); }, []); // Sanitize progress to ensure it's within valid bounds const sanitizedProgress = useMemo(() => { if ( typeof process.progress !== "number" || Number.isNaN(process.progress) ) { return 0; } return Math.max(0, Math.min(100, process.progress)); }, [process.progress]); return ( router.push(`/(auth)/items/page?id=${process.item.Id}`)} className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden' {...props} > {process.status === "downloading" && ( 0 ? `${Math.max(5, sanitizedProgress)}%` : "5%", }} /> )} {/* Action buttons in bottom right corner */} handleDelete(process.id)} className='p-2 bg-neutral-800 rounded-full' > {base64Image && ( )} {process.item.Type} {process.item.Name} {process.item.ProductionYear} {isTranscoding && ( Transcoding )} {/* Row 1: Progress + Downloaded/Total */} {sanitizedProgress === 0 ? ( ) : ( {sanitizedProgress.toFixed(0)}% )} {downloadedAmount && ( {downloadedAmount} {estimatedSize ? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}` : ""} )} {/* Row 2: Speed + ETA */} {process.speed && process.speed > 0 && ( {bytesToMB(process.speed).toFixed(2)} MB/s )} {eta && ( {t("home.downloads.eta", { eta: eta })} )} ); };