mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-25 15:20:34 +01:00
wip
This commit is contained in:
@@ -2,10 +2,15 @@ import { Text } from "@/components/common/Text";
|
|||||||
import ProgressCircle from "@/components/ProgressCircle";
|
import ProgressCircle from "@/components/ProgressCircle";
|
||||||
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||||
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
@@ -14,20 +19,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
const formatETA = (seconds: number): string => {
|
|
||||||
const hrs = Math.floor(seconds / 3600);
|
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (hrs > 0) parts.push(`${hrs}h`);
|
|
||||||
if (mins > 0) parts.push(`${mins}m`);
|
|
||||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getETA = (download: DownloadInfo): string | null => {
|
const getETA = (download: DownloadInfo): string | null => {
|
||||||
if (
|
if (
|
||||||
!download.startTime ||
|
!download.startTime ||
|
||||||
@@ -37,34 +28,57 @@ const getETA = (download: DownloadInfo): string | null => {
|
|||||||
console.log(download);
|
console.log(download);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const elapsed = Date.now() / 1000 - download.startTime;
|
||||||
const elapsed = Date.now() / 1000 - download.startTime; // seconds
|
|
||||||
|
|
||||||
if (elapsed <= 0 || download.secondsDownloaded <= 0) return null;
|
if (elapsed <= 0 || download.secondsDownloaded <= 0) return null;
|
||||||
|
const speed = download.secondsDownloaded / elapsed;
|
||||||
const speed = download.secondsDownloaded / elapsed; // downloaded seconds per second
|
|
||||||
const remainingBytes = download.secondsTotal - download.secondsDownloaded;
|
const remainingBytes = download.secondsTotal - download.secondsDownloaded;
|
||||||
|
|
||||||
if (speed <= 0) return null;
|
if (speed <= 0) return null;
|
||||||
|
|
||||||
const secondsLeft = remainingBytes / speed;
|
const secondsLeft = remainingBytes / speed;
|
||||||
|
return formatTimeString(secondsLeft, "s");
|
||||||
return formatETA(secondsLeft);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBytes = (i: number) => {
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
let l = 0;
|
|
||||||
let n = parseInt(i.toString(), 10) || 0;
|
|
||||||
while (n >= 1024 && ++l) {
|
|
||||||
n = n / 1024;
|
|
||||||
}
|
|
||||||
return n.toFixed(n < 10 && l > 0 ? 1 : 0) + " " + units[l];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { downloadedFiles, activeDownloads } = useNativeDownloads();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const {
|
||||||
|
downloadedFiles,
|
||||||
|
activeDownloads,
|
||||||
|
cancelDownload,
|
||||||
|
refetchDownloadedFiles,
|
||||||
|
} = useNativeDownloads();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleItemPress = (item: any) => {
|
||||||
|
showActionSheetWithOptions(
|
||||||
|
{
|
||||||
|
options: ["Play", "Delete", "Cancel"],
|
||||||
|
destructiveButtonIndex: 1,
|
||||||
|
cancelButtonIndex: 2,
|
||||||
|
},
|
||||||
|
async (selectedIndex) => {
|
||||||
|
if (selectedIndex === 0) {
|
||||||
|
goToVideo(item);
|
||||||
|
} else if (selectedIndex === 1) {
|
||||||
|
await deleteFile(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActiveItemPress = (id: string) => {
|
||||||
|
showActionSheetWithOptions(
|
||||||
|
{
|
||||||
|
options: ["Cancel Download", "Cancel"],
|
||||||
|
destructiveButtonIndex: 0,
|
||||||
|
cancelButtonIndex: 1,
|
||||||
|
},
|
||||||
|
async (selectedIndex) => {
|
||||||
|
if (selectedIndex === 0) {
|
||||||
|
await cancelDownload(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const goToVideo = (item: any) => {
|
const goToVideo = (item: any) => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -80,7 +94,15 @@ export default function Index() {
|
|||||||
[downloadedFiles]
|
[downloadedFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const base64Image = useCallback((id: string) => {
|
||||||
|
return storage.getString(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteFile = async (id: string) => {
|
||||||
|
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
||||||
|
await FileSystem.deleteAsync(downloadsDir + id + ".json");
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -89,129 +111,243 @@ export default function Index() {
|
|||||||
refreshing={
|
refreshing={
|
||||||
queryClient.isFetching({ queryKey: ["downloadedFiles"] }) > 0
|
queryClient.isFetching({ queryKey: ["downloadedFiles"] }) > 0
|
||||||
}
|
}
|
||||||
onRefresh={async () => {
|
onRefresh={() => {
|
||||||
await queryClient.invalidateQueries({
|
refetchDownloadedFiles();
|
||||||
queryKey: ["downloadedFiles"],
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
className="p-4 space-y-2"
|
className="flex-1 "
|
||||||
>
|
>
|
||||||
{activeDownloads.length ? (
|
<View className="flex p-4 space-y-2">
|
||||||
<View>
|
{!movies.length && !episodes.length && !activeDownloads.length ? (
|
||||||
<Text className="text-neutral-500 ml-2 text-xs mb-1">
|
<View className="flex flex-col items-center justify-center">
|
||||||
ACTIVE DOWNLOADS
|
<Text className="text-neutral-500 text-xs">
|
||||||
</Text>
|
No downloaded items
|
||||||
{activeDownloads.map((i) => {
|
</Text>
|
||||||
const progress =
|
</View>
|
||||||
i.secondsTotal && i.secondsDownloaded
|
) : null}
|
||||||
? i.secondsDownloaded / i.secondsTotal
|
|
||||||
: 0;
|
|
||||||
const eta = getETA(i);
|
|
||||||
const item = i.metadata?.item;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={i.id}
|
|
||||||
className="flex flex-row items-center justify-between p-4 rounded-xl bg-neutral-900"
|
|
||||||
>
|
|
||||||
<View className="space-y-0.5">
|
|
||||||
{item.Type === "Episode" ? (
|
|
||||||
<Text className="text-xs">{item?.SeriesName}</Text>
|
|
||||||
) : null}
|
|
||||||
<Text className="font-semibold">{item?.Name}</Text>
|
|
||||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<Text className="text-xs text-neutral-500">
|
|
||||||
{eta ? `${eta} remaining` : "Calculating time..."}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{i.state === "PENDING" ? (
|
{activeDownloads.length ? (
|
||||||
<ActivityIndicator />
|
<View>
|
||||||
) : (
|
<Text className="text-neutral-500 ml-2 text-xs mb-1">
|
||||||
<ProgressCircle
|
ACTIVE DOWNLOADS
|
||||||
size={48}
|
</Text>
|
||||||
fill={progress * 100}
|
<View className="space-y-2">
|
||||||
width={8}
|
{activeDownloads.map((i) => {
|
||||||
tintColor="#9334E9"
|
const progress =
|
||||||
backgroundColor="#bdc3c7"
|
i.secondsTotal && i.secondsDownloaded
|
||||||
/>
|
? i.secondsDownloaded / i.secondsTotal
|
||||||
)}
|
: 0;
|
||||||
|
const eta = getETA(i);
|
||||||
|
const item = i.metadata?.item;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!i.metadata.item.Id) throw new Error("No item id");
|
||||||
|
handleActiveItemPress(i.metadata.item.Id);
|
||||||
|
}}
|
||||||
|
key={i.id}
|
||||||
|
className="flex flex-row items-center p-2 pr-4 rounded-xl bg-neutral-900 space-x-4"
|
||||||
|
>
|
||||||
|
{i.metadata.item.Id && (
|
||||||
|
<View
|
||||||
|
className={`rounded-lg overflow-hidden ${
|
||||||
|
i.metadata.item.Type === "Movie"
|
||||||
|
? "h-24 aspect-[10/15]"
|
||||||
|
: "w-24 aspect-video"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image(
|
||||||
|
i.metadata.item.Id
|
||||||
|
)}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="space-y-0.5 flex-1">
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text className="text-xs">{item?.SeriesName}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text className="font-semibold">{item?.Name}</Text>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
{i.metadata.item.Type === "Episode" && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="text-xs text-neutral-500"
|
||||||
|
>
|
||||||
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||||
|
-{" "}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item?.RunTimeTicks ? (
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{formatTimeString(
|
||||||
|
ticksToSeconds(item?.RunTimeTicks),
|
||||||
|
"s"
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs text-purple-600">
|
||||||
|
{eta ? `~${eta} remaining` : "Calculating time..."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="ml-auto relative">
|
||||||
|
{i.state === "PENDING" ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<View className="relative items-center justify-center">
|
||||||
|
<ProgressCircle
|
||||||
|
size={48}
|
||||||
|
fill={progress * 100}
|
||||||
|
width={6}
|
||||||
|
tintColor="#9334E9"
|
||||||
|
backgroundColor="#bdc3c7"
|
||||||
|
/>
|
||||||
|
<Text className="absolute text-[10px] text-[#bdc3c7] top-[18px] left-[14px]">
|
||||||
|
{(progress * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="space-y-2">
|
||||||
|
{movies && movies.length ? (
|
||||||
|
<View>
|
||||||
|
<Text className="text-neutral-500 ml-2 text-xs mb-1">MOVIES</Text>
|
||||||
|
<View className="space-y-2">
|
||||||
|
{movies.map((i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i.id}
|
||||||
|
onPress={() => handleItemPress(i)}
|
||||||
|
className="flex flex-row items-center p-2 pr-4 rounded-xl bg-neutral-900 space-x-4"
|
||||||
|
>
|
||||||
|
{i.metadata.item.Id && (
|
||||||
|
<View className="h-24 aspect-[10/15] rounded-lg overflow-hidden">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image(
|
||||||
|
i.metadata.item.Id
|
||||||
|
)}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{i.metadata.item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<Text className="font-semibold">
|
||||||
|
{i.metadata.item?.Name}
|
||||||
|
</Text>
|
||||||
|
{i.metadata.item?.RunTimeTicks ? (
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{formatTimeString(
|
||||||
|
ticksToSeconds(i.metadata.item?.RunTimeTicks),
|
||||||
|
"s"
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name="play-circle"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
</View>
|
||||||
})}
|
) : null}
|
||||||
|
{episodes && episodes.length ? (
|
||||||
|
<View>
|
||||||
|
<Text className="text-neutral-500 ml-2 text-xs mb-1">
|
||||||
|
EPISODES
|
||||||
|
</Text>
|
||||||
|
<View className="space-y-2">
|
||||||
|
{episodes.map((i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i.id}
|
||||||
|
onPress={() => handleItemPress(i)}
|
||||||
|
className="bg-neutral-900 p-2 pr-4 rounded-xl flex flex-row items-center space-x-4"
|
||||||
|
>
|
||||||
|
{i.metadata.item.Id && (
|
||||||
|
<View className="w-24 aspect-video rounded-lg overflow-hidden">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image(
|
||||||
|
i.metadata.item.Id
|
||||||
|
)}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="flex-1">
|
||||||
|
{i.metadata.item.Type === "Episode" ? (
|
||||||
|
<Text className="text-[12px]">
|
||||||
|
{i.metadata.item?.SeriesName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
className="font-semibold text-[12px]"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{i.metadata.item?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="text-xs text-neutral-500"
|
||||||
|
>
|
||||||
|
{`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name="play-circle"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View className="space-y-2">
|
|
||||||
{movies && movies.length ? (
|
|
||||||
<View>
|
|
||||||
<Text className="text-neutral-500 ml-2 text-xs mb-1">MOVIES</Text>
|
|
||||||
<View className="space-y-2">
|
|
||||||
{movies.map((i) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={i.id}
|
|
||||||
onPress={() => goToVideo(i)}
|
|
||||||
className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
{i.metadata.item.Type === "Episode" ? (
|
|
||||||
<Text className="text-xs">
|
|
||||||
{i.metadata.item?.SeriesName}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{i.metadata.item?.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
className="text-xs text-neutral-500"
|
|
||||||
>
|
|
||||||
{`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons name="play-circle" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{episodes && episodes.length ? (
|
|
||||||
<View>
|
|
||||||
<Text className="text-neutral-500 ml-2 text-xs mb-1">EPISODES</Text>
|
|
||||||
<View className="space-y-2">
|
|
||||||
{episodes.map((i) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={i.id}
|
|
||||||
onPress={() => goToVideo(i)}
|
|
||||||
className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
{i.metadata.item.Type === "Episode" ? (
|
|
||||||
<Text className="text-xs">
|
|
||||||
{i.metadata.item?.SeriesName}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{i.metadata.item?.Name}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
className="text-xs text-neutral-500"
|
|
||||||
>
|
|
||||||
{`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons name="play-circle" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export default function page() {
|
|||||||
let m3u8Url = "";
|
let m3u8Url = "";
|
||||||
const path = `${FileSystem.documentDirectory}/downloads/${item?.Id}/Data`;
|
const path = `${FileSystem.documentDirectory}/downloads/${item?.Id}/Data`;
|
||||||
const files = await FileSystem.readDirectoryAsync(path);
|
const files = await FileSystem.readDirectoryAsync(path);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith(".m3u8")) {
|
if (file.endsWith(".m3u8")) {
|
||||||
console.log(file);
|
console.log(file);
|
||||||
@@ -138,10 +139,11 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log({
|
if (!m3u8Url) throw new Error("No m3u8 file found");
|
||||||
mediaSource: data.mediaSource,
|
|
||||||
|
console.log("stream ~", {
|
||||||
|
mediaSource: data.mediaSource.Id,
|
||||||
url: m3u8Url,
|
url: m3u8Url,
|
||||||
sessionId: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (item)
|
if (item)
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ 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";
|
||||||
const BackGroundDownloader = !Platform.isTV
|
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
|
||||||
: null;
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -171,26 +168,6 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = AppState.addEventListener(
|
|
||||||
"change",
|
|
||||||
(nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
|
|||||||
@@ -116,7 +116,18 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
|||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
});
|
});
|
||||||
toast.success("Download started");
|
toast.success(
|
||||||
|
t("home.downloads.toasts.download_started_for", { item: item.Name }),
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: "Go to download",
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download error:", error);
|
console.error("Download error:", error);
|
||||||
toast.error("Failed to start download");
|
toast.error("Failed to start download");
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ async function checkForExistingDownloads(): Promise<DownloadInfo[]> {
|
|||||||
return HlsDownloaderModule.checkForExistingDownloads();
|
return HlsDownloaderModule.checkForExistingDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels an ongoing download.
|
||||||
|
* @param id - The unique identifier for the download.
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
async function cancelDownload(id: string): Promise<void> {
|
async function cancelDownload(id: string): Promise<void> {
|
||||||
return HlsDownloaderModule.cancelDownload(id);
|
return HlsDownloaderModule.cancelDownload(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ public class HlsDownloaderModule: Module {
|
|||||||
startTime: Double
|
startTime: Double
|
||||||
)] = [:]
|
)] = [:]
|
||||||
|
|
||||||
|
struct DownloadRequest {
|
||||||
|
let providedId: String
|
||||||
|
let url: String
|
||||||
|
let metadata: [String: Any]?
|
||||||
|
}
|
||||||
|
var pendingDownloads: [DownloadRequest] = []
|
||||||
|
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("HlsDownloader")
|
Name("HlsDownloader")
|
||||||
|
|
||||||
@@ -16,9 +23,51 @@ 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
|
let startTime = Date().timeIntervalSince1970
|
||||||
print(
|
|
||||||
"Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata)), StartTime: \(startTime)"
|
// Enforce max 3 concurrent downloads.
|
||||||
)
|
if self.activeDownloads.count >= 3 {
|
||||||
|
self.pendingDownloads.append(
|
||||||
|
DownloadRequest(providedId: providedId, url: url, metadata: metadata))
|
||||||
|
self.sendEvent(
|
||||||
|
"onProgress",
|
||||||
|
[
|
||||||
|
"id": providedId,
|
||||||
|
"progress": 0.0,
|
||||||
|
"state": "QUEUED",
|
||||||
|
"metadata": metadata ?? [:],
|
||||||
|
"startTime": startTime,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if the asset already exists
|
||||||
|
let fm = FileManager.default
|
||||||
|
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
|
||||||
|
let potentialExistingLocation = downloadsDir.appendingPathComponent(
|
||||||
|
providedId, isDirectory: true)
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: potentialExistingLocation.path) {
|
||||||
|
// Check if the download is complete by looking for the master playlist
|
||||||
|
if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path),
|
||||||
|
files.contains(where: { $0.hasSuffix(".m3u8") })
|
||||||
|
{
|
||||||
|
// Asset exists and appears complete, send completion event
|
||||||
|
self.sendEvent(
|
||||||
|
"onComplete",
|
||||||
|
[
|
||||||
|
"id": providedId,
|
||||||
|
"location": potentialExistingLocation.absoluteString,
|
||||||
|
"state": "DONE",
|
||||||
|
"metadata": metadata ?? [:],
|
||||||
|
"startTime": startTime,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Asset exists but appears incomplete, clean it up
|
||||||
|
try? fm.removeItem(at: potentialExistingLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard let assetURL = URL(string: url) else {
|
guard let assetURL = URL(string: url) else {
|
||||||
self.sendEvent(
|
self.sendEvent(
|
||||||
@@ -33,7 +82,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add asset options to allow cellular downloads and specify allowed media types
|
// Rest of the download logic remains the same
|
||||||
let asset = AVURLAsset(
|
let asset = AVURLAsset(
|
||||||
url: assetURL,
|
url: assetURL,
|
||||||
options: [
|
options: [
|
||||||
@@ -42,7 +91,6 @@ public class HlsDownloaderModule: Module {
|
|||||||
"AVURLAssetAllowsCellularAccessKey": true,
|
"AVURLAssetAllowsCellularAccessKey": true,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Validate the asset before proceeding
|
|
||||||
asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) {
|
asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) {
|
||||||
var error: NSError?
|
var error: NSError?
|
||||||
let status = asset.statusOfValue(forKey: "playable", error: &error)
|
let status = asset.statusOfValue(forKey: "playable", error: &error)
|
||||||
@@ -63,7 +111,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configuration = URLSessionConfiguration.background(
|
let configuration = URLSessionConfiguration.background(
|
||||||
withIdentifier: "com.streamyfin.hlsdownload")
|
withIdentifier: "com.streamyfin.hlsdownload.\(providedId)") // Add unique identifier
|
||||||
configuration.allowsCellularAccess = true
|
configuration.allowsCellularAccess = true
|
||||||
configuration.sessionSendsLaunchEvents = true
|
configuration.sessionSendsLaunchEvents = true
|
||||||
configuration.isDiscretionary = false
|
configuration.isDiscretionary = false
|
||||||
@@ -103,7 +151,9 @@ public class HlsDownloaderModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delegate.taskIdentifier = task.taskIdentifier
|
delegate.taskIdentifier = task.taskIdentifier
|
||||||
|
|
||||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
|
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
|
||||||
|
|
||||||
self.sendEvent(
|
self.sendEvent(
|
||||||
"onProgress",
|
"onProgress",
|
||||||
[
|
[
|
||||||
@@ -115,7 +165,6 @@ public class HlsDownloaderModule: Module {
|
|||||||
])
|
])
|
||||||
|
|
||||||
task.resume()
|
task.resume()
|
||||||
print("Download task started with identifier: \(task.taskIdentifier)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,8 +202,6 @@ public class HlsDownloaderModule: Module {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let (task, delegate, metadata, startTime) = entry.value
|
let (task, delegate, metadata, startTime) = entry.value
|
||||||
task.cancel()
|
|
||||||
self.activeDownloads.removeValue(forKey: task.taskIdentifier)
|
|
||||||
self.sendEvent(
|
self.sendEvent(
|
||||||
"onError",
|
"onError",
|
||||||
[
|
[
|
||||||
@@ -164,6 +211,9 @@ public class HlsDownloaderModule: Module {
|
|||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"startTime": startTime,
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
|
task.cancel()
|
||||||
|
self.activeDownloads.removeValue(forKey: task.taskIdentifier)
|
||||||
|
self.startNextDownloadIfNeeded()
|
||||||
print("Download cancelled for identifier: \(providedId)")
|
print("Download cancelled for identifier: \(providedId)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,16 +233,26 @@ public class HlsDownloaderModule: Module {
|
|||||||
try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
|
try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true)
|
let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true)
|
||||||
|
|
||||||
|
// New atomic move implementation
|
||||||
|
let tempLocation = downloadsDir.appendingPathComponent("\(folderName)_temp", isDirectory: true)
|
||||||
|
|
||||||
|
// Clean up any existing temp folder
|
||||||
|
if fm.fileExists(atPath: tempLocation.path) {
|
||||||
|
try fm.removeItem(at: tempLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to temp location first
|
||||||
|
try fm.moveItem(at: originalLocation, to: tempLocation)
|
||||||
|
|
||||||
|
// If target exists, remove it
|
||||||
if fm.fileExists(atPath: newLocation.path) {
|
if fm.fileExists(atPath: newLocation.path) {
|
||||||
try fm.removeItem(at: newLocation)
|
try fm.removeItem(at: newLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the original file exists, move it. Otherwise, if newLocation already exists, assume it was moved.
|
// Final move from temp to target
|
||||||
if fm.fileExists(atPath: originalLocation.path) {
|
try fm.moveItem(at: tempLocation, to: newLocation)
|
||||||
try fm.moveItem(at: originalLocation, to: newLocation)
|
|
||||||
} else if !fm.fileExists(atPath: newLocation.path) {
|
|
||||||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
|
|
||||||
}
|
|
||||||
return newLocation
|
return newLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +275,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
var downloadedSeconds: Double = 0
|
var downloadedSeconds: Double = 0
|
||||||
var totalSeconds: Double = 0
|
var totalSeconds: Double = 0
|
||||||
var startTime: Double = 0
|
var startTime: Double = 0
|
||||||
|
private var wasCancelled = false
|
||||||
|
|
||||||
init(module: HlsDownloaderModule) {
|
init(module: HlsDownloaderModule) {
|
||||||
self.module = module
|
self.module = module
|
||||||
@@ -255,6 +316,10 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
||||||
didFinishDownloadingTo location: URL
|
didFinishDownloadingTo location: URL
|
||||||
) {
|
) {
|
||||||
|
if wasCancelled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||||
let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
|
let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
|
||||||
let folderName = providedId
|
let folderName = providedId
|
||||||
@@ -330,6 +395,12 @@ 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 {
|
||||||
|
if (error as NSError).code == NSURLErrorCancelled {
|
||||||
|
wasCancelled = true
|
||||||
|
module?.removeDownload(with: taskIdentifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
||||||
let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0
|
let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0
|
||||||
module?.sendEvent(
|
module?.sendEvent(
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
export type DownloadState =
|
export type DownloadState =
|
||||||
|
| "QUEUED"
|
||||||
| "PENDING"
|
| "PENDING"
|
||||||
| "DOWNLOADING"
|
| "DOWNLOADING"
|
||||||
| "PAUSED"
|
| "PAUSED"
|
||||||
| "DONE"
|
| "DONE"
|
||||||
| "FAILED"
|
| "FAILED"
|
||||||
|
| "CANCELLED"
|
||||||
| "STOPPED";
|
| "STOPPED";
|
||||||
|
|
||||||
export interface DownloadMetadata {
|
export interface DownloadMetadata {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
addProgressListener,
|
addProgressListener,
|
||||||
checkForExistingDownloads,
|
checkForExistingDownloads,
|
||||||
downloadHLSAsset,
|
downloadHLSAsset,
|
||||||
|
cancelDownload,
|
||||||
} from "@/modules/hls-downloader";
|
} from "@/modules/hls-downloader";
|
||||||
import {
|
import {
|
||||||
DownloadInfo,
|
DownloadInfo,
|
||||||
@@ -45,8 +46,10 @@ type DownloadContextType = {
|
|||||||
}: DownloadOptionsData
|
}: DownloadOptionsData
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
|
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
|
||||||
|
cancelDownload: (id: string) => Promise<void>;
|
||||||
activeDownloads: DownloadInfo[];
|
activeDownloads: DownloadInfo[];
|
||||||
downloadedFiles: DownloadedFileInfo[];
|
downloadedFiles: DownloadedFileInfo[];
|
||||||
|
refetchDownloadedFiles: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DownloadContext = createContext<DownloadContextType | undefined>(
|
const DownloadContext = createContext<DownloadContextType | undefined>(
|
||||||
@@ -82,22 +85,26 @@ export type DownloadedFileInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
const getDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
|
||||||
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
|
||||||
const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
|
|
||||||
if (!dirInfo.exists) return [];
|
|
||||||
const files = await FileSystem.readDirectoryAsync(downloadsDir);
|
|
||||||
const downloaded: DownloadedFileInfo[] = [];
|
const downloaded: DownloadedFileInfo[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
||||||
|
const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
|
||||||
|
|
||||||
|
if (!dirInfo.exists) return [];
|
||||||
|
|
||||||
|
const files = await FileSystem.readDirectoryAsync(downloadsDir);
|
||||||
|
|
||||||
|
for (let file of files) {
|
||||||
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file);
|
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file);
|
||||||
if (fileInfo.isDirectory) continue;
|
if (fileInfo.isDirectory) continue;
|
||||||
|
if (!file.endsWith(".json")) continue;
|
||||||
|
|
||||||
const doneFile = await isFileMarkedAsDone(file.replace(".json", ""));
|
const fileContent = await FileSystem.readAsStringAsync(downloadsDir + file);
|
||||||
if (!doneFile) continue;
|
|
||||||
|
|
||||||
const fileContent = await FileSystem.readAsStringAsync(
|
// Check that fileContent is actually DownloadMetadata
|
||||||
downloadsDir + file.replace("-done", "")
|
if (!fileContent) continue;
|
||||||
);
|
if (!fileContent.includes("mediaSource")) continue;
|
||||||
|
if (!fileContent.includes("item")) continue;
|
||||||
|
|
||||||
downloaded.push({
|
downloaded.push({
|
||||||
id: file.replace(".json", ""),
|
id: file.replace(".json", ""),
|
||||||
@@ -112,8 +119,6 @@ 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;
|
||||||
const doneFile = await isFileMarkedAsDone(id);
|
|
||||||
if (!doneFile) return null;
|
|
||||||
const fileContent = await FileSystem.readAsStringAsync(
|
const fileContent = await FileSystem.readAsStringAsync(
|
||||||
downloadsDir + id + ".json"
|
downloadsDir + id + ".json"
|
||||||
);
|
);
|
||||||
@@ -130,7 +135,7 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const { data: downloadedFiles } = useQuery({
|
const { data: downloadedFiles, refetch: refetchDownloadedFiles } = useQuery({
|
||||||
queryKey: ["downloadedFiles"],
|
queryKey: ["downloadedFiles"],
|
||||||
queryFn: getDownloadedFiles,
|
queryFn: getDownloadedFiles,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
@@ -140,9 +145,7 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize downloads from both HLS and regular downloads
|
|
||||||
const initializeDownloads = async () => {
|
const initializeDownloads = async () => {
|
||||||
// Check HLS downloads
|
|
||||||
const hlsDownloads = await checkForExistingDownloads();
|
const hlsDownloads = await checkForExistingDownloads();
|
||||||
const hlsDownloadStates = hlsDownloads.reduce(
|
const hlsDownloadStates = hlsDownloads.reduce(
|
||||||
(acc, download) => ({
|
(acc, download) => ({
|
||||||
@@ -204,7 +207,7 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] });
|
await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] });
|
||||||
|
|
||||||
toast.success("Download complete ✅");
|
if (payload.state === "DONE") toast.success("Download complete ✅");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to download file:", error);
|
console.error("Failed to download file:", error);
|
||||||
toast.error("Failed to download ❌");
|
toast.error("Failed to download ❌");
|
||||||
@@ -217,7 +220,12 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
delete newDownloads[error.id];
|
delete newDownloads[error.id];
|
||||||
return newDownloads;
|
return newDownloads;
|
||||||
});
|
});
|
||||||
toast.error("Failed to download ❌");
|
|
||||||
|
if (error.state === "CANCELLED") toast.info("Download cancelled 🟡");
|
||||||
|
else {
|
||||||
|
toast.error("Download failed ❌");
|
||||||
|
console.error("Download error:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -227,43 +235,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 () => {
|
|
||||||
const downloadsFolder = await FileSystem.getInfoAsync(
|
|
||||||
FileSystem.documentDirectory + "downloads"
|
|
||||||
);
|
|
||||||
if (!downloadsFolder.exists) return;
|
|
||||||
const files = await FileSystem.readDirectoryAsync(
|
|
||||||
FileSystem.documentDirectory + "downloads"
|
|
||||||
);
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.endsWith(".json")) {
|
|
||||||
const id = file.replace(".json", "");
|
|
||||||
const doneFile = await FileSystem.getInfoAsync(
|
|
||||||
FileSystem.documentDirectory + "downloads/" + id + "-done"
|
|
||||||
);
|
|
||||||
if (!doneFile.exists) {
|
|
||||||
console.log("Found unparsed download:", id);
|
|
||||||
|
|
||||||
const p = async () => {
|
|
||||||
await rewriteM3U8Files(
|
|
||||||
FileSystem.documentDirectory + "downloads/" + id
|
|
||||||
);
|
|
||||||
await markFileAsDone(id);
|
|
||||||
};
|
|
||||||
toast.promise(p(), {
|
|
||||||
error: () => "Failed to download ❌",
|
|
||||||
loading: "Finishing up download...",
|
|
||||||
success: () => "Download complete ✅",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// checkForUnparsedDownloads();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startDownload = async (
|
const startDownload = async (
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -315,6 +286,8 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
downloadedFiles: downloadedFiles ?? [],
|
downloadedFiles: downloadedFiles ?? [],
|
||||||
getDownloadedItem: getDownloadedFile,
|
getDownloadedItem: getDownloadedFile,
|
||||||
activeDownloads: Object.values(downloads),
|
activeDownloads: Object.values(downloads),
|
||||||
|
cancelDownload: cancelDownload,
|
||||||
|
refetchDownloadedFiles,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user