This commit is contained in:
Fredrik Burmester
2024-08-02 08:47:39 +02:00
parent 98880e05ec
commit 634f28ac28
35 changed files with 1488 additions and 546 deletions

View File

@@ -39,6 +39,7 @@
"expo-font", "expo-font",
"expo-video", "expo-video",
"react-native-compressor", "react-native-compressor",
// "react-native-google-cast",
[ [
"react-native-video", "react-native-video",
{ {

View File

@@ -19,7 +19,6 @@ export default function TabLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerStyle: { backgroundColor: "black" }, headerStyle: { backgroundColor: "black" },
title: "Home", title: "Home",
tabBarIcon: ({ color, focused }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon <TabBarIcon

View File

@@ -1,7 +1,7 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loading } from "@/components/Loading";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api";
@@ -26,6 +26,9 @@ export default function index() {
if (!api || !user?.Id) { if (!api || !user?.Id) {
return []; return [];
} }
console.log("[2] Items");
const response = await getItemsApi(api).getResumeItems({ const response = await getItemsApi(api).getResumeItems({
userId: user.Id, userId: user.Id,
}); });
@@ -48,6 +51,18 @@ export default function index() {
}) })
).data; ).data;
const order = ["boxsets", "tvshows", "movies"];
const cs = data.Items?.sort((a, b) => {
if (
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
) {
return 1;
}
return -1;
});
return data.Items || []; return data.Items || [];
}, },
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
@@ -85,56 +100,53 @@ export default function index() {
return ( return (
<ScrollView nestedScrollEnabled> <ScrollView nestedScrollEnabled>
<View className="py-4 px-4 gap-y-2"> <View className="py-4 gap-y-2">
<Text className="text-2xl font-bold">Continue Watching</Text> <Text className="px-4 text-2xl font-bold mb-2">Continue Watching</Text>
<ScrollView horizontal> <HorizontalScroll<BaseItemDto>
<View className="flex flex-row gap-x-2"> data={data}
{data.map((item) => ( renderItem={(item, index) => (
<TouchableOpacity <TouchableOpacity
key={item.Id} key={index}
onPress={() => router.push(`/items/${item.Id}/page`)} onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48" className="flex flex-col w-48"
> >
<View> <View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
))}
</View>
</ScrollView>
<Text className="text-2xl font-bold">Collections</Text>
<ScrollView horizontal>
<View className="flex flex-row gap-x-2">
{collections?.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/collections/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
))}
</View>
</ScrollView>
<Text className="text-2xl font-bold">Suggestions</Text>
<ScrollView horizontal>
<View className="flex flex-row gap-x-2">
{suggestions?.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
</TouchableOpacity> </View>
))} </TouchableOpacity>
</View> )}
</ScrollView> />
<Text className="px-4 text-2xl font-bold mb-2">Collections</Text>
<HorizontalScroll<BaseItemDto>
data={collections}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/collections/${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">Suggestions</Text>
<HorizontalScroll<BaseItemDto>
data={suggestions}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -1,7 +1,12 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/MoviePoster";
import Poster from "@/components/Poster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { getUserItemData } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -12,29 +17,12 @@ import { ScrollView, TouchableOpacity, View } from "react-native";
export default function search() { export default function search() {
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [totalResults, setTotalResults] = useState<number>(0);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
// useEffect(() => { const { data: movies } = useQuery({
// (async () => { queryKey: ["search-movies", search],
// if (!api || search.length === 0) return;
// const searchApi = await getSearchApi(api).getSearchHints({
// searchTerm: search,
// limit: 10,
// includeItemTypes: ["Movie"],
// });
// const data = searchApi.data;
// setTotalResults(data.TotalRecordCount || 0);
// setData(data.SearchHints || []);
// })();
// }, [search]);
const { data } = useQuery({
queryKey: ["search", search],
queryFn: async () => { queryFn: async () => {
if (!api || !user || search.length === 0) return []; if (!api || !user || search.length === 0) return [];
@@ -48,44 +36,166 @@ export default function search() {
}, },
}); });
return ( const { data: series } = useQuery({
<View className="p-4"> queryKey: ["search-series", search],
<View className="mb-4"> queryFn: async () => {
<Input if (!api || !user || search.length === 0) return [];
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
<ScrollView> const searchApi = await getSearchApi(api).getSearchHints({
<View className="rounded-xl overflow-hidden"> searchTerm: search,
{data?.map((item, index) => ( limit: 10,
<RenderItem item={item} key={index} /> includeItemTypes: ["Series"],
))} });
return searchApi.data.SearchHints;
},
});
const { data: episodes } = useQuery({
queryKey: ["search-episodes", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Episode"],
});
return searchApi.data.SearchHints;
},
});
return (
<ScrollView keyboardDismissMode="on-drag">
<View className="p-4 flex flex-col">
<View className="mb-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View> </View>
</ScrollView>
</View> <Text className="font-bold text-2xl mb-2">Movies</Text>
<SearchItemWrapper
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-32"
onPress={() => router.push(`/items/${item.Id}/page`)}
>
<MoviePoster item={item} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl my-2">Series</Text>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}/page`)}
className="flex flex-col w-32"
>
<Poster itemId={item.Id} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl my-2">Episodes</Text>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
)}
/>
{/* <Text>Series</Text>
<HorizontalScroll
data={series}
renderItem={(item, index) => <Poster itemId={item.Id} key={index} />}
/>
<Text>Episodes</Text>
<HorizontalScroll
data={episodes}
renderItem={(item, index) => (
<ContinueWatchingPoster item={item} key={index} />
)}
/> */}
</View>
</ScrollView>
); );
} }
type RenderItemProps = { type Props = {
item: BaseItemDto; ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
}; };
const RenderItem: React.FC<RenderItemProps> = ({ item }) => { const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
return ( const [api] = useAtom(apiAtom);
<TouchableOpacity const [user] = useAtom(userAtom);
onPress={() => router.push(`/(auth)/items/${item.Id}/page`)}
className="flex flex-row items-center justify-between p-4 bg-neutral-900 border-neutral-800" const { data, isLoading: l1 } = useQuery({
> queryKey: ["items", ids],
<View className="flex flex-col"> queryFn: async () => {
<Text className="font-bold">{item.Name}</Text> if (!user?.Id || !api || !ids || ids.length === 0) {
{item.Type === "Movie" && ( return [];
<Text className="opacity-50">{item.ProductionYear}</Text> }
)}
</View> const itemPromises = ids.map((id) =>
<Ionicons name="arrow-forward" size={24} color="white" /> getUserItemData({
</TouchableOpacity> api,
); userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter((item) => item !== null);
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data) return <Text className="opacity-50 text-xs">No results</Text>;
return renderItem(data);
}; };

View File

@@ -36,8 +36,6 @@ const page: React.FC = () => {
}) })
).data; ).data;
console.log(data.Items?.find((item) => item.Id == collectionId));
return data.Items?.find((item) => item.Id == collectionId); return data.Items?.find((item) => item.Id == collectionId);
}, },
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
@@ -143,6 +141,8 @@ const page: React.FC = () => {
onPress={() => { onPress={() => {
if (collection?.CollectionType === "movies") { if (collection?.CollectionType === "movies") {
router.push(`/items/${item.Id}/page`); router.push(`/items/${item.Id}/page`);
} else if (collection?.CollectionType === "tvshows") {
router.push(`/series/${item.Id}/page`);
} }
}} }}
> >

View File

@@ -1,5 +1,9 @@
import { LargePoster } from "@/components/common/LargePoster";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem"; import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
import { VideoPlayer } from "@/components/VideoPlayer"; import { VideoPlayer } from "@/components/VideoPlayer";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -7,17 +11,10 @@ import { getBackdrop, getStreamUrl, getUserItemData } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {} from "@jellyfin/sdk/lib/utils/url"; import {} from "@jellyfin/sdk/lib/utils/url";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { import { ActivityIndicator, ScrollView, View } from "react-native";
ActivityIndicator,
Dimensions,
SafeAreaView,
ScrollView,
View,
} from "react-native";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
@@ -40,8 +37,6 @@ const page: React.FC = () => {
staleTime: Infinity, staleTime: Infinity,
}); });
const screenWidth = Dimensions.get("window").width;
const { data: playbackURL, isLoading: l2 } = useQuery({ const { data: playbackURL, isLoading: l2 } = useQuery({
queryKey: ["playbackUrl", id], queryKey: ["playbackUrl", id],
queryFn: async () => { queryFn: async () => {
@@ -57,13 +52,6 @@ const page: React.FC = () => {
staleTime: Infinity, staleTime: Infinity,
}); });
const { data: url } = useQuery({
queryKey: ["backdrop", item?.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item?.Id,
staleTime: Infinity,
});
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => { headerRight: () => {
@@ -90,16 +78,9 @@ const page: React.FC = () => {
if (!playbackURL) return null; if (!playbackURL) return null;
return ( return (
<ScrollView style={[{ flex: 1 }]}> <ScrollView style={[{ flex: 1 }]} keyboardDismissMode="on-drag">
{posterUrl && ( <LargePoster url={posterUrl} />
<View className="p-4 rounded-xl overflow-hidden "> <View className="flex flex-col px-4 mb-4">
<Image
source={{ uri: posterUrl }}
className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"
/>
</View>
)}
<View className="flex flex-col text-center px-4 mb-4">
<View className="flex flex-col"> <View className="flex flex-col">
{item.Type === "Episode" ? ( {item.Type === "Episode" ? (
<> <>
@@ -107,6 +88,13 @@ const page: React.FC = () => {
<Text className="text-center font-bold text-2xl"> <Text className="text-center font-bold text-2xl">
{item?.Name} {item?.Name}
</Text> </Text>
<Text className="text-center opacity-50">
{`S${item?.SeasonName?.replace("Season ", "")}:E${(
item.IndexNumber || 0
).toString()}`}
{" - "}
{item.ProductionYear}
</Text>
</> </>
) : ( ) : (
<> <>
@@ -120,15 +108,54 @@ const page: React.FC = () => {
)} )}
</View> </View>
<View className="justify-center items-center w-full my-4"> <View className="flex flex-row justify-center items-center w-full my-4 space-x-4">
{playbackURL && <DownloadItem item={item} url={playbackURL} />} {playbackURL && <DownloadItem item={item} url={playbackURL} />}
<View className="ml-4">
<PlayedStatus item={item} />
</View>
</View> </View>
<Text>{item.Overview}</Text> <Text>{item.Overview}</Text>
</View> </View>
<View className="flex flex-col p-4"> <View className="flex flex-col p-4">
<VideoPlayer itemId={item.Id} /> <VideoPlayer itemId={item.Id} />
</View> </View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Video</Text>
<Text className="text-sm opacity-70">Audio</Text>
<Text className="text-sm opacity-70">Subtitles</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{
item.MediaStreams?.find((i) => i.Type === "Subtitle")
?.DisplayTitle
}
</Text>
</View>
</View>
</ScrollView>
<View className="px-4 mb-4">
<CastAndCrew item={item} />
</View>
{item.Type === "Episode" && (
<View className="px-4 mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} /> <SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ScrollView> </ScrollView>
); );
}; };

View File

@@ -0,0 +1,61 @@
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/MoviePoster";
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 { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { ScrollView, View } from "react-native";
const page: React.FC = () => {
const params = useLocalSearchParams();
const { id: seriesId } = params as { id: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item } = useQuery({
queryKey: ["item", seriesId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: Infinity,
});
const { data: next } = useQuery({
queryKey: ["nextUp", seriesId],
queryFn: async () =>
await nextUp({
userId: user?.Id,
api,
itemId: seriesId,
}),
enabled: !!api && !!seriesId && !!user?.Id,
staleTime: 0,
});
if (!item) return null;
return (
<ScrollView>
<View className="flex flex-col px-4 pt-4 pb-8">
<MoviePoster item={item} />
<View className="my-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<SeasonPicker item={item} />
<NextUp items={next} />
</View>
</ScrollView>
);
};
export default page;

View File

@@ -4,15 +4,17 @@ import { runningProcesses } from "@/components/DownloadItem";
import { ListItem } from "@/components/ListItem"; import { ListItem } from "@/components/ListItem";
import ProgressCircle from "@/components/ProgressCircle"; import ProgressCircle from "@/components/ProgressCircle";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { readFromLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native"; import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
const deleteAllFiles = async () => { const deleteAllFiles = async () => {
const directoryUri = FileSystem.documentDirectory; const directoryUri = FileSystem.documentDirectory;
@@ -83,90 +85,118 @@ export default function settings() {
})(); })();
}, [key]); }, [key]);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
});
return ( return (
<View className="p-4 flex flex-col gap-y-4"> <ScrollView>
<Text className="font-bold text-2xl">Information</Text> <View className="p-4 flex flex-col gap-y-4 pb-12">
<Text className="font-bold text-2xl">Information</Text>
<View className="rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-neutral-900"> <View className="rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-neutral-900">
<ListItem title="User" subTitle={user?.Name} /> <ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} /> <ListItem title="Server" subTitle={api?.basePath} />
</View> </View>
<Button onPress={logout}>Log out</Button>
<View className="mb-4"> <Button onPress={logout}>Log out</Button>
<Text className="font-bold text-2xl">Downloads</Text>
{files.length > 0 ? ( <View className="mb-4">
<View> <Text className="font-bold text-2xl mb-4">Downloads</Text>
{files.map((file) => (
<TouchableOpacity {files.length > 0 ? (
key={file.Id} <View>
className="rounded-xl overflow-hidden" {files.map((file) => (
onPress={() => { <TouchableOpacity
router.back(); key={file.Id}
router.push( className="rounded-xl overflow-hidden mb-2"
`/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}` onPress={() => {
); router.back();
}} router.push(
> `/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}`
<ListItem );
title={file.Name} }}
subTitle={file.ProductionYear?.toString()} >
iconAfter={ <ListItem
<TouchableOpacity title={file.Name}
onPress={() => { subTitle={file.ProductionYear?.toString()}
deleteFile(file.Id); iconAfter={
setKey((prevKey) => prevKey + 1); <TouchableOpacity
}} onPress={() => {
> deleteFile(file.Id);
<Ionicons setKey((prevKey) => prevKey + 1);
name="close-circle-outline" }}
size={24} >
color="white" <Ionicons
/> name="close-circle-outline"
</TouchableOpacity> size={24}
} color="white"
/>
</TouchableOpacity>
}
/>
</TouchableOpacity>
))}
</View>
) : activeProcess ? (
<ListItem
title={activeProcess.item.Name}
iconAfter={
<ProgressCircle
size={22}
fill={activeProcess.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/> />
</TouchableOpacity> }
))} />
</View> ) : (
) : activeProcess ? ( <Text className="opacity-50">No downloaded files</Text>
<ListItem )}
title={activeProcess.item.Name} </View>
iconAfter={
<ProgressCircle
size={22}
fill={activeProcess.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
}
/>
) : (
<Text className="opacity-50">No downloaded files</Text>
)}
</View>
<Button
onPress={() => {
deleteAllFiles();
setKey((prevKey) => prevKey + 1);
}}
>
Clear files data
</Button>
{session?.item.Id && (
<Button <Button
className="mb-2"
color="red"
onPress={() => { onPress={() => {
FFmpegKit.cancel(); deleteAllFiles();
setSession(null); setKey((prevKey) => prevKey + 1);
}} }}
> >
Cancel all downloads Clear downloads
</Button> </Button>
)}
</View> {session?.item.Id && (
<Button
className="mb-2"
onPress={() => {
FFmpegKit.cancel();
setSession(null);
}}
>
Cancel all downloads
</Button>
)}
<Text className="font-bold">Logs</Text>
<View className="flex flex-col space-y-2">
{logs?.map((l) => (
<View className="bg-neutral-800 border border-neutral-900 rounded p-2">
<Text
className={`
${l.level === "INFO" && "text-blue-500"}
${l.level === "ERROR" && "text-red-500"}
`}
>
{l.level}
</Text>
<Text>{l.message}</Text>
</View>
))}
</View>
</View>
</ScrollView>
); );
} }

View File

@@ -1,145 +0,0 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { z } from "zod";
const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});
const Login: React.FC = () => {
const { setServer, login, removeServer } = useJellyfin();
const [api] = useAtom(apiAtom);
const [serverURL, setServerURL] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: "",
password: "",
});
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
setLoading(true);
const result = CredentialsSchema.safeParse(credentials);
if (result.success) {
await login(credentials.username, credentials.password);
}
setLoading(false);
};
const parsedServerURL = useMemo(() => {
let parsedServerURL = serverURL.trim();
if (parsedServerURL) {
parsedServerURL = parsedServerURL.endsWith("/")
? parsedServerURL.replace("/", "")
: parsedServerURL;
parsedServerURL = parsedServerURL.startsWith("http")
? parsedServerURL
: "http://" + parsedServerURL;
return parsedServerURL;
}
return "";
}, [serverURL]);
const handleConnect = (url: string) => {
setServer({ address: url });
};
if (api?.basePath) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
<Text className="text-3xl font-bold">Jellyfin</Text>
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
<Button
onPress={() => {
removeServer();
setServerURL("");
}}
>
Change server
</Button>
<Text className="text-2xl font-bold">Log in</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</KeyboardAvoidingView>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
<Text className="text-3xl font-bold">Jellyfin</Text>
<Text className="opacity-50">Enter a server adress</Text>
<Input
className="mb-2"
placeholder="http(s)://..."
onChangeText={setServerURL}
value={serverURL}
autoFocus
secureTextEntry={false}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
clearButtonMode="while-editing"
maxLength={500}
/>
<Button onPress={() => handleConnect(parsedServerURL)}>Connect</Button>
</View>
</KeyboardAvoidingView>
);
};
export default Login;

