forked from Ninjalama/streamyfin_mirror
fix
This commit is contained in:
1
app.json
1
app.json
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
61
app/(auth)/series/[id]/page.tsx
Normal file
61
app/(auth)/series/[id]/page.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
58
components/PlayedStatus.tsx
Normal file
58
components/PlayedStatus.tsx
Normal 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
51
components/Poster.tsx
Normal 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;
|
||||||
@@ -24,6 +24,7 @@ const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
|||||||
fill={fill}
|
fill={fill}
|
||||||
tintColor={tintColor}
|
tintColor={tintColor}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
|
rotation={45}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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();
|
||||||
|
|||||||
13
components/WatchedIndicator.tsx
Normal file
13
components/WatchedIndicator.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
47
components/common/HorrizontalScroll.tsx
Normal file
47
components/common/HorrizontalScroll.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
components/series/CastAndCrew.tsx
Normal file
24
components/series/CastAndCrew.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
components/series/CurrentSeries.tsx
Normal file
28
components/series/CurrentSeries.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
components/series/NextUp.tsx
Normal file
40
components/series/NextUp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
137
components/series/SeasonPicker.tsx
Normal file
137
components/series/SeasonPicker.tsx
Normal 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
2
index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "react-native-url-polyfill/auto";
|
||||||
|
import "expo-router/entry";
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
47
utils/log.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user