mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
feat: favorites tab
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { actorId } = local as { actorId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", actorId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: actorId,
|
||||
}),
|
||||
enabled: !!actorId && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
personIds: [actorId],
|
||||
startIndex: pageParam,
|
||||
limit: 16,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
recursive: true,
|
||||
fields: [
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
],
|
||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||
collapseBoxSetItems: false,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, actorId]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 my-4">
|
||||
<View className="px-4 mb-4">
|
||||
<MoviesTitleHeader item={item} className="mb-4" />
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
Appeared In
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
height={247}
|
||||
renderItem={(i, idx) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={i}
|
||||
className={`flex flex-col
|
||||
${"w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={i} />
|
||||
<ItemCardText item={i} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className="h-12"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { SongsList } from "@/components/music/SongsList";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId, artistId, albumId } = searchParams as {
|
||||
collectionId: string;
|
||||
artistId: string;
|
||||
albumId: string;
|
||||
};
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const { data: album } = useQuery({
|
||||
queryKey: ["album", albumId, artistId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [albumId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!albumId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: songs,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["songs", artistId, albumId],
|
||||
queryFn: async () => {
|
||||
if (!api)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: albumId,
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!album) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<ItemImage
|
||||
variant={"Primary"}
|
||||
item={album}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="px-4 mb-8">
|
||||
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{songs?.TotalRecordCount} songs
|
||||
</Text>
|
||||
</View>
|
||||
<View className="px-4">
|
||||
<SongsList
|
||||
albumId={albumId}
|
||||
songs={songs?.Items}
|
||||
collectionId={collectionId}
|
||||
artistId={artistId}
|
||||
/>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { artistId } = searchParams as {
|
||||
artistId: string;
|
||||
};
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
|
||||
const { data: artist } = useQuery({
|
||||
queryKey: ["album", artistId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [artistId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: albums,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["albums", artistId, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: artistId,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
recursive: true,
|
||||
fields: [
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
],
|
||||
collapseBoxSetItems: false,
|
||||
albumArtistIds: [artistId],
|
||||
startIndex,
|
||||
limit: 100,
|
||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!artist || !albums) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<ItemImage
|
||||
variant={"Primary"}
|
||||
item={artist}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="px-4 mb-8">
|
||||
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{albums.TotalRecordCount} albums
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row flex-wrap justify-between px-4">
|
||||
{albums.Items.map((item, idx) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{ width: "30%", marginBottom: 20 }}
|
||||
key={idx}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<ArtistPoster item={item} />
|
||||
<Text numberOfLines={2}>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [collectionId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api || !collectionId)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const response = await getArtistsApi(api).getArtists({
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
parentId: collectionId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
return data?.TotalRecordCount;
|
||||
}, [data]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: 140,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View className="mb-4">
|
||||
<Text className="font-bold text-3xl mb-2">Artists</Text>
|
||||
</View>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
data={data.Items}
|
||||
numColumns={3}
|
||||
columnWrapperStyle={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<TouchableItemRouter
|
||||
style={{
|
||||
maxWidth: "30%",
|
||||
width: "100%",
|
||||
}}
|
||||
key={index}
|
||||
item={item}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
{collection?.CollectionType === "movies" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{collection?.CollectionType === "music" && (
|
||||
<ArtistPoster item={item} />
|
||||
)}
|
||||
<Text>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: collectionId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const data = response.data;
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
|
||||
if (!collection) return;
|
||||
|
||||
// Convert the DisplayOrder to SortByOption
|
||||
const displayOrder = collection.DisplayOrder as ItemSortBy;
|
||||
const sortByOption = displayOrder
|
||||
? SortByOption[displayOrder as keyof typeof SortByOption] ||
|
||||
SortByOption.PremiereDate
|
||||
: SortByOption.PremiereDate;
|
||||
|
||||
setSortBy([sortByOption]);
|
||||
}, [navigation, collection]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !collection) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
startIndex: pageParam,
|
||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||
sortBy: [sortBy[0]],
|
||||
sortOrder: [sortOrder[0]],
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
collectionId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
} else {
|
||||
fadeIn(() => {});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||
<Text>Could not load item</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-1 relative">
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className="bg-transparent rounded-lg mb-4 w-full"
|
||||
></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||
<View className="flex flex-row space-x-1 mb-8">
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
</View>
|
||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Image } from "expo-image";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { Button } from "@/components/Button";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequestString,
|
||||
posterSrc,
|
||||
...result
|
||||
} = params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
|
||||
const canRequest = canRequestString === "true";
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
if (result.id && issueType && issueMessage && details) {
|
||||
jellyseerrApi
|
||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||
.then(() => {
|
||||
setIssueType(undefined);
|
||||
setIssueMessage(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
});
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
[details, result, requestMedia]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<View>
|
||||
{result.backdropPath ? (
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<View className="space-y-4">
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className="font-bold text-2xl mb-1"
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className="opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
<Image
|
||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
source={{
|
||||
uri: posterSrc,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{canRequest ? (
|
||||
<Button color="purple" onPress={request}>
|
||||
Request
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||
color="transparent"
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name="warning-outline" size={24} color="white" />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
Report issue
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
</View>
|
||||
|
||||
{result.mediaType === MediaType.TV && (
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
details={details as TvDetails}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
Whats wrong?
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 items-start">
|
||||
<View className="flex flex-col">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
Issue Type
|
||||
</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: "Select an issue"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>Types</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder="(optional) Describe the issue..."
|
||||
value={issueMessage}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="none"
|
||||
maxLength={254}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||
Submit
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName="programs"
|
||||
keyboardDismissMode="none"
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
tabBarItemStyle: {
|
||||
width: 100,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="programs" />
|
||||
<Tab.Screen name="guide" />
|
||||
<Tab.Screen name="channels" />
|
||||
<Tab.Screen name="recordings" />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: 0,
|
||||
limit: 500,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex flex-1">
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex flex-row items-center px-4 mb-2">
|
||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
width: 60,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [date, setDate] = useState<Date>(new Date());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: guideInfo } = useQuery({
|
||||
queryKey: ["livetv", "guideInfo"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels", currentPage],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: programs } = useQuery({
|
||||
queryKey: ["livetv", "programs", date, currentPage],
|
||||
queryFn: async () => {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const now = new Date();
|
||||
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||
|
||||
const res = await getLiveTvApi(api!).getPrograms({
|
||||
getProgramsDto: {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
SortBy: ["StartDate"],
|
||||
EnableTotalRecordCount: false,
|
||||
EnableUserData: false,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!channels,
|
||||
});
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<PageButtons
|
||||
currentPage={currentPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
isNextDisabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className="bg-neutral-800"
|
||||
></View>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
item={c}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={{
|
||||
width: screenWidth - 64,
|
||||
}}
|
||||
horizontal
|
||||
scrollEnabled
|
||||
onScroll={(e) => {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
channel={c}
|
||||
programs={programs?.Items}
|
||||
key={c.Id}
|
||||
scrollX={scrollX}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
currentPage: number;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
currentPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isNextDisabled,
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${
|
||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-white">Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
>
|
||||
Next
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={"On now"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||
userId: user?.Id,
|
||||
isAiring: true,
|
||||
limit: 24,
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
title={"Shows"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isNews: false,
|
||||
isKids: false,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
title={"Movies"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
title={"Sports"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isSports: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
title={"For Kids"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isKids: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
title={"News"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isNews: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
return (
|
||||
<View className="flex items-center justify-center h-full -mt-12">
|
||||
<Text>Coming soon</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Ratings } from "@/components/Ratings";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
};
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["series", seriesId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: seriesId,
|
||||
}),
|
||||
enabled: !!seriesId && !!api,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() =>
|
||||
getLogoImageUrlById({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItems
|
||||
title="Download Series"
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [allEpisodes, isLoading]);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4">
|
||||
<SeriesHeader item={item} />
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
Reference in New Issue
Block a user