View File

@@ -69,7 +69,6 @@ export default function RootLayout() {
name="(auth)/settings" name="(auth)/settings"
options={{ options={{
headerShown: true, headerShown: true,
title: "Settings", title: "Settings",
presentation: "modal", presentation: "modal",
headerLeft: () => ( headerLeft: () => (
@@ -106,7 +105,16 @@ export default function RootLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name="(public)/login" name="(auth)/series/[id]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: true,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }} options={{ headerShown: false, title: "Login" }}
/> />
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import React, { PropsWithChildren, ReactNode } from "react"; import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native"; import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
import * as Haptics from "expo-haptics";
interface ButtonProps { interface ButtonProps {
onPress?: () => void; onPress?: () => void;
@@ -8,6 +9,7 @@ interface ButtonProps {
disabled?: boolean; disabled?: boolean;
children?: string; children?: string;
loading?: boolean; loading?: boolean;
color?: "purple" | "red";
iconRight?: ReactNode; iconRight?: ReactNode;
} }
@@ -17,19 +19,32 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
textClassName = "", textClassName = "",
disabled = false, disabled = false,
loading = false, loading = false,
color = "purple",
iconRight, iconRight,
children, children,
}) => { }) => {
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";
}
}, [color]);
return ( return (
<TouchableOpacity <TouchableOpacity
className={` className={`
bg-purple-600 p-3 rounded-xl items-center justify-center p-3 rounded-xl items-center justify-center
${disabled ? "bg-neutral-400" : "active:bg-purple-600"} ${loading || (disabled && "opacity-50")}
${loading && "opacity-50"} ${colorClasses}
${className} ${className}
`} `}
onPress={() => { onPress={() => {
if (!loading && !disabled && onPress) onPress(); if (!loading && !disabled && onPress) {
onPress();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
}} }}
disabled={disabled || loading} disabled={disabled || loading}
> >

View File

@@ -1,11 +1,14 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop } from "@/utils/jellyfin"; import { getBackdrop } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "./common/Text";
import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -27,10 +30,13 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item.UserData?.PlayedPercentage || 0 item.UserData?.PlayedPercentage || 0
); );
if (!url) return <View></View>; if (!url)
return (
<View className="w-48 aspect-video border border-neutral-800"></View>
);
return ( return (
<View className="w-48 aspect-video rounded relative overflow-hidden border border-neutral-800"> <View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<Image <Image
key={item.Id} key={item.Id}
id={item.Id} id={item.Id}
@@ -41,19 +47,20 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
contentFit="cover" contentFit="cover"
className="w-full h-full" className="w-full h-full"
/> />
<WatchedIndicator item={item} />
{progress > 0 && ( {progress > 0 && (
<> <>
<View <View
style={{ style={{
width: `100%`, width: `100%`,
}} }}
className={`absolute bottom-0 left-0 h-1.5 bg-neutral-700 opacity-80 w-full`} className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
></View> ></View>
<View <View
style={{ style={{
width: `${progress}%`, width: `${progress}%`,
}} }}
className={`absolute bottom-0 left-0 h-1.5 bg-red-600 w-full`} className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
></View> ></View>
</> </>
)} )}

View File

@@ -1,14 +1,15 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as FileSystem from "expo-file-system"; import { writeToLog } from "@/utils/log";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { FFmpegKit, FFmpegKitConfig, Session } from "ffmpeg-kit-react-native"; import * as FileSystem from "expo-file-system";
import ProgressCircle from "./ProgressCircle"; import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import ProgressCircle from "./ProgressCircle";
import { router } from "expo-router";
type DownloadProps = { type DownloadProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -23,7 +24,13 @@ type ProcessItem = {
export const runningProcesses = atom<ProcessItem | null>(null); export const runningProcesses = atom<ProcessItem | null>(null);
const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => { const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
if (!item.Id || !item.Name) throw new Error("Item must have an Id and Name"); if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments", {
item,
inputUrl,
});
throw new Error("Item must have an Id and Name");
}
const [session, setSession] = useAtom<ProcessItem | null>(runningProcesses); const [session, setSession] = useAtom<ProcessItem | null>(runningProcesses);
@@ -32,8 +39,26 @@ const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`; const command = `-y -fflags +genpts -i ${inputUrl} -c copy -max_muxing_queue_size 9999 ${output}`;
const startRemuxing = useCallback(async () => { const startRemuxing = useCallback(async () => {
if (!item.Id || !item.Name) if (!item.Id || !item.Name) {
writeToLog(
"ERROR",
"useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments",
{
item,
inputUrl,
}
);
throw new Error("Item must have an Id and Name"); throw new Error("Item must have an Id and Name");
}
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${inputUrl}`,
{
item,
inputUrl,
}
);
try { try {
setSession({ setSession({
@@ -55,14 +80,6 @@ const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
percentage = Math.floor((processedFrames / totalFrames) * 100); percentage = Math.floor((processedFrames / totalFrames) * 100);
} }
console.log({
videoLength,
fps,
totalFrames,
processedFrames: statistics.getVideoFrameNumber(),
percentage,
});
setSession((prev) => { setSession((prev) => {
return prev?.item.Id === item.Id! return prev?.item.Id === item.Id!
? { ...prev, progress: percentage } ? { ...prev, progress: percentage }
@@ -84,18 +101,49 @@ const useRemuxHlsToMp4 = (inputUrl: string, item: BaseItemDto) => {
JSON.stringify([...otherItems, item]) JSON.stringify([...otherItems, item])
); );
console.log("Remuxing completed successfully"); writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
{
item,
inputUrl,
}
);
setSession(null); setSession(null);
} else if (returnCode.isValueError()) { } else if (returnCode.isValueError()) {
console.error("Failed to remux:"); console.error("Failed to remux:");
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
{
item,
inputUrl,
}
);
setSession(null); setSession(null);
} else if (returnCode.isValueCancel()) { } else if (returnCode.isValueCancel()) {
console.log("Remuxing was cancelled"); console.log("Remuxing was cancelled");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
{
item,
inputUrl,
}
);
setSession(null); setSession(null);
} }
}); });
} catch (error) { } catch (error) {
console.error("Failed to remux:", error); console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
{
item,
inputUrl,
}
);
} }
}, [inputUrl, output, item, command]); }, [inputUrl, output, item, command]);
@@ -142,6 +190,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
onPress={() => { onPress={() => {
cancelRemuxing(); cancelRemuxing();
}} }}
className="-rotate-45"
> >
<ProgressCircle <ProgressCircle
size={22} size={22}
@@ -152,16 +201,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({ url, item }) => {
/> />
</TouchableOpacity> </TouchableOpacity>
) : downloaded ? ( ) : downloaded ? (
<> <TouchableOpacity
<Ionicons name="checkmark-circle-outline" size={24} color="white" /> onPress={() => {
</> router.push(
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`
);
}}
>
<Ionicons name="cloud-download" size={28} color="#16a34a" />
</TouchableOpacity>
) : ( ) : (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
startRemuxing(); startRemuxing();
}} }}
> >
<Ionicons name="cloud-download-outline" size={24} color="white" /> <Ionicons name="cloud-download-outline" size={28} color="white" />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>

View File

@@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
type ItemCardProps = { type ItemCardProps = {
item: any; item: BaseItemDto;
}; };
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => { export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
@@ -16,17 +17,17 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
style={{ flexWrap: "wrap" }} style={{ flexWrap: "wrap" }}
className="flex text-xs opacity-50 break-all" className="flex text-xs opacity-50 break-all"
> >
{`S${item.SeasonName?.replace("Season ", "").padStart( {`S${item.SeasonName?.replace(
2, "Season ",
"0" ""
)}:E${item.IndexNumber.toString().padStart(2, "0")}`}{" "} )}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name} {item.Name}
</Text> </Text>
</> </>
) : ( ) : (
<> <>
<Text>{item.Name}</Text> <Text>{item.Name}</Text>
<Text></Text> <Text className="text-xs opacity-50">{item.ProductionYear}</Text>
</> </>
)} )}
</View> </View>

