merge with master

This commit is contained in:
Alex Kim
2024-12-06 02:53:28 +11:00
16 changed files with 495 additions and 840 deletions

View File

@@ -1,12 +1,13 @@
import {Text} from "@/components/common/Text";
import {useDownload} from "@/providers/DownloadProvider";
import {router, useLocalSearchParams, useNavigation} from "expo-router";
import React, {useEffect, useMemo, useState} from "react";
import {ScrollView, View} from "react-native";
import React, {useCallback, useEffect, useMemo, useState} from "react";
import {ScrollView, TouchableOpacity, View} from "react-native";
import {EpisodeCard} from "@/components/downloads/EpisodeCard";
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
import {storage} from "@/utils/mmkv";
import {Ionicons} from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
@@ -17,7 +18,7 @@ export default function page() {
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>({});
const {downloadedFiles} = useDownload();
const {downloadedFiles, deleteItems} = useDownload();
const series = useMemo(() => {
try {
@@ -64,6 +65,10 @@ export default function page() {
}
}, [series]);
const deleteSeries = useCallback(
async () => deleteItems(groupBySeason),
[groupBySeason]
);
return (
<>
{series.length > 0 && <View className="my-4 flex flex-row items-center justify-start">
@@ -78,9 +83,16 @@ export default function page() {
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="flex flex-row items-center justify-between w-72">
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={22} color="white"/>
</TouchableOpacity>
</View>
</View>
</View>}
<ScrollView key={seasonIndex}>
{groupBySeason.map((episode, index) => (

View File

@@ -6,17 +6,24 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import {useNavigation, useRouter} from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {toast} from "sonner-native";
import {writeToLog} from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => {
try {
@@ -46,107 +53,160 @@ export default function page() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
</TouchableOpacity>
)
})
}, [downloadedFiles]);
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
const deleteMovies = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
return (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red"/>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads/>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
import {clearLogs, useLog} from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native";
import {Alert, ScrollView, View} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import * as Progress from 'react-native-progress';
import * as FileSystem from "expo-file-system";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useDownload();
const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
const insets = useSafeAreaInsets();
const {data: size , isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage
const remaining = await FileSystem.getFreeDiskStorageAsync()
const total = await FileSystem.getTotalDiskCapacityAsync()
return {app, remaining, total, used: (total - remaining) / total}
}
})
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
@@ -57,6 +66,27 @@ export default function settings() {
);
};
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
};
return (
<ScrollView
contentContainerStyle={{
@@ -81,6 +111,9 @@ export default function settings() {
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<View>
@@ -92,42 +125,36 @@ export default function settings() {
<SettingToggles />
<View>
<Text className="font-bold text-lg mb-2">Account and storage</Text>
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
)}
</View>
<Button
color="red"
onPress={onDeleteClicked}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={onClearLogsClicked}
>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>

View File

@@ -9,7 +9,7 @@ import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { writeToLog } from "@/utils/log";
import {LogProvider, writeToLog} from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
@@ -304,56 +304,58 @@ function Layout() {
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureHandlerRootView style={{flex: 1}}>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
<LogProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false}/>
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<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: false, title: "Login"}}
/>
<Stack.Screen name="+not-found"/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>

BIN
bun.lockb

Binary file not shown.

View File

@@ -31,6 +31,7 @@ import Animated, {
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -111,18 +112,11 @@ export const PlayButton: React.FC<Props> = ({
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
// If we're opening a currently playing item, don't restart the media.
// Instead just open controls.
if (isOpeningCurrentlyPlayingMedia) {
CastContext.showExpandedControls();
return;
}
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: ios,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,

View File

@@ -2,9 +2,10 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {useEffect, useMemo, useState} from "react";
import {Text} from "@/components/common/Text";
import useDownloadHelper from "@/utils/download";
import {useDownload} from "@/providers/DownloadProvider";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import {TextProps} from "react-native";
interface DownloadSizeProps {
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
@@ -13,7 +14,7 @@ interface DownloadSizes {
itemsNeedingSize: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items }) => {
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items, ...props }) => {
const { downloadedFiles, saveDownloadedItemInfo } = useDownload();
const { getDownloadSize } = useDownloadHelper();
const [size, setSize] = useState<string | undefined>();
@@ -53,17 +54,9 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({ items }) => {
return size
}, [size])
const bytesToReadable = (bytes: number) => {
const gb = bytes / 1e+9;
if (gb >= 1)
return `${gb.toFixed(2)} GB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
return (
<>
<Text className="text-xs text-neutral-500">{sizeText}</Text>
<Text className="text-xs text-neutral-500" {...props}>{sizeText}</Text>
</>
);
};

View File

@@ -1,20 +1,49 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {TouchableOpacity, View} from "react-native";
import { Text } from "../common/Text";
import React, {useEffect, useMemo, useState} from "react";
import React, {useCallback, useMemo} from "react";
import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () => deleteItems(items),
[items]
);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
showActionSheetWithOptions({
options,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex == destructiveButtonIndex) {
deleteSeries();
}
}
);
}, [showActionSheetWithOptions, deleteSeries]);
return (
<TouchableOpacity onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}>
<TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
onLongPress={showActionSheet}
>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image

View File

@@ -84,6 +84,7 @@
"react-native-ios-utilities": "^4.5.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",

View File

@@ -1,6 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { writeToLog } from "@/utils/log";
import {useLog, writeToLog} from "@/utils/log";
import {
cancelAllJobs,
cancelJobById,
@@ -47,6 +47,8 @@ import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import {FileInfo} from "expo-file-system";
import * as Haptics from "expo-haptics";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
@@ -67,6 +69,7 @@ function useDownloadProvider() {
const [settings] = useSettings();
const router = useRouter();
const [api] = useAtom(apiAtom);
const { logs } = useLog();
const {saveSeriesPrimaryImage} = useDownloadHelper();
const { saveImage } = useImageStorage();
@@ -388,19 +391,20 @@ function useDownloadProvider() {
);
const deleteAllFiles = async (): Promise<void> => {
try {
await deleteLocalFiles();
removeDownloadedItemsFromStorage();
await cancelAllServerJobs();
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
toast.success("All files, folders, and jobs deleted successfully");
} catch (error) {
console.error("Failed to delete all files, folders, and jobs:", error);
Promise.all([
deleteLocalFiles(),
removeDownloadedItemsFromStorage(),
cancelAllServerJobs(),
queryClient.invalidateQueries({queryKey: ["downloadedItems"]}),
]).then(() =>
toast.success("All files, folders, and jobs deleted successfully")
).catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error("An error occurred while deleting files and jobs");
}
});
};
const deleteLocalFiles = async (): Promise<void> => {
const forEveryDirectoryFile = async (includeMMKV: boolean = true, callback: (file: FileInfo) => void) => {
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
@@ -408,25 +412,36 @@ function useDownloadProvider() {
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
for (const item of dirContents) {
const itemPath = `${baseDirectory}${item}`;
const itemInfo = await FileSystem.getInfoAsync(itemPath);
// Exclude mmkv directory.
// Deleting this deletes all user information as well. Logout should handle this.
if (item == "mmkv" && !includeMMKV)
continue
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
if (itemInfo.exists) {
if (itemInfo.isDirectory) {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
} else {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
}
callback(itemInfo)
}
}
}
const deleteLocalFiles = async (): Promise<void> => {
await forEveryDirectoryFile(false, (file) => {
console.warn("Deleting file", file.uri)
FileSystem.deleteAsync(file.uri, {idempotent: true})
}
)
};
const removeDownloadedItemsFromStorage = (): void => {
try {
storage.delete("downloadedItems");
} catch (error) {
console.error("Failed to remove downloadedItems from storage:", error);
throw error;
}
const removeDownloadedItemsFromStorage = async () => {
// delete any saved images first
Promise.all([
deleteFileByType("Movie"),
deleteFileByType("Episode"),
]).then(() =>
storage.delete("downloadedItems")
).catch((reason) => {
console.error("Failed to remove downloadedItems from storage:", reason);
throw reason
})
};
const cancelAllServerJobs = async (): Promise<void> => {
@@ -434,7 +449,8 @@ function useDownloadProvider() {
throw new Error("No auth header available");
}
if (!settings?.optimizedVersionsServerUrl) {
throw new Error("No server URL configured");
console.error("No server URL configured");
return
}
const deviceId = await getOrSetDeviceId();
@@ -494,6 +510,43 @@ function useDownloadProvider() {
}
};
const deleteItems = async (items: BaseItemDto[]) => {
Promise.all(items.map(i => {
if (i.Id)
return deleteFile(i.Id)
return
})).then(() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
)
}
const deleteFileByType = async (type: BaseItemDto['Type']) => {
await Promise.all(
downloadedFiles
?.filter(file => file.item.Type == type)
?.flatMap(file => {
const promises = [];
if (type == "Episode" && file.item.SeriesId)
promises.push(deleteFile(file.item.SeriesId))
promises.push(deleteFile(file.item.Id!))
return promises;
})
|| []
);
}
const appSizeUsage = useMemo(async () => {
const sizes: number[] = [];
await forEveryDirectoryFile(
true,
file => {
if (file.exists) sizes.push(file.size)
}
)
return sizes.reduce((sum, size) => sum + size, 0);
}, [logs, downloadedFiles])
function getDownloadedItem(itemId: string): DownloadedItem | null {
try {
const downloadedItems = storage.getString("downloadedItems");
@@ -566,11 +619,14 @@ function useDownloadProvider() {
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage
};
}
@@ -591,3 +647,19 @@ export function useDownload() {
}
return context;
}
export function bytesToReadable(bytes: number): string {
const gb = bytes / 1e9;
if (gb >= 1)
return `${gb.toFixed(2)} GB`
const mb = bytes / 1024 / 1024
if (mb >= 1)
return `${mb.toFixed(2)} MB`
const kb = bytes / 1024
if (kb >= 1)
return `${kb.toFixed(2)} KB`
return `${bytes.toFixed(2)} B`
}

View File

@@ -1,5 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { storage } from "./mmkv";
import {useQuery} from "@tanstack/react-query";
import React, {createContext, useContext} from "react";
type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -17,6 +19,24 @@ const mmkvStorage = createJSONStorage(() => ({
}));
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
const DownloadContext = createContext<ReturnType<
typeof useLogProvider
> | null>(null);
function useLogProvider() {
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
return {
logs
}
}
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
@@ -44,4 +64,22 @@ export const clearLogs = () => {
storage.delete("logs");
};
export function useLog() {
const context = useContext(LogContext);
if (context === null) {
throw new Error("useLog must be used within a LogProvider");
}
return context;
}
export function LogProvider({children}: { children: React.ReactNode }) {
const provider = useLogProvider();
return (
<LogContext.Provider value={provider}>
{children}
</LogContext.Provider>
)
}
export default logsAtom;

View File

@@ -1,18 +1,25 @@
import {
DeviceProfile
} from "@jellyfin/sdk/lib/generated-client/models";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
export const chromecastProfile: DeviceProfile = {
Name: "Chromecast Video Profile",
Id: "chromecast-001",
MaxStreamingBitrate: 4000000, // 4 Mbps
MaxStaticBitrate: 4000000, // 4 Mbps
MaxStreamingBitrate: 8000000, // 8 Mbps
MaxStaticBitrate: 8000000, // 8 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
},
],
DirectPlayProfiles: [
{
Container: "mp4,webm",
Container: "mp4",
Type: "Video",
VideoCodec: "h264,vp8,vp9",
VideoCodec: "h264",
AudioCodec: "aac,mp3,opus,vorbis",
},
{
@@ -34,89 +41,32 @@ export const chromecastProfile: DeviceProfile = {
],
TranscodingProfiles: [
{
Container: "ts",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3",
Context: "Streaming",
Protocol: "hls",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
BreakOnNonKeyFrames: true,
Container: "ts",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
},
{
Container: "mp4",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac",
Type: "Audio",
Context: "Streaming",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
],
ContainerProfiles: [
{
Type: "Video",
Container: "mp4",
},
{
Type: "Video",
Container: "webm",
},
],
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "8",
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "41",
},
],
},
{
Type: "Video",
Codec: "vp9",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Hls",
Method: "Encode",
},
{
Format: "vtt",
Method: "External",
Method: "Encode",
},
],
};

View File

@@ -1,180 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import BaseProfile from './base';
/**
* Device profile for Expo Video player on iOS 10
*/
export default {
...BaseProfile,
Name: 'Expo iOS 10 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: MediaTypes.Video
},
...BaseProfile.CodecProfiles
],
DirectPlayProfiles: [
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4,m4v',
Type: MediaTypes.Video,
VideoCodec: 'h264,vc1'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mov',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
Container: 'mp3',
Type: MediaTypes.Audio
},
{
Container: 'aac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'alac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'wav',
Type: MediaTypes.Audio
}
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac,mp3',
BreakOnNonKeyFrames: true,
Container: 'ts',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4',
Context: 'Static',
Protocol: 'http',
Type: MediaTypes.Video,
VideoCodec: 'h264'
}
]
};

View File

@@ -1,49 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 11-12
*/
export default {
...iOSProfile,
Name: 'Expo iOS 12 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: 'Video'
},
...iOSProfile.CodecProfiles
]
};

View File

@@ -1,35 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 13+ with fMP4 support
*/
export default {
...iOSProfile,
Name: 'Expo iOS fMP4 Video Profile',
TranscodingProfiles: [
// Add all audio profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Audio),
// Add fMP4 profile
{
AudioCodec: 'aac,mp3,flac,alac',
BreakOnNonKeyFrames: true,
Container: 'mp4',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
// Add all video profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Video)
]
};

View File

@@ -1,259 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for old phones (aka does not support HEVC)
*
* This file is a modified version of the original file.
*
* Link to original: https://github.com/jellyfin/jellyfin-expo/blob/e7b7e736a8602c94612917ef02de22f87c7c28f2/utils/profiles/ios.js#L4
*/
export default {
MaxStreamingBitrate: 3000000,
MaxStaticBitrate: 3000000,
MusicStreamingTranscodingBitrate: 256000,
DirectPlayProfiles: [
{
Container: "mp4,m4v",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mkv",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mov",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "m4a",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "m4b",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "hls",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
],
TranscodingProfiles: [
{
Container: "mp4",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp4",
Type: "Video",
AudioCodec: "aac,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
{
Container: "ts",
Type: "Video",
AudioCodec: "aac,mp3,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
],
ContainerProfiles: [],
CodecProfiles: [
{
Type: "VideoAudio",
Codec: "aac",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "VideoAudio",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "NotEquals",
Property: "IsAnamorphic",
Value: "true",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoRangeType",
Value: "SDR",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "52",
IsRequired: false,
},
{
Condition: "NotEquals",
Property: "IsInterlaced",
Value: "true",
IsRequired: false,
},
],
},
{
Type: "Video",
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "65",
IsRequired: false,
},
],
},
],
SubtitleProfiles: [
{
Method: "Encode",
},
],
};