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.
|
||||
|
||||
<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
|
||||
|
||||
- 🔗 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 { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const { data: downloadedFiles } = useQuery({
|
||||
const { data: downloadedFiles, isLoading } = useQuery({
|
||||
queryKey: ["downloaded_files"],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
@@ -19,7 +19,7 @@ const downloads: React.FC = () => {
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie"),
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
[downloadedFiles]
|
||||
);
|
||||
|
||||
@@ -43,40 +43,45 @@ const downloads: React.FC = () => {
|
||||
);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
// Get all files from FileStorage
|
||||
// const filename = `${itemId}.mp4`;
|
||||
// const fileUri = `${FileSystem.documentDirectory}`;
|
||||
(async () => {
|
||||
if (!FileSystem.documentDirectory) return;
|
||||
const f = await FileSystem.readDirectoryAsync(
|
||||
FileSystem.documentDirectory
|
||||
);
|
||||
console.log("files", FileSystem.documentDirectory, f);
|
||||
})();
|
||||
}, []);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloadedFiles?.length === 0) {
|
||||
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 (
|
||||
<ScrollView>
|
||||
<View className="px-4 py-4">
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2">
|
||||
<Text className="text-2xl font-bold">Movies</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2">
|
||||
<Text className="text-2xl font-bold">Movies</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{movies?.map((item: BaseItemDto) => (
|
||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||
<MovieCard item={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{movies?.map((item: BaseItemDto) => (
|
||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||
<MovieCard item={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View>
|
||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
@@ -29,6 +29,8 @@ const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { id } = local as { id: string };
|
||||
|
||||
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -148,13 +150,20 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
<DownloadItem item={item} />
|
||||
{playbackURL && (
|
||||
<DownloadItem item={item} playbackURL={playbackURL} />
|
||||
)}
|
||||
<Chromecast />
|
||||
</View>
|
||||
<Text>{item.Overview}</Text>
|
||||
</View>
|
||||
<View className="flex flex-col p-4">
|
||||
<VideoPlayer itemId={item.Id} />
|
||||
<VideoPlayer
|
||||
itemId={item.Id}
|
||||
onChangePlaybackURL={(val) => {
|
||||
setPlaybackURL(val);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
|
||||
@@ -1,99 +1,22 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import ProgressCircle from "@/components/ProgressCircle";
|
||||
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 { 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 * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
import { ScrollView, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles } = useFiles();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
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({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => readFromLog(),
|
||||
@@ -111,99 +34,34 @@ export default function settings() {
|
||||
|
||||
<Button onPress={logout}>Log out</Button>
|
||||
|
||||
<View className="mb-4">
|
||||
<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 && (
|
||||
<View className="my-2">
|
||||
<Button
|
||||
className="mb-2"
|
||||
onPress={() => {
|
||||
FFmpegKit.cancel();
|
||||
setSession(null);
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
}}
|
||||
>
|
||||
Cancel all downloads
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="font-bold">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((l) => (
|
||||
<View className="bg-neutral-800 border border-neutral-900 rounded p-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className="bg-neutral-800 border border-neutral-900 rounded p-2"
|
||||
>
|
||||
<Text
|
||||
className={`
|
||||
${l.level === "INFO" && "text-blue-500"}
|
||||
${l.level === "ERROR" && "text-red-500"}
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{l.level}
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text>{l.message}</Text>
|
||||
<Text>{log.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -82,12 +82,6 @@ export default function RootLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
presentation: "modal",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<Feather name="x-circle" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -101,6 +95,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
@@ -116,6 +111,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]/page"
|
||||
options={{
|
||||
title: "",
|
||||
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":
|
||||
return "bg-purple-600 active:bg-purple-700";
|
||||
case "red":
|
||||
return "bg-red-500";
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
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 { 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
@@ -14,9 +18,13 @@ import { Text } from "./common/Text";
|
||||
|
||||
type DownloadProps = {
|
||||
item: BaseItemDto;
|
||||
playbackURL: string;
|
||||
};
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackURL,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
@@ -24,6 +32,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
const { downloadMedia, isDownloading, error, cancelDownload } =
|
||||
useDownloadMedia(api, user?.Id);
|
||||
|
||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackURL, item);
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.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];
|
||||
|
||||
console.log("Source:", JSON.stringify(source));
|
||||
|
||||
if (source?.SupportsDirectPlay && item.CanDownload) {
|
||||
downloadMedia(item);
|
||||
} else {
|
||||
@@ -80,22 +92,34 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
{process ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
cancelDownload();
|
||||
cancelRemuxing();
|
||||
}}
|
||||
className="relative"
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={26}
|
||||
fill={process.progress}
|
||||
width={3}
|
||||
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">
|
||||
<Text className="text-[6px]">{process.progress.toFixed(0)}%</Text>
|
||||
<View className="relative">
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={26}
|
||||
fill={process.progress}
|
||||
width={3}
|
||||
tintColor="#3498db"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
{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>
|
||||
|
||||
{process?.speed && (process?.speed || 0) > 0 ? (
|
||||
<View className="ml-2">
|
||||
<Text>{process.speed.toFixed(2)}x</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
<TouchableOpacity
|
||||
@@ -110,7 +134,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
downloadFile();
|
||||
// downloadFile();
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
chromecastProfile,
|
||||
iosProfile,
|
||||
iOSProfile_2,
|
||||
} from "@/utils/device-profiles";
|
||||
import {
|
||||
getStreamUrl,
|
||||
getUserItemData,
|
||||
@@ -17,7 +22,8 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} 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, {
|
||||
OnBufferData,
|
||||
OnPlaybackStateChangedData,
|
||||
@@ -28,13 +34,12 @@ import Video, {
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
|
||||
import GoogleCast from "react-native-google-cast";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { chromecastProfile, iosProfile } from "@/utils/device-profiles";
|
||||
import iosFmp4 from "../utils/profiles/iosFmp4";
|
||||
import ios12 from "../utils/profiles/ios12";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
itemId: string;
|
||||
onChangePlaybackURL: (url: string | null) => void;
|
||||
};
|
||||
|
||||
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 [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
|
||||
const [paused, setPaused] = useState(true);
|
||||
@@ -108,11 +116,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosProfile,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||
});
|
||||
|
||||
console.log("Transcode URL:", url);
|
||||
|
||||
onChangePlaybackURL(url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData,
|
||||
@@ -165,6 +175,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
|
||||
}, [item]);
|
||||
|
||||
const [hidePlayer, setHidePlayer] = useState(true);
|
||||
|
||||
const enableVideo = useMemo(() => {
|
||||
return (
|
||||
playbackURL !== undefined &&
|
||||
@@ -220,13 +232,11 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
{enableVideo === true &&
|
||||
playbackURL !== null &&
|
||||
playbackURL !== undefined ? (
|
||||
{enableVideo === true ? (
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: playbackURL,
|
||||
uri: playbackURL!,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
}}
|
||||
@@ -242,7 +252,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
videoRef.current?.pause();
|
||||
setPaused(true);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item?.SeriesId],
|
||||
refetchType: "all",
|
||||
@@ -260,19 +269,16 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
positionTicks: progress,
|
||||
sessionId: sessionData?.PlaySessionId,
|
||||
});
|
||||
|
||||
setHidePlayer(true);
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
play();
|
||||
}}
|
||||
paused={paused}
|
||||
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
|
||||
bufferConfig={{
|
||||
maxBufferMs: Infinity,
|
||||
minBufferMs: 1000 * 60 * 2,
|
||||
bufferForPlaybackMs: 1000,
|
||||
backBufferDurationMs: 30 * 1000,
|
||||
}}
|
||||
ignoreSilentSwitch="ignore"
|
||||
preferredForwardBufferDuration={1}
|
||||
/>
|
||||
) : null}
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
@@ -319,8 +325,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
|
||||
onPress={() => {
|
||||
if (chromecastReady) {
|
||||
cast();
|
||||
} else if (videoRef.current) {
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
} else {
|
||||
setHidePlayer(false);
|
||||
setTimeout(() => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
}, 1000);
|
||||
}
|
||||
}}
|
||||
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 { 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 }) => {
|
||||
const open = () => {
|
||||
router.back();
|
||||
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)
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
},
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={open}
|
||||
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<Text className=" text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
||||
</TouchableOpacity>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={openFile}
|
||||
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<Text className=" text-xs opacity-50">
|
||||
Episode {item.IndexNumber}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
alignOffset={0}
|
||||
avoidCollisions
|
||||
collisionPadding={10}
|
||||
loop={false}
|
||||
>
|
||||
{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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
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 }) => {
|
||||
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 (
|
||||
<View className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4">
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text className=" text-xs opacity-50">
|
||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={openFile}
|
||||
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<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}
|
||||
</Text>
|
||||
{seasonItems.map((item, index) => (
|
||||
<View className="mb-2">
|
||||
<EpisodeCard item={item} key={item.Id} />
|
||||
<View className="mb-2" key={index}>
|
||||
<EpisodeCard item={item} />
|
||||
</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 = {
|
||||
item: BaseItemDto;
|
||||
progress: number;
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
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 { runningProcesses } from "./atoms/downloads";
|
||||
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 inputPath = inputUri.replace("file://", "");
|
||||
const tempOutputPath = tempOutputUri.replace("file://", "");
|
||||
// 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}`;
|
||||
// try {
|
||||
// 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}`;
|
||||
try {
|
||||
const session = await FFmpegKit.execute(command);
|
||||
const rc: ReturnCode = await session.getReturnCode();
|
||||
if (ReturnCode.isSuccess(rc)) {
|
||||
console.log("Conversion successful, replacing the original file");
|
||||
// await FileSystem.moveAsync({
|
||||
// from: output,
|
||||
// to: input,
|
||||
// });
|
||||
|
||||
await FileSystem.moveAsync({
|
||||
from: tempOutputUri,
|
||||
to: inputUri,
|
||||
});
|
||||
|
||||
console.log("Replacement successful");
|
||||
} else {
|
||||
console.log("Conversion failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during conversion", error);
|
||||
}
|
||||
};
|
||||
// console.log("Replacement successful");
|
||||
// } else {
|
||||
// console.log("Conversion failed");
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error("Error during conversion", error);
|
||||
// }
|
||||
// };
|
||||
|
||||
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useAtom(runningProcesses);
|
||||
const [_, setProgress] = useAtom(runningProcesses);
|
||||
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
||||
null
|
||||
);
|
||||
@@ -60,9 +67,6 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("MediaSources: ", JSON.stringify(item.MediaSources));
|
||||
console.log("MediaStreams: ", JSON.stringify(item.MediaStreams));
|
||||
|
||||
setIsDownloading(true);
|
||||
setError(null);
|
||||
setProgress({
|
||||
@@ -76,7 +80,7 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
||||
const filename = `${itemId}`;
|
||||
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
||||
|
||||
const url = `${api.basePath}/Items/${itemId}/Download`;
|
||||
const url = `${api.basePath}/Items/${itemId}/File`;
|
||||
|
||||
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
||||
url,
|
||||
@@ -117,8 +121,6 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
||||
JSON.stringify(updatedFiles)
|
||||
);
|
||||
|
||||
await convertAndReplaceVideo(fileUri);
|
||||
|
||||
setIsDownloading(false);
|
||||
setProgress(null);
|
||||
return true;
|
||||
@@ -150,6 +152,113 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
||||
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 ({
|
||||
api,
|
||||
itemId,
|
||||
@@ -550,8 +659,12 @@ export const getStreamUrl = async ({
|
||||
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}`;
|
||||
};
|
||||
|
||||
|
||||
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)
|
||||
]
|
||||
};
|
||||
|
||||