View File

@@ -1,22 +1,27 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop } from "@/utils/jellyfin"; import { getBackdrop, getPrimaryImageById } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
type MoviePosterProps = { type MoviePosterProps = {
item: BaseItemDto; item: BaseItemDto;
showProgress?: boolean;
}; };
const MoviePoster: React.FC<MoviePosterProps> = ({ item }) => { const MoviePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { data: url } = useQuery({ const { data: url } = useQuery({
queryKey: ["backdrop", item.Id], queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item), queryFn: async () => getPrimaryImageById(api, item.Id),
enabled: !!api && !!item.Id, enabled: !!api && !!item.Id,
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -25,10 +30,18 @@ const MoviePoster: React.FC<MoviePosterProps> = ({ item }) => {
item.UserData?.PlayedPercentage || 0 item.UserData?.PlayedPercentage || 0
); );
if (!url) return <View></View>; if (!url)
return (
<View
className="rounded-md overflow-hidden border border-neutral-900"
style={{
aspectRatio: "10/15",
}}
></View>
);
return ( return (
<View className="rounded-md overflow-hidden"> <View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image <Image
key={item.Id} key={item.Id}
id={item.Id} id={item.Id}
@@ -41,7 +54,10 @@ const MoviePoster: React.FC<MoviePosterProps> = ({ item }) => {
aspectRatio: "10/15", aspectRatio: "10/15",
}} }}
/> />
{progress > 0 && <View className="h-1.5 bg-red-600 w-full"></View>} <WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>
)}
</View> </View>
); );
}; };

