diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 1ac06eda..c8f30a95 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -43,7 +43,7 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- - 0.24.0
+ - 0.25.0
- 0.22.0
- 0.21.0
- older
diff --git a/app.json b/app.json
index 1bef8558..1052c0c4 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.24.0",
+ "version": "0.25.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -112,7 +112,8 @@
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
- ["./plugins/withChangeNativeAndroidTextToWhite.js"]
+ ["./plugins/withChangeNativeAndroidTextToWhite.js"],
+ ["./plugins/withGoogleCastActivity.js"]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index 31d73c8b..4ed55a24 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -79,6 +79,20 @@ export default function IndexLayout() {
title: "",
}}
/>
+
+
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
))}
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index f8403a38..42b23440 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -23,7 +23,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
-import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
+import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -119,7 +119,7 @@ export default function index() {
}, []);
const {
- data: userViews,
+ data,
isError: e1,
isLoading: l1,
} = useQuery({
@@ -139,6 +139,11 @@ export default function index() {
staleTime: 60 * 1000,
});
+ const userViews = useMemo(
+ () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
+ [data, settings?.hiddenLibraries]
+ );
+
const {
data: mediaListCollections,
isError: e2,
diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx
new file mode 100644
index 00000000..cef9db31
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/intro/page.tsx
@@ -0,0 +1,109 @@
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { storage } from "@/utils/mmkv";
+import { Feather, Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { useFocusEffect, useRouter } from "expo-router";
+import { useCallback } from "react";
+import { TouchableOpacity, View } from "react-native";
+
+export default function page() {
+ const router = useRouter();
+
+ useFocusEffect(
+ useCallback(() => {
+ storage.set("hasShownIntro", true);
+ }, [])
+ );
+
+ return (
+
+
+
+ Welcome to Streamyfin
+
+
+ A free and open source client for Jellyfin.
+
+
+
+
+ Features
+
+ Streamyfin has a bunch of features and integrates with a wide array of
+ software which you can find in the settings menu, these include:
+
+
+
+
+ Jellyseerr
+
+ Connect to your Jellyseerr instance and request movies directly in
+ the app.
+
+
+
+
+
+
+
+
+ Downloads
+
+ Download movies and tv-shows to view offline. Use either the
+ default method or install the optimize server to download files in
+ the background.
+
+
+
+
+
+
+
+
+ Chromecast
+
+ Cast movies and tv-shows to your Chromecast devices.
+
+
+
+
+
+
+ {
+ router.back();
+ router.push("/settings");
+ }}
+ className="mt-4"
+ >
+ Go to settings
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 8f1131a7..bd1c9260 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -20,6 +20,7 @@ import { t } from "i18next";
import { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { storage } from "@/utils/mmkv";
export default function settings() {
const router = useRouter();
@@ -70,6 +71,22 @@ export default function settings() {
+
+ {
+ router.push("/intro/page");
+ }}
+ title={"Show intro"}
+ />
+ {
+ storage.set("hasShownIntro", false);
+ }}
+ title={"Reset intro"}
+ />
+
+
{
+ const response = await getUserViewsApi(api!).getUserViews({
+ userId: user?.Id,
+ });
+
+ return response.data.Items || null;
+ },
+ });
+
+ if (!settings) return null;
+
+ if (isLoading)
+ return (
+
+
+
+ );
+
+ return (
+
+
+ {data?.map((view) => (
+ {}}>
+ {
+ updateSettings({
+ hiddenLibraries: value
+ ? [...(settings.hiddenLibraries || []), view.Id!]
+ : settings.hiddenLibraries?.filter((id) => id !== view.Id),
+ });
+ }}
+ />
+
+ ))}
+
+
+ Select the libraries you want to hide from the Library tab and home page
+ sections.
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
new file mode 100644
index 00000000..c5eda557
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx
@@ -0,0 +1,95 @@
+import {router, useLocalSearchParams, useSegments,} from "expo-router";
+import React, {useMemo,} from "react";
+import {TouchableOpacity} from "react-native";
+import {useInfiniteQuery} from "@tanstack/react-query";
+import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
+import {Text} from "@/components/common/Text";
+import {Image} from "expo-image";
+import Poster from "@/components/posters/Poster";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
+import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import {uniqBy} from "lodash";
+import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
+
+export default function page() {
+ const local = useLocalSearchParams();
+ const {jellyseerrApi} = useJellyseerr();
+
+ const {companyId, name, image, type} = local as unknown as {
+ companyId: string,
+ name: string,
+ image: string,
+ type: DiscoverSliderType
+ };
+
+ const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
+ queryKey: ["jellyseerr", "company", type, companyId],
+ queryFn: async ({pageParam}) => {
+ let params: any = {
+ page: Number(pageParam),
+ };
+
+ return jellyseerrApi?.discover(
+ (
+ type == DiscoverSliderType.NETWORKS
+ ? Endpoints.DISCOVER_TV_NETWORK
+ : Endpoints.DISCOVER_MOVIES_STUDIO
+ ) + `/${companyId}`,
+ params
+ )
+ },
+ enabled: !!jellyseerrApi && !!companyId,
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, pages) =>
+ (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
+ 1,
+ staleTime: 0,
+ });
+
+ const flatData = useMemo(
+ () => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
+ [data]
+ );
+
+ const backdrops = useMemo(
+ () => jellyseerrApi
+ ? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
+ : [],
+ [jellyseerrApi, flatData]
+ );
+
+ return (
+ item.id.toString()}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage()
+ }
+ }}
+ logo={
+
+ }
+ renderItem={(item, index) =>
+
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx
new file mode 100644
index 00000000..dbbce320
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx
@@ -0,0 +1,87 @@
+import {router, useLocalSearchParams, useSegments,} from "expo-router";
+import React, {useMemo,} from "react";
+import {TouchableOpacity} from "react-native";
+import {useInfiniteQuery} from "@tanstack/react-query";
+import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
+import {Text} from "@/components/common/Text";
+import Poster from "@/components/posters/Poster";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
+import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {uniqBy} from "lodash";
+import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
+import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
+
+export default function page() {
+ const local = useLocalSearchParams();
+ const {jellyseerrApi} = useJellyseerr();
+
+ const {genreId, name, type} = local as unknown as {
+ genreId: string,
+ name: string,
+ type: DiscoverSliderType
+ };
+
+ const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
+ queryKey: ["jellyseerr", "company", type, genreId],
+ queryFn: async ({pageParam}) => {
+ let params: any = {
+ page: Number(pageParam),
+ genre: genreId
+ };
+
+ return jellyseerrApi?.discover(
+ type == DiscoverSliderType.MOVIE_GENRES
+ ? Endpoints.DISCOVER_MOVIES
+ : Endpoints.DISCOVER_TV,
+ params
+ )
+ },
+ enabled: !!jellyseerrApi && !!genreId,
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, pages) =>
+ (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
+ 1,
+ staleTime: 0,
+ });
+
+ const flatData = useMemo(
+ () => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
+ [data]
+ );
+
+ const backdrops = useMemo(
+ () => jellyseerrApi
+ ? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
+ : [],
+ [jellyseerrApi, flatData]
+ );
+
+ return (
+ item.id.toString()}
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage()
+ }
+ }}
+ logo={
+
+ {name}
+
+ }
+ renderItem={(item, index) =>
+
+ }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
index 92a48c9c..83fbf665 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
@@ -1,52 +1,53 @@
-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, BottomSheetTextInput,
- BottomSheetView,
-} from "@gorhom/bottom-sheet";
+import { Text } from "@/components/common/Text";
+import { GenreTags } from "@/components/GenreTags";
+import Cast from "@/components/jellyseerr/Cast";
+import DetailFacts from "@/components/jellyseerr/DetailFacts";
+import { OverviewText } from "@/components/OverviewText";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { JellyserrRatings } from "@/components/Ratings";
+import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
+import { ItemActions } from "@/components/series/SeriesActions";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
-import * as DropdownMenu from "zeego/dropdown-menu";
+import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
-import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
-import { JellyserrRatings } from "@/components/Ratings";
import { useTranslation } from "react-i18next";
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BottomSheetBackdrop,
+ BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetTextInput,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import { useQuery } from "@tanstack/react-query";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import * as DropdownMenu from "zeego/dropdown-menu";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
- const {
- mediaTitle,
- releaseYear,
- canRequest: canRequestString,
- posterSrc,
- ...result
- } = params as unknown as {
- mediaTitle: string;
- releaseYear: number;
- canRequest: string;
- posterSrc: string;
- } & Partial;
- const canRequest = canRequestString === "true";
+ const { mediaTitle, releaseYear, posterSrc, ...result } =
+ params as unknown as {
+ mediaTitle: string;
+ releaseYear: number;
+ canRequest: string;
+ posterSrc: string;
+ } & Partial;
+
+ const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState();
@@ -57,7 +58,7 @@ const Page: React.FC = () => {
data: details,
isFetching,
isLoading,
- refetch
+ refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
@@ -74,6 +75,8 @@ const Page: React.FC = () => {
},
});
+ const canRequest = useJellyseerrCanRequest(details);
+
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
{
}
}, [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: () => (
+
+
+
+ ),
+ });
+ }
+ }, [details]);
return (
{
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"
+ ),
}}
/>
) : (
@@ -184,7 +201,9 @@ const Page: React.FC = () => {
g.name) || []} />
- {canRequest ? (
+ {isLoading || isFetching ? (
+
+ ) : canRequest ? (
@@ -215,6 +234,11 @@ const Page: React.FC = () => {
refetch={refetch}
/>
)}
+
+
@@ -281,13 +305,11 @@ const Page: React.FC = () => {
-
+
({
+ 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(
+ () =>
+ uniqBy(orderBy(
+ data?.combinedCredits?.cast,
+ ["voteCount", "voteAverage"],
+ "desc"
+ ), 'id'),
+ [data?.combinedCredits]
+ );
+ const backdrops = useMemo(
+ () => jellyseerrApi
+ ? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
+ : [],
+ [jellyseerrApi, data?.combinedCredits]
+ );
+
+ return (
+ item.id.toString()}
+ logo={
+
+ }
+ HeaderContent={() => (
+ <>
+
+ {data?.details?.name}
+
+
+ Born{" "}
+ {new Date(data?.details?.birthday!!).toLocaleDateString(
+ `${locale}-${region}`,
+ {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }
+ )}{" "}
+ | {data?.details?.placeOfBirth}
+
+ >
+ )}
+ MainContent={() => (
+
+ )}
+ renderItem={(item, index) => }
+ />
+ );
+}
diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx
index 06ce1dbd..e4470305 100644
--- a/app/(auth)/(tabs)/(libraries)/index.tsx
+++ b/app/(auth)/(tabs)/(libraries)/index.tsx
@@ -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";
import { useTranslation } from "react-i18next";
@@ -26,20 +26,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({
@@ -66,7 +66,7 @@ export default function index() {
);
- if (!data)
+ if (!libraries)
return (
{t("library.no_libraries_found")}
@@ -84,7 +84,7 @@ export default function index() {
paddingLeft: insets.left,
paddingRight: insets.right,
}}
- data={data}
+ data={libraries}
renderItem={({ item }) => }
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx
index 2e7a617d..b031908e 100644
--- a/app/(auth)/(tabs)/(search)/_layout.tsx
+++ b/app/(auth)/(tabs)/(search)/_layout.tsx
@@ -38,6 +38,9 @@ export default function SearchLayout() {
}}
/>
+
+
+
);
}
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 614f9fa4..4b2ea40f 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -2,14 +2,17 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
+import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
-import { Loader } from "@/components/Loader";
+import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
+import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
+import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import {
BaseItemDto,
BaseItemKind,
@@ -20,7 +23,6 @@ import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
- PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
@@ -30,14 +32,7 @@ import React, {
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 { 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 { useTranslation } from "react-i18next";
-import {sortBy} from "lodash";
type SearchType = "Library" | "Discover";
@@ -152,48 +147,6 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
- const { data: jellyseerrResults, isFetching: j1 } = useQuery({
- queryKey: ["search", "jellyseerrResults", debouncedSearch],
- queryFn: async () => {
- const response = await jellyseerrApi?.search({
- query: new URLSearchParams(debouncedSearch).toString(),
- page: 1, // todo: maybe rework page & page-size if first results are not enough...
- language: "en",
- });
-
- return response?.results;
- },
- enabled:
- !!jellyseerrApi &&
- searchType === "Discover" &&
- 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 jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
- () =>
- jellyseerrResults?.filter(
- (r) => r.mediaType === MediaType.MOVIE
- ) as MovieResult[],
- [jellyseerrResults]
- );
-
- const jellyseerrTvResults: TvResult[] | undefined = useMemo(
- () =>
- jellyseerrResults?.filter(
- (r) => r.mediaType === MediaType.TV
- ) as TvResult[],
- [jellyseerrResults]
- );
-
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -273,25 +226,13 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
- actors?.length ||
- jellyseerrMovieResults?.length ||
- jellyseerrTvResults?.length
+ actors?.length
);
- }, [
- artists,
- episodes,
- albums,
- songs,
- movies,
- series,
- collections,
- actors,
- jellyseerrResults,
- ]);
+ }, [artists, episodes, albums, songs, movies, series, collections, actors]);
const loading = useMemo(() => {
- return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
- }, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
+ return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
+ }, [l1, l2, l3, l4, l5, l6, l7, l8]);
return (
<>
@@ -303,7 +244,7 @@ export default function search() {
paddingRight: insets.right,
}}
>
-
+
{Platform.OS === "android" && (
)}
- {!!q && (
-
-
- {t("search.results_for_x")} {q}
-
-
- )}
- {searchType === "Library" && (
- <>
+
+
+
+
+
+ {searchType === "Library" ? (
+
m.Id!)}
@@ -471,126 +410,39 @@ export default function search() {
)}
/>
- >
- )}
- {searchType === "Discover" && (
- <>
- (
-
- )}
- />
- (
-
- )}
- />
- >
+
+ ) : (
+
)}
- {loading ? (
-
-
-
- ) : noResults && debouncedSearch.length > 0 ? (
-
-
- {t("search.no_results_found_for")}
-
-
- "{debouncedSearch}"
-
-
- ) : debouncedSearch.length === 0 && searchType === "Library" ? (
-
- {exampleSearches.map((e) => (
- setSearch(e)}
- key={e}
- className="mb-2"
- >
- {e}
-
- ))}
-
- ) : debouncedSearch.length === 0 && searchType === "Discover" ? (
-
- {sortBy?.(
- jellyseerrDiscoverSettings?.filter((s) => s.enabled),
- "order"
- ).map((slide) => (
-
- ))}
-
- ) : null}
+ {searchType === "Library" && (
+ <>
+ {!loading && noResults && debouncedSearch.length > 0 ? (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{debouncedSearch}"
+
+
+ ) : debouncedSearch.length === 0 ? (
+
+ {exampleSearches.map((e) => (
+ setSearch(e)}
+ key={e}
+ className="mb-2"
+ >
+ {e}
+
+ ))}
+
+ ) : null}
+ >
+ )}
>
);
}
-
-type Props = {
- ids?: string[] | null;
- items?: T[];
- renderItem: (item: any) => React.ReactNode;
- header?: string;
-};
-
-const SearchItemWrapper = ({
- ids,
- items,
- renderItem,
- header,
-}: PropsWithChildren>) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { data, isLoading: l1 } = useQuery({
- queryKey: ["items", ids],
- queryFn: async () => {
- if (!user?.Id || !api || !ids || ids.length === 0) {
- return [];
- }
-
- const itemPromises = ids.map((id) =>
- getUserItemData({
- api,
- userId: user.Id,
- itemId: id,
- })
- );
-
- const results = await Promise.all(itemPromises);
-
- // Filter out null items
- return results.filter(
- (item) => item !== null
- ) as unknown as BaseItemDto[];
- },
- enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
- staleTime: Infinity,
- });
-
- if (!data && (!items || items.length === 0)) return null;
-
- return (
- <>
- {header}
-
- {data && data?.length > 0
- ? data.map((item) => renderItem(item))
- : items && items?.length > 0
- ? items.map((i) => renderItem(i))
- : undefined}
-
- >
- );
-};
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 17c7cb17..ade003ff 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -1,8 +1,8 @@
-import React from "react";
+import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
-import { withLayoutContext } from "expo-router";
+import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
@@ -14,12 +14,13 @@ const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
+import { useSettings } from "@/utils/atoms/settings";
+import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
-import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
@@ -31,11 +32,28 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
const [settings] = useSettings();
const { t } = useTranslation();
+ const router = useRouter();
+
+ useFocusEffect(
+ useCallback(() => {
+ const hasShownIntro = storage.getBoolean("hasShownIntro");
+ if (!hasShownIntro) {
+ const timer = setTimeout(() => {
+ router.push("/intro/page");
+ }, 1000);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }
+ }, [])
+ );
+
return (
<>
(null);
@@ -415,6 +416,8 @@ export default function page() {
}
}
+ const insets = useSafeAreaInsets();
+
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
return (
@@ -439,6 +442,8 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
+ paddingLeft: ignoreSafeAreas ? 0 : insets.left,
+ paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
{
- return url.split("v=")[1];
- }, [url]);
-
- const [playing, setPlaying] = useState(false);
-
- const onStateChange = useCallback((state: PLAYER_STATES) => {
- if (state === "ended") {
- setPlaying(false);
- Alert.alert(t("player.video_has_finished_playing"));
- }
- }, []);
-
- const togglePlaying = useCallback(() => {
- setPlaying((prev) => !prev);
- }, []);
-
- useEffect(() => {
- togglePlaying();
- }, []);
-
- const screenWidth = Dimensions.get("screen").width;
-
- return (
-
-
-
- );
-}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index c6df7538..2092d722 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -349,14 +349,6 @@ function Layout() {
header: () => null,
}}
/>
-
(_apiUrl);
const [serverName, setServerName] = useState("");
- const [error, setError] = useState("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
@@ -76,8 +80,10 @@ const CredentialsSchema = z.object({
onPress={() => {
removeServer();
}}
+ className="flex flex-row items-center"
>
-
+
+ Change server
) : null,
});
@@ -94,9 +100,9 @@ const CredentialsSchema = z.object({
}
} 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);
@@ -135,6 +141,8 @@ const CredentialsSchema = z.object({
return url;
}
+ return undefined;
+ } catch {
return undefined;
} finally {
setLoadingServerCheck(false);
@@ -228,7 +236,6 @@ const CredentialsSchema = z.object({
/>
setCredentials({ ...credentials, password: text })
@@ -242,28 +249,34 @@ const CredentialsSchema = z.object({
clearButtonMode="while-editing"
maxLength={500}
/>
+
+
+
+
+
+
-
- {error}
-
-
-
-
+
>
) : (
<>
-
+
-
- {t("server.server_url_hint")}
-
- {
- handleConnect(s.address);
- }}
- />
-
-
+ {
+ handleConnect(s.address);
+ }}
+ />
>
diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/jellyseerr-logo.svg
new file mode 100644
index 00000000..cda2394d
--- /dev/null
+++ b/assets/icons/jellyseerr-logo.svg
@@ -0,0 +1,118 @@
+
+
\ No newline at end of file
diff --git a/bun.lockb b/bun.lockb
index 219a11b3..bd6c5da1 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/Button.tsx b/components/Button.tsx
index 2c41ad50..4f7e25c4 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -37,7 +37,7 @@ export const Button: React.FC> = ({
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";
}
@@ -63,7 +63,9 @@ export const Button: React.FC> = ({
{...props}
>
{loading ? (
-
+
+
+
) : (
= ({
+export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp} & ViewProps> = ({
text,
textClass,
+ textStyle,
...props
}) => {
return (
- {text}
+ {text}
);
};
diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx
index daebca6b..5d7b28e0 100644
--- a/components/ParallaxPage.tsx
+++ b/components/ParallaxPage.tsx
@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
-import { View, ViewProps } from "react-native";
+import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
logo?: ReactElement;
episodePoster?: ReactElement;
headerHeight?: number;
+ onEndReached?: (() => void) | null | undefined;
}
export const ParallaxScrollView: React.FC> = ({
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC> = ({
episodePoster,
headerHeight = 400,
logo,
+ onEndReached,
...props
}: Props) => {
const scrollRef = useAnimatedRef();
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC> = ({
};
});
+
+ function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
+ return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
+ }
+
return (
> = ({
}}
ref={scrollRef}
scrollEventThrottle={16}
+ onScroll={e => {
+ if (isCloseToBottom(e.nativeEvent))
+ onEndReached?.()
+ }}
>
{logo && (
{
- 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> = ({
}) => {
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> = ({
from === "(favorites)"
)
return (
-
-
- {
- const url = itemRouter(item, from);
- // @ts-ignore
- router.push(url);
- }}
- {...props}
- >
- {children}
-
-
-
- Actions
- {
- markAsPlayedStatus(true);
- }}
- shouldDismissMenuOnSelect
- >
-
- Mark as watched
-
-
-
- {
- markAsPlayedStatus(false);
- }}
- shouldDismissMenuOnSelect
- destructive
- >
-
- Mark as not watched
-
-
-
-
-
+ {
+ const url = itemRouter(item, from);
+ // @ts-expect-error
+ router.push(url);
+ }}
+ {...props}
+ >
+ {children}
+
);
};
diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx
new file mode 100644
index 00000000..f5474caf
--- /dev/null
+++ b/components/jellyseerr/Cast.tsx
@@ -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 && (
+
+ Cast
+ }
+ estimatedItemSize={15}
+ keyExtractor={(item) => item?.id?.toString()}
+ contentContainerStyle={{ paddingHorizontal: 16 }}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+ )
+ );
+};
+
+export default CastSlide;
diff --git a/components/jellyseerr/DetailFacts.tsx b/components/jellyseerr/DetailFacts.tsx
new file mode 100644
index 00000000..782ede8b
--- /dev/null
+++ b/components/jellyseerr/DetailFacts.tsx
@@ -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 && (
+
+ {title}
+
+
+ {facts.map((f, idx) =>
+ typeof f === "string" ? {f} : f
+ )}
+
+
+ );
+
+const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
+ title,
+ fact,
+ ...props
+}) => fact && ;
+
+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 && (
+
+ Details
+
+
+
+ {details.keywords.some(
+ (keyword) => keyword.id === ANIME_KEYWORD_ID
+ ) && }
+ (
+
+ {r.type === 3 ? (
+ // Theatrical
+
+ ) : r.type === 4 ? (
+ // Digital
+
+ ) : (
+ // Physical
+
+ )}
+
+ {new Date(r.release_date).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ )}
+
+
+ ))}
+ />
+
+
+
+
+
+ (
+
+
+ {n.name}
+
+ ))}
+ />
+ n.name
+ )}
+ />
+ n.name)} />
+ s.name)}
+ />
+
+
+ )
+ );
+};
+
+export default DetailFacts;
diff --git a/components/jellyseerr/JellyseerrIndexPage.tsx b/components/jellyseerr/JellyseerrIndexPage.tsx
new file mode 100644
index 00000000..cd093deb
--- /dev/null
+++ b/components/jellyseerr/JellyseerrIndexPage.tsx
@@ -0,0 +1,161 @@
+import Discover from "@/components/jellyseerr/discover/Discover";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import {
+ MovieResult,
+ PersonResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
+import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
+import React, { useMemo } from "react";
+import { View, ViewProps } from "react-native";
+import {
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "../common/Text";
+import JellyseerrPoster from "../posters/JellyseerrPoster";
+import { LoadingSkeleton } from "../search/LoadingSkeleton";
+import { SearchItemWrapper } from "../search/SearchItemWrapper";
+import PersonPoster from "./PersonPoster";
+import { useTranslation } from "react-i18next";
+
+interface Props extends ViewProps {
+ searchQuery: string;
+}
+
+export const JellyserrIndexPage: React.FC = ({ searchQuery }) => {
+ const { jellyseerrApi } = useJellyseerr();
+ const opacity = useSharedValue(1);
+ const { t } = useTranslation();
+
+ const {
+ data: jellyseerrDiscoverSettings,
+ isFetching: f1,
+ isLoading: l1,
+ } = useReactNavigationQuery({
+ queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
+ queryFn: async () => jellyseerrApi?.discoverSettings(),
+ enabled: !!jellyseerrApi && searchQuery.length == 0,
+ });
+
+ const {
+ data: jellyseerrResults,
+ isFetching: f2,
+ isLoading: l2,
+ } = useReactNavigationQuery({
+ queryKey: ["search", "jellyseerr", "results", searchQuery],
+ queryFn: async () => {
+ const response = await jellyseerrApi?.search({
+ query: new URLSearchParams(searchQuery).toString(),
+ page: 1,
+ language: "en",
+ });
+ return response?.results;
+ },
+ enabled: !!jellyseerrApi && searchQuery.length > 0,
+ });
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: opacity.value,
+ };
+ });
+
+ useAnimatedReaction(
+ () => f1 || f2 || l1 || l2,
+ (isLoading) => {
+ if (isLoading) {
+ opacity.value = withTiming(1, { duration: 200 });
+ } else {
+ opacity.value = withTiming(0, { duration: 200 });
+ }
+ }
+ );
+
+ const jellyseerrMovieResults = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === MediaType.MOVIE
+ ) as MovieResult[],
+ [jellyseerrResults]
+ );
+
+ const jellyseerrTvResults = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === MediaType.TV
+ ) as TvResult[],
+ [jellyseerrResults]
+ );
+
+ const jellyseerrPersonResults = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === "person"
+ ) as PersonResult[],
+ [jellyseerrResults]
+ );
+
+ if (!searchQuery.length)
+ return (
+
+
+
+ );
+
+ return (
+
+
+
+ {!jellyseerrMovieResults?.length &&
+ !jellyseerrTvResults?.length &&
+ !jellyseerrPersonResults?.length &&
+ !f1 &&
+ !f2 &&
+ !l1 &&
+ !l2 && (
+
+
+ {t("search.no_results_found_for")}
+
+
+ "{searchQuery}"
+
+
+ )}
+
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ );
+};
diff --git a/components/jellyseerr/JellyseerrMediaIcon.tsx b/components/jellyseerr/JellyseerrMediaIcon.tsx
new file mode 100644
index 00000000..97a5ab69
--- /dev/null
+++ b/components/jellyseerr/JellyseerrMediaIcon.tsx
@@ -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 &&
+
+ {mediaType === MediaType.MOVIE ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default JellyseerrMediaIcon;
\ No newline at end of file
diff --git a/components/icons/JellyseerrIconStatus.tsx b/components/jellyseerr/JellyseerrStatusIcon.tsx
similarity index 93%
rename from components/icons/JellyseerrIconStatus.tsx
rename to components/jellyseerr/JellyseerrStatusIcon.tsx
index 4c1bda37..8fc593fa 100644
--- a/components/icons/JellyseerrIconStatus.tsx
+++ b/components/jellyseerr/JellyseerrStatusIcon.tsx
@@ -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 = ({
+const JellyseerrStatusIcon: React.FC = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC = ({
)
}
-export default JellyseerrIconStatus;
\ No newline at end of file
+export default JellyseerrStatusIcon;
\ No newline at end of file
diff --git a/components/jellyseerr/ParallaxSlideShow.tsx b/components/jellyseerr/ParallaxSlideShow.tsx
new file mode 100644
index 00000000..6a7fcb7f
--- /dev/null
+++ b/components/jellyseerr/ParallaxSlideShow.tsx
@@ -0,0 +1,160 @@
+import React, {
+ PropsWithChildren,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import {Dimensions, View, ViewProps} from "react-native";
+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 { FlashList } from "@shopify/flash-list";
+import {useFocusEffect} from "expo-router";
+
+const ANIMATION_ENTER = 250;
+const ANIMATION_EXIT = 250;
+const BACKDROP_DURATION = 5000;
+
+type Render = React.ComponentType
+ | React.ReactElement
+ | null
+ | undefined;
+
+interface Props {
+ data: T[]
+ images: string[];
+ logo?: React.ReactElement;
+ HeaderContent?: () => React.ReactElement;
+ MainContent?: () => React.ReactElement;
+ listHeader: string;
+ renderItem: (item: T, index: number) => Render;
+ keyExtractor: (item: T) => string;
+ onEndReached?: (() => void) | null | undefined;
+}
+
+const ParallaxSlideShow = ({
+ data,
+ images,
+ logo,
+ HeaderContent,
+ MainContent,
+ listHeader,
+ renderItem,
+ keyExtractor,
+ onEndReached,
+ ...props
+}: PropsWithChildren & ViewProps>
+) => {
+ const insets = useSafeAreaInsets();
+
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ 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 (images?.length) {
+ enterAnimation().start();
+
+ const intervalId = setInterval(() => {
+ Animated.sequence([
+ enterAnimation(),
+ exitAnimation()
+ ]).start(() => {
+ fadeAnim.setValue(0);
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
+ })
+ }, BACKDROP_DURATION);
+
+ return () => {
+ clearInterval(intervalId)
+ };
+ }
+ }, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
+
+ return (
+
+
+ }
+ logo={logo}
+ >
+
+
+
+ {HeaderContent && HeaderContent()}
+
+
+ {MainContent && MainContent()}
+
+
+
+ No results
+
+
+ }
+ contentInsetAdjustmentBehavior="automatic"
+ ListHeaderComponent={
+ {listHeader}
+ }
+ nestedScrollEnabled
+ showsVerticalScrollIndicator={false}
+ //@ts-ignore
+ renderItem={({ item, index}) => renderItem(item, index)}
+ keyExtractor={keyExtractor}
+ numColumns={3}
+ estimatedItemSize={214}
+ ItemSeparatorComponent={() => }
+ />
+
+
+
+
+ );
+}
+
+export default ParallaxSlideShow;
\ No newline at end of file
diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx
new file mode 100644
index 00000000..6e7d9aa6
--- /dev/null
+++ b/components/jellyseerr/PersonPoster.tsx
@@ -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 = ({
+ 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 (
+ router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)}>
+
+
+ {name}
+ {subName && {subName}}
+
+
+ )
+}
+
+export default PersonPoster;
\ No newline at end of file
diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx
new file mode 100644
index 00000000..b30df3d7
--- /dev/null
+++ b/components/jellyseerr/discover/CompanySlide.tsx
@@ -0,0 +1,41 @@
+import React, {useCallback} from "react";
+import {
+ useJellyseerr,
+} from "@/hooks/useJellyseerr";
+import {TouchableOpacity, ViewProps} from "react-native";
+import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
+import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
+import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
+import {router, useSegments} from "expo-router";
+
+const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => {
+ const segments = useSegments();
+ const { jellyseerrApi } = useJellyseerr();
+ const from = segments[2];
+
+ const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
+ params: {id, image, name, type: slide.type }
+ }), [slide]);
+
+ return (
+ item.id.toString()}
+ renderItem={(item, index) => (
+ navigate(item)}>
+
+
+ )}
+ />
+ );
+};
+
+export default CompanySlide;
diff --git a/components/jellyseerr/discover/Discover.tsx b/components/jellyseerr/discover/Discover.tsx
new file mode 100644
index 00000000..6270ad2b
--- /dev/null
+++ b/components/jellyseerr/discover/Discover.tsx
@@ -0,0 +1,47 @@
+import React, {useMemo} from "react";
+import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import {sortBy} from "lodash";
+import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
+import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
+import {View} from "react-native";
+import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
+import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
+
+interface Props {
+ sliders?: DiscoverSlider[];
+}
+const Discover: React.FC = ({ sliders }) => {
+ if (!sliders)
+ return;
+
+ const sortedSliders = useMemo(
+ () => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
+ [sliders]
+ );
+
+ return (
+
+ {sortedSliders.map(slide => {
+ switch (slide.type) {
+ case DiscoverSliderType.NETWORKS:
+ return
+ case DiscoverSliderType.STUDIOS:
+ return
+ case DiscoverSliderType.MOVIE_GENRES:
+ case DiscoverSliderType.TV_GENRES:
+ return
+ case DiscoverSliderType.TRENDING:
+ case DiscoverSliderType.POPULAR_MOVIES:
+ case DiscoverSliderType.UPCOMING_MOVIES:
+ case DiscoverSliderType.POPULAR_TV:
+ case DiscoverSliderType.UPCOMING_TV:
+ return
+ }
+ })}
+
+ )
+};
+
+export default Discover;
diff --git a/components/jellyseerr/discover/GenericSlideCard.tsx b/components/jellyseerr/discover/GenericSlideCard.tsx
new file mode 100644
index 00000000..776d1424
--- /dev/null
+++ b/components/jellyseerr/discover/GenericSlideCard.tsx
@@ -0,0 +1,59 @@
+import React from "react";
+import {StyleSheet, View, ViewProps} from "react-native";
+import {Image, ImageContentFit} from "expo-image";
+import {Text} from "@/components/common/Text";
+import {LinearGradient} from "expo-linear-gradient";
+
+export const textShadowStyle = StyleSheet.create({
+ shadow: {
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 1,
+ height: 1,
+ },
+ shadowOpacity: 1,
+ shadowRadius: .5,
+
+ elevation: 6,
+ }
+})
+
+const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
+ id,
+ url,
+ title,
+ colors = ['#9333ea', 'transparent'],
+ contentFit = "contain",
+ ...props
+}) => (
+ <>
+
+
+
+ {title &&
+
+ {title}
+
+ }
+
+
+ >
+);
+
+export default GenericSlideCard;
\ No newline at end of file
diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx
new file mode 100644
index 00000000..551ee2de
--- /dev/null
+++ b/components/jellyseerr/discover/GenreSlide.tsx
@@ -0,0 +1,56 @@
+import React, {useCallback} from "react";
+import {Endpoints, useJellyseerr,} from "@/hooks/useJellyseerr";
+import {TouchableOpacity, ViewProps} from "react-native";
+import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
+import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
+import {router, useSegments} from "expo-router";
+import {useQuery} from "@tanstack/react-query";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import {genreColorMap} from "@/utils/jellyseerr/src/components/Discover/constants";
+import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
+
+const GenreSlide: React.FC = ({ slide, ...props }) => {
+ const segments = useSegments();
+ const { jellyseerrApi } = useJellyseerr();
+ const from = segments[2];
+
+ const navigate = useCallback((genre: GenreSliderItem) => router.push({
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
+ params: {type: slide.type, name: genre.name}
+ }), [slide]);
+
+ const {data, isFetching, isLoading } = useQuery({
+ queryKey: ['jellyseerr', 'discover', slide.type, slide.id],
+ queryFn: async () => {
+ return jellyseerrApi?.getGenreSliders(
+ slide.type == DiscoverSliderType.MOVIE_GENRES
+ ? Endpoints.MOVIE
+ : Endpoints.TV
+ )
+ },
+ enabled: !!jellyseerrApi
+ })
+
+ return (
+ data && item.id.toString()}
+ renderItem={(item, index) => (
+ navigate(item)}>
+
+
+ )}
+ />
+ );
+};
+
+export default GenreSlide;
diff --git a/components/jellyseerr/DiscoverSlide.tsx b/components/jellyseerr/discover/MovieTvSlide.tsx
similarity index 58%
rename from components/jellyseerr/DiscoverSlide.tsx
rename to components/jellyseerr/discover/MovieTvSlide.tsx
index c7112def..723658c8 100644
--- a/components/jellyseerr/DiscoverSlide.tsx
+++ b/components/jellyseerr/discover/MovieTvSlide.tsx
@@ -1,5 +1,4 @@
-import React, { useMemo } from "react";
-import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import React, {useMemo} from "react";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
DiscoverEndpoint,
@@ -9,17 +8,13 @@ import {
import { useInfiniteQuery } from "@tanstack/react-query";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
-import { Text } from "@/components/common/Text";
-import { FlashList } from "@shopify/flash-list";
-import { View } from "react-native";
+import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
+import {ViewProps} from "react-native";
-interface Props {
- slide: DiscoverSlider;
-}
-const DiscoverSlide: React.FC = ({ slide }) => {
+const MovieTvSlide: React.FC = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
- const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
+ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = undefined;
@@ -62,42 +57,28 @@ const DiscoverSlide: React.FC = ({ slide }) => {
});
const flatData = useMemo(
- () =>
- data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
+ () => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
[data]
);
return (
flatData &&
flatData?.length > 0 && (
-
-
- {DiscoverSliderType[slide.type].toString().toTitle()}
-
- item!!.id.toString()}
- estimatedItemSize={250}
- data={flatData}
- onEndReachedThreshold={1}
- onEndReached={() => {
- if (hasNextPage) fetchNextPage();
- }}
- renderItem={({ item }) =>
- item ? (
-
- ) : (
- <>>
- )
- }
- />
-
+ item!!.id.toString()}
+ onEndReached={() => {
+ if (hasNextPage)
+ fetchNextPage()
+ }}
+ renderItem={(item) =>
+
+ }
+ />
)
);
};
-export default DiscoverSlide;
+export default MovieTvSlide;
diff --git a/components/jellyseerr/discover/Slide.tsx b/components/jellyseerr/discover/Slide.tsx
new file mode 100644
index 00000000..5a593b41
--- /dev/null
+++ b/components/jellyseerr/discover/Slide.tsx
@@ -0,0 +1,55 @@
+import React, {PropsWithChildren} from "react";
+import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
+import { Text } from "@/components/common/Text";
+import { FlashList } from "@shopify/flash-list";
+import {View, ViewProps} from "react-native";
+
+export interface SlideProps {
+ slide: DiscoverSlider;
+}
+
+interface Props extends SlideProps {
+ data: T[]
+ renderItem: (item: T, index: number) =>
+ | React.ComponentType
+ | React.ReactElement
+ | null
+ | undefined;
+ keyExtractor: (item: T) => string;
+ onEndReached?: (() => void) | null | undefined;
+}
+
+const Slide = ({
+ data,
+ slide,
+ renderItem,
+ keyExtractor,
+ onEndReached,
+ ...props
+}: PropsWithChildren & ViewProps>
+) => {
+ return (
+
+
+ {DiscoverSliderType[slide.type].toString().toTitle()}
+
+ item ? renderItem(item, index) : <>>}
+ />
+
+ );
+};
+
+export default Slide;
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx
index 5a9647ae..11fb4941 100644
--- a/components/posters/JellyseerrPoster.tsx
+++ b/components/posters/JellyseerrPoster.tsx
@@ -1,55 +1,63 @@
-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 { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
+import { Text } from "@/components/common/Text";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
+import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
+import { Image } from "expo-image";
+import { useMemo } from "react";
+import { View, ViewProps } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
-const JellyseerrPoster: React.FC = ({
- item,
- ...props
-}) => {
- const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
- // const imageSource =
+const JellyseerrPoster: React.FC = ({ item, ...props }) => {
+ const { jellyseerrApi } = useJellyseerr();
+ const loadingOpacity = useSharedValue(1);
+ const imageOpacity = useSharedValue(0);
- 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 loadingAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: loadingOpacity.value,
+ }));
+
+ const imageAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: imageOpacity.value,
+ }));
+
+ const handleImageLoad = () => {
+ loadingOpacity.value = withTiming(0, { duration: 200 });
+ imageOpacity.value = withTiming(1, { duration: 300 });
+ };
+
+ 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 showRequestButton = useMemo(() =>
- jellyseerrUser && hasPermission(
- [
- Permission.REQUEST,
- item.mediaType === 'movie'
- ? Permission.REQUEST_MOVIE
- : Permission.REQUEST_TV,
- ],
- jellyseerrUser.permissions,
- {type: 'or'}
- ),
- [item, jellyseerrUser]
- )
+ const releaseYear = useMemo(
+ () =>
+ new Date(
+ item.mediaType === MediaType.MOVIE
+ ? item.releaseDate
+ : item.firstAirDate
+ ).getFullYear(),
+ [item]
+ );
- const canRequest = useMemo(() => {
- const status = item?.mediaInfo?.status
- return showRequestButton && !status || status === MediaStatus.UNKNOWN
- }, [item])
+ const canRequest = useJellyseerrCanRequest(item);
return (
= ({
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
- posterSrc={imageSrc}
+ posterSrc={imageSrc!!}
>
-
-
+
+
+
-
+
{title}
- {releaseYear}
+ {releaseYear}
- )
-}
+ );
+};
-
-export default JellyseerrPoster;
\ No newline at end of file
+export default JellyseerrPoster;
diff --git a/components/posters/Poster.tsx b/components/posters/Poster.tsx
index 1787506e..68799f47 100644
--- a/components/posters/Poster.tsx
+++ b/components/posters/Poster.tsx
@@ -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 = ({ item, url, blurhash }) => {
- if (!item)
+const Poster: React.FC = ({ id, url, blurhash }) => {
+ if (!id && !url)
return (
= ({ item, url, blurhash }) => {
}
: null
}
- key={item.Id}
- id={item.Id}
+ key={id}
+ id={id!!}
source={
url
? {
diff --git a/components/search/LoadingSkeleton.tsx b/components/search/LoadingSkeleton.tsx
new file mode 100644
index 00000000..8ac38ada
--- /dev/null
+++ b/components/search/LoadingSkeleton.tsx
@@ -0,0 +1,66 @@
+import { View } from "react-native";
+import { Text } from "../common/Text";
+import Animated, {
+ useAnimatedStyle,
+ useAnimatedReaction,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+
+interface Props {
+ isLoading: boolean;
+}
+
+export const LoadingSkeleton: React.FC = ({ isLoading }) => {
+ const opacity = useSharedValue(1);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ opacity: opacity.value,
+ };
+ });
+
+ useAnimatedReaction(
+ () => isLoading,
+ (loading) => {
+ if (loading) {
+ opacity.value = withTiming(1, { duration: 200 });
+ } else {
+ opacity.value = withTiming(0, { duration: 200 });
+ }
+ }
+ );
+
+ return (
+
+ {[1, 2, 3].map((s) => (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Nisi mollit voluptate amet.
+
+
+
+
+ Lorem ipsum
+
+
+
+ ))}
+
+
+ ))}
+
+ );
+};
diff --git a/components/search/SearchItemWrapper.tsx b/components/search/SearchItemWrapper.tsx
new file mode 100644
index 00000000..45c3e341
--- /dev/null
+++ b/components/search/SearchItemWrapper.tsx
@@ -0,0 +1,70 @@
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useQuery } from "@tanstack/react-query";
+import { useAtom } from "jotai";
+import { PropsWithChildren } from "react";
+import { ScrollView } from "react-native";
+import { Text } from "../common/Text";
+
+type SearchItemWrapperProps = {
+ ids?: string[] | null;
+ items?: T[];
+ renderItem: (item: any) => React.ReactNode;
+ header?: string;
+};
+
+export const SearchItemWrapper = ({
+ ids,
+ items,
+ renderItem,
+ header,
+}: PropsWithChildren>) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const { data, isLoading: l1 } = useQuery({
+ queryKey: ["items", ids],
+ queryFn: async () => {
+ if (!user?.Id || !api || !ids || ids.length === 0) {
+ return [];
+ }
+
+ const itemPromises = ids.map((id) =>
+ getUserItemData({
+ api,
+ userId: user.Id,
+ itemId: id,
+ })
+ );
+
+ const results = await Promise.all(itemPromises);
+
+ // Filter out null items
+ return results.filter(
+ (item) => item !== null
+ ) as unknown as BaseItemDto[];
+ },
+ enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
+ staleTime: Infinity,
+ });
+
+ if (!data && (!items || items.length === 0)) return null;
+
+ return (
+ <>
+ {header}
+
+ {data && data?.length > 0
+ ? data.map((item) => renderItem(item))
+ : items && items?.length > 0
+ ? items.map((i) => renderItem(i))
+ : undefined}
+
+ >
+ );
+};
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 2d527af6..e774b561 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -57,7 +57,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
}}
className="flex flex-col w-28"
>
-
+
{i.Name}
{i.Role}
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index f95bb10a..16536a6d 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -31,7 +31,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => {
className="flex flex-col space-y-2 w-28"
>
{item.SeriesName}
diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx
index 66489a44..d5db2107 100644
--- a/components/series/JellyseerrSeasons.tsx
+++ b/components/series/JellyseerrSeasons.tsx
@@ -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,
@@ -62,7 +62,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"
@@ -247,7 +247,7 @@ const JellyseerrSeasons: React.FC<{
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
- requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}
diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx
index 80d219f5..569f719d 100644
--- a/components/series/SeriesActions.tsx
+++ b/components/series/SeriesActions.tsx
@@ -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 (
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index e74c1ea8..78887a89 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -6,6 +6,7 @@ import {
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
+import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect } from "react";
@@ -20,6 +21,7 @@ import { useTranslation } from "react-i18next";
interface Props extends ViewProps {}
export const OtherSettings: React.FC = () => {
+ const router = useRouter();
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
@@ -57,7 +59,7 @@ export const OtherSettings: React.FC = () => {
if (!settings) return null;
return (
-
+
{
}
/>
-
+ router.push("/settings/hide-libraries/page")}
+ title="Hide Libraries"
+ showArrow
+ />
= ({
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
+ const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
@@ -505,9 +509,13 @@ export const Controls: React.FC = ({
}}
style={{
position: "absolute",
- width: Dimensions.get("window").width,
- height: Dimensions.get("window").height,
+ width: screenWidth,
+ height: screenHeight,
backgroundColor: "black",
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
opacity: showControls ? 0.5 : 0,
}}
>
@@ -519,8 +527,8 @@ export const Controls: React.FC = ({
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
width: settings?.safeAreaInControlsEnabled
- ? Dimensions.get("window").width - insets.left - insets.right
- : Dimensions.get("window").width,
+ ? screenWidth - insets.left - insets.right
+ : screenWidth,
opacity: showControls ? 1 : 0,
},
]}
@@ -572,21 +580,24 @@ export const Controls: React.FC = ({
)}
- {mediaSource?.TranscodingUrl && (
-
-
-
- )}
+ {/* {mediaSource?.TranscodingUrl && ( */}
+
+
+
+ {/* )} */}
{
lightHapticFeedback();
+ await ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.PORTRAIT_UP
+ );
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
diff --git a/eas.json b/eas.json
index 8ce5fc71..9821cceb 100644
--- a/eas.json
+++ b/eas.json
@@ -22,13 +22,13 @@
}
},
"production": {
- "channel": "0.24.0",
+ "channel": "0.25.0",
"android": {
"image": "latest"
}
},
"production-apk": {
- "channel": "0.24.0",
+ "channel": "0.25.0",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts
index 5b183183..2a708115 100644
--- a/hooks/useJellyseerr.ts
+++ b/hooks/useJellyseerr.ts
@@ -29,6 +29,12 @@ import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { writeErrorLog } from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { t } from "i18next";
+import {
+ CombinedCredit,
+ PersonDetails,
+} from "@/utils/jellyseerr/server/models/Person";
+import { useQueryClient } from "@tanstack/react-query";
+import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
interface SearchParams {
query: string;
@@ -56,19 +62,27 @@ export enum Endpoints {
API_V1 = "/api/v1",
SEARCH = "/search",
REQUEST = "/request",
+ PERSON = "/person",
+ COMBINED_CREDITS = "/combined_credits",
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
TV = "/tv",
SETTINGS = "/settings",
+ NETWORK = "/network",
+ STUDIO = "/studio",
+ GENRE_SLIDER = "/genreslider",
DISCOVER = "/discover",
DISCOVER_TRENDING = DISCOVER + "/trending",
DISCOVER_MOVIES = DISCOVER + "/movies",
DISCOVER_TV = DISCOVER + TV,
+ DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
+ DISCOVER_MOVIES_STUDIO = DISCOVER + `${MOVIE}s` + STUDIO,
AUTH_JELLYFIN = "/auth/jellyfin",
}
export type DiscoverEndpoint =
+ | Endpoints.DISCOVER_TV_NETWORK
| Endpoints.DISCOVER_TRENDING
| Endpoints.DISCOVER_MOVIES
| Endpoints.DISCOVER_TV;
@@ -175,7 +189,7 @@ export class JellyseerrApi {
}
async discover(
- endpoint: DiscoverEndpoint,
+ endpoint: DiscoverEndpoint | string,
params: any
): Promise {
return this.axios
@@ -183,6 +197,15 @@ export class JellyseerrApi {
.then(({ data }) => data);
}
+ async getGenreSliders(
+ endpoint: Endpoints.TV | Endpoints.MOVIE,
+ params: any = undefined
+ ): Promise {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params })
+ .then(({ data }) => data);
+ }
+
async search(params: SearchParams): Promise {
const response = await this.axios?.get(
Endpoints.API_V1 + Endpoints.SEARCH,
@@ -205,6 +228,27 @@ export class JellyseerrApi {
});
}
+ async personDetails(id: number | string): Promise {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
+ .then((response) => {
+ return response?.data;
+ });
+ }
+
+ async personCombinedCredits(id: number | string): Promise {
+ return this.axios
+ ?.get(
+ Endpoints.API_V1 +
+ Endpoints.PERSON +
+ `/${id}` +
+ Endpoints.COMBINED_CREDITS
+ )
+ .then((response) => {
+ return response?.data;
+ });
+ }
+
async movieRatings(id: number) {
return this.axios
?.get(
@@ -239,14 +283,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,
+ filter: 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/${filter}/${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) {
@@ -322,6 +372,7 @@ const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER));
export const useJellyseerr = () => {
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const [settings, updateSettings] = useSettings();
+ const queryClient = useQueryClient();
const jellyseerrApi = useMemo(() => {
const cookies = storage.get(JELLYSEERR_COOKIES);
@@ -339,7 +390,11 @@ 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:
diff --git a/hooks/useOrientationSettings.ts b/hooks/useOrientationSettings.ts
index 85b8a113..907e9bf2 100644
--- a/hooks/useOrientationSettings.ts
+++ b/hooks/useOrientationSettings.ts
@@ -7,7 +7,9 @@ export const useOrientationSettings = () => {
useEffect(() => {
if (settings?.autoRotate) {
- // Don't need to do anything
+ ScreenOrientation.lockAsync(
+ ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
+ );
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
diff --git a/package.json b/package.json
index 2cfdbc7f..5afe2df9 100644
--- a/package.json
+++ b/package.json
@@ -75,9 +75,10 @@
"react-i18next": "^15.4.0",
"react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6",
- "react-native-bottom-tabs": "0.7.1",
+ "react-native-bottom-tabs": "0.7.8",
"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",
@@ -85,7 +86,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",
@@ -102,7 +103,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",
diff --git a/plugins/withGoogleCastActivity.js b/plugins/withGoogleCastActivity.js
new file mode 100644
index 00000000..1a8c0a30
--- /dev/null
+++ b/plugins/withGoogleCastActivity.js
@@ -0,0 +1,34 @@
+const { withAndroidManifest } = require("@expo/config-plugins");
+
+const withGoogleCastActivity = (config) =>
+ withAndroidManifest(config, async (config) => {
+ const mainApplication = config.modResults.manifest.application[0];
+
+ // Initialize activity array if it doesn't exist
+ if (!mainApplication.activity) {
+ mainApplication.activity = [];
+ }
+
+ // Check if the activity already exists
+ const activityExists = mainApplication.activity.some(
+ (activity) =>
+ activity.$?.["android:name"] ===
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity"
+ );
+
+ // Only add the activity if it doesn't already exist
+ if (!activityExists) {
+ mainApplication.activity.push({
+ $: {
+ "android:name":
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity",
+ "android:theme": "@style/Theme.MaterialComponents.NoActionBar",
+ "android:launchMode": "singleTask",
+ },
+ });
+ }
+
+ return config;
+ });
+
+module.exports = withGoogleCastActivity;
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 57dd94c6..f4ccce75 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -55,7 +55,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
- clientInfo: { name: "Streamyfin", version: "0.24.0" },
+ clientInfo: { name: "Streamyfin", version: "0.25.0" },
deviceInfo: {
name: deviceName,
id,
@@ -92,7 +92,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
- }, DeviceId="${deviceId}", Version="0.24.0"`,
+ }, DeviceId="${deviceId}", Version="0.25.0"`,
};
}, [deviceId]);
diff --git a/scripts/automerge.sh b/scripts/automerge.sh
new file mode 100755
index 00000000..d66a0941
--- /dev/null
+++ b/scripts/automerge.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+[[ -z $(git status --porcelain) ]] &&
+git checkout master &&
+git pull --ff-only &&
+git checkout develop &&
+git merge master &&
+git push --follow-tags &&
+git checkout master &&
+git merge develop --ff-only &&
+git push &&
+git checkout develop ||
+(echo "Error: Failed to merge" && exit 1)
\ No newline at end of file
diff --git a/scripts/reset-project.js b/scripts/reset-project.js
deleted file mode 100755
index 4512e162..00000000
--- a/scripts/reset-project.js
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script is used to reset the project to a blank state.
- * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
- * You can remove the `reset-project` script from package.json and safely delete this file after running it.
- */
-
-const fs = require('fs');
-const path = require('path');
-
-const root = process.cwd();
-const oldDirPath = path.join(root, 'app');
-const newDirPath = path.join(root, 'app-example');
-const newAppDirPath = path.join(root, 'app');
-
-const indexContent = `import { Text, View } from "react-native";
-
-export default function Index() {
- return (
-
- Edit app/index.tsx to edit this screen.
-
- );
-}
-`;
-
-const layoutContent = `import { Stack } from "expo-router";
-
-export default function RootLayout() {
- return (
-
-
-
- );
-}
-`;
-
-fs.rename(oldDirPath, newDirPath, (error) => {
- if (error) {
- return console.error(`Error renaming directory: ${error}`);
- }
- console.log('/app moved to /app-example.');
-
- fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
- if (error) {
- return console.error(`Error creating new app directory: ${error}`);
- }
- console.log('New /app directory created.');
-
- const indexPath = path.join(newAppDirPath, 'index.tsx');
- fs.writeFile(indexPath, indexContent, (error) => {
- if (error) {
- return console.error(`Error creating index.tsx: ${error}`);
- }
- console.log('app/index.tsx created.');
-
- const layoutPath = path.join(newAppDirPath, '_layout.tsx');
- fs.writeFile(layoutPath, layoutContent, (error) => {
- if (error) {
- return console.error(`Error creating _layout.tsx: ${error}`);
- }
- console.log('app/_layout.tsx created.');
- });
- });
- });
-});
diff --git a/translations/en.json b/translations/en.json
index eb1425a7..74a13001 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -6,7 +6,6 @@
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
- "use_quick_connect": "Use Quick Connect",
"login_button": "Log in",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
@@ -17,8 +16,7 @@
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
- "server_url_placeholder": "Server URL",
- "server_url_hint": "Make sure to include http or https",
+ "server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect",
"previous_servers": "previous servers",
"clear_button": "Clear"
@@ -209,7 +207,6 @@
}
},
"search": {
- "results_for_x": "Results for ",
"search_here": "Search here...",
"search": "Search...",
"x_items": "{{count}} items",
@@ -225,8 +222,8 @@
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
- "requested_movies": "Requested Movies",
- "requested_series": "Requested Series"
+ "request_movies": "Request Movies",
+ "request_series": "Request Series"
},
"library": {
"no_items_found": "No items found",
diff --git a/translations/fr.json b/translations/fr.json
index dcbfbaf8..02182ec2 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -6,7 +6,6 @@
"login_to_title": "Se connecter à",
"username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe",
- "use_quick_connect": "Utiliser Connexion Rapide",
"login_button": "Se connecter",
"quick_connect": "Connexion Rapide",
"enter_code_to_login": "Entrez le code {{code}} pour vous connecter",
@@ -17,8 +16,7 @@
},
"server": {
"enter_url_to_jellyfin_server": "Entrez l'URL de votre serveur Jellyfin",
- "server_url_placeholder": "URL du serveur",
- "server_url_hint": "Assurez-vous d'inclure http ou https",
+ "server_url_placeholder": "http(s)://votre-serveur.com",
"connect_button": "Connexion",
"previous_servers": "Serveurs précédents",
"clear_button": "Effacer"
@@ -209,7 +207,6 @@
}
},
"search": {
- "results_for_x": "Résultats pour ",
"search_here": "Rechercher ici...",
"search": "Rechercher...",
"x_items": "{{count}} items",
@@ -225,8 +222,8 @@
"artists": "Artistes",
"albums": "Albums",
"songs": "Chansons",
- "requested_movies": "Films demandés",
- "requested_series": "Séries demandées"
+ "request_movies": "Demander un film",
+ "request_series": "Demander une série"
},
"library": {
"no_items_found": "Aucun item trouvé",
diff --git a/translations/sv.json b/translations/sv.json
index 762823a3..d35f6c82 100644
--- a/translations/sv.json
+++ b/translations/sv.json
@@ -9,7 +9,6 @@
},
"server": {
"server_url_placeholder": "Server URL",
- "server_url_hint": "Server URL kräver http eller https",
"connect_button": "Anslut"
},
"home": {
diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts
new file mode 100644
index 00000000..ba692df3
--- /dev/null
+++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts
@@ -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;
+};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index 3b63009a..e60b0f1a 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -91,6 +91,7 @@ export type Settings = {
remuxConcurrentLimit: 1 | 2 | 3 | 4;
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
+ hiddenLibraries?: string[];
};
const loadSettings = (): Settings => {
@@ -131,6 +132,7 @@ const loadSettings = (): Settings => {
remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
+ hiddenLibraries: [],
};
try {
diff --git a/utils/jellyseerr b/utils/jellyseerr
index e69d160e..a15f2ab3 160000
--- a/utils/jellyseerr
+++ b/utils/jellyseerr
@@ -1 +1 @@
-Subproject commit e69d160e25f0962cd77b01c861ce248050e1ad38
+Subproject commit a15f2ab336936f49e38ea37f8b224da40e12588e
diff --git a/utils/useReactNavigationQuery.ts b/utils/useReactNavigationQuery.ts
new file mode 100644
index 00000000..a0c5b307
--- /dev/null
+++ b/utils/useReactNavigationQuery.ts
@@ -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
+): UseQueryResult {
+ 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;
+}