This commit is contained in:
Fredrik Burmester
2024-08-04 22:25:12 +02:00
parent 25a7edd86b
commit b3a74892c4
48 changed files with 546 additions and 449 deletions

View File

@@ -22,11 +22,6 @@
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<service android:name="com.brentvatne.exoplayer.VideoPlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 286 KiB

View File

@@ -4,11 +4,11 @@
"slug": "streamyfin",
"version": "0.0.4",
"orientation": "portrait",
"icon": "./assets/images/icon.jpg",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.jpg",
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
@@ -16,7 +16,8 @@
"ios": {
"userInterfaceStyle": "dark",
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes."
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin"
@@ -24,22 +25,16 @@
"android": {
"userInterfaceStyle": "light",
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.jpg",
"foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.fredrikburmester.streamyfin"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
"expo-video",
"react-native-compressor",
// "react-native-google-cast",
[
"react-native-video",
{

View File

@@ -5,11 +5,13 @@ import { ItemCardText } from "@/components/ItemCardText";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
@@ -21,7 +23,7 @@ export default function index() {
const router = useRouter();
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
queryKey: ["resumeItems", api, user?.Id],
queryKey: ["resumeItems", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return [];
@@ -85,6 +87,18 @@ export default function index() {
staleTime: 60,
});
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
setLoading(false);
}, [queryClient, user?.Id]);
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-12">
@@ -105,7 +119,12 @@ export default function index() {
if (!data || data.length === 0) return <Text>No data...</Text>;
return (
<ScrollView nestedScrollEnabled>
<ScrollView
nestedScrollEnabled
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="py-4 gap-y-2">
<Text className="px-4 text-2xl font-bold mb-2">Continue Watching</Text>
<HorizontalScroll<BaseItemDto>

View File

@@ -67,8 +67,8 @@ export default function search() {
return (
<ScrollView keyboardDismissMode="on-drag">
<View className="p-4 flex flex-col">
<View className="mb-4">
<View className="flex flex-col py-2">
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
@@ -79,7 +79,7 @@ export default function search() {
/>
</View>
<Text className="font-bold text-2xl mb-2">Movies</Text>
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
<SearchItemWrapper
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
@@ -101,7 +101,7 @@ export default function search() {
/>
)}
/>
<Text className="font-bold text-2xl my-2">Series</Text>
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
renderItem={(data) => (
@@ -123,7 +123,7 @@ export default function search() {
/>
)}
/>
<Text className="font-bold text-2xl my-2">Episodes</Text>
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
renderItem={(data) => (
@@ -195,7 +195,7 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
staleTime: Infinity,
});
if (!data) return <Text className="opacity-50 text-xs">No results</Text>;
if (!data) return <Text className="opacity-50 text-xs px-4">No results</Text>;
return renderItem(data);
};

View File

@@ -0,0 +1,83 @@
import type { PropsWithChildren, ReactElement } from "react";
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
useColorScheme,
} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from "react-native-reanimated";
import { ThemedView } from "@/components/ThemedView";
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}>;
export const ParallaxScrollView: React.FC<Props> = ({
children,
headerImage,
onScroll,
}: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1]
),
},
],
};
});
return (
<ThemedView style={styles.container}>
<Animated.ScrollView ref={scrollRef} scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: "white" },
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 250,
overflow: "hidden",
},
content: {
flex: 1,
overflow: "hidden",
},
});

View File