View File

@@ -8,10 +8,8 @@ type VideoPlayerProps = {
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => { export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
const videoRef = useRef<VideoRef | null>(null); const videoRef = useRef<VideoRef | null>(null);
console.log(url);
const onError = (error: any) => { const onError = (error: any) => {
console.log("Video Error: ", error); console.error("Video Error: ", error);
}; };
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,58 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { markAsNotPlayed, markAsPlayed } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient, InvalidateQueryFilters } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import * as Haptics from "expo-haptics";
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
console.log("PlayedStatus", item.UserData);
const queryClient = useQueryClient();
return (
<View>
{item.UserData?.Played ? (
<TouchableOpacity
onPress={() => {
markAsNotPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
refetchType: "all",
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
>
<Ionicons name="checkmark-circle" size={30} color="white" />
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
markAsPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
refetchType: "all",
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
>
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
</TouchableOpacity>
)}
</View>
);
};

51
components/Poster.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageById } from "@/utils/jellyfin";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { View } from "react-native";
type PosterProps = {
itemId?: 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)
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={itemId}
id={itemId}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default Poster;

View File

@@ -24,6 +24,7 @@ const ProgressCircle: React.FC<ProgressCircleProps> = ({
fill={fill} fill={fill}
tintColor={tintColor} tintColor={tintColor}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
rotation={45}
/> />
); );
}; };

