This commit is contained in:
Fredrik Burmester
2024-08-06 08:18:17 +02:00
parent 2aa30ab4ca
commit 382e70cf8e
55 changed files with 4135 additions and 397 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.0.5",
"version": "0.0.6",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -39,6 +39,11 @@
"expo-router",
"expo-font",
"react-native-compressor",
[
"react-native-google-cast",
{
}
],
[
"react-native-video",
{
@@ -50,7 +55,8 @@
"useExoplayerDash": false
}
}
]
],
["expo-build-properties", { "ios": { "deploymentTarget": "14.0" } }]
],
"experiments": {
"typedRoutes": true

View File

@@ -19,6 +19,7 @@ export default function TabLayout() {
options={{
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
title: "Home",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
@@ -43,6 +44,7 @@ export default function TabLayout() {
options={{
headerStyle: { backgroundColor: "black" },
headerShown: true,
headerShadowVisible: false,
title: "Search",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />

View File

@@ -3,12 +3,13 @@ import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { nextUp } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
@@ -38,6 +39,21 @@ export default function index() {
staleTime: 60,
});
const { data: _nextUpData } = useQuery({
queryKey: ["nextUp-all", user?.Id],
queryFn: async () =>
await nextUp({
userId: user?.Id,
api,
}),
enabled: !!api && !!user?.Id,
staleTime: 0,
});
const nextUpData = useMemo(() => {
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
}, [_nextUpData]);
const { data: collections } = useQuery({
queryKey: ["collections", user?.Id],
queryFn: async () => {
@@ -142,6 +158,22 @@ export default function index() {
</TouchableOpacity>
)}
/>
<Text className="px-4 text-2xl font-bold mb-2">Next Up</Text>
<HorizontalScroll<BaseItemDto>
data={nextUpData}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
<Text className="px-4 text-2xl font-bold mb-2">Collections</Text>
<HorizontalScroll<BaseItemDto>
data={collections}

View File

@@ -6,7 +6,7 @@ import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/MoviePoster";
import Poster from "@/components/Poster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin";
import { getPrimaryImage, getUserItemData } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
@@ -113,7 +113,11 @@ export default function search() {
onPress={() => router.push(`/series/${item.Id}/page`)}
className="flex flex-col w-32"
>
<Poster itemId={item.Id} key={item.Id} />
<Poster
item={item}
key={item.Id}
url={getPrimaryImage({ api, item })}
/>
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}

View File

@@ -1,83 +0,0 @@
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

@@ -1,4 +1,3 @@
import { LargePoster } from "@/components/common/LargePoster";
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus";
@@ -7,22 +6,26 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems";
import { VideoPlayer } from "@/components/VideoPlayer";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdrop, getStreamUrl, getUserItemData } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import {} from "@jellyfin/sdk/lib/utils/url";
import {
getBackdrop,
getLogoImageById,
getPrimaryImage,
getUserItemData,
} from "@/utils/jellyfin";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { Image } from "expo-image";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import {
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { ParallaxScrollView } from "./ParallaxPage";
import { Image } from "expo-image";
import { ParallaxScrollView } from "../../../../components/ParallaxPage";
import { Chromecast } from "@/components/Chromecast";
import { useRemoteMediaClient } from "react-native-google-cast";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -43,12 +46,21 @@ const page: React.FC = () => {
staleTime: 60,
});
const { data: posterUrl } = useQuery({
queryKey: ["backdrop", item?.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item?.Id,
staleTime: 60 * 60 * 24 * 7,
});
const backdropUrl = useMemo(
() =>
getBackdrop({
api,
item,
quality: 90,
width: 1000,
}),
[item]
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageById({ api, item }) : null),
[item]
);
if (l1)
return (
@@ -57,35 +69,71 @@ const page: React.FC = () => {
</View>
);
if (!item?.Id || !posterUrl) return null;
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: posterUrl,
uri: backdropUrl,
}}
style={{
width: "100%",
height: 250,
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
<Text className="text-center font-bold text-2xl">
{item?.Name}
</Text>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/series/${item.SeriesId}/page`)
}
>
<Text className="text-center opacity-50">
{item?.SeriesName}
</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">
{item?.SeasonName}
</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
<Text className="text-center opacity-50">
{`S${item?.SeasonName?.replace("Season ", "")}:E${(
item.IndexNumber || 0
).toString()}`}
{" - "}
{item.ProductionYear}
</Text>
</>
@@ -101,11 +149,9 @@ const page: React.FC = () => {
)}
</View>
<View className="flex flex-row justify-center items-center w-full my-4 space-x-4">
<View className="flex flex-row justify-between items-center w-full my-4">
<DownloadItem item={item} />
<View className="ml-4">
<PlayedStatus item={item} />
</View>
<Chromecast />
</View>
<Text>{item.Overview}</Text>
</View>

View File

@@ -1,14 +1,21 @@
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/MoviePoster";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageById, getUserItemData, nextUp } from "@/utils/jellyfin";
import {
getBackdrop,
getLogoImageById,
getPrimaryImage,
getPrimaryImageById,
getUserItemData,
} from "@/utils/jellyfin";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { ScrollView, View } from "react-native";
import { useMemo } from "react";
import { View } from "react-native";
const page: React.FC = () => {
const params = useLocalSearchParams();
@@ -26,25 +33,72 @@ const page: React.FC = () => {
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 60,
staleTime: 0,
});
if (!item) return null;
const backdropUrl = useMemo(
() =>
getBackdrop({
api,
item,
quality: 90,
width: 1000,
}),
[item]
);
const logoUrl = useMemo(
() =>
getLogoImageById({
api,
item,
}),
[item]
);
if (!item || !backdropUrl) return null;
return (
<ScrollView>
<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>
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col pt-4 pb-12">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<View className="mb-4">
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} />
<NextUp seriesId={seriesId} />
</View>
</ScrollView>
</ParallaxScrollView>
);
};

View File

@@ -12,6 +12,7 @@ import { TouchableOpacity } from "react-native";
import Feather from "@expo/vector-icons/Feather";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -87,10 +88,7 @@ export default function RootLayout() {
<Stack.Screen
name="(auth)/items/[id]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: true,
headerShown: false,
}}
/>
<Stack.Screen
@@ -99,16 +97,13 @@ export default function RootLayout() {
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: true,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: true,
headerShown: false,
}}
/>
<Stack.Screen

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 158 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -9,8 +9,10 @@ interface ButtonProps {
disabled?: boolean;
children?: string;
loading?: boolean;
color?: "purple" | "red";
color?: "purple" | "red" | "black";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
}
export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
@@ -21,14 +23,18 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
loading = false,
color = "purple",
iconRight,
iconLeft,
children,
justify = "center",
}) => {
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return "bg-purple-600 active:bg-purple-700";
case "red":
return "bg-red-500 active:bg-red-600";
return "bg-red-500";
case "black":
return "bg-black border border-neutral-900";
}
}, [color]);
@@ -51,18 +57,24 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{loading ? (
<ActivityIndicator color={"white"} size={24} />
) : (
<View className="flex flex-row items-center">
<View
className={`
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
>
{iconLeft ? iconLeft : <View className="w-4"></View>}
<Text
className={`
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}
${iconRight ? "mr-2" : ""}
${iconLeft ? "ml-2" : ""}
`}
>
{children}
</Text>
{iconRight}
{iconRight ? iconRight : <View className="w-4"></View>}
</View>
)}
</TouchableOpacity>

69
components/Chromecast.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { Feather, FontAwesome, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect } from "react";
import { TouchableOpacity, View } from "react-native";
import {
CastButton,
useCastDevice,
useDevices,
useRemoteMediaClient,
} from "react-native-google-cast";
import GoogleCast from "react-native-google-cast";
import { Text } from "./common/Text";
type Props = {
item?: BaseItemDto | null;
startTimeTicks?: number | null;
};
export const Chromecast: React.FC<Props> = ({ item, startTimeTicks }) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.log("No discoveryManager client");
return;
}
await discoveryManager.startDiscovery();
const started = await discoveryManager.isRunning();
console.log("started", started);
console.log({
devices,
castDevice,
sessionManager,
});
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
const cast = () => {
if (!client) {
console.log("No chromecast client");
return;
}
client.loadMedia({
mediaInfo: {
contentUrl:
"https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/mp4/BigBuckBunny.mp4",
contentType: "video/mp4",
metadata: {
type: item?.Type === "Episode" ? "tvShow" : "movie",
title: item?.Name || "",
subtitle: item?.Overview || "",
},
streamDuration: Math.floor((item?.RunTimeTicks || 0) / 10000),
},
startTime: Math.floor((startTimeTicks || 0) / 10000),
});
};
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
};

View File

@@ -1,13 +1,10 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import { getPrimaryImage } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useState } from "react";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = {
@@ -19,12 +16,16 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item.Id,
staleTime: 60 * 60 * 24 * 7,
});
const url = useMemo(
() =>
getPrimaryImage({
api,
item,
quality: 70,
width: 300,
}),
[item]
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
@@ -47,7 +48,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
contentFit="cover"
className="w-full h-full"
/>
<WatchedIndicator item={item} />
{!progress && <WatchedIndicator item={item} />}
{progress > 0 && (
<>
<View

View File

@@ -1,17 +1,16 @@
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 Ionicons from "@expo/vector-icons/Ionicons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
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;

View File

@@ -1,10 +1,10 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop, getPrimaryImageById } from "@/utils/jellyfin";
import { getPrimaryImage, getPrimaryImageById } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useState } from "react";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
@@ -19,12 +19,14 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getPrimaryImageById(api, item.Id),
enabled: !!api && !!item.Id,
staleTime: Infinity,
});
const url = useMemo(
() =>
getPrimaryImage({
api,
item,
}),
[item]
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0

View File

@@ -0,0 +1,96 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import type { PropsWithChildren, ReactElement } from "react";
import { TouchableOpacity, View } from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const HEADER_HEIGHT = 400;
type Props = PropsWithChildren<{
headerImage: ReactElement;
logo?: ReactElement;
}>;
export const ParallaxScrollView: React.FC<Props> = ({
children,
headerImage,
logo,
}: 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]
),
},
],
};
});
const inset = useSafeAreaInsets();
return (
<View className="flex-1">
<Animated.ScrollView
style={{
position: "relative",
}}
ref={scrollRef}
scrollEventThrottle={16}
>
<TouchableOpacity
onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{
top: inset.top,
}}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
/>
</TouchableOpacity>
{logo && (
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
{logo}
</View>
)}
<Animated.View
style={[
{
height: HEADER_HEIGHT,
backgroundColor: "black",
},
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<View className="flex-1 overflow-hidden bg-black">{children}</View>
</Animated.ScrollView>
</View>
);
};

