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");
}