View File

@@ -61,7 +61,9 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
</View> </View>
</ScrollView> </ScrollView>
)} )}
{similarItems?.length === 0 && <Text>No similar items</Text>} {similarItems?.length === 0 && (
<Text className="px-4">No similar items</Text>
)}
</View> </View>
); );
}; };

View File

@@ -1,41 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop } 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 { View } from "react-native";
type VerticalPosterProps = {
item: BaseItemDto;
};
const VerticalPoster: React.FC<VerticalPosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item.Id,
staleTime: Infinity,
});
if (!url) return null;
return (
<View>
<Image
source={{
uri: url,
}}
style={{
height: 180,
width: 130,
borderRadius: 10,
}}
/>
</View>
);
};
export default VerticalPoster;

View File

@@ -1,6 +1,11 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import {
ActivityIndicator,
Switch,
TouchableOpacity,
View,
} from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -15,20 +20,52 @@ import {
getStreamUrl, getStreamUrl,
getUserItemData, getUserItemData,
reportPlaybackProgress, reportPlaybackProgress,
reportPlaybackStopped,
} from "@/utils/jellyfin"; } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Button } from "./Button"; import { Button } from "./Button";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "./common/Text";
type VideoPlayerProps = { type VideoPlayerProps = {
itemId: string; itemId: string;
}; };
const BITRATES = [
{
key: "Max",
value: 140000000,
},
{
key: "10 Mb/s",
value: 10000000,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "1 Mb/s",
value: 1000000,
},
{
key: "500 Kb/s",
value: 500000,
},
];
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => { export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const videoRef = useRef<VideoRef | null>(null); const videoRef = useRef<VideoRef | null>(null);
const [showPoster, setShowPoster] = useState(true); const [showPoster, setShowPoster] = useState(true);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [buffering, setBuffering] = useState(false); const [buffering, setBuffering] = useState(false);
const [maxBitrate, setMaxbitrate] = useState(140000000);
const [paused, setPaused] = useState(true);
const [forceTranscoding, setForceTranscoding] = useState<boolean>(false);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -61,40 +98,36 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
staleTime: Infinity, staleTime: Infinity,
}); });
useEffect(() => {
console.log(item?.UserData?.PlaybackPositionTicks);
console.log(item?.UserData?.PlayedPercentage);
}, [item]);
const { data: playbackURL } = useQuery({ const { data: playbackURL } = useQuery({
queryKey: ["playbackUrl", itemId], queryKey: ["playbackUrl", itemId, maxBitrate, forceTranscoding],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id) return; if (!api || !user?.Id) return null;
return (
(await getStreamUrl({ const url = await getStreamUrl({
api, api,
userId: user.Id, userId: user.Id,
item, item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
})) || undefined maxStreamingBitrate: maxBitrate,
); forceTranscoding: forceTranscoding,
});
console.log("Transcode URL:", url);
return url;
}, },
enabled: !!itemId && !!api && !!user?.Id && !!item, enabled: !!itemId && !!api && !!user?.Id && !!item,
staleTime: Infinity, staleTime: 0,
}); });
const { data: posterUrl } = useQuery({ const [progress, setProgress] = useState(0);
queryKey: ["backdrop", item?.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item?.Id,
staleTime: Infinity,
});
const onProgress = ({ const onProgress = ({
currentTime, currentTime,
playableDuration, playableDuration,
seekableDuration, seekableDuration,
}: OnProgressData) => { }: OnProgressData) => {
setProgress(currentTime * 10000000);
reportPlaybackProgress({ reportPlaybackProgress({
api, api,
itemId: itemId, itemId: itemId,
@@ -110,11 +143,11 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
currentTime: number; currentTime: number;
seekTime: number; seekTime: number;
}) => { }) => {
console.log("Seek to time: ", seekTime); // console.log("Seek to time: ", seekTime);
}; };
const onError = (error: any) => { const onError = (error: any) => {
console.log("Video Error: ", error); // console.log("Video Error: ", error);
}; };
const play = () => { const play = () => {
@@ -123,45 +156,110 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
} }
}; };
const startPosition = useMemo(() => {
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
}, [item]);
useEffect(() => { useEffect(() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.pause(); videoRef.current.pause();
} }
}, []); }, []);
if (!playbackURL) return null; const enableVideo = useMemo(() => {
return (
playbackURL !== undefined &&
item !== undefined &&
item !== null &&
startPosition !== undefined &&
sessionData !== undefined
);
}, [playbackURL, item, startPosition, sessionData]);
return ( return (
<View> <View>
<Video {enableVideo && (
style={{ width: 0, height: 0 }} <Video
source={{ style={{ width: 0, height: 0 }}
uri: playbackURL, source={{
isNetwork: true, uri: playbackURL!,
startPosition: Math.round( isNetwork: true,
(item?.UserData?.PlaybackPositionTicks || 0) / 10000 startPosition,
), }}
}} ref={videoRef}
ref={videoRef} onSeek={(t) => onSeek(t)}
onSeek={(t) => onSeek(t)} onError={onError}
onError={onError} onProgress={(e) => onProgress(e)}
onProgress={(e) => onProgress(e)} onFullscreenPlayerDidDismiss={() => {
onFullscreenPlayerDidDismiss={() => { videoRef.current?.pause();
videoRef.current?.pause(); reportPlaybackStopped({
}} api,
onFullscreenPlayerDidPresent={() => { itemId: item?.Id,
play(); positionTicks: progress,
}} sessionId: sessionData?.PlaySessionId,
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}} });
bufferConfig={{ }}
maxBufferMs: Infinity, onFullscreenPlayerDidPresent={() => {
minBufferMs: 1000 * 60 * 2, play();
bufferForPlaybackMs: 1000, }}
backBufferDurationMs: 30 * 1000, paused={paused}
}} onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
/> bufferConfig={{
maxBufferMs: Infinity,
minBufferMs: 1000 * 60 * 2,
bufferForPlaybackMs: 1000,
backBufferDurationMs: 30 * 1000,
}}
/>
)}
<View className="flex flex-row items-center justify-between">
<View className="flex flex-col mb-2">
<Text className="opacity-50 text-xs mb-1">Force transcoding</Text>
<Switch
value={forceTranscoding}
onValueChange={setForceTranscoding}
/>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === maxBitrate)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b: any) => (
<DropdownMenu.Item
key={b.value}
onSelect={() => {
setMaxbitrate(b.value);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col w-full"> <View className="flex flex-col w-full">
<Button <Button
disabled={!enableVideo}
onPress={() => { onPress={() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.presentFullscreenPlayer(); videoRef.current.presentFullscreenPlayer();

View File

@@ -0,0 +1,13 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<>
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className="bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45"></View>
)}
</>
);
};

View File

@@ -0,0 +1,47 @@
import React from "react";
import { ScrollView, View, ViewStyle, ActivityIndicator } from "react-native";
interface HorizontalScrollProps<T> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
}
export function HorizontalScroll<T>({
data,
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
}: HorizontalScrollProps<T>): React.ReactElement {
if (!data) {
return (
<View
style={[
{ flex: 1, justifyContent: "center", alignItems: "center" },
loadingContainerStyle,
]}
>
<ActivityIndicator size="small" />
</View>
);
}
return (
<ScrollView
horizontal
style={containerStyle}
contentContainerStyle={contentContainerStyle}
>
<View className="flex flex-row px-4">
{data.map((item, index) => (
<View className="mr-2" key={index}>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
))}
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,20 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className="p-4 rounded-xl overflow-hidden ">
<View className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"></View>
</View>
);
return (
<View className="p-4 rounded-xl overflow-hidden ">
<Image
source={{ uri: url }}
className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"
/>
</View>
);
};

View File

@@ -0,0 +1,24 @@
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";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
return (
<View>
<Text className="text-lg font-bold mb-2">Cast & Crew</Text>
<HorizontalScroll<NonNullable<BaseItemDto["People"]>[number]>
data={item.People}
renderItem={(item, index) => (
<TouchableOpacity key={item.Id} className="flex flex-col w-32">
<Poster itemId={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text className="text-xs opacity-50">{item.Role}</Text>
</TouchableOpacity>
)}
/>
</View>
);
};

View File

@@ -0,0 +1,28 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import Poster from "../Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
return (
<View>
<Text className="text-lg font-bold mb-2">Series</Text>
<HorizontalScroll<BaseItemDto>
data={[item]}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
className="flex flex-col space-y-2 w-32"
>
<Poster itemId={item.ParentBackdropItemId} />
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
)}
/>
</View>
);
};

