diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index a75ca44c..f0811e7c 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,10 +2,7 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import { - registerBackgroundFetchAsync, - useDownload, -} from "@/providers/DownloadProvider"; +import { 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"; @@ -94,18 +91,6 @@ export default function settings() { - - Tests - - - Account and storage diff --git a/app/_layout.tsx b/app/_layout.tsx index 13bfb2a9..6743024d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,18 +1,27 @@ import { DownloadProvider } from "@/providers/DownloadProvider"; -import { JellyfinProvider } from "@/providers/JellyfinProvider"; +import { + getOrSetDeviceId, + getServerUrlFromStorage, + getTokenFromStoraage, + JellyfinProvider, +} from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaybackProvider } from "@/providers/PlaybackProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; -import { useSettings } from "@/utils/atoms/settings"; +import { Settings, useSettings } from "@/utils/atoms/settings"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; -import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; +import { + checkForExistingDownloads, + completeHandler, + download, +} from "@kesha-antonov/react-native-background-downloader"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; import * as Linking from "expo-linking"; -import { Stack } from "expo-router"; +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"; @@ -22,9 +31,198 @@ 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(); +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}); + +function useNotificationObserver() { + useEffect(() => { + let isMounted = true; + + function redirect(notification: Notifications.Notification) { + const url = notification.request.content.data?.url; + if (url) { + router.push(url); + } + } + + Notifications.getLastNotificationResponseAsync().then((response) => { + if (!isMounted || !response?.notification) { + return; + } + redirect(response?.notification); + }); + + const subscription = Notifications.addNotificationResponseReceivedListener( + (response) => { + redirect(response.notification); + } + ); + + return () => { + isMounted = false; + subscription.remove(); + }; + }, []); +} + +TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { + console.log("TaskManager ~ trigger"); + + const now = Date.now(); + + const settingsData = await AsyncStorage.getItem("settings"); + + if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; + + const settings: Partial = JSON.parse(settingsData); + const url = settings?.optimizedVersionsServerUrl; + + if (!settings?.autoDownload || !url) + return BackgroundFetch.BackgroundFetchResult.NoData; + + const token = await getTokenFromStoraage(); + const deviceId = await getOrSetDeviceId(); + const baseDirectory = FileSystem.documentDirectory; + + if (!token || !deviceId || !baseDirectory) + return BackgroundFetch.BackgroundFetchResult.NoData; + + console.log({ + token, + url, + deviceId, + }); + + const jobs = await getAllJobsByDeviceId({ + deviceId, + authHeader: token, + url, + }); + + console.log("TaskManager ~ Active jobs: ", jobs.length); + + for (let job of jobs) { + if (job.status === "completed") { + const downloadUrl = url + "download/" + job.id; + console.log({ + token, + deviceId, + baseDirectory, + url, + downloadUrl, + }); + + const tasks = await checkForExistingDownloads(); + + if (tasks.find((task) => task.id === job.id)) { + console.log("TaskManager ~ Download already in progress: ", job.id); + continue; + } + + download({ + id: job.id, + url: url + "download/" + job.id, + destination: `${baseDirectory}${job.item.Id}.mp4`, + headers: { + Authorization: token, + }, + }) + .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); + saveDownloadedItemInfo(job.item); + completeHandler(job.id); + cancelJobById({ + authHeader: token, + id: job.id, + url: url, + }); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download completed", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }) + .error((error) => { + console.log("TaskManager ~ Download error: ", job.id, error); + completeHandler(job.id); + Notifications.scheduleNotificationAsync({ + content: { + title: job.item.Name, + body: "Download failed", + data: { + url: `/downloads`, + }, + }, + trigger: null, + }); + }); + } + } + + console.log(`Auto download started: ${new Date(now).toISOString()}`); + + // Be sure to return the successful result type! + return BackgroundFetch.BackgroundFetchResult.NewData; +}); + +const checkAndRequestPermissions = async () => { + try { + const hasAskedBefore = await AsyncStorage.getItem( + "hasAskedForNotificationPermission" + ); + + if (hasAskedBefore !== "true") { + const { status } = await Notifications.requestPermissionsAsync(); + + if (status === "granted") { + console.log("Notification permissions granted."); + } else { + console.log("Notification permissions denied."); + } + + await AsyncStorage.setItem("hasAskedForNotificationPermission", "true"); + } else { + console.log("Already asked for notification permissions before."); + } + } catch (error) { + console.error("Error checking/requesting notification permissions:", error); + } +}; + export default function RootLayout() { const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), @@ -52,6 +250,7 @@ function Layout() { const [orientation, setOrientation] = useAtom(orientationAtom); useKeepAwake(); + useNotificationObserver(); const queryClientRef = useRef( new QueryClient({ @@ -67,6 +266,10 @@ function Layout() { }) ); + useEffect(() => { + checkAndRequestPermissions(); + }, []); + useEffect(() => { if (settings?.autoRotate === true) ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); @@ -164,7 +367,7 @@ function Layout() { ); } + +async function saveDownloadedItemInfo(item: BaseItemDto) { + try { + const downloadedItems = await AsyncStorage.getItem("downloadedItems"); + let items: BaseItemDto[] = downloadedItems + ? JSON.parse(downloadedItems) + : []; + + const existingItemIndex = items.findIndex((i) => i.Id === item.Id); + if (existingItemIndex !== -1) { + items[existingItemIndex] = item; + } else { + items.push(item); + } + + await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); + } catch (error) { + console.error("Failed to save downloaded item information:", error); + } +} diff --git a/bun.lockb b/bun.lockb index be0777f2..f20d90a6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 15b1557c..e8485627 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -101,7 +101,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" {...props} > - {process.status === "optimizing" && ( + {(process.status === "optimizing" || + process.status === "downloading") && ( = ({ ...props }) => { const [marlinUrl, setMarlinUrl] = useState(""); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(""); + useState(settings?.optimizedVersionsServerUrl || ""); const queryClient = useQueryClient(); + /******************** + * 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); + }; + + const toggleFetchTask = async () => { + if (isRegistered) { + console.log("Unregistering task"); + await unregisterBackgroundFetchAsync(); + updateSettings({ + autoDownload: false, + }); + } else { + console.log("Registering task"); + await registerBackgroundFetchAsync(); + updateSettings({ + autoDownload: true, + }); + } + + checkStatusAsync(); + }; + /********************** + *********************/ + const { data: mediaListCollections, isLoading: isLoadingMediaListCollections, @@ -515,6 +563,23 @@ export const SettingToggles: React.FC = ({ ...props }) => { + + + Auto download + + This will automatically download the media file when it's + finished optimizing on the server. + + + {isRegistered === null ? ( + + ) : ( + toggleFetchTask()} + /> + )} + = ({ ...props }) => { = ({ ...props }) => { Save - - {settings.optimizedVersionsServerUrl && ( - - {settings.optimizedVersionsServerUrl} - - )} diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 18c5ee3d..091325fb 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -62,7 +62,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { itemId: item.Id, outputPath: "", progress: 0, - status: "running", + status: "downloading", timestamp: new Date(), } as JobStatus, ]); diff --git a/package.json b/package.json index 64755bbe..fa01ee7b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "expo-linking": "~6.3.1", "expo-navigation-bar": "~3.0.7", "expo-network": "~6.0.1", + "expo-notifications": "~0.28.17", "expo-router": "~3.5.23", "expo-screen-orientation": "~7.0.5", "expo-sensors": "~13.0.9", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 0246cab5..06067fd9 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -11,22 +11,20 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { checkForExistingDownloads, completeHandler, - directories, download, setConfig, } from "@kesha-antonov/react-native-background-downloader"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { + focusManager, QueryClient, QueryClientProvider, useQuery, useQueryClient, } from "@tanstack/react-query"; import axios from "axios"; -import * as BackgroundFetch from "expo-background-fetch"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; -import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; import React, { createContext, @@ -34,37 +32,14 @@ import React, { useContext, useEffect, useMemo, - useRef, useState, } from "react"; +import { AppState, AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { apiAtom } from "./JellyfinProvider"; -export const BACKGROUND_FETCH_TASK = "background-fetch"; - -TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { - const now = Date.now(); - - console.log( - `Got background fetch call at date: ${new Date(now).toISOString()}` - ); - - // Be sure to return the successful result type! - return BackgroundFetch.BackgroundFetchResult.NewData; -}); - -const STORAGE_KEY = "runningProcesses"; - -export async function registerBackgroundFetchAsync() { - return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { - minimumInterval: 60 * 15, // 1 minutes - stopOnTerminate: false, // android only, - startOnBoot: true, // android only - }); -} - -export async function unregisterBackgroundFetchAsync() { - return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); +function onAppStateChange(status: AppStateStatus) { + focusManager.setFocused(status === "active"); } const DownloadContext = createContext { + const subscription = AppState.addEventListener("change", onAppStateChange); + + return () => subscription.remove(); + }, []); + useQuery({ queryKey: ["jobs"], queryFn: async () => { @@ -109,6 +93,29 @@ function useDownloadProvider() { url, }); + jobs.forEach((job) => { + const process = processes.find((p) => p.id === job.id); + if ( + process && + process.status === "optimizing" && + job.status === "completed" + ) { + if (settings.autoDownload) { + startDownload(job); + } else { + toast.info(`${job.item.Name} is ready to be downloaded`, { + action: { + label: "Go to downloads", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); + } + } + }); + // Local downloading processes that are still valid const downloadingProcesses = processes .filter((p) => p.status === "downloading") @@ -123,66 +130,30 @@ function useDownloadProvider() { return jobs; }, staleTime: 0, - refetchInterval: 1000, + refetchInterval: 2000, enabled: settings?.downloadMethod === "optimized", }); useEffect(() => { const checkIfShouldStartDownload = async () => { + if (processes.length === 0) return; const tasks = await checkForExistingDownloads(); - // for (let i = 0; i < processes.length; i++) { - // const job = processes[i]; + // 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; + // 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; + // } // } // } }; checkIfShouldStartDownload(); - }, []); - - /******************** - * Background task - *******************/ - // useEffect(() => { - // // Check background task status - // checkStatusAsync(); - // }, []); - - // const [isRegistered, setIsRegistered] = useState(false); - // const [status, setStatus] = - // useState(null); - - // const checkStatusAsync = async () => { - // const status = await BackgroundFetch.getStatusAsync(); - // const isRegistered = await TaskManager.isTaskRegisteredAsync( - // BACKGROUND_FETCH_TASK - // ); - // setStatus(status); - // setIsRegistered(isRegistered); - - // console.log("Background fetch status:", status); - // console.log("Background fetch task registered:", isRegistered); - // }; - - // const toggleFetchTask = async () => { - // if (isRegistered) { - // console.log("Unregistering background fetch task"); - // await unregisterBackgroundFetchAsync(); - // } else { - // console.log("Registering background fetch task"); - // await registerBackgroundFetchAsync(); - // } - - // checkStatusAsync(); - // }; - /********************** - ********************** - *********************/ + }, [settings, processes]); const removeProcess = useCallback( async (id: string) => { @@ -228,6 +199,16 @@ function useDownloadProvider() { }, }); + toast.info(`Download started for ${process.item.Name}`, { + action: { + label: "Go to downloads", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); + const baseDirectory = FileSystem.documentDirectory; download({ @@ -236,7 +217,6 @@ function useDownloadProvider() { destination: `${baseDirectory}/${process.item.Id}.mp4`, }) .begin(() => { - toast.info(`Download started for ${process.item.Name}`); setProcesses((prev) => prev.map((p) => p.id === process.id @@ -268,7 +248,16 @@ function useDownloadProvider() { }) .done(async () => { await saveDownloadedItemInfo(process.item); - toast.success(`Download completed for ${process.item.Name}`); + toast.success(`Download completed for ${process.item.Name}`, { + duration: 3000, + action: { + label: "Go to downloads", + onClick: () => { + router.push("/downloads"); + toast.dismiss(); + }, + }, + }); setTimeout(() => { completeHandler(process.id); removeProcess(process.id); diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f1a13370..bcd0fb07 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -40,17 +40,6 @@ const JellyfinContext = createContext( undefined ); -const getOrSetDeviceId = async () => { - let deviceId = await AsyncStorage.getItem("deviceId"); - - if (!deviceId) { - deviceId = uuid.v4() as string; - await AsyncStorage.setItem("deviceId", deviceId); - } - - return deviceId; -}; - export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { @@ -269,10 +258,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ ], queryFn: async () => { try { - const token = await AsyncStorage.getItem("token"); - const serverUrl = await AsyncStorage.getItem("serverUrl"); + const token = await getTokenFromStoraage(); + const serverUrl = await getServerUrlFromStorage(); const user = JSON.parse( - (await AsyncStorage.getItem("user")) as string + (await getUserFromStorage()) as string ) as UserDto; if (serverUrl && token && user.Id && jellyfin) { @@ -331,3 +320,26 @@ function useProtectedRoute(user: UserDto | null, loading = false) { } }, [user, segments, loading]); } + +export async function getTokenFromStoraage() { + return await AsyncStorage.getItem("token"); +} + +export async function getUserFromStorage() { + return await AsyncStorage.getItem("user"); +} + +export async function getServerUrlFromStorage() { + return await AsyncStorage.getItem("serverUrl"); +} + +export async function getOrSetDeviceId() { + let deviceId = await AsyncStorage.getItem("deviceId"); + + if (!deviceId) { + deviceId = uuid.v4() as string; + await AsyncStorage.setItem("deviceId", deviceId); + } + + return deviceId; +} diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index bf03502d..cabfc8cf 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -73,7 +73,8 @@ export type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; - downloadMethod?: "optimized" | "remux"; + downloadMethod: "optimized" | "remux"; + autoDownload: boolean; }; /** * @@ -110,6 +111,7 @@ const loadSettings = async (): Promise => { rewindSkipTime: 10, optimizedVersionsServerUrl: null, downloadMethod: "remux", + autoDownload: false, }; try { diff --git a/utils/background-tasks.ts b/utils/background-tasks.ts new file mode 100644 index 00000000..1d7f0a70 --- /dev/null +++ b/utils/background-tasks.ts @@ -0,0 +1,23 @@ +import * as BackgroundFetch from "expo-background-fetch"; + +export const BACKGROUND_FETCH_TASK = "background-fetch"; + +export async function registerBackgroundFetchAsync() { + try { + BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { + minimumInterval: 60 * 1, // 1 minutes + stopOnTerminate: false, // android only, + startOnBoot: false, // android only + }); + } catch (error) { + console.log("Error registering background fetch task", error); + } +} + +export async function unregisterBackgroundFetchAsync() { + try { + BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK); + } catch (error) { + console.log("Error unregistering background fetch task", error); + } +} diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 3db17037..f52ad799 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -53,6 +53,11 @@ export async function getAllJobsByDeviceId({ }, }); if (statusResponse.status !== 200) { + console.error( + statusResponse.status, + statusResponse.data, + statusResponse.statusText + ); throw new Error("Failed to fetch job status"); }