mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
merge with master
This commit is contained in:
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -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
|
||||
]
|
||||
};
|
||||
@@ -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)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user