@@ -13,8 +13,16 @@ import {} from "@jellyfin/sdk/lib/utils/url";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
View,
} from "react-native";
import { ParallaxScrollView } from "./ParallaxPage";
import { Image } from "expo-image";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -23,8 +31,6 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
@@ -34,22 +40,14 @@ const page: React.FC = () => {
itemId: id,
}),
enabled: !!id && !!api,
staleTime: Infinity,
staleTime: 60,
});
useEffect(() => {
navigation.setOptions({
headerRight: () => {
<Ionicons name="accessibility" />;
},
});
}, [item, navigation]);
const { data: posterUrl } = useQuery({
queryKey: ["backdrop", item?.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item?.Id,
staleTime: Infinity,
staleTime: 60 * 60 * 24 * 7,
});
if (l1)
@@ -59,12 +57,23 @@ const page: React.FC = () => {
</View>
);
if (!item?.Id) return null;
if (!item?.Id || !posterUrl) return null;
return (
<ScrollView style={[{ flex: 1 }]} keyboardDismissMode="on-drag">
<LargePoster url={posterUrl} />
<View className="flex flex-col px-4 mb-4">
<ParallaxScrollView
headerImage={
<Image
source={{
uri: posterUrl,
}}
style={{
width: "100%",
height: 250,
}}
/>
}
>
<View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<>
@@ -127,12 +136,10 @@ const page: React.FC = () => {
</View>
</ScrollView>
<View className="px-4 mb-4">
<CastAndCrew item={item} />
</View>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="px-4 mb-4">
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
@@ -140,7 +147,7 @@ const page: React.FC = () => {
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ScrollView>
</ParallaxScrollView>
);
};

View File

@@ -18,7 +18,7 @@ const page: React.FC = () => {
const [user] = useAtom(userAtom);
const { data: item } = useQuery({
queryKey: ["item", seriesId],
queryKey: ["series", seriesId],
queryFn: async () =>
await getUserItemData({
api,
@@ -26,33 +26,23 @@ const page: React.FC = () => {
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: Infinity,
});
const { data: next } = useQuery({
queryKey: ["nextUp", seriesId],
queryFn: async () =>
await nextUp({
userId: user?.Id,
api,
itemId: seriesId,
}),
enabled: !!api && !!seriesId && !!user?.Id,
staleTime: 0,
staleTime: 60,
});
if (!item) return null;
return (
<ScrollView>
<View className="flex flex-col px-4 pt-4 pb-8">
<MoviePoster item={item} />
<View className="my-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
<View className="flex flex-col pt-4 pb-8">
<View className="px-4">
<MoviePoster item={item} />
<View className="my-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
</View>
<SeasonPicker item={item} />
<NextUp items={next} />
<NextUp seriesId={seriesId} />
</View>
</ScrollView>
);

View File

@@ -34,15 +34,22 @@ const deleteAllFiles = async () => {
const deleteFile = async (id: string | null | undefined) => {
if (!id) return;
FileSystem.deleteAsync(`${FileSystem.documentDirectory}/${id}.mp4`).catch(
(err) => console.error(err)
);
try {
FileSystem.deleteAsync(`${FileSystem.documentDirectory}/${id}.mp4`).catch(
(err) => console.error(err)
);
const currentFiles = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) ?? "[]"
);
const updatedFiles = currentFiles.filter((f: string) => f !== id);
await AsyncStorage.setItem("downloaded_files", JSON.stringify(updatedFiles));
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 () => {
@@ -125,8 +132,8 @@ export default function settings() {
subTitle={file.ProductionYear?.toString()}
iconAfter={
<TouchableOpacity
onPress={() => {
deleteFile(file.Id);
onPress={async () => {
await deleteFile(file.Id);
setKey((prevKey) => prevKey + 1);
}}
>
@@ -142,18 +149,20 @@ export default function settings() {
))}
</View>
) : activeProcess ? (
<ListItem
title={activeProcess.item.Name}
iconAfter={
<ProgressCircle
size={22}
fill={activeProcess.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
}
/>
<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 File

@@ -30,12 +30,10 @@ export default function RootLayout() {
defaultOptions: {
queries: {
staleTime: 60,
refetchOnMount: false,
refetchInterval: false,
refetchIntervalInBackground: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retryOnMount: false,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
})

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -23,7 +23,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item.Id,
staleTime: Infinity,
staleTime: 60 * 60 * 24 * 7,
});
const [progress, setProgress] = useState(

View File

@@ -1,182 +1,50 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { writeToLog } from "@/utils/log";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
import Ionicons from "@expo/vector-icons/Ionicons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { router } from "expo-router";
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { ProcessItem, runningProcesses } from "@/utils/atoms/downloads";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useQuery } from "@tanstack/react-query";
type DownloadProps = {
item: BaseItemDto;
};
// const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
// if (!item.Id || !item.Name) {
// writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments", {
// item,
// inputUrl,
// });
// throw new Error("Item must have an Id and Name");
// }
// const [session, setSession] = useAtom<ProcessItem | null>(runningProcesses);
// const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
// const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`;
// const startRemuxing = useCallback(async () => {
// if (!item.Id || !item.Name) {
// writeToLog(
// "ERROR",
// "useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments",
// {
// item,
// inputUrl,
// }
// );
// throw new Error("Item must have an Id and Name");
// }
// writeToLog(
// "INFO",
// `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${inputUrl}`,
// {
// item,
// inputUrl,
// }
// );
// try {
// setSession({
// 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();
// if (totalFrames > 0) {
// percentage = Math.floor((processedFrames / totalFrames) * 100);
// }
// setSession((prev) => {
// return prev?.item.Id === item.Id!
// ? { ...prev, progress: percentage }
// : 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}`,
// {
// item,
// inputUrl,
// }
// );
// setSession(null);
// } else if (returnCode.isValueError()) {
// console.error("Failed to remux:");
// writeToLog(
// "ERROR",
// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// setSession(null);
// } else if (returnCode.isValueCancel()) {
// console.log("Remuxing was cancelled");
// writeToLog(
// "INFO",
// `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// setSession(null);
// }
// });
// } catch (error) {
// console.error("Failed to remux:", error);
// writeToLog(
// "ERROR",
// `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
// {
// item,
// inputUrl,
// }
// );
// }
// }, [inputUrl, output, item, command]);
// const cancelRemuxing = useCallback(async () => {
// FFmpegKit.cancel();
// setSession(null);
// console.log("Remuxing cancelled");
// }, []);
// return { session, startRemuxing, cancelRemuxing };
// };
export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
// const { session, startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(
// url,
// item
// );
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const { downloadMedia, isDownloading, error } = useDownloadMedia(api);
const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
});
const downloadFile = useCallback(async () => {
const playbackInfo = await getPlaybackInfo(api, item.Id, user?.Id);
if (!playbackInfo) return;
const source = playbackInfo?.MediaSources?.[0];
const source = playbackInfo.MediaSources?.[0];
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
console.log("file not supported");
throw new Error(
"Direct play not supported thus the file cannot be downloaded"
);
}
}, [item, user]);
}, [item, user, playbackInfo]);
const [downloaded, setDownloaded] = useState<boolean>(false);
const [key, setKey] = useState<string>("");
useEffect(() => {
(async () => {
@@ -186,7 +54,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
})();
}, [key]);
}, [process]);
if (isLoading) {
return <ActivityIndicator size={"small"} color={"white"} />;
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
}
if (process && process.item.Id !== item.Id!) {
return (
@@ -201,17 +81,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
{process ? (
<TouchableOpacity
onPress={() => {
// cancelRemuxing();
cancelDownload();
}}
className="-rotate-45"
className="relative"
>
<ProgressCircle
size={22}
fill={process.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
<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>
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
@@ -221,7 +106,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
);
}}
>
<Ionicons name="cloud-download" size={28} color="#16a34a" />
<Ionicons name="cloud-download" size={26} color="#16a34a" />
</TouchableOpacity>
) : (
<TouchableOpacity
@@ -229,7 +114,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
downloadFile();
}}
>
<Ionicons name="cloud-download-outline" size={28} color="white" />
<Ionicons name="cloud-download-outline" size={26} color="white" />
</TouchableOpacity>
)}
</View>

View File

@@ -4,7 +4,7 @@ import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient, InvalidateQueryFilters } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React from "react";
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import * as Haptics from "expo-haptics";
@@ -14,6 +14,29 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const queryClient = useQueryClient();
const invalidateQueries = useCallback(() => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["resumeItems", user?.Id],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["seasons"],
refetchType: "all",
});
}, [api, item.Id, queryClient, user?.Id]);
return (
<View>
{item.UserData?.Played ? (
@@ -24,11 +47,8 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
itemId: item?.Id,
userId: user?.Id,
});
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
refetchType: "all",
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle" size={30} color="white" />
@@ -41,11 +61,8 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
itemId: item?.Id,
userId: user?.Id,
});
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
refetchType: "all",
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle-outline" size={30} color="white" />

