diff --git a/app.json b/app.json index 3fef0c7c..64b560cc 100644 --- a/app.json +++ b/app.json @@ -43,11 +43,6 @@ "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" ] }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, "plugins": [ "expo-router", "expo-font", diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 9d30e53a..d546190f 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -23,6 +23,7 @@ import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, + Platform, RefreshControl, SafeAreaView, ScrollView, @@ -345,7 +346,8 @@ export default function index() { contentContainerStyle={{ paddingLeft: insets.left, paddingRight: insets.right, - paddingBottom: insets.bottom, + paddingBottom: + Platform.OS === "android" ? insets.bottom + 65 : insets.bottom, }} className="flex flex-col space-y-4" > diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index f0811e7c..d1fdf0f9 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -105,7 +105,6 @@ export default function settings() { Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success ); - toast.success("All files deleted"); } catch (e) { Haptics.notificationAsync( Haptics.NotificationFeedbackType.Error diff --git a/app/_layout.tsx b/app/_layout.tsx index 6743024d..99f93695 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,6 @@ import { DownloadProvider } from "@/providers/DownloadProvider"; import { getOrSetDeviceId, - getServerUrlFromStorage, getTokenFromStoraage, JellyfinProvider, } from "@/providers/JellyfinProvider"; @@ -9,36 +8,36 @@ import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaybackProvider } from "@/providers/PlaybackProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; import { Settings, useSettings } from "@/utils/atoms/settings"; +import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; +import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { checkForExistingDownloads, completeHandler, download, } from "@kesha-antonov/react-native-background-downloader"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as BackgroundFetch from "expo-background-fetch"; +import * as FileSystem from "expo-file-system"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; import * as Linking from "expo-linking"; +import * as Notifications from "expo-notifications"; import { router, Stack } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; +import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; import { useEffect, useRef } from "react"; import { AppState } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; -import * as TaskManager from "expo-task-manager"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as BackgroundFetch from "expo-background-fetch"; -import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; -import * as FileSystem from "expo-file-system"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import * as Notifications from "expo-notifications"; -import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; SplashScreen.preventAutoHideAsync(); @@ -145,16 +144,6 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { }) .begin(() => { console.log("TaskManager ~ Download started: ", job.id); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download started", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); }) .done(() => { console.log("TaskManager ~ Download completed: ", job.id); diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 627d74f9..5563659e 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -1,13 +1,18 @@ +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { - DefaultLanguageOption, - DownloadOptions, - ScreenOrientationEnum, - useSettings, -} from "@/utils/atoms/settings"; + BACKGROUND_FETCH_TASK, + registerBackgroundFetchAsync, + unregisterBackgroundFetchAsync, +} from "@/utils/background-tasks"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import * as BackgroundFetch from "expo-background-fetch"; +import * as ScreenOrientation from "expo-screen-orientation"; +import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; import { ActivityIndicator, Linking, @@ -16,23 +21,13 @@ import { View, ViewProps, } from "react-native"; +import { toast } from "sonner-native"; import * as DropdownMenu from "zeego/dropdown-menu"; +import { Button } from "../Button"; +import { Input } from "../common/Input"; import { Text } from "../common/Text"; import { Loader } from "../Loader"; -import { Input } from "../common/Input"; -import { useEffect, useState } from "react"; -import { Button } from "../Button"; import { MediaToggles } from "./MediaToggles"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors"; -import { useDownload } from "@/providers/DownloadProvider"; -import * as BackgroundFetch from "expo-background-fetch"; -import * as TaskManager from "expo-task-manager"; -import { - BACKGROUND_FETCH_TASK, - registerBackgroundFetchAsync, - unregisterBackgroundFetchAsync, -} from "@/utils/background-tasks"; interface Props extends ViewProps {} @@ -52,40 +47,24 @@ export const SettingToggles: React.FC = ({ ...props }) => { /******************** * Background task *******************/ - const [isRegistered, setIsRegistered] = useState(null); - const [status, setStatus] = - useState(null); - useEffect(() => { checkStatusAsync(); }, []); const checkStatusAsync = async () => { - const status = await BackgroundFetch.getStatusAsync(); - const isRegistered = await TaskManager.isTaskRegisteredAsync( - BACKGROUND_FETCH_TASK - ); - setStatus(status); - setIsRegistered(isRegistered); + await BackgroundFetch.getStatusAsync(); + await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); }; - const toggleFetchTask = async () => { - if (isRegistered) { - console.log("Unregistering task"); - await unregisterBackgroundFetchAsync(); - updateSettings({ - autoDownload: false, - }); + useEffect(() => { + if (settings?.autoDownload) { + registerBackgroundFetchAsync(); } else { - console.log("Registering task"); - await registerBackgroundFetchAsync(); - updateSettings({ - autoDownload: true, - }); + unregisterBackgroundFetchAsync(); } checkStatusAsync(); - }; + }, [settings?.autoDownload]); /********************** *********************/ @@ -571,14 +550,10 @@ export const SettingToggles: React.FC = ({ ...props }) => { finished optimizing on the server. - {isRegistered === null ? ( - - ) : ( - toggleFetchTask()} - /> - )} + updateSettings({ autoDownload: value })} + /> = ({ ...props }) => { color="purple" className="h-12 mt-2" onPress={() => { + toast.success("Saved"); updateSettings({ optimizedVersionsServerUrl: optimizedVersionsServerUrl.length === 0 diff --git a/eas.json b/eas.json index 710dabc5..4c03966e 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 9.1.0" + "version": ">= 9.1.0", + "appVersionSource": "local" }, "build": { "development": { diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 06067fd9..0ee942e8 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -37,6 +37,7 @@ import React, { import { AppState, AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; +import * as Notifications from "expo-notifications"; function onAppStateChange(status: AppStateStatus) { focusManager.setFocused(status === "active"); @@ -93,7 +94,20 @@ function useDownloadProvider() { url, }); - jobs.forEach((job) => { + // Local downloading processes that are still valid + const downloadingProcesses = processes + .filter((p) => p.status === "downloading") + .filter((p) => jobs.some((j) => j.id === p.id)); + + const updatedProcesses = jobs.filter( + (j) => !downloadingProcesses.some((p) => p.id === j.id) + ); + + setProcesses([...updatedProcesses, ...downloadingProcesses]); + + // Go though new jobs and compare them to old jobs + // if new job is now completed, start download. + for (let job of jobs) { const process = processes.find((p) => p.id === job.id); if ( process && @@ -112,20 +126,19 @@ function useDownloadProvider() { }, }, }); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: `${job.item.Name} is ready to be downloaded`, + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); } } - }); - - // Local downloading processes that are still valid - const downloadingProcesses = processes - .filter((p) => p.status === "downloading") - .filter((p) => jobs.some((j) => j.id === p.id)); - - const updatedProcesses = jobs.filter( - (j) => !downloadingProcesses.some((p) => p.id === j.id) - ); - - setProcesses([...updatedProcesses, ...downloadingProcesses]); + } return jobs; }, @@ -137,19 +150,7 @@ function useDownloadProvider() { useEffect(() => { const checkIfShouldStartDownload = async () => { if (processes.length === 0) return; - const tasks = await checkForExistingDownloads(); - // if (settings?.autoDownload) { - // for (let i = 0; i < processes.length; i++) { - // const job = processes[i]; - - // if (job.status === "completed") { - // // Check if the download is already in progress - // if (tasks.find((task) => task.id === job.id)) continue; - // await startDownload(job); - // continue; - // } - // } - // } + await checkForExistingDownloads(); }; checkIfShouldStartDownload(); @@ -178,6 +179,7 @@ function useDownloadProvider() { async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); + console.log("[0] Setting process to downloading"); setProcesses((prev) => prev.map((p) => p.id === process.id @@ -352,38 +354,71 @@ function useDownloadProvider() { const deleteAllFiles = async (): Promise => { try { - const baseDirectory = FileSystem.documentDirectory; + await deleteLocalFiles(); + await 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); + toast.error("An error occurred while deleting files and jobs"); + } + }; - if (!baseDirectory) { - throw new Error("Base directory not found"); - } + const deleteLocalFiles = async (): Promise => { + const baseDirectory = FileSystem.documentDirectory; + if (!baseDirectory) { + throw new Error("Base directory not found"); + } - const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); - - for (const item of dirContents) { - const itemPath = `${baseDirectory}${item}`; - const itemInfo = await FileSystem.getInfoAsync(itemPath); - - if (itemInfo.exists) { + const dirContents = await FileSystem.readDirectoryAsync(baseDirectory); + for (const item of dirContents) { + const itemPath = `${baseDirectory}${item}`; + const itemInfo = await FileSystem.getInfoAsync(itemPath); + if (itemInfo.exists) { + if (itemInfo.isDirectory) { + await FileSystem.deleteAsync(itemPath, { idempotent: true }); + } else { await FileSystem.deleteAsync(itemPath, { idempotent: true }); } } + } + }; + + const removeDownloadedItemsFromStorage = async (): Promise => { + try { await AsyncStorage.removeItem("downloadedItems"); - - if (!authHeader) throw new Error("No auth header"); - if (!settings?.optimizedVersionsServerUrl) - throw new Error("No server url"); - cancelAllJobs({ - authHeader, - url: settings?.optimizedVersionsServerUrl, - deviceId: await getOrSetDeviceId(), - }); - - queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); - - toast.success("All files and folders deleted successfully"); } catch (error) { - console.error("Failed to delete all files and folders:", error); + console.error( + "Failed to remove downloadedItems from AsyncStorage:", + error + ); + throw error; + } + }; + + const cancelAllServerJobs = async (): Promise => { + if (!authHeader) { + throw new Error("No auth header available"); + } + if (!settings?.optimizedVersionsServerUrl) { + throw new Error("No server URL configured"); + } + + const deviceId = await getOrSetDeviceId(); + if (!deviceId) { + throw new Error("Failed to get device ID"); + } + + try { + await cancelAllJobs({ + authHeader, + url: settings.optimizedVersionsServerUrl, + deviceId, + }); + } catch (error) { + console.error("Failed to cancel all server jobs:", error); + throw error; } };