diff --git a/README.md b/README.md
index 0b08e9f3..0c183897 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
+
## 🌟 Features
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
diff --git a/app/(auth)/downloads.tsx b/app/(auth)/downloads.tsx
index a4ec967b..53805108 100644
--- a/app/(auth)/downloads.tsx
+++ b/app/(auth)/downloads.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (downloadedFiles?.length === 0) {
+ return (
+
+
+ No downloaded files
+
+
+ );
+ }
return (
-
-
- Movies
-
- {movies?.length}
+ {movies.length > 0 && (
+
+
+ Movies
+
+ {movies?.length}
+
+ {movies?.map((item: BaseItemDto) => (
+
+
+
+ ))}
- {movies?.map((item: BaseItemDto) => (
-
-
-
- ))}
-
-
- {groupedBySeries?.map((items: BaseItemDto[], index: number) => (
-
- ))}
-
+ )}
+ {groupedBySeries?.map((items: BaseItemDto[], index: number) => (
+
+ ))}
);
diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx
index 61c49f62..94c55938 100644
--- a/app/(auth)/items/[id]/page.tsx
+++ b/app/(auth)/items/[id]/page.tsx
@@ -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(null);
+
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -148,13 +150,20 @@ const page: React.FC = () => {
-
+ {playbackURL && (
+
+ )}
{item.Overview}
-
+ {
+ setPlaybackURL(val);
+ }}
+ />
diff --git a/app/(auth)/settings.tsx b/app/(auth)/settings.tsx
index 3d28f5d1..b970cdd8 100644
--- a/app/(auth)/settings.tsx
+++ b/app/(auth)/settings.tsx
@@ -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([]);
- 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() {
Log out
-
- Downloads
-
- {files.length > 0 ? (
-
- {files.map((file) => (
- {
- router.back();
- router.push(
- `/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}`
- );
- }}
- >
- {
- await deleteFile(file.Id);
- setKey((prevKey) => prevKey + 1);
- }}
- >
-
-
- }
- />
-
- ))}
-
- ) : activeProcess ? (
-
-
- }
- />
-
- ) : (
- No downloaded files
- )}
-
-
- {
- await deleteAllFiles();
- setKey((prevKey) => prevKey + 1);
- }}
- >
- Clear downloads
-
-
- {session?.item.Id && (
+
{
- FFmpegKit.cancel();
- setSession(null);
+ color="red"
+ onPress={async () => {
+ await deleteAllFiles();
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}}
>
- Cancel all downloads
+ Delete all downloaded files
- )}
+
Logs
- {logs?.map((l) => (
-
+ {logs?.map((log, index) => (
+
- {l.level}
+ {log.level}
- {l.message}
+ {log.message}
))}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 670e3d87..da82317c 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -82,12 +82,6 @@ export default function RootLayout() {
options={{
headerShown: true,
title: "Downloads",
- presentation: "modal",
- headerLeft: () => (
- router.back()}>
-
-
- ),
}}
/>
@@ -116,6 +111,7 @@ export default function RootLayout() {
diff --git a/assets/images/screenshots/1.jpg b/assets/images/screenshots/1.jpg
new file mode 100644
index 00000000..67971fc3
Binary files /dev/null and b/assets/images/screenshots/1.jpg differ
diff --git a/assets/images/screenshots/2.jpg b/assets/images/screenshots/2.jpg
new file mode 100644
index 00000000..66857460
Binary files /dev/null and b/assets/images/screenshots/2.jpg differ
diff --git a/assets/images/screenshots/3.jpg b/assets/images/screenshots/3.jpg
new file mode 100644
index 00000000..7e745836
Binary files /dev/null and b/assets/images/screenshots/3.jpg differ
diff --git a/assets/images/screenshots/4.jpg b/assets/images/screenshots/4.jpg
new file mode 100644
index 00000000..9e277fc0
Binary files /dev/null and b/assets/images/screenshots/4.jpg differ
diff --git a/assets/images/screenshots/5.jpg b/assets/images/screenshots/5.jpg
new file mode 100644
index 00000000..637554c2
Binary files /dev/null and b/assets/images/screenshots/5.jpg differ
diff --git a/assets/images/screenshots/6.jpg b/assets/images/screenshots/6.jpg
new file mode 100644
index 00000000..c6971ad9
Binary files /dev/null and b/assets/images/screenshots/6.jpg differ
diff --git a/assets/images/screenshots/7.jpg b/assets/images/screenshots/7.jpg
new file mode 100644
index 00000000..553ba6aa
Binary files /dev/null and b/assets/images/screenshots/7.jpg differ
diff --git a/assets/images/screenshots/8.jpg b/assets/images/screenshots/8.jpg
new file mode 100644
index 00000000..efb8b071
Binary files /dev/null and b/assets/images/screenshots/8.jpg differ
diff --git a/components/Button.tsx b/components/Button.tsx
index 89f35ba3..616e6764 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -32,7 +32,7 @@ export const Button: React.FC> = ({
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";
}
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 6a34a2ab..40dc9159 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -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 = ({ item }) => {
+export const DownloadItem: React.FC = ({
+ item,
+ playbackURL,
+}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
@@ -24,6 +32,8 @@ export const DownloadItem: React.FC = ({ 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 = ({ 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 = ({ item }) => {
{process ? (
{
- cancelDownload();
+ cancelRemuxing();
}}
- className="relative"
+ className="flex flex-row items-center"
>
-
-
-
-
- {process.progress.toFixed(0)}%
+
+
+
+
+ {process.progress > 0 ? (
+
+
+ {process.progress.toFixed(0)}%
+
+
+ ) : null}
+
+ {process?.speed && (process?.speed || 0) > 0 ? (
+
+ {process.speed.toFixed(2)}x
+
+ ) : null}
) : downloaded ? (
= ({ item }) => {
) : (
{
- downloadFile();
+ // downloadFile();
+ startRemuxing();
}}
>
diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx
index e6ecb899..675c4fa9 100644
--- a/components/VideoPlayer.tsx
+++ b/components/VideoPlayer.tsx
@@ -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 = ({ itemId }) => {
+export const VideoPlayer: React.FC = ({
+ itemId,
+ onChangePlaybackURL,
+}) => {
const videoRef = useRef(null);
const [maxBitrate, setMaxbitrate] = useState(undefined);
const [paused, setPaused] = useState(true);
@@ -108,11 +116,13 @@ export const VideoPlayer: React.FC = ({ 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 = ({ 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 = ({ itemId }) => {
return (
- {enableVideo === true &&
- playbackURL !== null &&
- playbackURL !== undefined ? (
+ {enableVideo === true ? (
= ({ itemId }) => {
onFullscreenPlayerDidDismiss={() => {
videoRef.current?.pause();
setPaused(true);
-
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
@@ -260,19 +269,16 @@ export const VideoPlayer: React.FC = ({ 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}
@@ -319,8 +325,12 @@ export const VideoPlayer: React.FC = ({ 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={
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 69974a04..28c257ea 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -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 (
-
- {item.Name}
- Episode {item.IndexNumber}
-
+
+
+
+ {item.Name}
+
+ Episode {item.IndexNumber}
+
+
+
+
+ {options.map((i) => (
+ {
+ i.onSelect(item.Id!);
+ }}
+ key={i.label}
+ destructive={i.destructive}
+ >
+
+ {i.label}
+
+
+ ))}
+
+
);
};
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 4f47bcc7..1e254bef 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -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 (
-
- {item.Name}
-
- {item.ProductionYear}
-
- {runtimeTicksToMinutes(item.RunTimeTicks)}
-
-
-
+
+
+
+ {item.Name}
+
+ {item.ProductionYear}
+
+ {runtimeTicksToMinutes(item.RunTimeTicks)}
+
+
+
+
+
+ {options.map((i) => (
+ {
+ i.onSelect(item.Id!);
+ }}
+ key={i.label}
+ destructive={i.destructive}
+ >
+
+ {i.label}
+
+
+ ))}
+
+
);
};
diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx
index b1470fe5..96c63675 100644
--- a/components/downloads/SeriesCard.tsx
+++ b/components/downloads/SeriesCard.tsx
@@ -38,8 +38,8 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
{seasonItems[0].SeasonName}
{seasonItems.map((item, index) => (
-
-
+
+
))}
diff --git a/constants/MediaTypes.js b/constants/MediaTypes.js
new file mode 100644
index 00000000..2cf275cc
--- /dev/null
+++ b/constants/MediaTypes.js
@@ -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",
+};
diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts
index 7343ce64..89828aa7 100644
--- a/utils/atoms/downloads.ts
+++ b/utils/atoms/downloads.ts
@@ -4,6 +4,7 @@ import { atom } from "jotai";
export type ProcessItem = {
item: BaseItemDto;
progress: number;
+ speed?: number;
};
export const runningProcesses = atom(null);
diff --git a/utils/device-profiles.ts b/utils/device-profiles.ts
index 3dd1ac4d..0810820f 100644
--- a/utils/device-profiles.ts
+++ b/utils/device-profiles.ts
@@ -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",
+ },
+ ],
+};
diff --git a/utils/files/useFiles.ts b/utils/files/useFiles.ts
new file mode 100644
index 00000000..4eef2d13
--- /dev/null
+++ b/utils/files/useFiles.ts
@@ -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 };
+};
diff --git a/utils/jellyfin.ts b/utils/jellyfin.ts
index 2ab20bd8..b9df6615 100644
--- a/utils/jellyfin.ts
+++ b/utils/jellyfin.ts
@@ -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(null);
- const [progress, setProgress] = useAtom(runningProcesses);
+ const [_, setProgress] = useAtom(runningProcesses);
const downloadResumableRef = useRef(
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}`;
};
diff --git a/utils/profiles/base.js b/utils/profiles/base.js
new file mode 100644
index 00000000..e58039eb
--- /dev/null
+++ b/utils/profiles/base.js
@@ -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: []
+};
diff --git a/utils/profiles/ios.js b/utils/profiles/ios.js
new file mode 100644
index 00000000..5a143609
--- /dev/null
+++ b/utils/profiles/ios.js
@@ -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'
+ }
+ ]
+};
diff --git a/utils/profiles/ios10.js b/utils/profiles/ios10.js
new file mode 100644
index 00000000..349eff95
--- /dev/null
+++ b/utils/profiles/ios10.js
@@ -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'
+ }
+ ]
+};
diff --git a/utils/profiles/ios12.js b/utils/profiles/ios12.js
new file mode 100644
index 00000000..dc509ad8
--- /dev/null
+++ b/utils/profiles/ios12.js
@@ -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
+ ]
+};
diff --git a/utils/profiles/iosFmp4.js b/utils/profiles/iosFmp4.js
new file mode 100644
index 00000000..5e47eb39
--- /dev/null
+++ b/utils/profiles/iosFmp4.js
@@ -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)
+ ]
+};
+