View File

@@ -1,14 +1,17 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
ActivityIndicator,
Switch,
TouchableOpacity,
View,
} from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
getStreamUrl,
getUserItemData,
reportPlaybackProgress,
reportPlaybackStopped,
} from "@/utils/jellyfin";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
@@ -16,17 +19,8 @@ import Video, {
OnVideoErrorData,
VideoRef,
} from "react-native-video";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import {
getBackdrop,
getStreamUrl,
getUserItemData,
reportPlaybackProgress,
reportPlaybackStopped,
} from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "./Button";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "./common/Text";
type VideoPlayerProps = {
@@ -36,11 +30,7 @@ type VideoPlayerProps = {
const BITRATES = [
{
key: "Max",
value: 140000000,
},
{
key: "10 Mb/s",
value: 10000000,
value: undefined,
},
{
key: "4 Mb/s",
@@ -50,10 +40,6 @@ const BITRATES = [
key: "2 Mb/s",
value: 2000000,
},
{
key: "1 Mb/s",
value: 1000000,
},
{
key: "500 Kb/s",
value: 500000,
@@ -62,12 +48,8 @@ const BITRATES = [
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const videoRef = useRef<VideoRef | null>(null);
const [showPoster, setShowPoster] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [buffering, setBuffering] = useState(false);
const [maxBitrate, setMaxbitrate] = useState(140000000);
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [forceTranscoding, setForceTranscoding] = useState<boolean>(false);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -81,7 +63,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
itemId,
}),
enabled: !!itemId && !!api,
staleTime: 0,
staleTime: 60,
});
const { data: sessionData } = useQuery({
@@ -182,7 +164,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
return (
<View>
{playbackURL && (
{enableVideo === true &&
playbackURL !== null &&
playbackURL !== undefined ? (
<Video
style={{ width: 0, height: 0 }}
source={{
@@ -190,6 +174,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
isNetwork: true,
startPosition,
}}
debug={{
enable: true,
thread: true,
}}
ref={videoRef}
onBuffer={onBuffer}
onSeek={(t) => onSeek(t)}
@@ -216,7 +204,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
backBufferDurationMs: 30 * 1000,
}}
/>
)}
) : null}
<View className="flex flex-row items-center justify-between">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -241,9 +229,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b: any) => (
{BITRATES?.map((b: any, index: number) => (
<DropdownMenu.Item
key={b.value}
key={index.toString()}
onSelect={() => {
setMaxbitrate(b.value);
}}

View File

@@ -8,7 +8,7 @@ import Poster from "../Poster";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
return (
<View>
<Text className="text-lg font-bold mb-2">Cast & Crew</Text>
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<HorizontalScroll<NonNullable<BaseItemDto["People"]>[number]>
data={item.People}
renderItem={(item, index) => (

View File

@@ -9,7 +9,7 @@ import { Text } from "../common/Text";
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
return (
<View>
<Text className="text-lg font-bold mb-2">Series</Text>
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
<HorizontalScroll<BaseItemDto>
data={[item]}
renderItem={(item, index) => (

View File

@@ -7,8 +7,27 @@ import Poster from "../Poster";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { router } from "expo-router";
import { nextUp } from "@/utils/jellyfin";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { data: items } = useQuery({
queryKey: ["nextUp", seriesId],
queryFn: async () =>
await nextUp({
userId: user?.Id,
api,
itemId: seriesId,
}),
enabled: !!api && !!seriesId && !!user?.Id,
staleTime: 0,
});
export const NextUp = ({ items }: { items?: BaseItemDto[] | null }) => {
if (!items?.length)
return (
<View>
@@ -19,7 +38,7 @@ export const NextUp = ({ items }: { items?: BaseItemDto[] | null }) => {
return (
<View>
<Text className="text-lg font-bold mb-2">Next up</Text>
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
<HorizontalScroll<BaseItemDto>
data={items}
renderItem={(item, index) => (

View File

@@ -84,7 +84,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row">
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {selectedSeason}</Text>
</TouchableOpacity>

View File

@@ -1,5 +1,10 @@
PODS:
- boost (1.83.0)
- ComputableLayout (0.7.0):
- DGSwiftUtilities (~> 0.11)
- ContextMenuAuxiliaryPreview (0.5.0):
- DGSwiftUtilities (~> 0.18.1)
- DGSwiftUtilities (0.18.1)
- DoubleConversion (1.1.6)
- EXConstants (16.0.2):
- ExpoModulesCore
@@ -221,6 +226,8 @@ PODS:
- ExpoModulesCore
- ExpoFont (12.0.9):
- ExpoModulesCore
- ExpoHaptics (13.0.1):
- ExpoModulesCore
- ExpoHead (3.5.20):
- ExpoModulesCore
- ExpoImage (1.12.13):
@@ -1264,6 +1271,8 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-menu (1.1.2):
- React
- react-native-safe-area-context (4.10.5):
- React-Core
- react-native-video (6.4.3):
@@ -1538,7 +1547,16 @@ PODS:
- React-logger (= 0.74.3)
- React-perflogger (= 0.74.3)
- React-utils (= 0.74.3)
- RNCAsyncStorage (1.24.0):
- ReactNativeIosContextMenu (2.5.1):
- ContextMenuAuxiliaryPreview (~> 0.3)
- DGSwiftUtilities
- ExpoModulesCore
- ReactNativeIosUtilities
- ReactNativeIosUtilities (4.4.5):
- ComputableLayout (~> 0.7)
- DGSwiftUtilities (~> 0.17)
- ExpoModulesCore
- RNCAsyncStorage (1.23.1):
- React-Core
- RNGestureHandler (2.16.2):
- DoubleConversion
@@ -1604,7 +1622,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNSVG (15.4.0):
- RNSVG (15.2.0):
- React-Core
- SDWebImage (5.19.2):
- SDWebImage/Core (= 5.19.2)
@@ -1634,6 +1652,7 @@ DEPENDENCIES:
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoHaptics (from `../node_modules/expo-haptics/ios`)
- ExpoHead (from `../node_modules/expo-router/ios`)
- ExpoImage (from `../node_modules/expo-image/ios`)
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
@@ -1675,6 +1694,7 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- react-native-compressor (from `../node_modules/react-native-compressor`)
- "react-native-menu (from `../node_modules/@react-native-menu/menu`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-video (from `../node_modules/react-native-video`)
- React-nativeconfig (from `../node_modules/react-native/ReactCommon`)
@@ -1700,6 +1720,8 @@ DEPENDENCIES:
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- ReactNativeIosContextMenu (from `../node_modules/react-native-ios-context-menu`)
- ReactNativeIosUtilities (from `../node_modules/react-native-ios-utilities`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
@@ -1709,6 +1731,9 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- ComputableLayout
- ContextMenuAuxiliaryPreview
- DGSwiftUtilities
- ffmpeg-kit-ios-https
- libavif
- libdav1d
@@ -1746,6 +1771,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-file-system/ios"
ExpoFont:
:path: "../node_modules/expo-font/ios"
ExpoHaptics:
:path: "../node_modules/expo-haptics/ios"
ExpoHead:
:path: "../node_modules/expo-router/ios"
ExpoImage:
@@ -1825,6 +1852,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
react-native-compressor:
:path: "../node_modules/react-native-compressor"
react-native-menu:
:path: "../node_modules/@react-native-menu/menu"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-video:
@@ -1875,6 +1904,10 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/utils"
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
ReactNativeIosContextMenu:
:path: "../node_modules/react-native-ios-context-menu"
ReactNativeIosUtilities:
:path: "../node_modules/react-native-ios-utilities"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNGestureHandler:
@@ -1890,6 +1923,9 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: d3f49c53809116a5d38da093a8aa78bf551aed09
ComputableLayout: c50faffac4ed9f8f05b0ce5e6f3a60df1f6042c8
ContextMenuAuxiliaryPreview: 1c73742ff3100dac1e0fb70bbe30284a6e711071
DGSwiftUtilities: 9be88816a2057f125bc8fac3da6aca3ae89c1eec
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
EXConstants: 409690fbfd5afea964e5e9d6c4eb2c2b59222c59
EXJSONUtils: 30c17fd9cc364d722c0946a550dfbf1be92ef6a4
@@ -1902,6 +1938,7 @@ SPEC CHECKSUMS:
ExpoAsset: 323700f291684f110fb55f0d4022a3362ea9f875
ExpoFileSystem: 80bfe850b1f9922c16905822ecbf97acd711dc51
ExpoFont: e7f2275c10ca8573c991e007329ad6bf98086485
ExpoHaptics: 5a3a88971af384255baf2504f38b41189cec6984
ExpoHead: 3e8eacccdad1256f0643b657d89bf972c27afb1d
ExpoImage: cbe7617b4e0e7ba064cc4caa51bfc96262e51ef3
ExpoKeepAwake: 3b8815d9dd1d419ee474df004021c69fdd316d08
@@ -1945,6 +1982,7 @@ SPEC CHECKSUMS:
React-logger: fa92ba4d3a5d39ac450f59be2a3cec7b099f0304
React-Mapbuffer: 9f68550e7c6839d01411ac8896aea5c868eff63a
react-native-compressor: cef7d532563b6c4b190e69c12b3ffd09d8237483
react-native-menu: d32728a357dfb360cf01cd5979cf7713c5acbb95
react-native-safe-area-context: a240ad4b683349e48b1d51fed1611138d1bdad97
react-native-video: b3ba8f424c8c3f54dd9289d47bbe60fbc09bc986
React-nativeconfig: fa5de9d8f4dbd5917358f8ad3ad1e08762f01dcb
@@ -1970,11 +2008,13 @@ SPEC CHECKSUMS:
React-runtimescheduler: 0c80752bceb80924cb8a4babc2a8e3ed70d41e87
React-utils: a06061b3887c702235d2dac92dacbd93e1ea079e
ReactCommon: f00e436b3925a7ae44dfa294b43ef360fbd8ccc4
RNCAsyncStorage: ec53e44dc3e75b44aa2a9f37618a49c3bc080a7a
ReactNativeIosContextMenu: e5f972174bd78ab3a552bd6ee4745762ffaa42b3
ReactNativeIosUtilities: 638290fbfaa093c1b50d1f926715d77cabcae759
RNCAsyncStorage: 826b603ae9c0f88b5ac4e956801f755109fa4d5c
RNGestureHandler: 2282cfbcf86c360d29f44ace393203afd5c6cff7
RNReanimated: 35f9ac9c3ac42d0497ebd1cce5c39d7687a8493e
RNScreens: b32a9ff15bea7fcdbe5dff6477bc503f792b1208
RNSVG: cb24fb322de8c1ebf59904e7aca0447bb8dbed5a
RNSVG: 43b64ed39c14ce830d840903774154ca0c1f27ec
SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c

View File

@@ -10,12 +10,12 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
3257823D68914DDEBB3B12F1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230AA53500BA4DD9B884537F /* noop-file.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
64C94651955506861C263566 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2CAF2D47E225701446B0FD60 /* PrivacyInfo.xcprivacy */; };
96905EF65AED1B983A6B3ABC /* libPods-Streamyfin.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
B4F991FB2D65E78D01D256DB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2ACC4880CDABD7035AA9CD9D /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
FC4E9E0516EB46929623ECE3 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377710512B91474B8B78B27E /* noop-file.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -25,14 +25,14 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Streamyfin/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Streamyfin/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Streamyfin/main.m; sourceTree = "<group>"; };
2CAF2D47E225701446B0FD60 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Streamyfin/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
377710512B91474B8B78B27E /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Streamyfin/noop-file.swift"; sourceTree = "<group>"; };
230AA53500BA4DD9B884537F /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Streamyfin/noop-file.swift"; sourceTree = "<group>"; };
2ACC4880CDABD7035AA9CD9D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Streamyfin/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Streamyfin.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6C2E3173556A471DD304B334 /* Pods-Streamyfin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Streamyfin.debug.xcconfig"; path = "Target Support Files/Pods-Streamyfin/Pods-Streamyfin.debug.xcconfig"; sourceTree = "<group>"; };
74C714457E3741B898D70F79 /* Streamyfin-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Streamyfin-Bridging-Header.h"; path = "Streamyfin/Streamyfin-Bridging-Header.h"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Streamyfin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Streamyfin.release.xcconfig"; path = "Target Support Files/Pods-Streamyfin/Pods-Streamyfin.release.xcconfig"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Streamyfin/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C04FDA7EF00C4288BCABD623 /* Streamyfin-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Streamyfin-Bridging-Header.h"; path = "Streamyfin/Streamyfin-Bridging-Header.h"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Streamyfin/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -59,9 +59,9 @@
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
377710512B91474B8B78B27E /* noop-file.swift */,
74C714457E3741B898D70F79 /* Streamyfin-Bridging-Header.h */,
2CAF2D47E225701446B0FD60 /* PrivacyInfo.xcprivacy */,
230AA53500BA4DD9B884537F /* noop-file.swift */,
C04FDA7EF00C4288BCABD623 /* Streamyfin-Bridging-Header.h */,
2ACC4880CDABD7035AA9CD9D /* PrivacyInfo.xcprivacy */,
);
name = Streamyfin;
sourceTree = "<group>";
@@ -147,13 +147,13 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Streamyfin" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
FDD61697DC04AF9E42AF4D0B /* [Expo] Configure project */,
3552E666C7970D5BA4441A37 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
C3D83F10504094BBDE73DF71 /* [CP] Embed Pods Frameworks */,
605963CCBC2CC9CF367DC9DF /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -203,7 +203,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
64C94651955506861C263566 /* PrivacyInfo.xcprivacy in Resources */,
B4F991FB2D65E78D01D256DB /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -247,6 +247,59 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3552E666C7970D5BA4441A37 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Streamyfin/expo-configure-project.sh\"\n";
};
605963CCBC2CC9CF367DC9DF /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/ffmpegkit.framework/ffmpegkit",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavcodec.framework/libavcodec",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavdevice.framework/libavdevice",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavfilter.framework/libavfilter",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavformat.framework/libavformat",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavutil.framework/libavutil",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libswresample.framework/libswresample",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libswscale.framework/libswscale",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ffmpegkit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavdevice.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavfilter.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswscale.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -281,59 +334,6 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-resources.sh\"\n";
showEnvVarsInLog = 0;
};
C3D83F10504094BBDE73DF71 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/ffmpegkit.framework/ffmpegkit",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavcodec.framework/libavcodec",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavdevice.framework/libavdevice",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavfilter.framework/libavfilter",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavformat.framework/libavformat",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavutil.framework/libavutil",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libswresample.framework/libswresample",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libswscale.framework/libswscale",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ffmpegkit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavdevice.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavfilter.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswscale.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
FDD61697DC04AF9E42AF4D0B /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Streamyfin/expo-configure-project.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -344,7 +344,7 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
FC4E9E0516EB46929623ECE3 /* noop-file.swift in Sources */,
3257823D68914DDEBB3B12F1 /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -375,7 +375,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.fredrikburmester.streamyfin;
PRODUCT_NAME = "Streamyfin";
PRODUCT_NAME = Streamyfin;
SWIFT_OBJC_BRIDGING_HEADER = "Streamyfin/Streamyfin-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -403,7 +403,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.fredrikburmester.streamyfin;
PRODUCT_NAME = "Streamyfin";
PRODUCT_NAME = Streamyfin;
SWIFT_OBJC_BRIDGING_HEADER = "Streamyfin/Streamyfin-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -51,10 +51,11 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>The app needs access to your camera to scan barcodes.</string>
<key>NSMicrophoneUsageDescription</key>
<string>The app needs access to your microphone.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>

View File

@@ -17,7 +17,7 @@
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0",
"@react-native-async-storage/async-storage": "^1.24.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.2",
"@react-navigation/native": "^6.0.2",
"@tanstack/react-query": "^5.51.16",
@@ -32,7 +32,6 @@
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-video": "^1.2.4",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",
@@ -43,13 +42,12 @@
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1",
"react-native-google-cast": "^4.8.2",
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "^15.4.0",
"react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.4.3",
"react-native-web": "~0.19.10",

View File

@@ -118,6 +118,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
queryFn: async () => {
try {
const token = await AsyncStorage.getItem("token");
console.log({ token });
const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string

View File

@@ -8,22 +8,24 @@ import {
getMediaInfoApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { iosProfile } from "./device-profiles";
import * as FileSystem from "expo-file-system";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { useCallback, useRef, useState } from "react";
import { runningProcesses } from "./atoms/downloads";
import { useCallback, useState } from "react";
import { createVideoUrl } from "./video/createVideoUrl";
import { iosProfile } from "./device-profiles";
export const useDownloadMedia = (api: Api | null) => {
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 downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
null
);
const downloadMedia = useCallback(
async (item: BaseItemDto | null) => {
if (!item?.Id || !api) {
if (!item?.Id || !api || !userId) {
setError("Invalid item or API");
return false;
}
@@ -33,12 +35,38 @@ export const useDownloadMedia = (api: Api | null) => {
const itemId = item.Id;
console.info("Downloading media item", item);
// const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
// itemId,
// userId: userId,
// });
// const url = await getStreamUrl({
// api,
// userId: userId,
// item,
// startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
// sessionData: playbackData.data,
// });
// if (!url) {
// setError("Could not get stream URL");
// setIsDownloading(false);
// setProgress(null);
// return false;
// }
try {
const filename = `${itemId}.mp4`;
const fileUri = `${FileSystem.documentDirectory}${filename}`;
const downloadResumable = FileSystem.createDownloadResumable(
`${api.basePath}/Items/${itemId}/Download`,
const url = `${api.basePath}/Items/${itemId}/Download`;
console.info("Starting download of media item from URL", url);
downloadResumableRef.current = FileSystem.createDownloadResumable(
url,
fileUri,
{
headers: {
@@ -49,7 +77,6 @@ export const useDownloadMedia = (api: Api | null) => {
const currentProgress =
downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
console.log(`Download progress: ${currentProgress * 100}%`);
setProgress({
item,
@@ -58,7 +85,7 @@ export const useDownloadMedia = (api: Api | null) => {
}
);
const res = await downloadResumable.downloadAsync();
const res = await downloadResumableRef.current.downloadAsync();
const uri = res?.uri;
console.log("File downloaded to:", uri);
@@ -78,6 +105,7 @@ export const useDownloadMedia = (api: Api | null) => {
);
setIsDownloading(false);
setProgress(null);
return true;
} catch (error) {
console.error("Error downloading media:", error);
@@ -89,7 +117,22 @@ export const useDownloadMedia = (api: Api | null) => {
[api, setProgress]
);
return { downloadMedia, isDownloading, error };
const cancelDownload = useCallback(async () => {
if (downloadResumableRef.current) {
try {
await downloadResumableRef.current.pauseAsync();
setIsDownloading(false);
setError("Download cancelled");
setProgress(null);
downloadResumableRef.current = null;
} catch (error) {
console.error("Error cancelling download:", error);
setError("Failed to cancel download");
}
}
}, [setProgress]);
return { downloadMedia, isDownloading, error, cancelDownload };
};
export const markAsNotPlayed = async ({
@@ -442,63 +485,72 @@ export const getStreamUrl = async ({
return null;
}
try {
const itemId = item.Id;
const itemId = item.Id;
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
DeviceProfile: {
...iosProfile,
MaxStaticBitrate: maxStreamingBitrate,
MaxStreamingBitrate: maxStreamingBitrate,
},
UserId: userId,
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
DeviceProfile: {
...iosProfile,
MaxStaticBitrate: maxStreamingBitrate,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: startTimeTicks,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
MediaSourceId: itemId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (!mediaSource) {
throw new Error("No media source");
}
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
UserId: userId,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: startTimeTicks,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
MediaSourceId: itemId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
const streamParams = new URLSearchParams({
itemId,
deviceId: "",
mediaSourceId: itemId,
videoCodec: "h264,h264",
audioCodec: "aac",
playSessionId: sessionData.PlaySessionId,
transcodingMaxAudioChannels: "2",
tag: mediaSource.ETag || "",
segmentContainer: "mp4",
});
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (maxStreamingBitrate) {
// streamParams.append("videoBitRate", maxStreamingBitrate.toString());
// streamParams.append("transcodeReasons", "ContainerBitrateExceedsLimit");
}
return `/Videos/${itemId}/main.m3u8?${streamParams.toString()}`;
} catch (error) {
console.log(error);
return null;
if (!mediaSource) {
throw new Error("No media source");
}
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
}
const streamParams = new URLSearchParams({
Static: "true",
api_key: api.accessToken,
playSessionId: sessionData.PlaySessionId || "",
videoCodec: "h265,h264",
audioCodec: "aac",
maxAudioChannels: "6",
mediaSourceId: itemId,
Tag: mediaSource.ETag || "",
TranscodingMaxAudioChannels: "2",
RequireAvc: "false",
SegmentContainer: "mp4",
MinSegments: "2",
BreakOnNonKeyFrames: "True",
context: "Streaming",
"h264-level": "40",
"h264-videobitdepth": "8",
"h264-profile": "high",
"h264-audiochannels": "2",
"aac-profile": "lc",
"h264-rangetype": "SDR",
"h264-deinterlace": "true",
});
if (maxStreamingBitrate) {
streamParams.append("videoBitRate", maxStreamingBitrate.toString());
streamParams.append("transcodeReasons", "ContainerBitrateExceedsLimit");
}
return `${
api.basePath
}/Videos/${itemId}/main.m3u8?${streamParams.toString()}`;
};
/**