forked from Ninjalama/streamyfin_mirror
wip
This commit is contained in:
19
app.json
19
app.json
@@ -49,13 +49,6 @@
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
@@ -69,18 +62,6 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "14.0"
|
||||
},
|
||||
"android": {
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
|
||||
@@ -17,18 +17,6 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : 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: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
|
||||
@@ -217,21 +217,6 @@ export default function index() {
|
||||
return (
|
||||
<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-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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -245,11 +244,6 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<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} />
|
||||
</View>
|
||||
|
||||
|
||||
@@ -7,12 +7,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles } = useFiles();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -39,23 +37,12 @@ export default function settings() {
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -221,14 +220,6 @@ const page: React.FC = () => {
|
||||
<MoviesTitleHeader item={item} />
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
</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 className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
|
||||
@@ -101,15 +101,6 @@ function Layout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
@@ -31,7 +30,7 @@
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"expo": "~51.0.27",
|
||||
"expo": "~51.0.28",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
@@ -43,7 +42,7 @@
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "~3.5.21",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
@@ -51,7 +50,6 @@
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.22",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
|
||||
Reference in New Issue
Block a user