mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-20 07:44:42 +01:00
wip
This commit is contained in:
2
app.json
2
app.json
@@ -14,7 +14,7 @@
|
|||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": ["audio", "fetch", "processing"],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
SeasonDropdown,
|
|
||||||
SeasonIndexState,
|
|
||||||
} from "@/components/series/SeasonDropdown";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { seriesId, episodeSeasonIndex } = local as {
|
|
||||||
seriesId: string;
|
|
||||||
episodeSeasonIndex: number | string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const { downloadedFiles, deleteItems } = useDownload();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
downloadedFiles
|
|
||||||
?.filter((f) => f.item.SeriesId == seriesId)
|
|
||||||
?.sort(
|
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const seasonIndex =
|
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
|
||||||
episodeSeasonIndex ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
series?.forEach((episode) => {
|
|
||||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
|
||||||
seasons[episode.item.ParentIndexNumber!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}, [series, seasonIndex]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
|
||||||
[groupBySeason]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (series.length > 0) {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: series[0].item.SeriesName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
storage.delete(seriesId);
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}, [series]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(() => {
|
|
||||||
Alert.alert(
|
|
||||||
"Delete season",
|
|
||||||
"Are you sure you want to delete the entire season?",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Cancel",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
onPress: () => deleteItems(groupBySeason),
|
|
||||||
style: "destructive",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}, [groupBySeason]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1">
|
|
||||||
{series.length > 0 && (
|
|
||||||
<View className="flex flex-row items-center justify-start my-2 px-4">
|
|
||||||
<SeasonDropdown
|
|
||||||
item={series[0].item}
|
|
||||||
seasons={series.map((s) => s.item)}
|
|
||||||
state={seasonIndexState}
|
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
|
||||||
onSelect={(season) => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
|
||||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
|
||||||
<Ionicons name="trash" size={20} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ScrollView key={seasonIndex} className="px-4">
|
|
||||||
{groupBySeason.map((episode, index) => (
|
|
||||||
<EpisodeCard key={index} item={episode} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,253 +1,41 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { useRouter } from "expo-router";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { useEffect } from "react";
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { t } from 'i18next';
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function index() {
|
||||||
const navigation = useNavigation();
|
const { downloadedFiles, getDownloadedItem, activeDownloads } =
|
||||||
const { t } = useTranslation();
|
useNativeDownloads();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [settings] = useSettings();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const goToVideo = (item: any) => {
|
||||||
try {
|
console.log(item);
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
// @ts-expect-error
|
||||||
} catch {
|
router.push("/player/direct-player?offline=true&itemId=" + item.id);
|
||||||
migration_20241124();
|
};
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const episodes = downloadedFiles?.filter(
|
|
||||||
(f) => f.item.Type === "Episode"
|
|
||||||
);
|
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
|
||||||
series[e.item.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
console.log(activeDownloads);
|
||||||
headerRight: () => (
|
}, [activeDownloads]);
|
||||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const deleteMovies = () =>
|
|
||||||
deleteFileByType("Movie")
|
|
||||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
|
||||||
.catch((reason) => {
|
|
||||||
writeToLog("ERROR", reason);
|
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
|
||||||
});
|
|
||||||
const deleteShows = () =>
|
|
||||||
deleteFileByType("Episode")
|
|
||||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
|
||||||
.catch((reason) => {
|
|
||||||
writeToLog("ERROR", reason);
|
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
|
||||||
});
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View className="p-4 space-y-2">
|
||||||
<ScrollView
|
{activeDownloads.map((i) => (
|
||||||
contentContainerStyle={{
|
<View>
|
||||||
paddingLeft: insets.left,
|
<Text>{i.id}</Text>
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
|
||||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
|
||||||
{t("home.downloads.queue_hint")}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
|
||||||
{queue.map((q, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{q.item.Type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">
|
|
||||||
{groupedBySeries?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{groupedBySeries?.map((items) => (
|
|
||||||
<View
|
|
||||||
className="mb-2 last:mb-0"
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
>
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className="flex px-4">
|
|
||||||
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
))}
|
||||||
<BottomSheetModal
|
{downloadedFiles.map((i) => (
|
||||||
ref={bottomSheetModalRef}
|
<TouchableOpacity
|
||||||
enableDynamicSizing
|
key={i.id}
|
||||||
handleIndicatorStyle={{
|
onPress={() => goToVideo(i)}
|
||||||
backgroundColor: "white",
|
className="bg-neutral-800 p-4 rounded-lg"
|
||||||
}}
|
>
|
||||||
backgroundStyle={{
|
<Text>{i.metadata.item.Name}</Text>
|
||||||
backgroundColor: "#171717",
|
<Text>{i.metadata.item.Type}</Text>
|
||||||
}}
|
</TouchableOpacity>
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
))}
|
||||||
<BottomSheetBackdrop
|
</View>
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className="p-4 space-y-4 mb-4">
|
|
||||||
<Button color="purple" onPress={deleteMovies}>
|
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" onPress={deleteShows}>
|
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onPress={deleteAllMedia}>
|
|
||||||
{t("home.downloads.delete_all_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function migration_20241124() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { deleteAllFiles } = useDownload();
|
|
||||||
Alert.alert(
|
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
|
||||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("home.downloads.back"),
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("home.downloads.delete"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => await deleteAllFiles(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ 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 { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "@/providers/SplashScreenProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -24,23 +26,17 @@ 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 { useNavigation, useRouter } from "expo-router";
|
import { 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 { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
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";
|
||||||
import {
|
|
||||||
useSplashScreenLoading,
|
|
||||||
useSplashScreenVisible,
|
|
||||||
} from "@/providers/SplashScreenProvider";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type ScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
@@ -78,39 +74,8 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory")
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
const checkConnection = useCallback(async () => {
|
||||||
setLoadingRetry(true);
|
setLoadingRetry(true);
|
||||||
const state = await NetInfo.fetch();
|
const state = await NetInfo.fetch();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
@@ -85,21 +84,6 @@ const page: React.FC = () => {
|
|||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<AddToFavorites item={item} type="series" />
|
<AddToFavorites item={item} type="series" />
|
||||||
<DownloadItems
|
|
||||||
size="large"
|
|
||||||
title={t("item_card.download.download_series")}
|
|
||||||
items={allEpisodes || []}
|
|
||||||
MissingDownloadIconComponent={() => (
|
|
||||||
<Ionicons name="download" size={22} color="white" />
|
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons
|
|
||||||
name="checkmark-done-outline"
|
|
||||||
size={24}
|
|
||||||
color="#9333ea"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
@@ -12,11 +12,9 @@ import {
|
|||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
// import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
const downloadProvider = !Platform.isTV
|
|
||||||
? require("@/providers/DownloadProvider")
|
|
||||||
: null;
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
@@ -26,26 +24,19 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
View,
|
|
||||||
AppState,
|
|
||||||
AppStateStatus,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, AppState, AppStateStatus, Platform, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -66,10 +57,8 @@ export default function page() {
|
|||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
let getDownloadedItem = null;
|
|
||||||
if (!Platform.isTV) {
|
const { getDownloadedItem } = useNativeDownloads();
|
||||||
getDownloadedItem = downloadProvider.useDownload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -112,7 +101,7 @@ export default function page() {
|
|||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const item = await getDownloadedItem.getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,15 +124,30 @@ export default function page() {
|
|||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return null;
|
if (!data?.mediaSource) return null;
|
||||||
|
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
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);
|
||||||
|
m3u8Url = `${path}/${file}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
mediaSource: data.mediaSource,
|
||||||
|
url: m3u8Url,
|
||||||
|
sessionId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
if (item)
|
if (item)
|
||||||
return {
|
return {
|
||||||
mediaSource: data.mediaSource,
|
mediaSource: data.mediaSource,
|
||||||
url,
|
url: m3u8Url,
|
||||||
sessionId: undefined,
|
sessionId: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -197,9 +201,7 @@ export default function page() {
|
|||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: msToTicks(progress.get()),
|
positionTicks: msToTicks(progress.get()),
|
||||||
isPaused: !isPlaying,
|
isPaused: !isPlaying,
|
||||||
playMethod: stream?.url.includes("m3u8")
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,8 +295,8 @@ export default function page() {
|
|||||||
|
|
||||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
const { pipStarted } = e.nativeEvent;
|
const { pipStarted } = e.nativeEvent;
|
||||||
setIsPipStarted(pipStarted)
|
setIsPipStarted(pipStarted);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
@@ -331,7 +333,7 @@ export default function page() {
|
|||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
// Handle app going to the background
|
// Handle app going to the background
|
||||||
if (nextAppState.match(/inactive|background/)) {
|
if (nextAppState.match(/inactive|background/)) {
|
||||||
_setShowControls(false)
|
_setShowControls(false);
|
||||||
}
|
}
|
||||||
setAppState(nextAppState);
|
setAppState(nextAppState);
|
||||||
};
|
};
|
||||||
@@ -356,18 +358,16 @@ export default function page() {
|
|||||||
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(sub: { Type: string }) => sub.Type === "Subtitle"
|
(sub) => sub.Type === "Subtitle"
|
||||||
) || [];
|
) || [];
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
(sub: { Index: number }) => sub.Index === subtitleIndex
|
(sub) => sub.Index === subtitleIndex
|
||||||
);
|
);
|
||||||
const allAudio =
|
const allAudio =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(audio: { Type: string }) => audio.Type === "Audio"
|
(audio) => audio.Type === "Audio"
|
||||||
) || [];
|
) || [];
|
||||||
const chosenAudioTrack = allAudio.find(
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
(audio: { Index: number | undefined }) => audio.Index === audioIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
// Direct playback CASE
|
// Direct playback CASE
|
||||||
if (!bitrateValue) {
|
if (!bitrateValue) {
|
||||||
@@ -382,7 +382,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chosenAudioTrack)
|
if (chosenAudioTrack)
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
} else {
|
} else {
|
||||||
// Transcoded playback CASE
|
// Transcoded playback CASE
|
||||||
@@ -486,4 +486,4 @@ export default function page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,56 +321,54 @@ function Layout() {
|
|||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<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={{
|
||||||
</DownloadProvider>
|
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>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</NativeDownloadProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Href, router, useFocusEffect } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
|
||||||
import ProgressCircle from "./ProgressCircle";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
|
||||||
import { t } from "i18next";
|
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
|
||||||
items: BaseItemDto[];
|
|
||||||
MissingDownloadIconComponent: () => React.ReactElement;
|
|
||||||
DownloadedIconComponent: () => React.ReactElement;
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
size?: "default" | "large";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
|
||||||
items,
|
|
||||||
MissingDownloadIconComponent,
|
|
||||||
DownloadedIconComponent,
|
|
||||||
title = "Download",
|
|
||||||
subtitle = "",
|
|
||||||
size = "default",
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
|
||||||
MediaSourceInfo | undefined | null
|
|
||||||
>(undefined);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
|
||||||
useState<number>(0);
|
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
|
||||||
[user]
|
|
||||||
);
|
|
||||||
const usingOptimizedServer = useMemo(
|
|
||||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
|
||||||
[settings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
const handlePresentModalPress = useCallback(() => {
|
|
||||||
bottomSheetModalRef.current?.present();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
|
||||||
bottomSheetModalRef.current?.dismiss();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
|
||||||
|
|
||||||
const itemsNotDownloaded = useMemo(
|
|
||||||
() =>
|
|
||||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
|
||||||
[items, downloadedFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const allItemsDownloaded = useMemo(() => {
|
|
||||||
if (items.length === 0) return false;
|
|
||||||
return itemsNotDownloaded.length === 0;
|
|
||||||
}, [items, itemsNotDownloaded]);
|
|
||||||
const itemsProcesses = useMemo(
|
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
|
||||||
[processes, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
|
||||||
if (itemIds.length == 1)
|
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
|
||||||
return (
|
|
||||||
((itemIds.length -
|
|
||||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
|
||||||
itemIds.length) *
|
|
||||||
100
|
|
||||||
);
|
|
||||||
}, [queue, itemsProcesses, itemIds]);
|
|
||||||
|
|
||||||
const itemsQueued = useMemo(() => {
|
|
||||||
return (
|
|
||||||
itemsNotDownloaded.length > 0 &&
|
|
||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
|
||||||
);
|
|
||||||
}, [queue, itemsNotDownloaded]);
|
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
|
||||||
|
|
||||||
const onDownloadedPress = () => {
|
|
||||||
const firstItem = items?.[0];
|
|
||||||
router.push(
|
|
||||||
firstItem.Type !== "Episode"
|
|
||||||
? "/downloads"
|
|
||||||
: ({
|
|
||||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
|
||||||
params: {
|
|
||||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
|
||||||
},
|
|
||||||
} as Href)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const acceptDownloadOptions = useCallback(() => {
|
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
|
|
||||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
|
||||||
else {
|
|
||||||
queueActions.enqueue(
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
...itemsNotDownloaded.map((item) => ({
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => await initiateDownload(item),
|
|
||||||
item,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
itemsNotDownloaded,
|
|
||||||
usingOptimizedServer,
|
|
||||||
userCanDownload,
|
|
||||||
maxBitrate,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const initiateDownload = useCallback(
|
|
||||||
async (...items: BaseItemDto[]) => {
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!user?.Id ||
|
|
||||||
items.some((p) => !p.Id) ||
|
|
||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mediaSource = selectedMediaSource;
|
|
||||||
let audioIndex: number | undefined = selectedAudioStream;
|
|
||||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (itemsNotDownloaded.length > 1) {
|
|
||||||
const defaults = getDefaultPlaySettings(item, settings!);
|
|
||||||
mediaSource = defaults.mediaSource;
|
|
||||||
audioIndex = defaults.audioIndex;
|
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
|
||||||
// Keep using the selected bitrate for consistency across all downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: 0,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
mediaSourceId: mediaSource?.Id,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: download,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
Alert.alert(
|
|
||||||
t("home.downloads.something_went_wrong"),
|
|
||||||
t("home.downloads.could_not_get_stream_url_from_jellyfin")
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mediaSource: source, url } = res;
|
|
||||||
|
|
||||||
if (!url || !source) throw new Error("No url");
|
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
|
||||||
|
|
||||||
if (usingOptimizedServer) {
|
|
||||||
await startBackgroundDownload(url, item, source);
|
|
||||||
} else {
|
|
||||||
await startRemuxing(item, url, source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
itemsNotDownloaded,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
settings,
|
|
||||||
maxBitrate,
|
|
||||||
usingOptimizedServer,
|
|
||||||
startBackgroundDownload,
|
|
||||||
startRemuxing,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!settings) return;
|
|
||||||
if (itemsNotDownloaded.length !== 1) return;
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(items[0], settings);
|
|
||||||
|
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
|
||||||
setMaxBitrate(bitrate);
|
|
||||||
}, [items, itemsNotDownloaded, settings])
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
|
||||||
if (processes && itemsProcesses.length > 0) {
|
|
||||||
return progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} else if (itemsQueued) {
|
|
||||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
|
||||||
} else if (allItemsDownloaded) {
|
|
||||||
return <DownloadedIconComponent />;
|
|
||||||
} else {
|
|
||||||
return <MissingDownloadIconComponent />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onButtonPress = () => {
|
|
||||||
if (processes && itemsProcesses.length > 0) {
|
|
||||||
navigateToDownloads();
|
|
||||||
} else if (itemsQueued) {
|
|
||||||
navigateToDownloads();
|
|
||||||
} else if (allItemsDownloaded) {
|
|
||||||
onDownloadedPress();
|
|
||||||
} else {
|
|
||||||
handlePresentModalPress();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton size={size} onPress={onButtonPress}>
|
|
||||||
{renderButtonContent()}
|
|
||||||
</RoundButton>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-neutral-300">
|
|
||||||
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
|
||||||
<BitrateSelector
|
|
||||||
inverted
|
|
||||||
onChange={setMaxBitrate}
|
|
||||||
selected={maxBitrate}
|
|
||||||
/>
|
|
||||||
{itemsNotDownloaded.length === 1 && (
|
|
||||||
<>
|
|
||||||
<MediaSourceSelector
|
|
||||||
item={items[0]}
|
|
||||||
onChange={setSelectedMediaSource}
|
|
||||||
selected={selectedMediaSource}
|
|
||||||
/>
|
|
||||||
{selectedMediaSource && (
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<AudioTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
className="mt-auto"
|
|
||||||
onPress={acceptDownloadOptions}
|
|
||||||
color="purple"
|
|
||||||
>
|
|
||||||
{t("item_card.download.download_button")}
|
|
||||||
</Button>
|
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
|
||||||
<Text className="text-xs">
|
|
||||||
{usingOptimizedServer
|
|
||||||
? t("item_card.download.using_optimized_server")
|
|
||||||
: t("item_card.download.using_default_method")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadSingleItem: React.FC<{
|
|
||||||
size?: "default" | "large";
|
|
||||||
item: BaseItemDto;
|
|
||||||
}> = ({ item, size = "default" }) => {
|
|
||||||
return (
|
|
||||||
<DownloadItems
|
|
||||||
size={size}
|
|
||||||
title={item.Type == "Episode"
|
|
||||||
? t("item_card.download.download_episode")
|
|
||||||
: t("item_card.download.download_movie")}
|
|
||||||
subtitle={item.Name!}
|
|
||||||
items={[item]}
|
|
||||||
MissingDownloadIconComponent={() => (
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
@@ -15,6 +13,7 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
|
|||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -25,19 +24,17 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { NativeDownloadButton } from "./downloads/NativeDownloadButton";
|
||||||
import { NativeDownloadButton } from "./NativeDownloadButton";
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
|
||||||
import { formatTimeString } from "@/utils/time";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
const BackGroundDownloader = !Platform.isTV
|
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
|
||||||
: null;
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { t } from "i18next";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const { processes } = useDownload();
|
|
||||||
if (processes?.length === 0)
|
|
||||||
return (
|
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
|
|
||||||
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
|
|
||||||
<View className="space-y-2">
|
|
||||||
{processes?.map((p: JobStatus) => (
|
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DownloadCardProps extends TouchableOpacityProps {
|
|
||||||
process: JobStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|
||||||
const { processes, startDownload } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const { removeProcess, setProcesses } = useDownload();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const cancelJobMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
if (!process) throw new Error("No active download");
|
|
||||||
|
|
||||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
|
||||||
try {
|
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
for (const task of tasks) {
|
|
||||||
if (task.id === id) {
|
|
||||||
task.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
await removeProcess(id);
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
|
||||||
setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(t("home.downloads.toasts.download_cancelled"));
|
|
||||||
},
|
|
||||||
onError: (e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_cancel_download"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
|
||||||
if (!p.speed || !p.progress) return null;
|
|
||||||
|
|
||||||
const length = p?.item?.RunTimeTicks || 0;
|
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
|
||||||
return formatTimeString(timeLeft, "tick");
|
|
||||||
};
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(process.item.Id!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{(process.status === "optimizing" ||
|
|
||||||
process.status === "downloading") && (
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
width: process.progress
|
|
||||||
? `${Math.max(5, process.progress)}%`
|
|
||||||
: "5%",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
)}
|
|
||||||
<View className="px-3 py-1.5 flex flex-col w-full">
|
|
||||||
<View className="flex flex-row items-center w-full">
|
|
||||||
{base64Image && (
|
|
||||||
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View className="shrink mb-1">
|
|
||||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
|
||||||
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{process.item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
|
|
||||||
)}
|
|
||||||
{process.speed && (
|
|
||||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
|
||||||
)}
|
|
||||||
{eta(process) && (
|
|
||||||
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
<Text className="text-xs capitalize">{process.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={cancelJobMutation.isPending}
|
|
||||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
{cancelJobMutation.isPending ? (
|
|
||||||
<ActivityIndicator size="small" color="white" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{process.status === "completed" && (
|
|
||||||
<View className="flex flex-row mt-4 space-x-4">
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
startDownload(process);
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Download now
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { TextProps } from "react-native";
|
|
||||||
|
|
||||||
interface DownloadSizeProps extends TextProps {
|
|
||||||
items: BaseItemDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|
||||||
items,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { downloadedFiles, getDownloadedItemSize } = useDownload();
|
|
||||||
const [size, setSize] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!downloadedFiles) return;
|
|
||||||
|
|
||||||
let s = 0;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.Id) continue;
|
|
||||||
const size = getDownloadedItemSize(item.Id);
|
|
||||||
if (size) {
|
|
||||||
s += size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSize(s.bytesToReadable());
|
|
||||||
}, [itemIds]);
|
|
||||||
|
|
||||||
const sizeText = useMemo(() => {
|
|
||||||
if (!size) return "...";
|
|
||||||
return size;
|
|
||||||
}, [size]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text className="text-xs text-neutral-500" {...props}>
|
|
||||||
{sizeText}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
|
||||||
import {
|
|
||||||
ActionSheetProvider,
|
|
||||||
useActionSheet,
|
|
||||||
} from "@expo/react-native-action-sheet";
|
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|
||||||
|
|
||||||
interface EpisodeCardProps extends TouchableOpacityProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|
||||||
const { deleteFile } = useDownload();
|
|
||||||
const { openFile } = useDownloadedFileOpener();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(item.Id!);
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
openFile(item);
|
|
||||||
}, [item, openFile]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
successHapticFeedback();
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
|
||||||
const options = ["Delete", "Cancel"];
|
|
||||||
const destructiveButtonIndex = 0;
|
|
||||||
const cancelButtonIndex = 1;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case destructiveButtonIndex:
|
|
||||||
// Delete
|
|
||||||
handleDeleteFile();
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
// Cancelled
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
onLongPress={showActionSheet}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col mb-4"
|
|
||||||
>
|
|
||||||
<View className="flex flex-row items-start mb-2">
|
|
||||||
<View className="mr-2">
|
|
||||||
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
|
||||||
</View>
|
|
||||||
<View className="shrink">
|
|
||||||
<Text numberOfLines={2} className="">
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-500">
|
|
||||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
|
||||||
</Text>
|
|
||||||
<DownloadSize items={[item]} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
|
||||||
{item.Overview}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap the parent component with ActionSheetProvider
|
|
||||||
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
|
||||||
props
|
|
||||||
) => (
|
|
||||||
<ActionSheetProvider>
|
|
||||||
<EpisodeCard {...props} />
|
|
||||||
</ActionSheetProvider>
|
|
||||||
);
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionSheetProvider,
|
|
||||||
useActionSheet,
|
|
||||||
} from "@expo/react-native-action-sheet";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MovieCard component displays a movie with action sheet options.
|
|
||||||
* @param {MovieCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
|
||||||
*/
|
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useDownload();
|
|
||||||
const { openFile } = useDownloadedFileOpener();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
openFile(item);
|
|
||||||
}, [item, openFile]);
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(item.Id!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
successHapticFeedback();
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
|
||||||
const options = ["Delete", "Cancel"];
|
|
||||||
const destructiveButtonIndex = 0;
|
|
||||||
const cancelButtonIndex = 1;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case destructiveButtonIndex:
|
|
||||||
// Delete
|
|
||||||
handleDeleteFile();
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
// Cancelled
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
|
||||||
{base64Image ? (
|
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
|
||||||
<Ionicons
|
|
||||||
name="image-outline"
|
|
||||||
size={24}
|
|
||||||
color="gray"
|
|
||||||
className="self-center mt-16"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View className="w-28">
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
<DownloadSize items={[item]} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap the parent component with ActionSheetProvider
|
|
||||||
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
|
|
||||||
<ActionSheetProvider>
|
|
||||||
<MovieCard {...props} />
|
|
||||||
</ActionSheetProvider>
|
|
||||||
);
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -20,15 +21,14 @@ import { useAtom } from "jotai";
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, View, ViewProps } from "react-native";
|
import { ActivityIndicator, View, ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "../AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "../BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "../MediaSourceSelector";
|
||||||
import { RoundButton } from "./RoundButton";
|
import ProgressCircle from "../ProgressCircle";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { RoundButton } from "../RoundButton";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import { SubtitleTrackSelector } from "../SubtitleTrackSelector";
|
||||||
import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
|
|
||||||
|
|
||||||
interface NativeDownloadButton extends ViewProps {
|
interface NativeDownloadButton extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -102,8 +102,15 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
|||||||
|
|
||||||
if (!res?.url) throw new Error("No url found");
|
if (!res?.url) throw new Error("No url found");
|
||||||
if (!item.Id || !item.Name) throw new Error("No item id found");
|
if (!item.Id || !item.Name) throw new Error("No item id found");
|
||||||
|
if (!selectedMediaSource) throw new Error("No media source found");
|
||||||
|
if (!selectedAudioStream) throw new Error("No audio stream found");
|
||||||
|
|
||||||
await startDownload(item, res.url);
|
await startDownload(item, res.url, {
|
||||||
|
maxBitrate: maxBitrate.value,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
selectedMediaSource,
|
||||||
|
});
|
||||||
toast.success("Download started");
|
toast.success("Download started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download error:", error);
|
console.error("Download error:", error);
|
||||||
@@ -174,6 +181,18 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
|
|||||||
backgroundColor="#bdc3c7"
|
backgroundColor="#bdc3c7"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeDownload.state === "FAILED" && (
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
{activeDownload.state === "PAUSED" && (
|
||||||
|
<Ionicons name="pause" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
{activeDownload.state === "STOPPED" && (
|
||||||
|
<Ionicons name="stop" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
{activeDownload.state === "DONE" && (
|
||||||
|
<Ionicons name="cloud-done-outline" size={24} color={"white"} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {TouchableOpacity, View} from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import React, {useCallback, useMemo} from "react";
|
|
||||||
import {storage} from "@/utils/mmkv";
|
|
||||||
import {Image} from "expo-image";
|
|
||||||
import {Ionicons} from "@expo/vector-icons";
|
|
||||||
import {router} from "expo-router";
|
|
||||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
|
||||||
import {useDownload} from "@/providers/DownloadProvider";
|
|
||||||
import {useActionSheet} from "@expo/react-native-action-sheet";
|
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
|
||||||
const { deleteItems } = useDownload();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(items[0].SeriesId!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
|
||||||
async () => deleteItems(items),
|
|
||||||
[items]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
|
||||||
const options = ["Delete", "Cancel"];
|
|
||||||
const destructiveButtonIndex = 0;
|
|
||||||
|
|
||||||
showActionSheetWithOptions({
|
|
||||||
options,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
if (selectedIndex == destructiveButtonIndex) {
|
|
||||||
deleteSeries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, deleteSeries]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
|
||||||
onLongPress={showActionSheet}
|
|
||||||
>
|
|
||||||
{base64Image ? (
|
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
|
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
|
||||||
<Ionicons
|
|
||||||
name="image-outline"
|
|
||||||
size={24}
|
|
||||||
color="gray"
|
|
||||||
className="self-center mt-16"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className="w-28 mt-2 flex flex-col">
|
|
||||||
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
|
||||||
<DownloadSize items={items} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,7 +6,6 @@ import { atom, useAtom } from "jotai";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -148,17 +147,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
/>
|
/>
|
||||||
{episodes?.length || 0 > 0 ? (
|
{episodes?.length || 0 > 0 ? (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadItems
|
|
||||||
title={t("item_card.download.download_season")}
|
|
||||||
className="ml-2"
|
|
||||||
items={episodes || []}
|
|
||||||
MissingDownloadIconComponent={() => (
|
|
||||||
<Ionicons name="download" size={20} color="white" />
|
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons name="download" size={20} color="#9333ea" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<PlayedStatus items={episodes || []} />
|
<PlayedStatus items={episodes || []} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -199,9 +187,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start ml-auto -mt-0.5">
|
|
||||||
<DownloadSingleItem item={e} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { Platform, Switch, TouchableOpacity } from "react-native";
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
export default function DownloadSettings({ ...props }) {
|
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const allDisabled = useMemo(
|
|
||||||
() =>
|
|
||||||
pluginSettings?.downloadMethod?.locked === true &&
|
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
|
||||||
pluginSettings?.autoDownload.locked === true,
|
|
||||||
[pluginSettings]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={allDisabled} {...props} className="mb-4">
|
|
||||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.downloads.download_method")}
|
|
||||||
disabled={pluginSettings?.downloadMethod?.locked}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
|
||||||
{settings.downloadMethod === DownloadMethod.Remux
|
|
||||||
? t("home.settings.downloads.default")
|
|
||||||
: t("home.settings.downloads.optimized")}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-expand-sharp"
|
|
||||||
size={18}
|
|
||||||
color="#5A5960"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("home.settings.downloads.methods")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: DownloadMethod.Remux });
|
|
||||||
setProcesses([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{t("home.settings.downloads.default")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: DownloadMethod.Optimized });
|
|
||||||
setProcesses([]);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{t("home.settings.downloads.optimized")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.downloads.remux_max_download")}
|
|
||||||
disabled={
|
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked ||
|
|
||||||
settings.downloadMethod !== DownloadMethod.Remux
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Stepper
|
|
||||||
value={settings.remuxConcurrentLimit}
|
|
||||||
step={1}
|
|
||||||
min={1}
|
|
||||||
max={4}
|
|
||||||
onUpdate={(value) =>
|
|
||||||
updateSettings({
|
|
||||||
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.downloads.auto_download")}
|
|
||||||
disabled={
|
|
||||||
pluginSettings?.autoDownload?.locked ||
|
|
||||||
settings.downloadMethod !== DownloadMethod.Optimized
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
disabled={
|
|
||||||
pluginSettings?.autoDownload?.locked ||
|
|
||||||
settings.downloadMethod !== DownloadMethod.Optimized
|
|
||||||
}
|
|
||||||
value={settings.autoDownload}
|
|
||||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
disabled={
|
|
||||||
pluginSettings?.optimizedVersionsServerUrl?.locked ||
|
|
||||||
settings.downloadMethod !== DownloadMethod.Optimized
|
|
||||||
}
|
|
||||||
onPress={() => router.push("/settings/optimized-server/page")}
|
|
||||||
showArrow
|
|
||||||
title={t("home.settings.downloads.optimized_versions_server")}
|
|
||||||
></ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +1,15 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
|
||||||
queryKey: ["appSize", appSizeUsage],
|
|
||||||
queryFn: async () => {
|
|
||||||
const app = await appSizeUsage;
|
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
successHapticFeedback();
|
|
||||||
} catch (e) {
|
|
||||||
errorHapticFeedback();
|
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
|
||||||
return ((value / total) * 100).toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View className="flex flex-col gap-y-1">
|
{/* <View className="flex flex-col gap-y-1">
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between">
|
||||||
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
||||||
{size && (
|
{size && (
|
||||||
@@ -108,7 +74,7 @@ export const StorageSettings = () => {
|
|||||||
onPress={onDeleteClicked}
|
onPress={onDeleteClicked}
|
||||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup> */}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
} from "@/components/common/HorrizontalScroll";
|
} from "@/components/common/HorrizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
@@ -233,9 +232,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start mt-2">
|
|
||||||
<DownloadSingleItem item={_item} />
|
|
||||||
</View>
|
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={5}
|
numberOfLines={5}
|
||||||
className="text-xs text-neutral-500 shrink"
|
className="text-xs text-neutral-500 shrink"
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
|
||||||
const directory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!directory) {
|
|
||||||
throw new Error("Document directory is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
throw new Error("Item ID is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await FileSystem.readDirectoryAsync(directory);
|
|
||||||
const path = itemId!;
|
|
||||||
const matchingFile = files.find((file) => file.startsWith(path));
|
|
||||||
|
|
||||||
if (!matchingFile) {
|
|
||||||
throw new Error(`No file found for item ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${directory}${matchingFile}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDownloadedFileOpener = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
|
||||||
|
|
||||||
const openFile = useCallback(
|
|
||||||
async (item: BaseItemDto) => {
|
|
||||||
try {
|
|
||||||
// @ts-expect-error
|
|
||||||
router.push("/player/direct-player?offline=true&itemId=" + item.Id);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setOfflineSettings, setPlayUrl, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { openFile };
|
|
||||||
};
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
|
|
||||||
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
|
||||||
const FFMPEGKitReactNative = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import useImageStorage from "./useImageStorage";
|
|
||||||
import useDownloadHelper from "@/utils/download";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
|
|
||||||
type Statistics = typeof FFMPEGKitReactNative.Statistics
|
|
||||||
const FFmpegKit = FFMPEGKitReactNative.FFmpegKit;
|
|
||||||
const createFFmpegCommand = (url: string, output: string) => [
|
|
||||||
"-y", // overwrite output files without asking
|
|
||||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
|
||||||
|
|
||||||
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
|
||||||
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
|
||||||
"-multiple_requests 1", // http
|
|
||||||
"-tcp_nodelay 1", // http
|
|
||||||
// endregion ffmpeg protocol commands
|
|
||||||
|
|
||||||
"-fflags +genpts", // format flags
|
|
||||||
`-i ${url}`, // infile
|
|
||||||
"-map 0:v -map 0:a", // select all streams for video & audio
|
|
||||||
"-c copy", // streamcopy, preventing transcoding
|
|
||||||
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
|
||||||
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
|
||||||
output,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
|
||||||
*
|
|
||||||
* @param url - The URL of the HLS stream
|
|
||||||
* @param item - The BaseItemDto object representing the media item
|
|
||||||
* @returns An object with remuxing-related functions
|
|
||||||
*/
|
|
||||||
export const useRemuxHlsToMp4 = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const { saveImage } = useImageStorage();
|
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
|
||||||
const {
|
|
||||||
saveDownloadedItemInfo,
|
|
||||||
setProcesses,
|
|
||||||
processes,
|
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
|
||||||
} = useDownload();
|
|
||||||
|
|
||||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
|
||||||
await saveSeriesPrimaryImage(item);
|
|
||||||
const itemImage = getItemImage({
|
|
||||||
item,
|
|
||||||
api,
|
|
||||||
variant: "Primary",
|
|
||||||
quality: 90,
|
|
||||||
width: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
await saveImage(item.Id, itemImage?.uri);
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeCallback = useCallback(
|
|
||||||
async (session: FFmpegSession, item: BaseItemDto) => {
|
|
||||||
try {
|
|
||||||
console.log("completeCallback");
|
|
||||||
const returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
|
||||||
const stat = await session.getLastReceivedStatistics();
|
|
||||||
await FileSystem.moveAsync({
|
|
||||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
|
||||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["downloadedItems"],
|
|
||||||
});
|
|
||||||
saveDownloadedItemInfo(item, stat.getSize());
|
|
||||||
toast.success(t("home.downloads.toasts.download_completed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcesses((prev: any[]) => {
|
|
||||||
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("completeCallback ~ end");
|
|
||||||
},
|
|
||||||
[processes, setProcesses]
|
|
||||||
);
|
|
||||||
|
|
||||||
const statisticsCallback = useCallback(
|
|
||||||
(statistics: Statistics, item: BaseItemDto) => {
|
|
||||||
const videoLength =
|
|
||||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
|
||||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
|
||||||
const totalFrames = videoLength * fps;
|
|
||||||
const processedFrames = statistics.getVideoFrameNumber();
|
|
||||||
const speed = statistics.getSpeed();
|
|
||||||
|
|
||||||
const percentage =
|
|
||||||
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
|
||||||
|
|
||||||
if (!item.Id) throw new Error("Item is undefined");
|
|
||||||
setProcesses((prev: any[]) => {
|
|
||||||
return prev.map((process: { itemId: string | undefined; }) => {
|
|
||||||
if (process.itemId === item.Id) {
|
|
||||||
return {
|
|
||||||
...process,
|
|
||||||
id: statistics.getSessionId().toString(),
|
|
||||||
progress: percentage,
|
|
||||||
speed: Math.max(speed, 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return process;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setProcesses, completeCallback]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
|
||||||
const cacheDir = await FileSystem.getInfoAsync(
|
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY
|
|
||||||
);
|
|
||||||
if (!cacheDir.exists) {
|
|
||||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
|
||||||
intermediates: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`;
|
|
||||||
|
|
||||||
if (!api) throw new Error("API is not defined");
|
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
|
||||||
|
|
||||||
// First lets save any important assets we want to present to the user offline
|
|
||||||
await onSaveAssets(api, item);
|
|
||||||
|
|
||||||
toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), {
|
|
||||||
action: {
|
|
||||||
label: "Go to download",
|
|
||||||
onClick: () => {
|
|
||||||
router.push("/downloads");
|
|
||||||
toast.dismiss();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job: JobStatus = {
|
|
||||||
id: "",
|
|
||||||
deviceId: "",
|
|
||||||
inputUrl: url,
|
|
||||||
item: item,
|
|
||||||
itemId: item.Id!,
|
|
||||||
outputPath: output,
|
|
||||||
progress: 0,
|
|
||||||
status: "downloading",
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
|
||||||
setProcesses((prev: any) => [...prev, job]);
|
|
||||||
|
|
||||||
await FFmpegKit.executeAsync(
|
|
||||||
createFFmpegCommand(url, output).join(" "),
|
|
||||||
(session: any) => completeCallback(session, item),
|
|
||||||
undefined,
|
|
||||||
(s: any) => statisticsCallback(s, item)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
console.error("Failed to remux:", error);
|
|
||||||
writeErrorLog(
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
|
||||||
Error: ${error.message}, Stack: ${error.stack}`
|
|
||||||
);
|
|
||||||
setProcesses((prev: any[]) => {
|
|
||||||
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
|
||||||
});
|
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[settings, processes, setProcesses, completeCallback, statisticsCallback]
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProcesses([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
|
||||||
};
|
|
||||||
@@ -3,6 +3,7 @@ import { type EventSubscription } from "expo-modules-core";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DownloadInfo,
|
||||||
DownloadMetadata,
|
DownloadMetadata,
|
||||||
OnCompleteEventPayload,
|
OnCompleteEventPayload,
|
||||||
OnErrorEventPayload,
|
OnErrorEventPayload,
|
||||||
@@ -14,16 +15,14 @@ import HlsDownloaderModule from "./src/HlsDownloaderModule";
|
|||||||
* Initiates an HLS download.
|
* Initiates an HLS download.
|
||||||
* @param id - A unique identifier for the download.
|
* @param id - A unique identifier for the download.
|
||||||
* @param url - The HLS stream URL.
|
* @param url - The HLS stream URL.
|
||||||
* @param assetTitle - A title for the asset.
|
* @param metadata - Additional metadata for the download.
|
||||||
* @param destination - The destination path for the downloaded asset.
|
|
||||||
*/
|
*/
|
||||||
function downloadHLSAsset(
|
function downloadHLSAsset(
|
||||||
id: string,
|
id: string,
|
||||||
url: string,
|
url: string,
|
||||||
assetTitle: string,
|
|
||||||
metadata: DownloadMetadata
|
metadata: DownloadMetadata
|
||||||
): void {
|
): void {
|
||||||
HlsDownloaderModule.downloadHLSAsset(id, url, assetTitle, metadata);
|
HlsDownloaderModule.downloadHLSAsset(id, url, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,15 +30,7 @@ function downloadHLSAsset(
|
|||||||
* Returns an array of downloads with additional fields:
|
* Returns an array of downloads with additional fields:
|
||||||
* id, progress, bytesDownloaded, bytesTotal, and state.
|
* id, progress, bytesDownloaded, bytesTotal, and state.
|
||||||
*/
|
*/
|
||||||
async function checkForExistingDownloads(): Promise<
|
async function checkForExistingDownloads(): Promise<DownloadInfo[]> {
|
||||||
Array<{
|
|
||||||
id: string;
|
|
||||||
progress: number;
|
|
||||||
bytesDownloaded: number;
|
|
||||||
bytesTotal: number;
|
|
||||||
state: "PENDING" | "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED";
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
return HlsDownloaderModule.checkForExistingDownloads();
|
return HlsDownloaderModule.checkForExistingDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import ExpoModulesCore
|
|||||||
|
|
||||||
public class HlsDownloaderModule: Module {
|
public class HlsDownloaderModule: Module {
|
||||||
var activeDownloads:
|
var activeDownloads:
|
||||||
[Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:]
|
[Int: (
|
||||||
|
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
|
||||||
|
startTime: Date
|
||||||
|
)] = [:]
|
||||||
|
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("HlsDownloader")
|
Name("HlsDownloader")
|
||||||
@@ -11,9 +14,9 @@ public class HlsDownloaderModule: Module {
|
|||||||
Events("onProgress", "onError", "onComplete")
|
Events("onProgress", "onError", "onComplete")
|
||||||
|
|
||||||
Function("downloadHLSAsset") {
|
Function("downloadHLSAsset") {
|
||||||
(providedId: String, url: String, assetTitle: String, metadata: [String: Any]?) -> Void in
|
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
|
||||||
print(
|
print(
|
||||||
"Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))"
|
"Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata))"
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let assetURL = URL(string: url) else {
|
guard let assetURL = URL(string: url) else {
|
||||||
@@ -42,7 +45,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
guard
|
guard
|
||||||
let task = downloadSession.makeAssetDownloadTask(
|
let task = downloadSession.makeAssetDownloadTask(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
assetTitle: assetTitle,
|
assetTitle: providedId,
|
||||||
assetArtworkData: nil,
|
assetArtworkData: nil,
|
||||||
options: nil
|
options: nil
|
||||||
)
|
)
|
||||||
@@ -59,7 +62,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delegate.taskIdentifier = task.taskIdentifier
|
delegate.taskIdentifier = task.taskIdentifier
|
||||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:])
|
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], Date())
|
||||||
self.sendEvent(
|
self.sendEvent(
|
||||||
"onProgress",
|
"onProgress",
|
||||||
[
|
[
|
||||||
@@ -67,6 +70,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
"progress": 0.0,
|
"progress": 0.0,
|
||||||
"state": "PENDING",
|
"state": "PENDING",
|
||||||
"metadata": metadata ?? [:],
|
"metadata": metadata ?? [:],
|
||||||
|
"startTime": Date().timeIntervalSince1970,
|
||||||
])
|
])
|
||||||
|
|
||||||
task.resume()
|
task.resume()
|
||||||
@@ -80,6 +84,7 @@ public class HlsDownloaderModule: Module {
|
|||||||
let task = pair.task
|
let task = pair.task
|
||||||
let delegate = pair.delegate
|
let delegate = pair.delegate
|
||||||
let metadata = pair.metadata
|
let metadata = pair.metadata
|
||||||
|
let startTime = pair.startTime
|
||||||
let downloaded = delegate.downloadedSeconds
|
let downloaded = delegate.downloadedSeconds
|
||||||
let total = delegate.totalSeconds
|
let total = delegate.totalSeconds
|
||||||
let progress = total > 0 ? downloaded / total : 0
|
let progress = total > 0 ? downloaded / total : 0
|
||||||
@@ -90,6 +95,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,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
return downloads
|
return downloads
|
||||||
@@ -136,6 +142,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
|
||||||
|
|
||||||
init(module: HlsDownloaderModule) {
|
init(module: HlsDownloaderModule) {
|
||||||
self.module = module
|
self.module = module
|
||||||
}
|
}
|
||||||
@@ -150,7 +157,9 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
||||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier]
|
||||||
|
let metadata = downloadInfo?.metadata ?? [:]
|
||||||
|
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
||||||
|
|
||||||
self.downloadedSeconds = downloaded
|
self.downloadedSeconds = downloaded
|
||||||
self.totalSeconds = total
|
self.totalSeconds = total
|
||||||
@@ -166,6 +175,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
"bytesTotal": total,
|
"bytesTotal": total,
|
||||||
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
|
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,13 +183,22 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
||||||
didFinishDownloadingTo location: URL
|
didFinishDownloadingTo location: URL
|
||||||
) {
|
) {
|
||||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier]
|
||||||
let folderName = providedId // using providedId as the folder name
|
let metadata = downloadInfo?.metadata ?? [:]
|
||||||
|
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
||||||
|
let folderName = providedId
|
||||||
do {
|
do {
|
||||||
guard let module = module else { return }
|
guard let module = module else { return }
|
||||||
let newLocation = try module.persistDownloadedFolder(
|
let newLocation = try module.persistDownloadedFolder(
|
||||||
originalLocation: location, folderName: folderName)
|
originalLocation: location, folderName: folderName)
|
||||||
|
|
||||||
|
if !metadata.isEmpty {
|
||||||
|
let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent(
|
||||||
|
"\(providedId).json")
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: metadata, options: .prettyPrinted)
|
||||||
|
try jsonData.write(to: metadataLocation)
|
||||||
|
}
|
||||||
|
|
||||||
module.sendEvent(
|
module.sendEvent(
|
||||||
"onComplete",
|
"onComplete",
|
||||||
[
|
[
|
||||||
@@ -187,6 +206,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
"location": newLocation.absoluteString,
|
"location": newLocation.absoluteString,
|
||||||
"state": "DONE",
|
"state": "DONE",
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
} catch {
|
} catch {
|
||||||
module?.sendEvent(
|
module?.sendEvent(
|
||||||
@@ -196,6 +216,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
"error": error.localizedDescription,
|
"error": error.localizedDescription,
|
||||||
"state": "FAILED",
|
"state": "FAILED",
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
|
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
|
||||||
@@ -203,7 +224,10 @@ 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 metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
let downloadInfo = module?.activeDownloads[task.taskIdentifier]
|
||||||
|
let metadata = downloadInfo?.metadata ?? [:]
|
||||||
|
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
||||||
|
|
||||||
module?.sendEvent(
|
module?.sendEvent(
|
||||||
"onError",
|
"onError",
|
||||||
[
|
[
|
||||||
@@ -211,6 +235,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
|||||||
"error": error.localizedDescription,
|
"error": error.localizedDescription,
|
||||||
"state": "FAILED",
|
"state": "FAILED",
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"startTime": startTime,
|
||||||
])
|
])
|
||||||
module?.removeDownload(with: taskIdentifier)
|
module?.removeDownload(with: taskIdentifier)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
export type DownloadState =
|
export type DownloadState =
|
||||||
| "PENDING"
|
| "PENDING"
|
||||||
| "DOWNLOADING"
|
| "DOWNLOADING"
|
||||||
@@ -7,7 +12,8 @@ export type DownloadState =
|
|||||||
| "STOPPED";
|
| "STOPPED";
|
||||||
|
|
||||||
export interface DownloadMetadata {
|
export interface DownloadMetadata {
|
||||||
Name: string;
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +48,7 @@ export type HlsDownloaderModuleEvents = {
|
|||||||
// Export a common interface that can be used by both HLS and regular downloads
|
// Export a common interface that can be used by both HLS and regular downloads
|
||||||
export interface DownloadInfo {
|
export interface DownloadInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
startTime?: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
state: DownloadState;
|
state: DownloadState;
|
||||||
bytesDownloaded?: number;
|
bytesDownloaded?: number;
|
||||||
|
|||||||
@@ -1,751 +0,0 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
|
||||||
import useDownloadHelper from "@/utils/download";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { useLog, writeToLog } from "@/utils/log";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import {
|
|
||||||
cancelAllJobs,
|
|
||||||
cancelJobById,
|
|
||||||
deleteDownloadItemInfoFromDiskTmp,
|
|
||||||
getAllJobsByDeviceId,
|
|
||||||
getDownloadItemInfoFromDiskTmp,
|
|
||||||
JobStatus,
|
|
||||||
} from "@/utils/optimize-server";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
|
||||||
import * as Application from "expo-application";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { FileInfo } from "expo-file-system";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { AppState, AppStateStatus, Platform } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
|
||||||
const BackGroundDownloader = !Platform.isTV
|
|
||||||
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
|
|
||||||
: null;
|
|
||||||
// import * as Notifications from "expo-notifications";
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
|
||||||
|
|
||||||
export type DownloadedItem = {
|
|
||||||
item: Partial<BaseItemDto>;
|
|
||||||
mediaSource: MediaSourceInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const processesAtom = atom<JobStatus[]>([]);
|
|
||||||
|
|
||||||
function onAppStateChange(status: AppStateStatus) {
|
|
||||||
focusManager.setFocused(status === "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadContext = createContext<ReturnType<
|
|
||||||
typeof useDownloadProvider
|
|
||||||
> | null>(null);
|
|
||||||
|
|
||||||
function useDownloadProvider() {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const router = useRouter();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
|
||||||
const { saveImage } = useImageStorage();
|
|
||||||
|
|
||||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
|
||||||
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const authHeader = useMemo(() => {
|
|
||||||
return api?.accessToken;
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const { data: downloadedFiles, refetch } = useQuery({
|
|
||||||
queryKey: ["downloadedItems"],
|
|
||||||
queryFn: getAllDownloadedItems,
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
|
||||||
|
|
||||||
return () => subscription.remove();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["jobs"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const deviceId = await getOrSetDeviceId();
|
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
|
|
||||||
if (
|
|
||||||
settings?.downloadMethod !== DownloadMethod.Optimized ||
|
|
||||||
!url ||
|
|
||||||
!deviceId ||
|
|
||||||
!authHeader
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadingProcesses = processes
|
|
||||||
.filter((p) => p.status === "downloading")
|
|
||||||
.filter((p) => jobs.some((j) => j.id === p.id));
|
|
||||||
|
|
||||||
const updatedProcesses = jobs.filter(
|
|
||||||
(j) => !downloadingProcesses.some((p) => p.id === j.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
setProcesses([...updatedProcesses, ...downloadingProcesses]);
|
|
||||||
|
|
||||||
for (let job of jobs) {
|
|
||||||
const process = processes.find((p) => p.id === job.id);
|
|
||||||
if (
|
|
||||||
process &&
|
|
||||||
process.status === "optimizing" &&
|
|
||||||
job.status === "completed"
|
|
||||||
) {
|
|
||||||
if (settings.autoDownload) {
|
|
||||||
startDownload(job);
|
|
||||||
} else {
|
|
||||||
toast.info(
|
|
||||||
t("home.downloads.toasts.item_is_ready_to_be_downloaded", {
|
|
||||||
item: job.item.Name,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
action: {
|
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
|
||||||
onClick: () => {
|
|
||||||
router.push("/downloads");
|
|
||||||
toast.dismiss();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: `${job.item.Name} is ready to be downloaded`,
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return jobs;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
refetchInterval: 2000,
|
|
||||||
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkIfShouldStartDownload = async () => {
|
|
||||||
if (processes.length === 0) return;
|
|
||||||
await BackGroundDownloader?.checkForExistingDownloads();
|
|
||||||
};
|
|
||||||
|
|
||||||
checkIfShouldStartDownload();
|
|
||||||
}, [settings, processes]);
|
|
||||||
|
|
||||||
const removeProcess = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const deviceId = await getOrSetDeviceId();
|
|
||||||
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
id,
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
|
||||||
);
|
|
||||||
|
|
||||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
|
||||||
|
|
||||||
const startDownload = useCallback(
|
|
||||||
async (process: JobStatus) => {
|
|
||||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
|
||||||
|
|
||||||
setProcesses((prev) =>
|
|
||||||
prev.map((p) =>
|
|
||||||
p.id === process.id
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
speed: undefined,
|
|
||||||
status: "downloading",
|
|
||||||
progress: 0,
|
|
||||||
}
|
|
||||||
: p
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
BackGroundDownloader?.setConfig({
|
|
||||||
isLogsEnabled: true,
|
|
||||||
progressInterval: 500,
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.info(
|
|
||||||
t("home.downloads.toasts.download_stated_for_item", {
|
|
||||||
item: process.item.Name,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
action: {
|
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
|
||||||
onClick: () => {
|
|
||||||
router.push("/downloads");
|
|
||||||
toast.dismiss();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
BackGroundDownloader?.download({
|
|
||||||
id: process.id,
|
|
||||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
|
||||||
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
setProcesses((prev) =>
|
|
||||||
prev.map((p) =>
|
|
||||||
p.id === process.id
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
speed: undefined,
|
|
||||||
status: "downloading",
|
|
||||||
progress: 0,
|
|
||||||
}
|
|
||||||
: p
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.progress((data) => {
|
|
||||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
|
||||||
setProcesses((prev) =>
|
|
||||||
prev.map((p) =>
|
|
||||||
p.id === process.id
|
|
||||||
? {
|
|
||||||
...p,
|
|
||||||
speed: undefined,
|
|
||||||
status: "downloading",
|
|
||||||
progress: percent,
|
|
||||||
}
|
|
||||||
: p
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.done(async (doneHandler) => {
|
|
||||||
await saveDownloadedItemInfo(
|
|
||||||
process.item,
|
|
||||||
doneHandler.bytesDownloaded
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
t("home.downloads.toasts.download_completed_for_item", {
|
|
||||||
item: process.item.Name,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
duration: 3000,
|
|
||||||
action: {
|
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
|
||||||
onClick: () => {
|
|
||||||
router.push("/downloads");
|
|
||||||
toast.dismiss();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
BackGroundDownloader.completeHandler(process.id);
|
|
||||||
removeProcess(process.id);
|
|
||||||
}, 1000);
|
|
||||||
})
|
|
||||||
.error(async (error) => {
|
|
||||||
removeProcess(process.id);
|
|
||||||
BackGroundDownloader.completeHandler(process.id);
|
|
||||||
let errorMsg = "";
|
|
||||||
if (error.errorCode === 1000) {
|
|
||||||
errorMsg = "No space left";
|
|
||||||
}
|
|
||||||
if (error.errorCode === 404) {
|
|
||||||
errorMsg = "File not found on server";
|
|
||||||
}
|
|
||||||
toast.error(
|
|
||||||
t("home.downloads.toasts.download_failed_for_item", {
|
|
||||||
item: process.item.Name,
|
|
||||||
error: errorMsg,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
|
||||||
error,
|
|
||||||
processDetails: {
|
|
||||||
id: process.id,
|
|
||||||
itemName: process.item.Name,
|
|
||||||
itemId: process.item.Id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.error("Error details:", {
|
|
||||||
errorCode: error.errorCode,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startBackgroundDownload = useCallback(
|
|
||||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
|
||||||
if (!api || !item.Id || !authHeader)
|
|
||||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileExtension = mediaSource.TranscodingContainer;
|
|
||||||
const deviceId = await getOrSetDeviceId();
|
|
||||||
|
|
||||||
await saveSeriesPrimaryImage(item);
|
|
||||||
const itemImage = getItemImage({
|
|
||||||
item,
|
|
||||||
api,
|
|
||||||
variant: "Primary",
|
|
||||||
quality: 90,
|
|
||||||
width: 500,
|
|
||||||
});
|
|
||||||
await saveImage(item.Id, itemImage?.uri);
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
settings?.optimizedVersionsServerUrl + "optimize-version",
|
|
||||||
{
|
|
||||||
url,
|
|
||||||
fileExtension,
|
|
||||||
deviceId,
|
|
||||||
itemId: item.Id,
|
|
||||||
item,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status !== 201) {
|
|
||||||
throw new Error("Failed to start optimization job");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
t("home.downloads.toasts.queued_item_for_optimization", {
|
|
||||||
item: item.Name,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
action: {
|
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
|
||||||
onClick: () => {
|
|
||||||
router.push("/downloads");
|
|
||||||
toast.dismiss();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
|
||||||
console.error("Error in startBackgroundDownload:", error);
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
console.error("Axios error details:", {
|
|
||||||
message: error.message,
|
|
||||||
response: error.response?.data,
|
|
||||||
status: error.response?.status,
|
|
||||||
headers: error.response?.headers,
|
|
||||||
});
|
|
||||||
toast.error(
|
|
||||||
t("home.downloads.toasts.failed_to_start_download_for_item", {
|
|
||||||
item: item.Name,
|
|
||||||
message: error.message,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (error.response) {
|
|
||||||
toast.error(
|
|
||||||
t("home.downloads.toasts.server_responded_with_status", {
|
|
||||||
statusCode: error.response.status,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (error.request) {
|
|
||||||
t("home.downloads.toasts.no_response_received_from_server");
|
|
||||||
} else {
|
|
||||||
toast.error("Error setting up the request");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Non-Axios error:", error);
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"home.downloads.toasts.failed_to_start_download_for_item_unexpected_error",
|
|
||||||
{ item: item.Name }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {
|
|
||||||
Promise.all([
|
|
||||||
deleteLocalFiles(),
|
|
||||||
removeDownloadedItemsFromStorage(),
|
|
||||||
cancelAllServerJobs(),
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
|
||||||
])
|
|
||||||
.then(() =>
|
|
||||||
toast.success(
|
|
||||||
t(
|
|
||||||
"home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((reason) => {
|
|
||||||
console.error("Failed to delete all files, folders, and jobs:", reason);
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
"home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const forEveryDocumentDirFile = async (
|
|
||||||
includeMMKV: boolean = true,
|
|
||||||
ignoreList: string[] = [],
|
|
||||||
callback: (file: FileInfo) => void
|
|
||||||
) => {
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
if (!baseDirectory) {
|
|
||||||
throw new Error("Base directory not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
|
||||||
for (const item of dirContents) {
|
|
||||||
// Exclude mmkv directory.
|
|
||||||
// Deleting this deletes all user information as well. Logout should handle this.
|
|
||||||
if (
|
|
||||||
(item == "mmkv" && !includeMMKV) ||
|
|
||||||
ignoreList.some((i) => item.includes(i))
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
|
||||||
.then((itemInfo) => {
|
|
||||||
if (itemInfo.exists && !itemInfo.isDirectory) {
|
|
||||||
callback(itemInfo);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteLocalFiles = async (): Promise<void> => {
|
|
||||||
await forEveryDocumentDirFile(false, [], (file) => {
|
|
||||||
console.warn("Deleting file", file.uri);
|
|
||||||
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDownloadedItemsFromStorage = async () => {
|
|
||||||
// delete any saved images first
|
|
||||||
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
|
|
||||||
.then(() => storage.delete("downloadedItems"))
|
|
||||||
.catch((reason) => {
|
|
||||||
console.error("Failed to remove downloadedItems from storage:", reason);
|
|
||||||
throw reason;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelAllServerJobs = async (): Promise<void> => {
|
|
||||||
if (!authHeader) {
|
|
||||||
throw new Error("No auth header available");
|
|
||||||
}
|
|
||||||
if (!settings?.optimizedVersionsServerUrl) {
|
|
||||||
console.error("No server URL configured");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceId = await getOrSetDeviceId();
|
|
||||||
if (!deviceId) {
|
|
||||||
throw new Error("Failed to get device ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await cancelAllJobs({
|
|
||||||
authHeader,
|
|
||||||
url: settings.optimizedVersionsServerUrl,
|
|
||||||
deviceId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to cancel all server jobs:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFile = async (id: string): Promise<void> => {
|
|
||||||
if (!id) {
|
|
||||||
console.error("Invalid file ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const directory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!directory) {
|
|
||||||
console.error("Document directory not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dirContents = await FileSystem.readDirectoryAsync(directory);
|
|
||||||
|
|
||||||
for (const item of dirContents) {
|
|
||||||
const itemNameWithoutExtension = item.split(".")[0];
|
|
||||||
if (itemNameWithoutExtension === id) {
|
|
||||||
const filePath = `${directory}${item}`;
|
|
||||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
if (downloadedItems) {
|
|
||||||
let items = JSON.parse(downloadedItems) as DownloadedItem[];
|
|
||||||
items = items.filter((item) => item.item.Id !== id);
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to delete file and storage entry for ID ${id}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteItems = async (items: BaseItemDto[]) => {
|
|
||||||
Promise.all(
|
|
||||||
items.map((i) => {
|
|
||||||
if (i.Id) return deleteFile(i.Id);
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
).then(() => successHapticFeedback());
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanCacheDirectory = async () => {
|
|
||||||
const cacheDir = await FileSystem.getInfoAsync(
|
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY
|
|
||||||
);
|
|
||||||
if (cacheDir.exists) {
|
|
||||||
const cachedFiles = await FileSystem.readDirectoryAsync(
|
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY
|
|
||||||
);
|
|
||||||
let position = 0;
|
|
||||||
const batchSize = 3;
|
|
||||||
|
|
||||||
// batching promise.all to avoid OOM
|
|
||||||
while (position < cachedFiles.length) {
|
|
||||||
const itemsForBatch = cachedFiles.slice(position, position + batchSize);
|
|
||||||
await Promise.all(
|
|
||||||
itemsForBatch.map(async (file) => {
|
|
||||||
const info = await FileSystem.getInfoAsync(
|
|
||||||
`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`
|
|
||||||
);
|
|
||||||
if (info.exists) {
|
|
||||||
await FileSystem.deleteAsync(info.uri, { idempotent: true });
|
|
||||||
return Promise.resolve(file);
|
|
||||||
}
|
|
||||||
return Promise.reject();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
position += batchSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
|
||||||
await Promise.all(
|
|
||||||
downloadedFiles
|
|
||||||
?.filter((file) => file.item.Type == type)
|
|
||||||
?.flatMap((file) => {
|
|
||||||
const promises = [];
|
|
||||||
if (type == "Episode" && file.item.SeriesId)
|
|
||||||
promises.push(deleteFile(file.item.SeriesId));
|
|
||||||
promises.push(deleteFile(file.item.Id!));
|
|
||||||
return promises;
|
|
||||||
}) || []
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const appSizeUsage = useMemo(async () => {
|
|
||||||
const sizes: number[] =
|
|
||||||
downloadedFiles?.map((d) => {
|
|
||||||
return getDownloadedItemSize(d.item.Id!!);
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
await forEveryDocumentDirFile(
|
|
||||||
true,
|
|
||||||
getAllDownloadedItems().map((d) => d.item.Id!!),
|
|
||||||
(file) => {
|
|
||||||
if (file.exists) {
|
|
||||||
sizes.push(file.size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sizes.reduce((sum, size) => sum + size, 0);
|
|
||||||
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
|
||||||
|
|
||||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
|
||||||
try {
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
if (downloadedItems) {
|
|
||||||
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
|
||||||
const item = items.find((i) => i.item.Id === itemId);
|
|
||||||
return item || null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to retrieve item with ID ${itemId}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllDownloadedItems(): DownloadedItem[] {
|
|
||||||
try {
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
if (downloadedItems) {
|
|
||||||
return JSON.parse(downloadedItems) as DownloadedItem[];
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve downloaded items:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
|
||||||
try {
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
let items: DownloadedItem[] = downloadedItems
|
|
||||||
? JSON.parse(downloadedItems)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
|
||||||
|
|
||||||
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
|
||||||
|
|
||||||
if (!data?.mediaSource)
|
|
||||||
throw new Error(
|
|
||||||
"Media source not found in tmp storage. Did you forget to save it before starting download?"
|
|
||||||
);
|
|
||||||
|
|
||||||
const newItem = { item, mediaSource: data.mediaSource };
|
|
||||||
|
|
||||||
if (existingItemIndex !== -1) {
|
|
||||||
items[existingItemIndex] = newItem;
|
|
||||||
} else {
|
|
||||||
items.push(newItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
|
||||||
storage.set("downloadedItemSize-" + item.Id, size.toString());
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
|
||||||
refetch();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"Failed to save downloaded item information with media source:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDownloadedItemSize(itemId: string): number {
|
|
||||||
const size = storage.getString("downloadedItemSize-" + itemId);
|
|
||||||
return size ? parseInt(size) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
processes,
|
|
||||||
startBackgroundDownload,
|
|
||||||
downloadedFiles,
|
|
||||||
deleteAllFiles,
|
|
||||||
deleteFile,
|
|
||||||
deleteItems,
|
|
||||||
saveDownloadedItemInfo,
|
|
||||||
removeProcess,
|
|
||||||
setProcesses,
|
|
||||||
startDownload,
|
|
||||||
getDownloadedItem,
|
|
||||||
deleteFileByType,
|
|
||||||
appSizeUsage,
|
|
||||||
getDownloadedItemSize,
|
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
|
||||||
cleanCacheDirectory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const downloadProviderValue = useDownloadProvider();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DownloadContext.Provider value={downloadProviderValue}>
|
|
||||||
{children}
|
|
||||||
</DownloadContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDownload() {
|
|
||||||
const context = useContext(DownloadContext);
|
|
||||||
if (context === null) {
|
|
||||||
throw new Error("useDownload must be used within a DownloadProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import RNBackgroundDownloader, {
|
|
||||||
DownloadTaskState,
|
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
addCompleteListener,
|
addCompleteListener,
|
||||||
addErrorListener,
|
addErrorListener,
|
||||||
@@ -10,66 +6,138 @@ import {
|
|||||||
checkForExistingDownloads,
|
checkForExistingDownloads,
|
||||||
downloadHLSAsset,
|
downloadHLSAsset,
|
||||||
} from "@/modules/hls-downloader";
|
} from "@/modules/hls-downloader";
|
||||||
|
import {
|
||||||
|
DownloadInfo,
|
||||||
|
DownloadMetadata,
|
||||||
|
} from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import RNBackgroundDownloader from "@kesha-antonov/react-native-background-downloader";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
import { useAtomValue } from "jotai";
|
||||||
import { parseBootXML, processStream } from "@/utils/hls/av-file-parser";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import download from "@/utils/profiles/download";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type DownloadOptionsData = {
|
||||||
|
selectedAudioStream: number;
|
||||||
|
selectedSubtitleStream: number;
|
||||||
|
selectedMediaSource: MediaSourceInfo;
|
||||||
|
maxBitrate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type DownloadContextType = {
|
type DownloadContextType = {
|
||||||
downloads: Record<string, DownloadInfo>;
|
downloads: Record<string, DownloadInfo>;
|
||||||
startDownload: (item: BaseItemDto, url: string) => Promise<void>;
|
startDownload: (
|
||||||
|
item: BaseItemDto,
|
||||||
|
url: string,
|
||||||
|
{
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
selectedMediaSource,
|
||||||
|
maxBitrate,
|
||||||
|
}: DownloadOptionsData
|
||||||
|
) => Promise<void>;
|
||||||
cancelDownload: (id: string) => void;
|
cancelDownload: (id: string) => void;
|
||||||
|
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
|
||||||
|
activeDownloads: DownloadInfo[];
|
||||||
|
downloadedFiles: DownloadedFileInfo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const DownloadContext = createContext<DownloadContextType | undefined>(
|
const DownloadContext = createContext<DownloadContextType | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistDownloadedFile = async (
|
/**
|
||||||
originalLocation: string,
|
* Marks a file as done by creating a file with the same name in the downloads directory.
|
||||||
fileName: string
|
* @param doneFile - The name of the file to mark as done.
|
||||||
) => {
|
*/
|
||||||
const destinationDir = `${FileSystem.documentDirectory}downloads/`;
|
const markFileAsDone = async (id: string) => {
|
||||||
const newLocation = `${destinationDir}${fileName}`;
|
await FileSystem.writeAsStringAsync(
|
||||||
|
`${FileSystem.documentDirectory}downloads/${id}-done`,
|
||||||
try {
|
"done"
|
||||||
// Ensure the downloads directory exists
|
);
|
||||||
await FileSystem.makeDirectoryAsync(destinationDir, {
|
|
||||||
intermediates: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move the file to its final destination
|
|
||||||
await FileSystem.moveAsync({
|
|
||||||
from: originalLocation,
|
|
||||||
to: newLocation,
|
|
||||||
});
|
|
||||||
|
|
||||||
return newLocation;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error persisting file:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the boot.xml file and parses it to get the streams
|
* Checks if a file is marked as done by checking if a file with the same name exists in the downloads directory.
|
||||||
|
* @param doneFile - The name of the file to check.
|
||||||
|
* @returns True if the file is marked as done, false otherwise.
|
||||||
*/
|
*/
|
||||||
const getBootStreams = async (path: string) => {
|
const isFileMarkedAsDone = async (id: string) => {
|
||||||
const b = `${path}/boot.xml`;
|
const fileUri = `${FileSystem.documentDirectory}downloads/${id}-done`;
|
||||||
const fileInfo = await FileSystem.getInfoAsync(b);
|
const fileInfo = await FileSystem.getInfoAsync(fileUri);
|
||||||
if (fileInfo.exists) {
|
return fileInfo.exists;
|
||||||
const boot = await FileSystem.readAsStringAsync(b, {
|
};
|
||||||
encoding: FileSystem.EncodingType.UTF8,
|
|
||||||
|
export type DownloadedFileInfo = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
metadata: DownloadMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listDownloadedFiles = 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[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file);
|
||||||
|
if (fileInfo.isDirectory) continue;
|
||||||
|
|
||||||
|
console.log(file);
|
||||||
|
|
||||||
|
const doneFile = await isFileMarkedAsDone(file.replace(".json", ""));
|
||||||
|
if (!doneFile) continue;
|
||||||
|
|
||||||
|
const fileContent = await FileSystem.readAsStringAsync(
|
||||||
|
downloadsDir + file.replace("-done", "")
|
||||||
|
);
|
||||||
|
|
||||||
|
downloaded.push({
|
||||||
|
id: file.replace(".json", ""),
|
||||||
|
path: downloadsDir + file.replace(".json", ""),
|
||||||
|
metadata: JSON.parse(fileContent) as DownloadMetadata,
|
||||||
});
|
});
|
||||||
return parseBootXML(boot);
|
|
||||||
} else {
|
|
||||||
console.log(`No boot.xml found in ${path}`);
|
|
||||||
}
|
}
|
||||||
|
console.log(downloaded);
|
||||||
|
return downloaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDownloadedItem = async (id: string) => {
|
||||||
|
const downloadsDir = FileSystem.documentDirectory + "downloads/";
|
||||||
|
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json");
|
||||||
|
if (!fileInfo.exists) return null;
|
||||||
|
const doneFile = await isFileMarkedAsDone(id);
|
||||||
|
if (!doneFile) return null;
|
||||||
|
const fileContent = await FileSystem.readAsStringAsync(
|
||||||
|
downloadsDir + id + ".json"
|
||||||
|
);
|
||||||
|
return JSON.parse(fileContent) as DownloadMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NativeDownloadProvider: React.FC<{
|
export const NativeDownloadProvider: React.FC<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}> = ({ children }) => {
|
}> = ({ children }) => {
|
||||||
const [downloads, setDownloads] = useState<Record<string, DownloadInfo>>({});
|
const [downloads, setDownloads] = useState<Record<string, DownloadInfo>>({});
|
||||||
|
const { saveImage } = useImageStorage();
|
||||||
|
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { data: downloadedFiles } = useQuery({
|
||||||
|
queryKey: ["downloadedFiles"],
|
||||||
|
queryFn: listDownloadedFiles,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize downloads from both HLS and regular downloads
|
// Initialize downloads from both HLS and regular downloads
|
||||||
@@ -83,6 +151,8 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
id: download.id,
|
id: download.id,
|
||||||
progress: download.progress,
|
progress: download.progress,
|
||||||
state: download.state,
|
state: download.state,
|
||||||
|
bytesDownloaded: download.bytesDownloaded,
|
||||||
|
bytesTotal: download.bytesTotal,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
@@ -98,17 +168,14 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
id: download.id,
|
id: download.id,
|
||||||
progress: download.bytesDownloaded / download.bytesTotal,
|
progress: download.bytesDownloaded / download.bytesTotal,
|
||||||
state: download.state,
|
state: download.state,
|
||||||
|
bytesDownloaded: download.bytesDownloaded,
|
||||||
|
bytesTotal: download.bytesTotal,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
setDownloads({ ...hlsDownloadStates, ...regularDownloadStates });
|
setDownloads({ ...hlsDownloadStates, ...regularDownloadStates });
|
||||||
|
|
||||||
console.log("Existing downloads:", {
|
|
||||||
...hlsDownloadStates,
|
|
||||||
...regularDownloadStates,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeDownloads();
|
initializeDownloads();
|
||||||
@@ -122,29 +189,23 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
id: download.id,
|
id: download.id,
|
||||||
progress: download.progress,
|
progress: download.progress,
|
||||||
state: download.state,
|
state: download.state,
|
||||||
|
bytesDownloaded: download.bytesDownloaded,
|
||||||
|
bytesTotal: download.bytesTotal,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeListener = addCompleteListener(async (payload) => {
|
const completeListener = addCompleteListener(async (payload) => {
|
||||||
console.log("Download complete to:", payload.location);
|
if (!payload?.id) throw new Error("No id found in payload");
|
||||||
|
|
||||||
// try {
|
try {
|
||||||
// if (payload?.id) {
|
rewriteM3U8Files(payload.location);
|
||||||
// const newLocation = await persistDownloadedFile(
|
markFileAsDone(payload.id);
|
||||||
// payload.location,
|
toast.success("Download complete ✅");
|
||||||
// payload.id
|
} catch (error) {
|
||||||
// );
|
console.error("Failed to persist file:", error);
|
||||||
// console.log("File successfully persisted to:", newLocation);
|
toast.error("Failed to download ❌");
|
||||||
// } else {
|
}
|
||||||
// console.log(
|
|
||||||
// "No filename in metadata, using original location",
|
|
||||||
// payload
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Failed to persist file:", error);
|
|
||||||
// }
|
|
||||||
|
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
const newDownloads = { ...prev };
|
const newDownloads = { ...prev };
|
||||||
@@ -171,14 +232,86 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startDownload = async (item: BaseItemDto, url: string) => {
|
useEffect(() => {
|
||||||
|
// Go through all the files in the folder downloads, check for the file id.json and id-done.json, if the id.json exists but id-done.json does not exist, then the download is still in done but not parsed. Parse it.
|
||||||
|
const checkForUnparsedDownloads = async () => {
|
||||||
|
let found = false;
|
||||||
|
const downloadsFolder = await FileSystem.getInfoAsync(
|
||||||
|
FileSystem.documentDirectory + "downloads"
|
||||||
|
);
|
||||||
|
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 markFileAsDone(id);
|
||||||
|
rewriteM3U8Files(
|
||||||
|
FileSystem.documentDirectory + "downloads/" + id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
toast.promise(p(), {
|
||||||
|
error: () => "Failed to download ❌",
|
||||||
|
loading: "Finishing up download...",
|
||||||
|
success: () => "Download complete ✅",
|
||||||
|
});
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkForUnparsedDownloads();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDownload = async (
|
||||||
|
item: BaseItemDto,
|
||||||
|
url: string,
|
||||||
|
data: DownloadOptionsData
|
||||||
|
) => {
|
||||||
if (!item.Id || !item.Name) throw new Error("Item ID or Name is missing");
|
if (!item.Id || !item.Name) throw new Error("Item ID or Name is missing");
|
||||||
const jobId = item.Id;
|
const jobId = item.Id;
|
||||||
|
|
||||||
|
const itemImage = getItemImage({
|
||||||
|
item,
|
||||||
|
api: api!,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: 0,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: data.selectedAudioStream,
|
||||||
|
maxStreamingBitrate: data.maxBitrate,
|
||||||
|
mediaSourceId: data.selectedMediaSource.Id,
|
||||||
|
subtitleStreamIndex: data.selectedSubtitleStream,
|
||||||
|
deviceProfile: download,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) throw new Error("Failed to get stream URL");
|
||||||
|
|
||||||
|
const { mediaSource } = res;
|
||||||
|
|
||||||
|
if (!mediaSource) throw new Error("Failed to get media source");
|
||||||
|
|
||||||
|
await saveImage(item.Id, itemImage?.uri);
|
||||||
|
|
||||||
if (url.includes("master.m3u8")) {
|
if (url.includes("master.m3u8")) {
|
||||||
// HLS download
|
// HLS download
|
||||||
downloadHLSAsset(jobId, url, item.Name, {
|
downloadHLSAsset(jobId, url, {
|
||||||
Name: item.Name,
|
item,
|
||||||
|
mediaSource,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Regular download
|
// Regular download
|
||||||
@@ -186,7 +319,7 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
const task = RNBackgroundDownloader.download({
|
const task = RNBackgroundDownloader.download({
|
||||||
id: jobId,
|
id: jobId,
|
||||||
url: url,
|
url: url,
|
||||||
destination: `${FileSystem.documentDirectory}${jobId}`,
|
destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`,
|
||||||
});
|
});
|
||||||
|
|
||||||
task.begin(({ expectedBytes }) => {
|
task.begin(({ expectedBytes }) => {
|
||||||
@@ -249,7 +382,14 @@ export const NativeDownloadProvider: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadContext.Provider
|
<DownloadContext.Provider
|
||||||
value={{ downloads, startDownload, cancelDownload }}
|
value={{
|
||||||
|
downloads,
|
||||||
|
startDownload,
|
||||||
|
cancelDownload,
|
||||||
|
downloadedFiles,
|
||||||
|
getDownloadedItem: getDownloadedItem,
|
||||||
|
activeDownloads: Object.values(downloads),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DownloadContext.Provider>
|
</DownloadContext.Provider>
|
||||||
|
|||||||
44
utils/movpkg-to-vlc/parse/boot.ts
Normal file
44
utils/movpkg-to-vlc/parse/boot.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
export interface Boot {
|
||||||
|
Version: string;
|
||||||
|
HLSMoviePackageType: string;
|
||||||
|
Streams: {
|
||||||
|
Stream: Stream[];
|
||||||
|
};
|
||||||
|
MasterPlaylist: {
|
||||||
|
NetworkURL: string;
|
||||||
|
};
|
||||||
|
DataItems: {
|
||||||
|
Directory: string;
|
||||||
|
DataItem: DataItem;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stream {
|
||||||
|
ID: string;
|
||||||
|
NetworkURL: string;
|
||||||
|
Path: string;
|
||||||
|
Complete: string; // "YES" or "NO"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataItem {
|
||||||
|
ID: string;
|
||||||
|
Category: string;
|
||||||
|
Name: string;
|
||||||
|
DescriptorPath: string;
|
||||||
|
DataPath: string;
|
||||||
|
Role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseBootXML(xml: string): Promise<Boot> {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "",
|
||||||
|
parseAttributeValue: true,
|
||||||
|
});
|
||||||
|
const jsonObj = parser.parse(xml);
|
||||||
|
const b = jsonObj.HLSMoviePackage as Boot;
|
||||||
|
console.log(b.Streams);
|
||||||
|
return jsonObj.HLSMoviePackage as Boot;
|
||||||
|
}
|
||||||
45
utils/movpkg-to-vlc/parse/streamInfoBoot.ts
Normal file
45
utils/movpkg-to-vlc/parse/streamInfoBoot.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
export interface StreamInfo {
|
||||||
|
Version: string;
|
||||||
|
Complete: string;
|
||||||
|
PeakBandwidth: number;
|
||||||
|
Compressable: string;
|
||||||
|
MediaPlaylist: MediaPlaylist;
|
||||||
|
Type: string;
|
||||||
|
MediaSegments: {
|
||||||
|
SEG: SEG[];
|
||||||
|
};
|
||||||
|
EvictionPolicy: string;
|
||||||
|
MediaBytesStored: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaPlaylist {
|
||||||
|
NetworkURL: string;
|
||||||
|
PathToLocalCopy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SEG {
|
||||||
|
Dur: number;
|
||||||
|
Len: number;
|
||||||
|
Off: number;
|
||||||
|
PATH: string;
|
||||||
|
SeqNum: number;
|
||||||
|
Tim: number;
|
||||||
|
URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseStreamInfoXml(xml: string): Promise<StreamInfo> {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "",
|
||||||
|
parseAttributeValue: true,
|
||||||
|
isArray: (tagName, jPath) => {
|
||||||
|
// Force SEG elements to always be an array
|
||||||
|
if (jPath === "StreamInfo.MediaSegments.SEG") return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const jsonObj = parser.parse(xml);
|
||||||
|
return jsonObj.StreamInfo as StreamInfo;
|
||||||
|
}
|
||||||
116
utils/movpkg-to-vlc/tools.ts
Normal file
116
utils/movpkg-to-vlc/tools.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { parseBootXML } from "./parse/boot";
|
||||||
|
import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot";
|
||||||
|
|
||||||
|
export async function rewriteM3U8Files(baseDir: string): Promise<void> {
|
||||||
|
const bootData = await loadBootData(baseDir);
|
||||||
|
if (!bootData) return;
|
||||||
|
|
||||||
|
const localPlaylistPaths = await processAllStreams(baseDir, bootData);
|
||||||
|
await updateMasterPlaylist(
|
||||||
|
`${baseDir}/Data/${bootData.DataItems.DataItem.DataPath}`,
|
||||||
|
localPlaylistPaths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBootData(baseDir: string): Promise<any | null> {
|
||||||
|
const bootPath = `${baseDir}/boot.xml`;
|
||||||
|
try {
|
||||||
|
const bootInfo = await FileSystem.getInfoAsync(bootPath);
|
||||||
|
if (!bootInfo.exists) throw new Error("boot.xml not found");
|
||||||
|
|
||||||
|
const bootXML = await FileSystem.readAsStringAsync(bootPath);
|
||||||
|
return parseBootXML(bootXML);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load boot.xml from ${baseDir}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAllStreams(
|
||||||
|
baseDir: string,
|
||||||
|
bootData: any
|
||||||
|
): Promise<string[]> {
|
||||||
|
const localPaths: string[] = [];
|
||||||
|
|
||||||
|
for (const stream of bootData.Streams.Stream) {
|
||||||
|
const streamDir = `${baseDir}/${stream.ID}`;
|
||||||
|
try {
|
||||||
|
const streamInfo = await processStream(streamDir);
|
||||||
|
if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) {
|
||||||
|
localPaths.push(
|
||||||
|
`${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Skipping stream ${stream.ID} due to error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMasterPlaylist(
|
||||||
|
masterPath: string,
|
||||||
|
localPlaylistPaths: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const masterContent = await FileSystem.readAsStringAsync(masterPath);
|
||||||
|
const updatedContent = updatePlaylistWithLocalSegments(
|
||||||
|
masterContent,
|
||||||
|
localPlaylistPaths
|
||||||
|
);
|
||||||
|
await FileSystem.writeAsStringAsync(masterPath, updatedContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating master playlist at ${masterPath}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlaylistWithLocalSegments(
|
||||||
|
content: string,
|
||||||
|
localPaths: string[]
|
||||||
|
): string {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length && index < localPaths.length; i++) {
|
||||||
|
if (lines[i].trim() && !lines[i].startsWith("#")) {
|
||||||
|
lines[i] = localPaths[index++];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processStream(
|
||||||
|
streamDir: string
|
||||||
|
): Promise<StreamInfo | null> {
|
||||||
|
const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamXML = await FileSystem.readAsStringAsync(streamInfoPath);
|
||||||
|
const streamInfo = await parseStreamInfoXml(streamXML);
|
||||||
|
|
||||||
|
const localM3u8RelPath = streamInfo.MediaPlaylist?.PathToLocalCopy;
|
||||||
|
if (!localM3u8RelPath) {
|
||||||
|
console.warn(`No local m3u8 specified in ${streamDir}; skipping.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m3u8Path = `${streamDir}/${localM3u8RelPath}`;
|
||||||
|
const m3u8Content = await FileSystem.readAsStringAsync(m3u8Path);
|
||||||
|
|
||||||
|
const localSegmentPaths = streamInfo.MediaSegments.SEG.map(
|
||||||
|
(seg) => `${streamDir}/${seg.PATH}`
|
||||||
|
);
|
||||||
|
const updatedContent = updatePlaylistWithLocalSegments(
|
||||||
|
m3u8Content,
|
||||||
|
localSegmentPaths
|
||||||
|
);
|
||||||
|
await FileSystem.writeAsStringAsync(m3u8Path, updatedContent);
|
||||||
|
|
||||||
|
return streamInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing stream at ${streamDir}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import axios from "axios";
|
|
||||||
import { writeToLog } from "./log";
|
|
||||||
import { DownloadedItem } from "@/providers/DownloadProvider";
|
|
||||||
import { MMKV } from "react-native-mmkv";
|
|
||||||
|
|
||||||
interface IJobInput {
|
|
||||||
deviceId?: string | null;
|
|
||||||
authHeader?: string | null;
|
|
||||||
url?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobStatus {
|
|
||||||
id: string;
|
|
||||||
status:
|
|
||||||
| "queued"
|
|
||||||
| "optimizing"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "cancelled"
|
|
||||||
| "downloading";
|
|
||||||
progress: number;
|
|
||||||
outputPath: string;
|
|
||||||
inputUrl: string;
|
|
||||||
deviceId: string;
|
|
||||||
itemId: string;
|
|
||||||
item: BaseItemDto;
|
|
||||||
speed?: number;
|
|
||||||
timestamp: Date;
|
|
||||||
base64Image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all jobs for a specific device.
|
|
||||||
*
|
|
||||||
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
|
|
||||||
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
|
|
||||||
* @param {string} params.authHeader - The authorization header for the API request.
|
|
||||||
* @param {string} params.url - The base URL for the API endpoint.
|
|
||||||
*
|
|
||||||
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
|
|
||||||
*
|
|
||||||
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
|
|
||||||
*/
|
|
||||||
export async function getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
}: IJobInput): Promise<JobStatus[]> {
|
|
||||||
const statusResponse = await axios.get(`${url}all-jobs`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (statusResponse.status !== 200) {
|
|
||||||
console.error(
|
|
||||||
statusResponse.status,
|
|
||||||
statusResponse.data,
|
|
||||||
statusResponse.statusText
|
|
||||||
);
|
|
||||||
throw new Error("Failed to fetch job status");
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusResponse.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ICancelJob {
|
|
||||||
authHeader: string;
|
|
||||||
url: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
id,
|
|
||||||
}: ICancelJob): Promise<boolean> {
|
|
||||||
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (statusResponse.status !== 200) {
|
|
||||||
throw new Error("Failed to cancel process");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
|
||||||
if (!deviceId) return false;
|
|
||||||
if (!authHeader) return false;
|
|
||||||
if (!url) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
}).then((jobs) => {
|
|
||||||
jobs.forEach((job) => {
|
|
||||||
cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
id: job.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Failed to cancel all jobs", error);
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches statistics for a specific device.
|
|
||||||
*
|
|
||||||
* @param {IJobInput} params - The parameters for the API request.
|
|
||||||
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
|
|
||||||
* @param {string} params.authHeader - The authorization header for the API request.
|
|
||||||
* @param {string} params.url - The base URL for the API endpoint.
|
|
||||||
*
|
|
||||||
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
|
|
||||||
*
|
|
||||||
* @throws {Error} Throws an error if any required parameter is missing.
|
|
||||||
*/
|
|
||||||
export async function getStatistics({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
deviceId,
|
|
||||||
}: IJobInput): Promise<any | null> {
|
|
||||||
if (!deviceId || !authHeader || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusResponse = await axios.get(`${url}statistics`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return statusResponse.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch statistics:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the download item info to disk - this data is used temporarily to fetch additional download information
|
|
||||||
* in combination with the optimize server. This is used to not have to send all item info to the optimize server.
|
|
||||||
*
|
|
||||||
* @param {BaseItemDto} item - The item to save.
|
|
||||||
* @param {MediaSourceInfo} mediaSource - The media source of the item.
|
|
||||||
* @param {string} url - The URL of the item.
|
|
||||||
* @return {boolean} A promise that resolves when the item info is saved.
|
|
||||||
*/
|
|
||||||
export function saveDownloadItemInfoToDiskTmp(
|
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo,
|
|
||||||
url: string
|
|
||||||
): boolean {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
|
|
||||||
const downloadInfo = JSON.stringify({
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
storage.set(`tmp_download_info_${item.Id}`, downloadInfo);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save download item info to disk:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the download item info from disk.
|
|
||||||
*
|
|
||||||
* @param {string} itemId - The ID of the item to retrieve.
|
|
||||||
* @return {{
|
|
||||||
* item: BaseItemDto;
|
|
||||||
* mediaSource: MediaSourceInfo;
|
|
||||||
* url: string;
|
|
||||||
* } | null} The retrieved download item info or null if not found.
|
|
||||||
*/
|
|
||||||
export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo;
|
|
||||||
url: string;
|
|
||||||
} | null {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
|
||||||
|
|
||||||
if (rawInfo) {
|
|
||||||
return JSON.parse(rawInfo);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve download item info from disk:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the download item info from disk.
|
|
||||||
*
|
|
||||||
* @param {string} itemId - The ID of the item to delete.
|
|
||||||
* @return {boolean} True if the item info was successfully deleted, false otherwise.
|
|
||||||
*/
|
|
||||||
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
storage.delete(`tmp_download_info_${itemId}`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete download item info from disk:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,28 +22,29 @@ export default {
|
|||||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
DirectPlayProfiles: [
|
// DirectPlayProfiles: [
|
||||||
{
|
// {
|
||||||
Type: MediaTypes.Video,
|
// Type: MediaTypes.Video,
|
||||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
// Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||||
VideoCodec:
|
// VideoCodec:
|
||||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
// "h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
// AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
Type: MediaTypes.Audio,
|
// Type: MediaTypes.Audio,
|
||||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
// Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||||
AudioCodec:
|
// AudioCodec:
|
||||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
// "mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
TranscodingProfiles: [
|
TranscodingProfiles: [
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
Context: "Streaming",
|
Context: "Streaming",
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Container: "ts",
|
Container: "ts,mp4,mkv,avi,mov,flv,m2ts,webm,ogv,3gp",
|
||||||
VideoCodec: "h264, hevc",
|
VideoCodec:
|
||||||
|
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||||
AudioCodec: "aac,mp3,ac3",
|
AudioCodec: "aac,mp3,ac3",
|
||||||
CopyTimestamps: false,
|
CopyTimestamps: false,
|
||||||
EnableSubtitlesInManifest: true,
|
EnableSubtitlesInManifest: true,
|
||||||
@@ -52,8 +53,9 @@ export default {
|
|||||||
Type: MediaTypes.Audio,
|
Type: MediaTypes.Audio,
|
||||||
Context: "Streaming",
|
Context: "Streaming",
|
||||||
Protocol: "http",
|
Protocol: "http",
|
||||||
Container: "mp3",
|
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||||
AudioCodec: "mp3",
|
AudioCodec:
|
||||||
|
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||||
MaxAudioChannels: "2",
|
MaxAudioChannels: "2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user