This commit is contained in:
Fredrik Burmester
2024-08-06 21:16:37 +02:00
parent 165a9ddde7
commit 57e33428dc
29 changed files with 1113 additions and 296 deletions

View File

@@ -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.

View File

@@ -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>
);

View File

@@ -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 ">

View File

@@ -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>

View File

@@ -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,
}}
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -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";
}

View File

@@ -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" />

View File

@@ -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={

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
View 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",
};

View File

@@ -4,6 +4,7 @@ import { atom } from "jotai";
export type ProcessItem = {
item: BaseItemDto;
progress: number;
speed?: number;
};
export const runningProcesses = atom<ProcessItem | null>(null);

View File

@@ -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
View 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 };
};

View File

@@ -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
View 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
View 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
View 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
View 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
View 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)
]
};