mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-23 01:04:43 +01:00
wip
This commit is contained in:
@@ -1,39 +1,103 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||||
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function index() {
|
const PROGRESSBAR_HEIGHT = 10;
|
||||||
const { downloadedFiles, getDownloadedItem, activeDownloads } =
|
|
||||||
useNativeDownloads();
|
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 router = useRouter();
|
||||||
|
|
||||||
const goToVideo = (item: any) => {
|
const goToVideo = (item: any) => {
|
||||||
console.log(item);
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.push("/player/direct-player?offline=true&itemId=" + item.id);
|
router.push("/player/direct-player?offline=true&itemId=" + item.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(activeDownloads);
|
|
||||||
}, [activeDownloads]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="p-4 space-y-2">
|
<View className="p-4 space-y-2">
|
||||||
{activeDownloads.map((i) => (
|
{activeDownloads.map((i) => {
|
||||||
<View>
|
const progress =
|
||||||
<Text>{i.id}</Text>
|
i.bytesTotal && i.bytesDownloaded
|
||||||
</View>
|
? i.bytesDownloaded / i.bytesTotal
|
||||||
))}
|
: 0;
|
||||||
|
const eta = getETA(i);
|
||||||
|
return (
|
||||||
|
<View key={i.id}>
|
||||||
|
<Text>{i.metadata?.item?.Name}</Text>
|
||||||
|
{i.state === "PENDING" ? (
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
) : i.state === "DOWNLOADING" ? (
|
||||||
|
<Text>
|
||||||
|
{i.bytesDownloaded} / {i.bytesTotal}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
className="bg-neutral-800"
|
||||||
|
style={{
|
||||||
|
height: PROGRESSBAR_HEIGHT,
|
||||||
|
borderRadius: 5,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginVertical: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="bg-purple-600"
|
||||||
|
style={{
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{eta ? <Text>ETA: {eta}</Text> : <Text>Calculating...</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{downloadedFiles.map((i) => (
|
{downloadedFiles.map((i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={i.id}
|
key={i.id}
|
||||||
onPress={() => goToVideo(i)}
|
onPress={() => goToVideo(i)}
|
||||||
className="bg-neutral-800 p-4 rounded-lg"
|
className="bg-neutral-800 p-4 rounded-lg"
|
||||||
>
|
>
|
||||||
<Text>{i.metadata.item.Name}</Text>
|
<Text>{i.metadata.item?.Name}</Text>
|
||||||
<Text>{i.metadata.item.Type}</Text>
|
<Text>{i.metadata.item?.Type}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
useSplashScreenVisible,
|
useSplashScreenVisible,
|
||||||
} from "@/providers/SplashScreenProvider";
|
} from "@/providers/SplashScreenProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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 { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -26,7 +27,11 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import {
|
||||||
|
useNavigation,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -34,6 +39,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -83,6 +89,23 @@ export default function index() {
|
|||||||
setLoadingRetry(false);
|
setLoadingRetry(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<Feather name="download" color={Colors.primary} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
if (state.isConnected == false || state.isInternetReachable === false)
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ import React, { lazy, useEffect } from "react";
|
|||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
const DownloadSettings = lazy(
|
|
||||||
() => import("@/components/settings/DownloadSettings")
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -72,8 +69,6 @@ export default function settings() {
|
|||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
{!Platform.isTV && <DownloadSettings />}
|
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|||||||
@@ -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<string>(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 ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
|
||||||
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
|
||||||
className="p-4"
|
|
||||||
>
|
|
||||||
<OptimizedServerForm
|
|
||||||
value={optimizedVersionsServerUrl}
|
|
||||||
onChangeValue={setOptimizedVersionsServerUrl}
|
|
||||||
/>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
241
app/_layout.tsx
241
app/_layout.tsx
@@ -1,53 +1,38 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import {
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
getOrSetDeviceId,
|
import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider";
|
||||||
getTokenFromStorage,
|
|
||||||
JellyfinProvider,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import {
|
import {
|
||||||
SplashScreenProvider,
|
SplashScreenProvider,
|
||||||
useSplashScreenLoading,
|
useSplashScreenLoading,
|
||||||
} from "@/providers/SplashScreenProvider";
|
} from "@/providers/SplashScreenProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
|
||||||
import { LogProvider, writeToLog } from "@/utils/log";
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
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 { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { useFonts } from "expo-font";
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
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 { getLocales } from "expo-localization";
|
||||||
|
import { router, Stack } from "expo-router";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { I18nextProvider, useTranslation } from "react-i18next";
|
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 { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
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) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
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<Settings> = 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 () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
const hasAskedBefore = storage.getString(
|
const hasAskedBefore = storage.getString(
|
||||||
@@ -316,64 +205,62 @@ function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JobQueueProvider>
|
<JellyfinProvider>
|
||||||
<JellyfinProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<LogProvider>
|
<WebSocketProvider>
|
||||||
<WebSocketProvider>
|
<NativeDownloadProvider>
|
||||||
<NativeDownloadProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<SystemBars style="light" hidden={false} />
|
||||||
<SystemBars style="light" hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack>
|
||||||
<Stack>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/(tabs)"
|
||||||
name="(auth)/(tabs)"
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="login"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="+not-found" />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
<Stack.Screen
|
||||||
</BottomSheetModalProvider>
|
name="(auth)/player"
|
||||||
</NativeDownloadProvider>
|
options={{
|
||||||
</WebSocketProvider>
|
headerShown: false,
|
||||||
</LogProvider>
|
title: "",
|
||||||
</PlaySettingsProvider>
|
header: () => null,
|
||||||
</JellyfinProvider>
|
}}
|
||||||
</JobQueueProvider>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="login"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</NativeDownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Props> = ({
|
|
||||||
value,
|
|
||||||
onChangeValue,
|
|
||||||
}) => {
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
|
|
||||||
};
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
|
||||||
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
|
|
||||||
<Text className="mr-4">{t("home.settings.downloads.url")}</Text>
|
|
||||||
<TextInput
|
|
||||||
className="text-white"
|
|
||||||
placeholder={t("home.settings.downloads.server_url_placeholder")}
|
|
||||||
value={value}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => onChangeValue(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
{t("home.settings.downloads.optimized_version_hint")}{" "}
|
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
|
||||||
{t("home.settings.downloads.read_more_about_optimized_server")}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
var activeDownloads:
|
var activeDownloads:
|
||||||
[Int: (
|
[Int: (
|
||||||
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
|
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
|
||||||
startTime: Date
|
startTime: Double
|
||||||
)] = [:]
|
)] = [:]
|
||||||
|
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
@@ -15,8 +15,9 @@ public class HlsDownloaderModule: Module {
|
|||||||
|
|
||||||
Function("downloadHLSAsset") {
|
Function("downloadHLSAsset") {
|
||||||
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
|
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
|
||||||
|
let startTime = Date().timeIntervalSince1970
|
||||||
print(
|
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 {
|
guard let assetURL = URL(string: url) else {
|
||||||
@@ -27,6 +28,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
"error": "Invalid URL",
|
"error": "Invalid URL",
|
||||||
"state": "FAILED",
|
"state": "FAILED",
|
||||||
"metadata": metadata ?? [:],
|
"metadata": metadata ?? [:],
|
||||||
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
withIdentifier: "com.example.hlsdownload")
|
withIdentifier: "com.example.hlsdownload")
|
||||||
let delegate = HLSDownloadDelegate(module: self)
|
let delegate = HLSDownloadDelegate(module: self)
|
||||||
delegate.providedId = providedId
|
delegate.providedId = providedId
|
||||||
|
delegate.startTime = startTime
|
||||||
let downloadSession = AVAssetDownloadURLSession(
|
let downloadSession = AVAssetDownloadURLSession(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
assetDownloadDelegate: delegate,
|
assetDownloadDelegate: delegate,
|
||||||
@@ -47,7 +50,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
asset: asset,
|
asset: asset,
|
||||||
assetTitle: providedId,
|
assetTitle: providedId,
|
||||||
assetArtworkData: nil,
|
assetArtworkData: nil,
|
||||||
options: nil
|
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: startTime]
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
self.sendEvent(
|
self.sendEvent(
|
||||||
@@ -57,12 +60,13 @@ public class HlsDownloaderModule: Module {
|
|||||||
"error": "Failed to create download task",
|
"error": "Failed to create download task",
|
||||||
"state": "FAILED",
|
"state": "FAILED",
|
||||||
"metadata": metadata ?? [:],
|
"metadata": metadata ?? [:],
|
||||||
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
delegate.taskIdentifier = task.taskIdentifier
|
delegate.taskIdentifier = task.taskIdentifier
|
||||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], Date())
|
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
|
||||||
self.sendEvent(
|
self.sendEvent(
|
||||||
"onProgress",
|
"onProgress",
|
||||||
[
|
[
|
||||||
@@ -70,7 +74,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
"progress": 0.0,
|
"progress": 0.0,
|
||||||
"state": "PENDING",
|
"state": "PENDING",
|
||||||
"metadata": metadata ?? [:],
|
"metadata": metadata ?? [:],
|
||||||
"startTime": Date().timeIntervalSince1970,
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
|
|
||||||
task.resume()
|
task.resume()
|
||||||
@@ -95,7 +99,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
"bytesTotal": total,
|
"bytesTotal": total,
|
||||||
"state": self.mappedState(for: task),
|
"state": self.mappedState(for: task),
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"startTime": startTime.timeIntervalSince1970,
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
return downloads
|
return downloads
|
||||||
@@ -142,6 +146,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
var providedId: String = ""
|
var providedId: String = ""
|
||||||
var downloadedSeconds: Double = 0
|
var downloadedSeconds: Double = 0
|
||||||
var totalSeconds: Double = 0
|
var totalSeconds: Double = 0
|
||||||
|
var startTime: Double = 0
|
||||||
|
|
||||||
init(module: HlsDownloaderModule) {
|
init(module: HlsDownloaderModule) {
|
||||||
self.module = module
|
self.module = module
|
||||||
@@ -157,9 +162,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
||||||
let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier]
|
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||||
let metadata = downloadInfo?.metadata ?? [:]
|
let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
|
||||||
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
|
||||||
|
|
||||||
self.downloadedSeconds = downloaded
|
self.downloadedSeconds = downloaded
|
||||||
self.totalSeconds = total
|
self.totalSeconds = total
|
||||||
@@ -183,9 +187,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
||||||
didFinishDownloadingTo location: URL
|
didFinishDownloadingTo location: URL
|
||||||
) {
|
) {
|
||||||
let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier]
|
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||||
let metadata = downloadInfo?.metadata ?? [:]
|
let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
|
||||||
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
|
||||||
let folderName = providedId
|
let folderName = providedId
|
||||||
do {
|
do {
|
||||||
guard let module = module else { return }
|
guard let module = module else { return }
|
||||||
@@ -224,10 +227,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
|
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
let downloadInfo = module?.activeDownloads[task.taskIdentifier]
|
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
||||||
let metadata = downloadInfo?.metadata ?? [:]
|
let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0
|
||||||
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
|
||||||
|
|
||||||
module?.sendEvent(
|
module?.sendEvent(
|
||||||
"onError",
|
"onError",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ export interface DownloadMetadata {
|
|||||||
export type BaseEventPayload = {
|
export type BaseEventPayload = {
|
||||||
id: string;
|
id: string;
|
||||||
state: DownloadState;
|
state: DownloadState;
|
||||||
metadata?: DownloadMetadata;
|
metadata: DownloadMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OnProgressEventPayload = BaseEventPayload & {
|
export type OnProgressEventPayload = BaseEventPayload & {
|
||||||
progress: number;
|
progress: number;
|
||||||
bytesDownloaded: number;
|
bytesDownloaded: number;
|
||||||
bytesTotal: number;
|
bytesTotal: number;
|
||||||
|
startTime?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OnErrorEventPayload = BaseEventPayload & {
|
export type OnErrorEventPayload = BaseEventPayload & {
|
||||||
@@ -55,5 +56,5 @@ export interface DownloadInfo {
|
|||||||
bytesTotal?: number;
|
bytesTotal?: number;
|
||||||
location?: string;
|
location?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
metadata?: DownloadMetadata;
|
metadata: DownloadMetadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -11,20 +11,19 @@ import {
|
|||||||
DownloadMetadata,
|
DownloadMetadata,
|
||||||
} from "@/modules/hls-downloader/src/HlsDownloader.types";
|
} from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools";
|
import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools";
|
||||||
|
import download from "@/utils/profiles/download";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 * as FileSystem from "expo-file-system";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
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 = {
|
type DownloadOptionsData = {
|
||||||
selectedAudioStream: number;
|
selectedAudioStream: number;
|
||||||
@@ -45,7 +44,6 @@ type DownloadContextType = {
|
|||||||
maxBitrate,
|
maxBitrate,
|
||||||
}: DownloadOptionsData
|
}: DownloadOptionsData
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
cancelDownload: (id: string) => void;
|
|
||||||
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
|
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
|
||||||
activeDownloads: DownloadInfo[];
|
activeDownloads: DownloadInfo[];
|
||||||
downloadedFiles: DownloadedFileInfo[];
|
downloadedFiles: DownloadedFileInfo[];
|
||||||
@@ -83,7 +81,7 @@ export type DownloadedFileInfo = {
|
|||||||
metadata: DownloadMetadata;
|
metadata: DownloadMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
const getDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
||||||
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
||||||
const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
|
const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
|
||||||
if (!dirInfo.exists) return [];
|
if (!dirInfo.exists) return [];
|
||||||
@@ -113,7 +111,7 @@ const listDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
|||||||
return downloaded;
|
return downloaded;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDownloadedItem = async (id: string) => {
|
const getDownloadedFile = async (id: string) => {
|
||||||
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
||||||
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json");
|
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json");
|
||||||
if (!fileInfo.exists) return null;
|
if (!fileInfo.exists) return null;
|
||||||
@@ -136,7 +134,7 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
|
|
||||||
const { data: downloadedFiles } = useQuery({
|
const { data: downloadedFiles } = useQuery({
|
||||||
queryKey: ["downloadedFiles"],
|
queryKey: ["downloadedFiles"],
|
||||||
queryFn: listDownloadedFiles,
|
queryFn: getDownloadedFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,35 +151,21 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
state: download.state,
|
state: download.state,
|
||||||
bytesDownloaded: download.bytesDownloaded,
|
bytesDownloaded: download.bytesDownloaded,
|
||||||
bytesTotal: download.bytesTotal,
|
bytesTotal: download.bytesTotal,
|
||||||
|
metadata: download.metadata,
|
||||||
|
startTime: download?.startTime,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check regular downloads
|
setDownloads({ ...hlsDownloadStates });
|
||||||
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 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeDownloads();
|
initializeDownloads();
|
||||||
|
|
||||||
// Set up HLS download listeners
|
|
||||||
const progressListener = addProgressListener((download) => {
|
const progressListener = addProgressListener((download) => {
|
||||||
|
if (!download.metadata) throw new Error("No metadata found in download");
|
||||||
|
|
||||||
console.log("[HLS] Download progress:", download);
|
console.log("[HLS] Download progress:", download);
|
||||||
setDownloads((prev) => ({
|
setDownloads((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -191,12 +175,14 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
state: download.state,
|
state: download.state,
|
||||||
bytesDownloaded: download.bytesDownloaded,
|
bytesDownloaded: download.bytesDownloaded,
|
||||||
bytesTotal: download.bytesTotal,
|
bytesTotal: download.bytesTotal,
|
||||||
|
metadata: download.metadata,
|
||||||
|
startTime: download?.startTime,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeListener = addCompleteListener(async (payload) => {
|
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 {
|
try {
|
||||||
rewriteM3U8Files(payload.location);
|
rewriteM3U8Files(payload.location);
|
||||||
@@ -235,7 +221,6 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
useEffect(() => {
|
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.
|
// 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 () => {
|
const checkForUnparsedDownloads = async () => {
|
||||||
let found = false;
|
|
||||||
const downloadsFolder = await FileSystem.getInfoAsync(
|
const downloadsFolder = await FileSystem.getInfoAsync(
|
||||||
FileSystem.documentDirectory + "downloads"
|
FileSystem.documentDirectory + "downloads"
|
||||||
);
|
);
|
||||||
@@ -263,7 +248,6 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
loading: "Finishing up download...",
|
loading: "Finishing up download...",
|
||||||
success: () => "Download complete ✅",
|
success: () => "Download complete ✅",
|
||||||
});
|
});
|
||||||
found = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,83 +284,17 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res) throw new Error("Failed to get stream URL");
|
if (!res) throw new Error("Failed to get stream URL");
|
||||||
|
|
||||||
const { mediaSource } = res;
|
const { mediaSource } = res;
|
||||||
|
|
||||||
if (!mediaSource) throw new Error("Failed to get media source");
|
if (!mediaSource) throw new Error("Failed to get media source");
|
||||||
|
|
||||||
await saveImage(item.Id, itemImage?.uri);
|
await saveImage(item.Id, itemImage?.uri);
|
||||||
|
|
||||||
if (url.includes("master.m3u8")) {
|
if (!url.includes("master.m3u8"))
|
||||||
// HLS download
|
throw new Error("Only HLS downloads are supported");
|
||||||
downloadHLSAsset(jobId, url, {
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Regular download
|
|
||||||
try {
|
|
||||||
const task = RNBackgroundDownloader.download({
|
|
||||||
id: jobId,
|
|
||||||
url: url,
|
|
||||||
destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`,
|
|
||||||
});
|
|
||||||
|
|
||||||
task.begin(({ expectedBytes }) => {
|
downloadHLSAsset(jobId, url, {
|
||||||
setDownloads((prev) => ({
|
item,
|
||||||
...prev,
|
mediaSource,
|
||||||
[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;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -385,9 +303,8 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
value={{
|
value={{
|
||||||
downloads,
|
downloads,
|
||||||
startDownload,
|
startDownload,
|
||||||
cancelDownload,
|
downloadedFiles: downloadedFiles ?? [],
|
||||||
downloadedFiles,
|
getDownloadedItem: getDownloadedFile,
|
||||||
getDownloadedItem: getDownloadedItem,
|
|
||||||
activeDownloads: Object.values(downloads),
|
activeDownloads: Object.values(downloads),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runningAtom = atom<boolean>(false);
|
|
||||||
|
|
||||||
export const queueAtom = atom<Job[]>([]);
|
|
||||||
|
|
||||||
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<JobStatus[]>(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]);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user