View File

@@ -0,0 +1,49 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id]
);
if (!url || !id)
return (
<View
className="border border-neutral-900"
style={{
aspectRatio: "10/15",
}}
></View>
);
return (
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -58,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
onPress={() => {
markAsPlayed({
api: api,
itemId: item?.Id,
item: item,
userId: user?.Id,
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

View File

@@ -1,26 +1,18 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageById } from "@/utils/jellyfin";
import { useQuery } from "@tanstack/react-query";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { View } from "react-native";
type PosterProps = {
itemId?: string | null;
item?: BaseItemDto | BaseItemPerson | null;
url?: string | null;
showProgress?: boolean;
};
const Poster: React.FC<PosterProps> = ({ itemId }) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", itemId],
queryFn: async () => getPrimaryImageById(api, itemId),
enabled: !!api && !!itemId,
staleTime: Infinity,
});
if (!url || !itemId)
const Poster: React.FC<PosterProps> = ({ item, url }) => {
if (!url || !item)
return (
<View
className="border border-neutral-900"
@@ -33,8 +25,8 @@ const Poster: React.FC<PosterProps> = ({ itemId }) => {
return (
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
key={itemId}
id={itemId}
key={item.Id}
id={item.Id}
source={{
uri: url,
}}

View File

@@ -8,9 +8,15 @@ import {
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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import Video, {
OnBufferData,
@@ -22,6 +28,10 @@ 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";
type VideoPlayerProps = {
itemId: string;
@@ -50,10 +60,16 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const videoRef = useRef<VideoRef | null>(null);
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [progress, setProgress] = useState(0);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const { data: item } = useQuery({
queryKey: ["item", itemId],
queryFn: async () =>
@@ -81,7 +97,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
});
const { data: playbackURL } = useQuery({
queryKey: ["playbackUrl", itemId, maxBitrate],
queryKey: ["playbackUrl", itemId, maxBitrate, castDevice],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
@@ -92,6 +108,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosProfile,
});
console.log("Transcode URL:", url);
@@ -102,21 +119,22 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
staleTime: 0,
});
const [progress, setProgress] = useState(0);
const onProgress = useCallback(
({ currentTime, playableDuration, seekableDuration }: OnProgressData) => {
if (!currentTime || !sessionData?.PlaySessionId) return;
if (paused) return;
const onProgress = ({
currentTime,
playableDuration,
seekableDuration,
}: OnProgressData) => {
setProgress(currentTime * 10000000);
reportPlaybackProgress({
api,
itemId: itemId,
positionTicks: currentTime * 10000000,
sessionId: sessionData?.PlaySessionId,
});
};
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: itemId,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, item, api, paused]
);
const onSeek = ({
currentTime,
@@ -139,6 +157,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const play = () => {
if (videoRef.current) {
videoRef.current.resume();
setPaused(false);
}
};
@@ -146,12 +165,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
}, [item]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.pause();
}
}, []);
const enableVideo = useMemo(() => {
return (
playbackURL !== undefined &&
@@ -162,6 +175,45 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
);
}, [playbackURL, item, startPosition, sessionData]);
const cast = useCallback(() => {
if (client === null) {
console.log("no client ");
return;
}
if (!playbackURL) {
console.log("no playback url");
return;
}
if (!item) {
console.log("no item");
return;
}
client.loadMedia({
mediaInfo: {
contentUrl: playbackURL,
contentType: "video/mp4",
metadata: {
type: item?.Type === "Episode" ? "tvShow" : "movie",
title: item?.Name || "",
subtitle: item?.Overview || "",
},
streamDuration: Math.floor((item?.RunTimeTicks || 0) / 10000),
},
startTime: Math.floor(
(item?.UserData?.PlaybackPositionTicks || 0) / 10000
),
});
}, [item, client, playbackURL]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.pause();
}
}, []);
return (
<View>
{enableVideo === true &&
@@ -185,6 +237,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
onProgress={(e) => onProgress(e)}
onFullscreenPlayerDidDismiss={() => {
videoRef.current?.pause();
setPaused(true);
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
if (progress === 0) return;
reportPlaybackStopped({
api,
itemId: item?.Id,
@@ -203,6 +268,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
bufferForPlaybackMs: 1000,
backBufferDurationMs: 30 * 1000,
}}
ignoreSilentSwitch="ignore"
/>
) : null}
<View className="flex flex-row items-center justify-between">
@@ -247,7 +313,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
<Button
disabled={!enableVideo}
onPress={() => {
if (videoRef.current) {
if (castDevice?.deviceId && item) {
cast();
} else if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}}

View File

@@ -1,11 +1,15 @@
import { getPrimaryImage } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../Poster";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
@@ -13,7 +17,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
data={item.People}
renderItem={(item, index) => (
<TouchableOpacity key={item.Id} className="flex flex-col w-32">
<Poster itemId={item.Id} />
<Poster item={item} url={getPrimaryImage({ api, item })} />
<Text className="mt-2">{item.Name}</Text>
<Text className="text-xs opacity-50">{item.Role}</Text>
</TouchableOpacity>

View File

@@ -1,5 +1,8 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImage, getPrimaryImageById } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import Poster from "../Poster";
@@ -7,6 +10,8 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
@@ -18,7 +23,10 @@ export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
className="flex flex-col space-y-2 w-32"
>
<Poster itemId={item.ParentBackdropItemId} />
<Poster
item={item}
url={getPrimaryImageById({ api, id: item.ParentId })}
/>
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
)}

View File

@@ -114,7 +114,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
{episodes && (
<View className="mt-2">
<View className="mt-4">
<HorizontalScroll<BaseItemDto>
data={episodes}
renderItem={(item, index) => (

30
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local
# Bundle artifacts
*.jsbundle
# CocoaPods
/Pods/

11
ios/.xcode.env Normal file
View File

@@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

58
ios/Podfile Normal file
View File

@@ -0,0 +1,58 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
platform :ios, podfile_properties['ios.deploymentTarget'] || '13.4'
install! 'cocoapods',
:deterministic_uuids => false
prepare_react_native_project!
target 'Streamyfin' do
use_expo_modules!
config = use_native_modules!
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
.each do |pod_name, target_installation_result|
target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
resource_bundle_target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
end
post_integrate do |installer|
begin
expo_patch_react_imports!(installer)
rescue => e
Pod::UI.warn e
end
end
end

2071
ios/Podfile.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"ios.deploymentTarget": "14.0",
"apple.extraPods": "[]",
"apple.ccacheEnabled": "false",
"apple.privacyManifestAggregationEnabled": "true"
}

View File

@@ -0,0 +1,577 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
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 */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
76DE60737AC6B111D4CF6135 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6AFEBF12046598BC1A9FEC11 /* PrivacyInfo.xcprivacy */; };
96905EF65AED1B983A6B3ABC /* libPods-Streamyfin.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */; };
9B470C0ACF444752B7807218 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BFAF74683646218FA987CE /* noop-file.swift */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Streamyfin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Streamyfin.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Streamyfin/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Streamyfin/AppDelegate.mm; sourceTree = "<group>"; };
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>"; };
2092729FD1D54853BF6A8489 /* 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>"; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Streamyfin.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6AFEBF12046598BC1A9FEC11 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Streamyfin/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
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>"; };
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>"; };
B3BFAF74683646218FA987CE /* 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>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; 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 */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
96905EF65AED1B983A6B3ABC /* libPods-Streamyfin.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* Streamyfin */ = {
isa = PBXGroup;
children = (
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
B3BFAF74683646218FA987CE /* noop-file.swift */,
2092729FD1D54853BF6A8489 /* Streamyfin-Bridging-Header.h */,
6AFEBF12046598BC1A9FEC11 /* PrivacyInfo.xcprivacy */,
);
name = Streamyfin;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* Streamyfin */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
D65327D7A22EEC0BE12398D9 /* Pods */,
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Streamyfin.app */,
);
name = Products;
sourceTree = "<group>";
};
92DBD88DE9BF7D494EA9DA96 /* Streamyfin */ = {
isa = PBXGroup;
children = (
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */,
);
name = Streamyfin;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = Streamyfin/Supporting;
sourceTree = "<group>";
};
D65327D7A22EEC0BE12398D9 /* Pods */ = {
isa = PBXGroup;
children = (
6C2E3173556A471DD304B334 /* Pods-Streamyfin.debug.xcconfig */,
7A4D352CD337FB3A3BF06240 /* Pods-Streamyfin.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
92DBD88DE9BF7D494EA9DA96 /* Streamyfin */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* Streamyfin */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Streamyfin" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
E4ADA958330463261FFFF98E /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
FE3C21F6FE840DF7275CA666 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Streamyfin;
productName = Streamyfin;
productReference = 13B07F961A680F5B00A75B9A /* Streamyfin.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Streamyfin" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Streamyfin */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
76DE60737AC6B111D4CF6135 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Streamyfin-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Protobuf/Protobuf_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
"${PODS_ROOT}/google-cast-sdk/Resources/GoogleCastCoreResources.bundle",
"${PODS_ROOT}/google-cast-sdk/Resources/GoogleCastUIResources.bundle",
"${PODS_ROOT}/google-cast-sdk/Resources/MaterialDialogs.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/google-cast-sdk/GoogleCast.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Protobuf_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastCoreResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastUIResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialDialogs.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCast.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-resources.sh\"\n";
showEnvVarsInLog = 0;
};
E4ADA958330463261FFFF98E /* [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";
};
FE3C21F6FE840DF7275CA666 /* [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;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
9B470C0ACF444752B7807218 /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-Streamyfin.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Streamyfin/Streamyfin.entitlements;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = Streamyfin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.fredrikburmester.streamyfin;
PRODUCT_NAME = Streamyfin;
SWIFT_OBJC_BRIDGING_HEADER = "Streamyfin/Streamyfin-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-Streamyfin.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Streamyfin/Streamyfin.entitlements;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = Streamyfin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.fredrikburmester.streamyfin;
PRODUCT_NAME = Streamyfin;
SWIFT_OBJC_BRIDGING_HEADER = "Streamyfin/Streamyfin-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CXX = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
CXX = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Streamyfin" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Streamyfin" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Streamyfin.app"
BlueprintName = "Streamyfin"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "StreamyfinTests.xctest"
BlueprintName = "StreamyfinTests"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Streamyfin.app"
BlueprintName = "Streamyfin"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Streamyfin.app"
BlueprintName = "Streamyfin"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Streamyfin.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,7 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
#import <Expo/Expo.h>
@interface AppDelegate : EXAppDelegateWrapper
@end

View File

@@ -0,0 +1,80 @@
#import "AppDelegate.h"
// @generated begin react-native-google-cast-import - expo prebuild (DO NOT MODIFY) sync-da0acf16745f87cea5bffba9c0cc3a4f5e4387ea
#if __has_include(<GoogleCast/GoogleCast.h>)
#import <GoogleCast/GoogleCast.h>
#endif
// @generated end react-native-google-cast-import
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
#if __has_include(<GoogleCast/GoogleCast.h>)
NSString *receiverAppID = kGCKDefaultMediaReceiverApplicationID;
GCKDiscoveryCriteria *criteria = [[GCKDiscoveryCriteria alloc] initWithApplicationID:receiverAppID];
GCKCastOptions* options = [[GCKCastOptions alloc] initWithDiscoveryCriteria:criteria];
options.disableDiscoveryAutostart = false;
options.startDiscoveryAfterFirstTapOnCastButton = true;
options.suspendSessionsWhenBackgrounded = true;
[GCKCastContext setSharedInstanceWithOptions:options];
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;
#endif
// @generated end react-native-google-cast-didFinishLaunchingWithOptions
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

View File

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

101
ios/Streamyfin/Info.plist Normal file
View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Streamyfin</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.0.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>streamyfin</string>
<string>com.fredrikburmester.streamyfin</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+streamyfin</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>The app needs access to your camera to scan barcodes.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network</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>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
</dict>
</plist>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>0A2A.1</string>
<string>3B52.1</string>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
<string>85F4.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina5_5" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="414" height="736"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" insetsLayoutMarginsFromSafeArea="NO" image="SplashScreenBackground" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreenBackground" userLabel="SplashScreenBackground">
<rect key="frame" x="0.0" y="0.0" width="414" height="736"/>
</imageView>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreen" image="SplashScreen" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="1gX-mQ-vu6"/>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="6tX-OG-Sck"/>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="ABX-8g-7v4"/>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="jkI-2V-eW5"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="2VS-Uz-0LU"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="LhH-Ei-DKo"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="I6l-TP-6fn"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="nbp-HC-eaG"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="f934da460e9ab5acae3ad9987d5b676a108796c1"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="d6a0be88096b36fb132659aa90203d39139deda9"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="140.625" y="129.4921875"/>
</scene>
</scenes>
<resources>
<image name="SplashScreenBackground" width="1" height="1"/>
<image name="SplashScreen" width="414" height="736"/>
</resources>
</document>

View File

@@ -0,0 +1,3 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
</dict>
</plist>

10
ios/Streamyfin/main.m Normal file
View File

@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

View File

@@ -0,0 +1,4 @@
//
// @generated
// A blank Swift file must be created for native modules with Swift files to work correctly.
//

View File

@@ -17,13 +17,17 @@
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.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",
"@types/uuid": "^10.0.0",
"expo": "~51.0.24",
"expo-build-properties": "~0.12.4",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.21",
"expo-device": "~6.0.2",
"expo-font": "~12.0.9",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.13",
@@ -42,6 +46,8 @@
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0",
"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",
@@ -49,9 +55,11 @@
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.4.3",
"react-native-web": "~0.19.10",
"tailwindcss": "3.3.2",
"uuid": "^10.0.0",
"zeego": "^1.10.0",
"zod": "^3.23.8"
},

View File

@@ -12,6 +12,7 @@ import React, {
useEffect,
useState,
} from "react";
import uuid from "react-native-uuid";
interface Server {
address: string;
@@ -32,32 +33,50 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined
);
const getOrSetDeviceId = async () => {
let deviceId = await AsyncStorage.getItem("deviceId");
if (!deviceId) {
deviceId = uuid.v4() as string;
await AsyncStorage.setItem("deviceId", deviceId);
}
return deviceId;
};
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [jellyfin] = useState(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "1.0.0" },
deviceInfo: { name: "iOS", id: "unique-device-id" },
})
);
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
useEffect(() => {
(async () => {
const id = await getOrSetDeviceId();
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "1.0.0" },
deviceInfo: { name: "iOS", id },
})
);
})();
}, []);
const [api, setApi] = useAtom(apiAtom);
const [user, setUser] = useAtom(userAtom);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers = await jellyfin.discovery.getRecommendedServerCandidates(
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
url
);
return servers.map((server) => ({ address: server.address }));
return servers?.map((server) => ({ address: server.address })) || [];
};
const setServerMutation = useMutation({
mutationFn: async (server: Server) => {
const apiInstance = jellyfin.createApi(server.address);
const apiInstance = jellyfin?.createApi(server.address);
if (!apiInstance.basePath) throw new Error("Failed to connect");
if (!apiInstance?.basePath) throw new Error("Failed to connect");
setApi(apiInstance);
await AsyncStorage.setItem("serverUrl", server.address);
@@ -85,7 +104,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
username: string;
password: string;
}) => {
if (!api) throw new Error("API not initialized");
if (!api || !jellyfin) throw new Error("API not initialized");
const auth = await api.authenticateUserByName(username, password);
@@ -105,8 +124,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
setUser(null);
await AsyncStorage.removeItem("token");
setUser(null);
},
onError: (error) => {
console.error("Logout failed:", error);
@@ -114,17 +133,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
});
const { isLoading, isFetching } = useQuery({
queryKey: ["initializeJellyfin", user?.Id, api?.basePath],
queryKey: [
"initializeJellyfin",
user?.Id,
api?.basePath,
jellyfin?.clientInfo,
],
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
) as UserDto;
if (serverUrl && token && user.Id) {
if (serverUrl && token && user.Id && jellyfin) {
const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance);
setUser(user);
@@ -136,7 +159,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
},
staleTime: 0,
enabled: !user?.Id || !api,
enabled: !user?.Id || !api || !jellyfin,
});
const contextValue: JellyfinContextValue = {

View File

@@ -1,4 +1,7 @@
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import {
DeviceProfile,
DlnaProfileType,
} from "@jellyfin/sdk/lib/generated-client/models";
const MediaTypes = {
Audio: "Audio",
@@ -7,85 +10,6 @@ const MediaTypes = {
Book: "Book",
};
export const iPhone15Profile: DeviceProfile = {
Name: "iPhone 15",
Id: "iphone15-001",
MaxStreamingBitrate: 5000000, // 5 Mbps
MaxStaticBitrate: 10000000, // 10 Mbps
MusicStreamingTranscodingBitrate: 320000, // 320 kbps
MaxStaticMusicBitrate: 1411200, // CD Quality at 1,411.2 kbps
DirectPlayProfiles: [
{
Container: "mp4",
VideoCodec: "h264",
AudioCodec: "aac",
},
],
TranscodingProfiles: [
{
Container: "hls",
Type: "Video",
VideoCodec: "h265",
Context: "Streaming",
},
],
ContainerProfiles: [
{
Type: "Video",
Container: "mp4",
},
{
Type: "Video",
Container: "mov",
},
],
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
{
Type: "Video",
Codec: "h265",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
{
Type: "Audio",
Codec: "aac",
Conditions: [
{
Condition: "GreaterThanEqual",
Property: "AudioChannels",
Value: "2",
},
],
},
],
SubtitleProfiles: [
{
Format: "srt",
Method: "External",
},
{
Format: "vtt",
Method: "External",
},
],
};
const BaseProfile = {
Name: "Expo Base Video Profile",
MaxStaticBitrate: 100000000,
@@ -308,3 +232,122 @@ export const iosProfile = {
},
],
};
export const chromecastProfile: DeviceProfile = {
Name: "Chromecast",
Id: "chromecast-001",
MaxStreamingBitrate: 4000000, // 4 Mbps
MaxStaticBitrate: 4000000, // 4 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
DirectPlayProfiles: [
{
Container: "mp4,webm",
Type: "Video",
VideoCodec: "h264,vp8,vp9",
AudioCodec: "aac,mp3,opus,vorbis",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "flac",
Type: "Audio",
},
{
Container: "wav",
Type: "Audio",
},
],
TranscodingProfiles: [
{
Container: "ts",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3",
Protocol: "hls",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
BreakOnNonKeyFrames: true,
},
{
Container: "mp4",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
],
ContainerProfiles: [
{
Type: "Video",
Container: "mp4",
},
{
Type: "Video",
Container: "webm",
},
],
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "8",
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "41",
},
],
},
{
Type: "Video",
Codec: "vp9",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Hls",
},
{
Format: "vtt",
Method: "External",
},
],
};

View File

@@ -1,6 +1,7 @@
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemPerson,
MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
@@ -14,6 +15,7 @@ import { useAtom } from "jotai";
import { useCallback, useRef, useState } from "react";
import { runningProcesses } from "./atoms/downloads";
import { iosProfile } from "./device-profiles";
import { apiAtom } from "@/providers/JellyfinProvider";
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
const [isDownloading, setIsDownloading] = useState(false);
@@ -172,17 +174,19 @@ export const markAsNotPlayed = async ({
export const markAsPlayed = async ({
api,
itemId,
item,
userId,
}: {
api?: Api | null;
itemId?: string | null;
item?: BaseItemDto | null;
userId?: string | null;
}) => {
if (!itemId || !userId || !api) {
if (!item || !userId || !api || !item.RunTimeTicks) {
return false;
}
const itemId = item.Id;
try {
const response = await api.axiosInstance.post(
`${api.basePath}/UserPlayedItems/${itemId}`,
@@ -197,11 +201,27 @@ export const markAsPlayed = async ({
}
);
const response2 = await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,
PositionTicks: item.RunTimeTicks,
MediaSourceId: itemId,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
console.log(response, response2);
if (response.status === 200) return true;
return false;
} catch (error) {
const e = error as any;
console.error("Failed to report playback progress:", {
console.error("Failed to mark as played:", {
message: e.message,
status: e.response?.status,
statusText: e.response?.statusText,
@@ -235,7 +255,7 @@ export const nextUp = async ({
userId?: string | null;
api?: Api | null;
}) => {
if (!itemId || !userId || !api) {
if (!userId || !api) {
return [];
}
@@ -244,7 +264,7 @@ export const nextUp = async ({
`${api.basePath}/Shows/NextUp`,
{
params: {
SeriesId: itemId,
SeriesId: itemId ? itemId : undefined,
UserId: userId,
Fields: "MediaSourceCount",
},
@@ -257,7 +277,7 @@ export const nextUp = async ({
return response?.data.Items as BaseItemDto[];
} catch (error) {
const e = error as any;
console.error("Failed to report playback progress", e.message, e.status);
console.error("Failed to get next up", e.message, e.status);
return [];
}
};
@@ -473,6 +493,7 @@ export const getStreamUrl = async ({
startTimeTicks = 0,
maxStreamingBitrate,
sessionData,
deviceProfile = iosProfile,
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
@@ -480,6 +501,7 @@ export const getStreamUrl = async ({
startTimeTicks: number;
maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse;
deviceProfile: any;
}) => {
if (!api || !userId || !item?.Id) {
return null;
@@ -490,11 +512,7 @@ export const getStreamUrl = async ({
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
DeviceProfile: {
...iosProfile,
MaxStaticBitrate: maxStreamingBitrate,
MaxStreamingBitrate: maxStreamingBitrate,
},
DeviceProfile: deviceProfile,
UserId: userId,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: startTimeTicks,
@@ -519,90 +537,119 @@ export const getStreamUrl = async ({
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",
console.log(`${api.basePath}${mediaSource.TranscodingUrl}`);
return `${api.basePath}${mediaSource.TranscodingUrl}`;
};
/**
* Retrieves the primary image URL for a given item.
*
* @param api - The Jellyfin API instance.
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 90).
*/
export const getPrimaryImage = ({
api,
item,
quality = 90,
width = 500,
}: {
api?: Api | null;
item?: BaseItemDto | BaseItemPerson | null;
quality?: number | null;
width?: number | null;
}) => {
if (!item || !api) {
return null;
}
if (!isBaseItemDto(item)) {
return `${api?.basePath}/Items/${item?.Id}/Images/Primary`;
}
const backdropTag = item.BackdropImageTags?.[0];
const primaryTag = item.ImageTags?.["Primary"];
const params = new URLSearchParams({
fillWidth: width ? String(width) : "500",
quality: quality ? String(quality) : "90",
});
if (maxStreamingBitrate) {
streamParams.append("videoBitRate", maxStreamingBitrate.toString());
streamParams.append("transcodeReasons", "ContainerBitrateExceedsLimit");
if (primaryTag) {
params.set("tag", primaryTag);
} else if (backdropTag) {
params.set("tag", backdropTag);
}
return `${
api.basePath
}/Videos/${itemId}/main.m3u8?${streamParams.toString()}`;
return `${api?.basePath}/Items/${
item.Id
}/Images/Primary?${params.toString()}`;
};
/**
* Retrieves the backdrop image URL for a given item.
* Retrieves the primary image URL for a given item.
*
* @param api - The Jellyfin API instance.
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 90).
*/
export const getPrimaryImageById = ({
api,
id,
quality = 90,
width = 500,
}: {
api?: Api | null;
id?: string | null;
quality?: number | null;
width?: number | null;
}) => {
if (!id) {
return null;
}
const params = new URLSearchParams({
fillWidth: width ? String(width) : "500",
quality: quality ? String(quality) : "90",
});
return `${api?.basePath}/Items/${id}/Images/Primary?${params.toString()}`;
};
function isBaseItemDto(item: any): item is BaseItemDto {
return item && "BackdropImageTags" in item && "ImageTags" in item;
}
/**
* Retrieves the primary image URL for a given item.
*
* @param api - The Jellyfin API instance.
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 10).
*/
export const getBackdrop = (
api: Api | null | undefined,
item: BaseItemDto | null | undefined,
quality: number = 50
) => {
export const getLogoImageById = ({
api,
item,
}: {
api?: Api | null;
item?: BaseItemDto | null;
}) => {
if (!api || !item) {
console.warn("getBackdrop ~ Missing API or Item");
return null;
}
if (item.BackdropImageTags && item.BackdropImageTags[0]) {
return `${api.basePath}/Items/${item?.Id}/Images/Backdrop?quality=${quality}&fillWidth=500&tag=${item?.BackdropImageTags?.[0]}`;
}
const imageTags = item.ImageTags?.["Logo"];
if (item.ImageTags && item.ImageTags.Primary) {
return `${api.basePath}/Items/${item?.Id}/Images/Primary?quality=${quality}&fillWidth=500&tag=${item.ImageTags.Primary}`;
}
if (!imageTags) return null;
if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
return `${api.basePath}/Items/${item?.Id}/Images/Primary?quality=${quality}&fillWidth=500&tag=${item.ImageTags?.Primary}`;
}
const params = new URLSearchParams();
return null;
};
params.append("tag", imageTags);
params.append("quality", "90");
params.append("fillHeight", "130");
/**
* Retrieves the backdrop image URL for a given item.
*
* @param api - The Jellyfin API instance.
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 10).
*/
export const getBackdropById = (
api: Api | null | undefined,
itemId: string | null | undefined,
quality: number = 10
) => {
if (!api) {
console.warn("getBackdrop ~ Missing API or Item");
return null;
}
return `${api.basePath}/Items/${itemId}/Images/Backdrop?quality=${quality}`;
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
};
/**
@@ -612,16 +659,41 @@ export const getBackdropById = (
* @param item - The media item to retrieve the backdrop image URL for.
* @param quality - The desired image quality (default: 10).
*/
export const getPrimaryImageById = (
api: Api | null | undefined,
itemId: string | null | undefined,
quality: number = 10
) => {
if (!api) {
export const getBackdrop = ({
api,
item,
quality,
width,
}: {
api?: Api | null;
item?: BaseItemDto | null;
quality?: number;
width?: number;
}) => {
if (!api || !item) {
return null;
}
return `${api.basePath}/Items/${itemId}/Images/Primary?quality=${quality}`;
const backdropImageTags = item.BackdropImageTags?.[0];
const params = new URLSearchParams();
if (quality) {
params.append("quality", quality.toString());
}
if (width) {
params.append("fillWidth", width.toString());
}
if (backdropImageTags) {
params.append("tag", backdropImageTags);
return `${api.basePath}/Items/${
item.Id
}/Images/Backdrop/0?${params.toString()}`;
} else {
return getPrimaryImage({ api, item, quality, width });
}
};
/**