View File

@@ -0,0 +1,40 @@
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 ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { router } from "expo-router";
export const NextUp = ({ items }: { items?: BaseItemDto[] | null }) => {
if (!items?.length)
return (
<View>
<Text className="text-lg font-bold mb-2">Next up</Text>
<Text className="opacity-50">No items to display</Text>
</View>
);
return (
<View>
<Text className="text-lg font-bold mb-2">Next up</Text>
<HorizontalScroll<BaseItemDto>
data={items}
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/${item.Id}/page`);
}}
key={item.Id}
className="flex flex-col w-32"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View>
);
};

View File

@@ -0,0 +1,137 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { Text } from "../common/Text";
import * as DropdownMenu from "zeego/dropdown-menu";
import { TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { router } from "expo-router";
type Props = {
item: BaseItemDto;
};
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`,
{
params: {
userId: user?.Id,
itemId: item.Id,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
return response.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id,
});
const { data: episodes } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Episodes`,
{
params: {
userId: user?.Id,
seasonId: selectedSeasonId,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
return response.data.Items as BaseItemDto[];
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
useEffect(() => {
if (!seasons || seasons.length === 0) return;
setSelectedSeasonId(
seasons.find((season: any) => season.IndexNumber === 1)?.Id
);
setSelectedSeason(1);
}, [seasons]);
return (
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {selectedSeason}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.map((season: any) => (
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSelectedSeason(season.IndexNumber);
setSelectedSeasonId(season.Id);
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{episodes && (
<View className="mt-2">
<HorizontalScroll<BaseItemDto>
data={episodes}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => {
router.push(`/(auth)/items/${item.Id}/page`);
}}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View>
)}
</View>
);
};

2
index.js Normal file
View File

@@ -0,0 +1,2 @@
import "react-native-url-polyfill/auto";
import "expo-router/entry";

View File

@@ -1,6 +1,6 @@
{ {
"name": "streamyfin", "name": "streamyfin",
"main": "expo-router/entry", "main": "./index",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -18,12 +18,14 @@
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0", "@jellyfin/sdk": "^0.10.0",
"@react-native-async-storage/async-storage": "^1.24.0", "@react-native-async-storage/async-storage": "^1.24.0",
"@react-native-menu/menu": "^1.1.2",
"@react-navigation/native": "^6.0.2", "@react-navigation/native": "^6.0.2",
"@tanstack/react-query": "^5.51.16", "@tanstack/react-query": "^5.51.16",
"expo": "~51.0.24", "expo": "~51.0.24",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.21", "expo-dev-client": "~4.0.21",
"expo-font": "~12.0.9", "expo-font": "~12.0.9",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.13", "expo-image": "~1.12.13",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-router": "~3.5.20", "expo-router": "~3.5.20",
@@ -41,13 +43,18 @@
"react-native-circular-progress": "^1.4.0", "react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25", "react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1", "react-native-gesture-handler": "~2.16.1",
"react-native-google-cast": "^4.8.2",
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-svg": "^15.4.0", "react-native-svg": "^15.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.4.3", "react-native-video": "^6.4.3",
"react-native-web": "~0.19.10", "react-native-web": "~0.19.10",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"zeego": "^1.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -2,6 +2,7 @@ import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { isLoaded } from "expo-font";
import { router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
@@ -37,8 +38,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [jellyfin] = useState( const [jellyfin] = useState(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "My Client Application", version: "1.0.0" }, clientInfo: { name: "Streamyfin", version: "1.0.0" },
deviceInfo: { name: "Device Name", id: "unique-device-id" }, deviceInfo: { name: "iOS", id: "unique-device-id" },
}) })
); );
@@ -55,7 +56,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const setServerMutation = useMutation({ const setServerMutation = useMutation({
mutationFn: async (server: Server) => { 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); setApi(apiInstance);
await AsyncStorage.setItem("serverUrl", server.address); await AsyncStorage.setItem("serverUrl", server.address);
}, },
@@ -86,17 +89,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const auth = await api.authenticateUserByName(username, password); const auth = await api.authenticateUserByName(username, password);
if (!auth.data.User) { if (auth.data.AccessToken && auth.data.User) {
console.info("Failed to authenticate user"); setUser(auth.data.User);
return; await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
}
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
if (auth.data.AccessToken) {
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken); await AsyncStorage.setItem("token", auth.data?.AccessToken);
} else {
throw new Error("Invalid username or password");
} }
}, },
onError: (error) => { onError: (error) => {
@@ -107,8 +106,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
setUser(null); setUser(null);
setApi(null);
await AsyncStorage.removeItem("token"); await AsyncStorage.removeItem("token");
if (api) await api.logout();
}, },
onError: (error) => { onError: (error) => {
console.error("Logout failed:", error); console.error("Logout failed:", error);
@@ -116,7 +115,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}); });
const { isLoading, isFetching } = useQuery({ const { isLoading, isFetching } = useQuery({
queryKey: ["initializeJellyfin"], queryKey: ["initializeJellyfin", user?.Id, api?.basePath],
queryFn: async () => { queryFn: async () => {
try { try {
const token = await AsyncStorage.getItem("token"); const token = await AsyncStorage.getItem("token");
@@ -125,10 +124,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
(await AsyncStorage.getItem("user")) as string (await AsyncStorage.getItem("user")) as string
) as UserDto; ) as UserDto;
console.log({
token,
serverUrl,
user,
});
if (serverUrl && token && user.Id) { if (serverUrl && token && user.Id) {
console.log("[0] Setting api");
const apiInstance = jellyfin.createApi(serverUrl, token); const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance); setApi(apiInstance);
setUser(user); setUser(user);
console.log(apiInstance.accessToken);
} }
return true; return true;
@@ -136,7 +143,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
console.error(e); console.error(e);
} }
}, },
staleTime: Infinity, staleTime: 0,
enabled: !user?.Id || !api,
}); });
const contextValue: JellyfinContextValue = { const contextValue: JellyfinContextValue = {
@@ -164,16 +172,18 @@ export const useJellyfin = (): JellyfinContextValue => {
return context; return context;
}; };
function useProtectedRoute(user: UserDto | null, isLoading: boolean) { function useProtectedRoute(user: UserDto | null, loading = false) {
const segments = useSegments(); const segments = useSegments();
useEffect(() => { useEffect(() => {
if (isLoading) return; if (loading) return;
const inAuthGroup = segments[0] === "(auth)"; const inAuthGroup = segments[0] === "(auth)";
const inLoginGroup = segments[0] === "(public)";
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {
router.replace("/login"); router.replace("/login");
} else if (user?.Id && inLoginGroup) { } else if (user?.Id && !inAuthGroup) {
router.replace("/"); router.replace("/");
} }
}, [user, segments]); }, [user, segments, loading]);
} }

View File

@@ -7,6 +7,172 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { iosProfile } from "./device-profiles"; import { iosProfile } from "./device-profiles";
export const markAsNotPlayed = async ({
api,
itemId,
userId,
}: {
api?: Api | null;
itemId?: string | null;
userId?: string | null;
}) => {
if (!itemId || !userId || !api) {
return false;
}
try {
const response = await api.axiosInstance.delete(
`${api.basePath}/UserPlayedItems/${itemId}`,
{
params: {
userId,
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
if (response.status === 200) return true;
return false;
} catch (error) {
const e = error as any;
console.error("Failed to report playback progress", e.message, e.status);
return [];
}
};
export const markAsPlayed = async ({
api,
itemId,
userId,
}: {
api?: Api | null;
itemId?: string | null;
userId?: string | null;
}) => {
if (!itemId || !userId || !api) {
return false;
}
try {
const response = await api.axiosInstance.post(
`${api.basePath}/UserPlayedItems/${itemId}`,
{
userId,
datePlayed: new Date().toISOString(),
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
if (response.status === 200) return true;
return false;
} catch (error) {
const e = error as any;
console.error("Failed to report playback progress:", {
message: e.message,
status: e.response?.status,
statusText: e.response?.statusText,
url: e.config?.url,
method: e.config?.method,
data: e.response?.data,
headers: e.response?.headers,
});
if (e.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error("Server responded with error:", e.response.data);
} else if (e.request) {
// The request was made but no response was received
console.error("No response received from server");
} else {
// Something happened in setting up the request that triggered an Error
console.error("Error setting up the request:", e.message);
}
return [];
}
};
export const nextUp = async ({
itemId,
userId,
api,
}: {
itemId?: string | null;
userId?: string | null;
api?: Api | null;
}) => {
if (!itemId || !userId || !api) {
return [];
}
try {
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/NextUp`,
{
params: {
SeriesId: itemId,
UserId: userId,
Fields: "MediaSourceCount",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
console.log(response.data);
return response?.data.Items as BaseItemDto[];
} catch (error) {
const e = error as any;
console.error("Failed to report playback progress", e.message, e.status);
return [];
}
};
export const reportPlaybackStopped = async ({
api,
sessionId,
itemId,
positionTicks,
}: {
api: Api | null | undefined;
sessionId: string | null | undefined;
itemId: string | null | undefined;
positionTicks: number | null | undefined;
}) => {
if (!api || !sessionId || !itemId || !positionTicks) {
console.log("Missing required parameters", {
api,
sessionId,
itemId,
positionTicks,
});
return;
}
try {
await api.axiosInstance.delete(`${api.basePath}/PlayingItems/${itemId}`, {
params: {
playSessionId: sessionId,
positionTicks: Math.round(positionTicks),
mediaSourceId: itemId,
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
});
} catch (error) {
console.error("Failed to report playback progress", error);
}
};
export const reportPlaybackProgress = async ({ export const reportPlaybackProgress = async ({
api, api,
sessionId, sessionId,
@@ -47,15 +213,6 @@ export const reportPlaybackProgress = async ({
); );
} catch (error) { } catch (error) {
console.error("Failed to report playback progress", error); console.error("Failed to report playback progress", error);
if (error.response) {
console.error("Error data:", error.response.data);
console.error("Error status:", error.response.status);
console.error("Error headers:", error.response.headers);
} else if (error.request) {
console.error("No response received:", error.request);
} else {
console.error("Error message:", error.message);
}
} }
}; };
@@ -196,12 +353,14 @@ export const getStreamUrl = async ({
userId, userId,
startTimeTicks = 0, startTimeTicks = 0,
maxStreamingBitrate = 140000000, maxStreamingBitrate = 140000000,
forceTranscoding = false,
}: { }: {
api: Api | null | undefined; api: Api | null | undefined;
item: BaseItemDto | null | undefined; item: BaseItemDto | null | undefined;
userId: string | null | undefined; userId: string | null | undefined;
startTimeTicks: number; startTimeTicks: number;
maxStreamingBitrate?: number; maxStreamingBitrate?: number;
forceTranscoding?: boolean;
}) => { }) => {
if (!api || !userId || !item?.Id) { if (!api || !userId || !item?.Id) {
return null; return null;
@@ -210,26 +369,24 @@ export const getStreamUrl = async ({
const itemId = item.Id; const itemId = item.Id;
let url: string = ""; let url: string = "";
console.info(
`Retrieving stream URL for item ID: ${item.Id} with start position: ${startTimeTicks}.`
);
try { try {
const response = await api.axiosInstance.post( const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`, `${api.basePath}/Items/${itemId}/PlaybackInfo`,
{ {
DeviceProfile: iosProfile, DeviceProfile: {
...iosProfile,
MaxStaticBitrate: maxStreamingBitrate,
MaxStreamingBitrate: maxStreamingBitrate,
},
UserId: userId,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: startTimeTicks,
EnableTranscoding: forceTranscoding,
AutoOpenLiveStream: true,
MediaSourceId: itemId,
AllowVideoStreamCopy: forceTranscoding ? false : true,
}, },
{ {
params: {
itemId,
userId,
startTimeTicks,
maxStreamingBitrate,
mediaSourceId: itemId,
enableTranscoding: true,
autoOpenLiveStream: true,
},
headers: { headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
}, },
@@ -286,7 +443,7 @@ export const getStreamUrl = async ({
export const getBackdrop = ( export const getBackdrop = (
api: Api | null | undefined, api: Api | null | undefined,
item: BaseItemDto | null | undefined, item: BaseItemDto | null | undefined,
quality: number = 10 quality: number = 50
) => { ) => {
if (!api || !item) { if (!api || !item) {
console.warn("getBackdrop ~ Missing API or Item"); console.warn("getBackdrop ~ Missing API or Item");
@@ -294,20 +451,59 @@ export const getBackdrop = (
} }
if (item.BackdropImageTags && item.BackdropImageTags[0]) { if (item.BackdropImageTags && item.BackdropImageTags[0]) {
return `${api.basePath}/Items/${item?.Id}/Images/Backdrop?quality=${quality}&tag=${item?.BackdropImageTags?.[0]}`; return `${api.basePath}/Items/${item?.Id}/Images/Backdrop?quality=${quality}&fillWidth=500&tag=${item?.BackdropImageTags?.[0]}`;
} }
if (item.ImageTags && item.ImageTags.Primary) { if (item.ImageTags && item.ImageTags.Primary) {
return `${api.basePath}/Items/${item?.Id}/Images/Primary?quality=${quality}&tag=${item.ImageTags.Primary}`; return `${api.basePath}/Items/${item?.Id}/Images/Primary?quality=${quality}&fillWidth=500&tag=${item.ImageTags.Primary}`;
} }
if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) { if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
return `${api.basePath}/Items/${item?.Id}/Images/Primary?quality=${quality}&tag=${item.ImageTags?.Primary}`; return `${api.basePath}/Items/${item?.Id}/Images/Primary?quality=${quality}&fillWidth=500&tag=${item.ImageTags?.Primary}`;
} }
return null; return null;
}; };
/**
* 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}`;
};
/**
* 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 getPrimaryImageById = (
api: Api | null | undefined,
itemId: string | null | undefined,
quality: number = 10
) => {
if (!api) {
return null;
}
return `${api.basePath}/Items/${itemId}/Images/Primary?quality=${quality}`;
};
/** /**
* Builds a stream URL for the given item ID, user ID, and other parameters. * Builds a stream URL for the given item ID, user ID, and other parameters.
* *
@@ -354,3 +550,10 @@ function buildStreamUrl({
`${serverUrl}/Videos/${itemId}/stream.mp4?${streamParams.toString()}` `${serverUrl}/Videos/${itemId}/stream.mp4?${streamParams.toString()}`
); );
} }
export const bitrateToString = (bitrate: number) => {
const kbps = bitrate / 1000;
const mbps = (bitrate / 1000000).toFixed(2);
return `${mbps} Mb/s`;
};

47
utils/log.ts Normal file
View File

@@ -0,0 +1,47 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
type LogLevel = "INFO" | "WARN" | "ERROR";
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
data?: any;
}
const asyncStorage = createJSONStorage(() => AsyncStorage);
const logsAtom = atomWithStorage("logs", [], asyncStorage);
export const writeToLog = async (
level: LogLevel,
message: string,
data?: any
) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
data: data,
};
const currentLogs = await AsyncStorage.getItem("logs");
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
logs.push(newEntry);
const maxLogs = 100;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
};
export const readFromLog = async (): Promise<LogEntry[]> => {
const logs = await AsyncStorage.getItem("logs");
return logs ? JSON.parse(logs) : [];
};
export const clearLogs = async () => {
await AsyncStorage.removeItem("logs");
};
export default logsAtom;