mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 08:44:41 +01:00
wip
This commit is contained in:
19
app.json
19
app.json
@@ -49,13 +49,6 @@
|
|||||||
"@react-native-tvos/config-tv",
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -69,18 +62,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"expo-build-properties",
|
|
||||||
{
|
|
||||||
"ios": {
|
|
||||||
"deploymentTarget": "14.0"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"minSdkVersion": 24,
|
|
||||||
"usesCleartextTraffic": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-screen-orientation",
|
"expo-screen-orientation",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,18 +17,6 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
marginRight: Platform.OS === "android" ? 17 : 0,
|
|
||||||
}}
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Feather name="download" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
|
|||||||
@@ -217,21 +217,6 @@ export default function index() {
|
|||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
No worries, you can still watch{"\n"}downloaded content.
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Go to downloads
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
|
|
||||||
const { data: downloadedFiles, isLoading } = useQuery({
|
|
||||||
queryKey: ["downloaded_files", process?.item.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
) as BaseItemDto[],
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const movies = useMemo(
|
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
|
||||||
[downloadedFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
|
||||||
const series: { [key: string]: BaseItemDto[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
|
||||||
series[e.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const eta = useMemo(() => {
|
|
||||||
const length = process?.item?.RunTimeTicks || 0;
|
|
||||||
|
|
||||||
if (!process?.speed || !process?.progress) return "";
|
|
||||||
|
|
||||||
const timeLeft =
|
|
||||||
(length - length * (process.progress / 100)) / process.speed;
|
|
||||||
|
|
||||||
return formatNumber(timeLeft / 10000);
|
|
||||||
}, [process]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView>
|
|
||||||
<View className="px-4 py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4">
|
|
||||||
<View>
|
|
||||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{queue.map((q) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/${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"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
|
||||||
{process?.item ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{process.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{process.item.Type}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.progress.toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.speed?.toFixed(2)}x
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<Text className="text-xs">ETA {eta}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProcess(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
width: process.progress
|
|
||||||
? `${Math.max(5, process.progress)}%`
|
|
||||||
: "5%",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<Text className="opacity-50">No active downloads</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2">
|
|
||||||
<Text className="text-2xl font-bold">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>
|
|
||||||
{movies?.map((item: BaseItemDto) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
|
||||||
<MovieCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default downloads;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
|
|
||||||
* @param {number} num - The number to format
|
|
||||||
*
|
|
||||||
* @returns {string} - The formatted string
|
|
||||||
*/
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
const minutes = Math.floor(num / 60000);
|
|
||||||
const seconds = ((num % 60000) / 1000).toFixed(0);
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
fullScreenAtom,
|
fullScreenAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -245,11 +244,6 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center mb-2">
|
<View className="flex flex-row justify-between items-center mb-2">
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useFiles();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -39,23 +37,12 @@ export default function settings() {
|
|||||||
<Button color="black" onPress={logout}>
|
<Button color="black" onPress={logout}>
|
||||||
Log out
|
Log out
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await clearLogs();
|
await clearLogs();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -221,14 +220,6 @@ const page: React.FC = () => {
|
|||||||
<MoviesTitleHeader item={item} />
|
<MoviesTitleHeader item={item} />
|
||||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col p-4 w-full">
|
<View className="flex flex-col p-4 w-full">
|
||||||
<View className="flex flex-row items-center space-x-2 w-full">
|
<View className="flex flex-row items-center space-x-2 w-full">
|
||||||
|
|||||||
@@ -101,15 +101,6 @@ function Layout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/downloads"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "Downloads",
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/items/[id]"
|
name="(auth)/items/[id]"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import ProgressCircle from "./ProgressCircle";
|
|
||||||
|
|
||||||
type DownloadProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
playbackUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [process] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
|
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
|
||||||
|
|
||||||
const { data: playbackInfo, isLoading } = useQuery({
|
|
||||||
queryKey: ["playbackInfo", item.Id],
|
|
||||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
|
||||||
queryKey: ["downloaded", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id) return false;
|
|
||||||
|
|
||||||
const data: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.some((d) => d.Id === item.Id);
|
|
||||||
},
|
|
||||||
enabled: !!item.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading || isLoadingDownloaded) {
|
|
||||||
return (
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
|
||||||
return (
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process && process?.item.Id === item.Id) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={process.progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queue.some((i) => i.id === item.Id)) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloaded) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
queueActions.enqueue(queue, setQueue, {
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => {
|
|
||||||
await startRemuxing();
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "../CurrentlyPlayingBar";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EpisodeCard component displays an episode with context menu options.
|
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
|
||||||
*/
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useFiles();
|
|
||||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening the file for playback.
|
|
||||||
*/
|
|
||||||
const handleOpenFile = useCallback(async () => {
|
|
||||||
setCurrentlyPlaying({
|
|
||||||
item,
|
|
||||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true)
|
|
||||||
setFullscreen(true);
|
|
||||||
}, [item, setCurrentlyPlaying, settings]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const contextMenuOptions = [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
onSelect: handleDeleteFile,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions
|
|
||||||
collisionPadding={10}
|
|
||||||
loop={false}
|
|
||||||
>
|
|
||||||
{contextMenuOptions.map((option) => (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key={option.label}
|
|
||||||
onSelect={option.onSelect}
|
|
||||||
destructive={option.destructive}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "../CurrentlyPlayingBar";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MovieCard component displays a movie with context menu options.
|
|
||||||
* @param {MovieCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
|
||||||
*/
|
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useFiles();
|
|
||||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening the file for playback.
|
|
||||||
*/
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
console.log("Open movie file", item.Name);
|
|
||||||
setCurrentlyPlaying({
|
|
||||||
item,
|
|
||||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
|
||||||
setFullscreen(true);
|
|
||||||
}
|
|
||||||
}, [item, setCurrentlyPlaying, setPlaying, settings]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const contextMenuOptions = [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
onSelect: handleDeleteFile,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content>
|
|
||||||
{contextMenuOptions.map((option) => (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key={option.label}
|
|
||||||
onSelect={option.onSelect}
|
|
||||||
destructive={option.destructive}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|
||||||
const groupBySeason = useMemo(() => {
|
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!seasons[item.SeasonName!]) {
|
|
||||||
seasons[item.SeasonName!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-row items-center justify-between">
|
|
||||||
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2">TV-Series</Text>
|
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
|
||||||
<View key={seasonIndex}>
|
|
||||||
<Text className="mb-2 font-semibold">
|
|
||||||
{seasonItems[0].SeasonName}
|
|
||||||
</Text>
|
|
||||||
{seasonItems.map((item, index) => (
|
|
||||||
<View className="mb-2" key={index}>
|
|
||||||
<EpisodeCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for downloading media using the Jellyfin API.
|
|
||||||
*
|
|
||||||
* @param api - The Jellyfin API instance
|
|
||||||
* @param userId - The user ID
|
|
||||||
* @returns An object with download-related functions and state
|
|
||||||
*/
|
|
||||||
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadMedia = useCallback(
|
|
||||||
async (item: BaseItemDto | null): Promise<boolean> => {
|
|
||||||
if (!item?.Id || !api || !userId) {
|
|
||||||
setError("Invalid item or API");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
|
||||||
setError(null);
|
|
||||||
setProgress({ item, progress: 0 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filename = item.Id;
|
|
||||||
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
|
||||||
const url = `${api.basePath}/Items/${item.Id}/File`;
|
|
||||||
|
|
||||||
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
|
||||||
url,
|
|
||||||
fileUri,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(downloadProgress) => {
|
|
||||||
const currentProgress =
|
|
||||||
downloadProgress.totalBytesWritten /
|
|
||||||
downloadProgress.totalBytesExpectedToWrite;
|
|
||||||
setProgress({ item, progress: currentProgress * 100 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await downloadResumableRef.current.downloadAsync();
|
|
||||||
|
|
||||||
if (!res?.uri) {
|
|
||||||
throw new Error("Download failed: No URI returned");
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error downloading media:", error);
|
|
||||||
setError("Failed to download media");
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, userId, setProgress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (): Promise<void> => {
|
|
||||||
if (!downloadResumableRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadResumableRef.current.pauseAsync();
|
|
||||||
setIsDownloading(false);
|
|
||||||
setError("Download cancelled");
|
|
||||||
setProgress(null);
|
|
||||||
downloadResumableRef.current = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cancelling download:", error);
|
|
||||||
setError("Failed to cancel download");
|
|
||||||
}
|
|
||||||
}, [setProgress]);
|
|
||||||
|
|
||||||
return { downloadMedia, isDownloading, error, cancelDownload };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) ?? "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((file) => file.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing downloaded files.
|
|
||||||
* @returns An object with functions to delete individual files and all files.
|
|
||||||
*/
|
|
||||||
export const useFiles = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all downloaded files and clears the download record.
|
|
||||||
*/
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {
|
|
||||||
const directoryUri = FileSystem.documentDirectory;
|
|
||||||
if (!directoryUri) {
|
|
||||||
console.error("Document directory is undefined");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
|
|
||||||
await Promise.all(
|
|
||||||
fileNames.map((item) =>
|
|
||||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
|
||||||
idempotent: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await AsyncStorage.removeItem("downloaded_files");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete all files:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a specific file and updates the download record.
|
|
||||||
* @param id - The ID of the file to delete.
|
|
||||||
*/
|
|
||||||
const deleteFile = async (id: string): Promise<void> => {
|
|
||||||
if (!id) {
|
|
||||||
console.error("Invalid file ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FileSystem.deleteAsync(
|
|
||||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
|
||||||
{ idempotent: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentFiles = await getDownloadedFiles();
|
|
||||||
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
|
|
||||||
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete file with ID ${id}:`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deleteFile, deleteAllFiles };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the list of downloaded files from AsyncStorage.
|
|
||||||
* @returns An array of BaseItemDto objects representing downloaded files.
|
|
||||||
*/
|
|
||||||
async function getDownloadedFiles(): Promise<BaseItemDto[]> {
|
|
||||||
try {
|
|
||||||
const filesJson = await AsyncStorage.getItem("downloaded_files");
|
|
||||||
return filesJson ? JSON.parse(filesJson) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve downloaded files:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = (url: string, item: BaseItemDto) => {
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
|
|
||||||
if (!item.Id || !item.Name) {
|
|
||||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
|
||||||
throw new Error("Item must have an Id and Name");
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
|
||||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
|
||||||
|
|
||||||
const startRemuxing = useCallback(async () => {
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
|
||||||
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;
|
|
||||||
|
|
||||||
setProgress((prev) =>
|
|
||||||
prev?.item.Id === item.Id!
|
|
||||||
? { ...prev, progress: percentage, speed }
|
|
||||||
: prev,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
FFmpegKit.executeAsync(command, async (session) => {
|
|
||||||
try {
|
|
||||||
const returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
} else if (returnCode.isValueError()) {
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
|
||||||
} else if (returnCode.isValueCancel()) {
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(null);
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to remux:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
setProgress(null);
|
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
|
||||||
}
|
|
||||||
}, [output, item, command, setProgress]);
|
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProgress(null);
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
}, [item.Name, setProgress]);
|
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`Failed to update downloaded files for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1673
package-lock.json
generated
1673
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@
|
|||||||
"preset": "jest-expo"
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
"@gorhom/bottom-sheet": "^4",
|
"@gorhom/bottom-sheet": "^4",
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
"@types/lodash": "^4.17.7",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.3",
|
"axios": "^1.7.3",
|
||||||
"expo": "~51.0.27",
|
"expo": "~51.0.28",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
"expo-navigation-bar": "~3.0.7",
|
"expo-navigation-bar": "~3.0.7",
|
||||||
"expo-router": "~3.5.21",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-screen-orientation": "~7.0.5",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
"expo-splash-screen": "~0.27.5",
|
"expo-splash-screen": "~0.27.5",
|
||||||
@@ -51,7 +50,6 @@
|
|||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-updates": "~0.25.22",
|
"expo-updates": "~0.25.22",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
|
|||||||
Reference in New Issue
Block a user