diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx
index 30b67bbf..20429bd4 100644
--- a/app/(auth)/(tabs)/(home)/downloads.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads.tsx
@@ -1,30 +1,28 @@
import { Text } from "@/components/common/Text";
+import { ActiveDownload } from "@/components/downloads/ActiveDownload";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
-import { Loader } from "@/components/Loader";
-import { getAllDownloadedItems } from "@/hooks/useDownloadM3U8Files";
-import { runningProcesses } from "@/utils/atoms/downloads";
+import { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { router } from "expo-router";
-import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
- const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
-
- const { data: downloadedFiles, isLoading } = useQuery({
- queryKey: ["downloaded_files", process?.item.Id],
- queryFn: getAllDownloadedItems,
- staleTime: 0,
- });
+ const {
+ clearProcess,
+ process,
+ readProcess,
+ startBackgroundDownload,
+ updateProcess,
+ downloadedFiles,
+ } = useDownload();
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
@@ -96,14 +94,6 @@ const downloads: React.FC = () => {
const insets = useSafeAreaInsets();
- if (isLoading) {
- return (
-
-
-
- );
- }
-
return (
{
{
- setQueue((prev) => prev.filter((i) => i.id !== q.id));
+ clearProcess();
+ setQueue(async (prev) => {
+ if (!prev) return [];
+ return [...(await prev).filter((i) => i.id !== q.id)];
+ });
}}
>
@@ -144,49 +138,7 @@ const downloads: React.FC = () => {
)}
-
- Active download
- {process?.item ? (
-
- router.push(`/(auth)/items/page?id=${process.item.Id}`)
- }
- className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
- >
-
- {process.item.Name}
-
- {process.item.Type}
-
-
-
- {process.progress.toFixed(0)}%
-
-
-
- {
- FFmpegKit.cancel();
- setProcess(null);
- }}
- >
-
-
-
-
- ) : (
- No active downloads
- )}
-
+
{movies.length > 0 && (
@@ -212,15 +164,3 @@ const downloads: React.FC = () => {
};
export default downloads;
-
-/*
- * Format a number (Date.getTime) to a human readable string ex. 2m 34s
- * @param {number} num - The number to format
- *
- * @returns {string} - The formatted string
- */
-const formatNumber = (num: number) => {
- const minutes = Math.floor(num / 60000);
- const seconds = ((num % 60000) / 1000).toFixed(0);
- return `${minutes}m ${seconds}s`;
-};
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 75d74306..834ba569 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -2,22 +2,20 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
-import { useFiles } from "@/hooks/useFiles";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
-import { Ionicons } from "@expo/vector-icons";
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 { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
export default function settings() {
const { logout } = useJellyfin();
- const { deleteAllFiles } = useFiles();
+ const { deleteAllFiles } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index b8772e68..4dc8d799 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -1,5 +1,6 @@
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
+import { useCheckRunningJobs } from "@/hooks/useCheckRunningJobs";
import { BlurView } from "expo-blur";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
diff --git a/app/_layout.tsx b/app/_layout.tsx
index b942db93..b638b9c8 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -14,12 +14,15 @@ import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { Provider as JotaiProvider, useAtom } from "jotai";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import * as Linking from "expo-linking";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Toaster } from "sonner-native";
+import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
+import { AppState } from "react-native";
+import { DownloadProvider } from "@/providers/DownloadProvider";
SplashScreen.preventAutoHideAsync();
@@ -74,6 +77,25 @@ function Layout() {
);
}, [settings]);
+ const appState = useRef(AppState.currentState);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener("change", (nextAppState) => {
+ if (
+ appState.current.match(/inactive|background/) &&
+ nextAppState === "active"
+ ) {
+ checkForExistingDownloads();
+ }
+ });
+
+ checkForExistingDownloads();
+
+ return () => {
+ subscription.remove();
+ };
+ }, []);
+
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
@@ -101,57 +123,59 @@ function Layout() {
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 652b8131..e34ccb94 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -1,6 +1,6 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
@@ -17,8 +17,6 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -31,8 +29,6 @@ import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
-import { useDownloadM3U8Files } from "@/hooks/useDownloadM3U8Files";
-import * as FileSystem from "expo-file-system";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -41,12 +37,10 @@ interface DownloadProps extends ViewProps {
export const DownloadItem: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
- // const { startRemuxing } = useRemuxHlsToMp4(item);
-
- const { startBackgroundDownload } = useDownloadM3U8Files(item);
+ const { process, startBackgroundDownload } = useDownload();
+ const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] =
useState(null);
@@ -157,7 +151,14 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
if (!url) throw new Error("No url");
- return await startBackgroundDownload(url);
+ if (
+ settings?.optimizedVersionsServerUrl &&
+ settings.optimizedVersionsServerUrl.length > 0
+ ) {
+ return await startBackgroundDownload(url, item);
+ } else {
+ return await startRemuxing(url);
+ }
}, [
api,
item,
@@ -172,42 +173,13 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
/**
* Check if item is downloaded
*/
- const { data: downloaded, isFetching } = useQuery({
- queryKey: ["downloaded", item.Id],
- queryFn: async () => {
- if (!item.Id) {
- return false;
- }
+ const { downloadedFiles } = useDownload();
- try {
- // Check if the item exists in AsyncStorage
- const downloadedItems = await AsyncStorage.getItem("downloadedItems");
- const items: BaseItemDto[] = downloadedItems
- ? JSON.parse(downloadedItems)
- : [];
- const isInStorage = items.some(
- (storedItem) => storedItem.Id === item.Id
- );
+ const isDownloaded = useMemo(() => {
+ if (!downloadedFiles) return false;
- if (!isInStorage) {
- return false;
- }
-
- // Check if the directory and m3u8 file exist
- const directoryPath = `${FileSystem.documentDirectory}${item.Id}`;
- const m3u8FilePath = `${directoryPath}/local.m3u8`;
-
- const dirInfo = await FileSystem.getInfoAsync(directoryPath);
- const fileInfo = await FileSystem.getInfoAsync(m3u8FilePath);
-
- return dirInfo.exists && fileInfo.exists;
- } catch (error) {
- console.error("Error checking download status:", error);
- return false;
- }
- },
- enabled: !!item.Id,
- });
+ return downloadedFiles.some((file) => file.Id === item.Id);
+ }, [downloadedFiles, item.Id]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -225,9 +197,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
- {isFetching ? (
-
- ) : process && process?.item.Id === item.Id ? (
+ {process && process?.item.Id === item.Id ? (
{
router.push("/downloads");
@@ -255,7 +225,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
>
- ) : downloaded ? (
+ ) : isDownloaded ? (
{
router.push("/downloads");
@@ -315,9 +285,13 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
className="mt-auto"
onPress={() => {
if (userCanDownload === true) {
+ if (!item.Id) {
+ Alert.alert("Error", "Item ID is undefined.");
+ return;
+ }
closeModal();
queueActions.enqueue(queue, setQueue, {
- id: item.Id!,
+ id: item.Id,
execute: async () => {
await initiateDownload();
},
diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx
new file mode 100644
index 00000000..f08e00f0
--- /dev/null
+++ b/components/downloads/ActiveDownload.tsx
@@ -0,0 +1,83 @@
+import { TouchableOpacity, View, ViewProps } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useRouter } from "expo-router";
+import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
+import { Ionicons } from "@expo/vector-icons";
+import { useEffect, useState } from "react";
+import { useDownload } from "@/providers/DownloadProvider";
+import { useMutation } from "@tanstack/react-query";
+import axios from "axios";
+import { toast } from "sonner-native";
+import { useSettings } from "@/utils/atoms/settings";
+
+interface Props extends ViewProps {}
+
+export const ActiveDownload: React.FC = ({ ...props }) => {
+ const router = useRouter();
+ const { clearProcess, process } = useDownload();
+ const [settings] = useSettings();
+
+ const cancelJobMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (!process) return;
+
+ await axios.delete(settings?.optimizedVersionsServerUrl + id);
+ const tasks = await checkForExistingDownloads();
+ for (const task of tasks) task.stop();
+ clearProcess();
+ },
+ onSuccess: () => {
+ toast.success("Download cancelled");
+ },
+ onError: () => {
+ toast.error("Failed to cancel download");
+ },
+ });
+
+ if (!process)
+ return (
+
+ Active download
+ No active downloads
+
+ );
+
+ return (
+
+ Active download
+ router.push(`/(auth)/items/page?id=${process.item.Id}`)}
+ className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
+ >
+
+
+
+ {process.item.Name}
+ {process.item.Id}
+ {process.item.Type}
+
+ {process.progress.toFixed(0)}%
+
+
+ {process.state}
+
+
+ cancelJobMutation.mutate(process.id)}
+ >
+
+
+
+
+
+ );
+};
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 8b344279..a303caed 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -5,8 +5,8 @@ import { TouchableOpacity } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
-import { useFiles } from "@/hooks/useFiles";
import { Text } from "../common/Text";
+import { useDownload } from "@/providers/DownloadProvider";
interface EpisodeCardProps {
item: BaseItemDto;
@@ -18,7 +18,7 @@ interface EpisodeCardProps {
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC = ({ item }) => {
- const { deleteFile } = useFiles();
+ const { deleteFile } = useDownload();
const { openFile } = useFileOpener();
const handleOpenFile = useCallback(() => {
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 16808bbf..69eeae4f 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -4,11 +4,11 @@ import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import * as ContextMenu from "zeego/context-menu";
-import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownload } from "@/providers/DownloadProvider";
interface MovieCardProps {
item: BaseItemDto;
@@ -20,7 +20,7 @@ interface MovieCardProps {
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC = ({ item }) => {
- const { deleteFile } = useFiles();
+ const { deleteFile } = useDownload();
const { openFile } = useFileOpener();
const handleOpenFile = useCallback(() => {
diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx
index a4b17367..6eef150c 100644
--- a/components/settings/SettingToggles.tsx
+++ b/components/settings/SettingToggles.tsx
@@ -33,6 +33,8 @@ export const SettingToggles: React.FC = ({ ...props }) => {
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState("");
+ const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
+ useState("");
const queryClient = useQueryClient();
@@ -308,9 +310,9 @@ export const SettingToggles: React.FC = ({ ...props }) => {
Device profile
@@ -362,6 +364,7 @@ export const SettingToggles: React.FC = ({ ...props }) => {
+
= ({ ...props }) => {
{settings.searchEngine === "Marlin" && (
- <>
-
-
- setMarlinUrl(text)}
- />
-
-
+
+
+ setMarlinUrl(text)}
+ />
+
+
+ {settings.marlinServerUrl && (
- {settings.marlinServerUrl}
+ Current: {settings.marlinServerUrl}
- >
+ )}
)}
+
+
+ Optimized versions server
+
+ Set the URL for the optimized versions server for downloads.
+
+
+
+
+ setOptimizedVersionsServerUrl(text)}
+ />
+
+
+
+
+ {settings.optimizedVersionsServerUrl && (
+
+ Current: {settings.optimizedVersionsServerUrl}
+
+ )}
+
diff --git a/hooks/useDownloadM3U8Files.ts b/hooks/useDownloadM3U8Files.ts
deleted file mode 100644
index f96995a0..00000000
--- a/hooks/useDownloadM3U8Files.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { runningProcesses } from "@/utils/atoms/downloads";
-import { writeToLog } from "@/utils/log";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { download } from "@kesha-antonov/react-native-background-downloader";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQueryClient } from "@tanstack/react-query";
-import * as FileSystem from "expo-file-system";
-import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native";
-import { useAtom } from "jotai";
-import { useCallback, useEffect, useState } from "react";
-import { toast } from "sonner-native";
-
-export const useDownloadM3U8Files = (item: BaseItemDto) => {
- const [_, setProgress] = useAtom(runningProcesses);
- const queryClient = useQueryClient();
- const [api] = useAtom(apiAtom);
-
- const [totalSegments, setTotalSegments] = useState(0);
- const [downloadedSegments, setDownloadedSegments] = useState([]);
-
- if (!item.Id || !item.Name) {
- throw new Error("Item must have an Id and Name");
- }
-
- const startBackgroundDownload = useCallback(
- async (url: string) => {
- if (!api) {
- throw new Error("API is not defined");
- }
-
- toast.success("Download started", { invert: true });
- writeToLog("INFO", `Starting download for item ${item.Name}`);
- setProgress({
- startTime: new Date(),
- item,
- progress: 0,
- });
-
- try {
- const directoryPath = `${FileSystem.documentDirectory}${item.Id}`;
- await FileSystem.makeDirectoryAsync(directoryPath, {
- intermediates: true,
- });
-
- const m3u8Content = await FileSystem.downloadAsync(
- url,
- `${directoryPath}/original.m3u8`
- );
-
- if (m3u8Content.status !== 200) {
- throw new Error("Failed to download m3u8 file");
- }
-
- const m3u8Text = await FileSystem.readAsStringAsync(m3u8Content.uri);
- const segments = await fetchSegmentInfo(
- m3u8Text,
- api.basePath,
- item.Id!
- );
-
- setTotalSegments(segments.length);
-
- for (let i = 0; i < segments.length; i++) {
- const segment = segments[i];
- const segmentUrl = `${api.basePath}/videos/${item.Id}/${segment.path}`;
- const destination = `${directoryPath}/${i}.ts`;
-
- download({
- id: `${item.Id}_segment_${i}`,
- url: segmentUrl,
- destination: destination,
- }).done(() => {
- setDownloadedSegments((prev) => [...prev, i]);
- });
- }
-
- await createLocalM3U8File(segments, directoryPath);
- await saveDownloadedItemInfo(item);
-
- writeToLog("INFO", `Download completed for item: ${item.Name}`);
- await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
- await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
- } catch (error) {
- console.error("Failed to download:", error);
- writeToLog("ERROR", `Download failed for item: ${item.Name}`);
- setProgress(null);
- throw error;
- }
- },
- [item, queryClient, api]
- );
-
- useEffect(() => {
- if (totalSegments === 0) return;
-
- console.log("[0]", downloadedSegments.length, totalSegments);
-
- const progress = (downloadedSegments.length / totalSegments) * 100;
- setProgress((prev) => ({
- ...prev!,
- progress,
- }));
- if (progress > 99) {
- setProgress(null);
- }
- }, [downloadedSegments, totalSegments]);
-
- return { startBackgroundDownload };
-};
-
-interface Segment {
- duration: number;
- path: string;
-}
-
-async function fetchSegmentInfo(
- masterM3U8Content: string,
- baseUrl: string,
- itemId: string
-): Promise {
- const lines = masterM3U8Content.split("\n");
- const mainPlaylistLine = lines.find((line) => line.startsWith("main.m3u8"));
-
- if (!mainPlaylistLine) {
- throw new Error("Main playlist URL not found in the master M3U8");
- }
-
- const url = `${baseUrl}/videos/${itemId}/${mainPlaylistLine}`;
- const response = await fetch(url);
- const mainPlaylistContent = await response.text();
-
- const segments: Segment[] = [];
- const mainPlaylistLines = mainPlaylistContent.split("\n");
-
- for (let i = 0; i < mainPlaylistLines.length; i++) {
- if (mainPlaylistLines[i].startsWith("#EXTINF:")) {
- const durationMatch = mainPlaylistLines[i].match(
- /#EXTINF:(\d+(?:\.\d+)?)/
- );
- const duration = durationMatch ? parseFloat(durationMatch[1]) : 0;
- const path = mainPlaylistLines[i + 1];
-
- if (path) {
- segments.push({ duration, path });
- }
-
- i++;
- }
- }
-
- return segments;
-}
-
-async function createLocalM3U8File(segments: Segment[], directoryPath: string) {
- let localM3U8Content = "#EXTM3U\n#EXT-X-VERSION:3\n";
- localM3U8Content += `#EXT-X-TARGETDURATION:${Math.ceil(
- Math.max(...segments.map((s) => s.duration))
- )}\n`;
- localM3U8Content += "#EXT-X-MEDIA-SEQUENCE:0\n";
-
- segments.forEach((segment, index) => {
- console.log(segment.path.split(".")[1]);
- localM3U8Content += `#EXTINF:${segment.duration.toFixed(3)},\n`;
- localM3U8Content += `${directoryPath}/${index}.ts\n`;
- });
-
- localM3U8Content += "#EXT-X-ENDLIST\n";
-
- const localM3U8Path = `${directoryPath}/local.m3u8`;
- await FileSystem.writeAsStringAsync(localM3U8Path, localM3U8Content);
-}
-
-export 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);
- }
-}
-
-export async function deleteDownloadedItem(itemId: string) {
- try {
- const downloadedItems = await AsyncStorage.getItem("downloadedItems");
- let items: BaseItemDto[] = downloadedItems
- ? JSON.parse(downloadedItems)
- : [];
- items = items.filter((item) => item.Id !== itemId);
- await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
-
- const directoryPath = `${FileSystem.documentDirectory}${itemId}`;
- await FileSystem.deleteAsync(directoryPath, { idempotent: true });
- } catch (error) {
- console.error("Failed to delete downloaded item:", error);
- }
-}
-
-export async function getAllDownloadedItems(): Promise {
- try {
- const downloadedItems = await AsyncStorage.getItem("downloadedItems");
- if (downloadedItems) {
- return JSON.parse(downloadedItems) as BaseItemDto[];
- } else {
- return [];
- }
- } catch (error) {
- console.error("Failed to retrieve downloaded items:", error);
- return [];
- }
-}
diff --git a/hooks/useDownloadMedia.ts b/hooks/useDownloadMedia.ts
deleted file mode 100644
index 61351e2d..00000000
--- a/hooks/useDownloadMedia.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { useCallback, useRef, useState } from "react";
-import { useAtom } from "jotai";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import * as FileSystem from "expo-file-system";
-import { Api } from "@jellyfin/sdk";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { runningProcesses } from "@/utils/atoms/downloads";
-
-/**
- * Custom hook for downloading media using the Jellyfin API.
- *
- * @param api - The Jellyfin API instance
- * @param userId - The user ID
- * @returns An object with download-related functions and state
- */
-export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
- const [isDownloading, setIsDownloading] = useState(false);
- const [error, setError] = useState(null);
- const [_, setProgress] = useAtom(runningProcesses);
- const downloadResumableRef = useRef(
- null,
- );
-
- const downloadMedia = useCallback(
- async (item: BaseItemDto | null): Promise => {
- if (!item?.Id || !api || !userId) {
- setError("Invalid item or API");
- return false;
- }
-
- setIsDownloading(true);
- setError(null);
- setProgress({ item, progress: 0 });
-
- try {
- const filename = item.Id;
- const fileUri = `${FileSystem.documentDirectory}${filename}`;
- const url = `${api.basePath}/Items/${item.Id}/File`;
-
- downloadResumableRef.current = FileSystem.createDownloadResumable(
- url,
- fileUri,
- {
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- },
- (downloadProgress) => {
- const currentProgress =
- downloadProgress.totalBytesWritten /
- downloadProgress.totalBytesExpectedToWrite;
- setProgress({ item, progress: currentProgress * 100 });
- },
- );
-
- const res = await downloadResumableRef.current.downloadAsync();
-
- if (!res?.uri) {
- throw new Error("Download failed: No URI returned");
- }
-
- await updateDownloadedFiles(item);
-
- setIsDownloading(false);
- setProgress(null);
- return true;
- } catch (error) {
- console.error("Error downloading media:", error);
- setError("Failed to download media");
- setIsDownloading(false);
- setProgress(null);
- return false;
- }
- },
- [api, userId, setProgress],
- );
-
- const cancelDownload = useCallback(async (): Promise => {
- if (!downloadResumableRef.current) return;
-
- try {
- await downloadResumableRef.current.pauseAsync();
- setIsDownloading(false);
- setError("Download cancelled");
- setProgress(null);
- downloadResumableRef.current = null;
- } catch (error) {
- console.error("Error cancelling download:", error);
- setError("Failed to cancel download");
- }
- }, [setProgress]);
-
- return { downloadMedia, isDownloading, error, cancelDownload };
-};
-
-/**
- * Updates the list of downloaded files in AsyncStorage.
- *
- * @param item - The item to add to the downloaded files list
- */
-async function updateDownloadedFiles(item: BaseItemDto): Promise {
- try {
- const currentFiles: BaseItemDto[] = JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) ?? "[]",
- );
- const updatedFiles = [
- ...currentFiles.filter((file) => file.Id !== item.Id),
- item,
- ];
- await AsyncStorage.setItem(
- "downloaded_files",
- JSON.stringify(updatedFiles),
- );
- } catch (error) {
- console.error("Error updating downloaded files:", error);
- }
-}
diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts
index a2e38943..09aecd26 100644
--- a/hooks/useDownloadedFileOpener.ts
+++ b/hooks/useDownloadedFileOpener.ts
@@ -1,11 +1,10 @@
// hooks/useFileOpener.ts
-import { useCallback } from "react";
-import { useRouter } from "expo-router";
-import * as FileSystem from "expo-file-system";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { usePlayback } from "@/providers/PlaybackProvider";
-import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import * as FileSystem from "expo-file-system";
+import { useRouter } from "expo-router";
+import { useCallback } from "react";
export const useFileOpener = () => {
const router = useRouter();
@@ -13,77 +12,17 @@ export const useFileOpener = () => {
const openFile = useCallback(
async (item: BaseItemDto) => {
- const m3u8File = `${FileSystem.documentDirectory}${item.Id}/local.m3u8`;
- const outputFile = `${FileSystem.documentDirectory}${item.Id}/output.mp4`;
+ const directory = FileSystem.documentDirectory;
+ const url = `${directory}/${item.Id}.mp4`;
- console.log("Checking for output file:", outputFile);
-
- const outputFileInfo = await FileSystem.getInfoAsync(outputFile);
-
- if (outputFileInfo.exists) {
- console.log("Output MP4 file already exists. Playing directly.");
- startDownloadedFilePlayback({
- item,
- url: outputFile,
- });
- router.push("/play");
- return;
- }
-
- console.log("Output MP4 file does not exist. Converting from M3U8.");
-
- const m3u8FileInfo = await FileSystem.getInfoAsync(m3u8File);
-
- if (!m3u8FileInfo.exists) {
- console.warn("m3u8 file does not exist:", m3u8File);
- return;
- }
-
- const conversionSuccess = await convertM3U8ToMP4(m3u8File, outputFile);
-
- if (conversionSuccess) {
- startDownloadedFilePlayback({
- item,
- url: outputFile,
- });
- router.push("/play");
- } else {
- console.error("Failed to convert M3U8 to MP4");
- // Handle conversion failure (e.g., show an error message to the user)
- }
+ startDownloadedFilePlayback({
+ item,
+ url,
+ });
+ router.push("/play");
},
[startDownloadedFilePlayback]
);
return { openFile };
};
-
-export async function convertM3U8ToMP4(
- inputM3U8: string,
- outputMP4: string
-): Promise {
- console.log("Converting M3U8 to MP4");
- console.log("Input M3U8:", inputM3U8);
- console.log("Output MP4:", outputMP4);
-
- try {
- const command = `-i ${inputM3U8} -c copy ${outputMP4}`;
- console.log("Executing FFmpeg command:", command);
-
- const session = await FFmpegKit.execute(command);
- const returnCode = await session.getReturnCode();
-
- if (ReturnCode.isSuccess(returnCode)) {
- console.log("Conversion completed successfully");
- return true;
- } else {
- console.error("Conversion failed. Return code:", returnCode);
- const output = await session.getOutput();
- console.error("FFmpeg output:", output);
- return false;
- }
- } catch (error) {
- console.error("Error during conversion:", error);
- return false;
- }
-}
diff --git a/hooks/useFiles.ts b/hooks/useFiles.ts
deleted file mode 100644
index 018ffcd6..00000000
--- a/hooks/useFiles.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQueryClient } from "@tanstack/react-query";
-import * as FileSystem from "expo-file-system";
-
-/**
- * Custom hook for managing downloaded files.
- * @returns An object with functions to delete individual files and all files.
- */
-export const useFiles = () => {
- const queryClient = useQueryClient();
-
- /**
- * Deletes all downloaded files and clears the download record.
- */
- const deleteAllFiles = async (): Promise => {
- try {
- // Get all downloaded items
- const downloadedItems = await AsyncStorage.getItem("downloadedItems");
- if (downloadedItems) {
- const items = JSON.parse(downloadedItems);
-
- // Delete each item's folder
- for (const item of items) {
- const folderPath = `${FileSystem.documentDirectory}${item.Id}`;
- await FileSystem.deleteAsync(folderPath, { idempotent: true });
- }
- }
-
- // Clear the downloadedItems in AsyncStorage
- await AsyncStorage.removeItem("downloadedItems");
-
- // Invalidate the query to refresh the UI
- queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
-
- console.log(
- "Successfully deleted all downloaded files and cleared AsyncStorage"
- );
- } catch (error) {
- console.error("Failed to delete all files:", error);
- }
- };
-
- /**
- * Deletes a specific file and updates the download record.
- * @param id - The ID of the file to delete.
- */
- const deleteFile = async (id: string): Promise => {
- if (!id) {
- console.error("Invalid file ID");
- return;
- }
-
- try {
- // Delete the entire folder
- const folderPath = `${FileSystem.documentDirectory}${id}`;
- await FileSystem.deleteAsync(folderPath, { idempotent: true });
-
- // Remove the item from AsyncStorage
- const downloadedItems = await AsyncStorage.getItem("downloadedItems");
- if (downloadedItems) {
- let items = JSON.parse(downloadedItems);
- items = items.filter((item: any) => item.Id !== id);
- await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
- }
-
- // Invalidate the query to refresh the UI
- queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
-
- console.log(
- `Successfully deleted folder and AsyncStorage entry for ID ${id}`
- );
- } catch (error) {
- console.error(
- `Failed to delete folder and AsyncStorage entry for ID ${id}:`,
- error
- );
- }
- };
-
- return { deleteFile, deleteAllFiles };
-};
-
-/**
- * Retrieves the list of downloaded files from AsyncStorage.
- * @returns An array of BaseItemDto objects representing downloaded files.
- */
-async function getDownloadedFiles(): Promise {
- try {
- const filesJson = await AsyncStorage.getItem("downloaded_files");
- return filesJson ? JSON.parse(filesJson) : [];
- } catch (error) {
- console.error("Failed to retrieve downloaded files:", error);
- return [];
- }
-}
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index be78aed1..0a41bb14 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -4,10 +4,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner-native";
+import { useDownload } from "@/providers/DownloadProvider";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -17,8 +17,9 @@ import { toast } from "sonner-native";
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
- const [_, setProgress] = useAtom(runningProcesses);
const queryClient = useQueryClient();
+ const { process, updateProcess, clearProcess, saveDownloadedItemInfo } =
+ useDownload();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
@@ -29,9 +30,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const startRemuxing = useCallback(
async (url: string) => {
- toast.success("Download started", {
- invert: true,
- });
+ toast.success("Download started");
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
@@ -41,7 +40,12 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
);
try {
- setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
+ updateProcess({
+ id: item.Id!,
+ item,
+ progress: 0,
+ state: "downloading",
+ });
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
@@ -56,11 +60,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
- setProgress((prev) =>
- prev?.item.Id === item.Id!
- ? { ...prev, progress: percentage, speed }
- : prev
- );
+ updateProcess((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ progress: percentage,
+ };
+ });
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
@@ -70,19 +76,25 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
- await updateDownloadedFiles(item);
+ await saveDownloadedItemInfo(item);
+ toast.success("Download completed");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
+ await queryClient.invalidateQueries({
+ queryKey: ["downloadedItems"],
+ });
resolve();
} else if (returnCode.isValueError()) {
+ toast.success("Download failed");
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
+ toast.success("Download canceled");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
@@ -90,63 +102,33 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
resolve();
}
- setProgress(null);
+ clearProcess();
} catch (error) {
reject(error);
}
});
});
-
- await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
- await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
- setProgress(null);
+ clearProcess();
throw error; // Re-throw the error to propagate it to the caller
}
},
- [output, item, setProgress]
+ [output, item, clearProcess]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
- setProgress(null);
+ clearProcess();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
);
- }, [item.Name, setProgress]);
+ }, [item.Name, clearProcess]);
return { startRemuxing, cancelRemuxing };
};
-
-/**
- * Updates the list of downloaded files in AsyncStorage.
- *
- * @param item - The item to add to the downloaded files list
- */
-async function updateDownloadedFiles(item: BaseItemDto): Promise {
- try {
- const currentFiles: BaseItemDto[] = JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) || "[]"
- );
- const updatedFiles = [
- ...currentFiles.filter((i) => i.Id !== item.Id),
- item,
- ];
- await AsyncStorage.setItem(
- "downloaded_files",
- JSON.stringify(updatedFiles)
- );
- } catch (error) {
- console.error("Error updating downloaded files:", error);
- writeToLog(
- "ERROR",
- `Failed to update downloaded files for item: ${item.Name}`
- );
- }
-}
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
new file mode 100644
index 00000000..9718b941
--- /dev/null
+++ b/providers/DownloadProvider.tsx
@@ -0,0 +1,442 @@
+import { useSettings } from "@/utils/atoms/settings";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import {
+ completeHandler,
+ directories,
+ download,
+} from "@kesha-antonov/react-native-background-downloader";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import axios from "axios";
+import * as FileSystem from "expo-file-system";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { toast } from "sonner-native";
+
+export type ProcessItem = {
+ id: string;
+ item: Partial;
+ progress: number;
+ size?: number;
+ state: "optimizing" | "downloading" | "done" | "error" | "canceled";
+};
+
+const STORAGE_KEY = "runningProcess";
+
+const DownloadContext = createContext | null>(null);
+
+function useDownloadProvider() {
+ const queryClient = useQueryClient();
+ const [process, setProcess] = useState(null);
+ const [settings] = useSettings();
+
+ const {
+ data: downloadedFiles,
+ isLoading,
+ refetch,
+ } = useQuery({
+ queryKey: ["downloadedItems"],
+ queryFn: getAllDownloadedItems,
+ staleTime: 0,
+ });
+
+ useEffect(() => {
+ // Load initial process state from AsyncStorage
+ const loadInitialProcess = async () => {
+ const storedProcess = await readProcess();
+ setProcess(storedProcess);
+ };
+ loadInitialProcess();
+ }, []);
+
+ const clearProcess = useCallback(async () => {
+ await AsyncStorage.removeItem(STORAGE_KEY);
+ setProcess(null);
+ }, []);
+
+ const updateProcess = useCallback(
+ async (
+ itemOrUpdater:
+ | ProcessItem
+ | null
+ | ((prevState: ProcessItem | null) => ProcessItem | null)
+ ) => {
+ setProcess((prevProcess) => {
+ let newState: ProcessItem | null;
+ if (typeof itemOrUpdater === "function") {
+ newState = itemOrUpdater(prevProcess);
+ } else {
+ newState = itemOrUpdater;
+ }
+
+ if (newState === null) {
+ AsyncStorage.removeItem(STORAGE_KEY);
+ } else {
+ AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
+ }
+
+ return newState;
+ });
+ },
+ []
+ );
+
+ const readProcess = useCallback(async (): Promise => {
+ const item = await AsyncStorage.getItem(STORAGE_KEY);
+ return item ? JSON.parse(item) : null;
+ }, []);
+
+ const startDownload = useCallback(() => {
+ if (!process?.item.Id) throw new Error("No item id");
+
+ download({
+ id: process.id,
+ url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
+ destination: `${directories.documents}/${process?.item.Id}.mp4`,
+ })
+ .begin(() => {
+ updateProcess((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ state: "downloading",
+ progress: 50,
+ } as ProcessItem;
+ });
+ })
+ .progress((data) => {
+ const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
+ updateProcess((prev) => {
+ if (!prev) {
+ console.warn("no prev");
+ return null;
+ }
+ return {
+ ...prev,
+ state: "downloading",
+ progress: percent,
+ };
+ });
+ })
+ .done(async () => {
+ clearProcess();
+ await saveDownloadedItemInfo(process.item);
+ await queryClient.invalidateQueries({
+ queryKey: ["downloadedItems"],
+ });
+ await refetch();
+ completeHandler(process.id);
+ toast.success(`Download completed for ${process.item.Name}`);
+ })
+ .error((error) => {
+ updateProcess((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ state: "error",
+ };
+ });
+ toast.error(`Download failed for ${process.item.Name}: ${error}`);
+ });
+ }, [queryClient, process?.id, settings?.optimizedVersionsServerUrl]);
+
+ useEffect(() => {
+ let intervalId: NodeJS.Timeout | null = null;
+
+ const checkJobStatusPeriodically = async () => {
+ // console.log("checkJobStatusPeriodically ~");
+ if (
+ !process?.id ||
+ !process.state ||
+ !process.item.Id ||
+ !settings?.optimizedVersionsServerUrl
+ )
+ return;
+ if (process.state === "optimizing") {
+ const job = await checkJobStatus(
+ process.id,
+ settings?.optimizedVersionsServerUrl
+ );
+
+ if (!job) {
+ clearProcess();
+ return;
+ }
+
+ // console.log("Job ~", job);
+
+ // Update the local process state with the state from the server.
+ let newState: ProcessItem["state"] = "optimizing";
+ if (job.status === "completed") {
+ if (intervalId) clearInterval(intervalId);
+ startDownload();
+ return;
+ } else if (job.status === "failed") {
+ newState = "error";
+ } else if (job.status === "cancelled") {
+ newState = "canceled";
+ }
+
+ updateProcess((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ state: newState,
+ progress: job.progress,
+ };
+ });
+ } else if (process.state === "downloading") {
+ // Don't do anything, it's downloading locally
+ return;
+ } else if (["done", "canceled", "error"].includes(process.state)) {
+ console.log("Job is done or failed or canceled");
+ clearProcess();
+ if (intervalId) clearInterval(intervalId);
+ }
+ };
+
+ console.log("Starting interval check");
+
+ // Start checking immediately
+ checkJobStatusPeriodically();
+
+ // Then check every 2 seconds
+ intervalId = setInterval(checkJobStatusPeriodically, 2000);
+
+ // Clean up function
+ return () => {
+ if (intervalId) {
+ clearInterval(intervalId);
+ }
+ };
+ }, [process?.id, settings?.optimizedVersionsServerUrl]);
+
+ const startBackgroundDownload = useCallback(
+ async (url: string, item: BaseItemDto) => {
+ try {
+ const response = await axios.post(
+ settings?.optimizedVersionsServerUrl + "optimize-version",
+ { url },
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ if (response.status !== 201) {
+ throw new Error("Failed to start optimization job");
+ }
+
+ const { id } = response.data;
+
+ updateProcess({
+ id,
+ item: item,
+ progress: 0,
+ state: "optimizing",
+ });
+
+ toast.success(`Optimization job started for ${item.Name}`);
+ } catch (error) {
+ console.error("Error in startBackgroundDownload:", error);
+ toast.error(`Failed to start download for ${item.Name}`);
+ }
+ },
+ [settings?.optimizedVersionsServerUrl]
+ );
+
+ /**
+ * Deletes all downloaded files and clears the download record.
+ */
+ const deleteAllFiles = async (): Promise => {
+ try {
+ // Get the base directory
+ const baseDirectory = FileSystem.documentDirectory;
+
+ if (!baseDirectory) {
+ throw new Error("Base directory not found");
+ }
+
+ // Read the contents of the base directory
+ const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
+
+ // Delete each item in the directory
+ for (const item of dirContents) {
+ const itemPath = `${baseDirectory}${item}`;
+ const itemInfo = await FileSystem.getInfoAsync(itemPath);
+
+ if (itemInfo.exists) {
+ await FileSystem.deleteAsync(itemPath, { idempotent: true });
+ }
+ }
+ // Clear the downloadedItems in AsyncStorage
+ await AsyncStorage.removeItem("downloadedItems");
+ await AsyncStorage.removeItem("runningProcess");
+ clearProcess();
+
+ // Invalidate the query to refresh the UI
+ queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
+
+ console.log(
+ "Successfully deleted all files and folders in the directory and cleared AsyncStorage"
+ );
+ } catch (error) {
+ console.error("Failed to delete all files and folders:", error);
+ }
+ };
+
+ /**
+ * Deletes a specific file and updates the download record.
+ * @param id - The ID of the file to delete.
+ */
+ const deleteFile = async (id: string): Promise => {
+ if (!id) {
+ console.error("Invalid file ID");
+ return;
+ }
+
+ try {
+ // Get the directory path
+ const directory = FileSystem.documentDirectory;
+
+ if (!directory) {
+ console.error("Document directory not found");
+ return;
+ }
+ // Read the contents of the directory
+ const dirContents = await FileSystem.readDirectoryAsync(directory);
+
+ // Find and delete the file with the matching ID (without extension)
+ for (const item of dirContents) {
+ const itemNameWithoutExtension = item.split(".")[0];
+ if (itemNameWithoutExtension === id) {
+ const filePath = `${directory}${item}`;
+ await FileSystem.deleteAsync(filePath, { idempotent: true });
+ console.log(`Successfully deleted file: ${item}`);
+ break;
+ }
+ }
+
+ // Remove the item from AsyncStorage
+ const downloadedItems = await AsyncStorage.getItem("downloadedItems");
+ if (downloadedItems) {
+ let items = JSON.parse(downloadedItems);
+ items = items.filter((item: any) => item.Id !== id);
+ await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
+ }
+
+ // Invalidate the query to refresh the UI
+ queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
+
+ console.log(
+ `Successfully deleted file and AsyncStorage entry for ID ${id}`
+ );
+ } catch (error) {
+ console.error(
+ `Failed to delete file and AsyncStorage entry for ID ${id}:`,
+ error
+ );
+ }
+ };
+
+ /**
+ * Retrieves the list of downloaded files from AsyncStorage.
+ * @returns An array of BaseItemDto objects representing downloaded files.
+ */
+ async function getAllDownloadedItems(): Promise {
+ try {
+ const downloadedItems = await AsyncStorage.getItem("downloadedItems");
+ if (downloadedItems) {
+ return JSON.parse(downloadedItems) as BaseItemDto[];
+ } else {
+ return [];
+ }
+ } catch (error) {
+ console.error("Failed to retrieve downloaded items:", error);
+ return [];
+ }
+ }
+
+ 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);
+ }
+ }
+
+ return {
+ process,
+ updateProcess,
+ startBackgroundDownload,
+ clearProcess,
+ readProcess,
+ downloadedFiles,
+ deleteAllFiles,
+ deleteFile,
+ saveDownloadedItemInfo,
+ };
+}
+
+// Create the provider component
+export function DownloadProvider({ children }: { children: React.ReactNode }) {
+ const downloadProviderValue = useDownloadProvider();
+ const queryClient = new QueryClient();
+
+ return (
+
+ {children}
+
+ );
+}
+
+// Create a custom hook to use the download context
+export function useDownload() {
+ const context = useContext(DownloadContext);
+ if (context === null) {
+ throw new Error("useDownload must be used within a DownloadProvider");
+ }
+ return context;
+}
+
+const checkJobStatus = async (
+ id: string,
+ baseUrl: string
+): Promise<{
+ progress: number;
+ status: "running" | "completed" | "failed" | "cancelled";
+}> => {
+ const statusResponse = await axios.get(`${baseUrl}job-status/${id}`);
+
+ if (statusResponse.status !== 200) {
+ throw new Error("Failed to fetch job status");
+ }
+
+ const json = statusResponse.data;
+ return json;
+};
diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts
index 1fc47d18..e69de29b 100644
--- a/utils/atoms/downloads.ts
+++ b/utils/atoms/downloads.ts
@@ -1,10 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { atom } from "jotai";
-
-export type ProcessItem = {
- item: BaseItemDto;
- progress: number;
- startTime?: Date;
-};
-
-export const runningProcesses = atom(null);
diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts
index 09bccb37..de6a7336 100644
--- a/utils/atoms/queue.ts
+++ b/utils/atoms/queue.ts
@@ -1,5 +1,7 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import AsyncStorage from "@react-native-async-storage/async-storage";
import { atom, useAtom } from "jotai";
+import { atomWithStorage } from "jotai/utils";
import { useEffect } from "react";
export interface Job {
@@ -8,8 +10,31 @@ export interface Job {
execute: () => void | Promise;
}
-export const queueAtom = atom([]);
-export const isProcessingAtom = atom(false);
+export const runningAtom = atomWithStorage("queueRunning", false, {
+ getItem: async (key) => {
+ const value = await AsyncStorage.getItem(key);
+ return value ? JSON.parse(value) : false;
+ },
+ setItem: async (key, value) => {
+ await AsyncStorage.setItem(key, JSON.stringify(value));
+ },
+ removeItem: async (key) => {
+ await AsyncStorage.removeItem(key);
+ },
+});
+
+export const queueAtom = atomWithStorage("queueJobs", [], {
+ getItem: async (key) => {
+ const value = await AsyncStorage.getItem(key);
+ return value ? JSON.parse(value) : [];
+ },
+ setItem: async (key, value) => {
+ await AsyncStorage.setItem(key, JSON.stringify(value));
+ },
+ removeItem: async (key) => {
+ await AsyncStorage.removeItem(key);
+ },
+});
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
@@ -20,7 +45,7 @@ export const queueActions = {
processJob: async (
queue: Job[],
setQueue: (update: Job[]) => void,
- setProcessing: (processing: boolean) => void,
+ setProcessing: (processing: boolean) => void
) => {
const [job, ...rest] = queue;
setQueue(rest);
@@ -28,13 +53,17 @@ export const queueActions = {
console.info("Processing job", job);
setProcessing(true);
+
+ // Excute the function assiociated with the job.
await job.execute();
+
console.info("Job done", job);
+
setProcessing(false);
},
clear: (
setQueue: (update: Job[]) => void,
- setProcessing: (processing: boolean) => void,
+ setProcessing: (processing: boolean) => void
) => {
setQueue([]);
setProcessing(false);
@@ -43,12 +72,12 @@ export const queueActions = {
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
- const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
+ const [running, setRunning] = useAtom(runningAtom);
useEffect(() => {
- if (queue.length > 0 && !isProcessing) {
+ if (queue.length > 0 && !running) {
console.info("Processing queue", queue);
- queueActions.processJob(queue, setQueue, setProcessing);
+ queueActions.processJob(queue, setQueue, setRunning);
}
- }, [queue, isProcessing, setQueue, setProcessing]);
+ }, [queue, running, setQueue, setRunning]);
};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index d4d8356a..9618efa4 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -72,6 +72,7 @@ type Settings = {
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
+ optimizedVersionsServerUrl?: string | null;
};
/**
*