forked from Ninjalama/streamyfin_mirror
Compare commits
33 Commits
revert-377
...
feat/hide-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2beb8299 | ||
|
|
49d157a95a | ||
|
|
a061f9f480 | ||
|
|
0fb6f2fb30 | ||
|
|
0773f773ba | ||
|
|
39bb3a9370 | ||
|
|
b79e534692 | ||
|
|
e9336e9a67 | ||
|
|
adfde1a7cd | ||
|
|
cab6257fb2 | ||
|
|
3f0f0090af | ||
|
|
b278632581 | ||
|
|
5ee1a9cabb | ||
|
|
2169bea031 | ||
|
|
95cf252349 | ||
|
|
636a27246f | ||
|
|
a488c68633 | ||
|
|
7342b7eb92 | ||
|
|
8370519758 | ||
|
|
85e21edbf1 | ||
|
|
8d4115f5a0 | ||
|
|
c5d7a6729b | ||
|
|
db4046267f | ||
|
|
b506871c46 | ||
|
|
734678b1d5 | ||
|
|
68e98bbb94 | ||
|
|
d84ed558f3 | ||
|
|
ad39e8e10a | ||
|
|
29bba04fdd | ||
|
|
5a24957e88 | ||
|
|
39f2735756 | ||
|
|
5dc86d4765 | ||
|
|
d13731c28f |
@@ -77,6 +77,12 @@ export default function IndexLayout() {
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/hide-libraries/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
|
||||
60
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
60
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Switch, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="mt-4">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="px-4">
|
||||
<ListGroup>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
hiddenLibraries: value
|
||||
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
Select the libraries you want to hide from the Library tab.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
router,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Animated } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { orderBy } from "lodash";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
|
||||
const ANIMATION_ENTER = 250;
|
||||
const ANIMATION_EXIT = 250;
|
||||
const BACKDROP_DURATION = 5000;
|
||||
|
||||
export default function page() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const local = useLocalSearchParams();
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
const from = segments[2];
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ["jellyseerr", "person", personId],
|
||||
queryFn: async () => ({
|
||||
details: await jellyseerrApi?.personDetails(personId),
|
||||
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
||||
}),
|
||||
enabled: !!jellyseerrApi && !!personId,
|
||||
});
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const region = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc"
|
||||
),
|
||||
[data?.combinedCredits]
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() => castedRoles.map((c) => c.backdropPath),
|
||||
[data?.combinedCredits]
|
||||
);
|
||||
|
||||
const enterAnimation = useCallback(
|
||||
() =>
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: ANIMATION_ENTER,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[fadeAnim]
|
||||
);
|
||||
|
||||
const exitAnimation = useCallback(
|
||||
() =>
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: ANIMATION_EXIT,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
[fadeAnim]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (backdrops?.length) {
|
||||
enterAnimation().start();
|
||||
const intervalId = setInterval(() => {
|
||||
exitAnimation().start((end) => {
|
||||
if (end.finished)
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length);
|
||||
});
|
||||
}, BACKDROP_DURATION);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
|
||||
|
||||
const viewDetails = (credit: PersonCreditCast) => {
|
||||
router.push({
|
||||
//@ts-ignore
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
//@ts-ignore
|
||||
params: {
|
||||
...credit,
|
||||
mediaTitle: credit.title,
|
||||
releaseYear: new Date(credit.releaseDate).getFullYear(),
|
||||
canRequest: "false",
|
||||
posterSrc: jellyseerrApi?.imageProxy(
|
||||
credit.posterPath,
|
||||
"w300_and_h450_face"
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<Animated.Image
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
backdrops?.[currentIndex],
|
||||
"w1920_and_h800_multi_faces"
|
||||
),
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: fadeAnim,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
<Image
|
||||
key={data?.details?.id}
|
||||
id={data?.details?.id.toString()}
|
||||
className="rounded-full bottom-1"
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
data?.details?.profilePath,
|
||||
"w600_and_h600_bestv2"
|
||||
),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
width: 125,
|
||||
height: 125,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-full">
|
||||
<Text className="font-bold text-2xl mb-1">
|
||||
{data?.details?.name}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
Born{" "}
|
||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||
|
||||
<View>
|
||||
<FlashList
|
||||
data={castedRoles}
|
||||
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>
|
||||
}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={
|
||||
<Text className="text-lg font-bold my-2">Appearances</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
className="w-full flex flex-col pr-2"
|
||||
onPress={() => viewDetails(item)}
|
||||
>
|
||||
<Poster
|
||||
id={item.id.toString()}
|
||||
url={jellyseerrApi?.imageProxy(item.posterPath)}
|
||||
/>
|
||||
<JellyseerrMediaIcon
|
||||
className="absolute top-1 left-1"
|
||||
mediaType={item.mediaType as "movie" | "tv"}
|
||||
/>
|
||||
{/*<Text numberOfLines={1}>{item.title}</Text>*/}
|
||||
{item.character && (
|
||||
<Text
|
||||
className="text-xs opacity-50 align-bottom mt-1"
|
||||
numberOfLines={1}
|
||||
>
|
||||
as {item.character}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
estimatedItemSize={255}
|
||||
numColumns={3}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useLocalSearchParams, useNavigation } 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 { 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 {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
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, BottomSheetTextInput,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
@@ -27,24 +38,24 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
|
||||
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 { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||
params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
|
||||
const canRequest = canRequestString === "true";
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
@@ -55,7 +66,7 @@ const Page: React.FC = () => {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
@@ -72,6 +83,8 @@ const Page: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const canRequest = useJellyseerrCanRequest(details);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -95,21 +108,32 @@ const Page: React.FC = () => {
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const request = useCallback(
|
||||
async () => {
|
||||
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),
|
||||
},
|
||||
refetch
|
||||
)
|
||||
},
|
||||
[details, result, requestMedia]
|
||||
);
|
||||
const request = useCallback(async () => {
|
||||
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),
|
||||
},
|
||||
refetch
|
||||
);
|
||||
}, [details, result, requestMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
||||
<ItemActions item={details} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -133,7 +157,10 @@ const Page: React.FC = () => {
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
result.backdropPath,
|
||||
"w1920_and_h800_multi_faces"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -182,7 +209,9 @@ const Page: React.FC = () => {
|
||||
<View className="mb-4">
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{canRequest ? (
|
||||
{isLoading || isFetching ? (
|
||||
<Button loading={true} disabled={true} color="purple"></Button>
|
||||
) : canRequest ? (
|
||||
<Button color="purple" onPress={request}>
|
||||
Request
|
||||
</Button>
|
||||
@@ -213,6 +242,11 @@ const Page: React.FC = () => {
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
<DetailFacts
|
||||
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
||||
details={details}
|
||||
/>
|
||||
<Cast details={details} />
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
@@ -279,13 +313,11 @@ const Page: React.FC = () => {
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full"
|
||||
>
|
||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{color: "white"}}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode="always"
|
||||
placeholder="(optional) Describe the issue..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
@@ -23,20 +23,20 @@ export default function index() {
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000 * 60,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const libraries = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const item of data || []) {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -63,7 +63,7 @@ export default function index() {
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!data)
|
||||
if (!libraries)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||
@@ -81,7 +81,7 @@ export default function index() {
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={data}
|
||||
data={libraries}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
ItemSeparatorComponent={() =>
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function SearchLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,12 +31,18 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||
import { sortBy } from "lodash";
|
||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -149,8 +155,8 @@ export default function search() {
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||
const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
|
||||
queryKey: ["search", "jellyseerr", "results", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
const response = await jellyseerrApi?.search({
|
||||
query: new URLSearchParams(debouncedSearch).toString(),
|
||||
@@ -166,14 +172,15 @@ export default function search() {
|
||||
debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length == 0,
|
||||
});
|
||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } =
|
||||
useReactNavigationQuery({
|
||||
queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length == 0,
|
||||
});
|
||||
|
||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||
() =>
|
||||
@@ -191,6 +198,14 @@ export default function search() {
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === "person"
|
||||
) as PersonResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
@@ -300,7 +315,7 @@ export default function search() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col pt-2">
|
||||
<View className="flex flex-col">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
@@ -486,6 +501,19 @@ export default function search() {
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header="Actors"
|
||||
items={jellyseerrPersonResults}
|
||||
renderItem={(item: PersonResult) => (
|
||||
<PersonPoster
|
||||
className="mr-2"
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
name={item.name}
|
||||
posterPath={item.profilePath}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Dimensions, View } from "react-native";
|
||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useGlobalSearchParams();
|
||||
|
||||
const { url } = searchParams as { url: string };
|
||||
|
||||
const videoId = useMemo(() => {
|
||||
return url.split("v=")[1];
|
||||
}, [url]);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||
if (state === "ended") {
|
||||
setPlaying(false);
|
||||
Alert.alert("video has finished playing!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlaying = useCallback(() => {
|
||||
setPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
togglePlaying();
|
||||
}, []);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||
<YoutubePlayer
|
||||
height={300}
|
||||
play={playing}
|
||||
videoId={videoId}
|
||||
onChangeState={onStateChange}
|
||||
width={screenWidth}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -336,14 +336,6 @@ function Layout() {
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/trailer/page"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
|
||||
@@ -2,8 +2,13 @@ import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
Ionicons,
|
||||
MaterialCommunityIcons,
|
||||
MaterialIcons,
|
||||
} from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
@@ -39,7 +44,6 @@ const Login: React.FC = () => {
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -77,8 +81,10 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600">Change server</Text>
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
@@ -95,9 +101,9 @@ const Login: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
Alert.alert("Connection failed", error.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
Alert.alert("Connection failed", "An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -136,6 +142,8 @@ const Login: React.FC = () => {
|
||||
return url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
@@ -230,7 +238,6 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
@@ -244,28 +251,34 @@ const Login: React.FC = () => {
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="flex-1 mr-2"
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="cellphone-lock"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
||||
<Button
|
||||
color="black"
|
||||
onPress={handleQuickConnect}
|
||||
className="w-full mb-2"
|
||||
>
|
||||
Use Quick Connect
|
||||
</Button>
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
||||
<View className="flex flex-col h-full items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
<Image
|
||||
style={{
|
||||
@@ -281,7 +294,8 @@ const Login: React.FC = () => {
|
||||
Enter the URL to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
aria-label="Server URL"
|
||||
placeholder="http(s)://your-server.com"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
@@ -290,16 +304,7 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="text-xs text-neutral-500 ml-4">
|
||||
Make sure to include http or https
|
||||
</Text>
|
||||
<PreviousServersList
|
||||
onServerSelect={(s) => {
|
||||
handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
@@ -308,6 +313,11 @@ const Login: React.FC = () => {
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<PreviousServersList
|
||||
onServerSelect={(s) => {
|
||||
handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
case "red":
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
return "bg-neutral-900 border border-neutral-800";
|
||||
return "bg-neutral-900";
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
}
|
||||
@@ -61,7 +61,9 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader />
|
||||
<View className="p-0.5">
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
className={`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// GenreTags.tsx
|
||||
import React from "react";
|
||||
import {View, ViewProps} from "react-native";
|
||||
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
@@ -8,14 +8,15 @@ interface TagProps {
|
||||
textClass?: ViewProps["className"]
|
||||
}
|
||||
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
||||
text,
|
||||
textClass,
|
||||
textStyle,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||
<Text className={textClass}>{text}</Text>
|
||||
<Text className={textClass} style={textStyle}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||
className="p-4 rounded-xl bg-neutral-900"
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -16,8 +18,6 @@ export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string
|
||||
) => {
|
||||
console.log(item.Type, item?.CollectionType);
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
@@ -68,10 +68,33 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
||||
|
||||
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
await markAsPlayedStatus(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
||||
|
||||
if (
|
||||
from === "(home)" ||
|
||||
@@ -80,78 +103,16 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
from === "(favorites)"
|
||||
)
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(true);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">
|
||||
Mark as watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "green", // Changed to green for "watched"
|
||||
light: "green",
|
||||
},
|
||||
}}
|
||||
androidIconName="checkmark-circle"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
key="item-2"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(false);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
destructive
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-2-title">
|
||||
Mark as not watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
||||
pointSize: 18, // Adjusted for better visibility
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "red", // Changed to red for "not watched"
|
||||
light: "red",
|
||||
},
|
||||
// Removed paletteColors as it's not necessary in this case
|
||||
}}
|
||||
androidIconName="eye-slash"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
39
components/jellyseerr/Cast.tsx
Normal file
39
components/jellyseerr/Cast.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import React from "react";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||
|
||||
const CastSlide: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, ...props }) => {
|
||||
return (
|
||||
details?.credits?.cast?.length &&
|
||||
details?.credits?.cast?.length > 0 && (
|
||||
<View {...props}>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
ItemSeparatorComponent={() => <View className="w-2" />}
|
||||
estimatedItemSize={15}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
<PersonPoster
|
||||
id={item.id.toString()}
|
||||
posterPath={item.profilePath}
|
||||
name={item.name}
|
||||
subName={item.character}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default CastSlide;
|
||||
218
components/jellyseerr/DetailFacts.tsx
Normal file
218
components/jellyseerr/DetailFacts.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { uniqBy } from "lodash";
|
||||
import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
|
||||
interface Release {
|
||||
certification: string;
|
||||
iso_639_1?: string;
|
||||
note?: string;
|
||||
release_date: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
|
||||
const Facts: React.FC<
|
||||
{ title: string; facts?: string[] | React.ReactNode[] } & ViewProps
|
||||
> = ({ title, facts, ...props }) =>
|
||||
facts &&
|
||||
facts?.length > 0 && (
|
||||
<View className="flex flex-row justify-between py-2" {...props}>
|
||||
<Text className="font-bold">{title}</Text>
|
||||
|
||||
<View className="flex flex-col items-end">
|
||||
{facts.map((f, idx) =>
|
||||
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
title,
|
||||
fact,
|
||||
...props
|
||||
}) => fact && <Facts title={title} facts={[fact]} {...props} />;
|
||||
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { jellyseerrUser } = useJellyseerr();
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const region = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const releases = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region
|
||||
)?.release_dates as TmdbRelease["release_dates"],
|
||||
[details]
|
||||
);
|
||||
|
||||
// Release date types:
|
||||
// 1. Premiere
|
||||
// 2. Theatrical (limited)
|
||||
// 3. Theatrical
|
||||
// 4. Digital
|
||||
// 5. Physical
|
||||
// 6. TV
|
||||
const filteredReleases = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
|
||||
"type"
|
||||
),
|
||||
[releases]
|
||||
);
|
||||
|
||||
const firstAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
if (firstAirDate) {
|
||||
return new Date(firstAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
const nextAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||
return new Date(nextAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
|
||||
const revenue = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" }
|
||||
),
|
||||
[details]
|
||||
);
|
||||
|
||||
const budget = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" }
|
||||
),
|
||||
[details]
|
||||
);
|
||||
|
||||
const streamingProviders = useMemo(
|
||||
() =>
|
||||
details?.watchProviders?.find(
|
||||
(provider) => provider.iso_3166_1 === region
|
||||
)?.flatrate,
|
||||
[details]
|
||||
);
|
||||
|
||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||
|
||||
const spokenLanguage = useMemo(
|
||||
() =>
|
||||
details?.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === details.originalLanguage
|
||||
)?.name,
|
||||
[details]
|
||||
);
|
||||
|
||||
return (
|
||||
details && (
|
||||
<View className="p-4">
|
||||
<Text className="text-lg font-bold">Details</Text>
|
||||
<View
|
||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||
{...props}
|
||||
>
|
||||
<Fact title="Status" fact={details?.status} />
|
||||
<Fact
|
||||
title="Original Title"
|
||||
fact={(details as TvDetails)?.originalName}
|
||||
/>
|
||||
{details.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
) && <Fact title="Series Type" fact="Anime" />}
|
||||
<Facts
|
||||
title="Release Dates"
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className="flex flex-row space-x-2 items-center">
|
||||
{r.type === 3 ? (
|
||||
// Theatrical
|
||||
<Ionicons name="ticket" size={16} color="white" />
|
||||
) : r.type === 4 ? (
|
||||
// Digital
|
||||
<Ionicons name="cloud" size={16} color="white" />
|
||||
) : (
|
||||
// Physical
|
||||
<MaterialCommunityIcons
|
||||
name="record-circle-outline"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
<Text>
|
||||
{new Date(r.release_date).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Fact title="First Air Date" fact={firstAirDate} />
|
||||
<Fact title="Next Air Date" fact={nextAirDate} />
|
||||
<Fact title="Revenue" fact={revenue} />
|
||||
<Fact title="Budget" fact={budget} />
|
||||
<Fact title="Original Language" fact={spokenLanguage} />
|
||||
<Facts
|
||||
title="Production Country"
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className="flex flex-row items-center space-x-2">
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
<Text>{n.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Facts
|
||||
title="Studios"
|
||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||
(n) => n.name
|
||||
)}
|
||||
/>
|
||||
<Facts title="Network" facts={networks?.map((n) => n.name)} />
|
||||
<Facts
|
||||
title="Currently Streaming on"
|
||||
facts={streamingProviders?.map((s) => s.name)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailFacts;
|
||||
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import {useMemo} from "react";
|
||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {View, ViewProps} from "react-native";
|
||||
|
||||
const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||
mediaType,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const style = useMemo(
|
||||
() => mediaType === MediaType.MOVIE
|
||||
? 'bg-blue-600/90 border-blue-400/40'
|
||||
: 'bg-purple-600/90 border-purple-400/40',
|
||||
[mediaType]
|
||||
);
|
||||
return (
|
||||
mediaType &&
|
||||
<View className={`${className} border ${style} rounded-full p-1`} {...props}>
|
||||
{mediaType === MediaType.MOVIE ? (
|
||||
<MaterialCommunityIcons
|
||||
name="movie-open"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
) : (
|
||||
<Feather
|
||||
size={16}
|
||||
name="tv"
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrMediaIcon;
|
||||
@@ -2,7 +2,6 @@ import {useEffect, useState} from "react";
|
||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
interface Props {
|
||||
mediaStatus?: MediaStatus;
|
||||
@@ -10,7 +9,7 @@ interface Props {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrIconStatus;
|
||||
export default JellyseerrStatusIcon;
|
||||
42
components/jellyseerr/PersonPoster.tsx
Normal file
42
components/jellyseerr/PersonPoster.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||
import React from "react";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import {useRouter, useSegments} from "expo-router";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
posterPath?: string
|
||||
name: string
|
||||
subName?: string
|
||||
}
|
||||
|
||||
const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
id,
|
||||
posterPath,
|
||||
name,
|
||||
subName,
|
||||
...props
|
||||
}) => {
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = segments[2];
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
|
||||
<View className="flex flex-col w-28" {...props}>
|
||||
<Poster
|
||||
id={id}
|
||||
url={jellyseerrApi?.imageProxy(posterPath, 'w600_and_h900_bestv2')}
|
||||
/>
|
||||
<Text className="mt-2">{name}</Text>
|
||||
{subName && <Text className="text-xs opacity-50">{subName}</Text>}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
export default PersonPoster;
|
||||
@@ -1,55 +1,47 @@
|
||||
import {View, ViewProps} from "react-native";
|
||||
import {Image} from "expo-image";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
interface Props extends ViewProps {
|
||||
item: MovieResult | TvResult;
|
||||
}
|
||||
|
||||
const JellyseerrPoster: React.FC<Props> = ({
|
||||
item,
|
||||
...props
|
||||
}) => {
|
||||
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
||||
// const imageSource =
|
||||
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const imageSrc = useMemo(() =>
|
||||
item.posterPath ?
|
||||
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
||||
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
||||
const imageSrc = useMemo(
|
||||
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
|
||||
[item, jellyseerrApi]
|
||||
)
|
||||
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
||||
const releaseYear = useMemo(() =>
|
||||
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
||||
);
|
||||
const title = useMemo(
|
||||
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
|
||||
[item]
|
||||
)
|
||||
);
|
||||
const releaseYear = useMemo(
|
||||
() =>
|
||||
new Date(
|
||||
item.mediaType === MediaType.MOVIE
|
||||
? item.releaseDate
|
||||
: item.firstAirDate
|
||||
).getFullYear(),
|
||||
[item]
|
||||
);
|
||||
|
||||
const showRequestButton = useMemo(() =>
|
||||
jellyseerrUser && hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
item.mediaType === 'movie'
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
jellyseerrUser.permissions,
|
||||
{type: 'or'}
|
||||
),
|
||||
[item, jellyseerrUser]
|
||||
)
|
||||
|
||||
const canRequest = useMemo(() => {
|
||||
const status = item?.mediaInfo?.status
|
||||
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
||||
}, [item])
|
||||
const canRequest = useJellyseerrCanRequest(item);
|
||||
|
||||
return (
|
||||
<TouchableJellyseerrRouter
|
||||
@@ -57,14 +49,14 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
mediaTitle={title}
|
||||
releaseYear={releaseYear}
|
||||
canRequest={canRequest}
|
||||
posterSrc={imageSrc}
|
||||
posterSrc={imageSrc!!}
|
||||
>
|
||||
<View className="flex flex-col w-28 mr-2">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
source={{uri: imageSrc}}
|
||||
source={{ uri: imageSrc }}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
@@ -72,21 +64,24 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<JellyseerrIconStatus
|
||||
<JellyseerrStatusIcon
|
||||
className="absolute bottom-1 right-1"
|
||||
showRequestIcon={canRequest}
|
||||
mediaStatus={item?.mediaInfo?.status}
|
||||
/>
|
||||
|
||||
<JellyseerrMediaIcon
|
||||
className="absolute top-1 left-1"
|
||||
mediaType={item?.mediaType}
|
||||
/>
|
||||
</View>
|
||||
<View className="mt-2 flex flex-col">
|
||||
<Text numberOfLines={2}>{title}</Text>
|
||||
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
||||
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableJellyseerrRouter>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default JellyseerrPoster;
|
||||
export default JellyseerrPoster;
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
|
||||
type PosterProps = {
|
||||
item?: BaseItemDto | BaseItemPerson | null;
|
||||
id?: string | null;
|
||||
url?: string | null;
|
||||
showProgress?: boolean;
|
||||
blurhash?: string | null;
|
||||
};
|
||||
|
||||
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||
if (!item)
|
||||
const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
|
||||
if (!id && !url)
|
||||
return (
|
||||
<View
|
||||
className="border border-neutral-900"
|
||||
@@ -33,8 +29,8 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||
}
|
||||
: null
|
||||
}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
key={id}
|
||||
id={id!!}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
|
||||
@@ -55,7 +55,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
}}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
|
||||
<Poster id={i.id} url={getPrimaryImageUrl({ api, item: i })} />
|
||||
<Text className="mt-2">{i.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{i.Role}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
className="flex flex-col space-y-2 w-28"
|
||||
>
|
||||
<Poster
|
||||
item={item}
|
||||
id={item.id}
|
||||
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
|
||||
/>
|
||||
<Text>{item.SeriesName}</Text>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { orderBy } from "lodash";
|
||||
import { Tags } from "@/components/GenreTags";
|
||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||
import {
|
||||
MediaStatus,
|
||||
@@ -61,7 +61,7 @@ const RenderItem = ({ item, index }: any) => {
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
@@ -246,7 +246,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||
?.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<JellyseerrIconStatus
|
||||
<JellyseerrStatusIcon
|
||||
key={0}
|
||||
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
item: BaseItemDto | MovieDetails | TvDetails;
|
||||
}
|
||||
|
||||
export const ItemActions = ({ item, ...props }: Props) => {
|
||||
const router = useRouter();
|
||||
const trailerLink = useMemo(() => {
|
||||
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
|
||||
return item.RemoteTrailers[0].Url;
|
||||
}
|
||||
|
||||
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
||||
if ("relatedVideos" in item) {
|
||||
return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [item]);
|
||||
|
||||
const openTrailer = useCallback(async () => {
|
||||
if (!trailerLink) return;
|
||||
if (!trailerLink) {
|
||||
Alert.alert("No trailer available");
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
||||
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
||||
}, [router, trailerLink]);
|
||||
try {
|
||||
await Linking.openURL(trailerLink);
|
||||
} catch (err) {
|
||||
console.error("Failed to open trailer link:", err);
|
||||
}
|
||||
}, [trailerLink]);
|
||||
|
||||
return (
|
||||
<View className="" {...props}>
|
||||
|
||||
@@ -15,10 +15,12 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const OtherSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
/********************
|
||||
@@ -54,7 +56,7 @@ export const OtherSettings: React.FC = () => {
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ListGroup title="Other" className="mb-4">
|
||||
<ListGroup title="Other" className="">
|
||||
<ListItem title="Auto rotate">
|
||||
<Switch
|
||||
value={settings.autoRotate}
|
||||
@@ -178,6 +180,11 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||
title="Hide Libraries"
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,11 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
|
||||
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||
import { writeErrorLog } from "@/utils/log";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import {
|
||||
CombinedCredit,
|
||||
PersonDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Person";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
@@ -55,6 +60,8 @@ export enum Endpoints {
|
||||
API_V1 = "/api/v1",
|
||||
SEARCH = "/search",
|
||||
REQUEST = "/request",
|
||||
PERSON = "/person",
|
||||
COMBINED_CREDITS = "/combined_credits",
|
||||
MOVIE = "/movie",
|
||||
RATINGS = "/ratings",
|
||||
ISSUE = "/issue",
|
||||
@@ -204,6 +211,27 @@ export class JellyseerrApi {
|
||||
});
|
||||
}
|
||||
|
||||
async personDetails(id: number | string): Promise<PersonDetails> {
|
||||
return this.axios
|
||||
?.get<PersonDetails>(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async personCombinedCredits(id: number | string): Promise<CombinedCredit> {
|
||||
return this.axios
|
||||
?.get<CombinedCredit>(
|
||||
Endpoints.API_V1 +
|
||||
Endpoints.PERSON +
|
||||
`/${id}` +
|
||||
Endpoints.COMBINED_CREDITS
|
||||
)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async movieRatings(id: number) {
|
||||
return this.axios
|
||||
?.get<RTRating>(
|
||||
@@ -238,14 +266,20 @@ export class JellyseerrApi {
|
||||
});
|
||||
}
|
||||
|
||||
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
|
||||
return (
|
||||
this.axios.defaults.baseURL +
|
||||
`/_next/image?` +
|
||||
new URLSearchParams(
|
||||
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
|
||||
).toString()
|
||||
);
|
||||
imageProxy(
|
||||
path?: string,
|
||||
tmdbPath: string = "original",
|
||||
width: number = 1920,
|
||||
quality: number = 75
|
||||
) {
|
||||
return path
|
||||
? this.axios.defaults.baseURL +
|
||||
`/_next/image?` +
|
||||
new URLSearchParams(
|
||||
`url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
|
||||
).toString()
|
||||
: this.axios?.defaults.baseURL +
|
||||
`/images/overseerr_poster_not_found_logo_top.png`;
|
||||
}
|
||||
|
||||
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
||||
@@ -321,6 +355,7 @@ const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||
export const useJellyseerr = () => {
|
||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const jellyseerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
@@ -338,12 +373,16 @@ export const useJellyseerr = () => {
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["search", "jellyseerr"],
|
||||
});
|
||||
|
||||
switch (mediaRequest.status) {
|
||||
case MediaRequestStatus.PENDING:
|
||||
case MediaRequestStatus.APPROVED:
|
||||
toast.success(`Requested ${title}!`);
|
||||
onSuccess?.()
|
||||
onSuccess?.();
|
||||
break;
|
||||
case MediaRequestStatus.DECLINED:
|
||||
toast.error(`You don't have permission to request!`);
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"react-native-bottom-tabs": "0.7.1",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-compressor": "^1.9.0",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.1",
|
||||
"react-native-edge-to-edge": "^1.1.3",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
@@ -82,7 +83,7 @@
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.2",
|
||||
"react-native-ios-utilities": "^4.5.1",
|
||||
"react-native-ios-utilities": "4.5.3",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.3.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
@@ -99,7 +100,6 @@
|
||||
"react-native-volume-manager": "^1.10.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.8.6",
|
||||
"react-native-youtube-iframe": "^2.3.0",
|
||||
"sonner-native": "^0.14.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
|
||||
52
utils/_jellyseerr/useJellyseerrCanRequest.ts
Normal file
52
utils/_jellyseerr/useJellyseerrCanRequest.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useMemo } from "react";
|
||||
import MediaRequest from "../jellyseerr/server/entity/MediaRequest";
|
||||
import { MovieDetails } from "../jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "../jellyseerr/server/models/Tv";
|
||||
|
||||
export const useJellyseerrCanRequest = (
|
||||
item?: MovieResult | TvResult | MovieDetails | TvDetails
|
||||
) => {
|
||||
const { jellyseerrUser } = useJellyseerr();
|
||||
|
||||
const canRequest = useMemo(() => {
|
||||
if (!jellyseerrUser || !item) return false;
|
||||
|
||||
const canNotRequest =
|
||||
item?.mediaInfo?.requests?.some(
|
||||
(r: MediaRequest) =>
|
||||
r.status == MediaRequestStatus.PENDING ||
|
||||
r.status == MediaRequestStatus.APPROVED
|
||||
) ||
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.BLACKLISTED ||
|
||||
item.mediaInfo?.status === MediaStatus.PENDING ||
|
||||
item.mediaInfo?.status === MediaStatus.PROCESSING;
|
||||
|
||||
if (canNotRequest) return false;
|
||||
|
||||
const userHasPermission = hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
item?.mediaInfo?.mediaType
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
jellyseerrUser.permissions,
|
||||
{ type: "or" }
|
||||
);
|
||||
|
||||
return userHasPermission && !canNotRequest;
|
||||
}, [item, jellyseerrUser]);
|
||||
|
||||
return canRequest;
|
||||
};
|
||||
@@ -88,6 +88,7 @@ export type Settings = {
|
||||
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
||||
safeAreaInControlsEnabled: boolean;
|
||||
jellyseerrServerUrl?: string;
|
||||
hiddenLibraries?: string[];
|
||||
};
|
||||
|
||||
const loadSettings = (): Settings => {
|
||||
@@ -126,6 +127,7 @@ const loadSettings = (): Settings => {
|
||||
remuxConcurrentLimit: 1,
|
||||
safeAreaInControlsEnabled: true,
|
||||
jellyseerrServerUrl: undefined,
|
||||
hiddenLibraries: [],
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
32
utils/useReactNavigationQuery.ts
Normal file
32
utils/useReactNavigationQuery.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useFocusEffect } from "@react-navigation/core";
|
||||
import {
|
||||
QueryKey,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useReactNavigationQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey
|
||||
>(
|
||||
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
): UseQueryResult<TData, TError> {
|
||||
const useQueryReturn = useQuery(options);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (
|
||||
((options.refetchOnWindowFocus && useQueryReturn.isStale) ||
|
||||
options.refetchOnWindowFocus === "always") &&
|
||||
options.enabled !== false
|
||||
)
|
||||
useQueryReturn.refetch();
|
||||
}, [options.enabled, options.refetchOnWindowFocus])
|
||||
);
|
||||
|
||||
return useQueryReturn;
|
||||
}
|
||||
Reference in New Issue
Block a user