forked from Ninjalama/streamyfin_mirror
WIP
This commit is contained in:
@@ -64,12 +64,6 @@ export default function IndexLayout() {
|
||||
title: t("home.settings.settings_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/optimized-server/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
|
||||
@@ -22,7 +22,7 @@ import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
function migration_20241124(
|
||||
@@ -51,8 +51,7 @@ export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } =
|
||||
useDownload();
|
||||
const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
@@ -121,51 +120,49 @@ export default function page() {
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<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) => (
|
||||
<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={() =>
|
||||
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}
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Ionicons name='close' size={24} color='red' />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { getStatistics } from "@/utils/optimize-server";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newVal: string) => {
|
||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||
toast.error(t("home.settings.toasts.invalid_url"));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
||||
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl: updatedUrl,
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: updatedUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
toast.success(t("home.settings.toasts.connected"));
|
||||
} else {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = (newVal: string) => {
|
||||
saveMutation.mutate(newVal);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.downloads.optimized_server"),
|
||||
headerRight: () =>
|
||||
saveMutation.isPending ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
||||
>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.downloads.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<OptimizedServerForm
|
||||
value={optimizedVersionsServerUrl}
|
||||
onChangeValue={setOptimizedVersionsServerUrl}
|
||||
/>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ const page: React.FC = () => {
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
|
||||
@@ -611,7 +611,6 @@ export default function page() {
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
isVlc
|
||||
downloadedItem={downloadedItem}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
185
app/_layout.tsx
185
app/_layout.tsx
@@ -7,7 +7,6 @@ import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
writeToLog,
|
||||
} from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -137,16 +135,13 @@ if (!Platform.isTV) {
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url)
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
@@ -156,74 +151,6 @@ if (!Platform.isTV) {
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = `${url}download/${job.id}`;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
BackGroundDownloader.download({
|
||||
id: job.id,
|
||||
url: downloadUrl,
|
||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
console.log("TaskManager ~ Download started: ", job.id);
|
||||
})
|
||||
.done(() => {
|
||||
console.log("TaskManager ~ Download completed: ", job.id);
|
||||
saveDownloadedItemInfo(job.item);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
cancelJobById({
|
||||
authHeader: token,
|
||||
id: job.id,
|
||||
url: url,
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download completed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
})
|
||||
.error((error: any) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download failed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
});
|
||||
@@ -464,64 +391,62 @@ function Layout() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
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",
|
||||
},
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
<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>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { 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 { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
@@ -83,7 +82,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((_index: number) => {}, []);
|
||||
const handleSheetChanges = useCallback((_index: number) => { }, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
@@ -131,11 +130,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href),
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -212,7 +211,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Button } from "../Button";
|
||||
|
||||
@@ -25,7 +24,7 @@ const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
interface Props extends ViewProps { }
|
||||
|
||||
const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
@@ -66,10 +65,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { startDownload, removeProcess } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
console.log("process", process.progress);
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
@@ -84,9 +79,6 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
DownloadMethod,
|
||||
type 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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
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],
|
||||
@@ -37,69 +25,10 @@ export default function DownloadSettings({ ...props }) {
|
||||
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.download_method")}
|
||||
</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
|
||||
pluginSettings?.remuxConcurrentLimit?.locked
|
||||
}
|
||||
>
|
||||
<Stepper
|
||||
@@ -114,33 +43,6 @@ export default function DownloadSettings({ ...props }) {
|
||||
}
|
||||
/>
|
||||
</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")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, TextInput, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChangeValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export const OptimizedServerForm: React.FC<Props> = ({
|
||||
value,
|
||||
onChangeValue,
|
||||
}) => {
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'>
|
||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||
<Text className='mr-4'>{t("home.settings.downloads.url")}</Text>
|
||||
<TextInput
|
||||
className='text-white'
|
||||
placeholder={t("home.settings.downloads.server_url_placeholder")}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => onChangeValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.downloads.optimized_version_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.downloads.read_more_about_optimized_server")}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -90,7 +90,6 @@ interface Props {
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
isVlc?: boolean;
|
||||
downloadedItem?: DownloadedItem | null;
|
||||
}
|
||||
|
||||
const CONTROLS_TIMEOUT = 4000;
|
||||
@@ -120,7 +119,6 @@ export const Controls: FC<Props> = ({
|
||||
setAudioTrack,
|
||||
offline = false,
|
||||
isVlc = false,
|
||||
downloadedItem = null,
|
||||
}) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -143,7 +141,7 @@ export const Controls: FC<Props> = ({
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item, downloadedItem);
|
||||
} = useTrickplay(item);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
@@ -312,21 +310,17 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (downloadedItem) {
|
||||
if (offline) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId,
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) {
|
||||
return;
|
||||
}
|
||||
if (!gotoItem) return;
|
||||
goToItemCommon(gotoItem);
|
||||
},
|
||||
[goToItemCommon, api],
|
||||
@@ -533,9 +527,8 @@ export const Controls: FC<Props> = ({
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${
|
||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
||||
}`}
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
||||
}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -828,19 +821,19 @@ export const Controls: FC<Props> = ({
|
||||
/>
|
||||
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -20,6 +20,7 @@ export const usePlaybackManager = () => {
|
||||
const netInfo = useNetInfo();
|
||||
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
|
||||
|
||||
/** Whether the device is online. actually it's connected to the internet. */
|
||||
const isOnline = netInfo.isConnected;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import {
|
||||
calculateTrickplayTile,
|
||||
generateTrickplayUrl,
|
||||
getTrickplayInfo,
|
||||
} from "@/utils/trickplay";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
|
||||
interface TrickplayUrl {
|
||||
x: number;
|
||||
@@ -14,67 +13,47 @@ interface TrickplayUrl {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useTrickplay = (
|
||||
item: BaseItemDto,
|
||||
downloadedItem?: DownloadedItem | null,
|
||||
) => {
|
||||
/** Hook to handle trickplay logic for a given item. */
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200;
|
||||
const isOffline = useGlobalSearchParams().offline === "true";
|
||||
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||
|
||||
/** Generates the trickplay URL for the given item and sheet index.
|
||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||
const getTrickplayUrl = useCallback((item: BaseItemDto, sheetIndex: number) => {
|
||||
// If we are offline, we can use the downloaded item's trickplay data path
|
||||
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||
}
|
||||
return generateTrickplayUrl(item, sheetIndex);
|
||||
}, [trickplayInfo]);
|
||||
|
||||
const trickplayInfo = useMemo(() => {
|
||||
return getTrickplayInfo(item);
|
||||
}, [item]);
|
||||
|
||||
/** Calculates the trickplay URL for the current progress. */
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: number) => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!trickplayInfo ||
|
||||
!item.Id ||
|
||||
now - lastCalculationTime.current < throttleDelay
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!trickplayInfo || !item.Id || now - lastCalculationTime.current < throttleDelay) return;
|
||||
lastCalculationTime.current = now;
|
||||
|
||||
const { sheetIndex, x, y } = calculateTrickplayTile(
|
||||
progress,
|
||||
trickplayInfo,
|
||||
);
|
||||
|
||||
const url = generateTrickplayUrl(
|
||||
item.Id,
|
||||
trickplayInfo.resolution,
|
||||
sheetIndex,
|
||||
downloadedItem?.trickPlayData?.path,
|
||||
);
|
||||
|
||||
if (url) {
|
||||
setTrickPlayUrl({ x, y, url });
|
||||
}
|
||||
const { sheetIndex, x, y } = calculateTrickplayTile(progress, trickplayInfo);
|
||||
const url = getTrickplayUrl(item, sheetIndex);
|
||||
if (url) setTrickPlayUrl({ x, y, url });
|
||||
},
|
||||
[trickplayInfo, item, throttleDelay, downloadedItem],
|
||||
[trickplayInfo, item, throttleDelay, getTrickplayUrl],
|
||||
);
|
||||
|
||||
|
||||
/** Prefetches all the trickplay images for the item. */
|
||||
const prefetchAllTrickplayImages = useCallback(() => {
|
||||
if (!trickplayInfo || !item.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trickplayInfo || !item.Id) return;
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = generateTrickplayUrl(
|
||||
item.Id,
|
||||
trickplayInfo.resolution,
|
||||
index,
|
||||
downloadedItem?.trickPlayData?.path,
|
||||
);
|
||||
|
||||
if (url) {
|
||||
Image.prefetch(url);
|
||||
}
|
||||
const url = getTrickplayUrl(item, index);
|
||||
if (url) Image.prefetch(url);
|
||||
}
|
||||
}, [trickplayInfo, item, downloadedItem]);
|
||||
}, [trickplayInfo, item, getTrickplayUrl]);
|
||||
|
||||
return {
|
||||
trickPlayUrl,
|
||||
@@ -83,3 +62,103 @@ export const useTrickplay = (
|
||||
trickplayInfo,
|
||||
};
|
||||
};
|
||||
|
||||
export interface TrickplayData {
|
||||
Interval?: number;
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
Height?: number;
|
||||
Width?: number;
|
||||
ThumbnailCount?: number;
|
||||
}
|
||||
|
||||
export interface TrickplayInfo {
|
||||
resolution: string;
|
||||
aspectRatio: number;
|
||||
data: TrickplayData;
|
||||
totalImageSheets: number;
|
||||
}
|
||||
|
||||
/** Generates a trickplay URL based on the item, resolution, and sheet index. */
|
||||
export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
|
||||
const api = store.get(apiAtom);
|
||||
const resolution = getTrickplayInfo(item)?.resolution;
|
||||
if (!resolution || !api) return null;
|
||||
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the trickplay metadata from a BaseItemDto.
|
||||
* @param item The Jellyfin media item.
|
||||
* @returns Parsed trickplay information or null if not available.
|
||||
*/
|
||||
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
|
||||
if (!item.Id || !item.Trickplay) return null;
|
||||
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayDataForSource = item.Trickplay[mediaSourceId];
|
||||
|
||||
if (!trickplayDataForSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstResolution = Object.keys(trickplayDataForSource)[0];
|
||||
if (!firstResolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = trickplayDataForSource[firstResolution];
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!Width ||
|
||||
!Height ||
|
||||
!item.RunTimeTicks
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||
const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
|
||||
|
||||
return {
|
||||
resolution: firstResolution,
|
||||
aspectRatio: Width / Height,
|
||||
data,
|
||||
totalImageSheets,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the specific image sheet and tile offset for a given time.
|
||||
* @param progressTicks The current playback time in ticks.
|
||||
* @param trickplayInfo The parsed trickplay information object.
|
||||
* @returns An object with the image sheet index, and the X/Y coordinates for the tile.
|
||||
*/
|
||||
const calculateTrickplayTile = (
|
||||
progressTicks: number,
|
||||
trickplayInfo: TrickplayInfo,
|
||||
) => {
|
||||
const { data } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight } = data;
|
||||
|
||||
if (!Interval || !TileWidth || !TileHeight) {
|
||||
throw new Error("Invalid trickplay data provided to calculateTile");
|
||||
}
|
||||
|
||||
const currentTimeMs = Math.max(0, ticksToMs(progressTicks));
|
||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const sheetIndex = Math.floor(currentTile / tilesPerSheet);
|
||||
const tileIndexInSheet = currentTile % tilesPerSheet;
|
||||
|
||||
const x = tileIndexInSheet % TileWidth;
|
||||
const y = Math.floor(tileIndexInSheet / TileWidth);
|
||||
|
||||
return { sheetIndex, x, y };
|
||||
};
|
||||
|
||||
@@ -28,9 +28,9 @@ import useDownloadHelper from "@/utils/download";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { type JobStatus } from "@/utils/optimize-server";
|
||||
import { JobStatus } from "./Downloads/types";
|
||||
import { fetchAndParseSegments } from "@/utils/segments";
|
||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||
import { getTrickplayInfo, generateTrickplayUrl } from "@/hooks/useTrickplay";
|
||||
import { Bitrate } from "../components/BitrateSelector";
|
||||
import {
|
||||
DownloadedItem,
|
||||
@@ -167,9 +167,9 @@ function useDownloadProvider() {
|
||||
prev.map((p) =>
|
||||
p.id === processId
|
||||
? {
|
||||
...p,
|
||||
...newStatus,
|
||||
}
|
||||
...p,
|
||||
...newStatus,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
@@ -268,18 +268,12 @@ function useDownloadProvider() {
|
||||
}
|
||||
|
||||
const filename = generateFilename(item);
|
||||
const trickplayDir = `${
|
||||
FileSystem.documentDirectory
|
||||
}${filename}_trickplay/`;
|
||||
const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`;
|
||||
await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true });
|
||||
let totalSize = 0;
|
||||
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = generateTrickplayUrl(
|
||||
item.Id,
|
||||
trickplayInfo.resolution,
|
||||
index,
|
||||
);
|
||||
const url = generateTrickplayUrl(item, index);
|
||||
if (!url) continue;
|
||||
const destination = `${trickplayDir}${index}.jpg`;
|
||||
try {
|
||||
@@ -289,10 +283,7 @@ function useDownloadProvider() {
|
||||
totalSize += fileInfo.size;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to download trickplay image ${index} for item ${item.Id}`,
|
||||
e,
|
||||
);
|
||||
console.error(`Failed to download trickplay image ${index} for item ${item.Id}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +564,7 @@ function useDownloadProvider() {
|
||||
await FileSystem.deleteAsync(item.videoFilePath, { idempotent: true });
|
||||
}
|
||||
|
||||
await saveDownloadsDatabase({ movies: {}, series: {} });
|
||||
await saveDownloadsDatabase({ movies: {}, series: {} } as DownloadsDatabase);
|
||||
toast.success(
|
||||
t(
|
||||
"home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -52,13 +53,7 @@ export interface DownloadedItem {
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** The user data for the item. */
|
||||
offlineUserData: {
|
||||
audioStreamIndex: number;
|
||||
subtitleStreamIndex: number;
|
||||
};
|
||||
syncStatus: SyncStatus;
|
||||
lastSyncedAt: string;
|
||||
serverEtag?: string;
|
||||
userData: UserData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,3 +90,23 @@ export interface DownloadsDatabase {
|
||||
/** A map of series IDs to their downloaded series data. */
|
||||
series: Record<string, DownloadedSeries>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the status of a download job.
|
||||
*/
|
||||
export type JobStatus = {
|
||||
id: string;
|
||||
inputUrl: string;
|
||||
item: BaseItemDto;
|
||||
itemId: string;
|
||||
deviceId: string;
|
||||
progress: number;
|
||||
status: "downloading" | "paused" | "error" | "pending" | "completed";
|
||||
timestamp: Date;
|
||||
mediaSource: MediaSourceInfo;
|
||||
maxBitrate: Bitrate;
|
||||
bytesDownloaded?: number;
|
||||
lastProgressUpdateTime?: Date;
|
||||
speed?: number;
|
||||
estimatedTotalSizeBytes?: number;
|
||||
};
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
||||
import type React from "react";
|
||||
import { createContext } from "react";
|
||||
|
||||
const JobQueueContext = createContext(null);
|
||||
|
||||
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
useJobProcessor();
|
||||
|
||||
return (
|
||||
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { processesAtom } from "@/providers/DownloadProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
|
||||
@@ -81,7 +81,6 @@ export type DefaultLanguageOption = {
|
||||
|
||||
export enum DownloadMethod {
|
||||
Remux = "remux",
|
||||
Optimized = "optimized",
|
||||
}
|
||||
|
||||
export type Home = {
|
||||
@@ -154,7 +153,6 @@ export type Settings = {
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||
forwardSkipTime: number;
|
||||
rewindSkipTime: number;
|
||||
optimizedVersionsServerUrl?: string | null;
|
||||
downloadMethod: DownloadMethod;
|
||||
autoDownload: boolean;
|
||||
showCustomMenuLinks: boolean;
|
||||
@@ -211,7 +209,6 @@ const defaultValues: Settings = {
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||
forwardSkipTime: 30,
|
||||
rewindSkipTime: 10,
|
||||
optimizedVersionsServerUrl: null,
|
||||
downloadMethod: DownloadMethod.Remux,
|
||||
autoDownload: false,
|
||||
showCustomMenuLinks: false,
|
||||
@@ -222,7 +219,7 @@ const defaultValues: Settings = {
|
||||
jellyseerrServerUrl: undefined,
|
||||
hiddenLibraries: [],
|
||||
enableH265ForChromecast: false,
|
||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
||||
defaultPlayer: VideoPlayer.VLC_4, // ios-only setting. does not matter what this is for android
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
};
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import axios from "axios";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import { writeToLog } from "./log";
|
||||
|
||||
interface IJobInput {
|
||||
deviceId?: string | null;
|
||||
authHeader?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export type JobStatus = {
|
||||
id: string;
|
||||
inputUrl: string;
|
||||
item: BaseItemDto;
|
||||
itemId: string;
|
||||
deviceId: string;
|
||||
progress: number;
|
||||
status: "downloading" | "paused" | "error" | "pending";
|
||||
timestamp: Date;
|
||||
mediaSource: MediaSourceInfo;
|
||||
maxBitrate: Bitrate;
|
||||
bytesDownloaded?: number;
|
||||
lastProgressUpdateTime?: Date;
|
||||
speed?: number;
|
||||
estimatedTotalSizeBytes?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
for (const job of jobs) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "./store";
|
||||
import { ticksToMs } from "./time";
|
||||
|
||||
export interface TrickplayData {
|
||||
Interval?: number;
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
Height?: number;
|
||||
Width?: number;
|
||||
ThumbnailCount?: number;
|
||||
}
|
||||
|
||||
export interface TrickplayInfo {
|
||||
resolution: string;
|
||||
aspectRatio: number;
|
||||
data: TrickplayData;
|
||||
totalImageSheets: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the trickplay metadata from a BaseItemDto.
|
||||
* @param item The Jellyfin media item.
|
||||
* @returns Parsed trickplay information or null if not available.
|
||||
*/
|
||||
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
|
||||
if (!item.Id || !item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayDataForSource = item.Trickplay[mediaSourceId];
|
||||
|
||||
if (!trickplayDataForSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstResolution = Object.keys(trickplayDataForSource)[0];
|
||||
if (!firstResolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = trickplayDataForSource[firstResolution];
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!Width ||
|
||||
!Height ||
|
||||
!item.RunTimeTicks
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||
const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
|
||||
|
||||
return {
|
||||
resolution: firstResolution,
|
||||
aspectRatio: Width / Height,
|
||||
data,
|
||||
totalImageSheets,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the specific image sheet and tile offset for a given time.
|
||||
* @param progressTicks The current playback time in ticks.
|
||||
* @param trickplayInfo The parsed trickplay information object.
|
||||
* @returns An object with the image sheet index, and the X/Y coordinates for the tile.
|
||||
*/
|
||||
export const calculateTrickplayTile = (
|
||||
progressTicks: number,
|
||||
trickplayInfo: TrickplayInfo,
|
||||
) => {
|
||||
const { data } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight } = data;
|
||||
|
||||
if (!Interval || !TileWidth || !TileHeight) {
|
||||
throw new Error("Invalid trickplay data provided to calculateTile");
|
||||
}
|
||||
|
||||
const currentTimeMs = Math.max(0, ticksToMs(progressTicks));
|
||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const sheetIndex = Math.floor(currentTile / tilesPerSheet);
|
||||
const tileIndexInSheet = currentTile % tilesPerSheet;
|
||||
|
||||
const x = tileIndexInSheet % TileWidth;
|
||||
const y = Math.floor(tileIndexInSheet / TileWidth);
|
||||
|
||||
return { sheetIndex, x, y };
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a trickplay URL based on the item ID, resolution, sheet index, and offline path.
|
||||
* @param itemId The ID of the media item.
|
||||
* @param resolution The resolution of the trickplay image.
|
||||
* @param sheetIndex The index of the image sheet.
|
||||
* @param offlinePath The path to the offline trickplay images.
|
||||
* @returns The URL of the trickplay image.
|
||||
*/
|
||||
export const generateTrickplayUrl = (
|
||||
itemId: string,
|
||||
resolution: string,
|
||||
sheetIndex: number,
|
||||
offlinePath?: string,
|
||||
): string | null => {
|
||||
if (offlinePath) {
|
||||
return `${offlinePath}${sheetIndex}.jpg`;
|
||||
}
|
||||
const api = store.get(apiAtom);
|
||||
if (api) {
|
||||
return `${api.basePath}/Videos/${itemId}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user