fix
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: row; gap: 5px">
|
||||||
|
<img width=100 src="./assets/images/screenshots/1.jpg" />
|
||||||
|
<img width=100 src="./assets/images/screenshots/3.jpg" />
|
||||||
|
<img width=100 src="./assets/images/screenshots/4.jpg" />
|
||||||
|
<img width=100 src="./assets/images/screenshots/5.jpg" />
|
||||||
|
<img width=100 src="./assets/images/screenshots/7.jpg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
|
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
const downloads: React.FC = () => {
|
||||||
const { data: downloadedFiles } = useQuery({
|
const { data: downloadedFiles, isLoading } = useQuery({
|
||||||
queryKey: ["downloaded_files"],
|
queryKey: ["downloaded_files"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
@@ -19,7 +19,7 @@ const downloads: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const movies = useMemo(
|
const movies = useMemo(
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie"),
|
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||||
[downloadedFiles]
|
[downloadedFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -43,40 +43,45 @@ const downloads: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (isLoading) {
|
||||||
// Get all files from FileStorage
|
return (
|
||||||
// const filename = `${itemId}.mp4`;
|
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||||
// const fileUri = `${FileSystem.documentDirectory}`;
|
<ActivityIndicator size="small" color="white" />
|
||||||
(async () => {
|
</View>
|
||||||
if (!FileSystem.documentDirectory) return;
|
);
|
||||||
const f = await FileSystem.readDirectoryAsync(
|
}
|
||||||
FileSystem.documentDirectory
|
|
||||||
);
|
if (downloadedFiles?.length === 0) {
|
||||||
console.log("files", FileSystem.documentDirectory, f);
|
return (
|
||||||
})();
|
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||||
}, []);
|
<Text className="text-white text-lg font-bold">
|
||||||
|
No downloaded files
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
<View className="px-4 py-4">
|
<View className="px-4 py-4">
|
||||||
<View className="mb-4">
|
{movies.length > 0 && (
|
||||||
<View className="flex flex-row items-center justify-between mb-2">
|
<View className="mb-4">
|
||||||
<Text className="text-2xl font-bold">Movies</Text>
|
<View className="flex flex-row items-center justify-between mb-2">
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<Text className="text-2xl font-bold">Movies</Text>
|
||||||
<Text className="text-xs font-bold">{movies?.length}</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>
|
</View>
|
||||||
|
{movies?.map((item: BaseItemDto) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||||
|
<MovieCard item={item} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
{movies?.map((item: BaseItemDto) => (
|
)}
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||||
<MovieCard item={item} />
|
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||||
</View>
|
))}
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -29,6 +29,8 @@ const page: React.FC = () => {
|
|||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { id } = local as { id: string };
|
const { id } = local as { id: string };
|
||||||
|
|
||||||
|
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -148,13 +150,20 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||||
<DownloadItem item={item} />
|
{playbackURL && (
|
||||||
|
<DownloadItem item={item} playbackURL={playbackURL} />
|
||||||
|
)}
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
</View>
|
</View>
|
||||||
<Text>{item.Overview}</Text>
|
<Text>{item.Overview}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col p-4">
|
<View className="flex flex-col p-4">
|
||||||
<VideoPlayer itemId={item.Id} />
|
<VideoPlayer
|
||||||
|
itemId={item.Id}
|
||||||
|
onChangePlaybackURL={(val) => {
|
||||||
|
setPlaybackURL(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal className="flex px-4 mb-4">
|
<ScrollView horizontal className="flex px-4 mb-4">
|
||||||
<View className="flex flex-row space-x-2 ">
|
<View className="flex flex-row space-x-2 ">
|
||||||
|
|||||||
@@ -1,99 +1,22 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import ProgressCircle from "@/components/ProgressCircle";
|
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
import { useFiles } from "@/utils/files/useFiles";
|
||||||
import { readFromLog } from "@/utils/log";
|
import { readFromLog } from "@/utils/log";
|
||||||
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 { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { ScrollView, View } from "react-native";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
const deleteAllFiles = async () => {
|
|
||||||
const directoryUri = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNames = await FileSystem.readDirectoryAsync(directoryUri!);
|
|
||||||
for (let item of fileNames) {
|
|
||||||
await FileSystem.deleteAsync(`${directoryUri}/${item}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncStorage.removeItem("downloaded_files");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete the directory:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFile = async (id: string | null | undefined) => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
FileSystem.deleteAsync(`${FileSystem.documentDirectory}/${id}.mp4`).catch(
|
|
||||||
(err) => console.error(err)
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentFiles = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) ?? "[]"
|
|
||||||
) as BaseItemDto[];
|
|
||||||
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listDownloadedFiles = async () => {
|
|
||||||
const directoryUri = FileSystem.documentDirectory; // Directory where files are stored
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNames = await FileSystem.readDirectoryAsync(directoryUri!);
|
|
||||||
return fileNames; // This will be an array of file names in the directory
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to read the directory:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
|
const { deleteAllFiles } = useFiles();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [files, setFiles] = useState<BaseItemDto[]>([]);
|
|
||||||
const [key, setKey] = useState(0);
|
|
||||||
|
|
||||||
const [session, setSession] = useAtom(runningProcesses);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [activeProcess] = useAtom(runningProcesses);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const data = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
) as BaseItemDto[];
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Files",
|
|
||||||
data.map((i) => i.Name)
|
|
||||||
);
|
|
||||||
|
|
||||||
setFiles(data);
|
|
||||||
})();
|
|
||||||
}, [key]);
|
|
||||||
|
|
||||||
const { data: logs } = useQuery({
|
const { data: logs } = useQuery({
|
||||||
queryKey: ["logs"],
|
queryKey: ["logs"],
|
||||||
queryFn: async () => readFromLog(),
|
queryFn: async () => readFromLog(),
|
||||||
@@ -111,99 +34,34 @@ export default function settings() {
|
|||||||
|
|
||||||
<Button onPress={logout}>Log out</Button>
|
<Button onPress={logout}>Log out</Button>
|
||||||
|
|
||||||
<View className="mb-4">
|
<View className="my-2">
|
||||||
<Text className="font-bold text-2xl mb-4">Downloads</Text>
|
|
||||||
|
|
||||||
{files.length > 0 ? (
|
|
||||||
<View>
|
|
||||||
{files.map((file) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={file.Id}
|
|
||||||
className="rounded-xl overflow-hidden mb-2"
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
router.push(
|
|
||||||
`/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={file.Name}
|
|
||||||
subTitle={file.ProductionYear?.toString()}
|
|
||||||
iconAfter={
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
await deleteFile(file.Id);
|
|
||||||
setKey((prevKey) => prevKey + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="close-circle-outline"
|
|
||||||
size={24}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : activeProcess ? (
|
|
||||||
<View className="rounded-xl overflow-hidden mb-2">
|
|
||||||
<ListItem
|
|
||||||
title={activeProcess.item.Name}
|
|
||||||
iconAfter={
|
|
||||||
<ProgressCircle
|
|
||||||
size={22}
|
|
||||||
fill={activeProcess.progress}
|
|
||||||
width={3}
|
|
||||||
tintColor="#3498db"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text className="opacity-50">No downloaded files</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="mb-2"
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await deleteAllFiles();
|
|
||||||
setKey((prevKey) => prevKey + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear downloads
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{session?.item.Id && (
|
|
||||||
<Button
|
<Button
|
||||||
className="mb-2"
|
color="red"
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
FFmpegKit.cancel();
|
await deleteAllFiles();
|
||||||
setSession(null);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel all downloads
|
Delete all downloaded files
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
<Text className="font-bold">Logs</Text>
|
<Text className="font-bold">Logs</Text>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
{logs?.map((l) => (
|
{logs?.map((log, index) => (
|
||||||
<View className="bg-neutral-800 border border-neutral-900 rounded p-2">
|
<View
|
||||||
|
key={index}
|
||||||
|
className="bg-neutral-800 border border-neutral-900 rounded p-2"
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
${l.level === "INFO" && "text-blue-500"}
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
${l.level === "ERROR" && "text-red-500"}
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{l.level}
|
{log.level}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>{l.message}</Text>
|
<Text>{log.message}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -82,12 +82,6 @@ export default function RootLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
title: "Downloads",
|
title: "Downloads",
|
||||||
presentation: "modal",
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity onPress={() => router.back()}>
|
|
||||||
<Feather name="x-circle" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -101,6 +95,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/items/[id]/page"
|
name="(auth)/items/[id]/page"
|
||||||
options={{
|
options={{
|
||||||
|
title: "",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -116,6 +111,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/series/[id]/page"
|
name="(auth)/series/[id]/page"
|
||||||
options={{
|
options={{
|
||||||
|
title: "",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
BIN
assets/images/screenshots/1.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
assets/images/screenshots/2.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
assets/images/screenshots/3.jpg
Normal file
|
After Width: | Height: | Size: 433 KiB |
BIN
assets/images/screenshots/4.jpg
Normal file
|
After Width: | Height: | Size: 385 KiB |
BIN
assets/images/screenshots/5.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
assets/images/screenshots/6.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/images/screenshots/7.jpg
Normal file
|
After Width: | Height: | Size: 322 KiB |
BIN
assets/images/screenshots/8.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -32,7 +32,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
case "purple":
|
case "purple":
|
||||||
return "bg-purple-600 active:bg-purple-700";
|
return "bg-purple-600 active:bg-purple-700";
|
||||||
case "red":
|
case "red":
|
||||||
return "bg-red-500";
|
return "bg-red-600";
|
||||||
case "black":
|
case "black":
|
||||||
return "bg-black border border-neutral-900";
|
return "bg-black border border-neutral-900";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
|
import {
|
||||||
|
getPlaybackInfo,
|
||||||
|
useDownloadMedia,
|
||||||
|
useRemuxHlsToMp4,
|
||||||
|
} from "@/utils/jellyfin";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
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 AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
@@ -14,9 +18,13 @@ import { Text } from "./common/Text";
|
|||||||
|
|
||||||
type DownloadProps = {
|
type DownloadProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
playbackURL: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||||
|
item,
|
||||||
|
playbackURL,
|
||||||
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [process] = useAtom(runningProcesses);
|
const [process] = useAtom(runningProcesses);
|
||||||
@@ -24,6 +32,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
|||||||
const { downloadMedia, isDownloading, error, cancelDownload } =
|
const { downloadMedia, isDownloading, error, cancelDownload } =
|
||||||
useDownloadMedia(api, user?.Id);
|
useDownloadMedia(api, user?.Id);
|
||||||
|
|
||||||
|
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackURL, item);
|
||||||
|
|
||||||
const { data: playbackInfo, isLoading } = useQuery({
|
const { data: playbackInfo, isLoading } = useQuery({
|
||||||
queryKey: ["playbackInfo", item.Id],
|
queryKey: ["playbackInfo", item.Id],
|
||||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||||
@@ -34,6 +44,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
|||||||
|
|
||||||
const source = playbackInfo.MediaSources?.[0];
|
const source = playbackInfo.MediaSources?.[0];
|
||||||
|
|
||||||
|
console.log("Source:", JSON.stringify(source));
|
||||||
|
|
||||||
if (source?.SupportsDirectPlay && item.CanDownload) {
|
if (source?.SupportsDirectPlay && item.CanDownload) {
|
||||||
downloadMedia(item);
|
downloadMedia(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -80,22 +92,34 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
|||||||
{process ? (
|
{process ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
cancelDownload();
|
cancelRemuxing();
|
||||||
}}
|
}}
|
||||||
className="relative"
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<View className="-rotate-45">
|
<View className="relative">
|
||||||
<ProgressCircle
|
<View className="-rotate-45">
|
||||||
size={26}
|
<ProgressCircle
|
||||||
fill={process.progress}
|
size={26}
|
||||||
width={3}
|
fill={process.progress}
|
||||||
tintColor="#3498db"
|
width={3}
|
||||||
backgroundColor="#bdc3c7"
|
tintColor="#3498db"
|
||||||
/>
|
backgroundColor="#bdc3c7"
|
||||||
</View>
|
/>
|
||||||
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
|
</View>
|
||||||
<Text className="text-[6px]">{process.progress.toFixed(0)}%</Text>
|
{process.progress > 0 ? (
|
||||||
|
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<Text className="text-[6px]">
|
||||||
|
{process.progress.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{process?.speed && (process?.speed || 0) > 0 ? (
|
||||||
|
<View className="ml-2">
|
||||||
|
<Text>{process.speed.toFixed(2)}x</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : downloaded ? (
|
) : downloaded ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -110,7 +134,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
|||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
downloadFile();
|
// downloadFile();
|
||||||
|
startRemuxing();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
chromecastProfile,
|
||||||
|
iosProfile,
|
||||||
|
iOSProfile_2,
|
||||||
|
} from "@/utils/device-profiles";
|
||||||
import {
|
import {
|
||||||
getStreamUrl,
|
getStreamUrl,
|
||||||
getUserItemData,
|
getUserItemData,
|
||||||
@@ -17,7 +22,8 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
|
||||||
import Video, {
|
import Video, {
|
||||||
OnBufferData,
|
OnBufferData,
|
||||||
OnPlaybackStateChangedData,
|
OnPlaybackStateChangedData,
|
||||||
@@ -28,13 +34,12 @@ import Video, {
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
|
import iosFmp4 from "../utils/profiles/iosFmp4";
|
||||||
import GoogleCast from "react-native-google-cast";
|
import ios12 from "../utils/profiles/ios12";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { chromecastProfile, iosProfile } from "@/utils/device-profiles";
|
|
||||||
|
|
||||||
type VideoPlayerProps = {
|
type VideoPlayerProps = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
onChangePlaybackURL: (url: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BITRATES = [
|
const BITRATES = [
|
||||||
@@ -56,7 +61,10 @@ const BITRATES = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
|
itemId,
|
||||||
|
onChangePlaybackURL,
|
||||||
|
}) => {
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
|
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
|
||||||
const [paused, setPaused] = useState(true);
|
const [paused, setPaused] = useState(true);
|
||||||
@@ -108,11 +116,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
|||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
maxStreamingBitrate: maxBitrate,
|
maxStreamingBitrate: maxBitrate,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosProfile,
|
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Transcode URL:", url);
|
console.log("Transcode URL:", url);
|
||||||
|
|
||||||
|
onChangePlaybackURL(url);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
enabled: !!sessionData,
|
enabled: !!sessionData,
|
||||||
@@ -165,6 +175,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
|||||||
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
|
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
|
const [hidePlayer, setHidePlayer] = useState(true);
|
||||||
|
|
||||||
const enableVideo = useMemo(() => {
|
const enableVideo = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
playbackURL !== undefined &&
|
playbackURL !== undefined &&
|
||||||
@@ -220,13 +232,11 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{enableVideo === true &&
|
{enableVideo === true ? (
|
||||||
playbackURL !== null &&
|
|
||||||
playbackURL !== undefined ? (
|
|
||||||
<Video
|
<Video
|
||||||
style={{ width: 0, height: 0 }}
|
style={{ width: 0, height: 0 }}
|
||||||
source={{
|
source={{
|
||||||
uri: playbackURL,
|
uri: playbackURL!,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
}}
|
}}
|
||||||
@@ -242,7 +252,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
|||||||
onFullscreenPlayerDidDismiss={() => {
|
onFullscreenPlayerDidDismiss={() => {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
setPaused(true);
|
setPaused(true);
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["nextUp", item?.SeriesId],
|
queryKey: ["nextUp", item?.SeriesId],
|
||||||
refetchType: "all",
|
refetchType: "all",
|
||||||
@@ -260,19 +269,16 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
|||||||
positionTicks: progress,
|
positionTicks: progress,
|
||||||
sessionId: sessionData?.PlaySessionId,
|
sessionId: sessionData?.PlaySessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setHidePlayer(true);
|
||||||
}}
|
}}
|
||||||
onFullscreenPlayerDidPresent={() => {
|
onFullscreenPlayerDidPresent={() => {
|
||||||
play();
|
play();
|
||||||
}}
|
}}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
|
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
|
||||||
bufferConfig={{
|
|
||||||
maxBufferMs: Infinity,
|
|
||||||
minBufferMs: 1000 * 60 * 2,
|
|
||||||
bufferForPlaybackMs: 1000,
|
|
||||||
backBufferDurationMs: 30 * 1000,
|
|
||||||
}}
|
|
||||||
ignoreSilentSwitch="ignore"
|
ignoreSilentSwitch="ignore"
|
||||||
|
preferredForwardBufferDuration={1}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between">
|
||||||
@@ -319,8 +325,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (chromecastReady) {
|
if (chromecastReady) {
|
||||||
cast();
|
cast();
|
||||||
} else if (videoRef.current) {
|
} else {
|
||||||
videoRef.current.presentFullscreenPlayer();
|
setHidePlayer(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
videoRef.current.presentFullscreenPlayer();
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
iconRight={
|
iconRight={
|
||||||
|
|||||||
@@ -1,23 +1,68 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
import { TouchableOpacity } from "react-native";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useFiles } from "@/utils/files/useFiles";
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const open = () => {
|
const { deleteFile } = useFiles();
|
||||||
router.back();
|
|
||||||
|
const openFile = () => {
|
||||||
router.push(
|
router.push(
|
||||||
`/(auth)/player/offline/page?url=${item.Id}.${item.MediaSources?.[0].Container}&itemId=${item.Id}`
|
`/(auth)/player/offline/page?url=${item.Id}.${item.MediaSources?.[0].Container}&itemId=${item.Id}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
onSelect: (id: string) => {
|
||||||
|
deleteFile(id)
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||||
|
},
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<ContextMenu.Root>
|
||||||
onPress={open}
|
<ContextMenu.Trigger>
|
||||||
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
|
<TouchableOpacity
|
||||||
>
|
onPress={openFile}
|
||||||
<Text className=" font-bold">{item.Name}</Text>
|
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
|
||||||
<Text className=" text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
>
|
||||||
</TouchableOpacity>
|
<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}
|
||||||
|
>
|
||||||
|
{options.map((i) => (
|
||||||
|
<ContextMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
i.onSelect(item.Id!);
|
||||||
|
}}
|
||||||
|
key={i.label}
|
||||||
|
destructive={i.destructive}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle
|
||||||
|
style={{
|
||||||
|
color: "red",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i.label}
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
))}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,68 @@
|
|||||||
import { View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useFiles } from "@/utils/files/useFiles";
|
||||||
|
|
||||||
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
const { deleteFile } = useFiles();
|
||||||
|
|
||||||
|
const openFile = () => {
|
||||||
|
router.push(
|
||||||
|
`/(auth)/player/offline/page?url=${item.Id}.${item.MediaSources?.[0].Container}&itemId=${item.Id}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
onSelect: (id: string) => deleteFile(id),
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4">
|
<ContextMenu.Root>
|
||||||
<Text className=" font-bold">{item.Name}</Text>
|
<ContextMenu.Trigger>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<TouchableOpacity
|
||||||
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
|
onPress={openFile}
|
||||||
<Text className=" text-xs opacity-50">
|
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
|
||||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
>
|
||||||
</Text>
|
<Text className=" font-bold">{item.Name}</Text>
|
||||||
</View>
|
<View className="flex flex-row items-center justify-between">
|
||||||
</View>
|
<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
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={false}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={false}
|
||||||
|
>
|
||||||
|
{options.map((i) => (
|
||||||
|
<ContextMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
i.onSelect(item.Id!);
|
||||||
|
}}
|
||||||
|
key={i.label}
|
||||||
|
destructive={i.destructive}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle
|
||||||
|
style={{
|
||||||
|
color: "red",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i.label}
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
))}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
{seasonItems[0].SeasonName}
|
{seasonItems[0].SeasonName}
|
||||||
</Text>
|
</Text>
|
||||||
{seasonItems.map((item, index) => (
|
{seasonItems.map((item, index) => (
|
||||||
<View className="mb-2">
|
<View className="mb-2" key={index}>
|
||||||
<EpisodeCard item={item} key={item.Id} />
|
<EpisodeCard item={item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
11
constants/MediaTypes.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
Audio: "Audio",
|
||||||
|
Video: "Video",
|
||||||
|
Photo: "Photo",
|
||||||
|
Book: "Book",
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { atom } from "jotai";
|
|||||||
export type ProcessItem = {
|
export type ProcessItem = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
speed?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runningProcesses = atom<ProcessItem | null>(null);
|
export const runningProcesses = atom<ProcessItem | null>(null);
|
||||||
|
|||||||
@@ -351,3 +351,127 @@ export const chromecastProfile: DeviceProfile = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const iOSProfile_2: DeviceProfile = {
|
||||||
|
Id: "iPhone",
|
||||||
|
Name: "iPhone",
|
||||||
|
MaxStreamingBitrate: 20000000,
|
||||||
|
MaxStaticBitrate: 30000000,
|
||||||
|
MusicStreamingTranscodingBitrate: 192000,
|
||||||
|
DirectPlayProfiles: [
|
||||||
|
{
|
||||||
|
Container: "mp4,m4v",
|
||||||
|
Type: "Video",
|
||||||
|
VideoCodec: "h264,hevc,mp4v",
|
||||||
|
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Container: "mov",
|
||||||
|
Type: "Video",
|
||||||
|
VideoCodec: "h264,hevc",
|
||||||
|
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Container: "m4a",
|
||||||
|
Type: "Audio",
|
||||||
|
AudioCodec: "aac,alac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Container: "mp3",
|
||||||
|
Type: "Audio",
|
||||||
|
AudioCodec: "mp3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
TranscodingProfiles: [
|
||||||
|
{
|
||||||
|
Container: "ts",
|
||||||
|
Type: "Video",
|
||||||
|
VideoCodec: "h264",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
Context: "Streaming",
|
||||||
|
Protocol: "hls",
|
||||||
|
MaxAudioChannels: "2",
|
||||||
|
MinSegments: 2,
|
||||||
|
BreakOnNonKeyFrames: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Container: "mp3",
|
||||||
|
Type: "Audio",
|
||||||
|
AudioCodec: "mp3",
|
||||||
|
Context: "Streaming",
|
||||||
|
Protocol: "http",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ContainerProfiles: [],
|
||||||
|
CodecProfiles: [
|
||||||
|
{
|
||||||
|
Type: "VideoAudio",
|
||||||
|
Codec: "aac",
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "Equals",
|
||||||
|
Property: "IsSecondaryAudio",
|
||||||
|
Value: "false",
|
||||||
|
IsRequired: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "VideoAudio",
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "LessThanEqual",
|
||||||
|
Property: "AudioChannels",
|
||||||
|
Value: "2",
|
||||||
|
IsRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "Video",
|
||||||
|
Codec: "h264",
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "LessThanEqual",
|
||||||
|
Property: "VideoLevel",
|
||||||
|
Value: "51",
|
||||||
|
IsRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: "EqualsAny",
|
||||||
|
Property: "VideoProfile",
|
||||||
|
Value: "main|high|baseline",
|
||||||
|
IsRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "Video",
|
||||||
|
Codec: "hevc",
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: "LessThanEqual",
|
||||||
|
Property: "VideoLevel",
|
||||||
|
Value: "153",
|
||||||
|
IsRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: "EqualsAny",
|
||||||
|
Property: "VideoProfile",
|
||||||
|
Value: "main|main10",
|
||||||
|
IsRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SubtitleProfiles: [
|
||||||
|
{
|
||||||
|
Format: "vtt",
|
||||||
|
Method: "External",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Format: "mov_text",
|
||||||
|
Method: "Embed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|||||||
63
utils/files/useFiles.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export const useFiles = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteAllFiles = async () => {
|
||||||
|
const directoryUri = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileNames = await FileSystem.readDirectoryAsync(directoryUri!);
|
||||||
|
for (let item of fileNames) {
|
||||||
|
await FileSystem.deleteAsync(`${directoryUri}/${item}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncStorage.removeItem("downloaded_files");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete the directory:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFile = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const files = await FileSystem.readDirectoryAsync(
|
||||||
|
`${FileSystem.documentDirectory}`
|
||||||
|
);
|
||||||
|
console.log(`Files:`, files);
|
||||||
|
|
||||||
|
await FileSystem.deleteAsync(
|
||||||
|
`${FileSystem.documentDirectory}/${id}.mp4`
|
||||||
|
).catch((err) => console.error(err));
|
||||||
|
|
||||||
|
const currentFiles = JSON.parse(
|
||||||
|
(await AsyncStorage.getItem("downloaded_files")) ?? "[]"
|
||||||
|
) as BaseItemDto[];
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Current files",
|
||||||
|
currentFiles.map((i) => i.Name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Current files",
|
||||||
|
currentFiles.map((i) => i.Name)
|
||||||
|
);
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
"downloaded_files",
|
||||||
|
JSON.stringify(updatedFiles)
|
||||||
|
);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { deleteFile, deleteAllFiles };
|
||||||
|
};
|
||||||
@@ -15,40 +15,47 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { runningProcesses } from "./atoms/downloads";
|
import { runningProcesses } from "./atoms/downloads";
|
||||||
import { iosProfile } from "./device-profiles";
|
import { iosProfile } from "./device-profiles";
|
||||||
import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native";
|
import {
|
||||||
|
FFmpegKit,
|
||||||
|
FFmpegKitConfig,
|
||||||
|
ReturnCode,
|
||||||
|
} from "ffmpeg-kit-react-native";
|
||||||
|
import { writeToLog } from "./log";
|
||||||
|
|
||||||
const convertAndReplaceVideo = async (inputUri: string) => {
|
/**
|
||||||
const tempOutputUri = inputUri.replace(/\.\w+$/, "_temp.mp4");
|
* Try to convert the downloaded file to a supported format on-device. Leveraging the capability of modern phones.
|
||||||
|
*
|
||||||
|
* ⚠️ This function does not work, and the app crashes when running it.
|
||||||
|
*/
|
||||||
|
// const convertAndReplaceVideo = async (id: string) => {
|
||||||
|
// const input = FileSystem.documentDirectory + id;
|
||||||
|
// const output = FileSystem.documentDirectory + id + "_tmp.mp4";
|
||||||
|
|
||||||
// Strip the file:/// prefix
|
// const command = `-i ${input} -c:v h264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart ${output}`;
|
||||||
const inputPath = inputUri.replace("file://", "");
|
// try {
|
||||||
const tempOutputPath = tempOutputUri.replace("file://", "");
|
// const session = await FFmpegKit.execute(command);
|
||||||
|
// const rc: ReturnCode = await session.getReturnCode();
|
||||||
|
// if (ReturnCode.isSuccess(rc)) {
|
||||||
|
// console.log("Conversion successful, replacing the original file");
|
||||||
|
|
||||||
const command = `-i ${inputPath} -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart ${tempOutputPath}`;
|
// await FileSystem.moveAsync({
|
||||||
try {
|
// from: output,
|
||||||
const session = await FFmpegKit.execute(command);
|
// to: input,
|
||||||
const rc: ReturnCode = await session.getReturnCode();
|
// });
|
||||||
if (ReturnCode.isSuccess(rc)) {
|
|
||||||
console.log("Conversion successful, replacing the original file");
|
|
||||||
|
|
||||||
await FileSystem.moveAsync({
|
// console.log("Replacement successful");
|
||||||
from: tempOutputUri,
|
// } else {
|
||||||
to: inputUri,
|
// console.log("Conversion failed");
|
||||||
});
|
// }
|
||||||
|
// } catch (error) {
|
||||||
console.log("Replacement successful");
|
// console.error("Error during conversion", error);
|
||||||
} else {
|
// }
|
||||||
console.log("Conversion failed");
|
// };
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during conversion", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useAtom(runningProcesses);
|
const [_, setProgress] = useAtom(runningProcesses);
|
||||||
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -60,9 +67,6 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("MediaSources: ", JSON.stringify(item.MediaSources));
|
|
||||||
console.log("MediaStreams: ", JSON.stringify(item.MediaStreams));
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress({
|
setProgress({
|
||||||
@@ -76,7 +80,7 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|||||||
const filename = `${itemId}`;
|
const filename = `${itemId}`;
|
||||||
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
||||||
|
|
||||||
const url = `${api.basePath}/Items/${itemId}/Download`;
|
const url = `${api.basePath}/Items/${itemId}/File`;
|
||||||
|
|
||||||
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
||||||
url,
|
url,
|
||||||
@@ -117,8 +121,6 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|||||||
JSON.stringify(updatedFiles)
|
JSON.stringify(updatedFiles)
|
||||||
);
|
);
|
||||||
|
|
||||||
await convertAndReplaceVideo(fileUri);
|
|
||||||
|
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
return true;
|
return true;
|
||||||
@@ -150,6 +152,113 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|||||||
return { downloadMedia, isDownloading, error, cancelDownload };
|
return { downloadMedia, isDownloading, error, cancelDownload };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
|
||||||
|
const startRemuxing = useCallback(async () => {
|
||||||
|
if (!item.Id || !item.Name) {
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments"
|
||||||
|
);
|
||||||
|
throw new Error("Item must have an Id and Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToLog(
|
||||||
|
"INFO",
|
||||||
|
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${url}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProgress({
|
||||||
|
item,
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||||
|
let percentage = 0;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (totalFrames > 0) {
|
||||||
|
percentage = Math.floor((processedFrames / totalFrames) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress((prev) => {
|
||||||
|
return prev?.item.Id === item.Id!
|
||||||
|
? { ...prev, progress: percentage, speed }
|
||||||
|
: prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await FFmpegKit.executeAsync(command, async (session) => {
|
||||||
|
const returnCode = await session.getReturnCode();
|
||||||
|
if (returnCode.isValueSuccess()) {
|
||||||
|
const currentFiles: BaseItemDto[] = JSON.parse(
|
||||||
|
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherItems = currentFiles.filter((i) => i.Id !== item.Id);
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
"downloaded_files",
|
||||||
|
JSON.stringify([...otherItems, item])
|
||||||
|
);
|
||||||
|
|
||||||
|
writeToLog(
|
||||||
|
"INFO",
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||||
|
);
|
||||||
|
setProgress(null);
|
||||||
|
} else if (returnCode.isValueError()) {
|
||||||
|
console.error("Failed to remux:");
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||||
|
);
|
||||||
|
setProgress(null);
|
||||||
|
} else if (returnCode.isValueCancel()) {
|
||||||
|
console.log("Remuxing was cancelled");
|
||||||
|
writeToLog(
|
||||||
|
"INFO",
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
|
||||||
|
);
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to remux:", error);
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [output, item, command]);
|
||||||
|
|
||||||
|
const cancelRemuxing = useCallback(async () => {
|
||||||
|
FFmpegKit.cancel();
|
||||||
|
setProgress(null);
|
||||||
|
console.log("Remuxing cancelled");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { startRemuxing, cancelRemuxing };
|
||||||
|
};
|
||||||
|
|
||||||
export const markAsNotPlayed = async ({
|
export const markAsNotPlayed = async ({
|
||||||
api,
|
api,
|
||||||
itemId,
|
itemId,
|
||||||
@@ -550,8 +659,12 @@ export const getStreamUrl = async ({
|
|||||||
throw new Error("no PlaySessionId");
|
throw new Error("no PlaySessionId");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${api.basePath}${mediaSource.TranscodingUrl}`);
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
|
console.log("Using direct stream!");
|
||||||
|
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
return `${api.basePath}${mediaSource.TranscodingUrl}`;
|
return `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
86
utils/profiles/base.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import MediaTypes from '../../constants/MediaTypes';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Name: 'Expo Base Video Profile',
|
||||||
|
MaxStaticBitrate: 100000000,
|
||||||
|
MaxStreamingBitrate: 120000000,
|
||||||
|
MusicStreamingTranscodingBitrate: 384000,
|
||||||
|
CodecProfiles: [
|
||||||
|
{
|
||||||
|
Codec: 'h264',
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: 'NotEquals',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'IsAnamorphic',
|
||||||
|
Value: 'true'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: 'EqualsAny',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'VideoProfile',
|
||||||
|
Value: 'high|main|baseline|constrained baseline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: 'LessThanEqual',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'VideoLevel',
|
||||||
|
Value: '51'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: 'NotEquals',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'IsInterlaced',
|
||||||
|
Value: 'true'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Type: MediaTypes.Video
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Codec: 'hevc',
|
||||||
|
Conditions: [
|
||||||
|
{
|
||||||
|
Condition: 'NotEquals',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'IsAnamorphic',
|
||||||
|
Value: 'true'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: 'EqualsAny',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'VideoProfile',
|
||||||
|
Value: 'main|main 10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: 'LessThanEqual',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'VideoLevel',
|
||||||
|
Value: '183'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: 'NotEquals',
|
||||||
|
IsRequired: false,
|
||||||
|
Property: 'IsInterlaced',
|
||||||
|
Value: 'true'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Type: MediaTypes.Video
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ContainerProfiles: [],
|
||||||
|
DirectPlayProfiles: [],
|
||||||
|
ResponseProfiles: [
|
||||||
|
{
|
||||||
|
Container: 'm4v',
|
||||||
|
MimeType: 'video/mp4',
|
||||||
|
Type: MediaTypes.Video
|
||||||
|
}
|
||||||
|
],
|
||||||
|
SubtitleProfiles: [
|
||||||
|
{
|
||||||
|
Format: 'vtt',
|
||||||
|
Method: 'Hls'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
TranscodingProfiles: []
|
||||||
|
};
|
||||||
149
utils/profiles/ios.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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 13+
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
...BaseProfile,
|
||||||
|
Name: 'Expo iOS Video Profile',
|
||||||
|
DirectPlayProfiles: [
|
||||||
|
{
|
||||||
|
AudioCodec: 'aac,mp3,ac3,eac3,flac,alac',
|
||||||
|
Container: 'mp4,m4v',
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
VideoCodec: 'hevc,h264'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AudioCodec: 'aac,mp3,ac3,eac3,flac,alac',
|
||||||
|
Container: 'mov',
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
VideoCodec: 'hevc,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: 'flac',
|
||||||
|
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,ac3,eac3,flac,alac',
|
||||||
|
Container: 'mp4',
|
||||||
|
Context: 'Static',
|
||||||
|
Protocol: 'http',
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
VideoCodec: 'h264'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
180
utils/profiles/ios10.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
49
utils/profiles/ios12.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
]
|
||||||
|
};
|
||||||
35
utils/profiles/iosFmp4.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||