This commit is contained in:
Fredrik Burmester
2025-02-17 13:46:31 +01:00
parent fdbe4a024b
commit 124c8bfb3a
8 changed files with 425 additions and 248 deletions

View File

@@ -2,10 +2,15 @@ import { Text } from "@/components/common/Text";
import ProgressCircle from "@/components/ProgressCircle";
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
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 { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import {
ActivityIndicator,
RefreshControl,
@@ -14,20 +19,6 @@ import {
View,
} 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 => {
if (
!download.startTime ||
@@ -37,34 +28,57 @@ const getETA = (download: DownloadInfo): string | null => {
console.log(download);
return null;
}
const elapsed = Date.now() / 1000 - download.startTime; // seconds
const elapsed = Date.now() / 1000 - download.startTime;
if (elapsed <= 0 || download.secondsDownloaded <= 0) return null;
const speed = download.secondsDownloaded / elapsed; // downloaded seconds per second
const speed = download.secondsDownloaded / elapsed;
const remainingBytes = download.secondsTotal - download.secondsDownloaded;
if (speed <= 0) return null;
const secondsLeft = remainingBytes / speed;
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];
return formatTimeString(secondsLeft, "s");
};
export default function Index() {
const { downloadedFiles, activeDownloads } = useNativeDownloads();
const { showActionSheetWithOptions } = useActionSheet();
const {
downloadedFiles,
activeDownloads,
cancelDownload,
refetchDownloadedFiles,
} = useNativeDownloads();
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) => {
// @ts-expect-error
@@ -80,7 +94,15 @@ export default function Index() {
[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 (
<ScrollView
@@ -89,129 +111,243 @@ export default function Index() {
refreshing={
queryClient.isFetching({ queryKey: ["downloadedFiles"] }) > 0
}
onRefresh={async () => {
await queryClient.invalidateQueries({
queryKey: ["downloadedFiles"],
});
onRefresh={() => {
refetchDownloadedFiles();
}}
/>
}
className="p-4 space-y-2"
className="flex-1 "
>
{activeDownloads.length ? (
<View>
<Text className="text-neutral-500 ml-2 text-xs mb-1">
ACTIVE DOWNLOADS
</Text>
{activeDownloads.map((i) => {
const progress =
i.secondsTotal && i.secondsDownloaded
? 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>
<View className="flex p-4 space-y-2">
{!movies.length && !episodes.length && !activeDownloads.length ? (
<View className="flex flex-col items-center justify-center">
<Text className="text-neutral-500 text-xs">
No downloaded items
</Text>
</View>
) : null}
{i.state === "PENDING" ? (
<ActivityIndicator />
) : (
<ProgressCircle
size={48}
fill={progress * 100}
width={8}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
)}
{activeDownloads.length ? (
<View>
<Text className="text-neutral-500 ml-2 text-xs mb-1">
ACTIVE DOWNLOADS
</Text>
<View className="space-y-2">
{activeDownloads.map((i) => {
const progress =
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>
) : 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>
) : 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>
</ScrollView>
);

View File

@@ -130,6 +130,7 @@ export default function page() {
let m3u8Url = "";
const path = `${FileSystem.documentDirectory}/downloads/${item?.Id}/Data`;
const files = await FileSystem.readDirectoryAsync(path);
for (const file of files) {
if (file.endsWith(".m3u8")) {
console.log(file);
@@ -138,10 +139,11 @@ export default function page() {
}
}
console.log({
mediaSource: data.mediaSource,
if (!m3u8Url) throw new Error("No m3u8 file found");
console.log("stream ~", {
mediaSource: data.mediaSource.Id,
url: m3u8Url,
sessionId: undefined,
});
if (item)

View File

@@ -29,9 +29,6 @@ import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
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;
if (!Platform.isTV) {
@@ -171,26 +168,6 @@ function Layout() {
);
}
}, [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({