diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index 6aed50a9..281ed07e 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -1,39 +1,103 @@
import { Text } from "@/components/common/Text";
+import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
import { useRouter } from "expo-router";
import { useEffect } from "react";
-import { TouchableOpacity, View } from "react-native";
+import { ActivityIndicator, TouchableOpacity, View } from "react-native";
-export default function index() {
- const { downloadedFiles, getDownloadedItem, activeDownloads } =
- useNativeDownloads();
+const PROGRESSBAR_HEIGHT = 10;
+
+const formatETA = (seconds: number): string => {
+ const pad = (n: number) => n.toString().padStart(2, "0");
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
+};
+
+const getETA = (download: DownloadInfo): string | null => {
+ console.log("getETA", download);
+ if (
+ !download.startTime ||
+ !download.bytesDownloaded ||
+ !download.bytesTotal
+ ) {
+ console.log(download);
+ return null;
+ }
+
+ const elapsed = Date.now() / 100 - download.startTime; // seconds
+
+ console.log("Elapsed (s):", Number(download.startTime), Date.now(), elapsed);
+
+ if (elapsed <= 0 || download.bytesDownloaded <= 0) return null;
+
+ const speed = download.bytesDownloaded / elapsed; // bytes per second
+ const remainingBytes = download.bytesTotal - download.bytesDownloaded;
+
+ if (speed <= 0) return null;
+
+ const secondsLeft = remainingBytes / speed;
+
+ return formatETA(secondsLeft);
+};
+
+export default function Index() {
+ const { downloadedFiles, activeDownloads } = useNativeDownloads();
const router = useRouter();
const goToVideo = (item: any) => {
- console.log(item);
// @ts-expect-error
router.push("/player/direct-player?offline=true&itemId=" + item.id);
};
- useEffect(() => {
- console.log(activeDownloads);
- }, [activeDownloads]);
-
return (
- {activeDownloads.map((i) => (
-
- {i.id}
-
- ))}
+ {activeDownloads.map((i) => {
+ const progress =
+ i.bytesTotal && i.bytesDownloaded
+ ? i.bytesDownloaded / i.bytesTotal
+ : 0;
+ const eta = getETA(i);
+ return (
+
+ {i.metadata?.item?.Name}
+ {i.state === "PENDING" ? (
+
+ ) : i.state === "DOWNLOADING" ? (
+
+ {i.bytesDownloaded} / {i.bytesTotal}
+
+ ) : null}
+
+
+
+ {eta ? ETA: {eta} : Calculating...}
+
+ );
+ })}
{downloadedFiles.map((i) => (
goToVideo(i)}
className="bg-neutral-800 p-4 rounded-lg"
>
- {i.metadata.item.Name}
- {i.metadata.item.Type}
+ {i.metadata.item?.Name}
+ {i.metadata.item?.Type}
))}
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index a794d145..01e11220 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
+import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
@@ -11,7 +12,7 @@ import {
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { Ionicons } from "@expo/vector-icons";
+import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
@@ -26,7 +27,11 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
-import { useRouter } from "expo-router";
+import {
+ useNavigation,
+ useNavigationContainerRef,
+ useRouter,
+} from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -34,6 +39,7 @@ import {
ActivityIndicator,
RefreshControl,
ScrollView,
+ TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -83,6 +89,23 @@ export default function index() {
setLoadingRetry(false);
}, []);
+ const navigation = useNavigation();
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerLeft: () => (
+ {
+ router.push("/(auth)/downloads");
+ }}
+ className="p-2"
+ >
+
+
+ ),
+ });
+ }, []);
+
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 5828c88f..f286adf6 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -21,9 +21,6 @@ import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
-const DownloadSettings = lazy(
- () => import("@/components/settings/DownloadSettings")
-);
export default function settings() {
const router = useRouter();
@@ -72,8 +69,6 @@ export default function settings() {
- {!Platform.isTV && }
-
diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
deleted file mode 100644
index 988651f0..00000000
--- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getOrSetDeviceId } from "@/utils/device";
-import { getStatistics } from "@/utils/optimize-server";
-import { useMutation } from "@tanstack/react-query";
-import { useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import { ActivityIndicator, TouchableOpacity, View } from "react-native";
-import { toast } from "sonner-native";
-import { useTranslation } from "react-i18next";
-import DisabledSetting from "@/components/settings/DisabledSetting";
-
-export default function page() {
- const navigation = useNavigation();
-
- const { t } = useTranslation();
-
- const [api] = useAtom(apiAtom);
- const [settings, updateSettings, pluginSettings] = useSettings();
-
- const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
- useState(settings?.optimizedVersionsServerUrl || "");
-
- const saveMutation = useMutation({
- mutationFn: async (newVal: string) => {
- if (newVal.length === 0 || !newVal.startsWith("http")) {
- toast.error(t("home.settings.toasts.invalid_url"));
- return;
- }
-
- const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
-
- updateSettings({
- optimizedVersionsServerUrl: updatedUrl,
- });
-
- return await getStatistics({
- url: settings?.optimizedVersionsServerUrl,
- authHeader: api?.accessToken,
- deviceId: getOrSetDeviceId(),
- });
- },
- onSuccess: (data) => {
- if (data) {
- toast.success(t("home.settings.toasts.connected"));
- } else {
- toast.error(t("home.settings.toasts.could_not_connect"));
- }
- },
- onError: () => {
- toast.error(t("home.settings.toasts.could_not_connect"));
- },
- });
-
- const onSave = (newVal: string) => {
- saveMutation.mutate(newVal);
- };
-
- useEffect(() => {
- if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
- navigation.setOptions({
- title: t("home.settings.downloads.optimized_server"),
- headerRight: () =>
- saveMutation.isPending ? (
-
- ) : (
- onSave(optimizedVersionsServerUrl)}>
- {t("home.settings.downloads.save_button")}
-
- ),
- });
- }
- }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
-
- return (
-
-
-
- );
-}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 1cd6ac89..7f12fb8d 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,53 +1,38 @@
import "@/augmentations";
-import { Platform } from "react-native";
-import { Text } from "@/components/common/Text";
import i18n from "@/i18n";
-import { DownloadProvider } from "@/providers/DownloadProvider";
-import {
- getOrSetDeviceId,
- getTokenFromStorage,
- JellyfinProvider,
-} from "@/providers/JellyfinProvider";
-import { JobQueueProvider } from "@/providers/JobQueueProvider";
+import * as ScreenOrientation from "@/packages/expo-screen-orientation";
+import { JellyfinProvider } from "@/providers/JellyfinProvider";
+import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
-import { Settings, useSettings } from "@/utils/atoms/settings";
-import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
+import { useSettings } from "@/utils/atoms/settings";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
-import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-const BackGroundDownloader = !Platform.isTV
- ? require("@kesha-antonov/react-native-background-downloader")
- : null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-const BackgroundFetch = !Platform.isTV
- ? require("expo-background-fetch")
- : null;
-import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
-const Notifications = !Platform.isTV ? require("expo-notifications") : null;
-import { router, Stack } from "expo-router";
-import * as ScreenOrientation from "@/packages/expo-screen-orientation";
-const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
+import { router, Stack } from "expo-router";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
-import { Appearance, AppState } from "react-native";
+import { Appearance, AppState, Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
-import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider";
+const BackGroundDownloader = !Platform.isTV
+ ? require("@kesha-antonov/react-native-background-downloader")
+ : null;
+const Notifications = !Platform.isTV ? require("expo-notifications") : null;
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -94,102 +79,6 @@ function useNotificationObserver() {
}, []);
}
-if (!Platform.isTV) {
- TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
- console.log("TaskManager ~ trigger");
-
- const now = Date.now();
-
- const settingsData = storage.getString("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 = getTokenFromStorage();
- const deviceId = getOrSetDeviceId();
- const baseDirectory = FileSystem.documentDirectory;
-
- if (!token || !deviceId || !baseDirectory)
- return BackgroundFetch.BackgroundFetchResult.NoData;
-
- 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;
- const tasks = await BackGroundDownloader.checkForExistingDownloads();
-
- if (tasks.find((task: { id: string }) => task.id === job.id)) {
- console.log("TaskManager ~ Download already in progress: ", job.id);
- continue;
- }
-
- BackGroundDownloader.download({
- id: job.id,
- url: downloadUrl,
- destination: `${baseDirectory}${job.item.Id}.mp4`,
- headers: {
- Authorization: token,
- },
- })
- .begin(() => {
- console.log("TaskManager ~ Download started: ", job.id);
- })
- .done(() => {
- console.log("TaskManager ~ Download completed: ", job.id);
- saveDownloadedItemInfo(job.item);
- BackGroundDownloader.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: any) => {
- console.log("TaskManager ~ Download error: ", job.id, error);
- BackGroundDownloader.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 = storage.getString(
@@ -316,64 +205,62 @@ function Layout() {
return (
-
-
-
-
-
-
-
-
-
-
- null,
- }}
- />
- null,
- }}
- />
-
-
-
-
+
+
+
+
+
+
+
+
+ null,
}}
- closeButton
/>
-
-
-
-
-
-
-
-
+ null,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx
deleted file mode 100644
index 35910f04..00000000
--- a/components/settings/OptimizedServerForm.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { TextInput, View, Linking } from "react-native";
-import { Text } from "../common/Text";
-import { useTranslation } from "react-i18next";
-
-interface Props {
- value: string;
- onChangeValue: (value: string) => void;
-}
-
-export const OptimizedServerForm: React.FC = ({
- value,
- onChangeValue,
-}) => {
- const handleOpenLink = () => {
- Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
- };
-
- const { t } = useTranslation();
-
- return (
-
-
-
- {t("home.settings.downloads.url")}
- onChangeValue(text)}
- />
-
-
-
- {t("home.settings.downloads.optimized_version_hint")}{" "}
-
- {t("home.settings.downloads.read_more_about_optimized_server")}
-
-
-
- );
-};
diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift
index 8a47945c..a23ba6c3 100644
--- a/modules/hls-downloader/ios/HlsDownloaderModule.swift
+++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift
@@ -5,7 +5,7 @@ public class HlsDownloaderModule: Module {
var activeDownloads:
[Int: (
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
- startTime: Date
+ startTime: Double
)] = [:]
public func definition() -> ModuleDefinition {
@@ -15,8 +15,9 @@ public class HlsDownloaderModule: Module {
Function("downloadHLSAsset") {
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
+ let startTime = Date().timeIntervalSince1970
print(
- "Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata))"
+ "Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata)), StartTime: \(startTime)"
)
guard let assetURL = URL(string: url) else {
@@ -27,6 +28,7 @@ public class HlsDownloaderModule: Module {
"error": "Invalid URL",
"state": "FAILED",
"metadata": metadata ?? [:],
+ "startTime": startTime,
])
return
}
@@ -36,6 +38,7 @@ public class HlsDownloaderModule: Module {
withIdentifier: "com.example.hlsdownload")
let delegate = HLSDownloadDelegate(module: self)
delegate.providedId = providedId
+ delegate.startTime = startTime
let downloadSession = AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: delegate,
@@ -47,7 +50,7 @@ public class HlsDownloaderModule: Module {
asset: asset,
assetTitle: providedId,
assetArtworkData: nil,
- options: nil
+ options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: startTime]
)
else {
self.sendEvent(
@@ -57,12 +60,13 @@ public class HlsDownloaderModule: Module {
"error": "Failed to create download task",
"state": "FAILED",
"metadata": metadata ?? [:],
+ "startTime": startTime,
])
return
}
delegate.taskIdentifier = task.taskIdentifier
- self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], Date())
+ self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
self.sendEvent(
"onProgress",
[
@@ -70,7 +74,7 @@ public class HlsDownloaderModule: Module {
"progress": 0.0,
"state": "PENDING",
"metadata": metadata ?? [:],
- "startTime": Date().timeIntervalSince1970,
+ "startTime": startTime,
])
task.resume()
@@ -95,7 +99,7 @@ public class HlsDownloaderModule: Module {
"bytesTotal": total,
"state": self.mappedState(for: task),
"metadata": metadata,
- "startTime": startTime.timeIntervalSince1970,
+ "startTime": startTime,
])
}
return downloads
@@ -142,6 +146,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
var providedId: String = ""
var downloadedSeconds: Double = 0
var totalSeconds: Double = 0
+ var startTime: Double = 0
init(module: HlsDownloaderModule) {
self.module = module
@@ -157,9 +162,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
}
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
- let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier]
- let metadata = downloadInfo?.metadata ?? [:]
- let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
+ let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
+ let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
self.downloadedSeconds = downloaded
self.totalSeconds = total
@@ -183,9 +187,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL
) {
- let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier]
- let metadata = downloadInfo?.metadata ?? [:]
- let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
+ let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
+ let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
let folderName = providedId
do {
guard let module = module else { return }
@@ -224,10 +227,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
- let downloadInfo = module?.activeDownloads[task.taskIdentifier]
- let metadata = downloadInfo?.metadata ?? [:]
- let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
-
+ let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
+ let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0
module?.sendEvent(
"onError",
[
diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts
index a4d3ab40..96369888 100644
--- a/modules/hls-downloader/src/HlsDownloader.types.ts
+++ b/modules/hls-downloader/src/HlsDownloader.types.ts
@@ -20,13 +20,14 @@ export interface DownloadMetadata {
export type BaseEventPayload = {
id: string;
state: DownloadState;
- metadata?: DownloadMetadata;
+ metadata: DownloadMetadata;
};
export type OnProgressEventPayload = BaseEventPayload & {
progress: number;
bytesDownloaded: number;
bytesTotal: number;
+ startTime?: number;
};
export type OnErrorEventPayload = BaseEventPayload & {
@@ -55,5 +56,5 @@ export interface DownloadInfo {
bytesTotal?: number;
location?: string;
error?: string;
- metadata?: DownloadMetadata;
+ metadata: DownloadMetadata;
}
diff --git a/providers/JobQueueProvider.tsx b/providers/JobQueueProvider.tsx
deleted file mode 100644
index 00358e48..00000000
--- a/providers/JobQueueProvider.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React, { createContext } from "react";
-import { useJobProcessor } from "@/utils/atoms/queue";
-
-const JobQueueContext = createContext(null);
-
-export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
- children,
-}) => {
- useJobProcessor();
-
- return (
- {children}
- );
-};
diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx
index 88abc8fa..53c64fc1 100644
--- a/providers/NativeDownloadProvider.tsx
+++ b/providers/NativeDownloadProvider.tsx
@@ -11,20 +11,19 @@ import {
DownloadMetadata,
} from "@/modules/hls-downloader/src/HlsDownloader.types";
import { getItemImage } from "@/utils/getItemImage";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools";
+import download from "@/utils/profiles/download";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import RNBackgroundDownloader from "@kesha-antonov/react-native-background-downloader";
+import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useAtomValue } from "jotai";
import { createContext, useContext, useEffect, useState } from "react";
import { toast } from "sonner-native";
import { apiAtom, userAtom } from "./JellyfinProvider";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import download from "@/utils/profiles/download";
-import { useQuery } from "@tanstack/react-query";
type DownloadOptionsData = {
selectedAudioStream: number;
@@ -45,7 +44,6 @@ type DownloadContextType = {
maxBitrate,
}: DownloadOptionsData
) => Promise;
- cancelDownload: (id: string) => void;
getDownloadedItem: (id: string) => Promise;
activeDownloads: DownloadInfo[];
downloadedFiles: DownloadedFileInfo[];
@@ -83,7 +81,7 @@ export type DownloadedFileInfo = {
metadata: DownloadMetadata;
};
-const listDownloadedFiles = async (): Promise => {
+const getDownloadedFiles = async (): Promise => {
const downloadsDir = FileSystem.documentDirectory + "downloads/";
const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
if (!dirInfo.exists) return [];
@@ -113,7 +111,7 @@ const listDownloadedFiles = async (): Promise => {
return downloaded;
};
-const getDownloadedItem = async (id: string) => {
+const getDownloadedFile = async (id: string) => {
const downloadsDir = FileSystem.documentDirectory + "downloads/";
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json");
if (!fileInfo.exists) return null;
@@ -136,7 +134,7 @@ export const NativeDownloadProvider: React.FC<{
const { data: downloadedFiles } = useQuery({
queryKey: ["downloadedFiles"],
- queryFn: listDownloadedFiles,
+ queryFn: getDownloadedFiles,
});
useEffect(() => {
@@ -153,35 +151,21 @@ export const NativeDownloadProvider: React.FC<{
state: download.state,
bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal,
+ metadata: download.metadata,
+ startTime: download?.startTime,
},
}),
{}
);
- // Check regular downloads
- const regularDownloads =
- await RNBackgroundDownloader.checkForExistingDownloads();
- const regularDownloadStates = regularDownloads.reduce(
- (acc, download) => ({
- ...acc,
- [download.id]: {
- id: download.id,
- progress: download.bytesDownloaded / download.bytesTotal,
- state: download.state,
- bytesDownloaded: download.bytesDownloaded,
- bytesTotal: download.bytesTotal,
- },
- }),
- {}
- );
-
- setDownloads({ ...hlsDownloadStates, ...regularDownloadStates });
+ setDownloads({ ...hlsDownloadStates });
};
initializeDownloads();
- // Set up HLS download listeners
const progressListener = addProgressListener((download) => {
+ if (!download.metadata) throw new Error("No metadata found in download");
+
console.log("[HLS] Download progress:", download);
setDownloads((prev) => ({
...prev,
@@ -191,12 +175,14 @@ export const NativeDownloadProvider: React.FC<{
state: download.state,
bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal,
+ metadata: download.metadata,
+ startTime: download?.startTime,
},
}));
});
const completeListener = addCompleteListener(async (payload) => {
- if (!payload?.id) throw new Error("No id found in payload");
+ if (!payload.id) throw new Error("No id found in payload");
try {
rewriteM3U8Files(payload.location);
@@ -235,7 +221,6 @@ export const NativeDownloadProvider: React.FC<{
useEffect(() => {
// Go through all the files in the folder downloads, check for the file id.json and id-done.json, if the id.json exists but id-done.json does not exist, then the download is still in done but not parsed. Parse it.
const checkForUnparsedDownloads = async () => {
- let found = false;
const downloadsFolder = await FileSystem.getInfoAsync(
FileSystem.documentDirectory + "downloads"
);
@@ -263,7 +248,6 @@ export const NativeDownloadProvider: React.FC<{
loading: "Finishing up download...",
success: () => "Download complete ✅",
});
- found = true;
}
}
}
@@ -300,83 +284,17 @@ export const NativeDownloadProvider: React.FC<{
});
if (!res) throw new Error("Failed to get stream URL");
-
const { mediaSource } = res;
-
if (!mediaSource) throw new Error("Failed to get media source");
await saveImage(item.Id, itemImage?.uri);
- if (url.includes("master.m3u8")) {
- // HLS download
- downloadHLSAsset(jobId, url, {
- item,
- mediaSource,
- });
- } else {
- // Regular download
- try {
- const task = RNBackgroundDownloader.download({
- id: jobId,
- url: url,
- destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`,
- });
+ if (!url.includes("master.m3u8"))
+ throw new Error("Only HLS downloads are supported");
- task.begin(({ expectedBytes }) => {
- setDownloads((prev) => ({
- ...prev,
- [jobId]: {
- id: jobId,
- progress: 0,
- state: "DOWNLOADING",
- },
- }));
- });
-
- task.progress(({ bytesDownloaded, bytesTotal }) => {
- console.log(
- "[Normal] Download progress:",
- bytesDownloaded,
- bytesTotal
- );
- setDownloads((prev) => ({
- ...prev,
- [jobId]: {
- id: jobId,
- progress: bytesDownloaded / bytesTotal,
- state: "DOWNLOADING",
- },
- }));
- });
-
- task.done(() => {
- setDownloads((prev) => {
- const newDownloads = { ...prev };
- delete newDownloads[jobId];
- return newDownloads;
- });
- });
-
- task.error(({ error }) => {
- console.error("Download error:", error);
- setDownloads((prev) => {
- const newDownloads = { ...prev };
- delete newDownloads[jobId];
- return newDownloads;
- });
- });
- } catch (error) {
- console.error("Error starting download:", error);
- }
- }
- };
-
- const cancelDownload = (id: string) => {
- // Implement cancel logic here
- setDownloads((prev) => {
- const newDownloads = { ...prev };
- delete newDownloads[id];
- return newDownloads;
+ downloadHLSAsset(jobId, url, {
+ item,
+ mediaSource,
});
};
@@ -385,9 +303,8 @@ export const NativeDownloadProvider: React.FC<{
value={{
downloads,
startDownload,
- cancelDownload,
- downloadedFiles,
- getDownloadedItem: getDownloadedItem,
+ downloadedFiles: downloadedFiles ?? [],
+ getDownloadedItem: getDownloadedFile,
activeDownloads: Object.values(downloads),
}}
>
diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts
deleted file mode 100644
index 70f85de9..00000000
--- a/utils/atoms/queue.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { atom, useAtom } from "jotai";
-import { useEffect } from "react";
-import {JobStatus} from "@/utils/optimize-server";
-import {processesAtom} from "@/providers/DownloadProvider";
-import {useSettings} from "@/utils/atoms/settings";
-
-export interface Job {
- id: string;
- item: BaseItemDto;
- execute: () => void | Promise;
-}
-
-export const runningAtom = atom(false);
-
-export const queueAtom = atom([]);
-
-export const queueActions = {
- enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
- const updatedQueue = [...queue, ...job];
- console.info("Enqueueing job", job, updatedQueue);
- setQueue(updatedQueue);
- },
- processJob: async (
- queue: Job[],
- setQueue: (update: Job[]) => void,
- setProcessing: (processing: boolean) => void
- ) => {
- const [job, ...rest] = queue;
-
- console.info("Processing job", job);
-
- setProcessing(true);
-
- // Allow job to execute so that it gets added as a processes first BEFORE updating new queue
- try {
- await job.execute();
- } finally {
- setQueue(rest);
- }
-
- console.info("Job done", job);
-
- setProcessing(false);
- },
- clear: (
- setQueue: (update: Job[]) => void,
- setProcessing: (processing: boolean) => void
- ) => {
- setQueue([]);
- setProcessing(false);
- },
-};
-
-export const useJobProcessor = () => {
- const [queue, setQueue] = useAtom(queueAtom);
- const [running, setRunning] = useAtom(runningAtom);
- const [processes] = useAtom(processesAtom);
- const [settings] = useSettings();
-
- useEffect(() => {
- if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
- console.info("Processing queue", queue);
- queueActions.processJob(queue, setQueue, setRunning);
- }
- }, [processes, queue, running, setQueue, setRunning]);
-};