diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx index 397df9de..2699354d 100644 --- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx +++ b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx @@ -1,12 +1,13 @@ import {Text} from "@/components/common/Text"; import {useDownload} from "@/providers/DownloadProvider"; import {router, useLocalSearchParams, useNavigation} from "expo-router"; -import React, {useEffect, useMemo, useState} from "react"; -import {ScrollView, View} from "react-native"; +import React, {useCallback, useEffect, useMemo, useState} from "react"; +import {ScrollView, TouchableOpacity, View} from "react-native"; import {EpisodeCard} from "@/components/downloads/EpisodeCard"; import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models"; import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown"; import {storage} from "@/utils/mmkv"; +import {Ionicons} from "@expo/vector-icons"; export default function page() { const navigation = useNavigation(); @@ -17,7 +18,7 @@ export default function page() { }; const [seasonIndexState, setSeasonIndexState] = useState({}); - const {downloadedFiles} = useDownload(); + const {downloadedFiles, deleteItems} = useDownload(); const series = useMemo(() => { try { @@ -64,6 +65,10 @@ export default function page() { } }, [series]); + const deleteSeries = useCallback( + async () => deleteItems(groupBySeason), + [groupBySeason] + ); return ( <> {series.length > 0 && @@ -78,9 +83,16 @@ export default function page() { [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, })); }}/> - - {groupBySeason.length} - + + + {groupBySeason.length} + + + + + + + } {groupBySeason.map((episode, index) => ( diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 7f169b6c..39f46c8c 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -6,17 +6,24 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { useSettings } from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; +import {useNavigation, useRouter} from "expo-router"; import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; +import React, {useEffect, useMemo, useRef} from "react"; +import {Alert, ScrollView, TouchableOpacity, View} from "react-native"; +import { Button } from "@/components/Button"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import {DownloadSize} from "@/components/downloads/DownloadSize"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import {toast} from "sonner-native"; +import {writeToLog} from "@/utils/log"; export default function page() { + const navigation = useNavigation(); const [queue, setQueue] = useAtom(queueAtom); - const { removeProcess, downloadedFiles } = useDownload(); + const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); const router = useRouter(); const [settings] = useSettings(); + const bottomSheetModalRef = useRef(null); const movies = useMemo(() => { try { @@ -46,107 +53,160 @@ export default function page() { const insets = useSafeAreaInsets(); - return ( - - - - {settings?.downloadMethod === "remux" && ( - - Queue - - Queue and downloads will be lost on app restart - - - {queue.map((q, index) => ( - - router.push(`/(auth)/items/page?id=${q.item.Id}`) - } - className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" - key={index} - > - - {q.item.Name} - {q.item.Type} - - { - removeProcess(q.id); - setQueue((prev) => { - if (!prev) return []; - return [...prev.filter((i) => i.id !== q.id)]; - }); - }} - > - - - - ))} - + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + f.item) || []}/> + + ) + }) + }, [downloadedFiles]); - {queue.length === 0 && ( - No items in queue - )} + const deleteMovies = () => deleteFileByType("Movie") + .then(() => toast.success("Deleted all movies successfully!")) + .catch((reason) => { + writeToLog("ERROR", reason); + toast.error("Failed to delete all movies"); + }); + const deleteShows = () => deleteFileByType("Movie") + .then(() => toast.success("Deleted all TV-Series successfully!")) + .catch((reason) => { + writeToLog("ERROR", reason); + toast.error("Failed to delete all TV-Series"); + }); + const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()]) + + return ( + <> + + + + {settings?.downloadMethod === "remux" && ( + + Queue + + Queue and downloads will be lost on app restart + + + {queue.map((q, index) => ( + + router.push(`/(auth)/items/page?id=${q.item.Id}`) + } + className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" + key={index} + > + + {q.item.Name} + {q.item.Type} + + { + removeProcess(q.id); + setQueue((prev) => { + if (!prev) return []; + return [...prev.filter((i) => i.id !== q.id)]; + }); + }} + > + + + + ))} + + + {queue.length === 0 && ( + No items in queue + )} + + )} + + + + + {movies.length > 0 && ( + + + Movies + + {movies?.length} + + + + + {movies?.map((item) => ( + + + + ))} + + + + )} + {groupedBySeries.length > 0 && ( + + + TV-Series + + {groupedBySeries?.length} + + + + + {groupedBySeries?.map((items) => ( + + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + + + )} + {downloadedFiles?.length === 0 && ( + + No downloaded items )} - - - - {movies.length > 0 && ( - - - Movies - - {movies?.length} - - - - - {movies?.map((item) => ( - - - - ))} - - - + + ( + )} - {groupedBySeries.length > 0 && ( - - - TV-Series - - {groupedBySeries?.length} - - - - - {groupedBySeries?.map((items) => ( - - i.item)} - key={items[0].item.SeriesId} - /> - - ))} - - + > + + + + + - )} - {downloadedFiles?.length === 0 && ( - - No downloaded items - - )} - - + + + ); } diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 6c5cc32a..e1a49d54 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,20 +2,22 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import { useDownload } from "@/providers/DownloadProvider"; +import {bytesToReadable, useDownload} from "@/providers/DownloadProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { clearLogs, readFromLog } from "@/utils/log"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useAtom } from "jotai"; -import { Alert, ScrollView, View } from "react-native"; +import {Alert, ScrollView, View} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; +import * as Progress from 'react-native-progress'; +import * as FileSystem from "expo-file-system"; export default function settings() { const { logout } = useJellyfin(); - const { deleteAllFiles } = useDownload(); + const { deleteAllFiles, getAppSizeUsage } = useDownload(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -28,6 +30,18 @@ export default function settings() { const insets = useSafeAreaInsets(); + const {data: size , isLoading: appSizeLoading } = useQuery({ + queryKey: ["appSize"], + queryFn: async () => { + const app = await getAppSizeUsage() + + const remaining = await FileSystem.getFreeDiskStorageAsync() + const total = await FileSystem.getTotalDiskCapacityAsync() + + return {app, remaining, total, used: (total - remaining) / total} + } + }) + const openQuickConnectAuthCodeInput = () => { Alert.prompt( "Quick connect", @@ -57,6 +71,27 @@ export default function settings() { ); }; + const onDeleteClicked = async () => { + try { + await deleteAllFiles(); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + } catch (e) { + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Error + ); + toast.error("Error deleting files"); + } + } + + const onClearLogsClicked = async () => { + clearLogs(); + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + }; + return ( + @@ -92,42 +130,33 @@ export default function settings() { - - Account and storage - - - - - + + Storage + + {size && ( + Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)} + )} + + Logs diff --git a/bun.lockb b/bun.lockb index db7d734e..3a854c55 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx index a69fc4a0..d1b11bf0 100644 --- a/components/downloads/DownloadSize.tsx +++ b/components/downloads/DownloadSize.tsx @@ -2,9 +2,10 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import React, {useEffect, useMemo, useState} from "react"; import {Text} from "@/components/common/Text"; import useDownloadHelper from "@/utils/download"; -import {useDownload} from "@/providers/DownloadProvider"; +import {bytesToReadable, useDownload} from "@/providers/DownloadProvider"; +import {TextProps} from "react-native"; -interface DownloadSizeProps { +interface DownloadSizeProps extends TextProps { items: BaseItemDto[]; } @@ -13,7 +14,7 @@ interface DownloadSizes { itemsNeedingSize: BaseItemDto[]; } -export const DownloadSize: React.FC = ({ items }) => { +export const DownloadSize: React.FC = ({ items, ...props }) => { const { downloadedFiles, saveDownloadedItemInfo } = useDownload(); const { getDownloadSize } = useDownloadHelper(); const [size, setSize] = useState(); @@ -53,17 +54,9 @@ export const DownloadSize: React.FC = ({ items }) => { return size }, [size]) - const bytesToReadable = (bytes: number) => { - const gb = bytes / 1e+9; - - if (gb >= 1) - return `${gb.toFixed(2)} GB` - return `${(bytes / 1024 / 1024).toFixed(2)} MB` - } - return ( <> - {sizeText} + {sizeText} ); }; \ No newline at end of file diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx index 1b665829..4c6efa1f 100644 --- a/components/downloads/SeriesCard.tsx +++ b/components/downloads/SeriesCard.tsx @@ -1,20 +1,49 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import {TouchableOpacity, View} from "react-native"; import { Text } from "../common/Text"; -import React, {useEffect, useMemo, useState} from "react"; +import React, {useCallback, useMemo} from "react"; import {storage} from "@/utils/mmkv"; import {Image} from "expo-image"; import {Ionicons} from "@expo/vector-icons"; import {router} from "expo-router"; import {DownloadSize} from "@/components/downloads/DownloadSize"; +import {useDownload} from "@/providers/DownloadProvider"; +import {useActionSheet} from "@expo/react-native-action-sheet"; export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => { + const { deleteItems } = useDownload(); + const { showActionSheetWithOptions } = useActionSheet(); + const base64Image = useMemo(() => { return storage.getString(items[0].SeriesId!); }, []); + const deleteSeries = useCallback( + async () => deleteItems(items), + [items] + ); + + const showActionSheet = useCallback(() => { + const options = ["Delete", "Cancel"]; + const destructiveButtonIndex = 0; + + showActionSheetWithOptions({ + options, + destructiveButtonIndex, + }, + (selectedIndex) => { + if (selectedIndex == destructiveButtonIndex) { + deleteSeries(); + } + } + ); + }, [showActionSheetWithOptions, deleteSeries]); + return ( - router.push(`/downloads/${items[0].SeriesId}`)}> + router.push(`/downloads/${items[0].SeriesId}`)} + onLongPress={showActionSheet} + > {base64Image ? ( ; @@ -388,19 +390,20 @@ function useDownloadProvider() { ); const deleteAllFiles = async (): Promise => { - try { - await deleteLocalFiles(); - removeDownloadedItemsFromStorage(); - await cancelAllServerJobs(); - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - toast.success("All files, folders, and jobs deleted successfully"); - } catch (error) { - console.error("Failed to delete all files, folders, and jobs:", error); + Promise.all([ + deleteLocalFiles(), + removeDownloadedItemsFromStorage(), + cancelAllServerJobs(), + queryClient.invalidateQueries({queryKey: ["downloadedItems"]}), + ]).then(() => + toast.success("All files, folders, and jobs deleted successfully") + ).catch((reason) => { + console.error("Failed to delete all files, folders, and jobs:", reason); toast.error("An error occurred while deleting files and jobs"); - } + }); }; - const deleteLocalFiles = async (): Promise => { + const forEveryDirectory = async (callback: (dir: FileInfo) => void) => { const baseDirectory = FileSystem.documentDirectory; if (!baseDirectory) { throw new Error("Base directory not found"); @@ -408,25 +411,36 @@ function useDownloadProvider() { const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); for (const item of dirContents) { - const itemPath = `${baseDirectory}${item}`; - const itemInfo = await FileSystem.getInfoAsync(itemPath); + // Exclude mmkv directory. + // Deleting this deletes all user information as well. Logout should handle this. + if (item == "mmkv") + continue + const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`); if (itemInfo.exists) { - if (itemInfo.isDirectory) { - await FileSystem.deleteAsync(itemPath, { idempotent: true }); - } else { - await FileSystem.deleteAsync(itemPath, { idempotent: true }); - } + callback(itemInfo) } } + } + + const deleteLocalFiles = async (): Promise => { + await forEveryDirectory((dir) => { + console.warn("Deleting file", dir.uri) + FileSystem.deleteAsync(dir.uri, {idempotent: true}) + } + ) }; - const removeDownloadedItemsFromStorage = (): void => { - try { - storage.delete("downloadedItems"); - } catch (error) { - console.error("Failed to remove downloadedItems from storage:", error); - throw error; - } + const removeDownloadedItemsFromStorage = async () => { + // delete any saved images first + Promise.all([ + deleteFileByType("Movie"), + deleteFileByType("Episode"), + ]).then(() => + storage.delete("downloadedItems") + ).catch((reason) => { + console.error("Failed to remove downloadedItems from storage:", reason); + throw reason + }) }; const cancelAllServerJobs = async (): Promise => { @@ -434,7 +448,8 @@ function useDownloadProvider() { throw new Error("No auth header available"); } if (!settings?.optimizedVersionsServerUrl) { - throw new Error("No server URL configured"); + console.error("No server URL configured"); + return } const deviceId = await getOrSetDeviceId(); @@ -494,6 +509,41 @@ function useDownloadProvider() { } }; + const deleteItems = async (items: BaseItemDto[]) => { + Promise.all(items.map(i => { + if (i.Id) + return deleteFile(i.Id) + return + })).then(() => + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) + ) + } + + const deleteFileByType = async (type: BaseItemDto['Type']) => { + await Promise.all( + downloadedFiles + ?.filter(file => file.item.Type == type) + ?.flatMap(file => { + const promises = []; + if (type == "Episode" && file.item.SeriesId) + promises.push(deleteFile(file.item.SeriesId)) + promises.push(deleteFile(file.item.Id!)) + return promises; + }) + || [] + ); + } + + const getAppSizeUsage = async () => { + const sizes: number[] = []; + await forEveryDirectory(dir => { + if (dir.exists) + sizes.push(dir.size) + }) + + return sizes.reduce((sum, size) => sum + size, 0); + } + function getDownloadedItem(itemId: string): DownloadedItem | null { try { const downloadedItems = storage.getString("downloadedItems"); @@ -566,11 +616,14 @@ function useDownloadProvider() { downloadedFiles, deleteAllFiles, deleteFile, + deleteItems, saveDownloadedItemInfo, removeProcess, setProcesses, startDownload, getDownloadedItem, + deleteFileByType, + getAppSizeUsage }; } @@ -591,3 +644,11 @@ export function useDownload() { } return context; } + +export function bytesToReadable(bytes: number): string { + const gb = bytes / 1e+9; + + if (gb >= 1) + return `${gb.toFixed(2)} GB` + return `${(bytes / 1024 / 1024).toFixed(2)} MB` +} \ No newline at end of file