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 ( <> - - - - + ) : ( <> - + - - {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 @@ + +AAAsdGp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAACxOanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46dXVpZDpjOGFmZTAwYS1iN2JiLTRkNTUtYmUwZi1iN2Y2Mzc4NzRlYTUAAAABtGp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAANdqdW1iAAAAJmp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuYWN0aW9ucwAAAACpY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmtjMnBhLmVkaXRlZG1zb2Z0d2FyZUFnZW50bUFkb2JlIEZpcmVmbHlxZGlnaXRhbFNvdXJjZVR5cGV4U2h0dHA6Ly9jdi5pcHRjLm9yZy9uZXdzY29kZXMvZGlnaXRhbHNvdXJjZXR5cGUvY29tcG9zaXRlV2l0aFRyYWluZWRBbGdvcml0aG1pY01lZGlhAAAArGp1bWIAAAAoanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5oYXNoLmRhdGEAAAAAfGNib3KlamV4Y2x1c2lvbnOBomVzdGFydBjuZmxlbmd0aBk7SGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggrnb/Z0LL/KWPpqmjemYRvQg3RH4cxUsaxZtMKj493SpjcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJGp1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0AAAAB32Nib3KoaGRjOnRpdGxlb0dlbmVyYXRlZCBJbWFnZWlkYzpmb3JtYXRtaW1hZ2Uvc3ZnK3htbGppbnN0YW5jZUlEeCx4bXA6aWlkOjJmMzZiOTBiLTUyNTctNGIzMi05NjIyLTExOGUyYjY1NTJmZW9jbGFpbV9nZW5lcmF0b3J4NkFkb2JlX0lsbHVzdHJhdG9yLzI4LjQgYWRvYmVfYzJwYS8wLjcuNiBjMnBhLXJzLzAuMjUuMnRjbGFpbV9nZW5lcmF0b3JfaW5mb4G/ZG5hbWVxQWRvYmUgSWxsdXN0cmF0b3JndmVyc2lvbmQyOC40/2lzaWduYXR1cmV4GXNlbGYjanVtYmY9YzJwYS5zaWduYXR1cmVqYXNzZXJ0aW9uc4KiY3VybHg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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQEAcNiFxc4R79ozvFI3cymplwVvAWDIKFyiBFAYVnZ4u3HEcPLDTfIt9X7Nd1vyzbJIZpVE6NOicYEaRwt+uauSMcSPsX9PHUJgyWALEQ6RHudtr57nbNIgmioCefdyEtzGbCylEalKZNWNlzjT2rgZFB1shhJ3hhVHDBPaKX2KxL3C8utMK2iBREKaVCatCmw4JVECUjwN7Qn3V347tiBf5wbCt/a+q382311bbBSW57XWiNjoek/xXArl25l6pWZSkTcShpTPT7ynjoFFRwCewR5+xU+2LKETQ4wrV3n5nK6RayHlThKGkqv3GuPOMk8ogRGaHezj/nphLuUsoIjpNA== + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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; +}