diff --git a/README.md b/README.md index 360f4949..a0843126 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp - ๐Ÿš€ **Skp intro / credits support** - ๐Ÿ–ผ๏ธ **Trickplay images**: The new golden standard for chapter previews when seeking. -- ๐Ÿ“บ **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone. - ๐Ÿ”Š **Background audio**: Stream music in the background, even when locking the phone. - ๐Ÿ“ฅ **Download media** (Experimental): Save your media locally and watch it offline. - ๐Ÿ“ก **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. +- ๐Ÿค– **Jellyseerr integration**: Request media directly in the app. ## ๐Ÿงช Experimental Features @@ -70,11 +70,9 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream ### Beta testing -Get the latest updates by using the TestFlight version of the app. +To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the โ ๐Ÿงช-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you. - - Get the beta on TestFlight - + **Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas. ## ๐Ÿš€ Getting Started @@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea ### Development info -1. Use node `20` -2. Install dependencies `bun i` -3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. - -## Extended chromecast controls - -Add this to AppDelegate.mm: - -``` -- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions -{ -// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091 -#if __has_include() -... - -[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;` -#endif -``` - -Add this to Info.plist: - -``` -NSBonjourServices - - _googlecast._tcp - _CC1AD845._googlecast._tcp - -NSLocalNetworkUsageDescription -${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network. -``` +1. Use node `>20` +2. Install dependencies `bun i && bun run submodule-reload` +3. Make sure you have xcode and/or android studio installed. +4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app. ## ๐Ÿ“„ License @@ -153,6 +125,7 @@ I'd like to thank the following people and projects for their contributions to S - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. +- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project. - The Jellyfin devs for always being helpful in the Discord. ## Star History diff --git a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx index bd778042..edf91697 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx @@ -1,40 +1,62 @@ -import React, {useCallback, useRef, useState} from "react"; -import {useLocalSearchParams} from "expo-router"; -import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; -import {Text} from "@/components/common/Text"; -import {ParallaxScrollView} from "@/components/ParallaxPage"; -import {Image} from "expo-image"; -import {TouchableOpacity, View} from "react-native"; -import {Ionicons} from "@expo/vector-icons"; -import {useSafeAreaInsets} from "react-native-safe-area-context"; -import {OverviewText} from "@/components/OverviewText"; -import {GenreTags} from "@/components/GenreTags"; -import {MediaType} from "@/utils/jellyseerr/server/constants/media"; -import {useQuery} from "@tanstack/react-query"; -import {useJellyseerr} from "@/hooks/useJellyseerr"; -import {Button} from "@/components/Button"; -import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; -import {IssueType, IssueTypeName} from "@/utils/jellyseerr/server/constants/issue"; +import React, { useCallback, useRef, useState } from "react"; +import { useLocalSearchParams } from "expo-router"; +import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { Text } from "@/components/common/Text"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { Image } from "expo-image"; +import { TouchableOpacity, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { OverviewText } from "@/components/OverviewText"; +import { GenreTags } from "@/components/GenreTags"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import { useQuery } from "@tanstack/react-query"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { Button } from "@/components/Button"; +import { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { + IssueType, + IssueTypeName, +} from "@/utils/jellyseerr/server/constants/issue"; import * as DropdownMenu from "zeego/dropdown-menu"; -import {Input} from "@/components/common/Input"; -import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; +import { Input } from "@/components/common/Input"; +import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; -import {JellyserrRatings} from "@/components/Ratings"; +import { JellyserrRatings } from "@/components/Ratings"; const Page: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); - const {mediaTitle, releaseYear, canRequest: canRequestString, posterSrc, ...result} = - params as unknown as {mediaTitle: string, releaseYear: number, canRequest: string, posterSrc: string} & Partial; + 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 {jellyseerrApi, requestMedia} = useJellyseerr(); + const { jellyseerrApi, requestMedia } = useJellyseerr(); const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); const bottomSheetModalRef = useRef(null); - const {data: details, isLoading} = useQuery({ + const { + data: details, + isFetching, + isLoading, + } = useQuery({ enabled: !!jellyseerrApi && !!result && !!result.id, queryKey: ["jellyseerr", "detail", result.mediaType, result.id], staleTime: 0, @@ -45,8 +67,8 @@ const Page: React.FC = () => { queryFn: async () => { return result.mediaType === MediaType.MOVIE ? jellyseerrApi?.movieDetails(result.id!!) - : jellyseerrApi?.tvDetails(result.id!!) - } + : jellyseerrApi?.tvDetails(result.id!!); + }, }); const renderBackdrop = useCallback( @@ -62,23 +84,30 @@ const Page: React.FC = () => { const submitIssue = useCallback(() => { if (result.id && issueType && issueMessage && details) { - jellyseerrApi?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) + jellyseerrApi + ?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) .then(() => { - setIssueType(undefined) - setIssueMessage(undefined) - bottomSheetModalRef?.current?.close() - }) + setIssueType(undefined); + setIssueMessage(undefined); + bottomSheetModalRef?.current?.close(); + }); } - }, [jellyseerrApi, details, result, issueType, issueMessage]) + }, [jellyseerrApi, details, result, issueType, issueMessage]); - const request = useCallback(() => requestMedia(mediaTitle, { - mediaId: Number(result.id!!), - mediaType: result.mediaType!!, - tvdbId: details?.externalIds?.tvdbId, - seasons: (details as TvDetails)?.seasons.filter(s => s.seasonNumber !== 0).map(s => s.seasonNumber) - }), [details, result, requestMedia]); + const request = useCallback( + () => + requestMedia(mediaTitle, { + mediaId: Number(result.id!!), + mediaType: result.mediaType!!, + tvdbId: details?.externalIds?.tvdbId, + seasons: (details as TvDetails)?.seasons + ?.filter?.((s) => s.seasonNumber !== 0) + ?.map?.((s) => s.seasonNumber), + }), + [details, result, requestMedia] + ); - return ( + return ( { name="image-outline" size={24} color="white" - style={{opacity: 0.4}} + style={{ opacity: 0.4 }} /> )} @@ -123,12 +152,18 @@ const Page: React.FC = () => { } > - - <> + + - - {mediaTitle} + + + {mediaTitle} + {releaseYear} { }} /> - - g.name) || []} /> - {canRequest ? - - : - - } - + + g.name) || []} /> + + {canRequest ? ( + + ) : ( + + )} + + - {result.mediaType === MediaType.TV && - - } + {result.mediaType === MediaType.TV && ( + + )} @@ -185,17 +225,23 @@ const Page: React.FC = () => { - Whats wrong? + + Whats wrong? + - Issue Type + + Issue Type + - {issueType ? IssueTypeName[issueType] : 'Select an issue' } + {issueType + ? IssueTypeName[issueType] + : "Select an issue"} @@ -210,14 +256,20 @@ const Page: React.FC = () => { sideOffset={0} > Types - {Object.entries(IssueTypeName).reverse().map(([key, value], idx) => ( - setIssueType(key as unknown as IssueType)} - > - {value} - - ))} + {Object.entries(IssueTypeName) + .reverse() + .map(([key, value], idx) => ( + + setIssueType(key as unknown as IssueType) + } + > + + {value} + + + ))} @@ -234,11 +286,7 @@ const Page: React.FC = () => { onChangeText={setIssueMessage} /> - @@ -246,6 +294,6 @@ const Page: React.FC = () => { ); -} +}; -export default Page; \ No newline at end of file +export default Page; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index ecee672b..e9c9fdea 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -1,8 +1,11 @@ import { Text } from "@/components/common/Text"; import { DownloadItems } from "@/components/DownloadItem"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { Ratings } from "@/components/Ratings"; import { NextUp } from "@/components/series/NextUp"; import { SeasonPicker } from "@/components/series/SeasonPicker"; +import { SeriesActions } from "@/components/series/SeriesActions"; +import { SeriesHeader } from "@/components/series/SeriesHeader"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; @@ -70,6 +73,7 @@ const page: React.FC = () => { }); return res?.data.Items || []; }, + staleTime: 60, enabled: !!api && !!user?.Id && !!item?.Id, }); @@ -133,10 +137,7 @@ const page: React.FC = () => { } > - - {item?.Name} - {item?.Overview} - + diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 0b7cd1e2..a0cf2f6c 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -144,8 +144,6 @@ const Page = () => { }): Promise => { if (!api || !library) return null; - console.log("[libraryId] ~", library); - let itemType: BaseItemKind | undefined; // This fix makes sure to only return 1 type of items, if defined. diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 64400502..81acd37c 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -30,16 +30,16 @@ 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 { 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 { Tag } from "@/components/GenreTags"; import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide"; -import {sortBy} from "lodash"; import { useTranslation } from "react-i18next"; +import {sortBy} from "lodash"; -type SearchType = 'Library' | 'Discover'; +type SearchType = "Library" | "Discover"; const exampleSearches = [ "Lord of the rings", @@ -155,29 +155,41 @@ export default function search() { 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' - }) + language: "en", + }); return response?.results; }, - enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length > 0, + 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, + enabled: + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length == 0, }); - const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(() => - jellyseerrResults?.filter(r => r.mediaType === MediaType.MOVIE) as MovieResult[], + 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[], + 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], @@ -262,7 +274,17 @@ export default function search() { jellyseerrMovieResults?.length || jellyseerrTvResults?.length ); - }, [artists, episodes, albums, songs, movies, series, collections, actors, jellyseerrResults]); + }, [ + artists, + episodes, + albums, + songs, + movies, + series, + collections, + actors, + jellyseerrResults, + ]); const loading = useMemo(() => { return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2; @@ -292,14 +314,24 @@ export default function search() { )} {jellyseerrApi && ( - - setSearchType('Library')}> - + + setSearchType("Library")}> + - setSearchType('Discover')}> - + setSearchType("Discover")}> + )} @@ -321,7 +353,7 @@ export default function search() { className="flex flex-col w-28 mr-2" item={item} > - + {item.Name} @@ -340,7 +372,7 @@ export default function search() { item={item} className="flex flex-col w-28 mr-2" > - + {item.Name} @@ -359,8 +391,8 @@ export default function search() { key={item.Id} className="flex flex-col w-44 mr-2" > - - + + )} /> @@ -373,7 +405,7 @@ export default function search() { item={item} className="flex flex-col w-28 mr-2" > - + {item.Name} @@ -389,8 +421,8 @@ export default function search() { key={item.Id} className="flex flex-col w-28 mr-2" > - - + + )} /> @@ -403,8 +435,8 @@ export default function search() { key={item.Id} className="flex flex-col w-28 mr-2" > - - + + )} /> @@ -417,8 +449,8 @@ export default function search() { key={item.Id} className="flex flex-col w-28 mr-2" > - - + + )} /> @@ -431,8 +463,8 @@ export default function search() { key={item.Id} className="flex flex-col w-28 mr-2" > - - + + )} /> @@ -444,14 +476,14 @@ export default function search() { header="Request Movies" items={jellyseerrMovieResults} renderItem={(item: MovieResult) => ( - + )} /> ( - + )} /> @@ -470,7 +502,7 @@ export default function search() { "{debouncedSearch}" - ) : debouncedSearch.length === 0 && searchType === 'Library' ? ( + ) : debouncedSearch.length === 0 && searchType === "Library" ? ( {exampleSearches.map((e) => ( ))} - ) : debouncedSearch.length === 0 && searchType === 'Discover' ? ( - - {sortBy?.(jellyseerrDiscoverSettings?.filter(s => s.enabled), 'order') - .map((slide) => ) - } + ) : debouncedSearch.length === 0 && searchType === "Discover" ? ( + + {sortBy?.( + jellyseerrDiscoverSettings?.filter((s) => s.enabled), + "order" + ).map((slide) => ( + + ))} ) : null} @@ -502,7 +537,12 @@ type Props = { header?: string; }; -const SearchItemWrapper = ({ ids, items, renderItem, header }: PropsWithChildren>) => { +const SearchItemWrapper = ({ + ids, + items, + renderItem, + header, +}: PropsWithChildren>) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -542,14 +582,11 @@ const SearchItemWrapper = ({ ids, items, renderItem, header className="px-4 mb-2" showsHorizontalScrollIndicator={false} > - { - data && data?.length > 0 - ? data.map((item) => renderItem(item)) - : - items && items?.length > 0 - ? items.map(i => renderItem(i)) - : undefined - } + {data && data?.length > 0 + ? data.map((item) => renderItem(item)) + : items && items?.length > 0 + ? items.map((i) => renderItem(i)) + : undefined} ); diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 5e62d868..e4b49320 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -282,13 +282,6 @@ export default function page() { if (!item?.Id || !stream) return; - console.log( - "onProgress ~", - currentTimeInTicks, - isPlaying, - `AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}` - ); - await getPlaystateApi(api!).onPlaybackProgress({ itemId: item.Id, audioStreamIndex: audioIndex ? audioIndex : undefined, diff --git a/app/(auth)/trailer/page.tsx b/app/(auth)/trailer/page.tsx new file mode 100644 index 00000000..c2a84809 --- /dev/null +++ b/app/(auth)/trailer/page.tsx @@ -0,0 +1,53 @@ +import { useGlobalSearchParams, useNavigation } from "expo-router"; +import { useState, useCallback, useEffect, useMemo } from "react"; +import { Button, Dimensions } from "react-native"; +import { Alert, View } from "react-native"; +import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe"; + +export default function page() { + const searchParams = useGlobalSearchParams(); + const navigation = useNavigation(); + console.log(searchParams); + + const { url } = searchParams as { url: string }; + + const videoId = useMemo(() => { + return url.split("v=")[1]; + }, [url]); + + const [playing, setPlaying] = useState(false); + + const onStateChange = useCallback((state: PLAYER_STATES) => { + if (state === "ended") { + setPlaying(false); + Alert.alert("video has finished playing!"); + } + }, []); + + const togglePlaying = useCallback(() => { + setPlaying((prev) => !prev); + }, []); + + useEffect(() => { + navigation.setOptions({ + headerShown: false, + }); + + togglePlaying(); + }, []); + + const screenWidth = Dimensions.get("screen").width; + const screenHeight = Dimensions.get("screen").height; + + return ( + + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 0c4c9d07..8096a100 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -348,6 +348,13 @@ function Layout() { header: () => null, }} /> + + + + + + + + + + + + + + + + + + + + + diff --git a/bun.lockb b/bun.lockb index e34e84fe..902ab685 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/Button.tsx b/components/Button.tsx index 1498a975..1a73ad01 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -47,7 +47,7 @@ export const Button: React.FC> = ({ = React.memo( // Needs to automatically change the selected to the default values for default indexes. useEffect(() => { - console.log(defaultAudioIndex, defaultSubtitleIndex); setSelectedOptions(() => ({ bitrate: defaultBitrate, mediaSource: defaultMediaSource, @@ -220,7 +219,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( className="mr-1" source={selectedOptions.mediaSource} onChange={(val) => { - console.log(val); setSelectedOptions( (prev) => prev && { diff --git a/components/ListItem.tsx b/components/ListItem.tsx index 755f79ed..0287c690 100644 --- a/components/ListItem.tsx +++ b/components/ListItem.tsx @@ -24,7 +24,7 @@ export const ListItem: React.FC> = ({ {title} {subTitle && ( - + {subTitle} )} diff --git a/components/Ratings.tsx b/components/Ratings.tsx index e5eb8fc3..64d3d83b 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -3,10 +3,10 @@ import { View, ViewProps } from "react-native"; import { Badge } from "./Badge"; import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; -import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; -import {useJellyseerr} from "@/hooks/useJellyseerr"; -import {useQuery} from "@tanstack/react-query"; -import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useQuery } from "@tanstack/react-query"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; interface Props extends ViewProps { item?: BaseItemDto | null; @@ -21,7 +21,7 @@ export const Ratings: React.FC = ({ item, ...props }) => { )} {item.CommunityRating && ( } /> @@ -32,7 +32,11 @@ export const Ratings: React.FC = ({ item, ...props }) => { variant="gray" iconLeft={ = ({ item, ...props }) => { ); }; - -export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ result }) => { - const {jellyseerrApi} = useJellyseerr(); +export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({ + result, +}) => { + const { jellyseerrApi } = useJellyseerr(); const { data, isLoading } = useQuery({ - queryKey: ['jellyseerr', result.id, result.mediaType, 'ratings'], + queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"], queryFn: async () => { return result.mediaType === MediaType.MOVIE ? jellyseerrApi?.movieRatings(result.id) - : jellyseerrApi?.tvRatings(result.id) + : jellyseerrApi?.tvRatings(result.id); }, staleTime: (5).minutesToMilliseconds(), retry: false, enabled: !!jellyseerrApi, }); - return (isLoading || !!result.voteCount || + return ( + (isLoading || + !!result.voteCount || (data?.criticsRating && !!data?.criticsScore) || (data?.audienceRating && !!data?.audienceScore)) && ( @@ -72,7 +79,7 @@ export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ r = ({ r = ({ r )} ) -} \ No newline at end of file + ); +}; diff --git a/components/jellyseerr/DiscoverSlide.tsx b/components/jellyseerr/DiscoverSlide.tsx index 94a2d4dd..7e566630 100644 --- a/components/jellyseerr/DiscoverSlide.tsx +++ b/components/jellyseerr/DiscoverSlide.tsx @@ -1,26 +1,31 @@ -import React, {useMemo} from "react"; +import React, { useMemo } from "react"; import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; -import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover"; -import {DiscoverEndpoint, Endpoints, useJellyseerr} from "@/hooks/useJellyseerr"; -import {useInfiniteQuery} from "@tanstack/react-query"; -import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; +import { + DiscoverEndpoint, + Endpoints, + useJellyseerr, +} from "@/hooks/useJellyseerr"; +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 { Text } from "@/components/common/Text"; +import { FlashList } from "@shopify/flash-list"; +import { View } from "react-native"; interface Props { - slide: DiscoverSlider + slide: DiscoverSlider; } -const DiscoverSlide: React.FC = ({slide}) => { - const {jellyseerrApi} = useJellyseerr(); +const DiscoverSlide: React.FC = ({ slide }) => { + const { jellyseerrApi } = useJellyseerr(); - const {data, isFetching, fetchNextPage, hasNextPage} = useInfiniteQuery({ + const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey: ["jellyseerr", "discover", slide.id], queryFn: async ({ pageParam }) => { let endpoint: DiscoverEndpoint | undefined = undefined; let params: any = { - page: Number(pageParam) - } + page: Number(pageParam), + }; switch (slide.type) { case DiscoverSliderType.TRENDING: @@ -28,48 +33,68 @@ const DiscoverSlide: React.FC = ({slide}) => { break; case DiscoverSliderType.POPULAR_MOVIES: case DiscoverSliderType.UPCOMING_MOVIES: - endpoint = Endpoints.DISCOVER_MOVIES + endpoint = Endpoints.DISCOVER_MOVIES; if (slide.type === DiscoverSliderType.UPCOMING_MOVIES) - params = { ...params, primaryReleaseDateGte: new Date().toISOString().split('T')[0]} + params = { + ...params, + primaryReleaseDateGte: new Date().toISOString().split("T")[0], + }; break; case DiscoverSliderType.POPULAR_TV: case DiscoverSliderType.UPCOMING_TV: - endpoint = Endpoints.DISCOVER_TV + endpoint = Endpoints.DISCOVER_TV; if (slide.type === DiscoverSliderType.UPCOMING_TV) - params = {...params, firstAirDateGte: new Date().toISOString().split('T')[0]} + params = { + ...params, + firstAirDateGte: new Date().toISOString().split("T")[0], + }; break; } return endpoint ? jellyseerrApi?.discover(endpoint, params) : null; }, initialPageParam: 1, - getNextPageParam: (lastPage, pages) => ((lastPage?.page || pages?.findLast(p => p?.results.length)?.page) || 1) + 1, + getNextPageParam: (lastPage, pages) => + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + + 1, enabled: !!jellyseerrApi, - staleTime: 0 + staleTime: 0, }); - const flatData = useMemo(() => data?.pages?.filter(p => p?.results.length).flatMap(p => p?.results), [data]) + const flatData = useMemo( + () => + 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 ? : <>) - } - /> - - ) -} + 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 ? ( + + ) : ( + <> + ) + } + /> + + ) + ); +}; -export default DiscoverSlide; \ No newline at end of file +export default DiscoverSlide; diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx index a62689b9..f0efa418 100644 --- a/components/series/JellyseerrSeasons.tsx +++ b/components/series/JellyseerrSeasons.tsx @@ -1,33 +1,37 @@ -import {Text} from "@/components/common/Text"; -import React, {useCallback, useMemo, useState} from "react"; -import {Alert, TouchableOpacity, View} from "react-native"; -import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; -import {FlashList} from "@shopify/flash-list"; -import {orderBy} from "lodash"; -import {Tags} from "@/components/GenreTags"; +import { Text } from "@/components/common/Text"; +import React, { useCallback, useMemo, useState } from "react"; +import { Alert, TouchableOpacity, View } from "react-native"; +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 Season from "@/utils/jellyseerr/server/entity/Season"; -import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; -import {Ionicons} from "@expo/vector-icons"; -import {RoundButton} from "@/components/RoundButton"; -import {useJellyseerr} from "@/hooks/useJellyseerr"; -import {TvResult} from "@/utils/jellyseerr/server/models/Search"; -import {useQuery} from "@tanstack/react-query"; -import {HorizontalScroll} from "@/components/common/HorrizontalScroll"; -import {Image} from "expo-image"; +import { + MediaStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; +import { Ionicons } from "@expo/vector-icons"; +import { RoundButton } from "@/components/RoundButton"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { useQuery } from "@tanstack/react-query"; +import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; +import { Image } from "expo-image"; import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; +import { Loader } from "../Loader"; -const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: number}> = ({ - details, - seasonNumber -}) => { - const {jellyseerrApi} = useJellyseerr(); +const JellyseerrSeasonEpisodes: React.FC<{ + details: TvDetails; + seasonNumber: number; +}> = ({ details, seasonNumber }) => { + const { jellyseerrApi } = useJellyseerr(); - const {data: seasonWithEpisodes, isLoading} = useQuery({ + const { data: seasonWithEpisodes, isLoading } = useQuery({ queryKey: ["jellyseerr", details.id, "season", seasonNumber], queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber), - enabled: details.seasons.filter(s => s.seasonNumber !== 0).length > 0 - }) + enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0, + }); return ( item.id} - ItemSeparatorComponent={() => } renderItem={(item, index) => ( - - {item.stillPath && ( - - - - )} - - - {item?.name} - - - {`S${item?.seasonNumber}:E${item?.episodeNumber}`} - - - - - {item?.overview} - - + )} /> - ) -} + ); +}; + +const RenderItem = ({ item, index }: any) => { + const { jellyseerrApi } = useJellyseerr(); + const [imageError, setImageError] = useState(false); + + return ( + + + {!imageError ? ( + { + setImageError(true); + }} + /> + ) : ( + + + + )} + + + + {item.name} + + + {`S${item.seasonNumber}:E${item.episodeNumber}`} + + + + + {item.overview} + + + ); +}; const JellyseerrSeasons: React.FC<{ - isLoading: boolean, - result?: TvResult, - details?: TvDetails -}> = ({ - isLoading, - result, - details, -}) => { - if (!details) - return null; + isLoading: boolean; + result?: TvResult; + details?: TvDetails; +}> = ({ isLoading, result, details }) => { + if (!details) return null; - const {jellyseerrApi, requestMedia} = useJellyseerr(); - const [seasonStates, setSeasonStates] = useState<{[key: number]: boolean}>(); + const { jellyseerrApi, requestMedia } = useJellyseerr(); + const [seasonStates, setSeasonStates] = useState<{ + [key: number]: boolean; + }>(); const seasons = useMemo(() => { - const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter((s: Season) => s.seasonNumber !== 0) - const requestedSeasons = details?.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) - return details.seasons?.map((season) => { - return { - ...season, - status: - // What our library status is - mediaInfoSeasons - ?.find((mediaSeason: Season) => mediaSeason.seasonNumber === season.seasonNumber) - ?.status - ?? - // What our request status is - requestedSeasons - ?.find((s: Season) => s.seasonNumber === season.seasonNumber) - ?.status - ?? - // Otherwise set it as unknown - MediaStatus.UNKNOWN - } - }) - }, - [details] - ); + const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter( + (s: Season) => s.seasonNumber !== 0 + ); + const requestedSeasons = details?.mediaInfo?.requests?.flatMap( + (r: MediaRequest) => r.seasons + ); + return details.seasons?.map((season) => { + return { + ...season, + status: + // What our library status is + mediaInfoSeasons?.find( + (mediaSeason: Season) => + mediaSeason.seasonNumber === season.seasonNumber + )?.status ?? + // What our request status is + requestedSeasons?.find( + (s: Season) => s.seasonNumber === season.seasonNumber + )?.status ?? + // Otherwise set it as unknown + MediaStatus.UNKNOWN, + }; + }); + }, [details]); - const allSeasonsAvailable = useMemo(() => - seasons?.every(season => season.status === MediaStatus.AVAILABLE), + const allSeasonsAvailable = useMemo( + () => seasons?.every((season) => season.status === MediaStatus.AVAILABLE), [seasons] - ) + ); const requestAll = useCallback(() => { if (details && jellyseerrApi) { @@ -125,48 +145,73 @@ const JellyseerrSeasons: React.FC<{ mediaType: MediaType.TV, tvdbId: details.externalIds?.tvdbId, seasons: seasons - .filter(s => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0) - .map(s => s.seasonNumber) - }) + .filter( + (s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0 + ) + .map((s) => s.seasonNumber), + }); } - }, [jellyseerrApi, seasons, details]) + }, [jellyseerrApi, seasons, details]); - const promptRequestAll = useCallback(() => ( - Alert.alert('Request all?', 'Are you sure you want to request all seasons?', [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'YES', - onPress: requestAll - }, - ])), [requestAll]); + const promptRequestAll = useCallback( + () => + Alert.alert("Confirm", "Are you sure you want to request all seasons?", [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Yes", + onPress: requestAll, + }, + ]), + [requestAll] + ); + + if (isLoading) + return ( + + + Seasons + {!allSeasonsAvailable && ( + + + + )} + + + + ); return ( s.seasonNumber !== 0), 'seasonNumber', 'desc')} + data={orderBy( + details.seasons.filter((s) => s.seasonNumber !== 0), + "seasonNumber", + "desc" + )} ListHeaderComponent={() => ( - + Seasons {!allSeasonsAvailable && ( - - + + )} )} ItemSeparatorComponent={() => } estimatedItemSize={250} - renderItem={({item: season}) => ( + renderItem={({ item: season }) => ( <> setSeasonStates((prevState) => ( - {...prevState, [season.seasonNumber]: !prevState?.[season.seasonNumber]} - ))} + onPress={() => + setSeasonStates((prevState) => ({ + ...prevState, + [season.seasonNumber]: !prevState?.[season.seasonNumber], + })) + } + className="px-4" > {[0].map(() => { - const canRequest = seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status === MediaStatus.UNKNOWN - return - requestMedia( - `${result?.name!!}, Season ${season.seasonNumber}`, - { - mediaId: details.id, - mediaType: MediaType.TV, - tvdbId: details.externalIds?.tvdbId, - seasons: [season.seasonNumber] - } - ) : undefined - } - className={canRequest ? 'bg-gray-700/40' : undefined} - mediaStatus={seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status} - showRequestIcon={canRequest} - /> + const canRequest = + seasons?.find((s) => s.seasonNumber === season.seasonNumber) + ?.status === MediaStatus.UNKNOWN; + return ( + + requestMedia( + `${result?.name!!}, Season ${ + season.seasonNumber + }`, + { + mediaId: details.id, + mediaType: MediaType.TV, + tvdbId: details.externalIds?.tvdbId, + seasons: [season.seasonNumber], + } + ) + : undefined + } + className={canRequest ? "bg-gray-700/40" : undefined} + mediaStatus={ + seasons?.find( + (s) => s.seasonNumber === season.seasonNumber + )?.status + } + showRequestIcon={canRequest} + /> + ); })} @@ -206,10 +267,9 @@ const JellyseerrSeasons: React.FC<{ /> )} - ) - } + )} /> - ) -} + ); +}; -export default JellyseerrSeasons; \ No newline at end of file +export default JellyseerrSeasons; diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx index ce87bb19..95834b9d 100644 --- a/components/series/NextUp.tsx +++ b/components/series/NextUp.tsx @@ -11,6 +11,7 @@ import { Text } from "../common/Text"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { ItemCardText } from "../ItemCardText"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import { FlashList } from "@shopify/flash-list"; export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { const [user] = useAtom(userAtom); @@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { return ( - Next up - Next up + ( + renderItem={({ item, index }) => ( = ({ diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 00093cd4..26a5f747 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -30,7 +30,10 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { const [user] = useAtom(userAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); - const seasonIndex = seasonIndexState[item.Id ?? ""]; + const seasonIndex = useMemo( + () => seasonIndexState[item.Id ?? ""], + [item, seasonIndexState] + ); const { data: seasons } = useQuery({ queryKey: ["seasons", item.Id], @@ -53,19 +56,28 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { return response.data.Items; }, + staleTime: 60, enabled: !!api && !!user?.Id && !!item.Id, }); - const selectedSeasonId: string | null = useMemo( - () => - seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, - [seasons, seasonIndex] - ); + const selectedSeasonId: string | null = useMemo(() => { + const season: BaseItemDto = seasons?.find( + (s: BaseItemDto) => + s.IndexNumber === seasonIndex || s.Name === seasonIndex + ); + + if (!season?.Id) return null; + + return season.Id!; + }, [seasons, seasonIndex]); const { data: episodes, isFetching } = useQuery({ queryKey: ["episodes", item.Id, selectedSeasonId], queryFn: async () => { - if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; + if (!api || !user?.Id || !item.Id || !selectedSeasonId) { + return []; + } + const res = await getTvShowsApi(api).getEpisodes({ seriesId: item.Id, userId: user.Id, @@ -74,6 +86,12 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { fields: ["MediaSources", "MediaStreams", "Overview"], }); + if (res.data.TotalRecordCount === 0) + console.warn( + "No episodes found for season with ID ~", + selectedSeasonId + ); + return res.data.Items; }, enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, @@ -118,25 +136,28 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { seasons={seasons} state={seasonIndexState} onSelect={(season) => { + if (!item.Id) return; setSeasonIndexState((prev) => ({ ...prev, - [item.Id ?? ""]: season.IndexNumber, + [item.Id!]: season.IndexNumber ?? season.Name, })); }} /> - ( - - )} - DownloadedIconComponent={() => ( - - )} - /> + {episodes?.length || 0 > 0 ? ( + ( + + )} + DownloadedIconComponent={() => ( + + )} + /> + ) : null} - + {isFetching ? ( = ({ item, initialSeasonIndex }) => { )) )} + {(episodes?.length || 0) === 0 ? ( + + + No episodes for this season + + + ) : null} ); diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx new file mode 100644 index 00000000..7e87247f --- /dev/null +++ b/components/series/SeriesActions.tsx @@ -0,0 +1,32 @@ +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 { View, TouchableOpacity, ViewProps } from "react-native"; + +interface Props extends ViewProps { + item: BaseItemDto; +} + +export const SeriesActions = ({ item, ...props }: Props) => { + const router = useRouter(); + + const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]); + + const openTrailer = useCallback(async () => { + if (!trailerLink) return; + + const encodedTrailerLink = encodeURIComponent(trailerLink); + router.push(`/trailer/page?url=${encodedTrailerLink}`); + }, [router, trailerLink]); + + return ( + + {trailerLink && ( + + + + )} + + ); +}; diff --git a/components/series/SeriesHeader.tsx b/components/series/SeriesHeader.tsx new file mode 100644 index 00000000..3fa5ca67 --- /dev/null +++ b/components/series/SeriesHeader.tsx @@ -0,0 +1,64 @@ +import { View } from "react-native"; +import { Text } from "../common/Text"; +import { Ratings } from "../Ratings"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useMemo } from "react"; +import { SeriesActions } from "./SeriesActions"; + +interface Props { + item: BaseItemDto; +} + +export const SeriesHeader = ({ item }: Props) => { + const startYear = useMemo(() => { + if (item?.StartDate) { + return new Date(item.StartDate) + .toLocaleDateString("sv-SE", { + calendar: "gregory", + year: "numeric", + }) + .toString() + .trim(); + } + return item.ProductionYear?.toString().trim(); + }, [item]); + + const endYear = useMemo(() => { + if (item.EndDate) { + return new Date(item.EndDate) + .toLocaleDateString("sv-SE", { + calendar: "gregory", + year: "numeric", + }) + .toString() + .trim(); + } + return ""; + }, [item]); + + const yearString = useMemo(() => { + if (startYear && endYear) { + if (startYear === endYear) return startYear; + return `${startYear} - ${endYear}`; + } + if (startYear) { + return startYear; + } + if (endYear) { + return endYear; + } + return ""; + }, [startYear, endYear]); + + return ( + + {item?.Name} + {yearString} + + + + + {item?.Overview} + + ); +}; diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx new file mode 100644 index 00000000..ad4f5af7 --- /dev/null +++ b/components/settings/Jellyseerr.tsx @@ -0,0 +1,207 @@ +import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; +import { View } from "react-native"; +import { Text } from "../common/Text"; +import { useCallback, useRef, useState } from "react"; +import { Input } from "../common/Input"; +import { ListItem } from "../ListItem"; +import { Loader } from "../Loader"; +import { useSettings } from "@/utils/atoms/settings"; +import { Button } from "../Button"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useAtom } from "jotai"; +import { toast } from "sonner-native"; +import { useMutation } from "@tanstack/react-query"; + +export const JellyseerrSettings = () => { + const { + jellyseerrApi, + jellyseerrUser, + setJellyseerrUser, + clearAllJellyseerData, + } = useJellyseerr(); + + const [user] = useAtom(userAtom); + const [settings, updateSettings] = useSettings(); + + const [promptForJellyseerrPass, setPromptForJellyseerrPass] = + useState(false); + + const [jellyseerrPassword, setJellyseerrPassword] = useState< + string | undefined + >(undefined); + + const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState< + string | undefined + >(settings?.jellyseerrServerUrl || undefined); + + const loginToJellyseerrMutation = useMutation({ + mutationFn: async () => { + if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) { + throw new Error("Missing required information for login"); + } + const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); + return jellyseerrTempApi.login(user.Name, jellyseerrPassword); + }, + onSuccess: (user) => { + setJellyseerrUser(user); + updateSettings({ jellyseerrServerUrl }); + }, + onError: () => { + toast.error("Failed to login"); + }, + onSettled: () => { + setJellyseerrPassword(undefined); + }, + }); + + const testJellyseerrServerUrlMutation = useMutation({ + mutationFn: async () => { + if (!jellyseerrServerUrl || jellyseerrApi) return null; + const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); + return jellyseerrTempApi.test(); + }, + onSuccess: (result) => { + if (result && result.isValid) { + if (result.requiresPass) { + setPromptForJellyseerrPass(true); + } else { + updateSettings({ jellyseerrServerUrl }); + } + } else { + setPromptForJellyseerrPass(false); + setjellyseerrServerUrl(undefined); + clearAllJellyseerData(); + } + }, + }); + + const clearData = () => { + clearAllJellyseerData().finally(() => { + setjellyseerrServerUrl(undefined); + setPromptForJellyseerrPass(false); + }); + }; + + return ( + + Jellyseerr + + {jellyseerrUser ? ( + + + + + + + + + + + ) : ( + + + This integration is in its early stages. Expect things to change. + + Server URL + + + Example: http(s)://your-host.url + + + (add port if required) + + + + + + + + Password + + + + + )} + + + ); +}; diff --git a/components/settings/MediaContext.tsx b/components/settings/MediaContext.tsx index c425b110..e0a81e6e 100644 --- a/components/settings/MediaContext.tsx +++ b/components/settings/MediaContext.tsx @@ -57,8 +57,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => { updateSettings(update); - console.log("update", update); - let updatePayload = { SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode, PlayDefaultAudioTrack: @@ -84,8 +82,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => { settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName || ""; - console.log("updatePayload", updatePayload); - updateUserConfiguration(updatePayload); }; diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 2f55edaf..a83f95f8 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -21,9 +21,8 @@ import * as BackgroundFetch from "expo-background-fetch"; import * as ScreenOrientation from "expo-screen-orientation"; import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; -import React, {useCallback, useEffect, useState} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { - Alert, Linking, Switch, TouchableOpacity, @@ -41,32 +40,23 @@ import { Stepper } from "@/components/inputs/Stepper"; import { MediaProvider } from "./MediaContext"; import { SubtitleToggles } from "./SubtitleToggles"; import { AudioToggles } from "./AudioToggles"; -import {JellyseerrApi, useJellyseerr} from "@/hooks/useJellyseerr"; -import {ListItem} from "@/components/ListItem"; +import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; +import { ListItem } from "@/components/ListItem"; +import { JellyseerrSettings } from "./Jellyseerr"; interface Props extends ViewProps {} export const SettingToggles: React.FC = ({ ...props }) => { const [settings, updateSettings] = useSettings(); const { setProcesses } = useDownload(); - const { - jellyseerrApi, - jellyseerrUser, - setJellyseerrUser , - clearAllJellyseerData - } = useJellyseerr(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [marlinUrl, setMarlinUrl] = useState(""); - const [jellyseerrPassword, setJellyseerrPassword] = useState(undefined); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = useState(settings?.optimizedVersionsServerUrl || ""); - const [jellyseerrServerUrl, setjellyseerrServerUrl] = - useState(settings?.jellyseerrServerUrl || undefined); - const queryClient = useQueryClient(); /******************** @@ -121,54 +111,6 @@ export const SettingToggles: React.FC = ({ ...props }) => { staleTime: 0, }); - const promptForJellyseerrLogin = useCallback(() => - Alert.prompt( - 'Enter jellyfin password', - `Enter password for jellyfin user ${user?.Name}`, - (input) => setJellyseerrPassword(input), - 'secure-text' - ), - [user, setJellyseerrPassword] - ); - - const testJellyseerrServerUrl = useCallback(async () => { - if (!jellyseerrServerUrl || jellyseerrApi) - return; - - const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); - - jellyseerrTempApi.test().then(result => { - if (result.isValid) { - - if (result.requiresPass) - promptForJellyseerrLogin() - else - updateSettings({jellyseerrServerUrl}) - } - else { - setjellyseerrServerUrl(undefined); - clearAllJellyseerData(); - } - }) - }, [jellyseerrServerUrl]) - - useEffect(() => { - if (jellyseerrServerUrl && user?.Name && jellyseerrPassword) { - const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); - jellyseerrTempApi.login(user?.Name, jellyseerrPassword) - .then(user => { - setJellyseerrUser(user); - updateSettings({jellyseerrServerUrl}) - }) - .catch(() => { - toast.error("Failed to login to jellyseerr!") - }) - .finally(() => { - setJellyseerrPassword(undefined); - }) - } - }, [user, jellyseerrServerUrl, jellyseerrPassword]); - if (!settings) return null; return ( @@ -695,66 +637,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { - - Jellyseerr - - {jellyseerrUser && <> - - - - - - - - } - - - - - - Server URL - - - - Set the URL for your jellyseerr instance. - - Example: http(s)://your-host.url - (add port if required) - This integration is in its early stages. Expect things to change. - - - - - - - + ); }; diff --git a/components/video-player/controls/AudioSlider.tsx b/components/video-player/controls/AudioSlider.tsx index 6d579e0a..65ab7b9f 100644 --- a/components/video-player/controls/AudioSlider.tsx +++ b/components/video-player/controls/AudioSlider.tsx @@ -20,7 +20,6 @@ const AudioSlider: React.FC = ({ setVisibility }) => { const fetchInitialVolume = async () => { try { const { volume: initialVolume } = await VolumeManager.getVolume(); - console.log("initialVolume", initialVolume); volume.value = initialVolume * 100; } catch (error) { console.error("Error fetching initial volume:", error); @@ -39,7 +38,6 @@ const AudioSlider: React.FC = ({ setVisibility }) => { const handleValueChange = async (value: number) => { volume.value = value; - console.log("volume through slider", value); await VolumeManager.setVolume(value / 100); // Re-call showNativeVolumeUI to ensure the setting is applied on iOS @@ -48,7 +46,6 @@ const AudioSlider: React.FC = ({ setVisibility }) => { useEffect(() => { const volumeListener = VolumeManager.addVolumeListener((result) => { - console.log("Volume through device", result.volume); volume.value = result.volume * 100; setVisibility(true); diff --git a/components/video-player/controls/BrightnessSlider.tsx b/components/video-player/controls/BrightnessSlider.tsx index 3cbbd460..f7b0f392 100644 --- a/components/video-player/controls/BrightnessSlider.tsx +++ b/components/video-player/controls/BrightnessSlider.tsx @@ -14,7 +14,6 @@ const BrightnessSlider = () => { useEffect(() => { const fetchInitialBrightness = async () => { const initialBrightness = await Brightness.getBrightnessAsync(); - console.log("initialBrightness", initialBrightness); brightness.value = initialBrightness * 100; }; fetchInitialBrightness(); diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 65206d8f..d7386134 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -240,8 +240,6 @@ export const Controls: React.FC = ({ ? maxValue - currentProgress : ticksToSeconds(maxValue - currentProgress); - console.log("remaining: ", remaining); - setCurrentTime(current); setRemainingTime(remaining); }, @@ -349,7 +347,6 @@ export const Controls: React.FC = ({ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); try { const curr = progress.value; - console.log(curr); if (curr !== undefined) { const newTime = isVlc ? curr + secondsToMs(settings.forwardSkipTime) @@ -375,8 +372,6 @@ export const Controls: React.FC = ({ const tileWidth = 150; const tileHeight = 150 / trickplayInfo.aspectRatio!; - console.log("time, ", time); - return ( = ({ }, (finished) => { if (finished && onFinish) { - console.log("finish"); runOnJS(onFinish)(); } } diff --git a/components/video-player/controls/dropdown/DropdownViewDirect.tsx b/components/video-player/controls/dropdown/DropdownViewDirect.tsx index 238e9775..28b55fa0 100644 --- a/components/video-player/controls/dropdown/DropdownViewDirect.tsx +++ b/components/video-player/controls/dropdown/DropdownViewDirect.tsx @@ -106,19 +106,12 @@ const DropdownViewDirect: React.FC = ({ if ("deliveryUrl" in sub && sub.deliveryUrl) { setSubtitleURL && setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name); - - console.log( - "Set external subtitle: ", - api?.basePath + sub.deliveryUrl - ); } else { - console.log("Set sub index: ", sub.index); setSubtitleTrack && setSubtitleTrack(sub.index); } router.setParams({ subtitleIndex: sub.index.toString(), }); - console.log("Subtitle: ", sub); }} > diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx index 7bbe7e82..8739b07a 100644 --- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx +++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx @@ -66,7 +66,6 @@ const DropdownView: React.FC = ({ showControls }) => { const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles); - console.log("sortedSubtitles", sortedSubtitles); return [disableSubtitle, ...sortedSubtitles]; } @@ -104,7 +103,6 @@ const DropdownView: React.FC = ({ showControls }) => { const ChangeTranscodingAudio = useCallback( (audioIndex: number) => { - console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex); const queryParams = new URLSearchParams({ itemId: item.Id ?? "", // Ensure itemId is a string audioIndex: audioIndex?.toString() ?? "", @@ -167,7 +165,6 @@ const DropdownView: React.FC = ({ showControls }) => { } key={`subtitle-item-${idx}`} onValueChange={() => { - console.log("sub", sub); if ( subtitleIndex === (isOnTextSubtitle && sub.IsTextSubtitleStream @@ -216,7 +213,6 @@ const DropdownView: React.FC = ({ showControls }) => { value={audioIndex === track.index.toString()} onValueChange={() => { if (audioIndex === track.index.toString()) return; - console.log("Setting audio track to: ", track.index); router.setParams({ audioIndex: track.index.toString(), }); diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index e6d28167..15aaff05 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -76,7 +76,6 @@ export const useIntroSkipper = ( }, [introTimestamps, currentTime]); const skipIntro = useCallback(() => { - console.log("skipIntro"); if (!introTimestamps) return; try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index e024720c..c0a8af22 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -1,45 +1,54 @@ -import axios, {AxiosError, AxiosInstance} from "axios"; -import {Results} from "@/utils/jellyseerr/server/models/Search"; +import axios, { AxiosError, AxiosInstance } from "axios"; +import { Results } from "@/utils/jellyseerr/server/models/Search"; import { storage } from "@/utils/mmkv"; -import {inRange} from "lodash"; -import {User as JellyseerrUser} from "@/utils/jellyseerr/server/entity/User"; -import {atom} from "jotai"; -import {useAtom} from "jotai/index"; +import { inRange } from "lodash"; +import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User"; +import { atom } from "jotai"; +import { useAtom } from "jotai/index"; import "@/augmentations"; -import {useCallback, useMemo} from "react"; -import {useSettings} from "@/utils/atoms/settings"; -import {toast} from "sonner-native"; -import {MediaRequestStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; +import { useCallback, useMemo } from "react"; +import { useSettings } from "@/utils/atoms/settings"; +import { toast } from "sonner-native"; +import { + MediaRequestStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; -import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; -import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; -import {SeasonWithEpisodes, TvDetails} from "@/utils/jellyseerr/server/models/Tv"; -import {IssueStatus, IssueType} from "@/utils/jellyseerr/server/constants/issue"; +import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; +import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; +import { + SeasonWithEpisodes, + TvDetails, +} from "@/utils/jellyseerr/server/models/Tv"; +import { + IssueStatus, + IssueType, +} from "@/utils/jellyseerr/server/constants/issue"; import Issue from "@/utils/jellyseerr/server/entity/Issue"; -import {RTRating} from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; -import {writeErrorLog} from "@/utils/log"; +import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; +import { writeErrorLog } from "@/utils/log"; import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; interface SearchParams { - query: string, - page: number, + query: string; + page: number; language: string; } interface SearchResults { - page: number, - totalPages: number, + page: number; + totalPages: number; totalResults: number; results: Results[]; } -const JELLYSEERR_USER = "JELLYSEERR_USER" -const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES" +const JELLYSEERR_USER = "JELLYSEERR_USER"; +const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES"; export const clearJellyseerrStorageData = () => { storage.delete(JELLYSEERR_USER); storage.delete(JELLYSEERR_COOKIES); -} +}; export enum Endpoints { STATUS = "/status", @@ -58,24 +67,29 @@ export enum Endpoints { AUTH_JELLYFIN = "/auth/jellyfin", } -export type DiscoverEndpoint = Endpoints.DISCOVER_TRENDING | Endpoints.DISCOVER_MOVIES | Endpoints.DISCOVER_TV; +export type DiscoverEndpoint = + | Endpoints.DISCOVER_TRENDING + | Endpoints.DISCOVER_MOVIES + | Endpoints.DISCOVER_TV; -export type TestResult = { - isValid: true; - requiresPass: boolean; -} | { - isValid: false; -}; +export type TestResult = + | { + isValid: true; + requiresPass: boolean; + } + | { + isValid: false; + }; export class JellyseerrApi { axios: AxiosInstance; - constructor (baseUrl: string) { + constructor(baseUrl: string) { this.axios = axios.create({ baseURL: baseUrl, withCredentials: true, withXSRFToken: true, - xsrfHeaderName: "XSRF-TOKEN" + xsrfHeaderName: "XSRF-TOKEN", }); this.setInterceptors(); @@ -86,132 +100,169 @@ export class JellyseerrApi { const cookies = storage.get(JELLYSEERR_COOKIES); if (user && cookies) { - console.log("User & cookies data exist for jellyseerr") return Promise.resolve({ isValid: true, - requiresPass: false + requiresPass: false, }); } - console.log("Testing jellyseerr connection") - return await this.axios.get(Endpoints.API_V1 + Endpoints.STATUS) + return await this.axios + .get(Endpoints.API_V1 + Endpoints.STATUS) .then((response) => { - const {status, headers, data} = response; + const { status, headers, data } = response; if (inRange(status, 200, 299)) { if (data.version < "2.0.0") { - const error = "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0"; + const error = + "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0"; toast.error(error); throw Error(error); } - console.log("Jellyseerr connecting successfully tested!"); - storage.setAny(JELLYSEERR_COOKIES, headers["set-cookie"]?.flatMap(c => c.split("; ")) ?? []); + storage.setAny( + JELLYSEERR_COOKIES, + headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [] + ); return { isValid: true, - requiresPass: true + requiresPass: true, }; } toast.error(`Jellyseerr test failed. Please try again.`); writeErrorLog( `Jellyseerr returned a ${status} for url:\n` + - response.config.url + '\n' + - JSON.stringify(response.data) + response.config.url + + "\n" + + JSON.stringify(response.data) ); return { isValid: false, - requiresPass: false + requiresPass: false, }; }) .catch((e) => { const msg = "Failed to test jellyseerr server url"; - toast.error(msg) - console.error(msg, e) + toast.error(msg); + console.error(msg, e); return { isValid: false, - requiresPass: false + requiresPass: false, }; - }) + }); } async login(username: string, password: string): Promise { - return this.axios?.post(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, { - username, - password, - email: username - }).then(response => { - const user = response?.data; - if (!user) - throw Error("Login failed") - storage.setAny(JELLYSEERR_USER, user); - return user - }) + return this.axios + ?.post(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, { + username, + password, + email: username, + }) + .then((response) => { + const user = response?.data; + if (!user) throw Error("Login failed"); + storage.setAny(JELLYSEERR_USER, user); + return user; + }); } async discoverSettings(): Promise { - return this.axios?.get(Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER) - .then(({data}) => data) + return this.axios + ?.get( + Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER + ) + .then(({ data }) => data); } - async discover(endpoint: DiscoverEndpoint, params: any): Promise { - return this.axios?.get(Endpoints.API_V1 + endpoint, { params }) - .then(({data}) => data) + async discover( + endpoint: DiscoverEndpoint, + params: any + ): Promise { + return this.axios + ?.get(Endpoints.API_V1 + endpoint, { params }) + .then(({ data }) => data); } async search(params: SearchParams): Promise { - const response = await this.axios?.get(Endpoints.API_V1 + Endpoints.SEARCH, {params}) - return response?.data + const response = await this.axios?.get( + Endpoints.API_V1 + Endpoints.SEARCH, + { params } + ); + return response?.data; } async request(request: MediaRequestBody): Promise { - return this.axios?.post(Endpoints.API_V1 + Endpoints.REQUEST, request) - .then(({data}) => data) + return this.axios + ?.post(Endpoints.API_V1 + Endpoints.REQUEST, request) + .then(({ data }) => data); } async movieDetails(id: number) { - return this.axios?.get(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`).then(response => { - return response?.data - }) + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`) + .then((response) => { + return response?.data; + }); } - async movieRatings(id: number) { - return this.axios?.get(`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`) - .then(({data}) => data) + return this.axios + ?.get( + `${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}` + ) + .then(({ data }) => data); } async tvDetails(id: number) { - return this.axios?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}`).then(response => { - return response?.data - }) + return this.axios + ?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}`) + .then((response) => { + return response?.data; + }); } async tvRatings(id: number) { - return this.axios?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`) - .then(({data}) => data) + return this.axios + ?.get( + `${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}` + ) + .then(({ data }) => data); } async tvSeason(id: number, seasonId: number) { - return this.axios?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`).then(response => { - console.log(response.data.episodes) - return response?.data - }) + return this.axios + ?.get( + `${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}` + ) + .then((response) => { + return response?.data; + }); } 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() + return ( + this.axios.defaults.baseURL + + `/_next/image?` + + new URLSearchParams( + `url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}` + ).toString() + ); } async submitIssue(mediaId: number, issueType: IssueType, message: string) { - return this.axios?.post(Endpoints.API_V1 + Endpoints.ISSUE, { - mediaId, issueType, message - }).then((response) => { - const issue = response.data + return this.axios + ?.post(Endpoints.API_V1 + Endpoints.ISSUE, { + mediaId, + issueType, + message, + }) + .then((response) => { + const issue = response.data; - if (issue.status === IssueStatus.OPEN) { - toast.success("Issue submitted!") - } - return issue - }) + if (issue.status === IssueStatus.OPEN) { + toast.success("Issue submitted!"); + } + return issue; + }); } private setInterceptors() { @@ -219,7 +270,10 @@ export class JellyseerrApi { async (response) => { const cookies = response.headers["set-cookie"]; if (cookies) { - storage.setAny(JELLYSEERR_COOKIES, response.headers["set-cookie"]?.flatMap(c => c.split("; "))); + storage.setAny( + JELLYSEERR_COOKIES, + response.headers["set-cookie"]?.flatMap((c) => c.split("; ")) + ); } return response; }, @@ -227,16 +281,17 @@ export class JellyseerrApi { const errorMsg = "Jellyseerr response error"; console.error(errorMsg, error, error.response?.data); writeErrorLog( - errorMsg + `\n` + - `error: ${error.toString()}\n` + - `url: ${error?.config?.url}\n` + - `data:\n` + - JSON.stringify(error.response?.data) + errorMsg + + `\n` + + `error: ${error.toString()}\n` + + `url: ${error?.config?.url}\n` + + `data:\n` + + JSON.stringify(error.response?.data) ); if (error.status === 403) { - clearJellyseerrStorageData() + clearJellyseerrStorageData(); } - return Promise.reject(error) + return Promise.reject(error); } ); @@ -246,22 +301,22 @@ export class JellyseerrApi { if (cookies) { const headerName = this.axios.defaults.xsrfHeaderName!!; const xsrfToken = cookies - .find(c => c.includes(headerName)) - ?.split(headerName + "=")?.[1] + .find((c) => c.includes(headerName)) + ?.split(headerName + "=")?.[1]; if (xsrfToken) { config.headers[headerName] = xsrfToken; } } - return config + return config; }, (error) => { - console.error("Jellyseerr request error", error) + console.error("Jellyseerr request error", error); } ); } } -const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)) +const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)); export const useJellyseerr = () => { const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); @@ -270,43 +325,47 @@ export const useJellyseerr = () => { const jellyseerrApi = useMemo(() => { const cookies = storage.get(JELLYSEERR_COOKIES); if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) { - return new JellyseerrApi(settings?.jellyseerrServerUrl) + return new JellyseerrApi(settings?.jellyseerrServerUrl); } - return undefined - }, [settings?.jellyseerrServerUrl, jellyseerrUser]) + return undefined; + }, [settings?.jellyseerrServerUrl, jellyseerrUser]); const clearAllJellyseerData = useCallback(async () => { - clearJellyseerrStorageData() + clearJellyseerrStorageData(); setJellyseerrUser(undefined); - updateSettings({jellyseerrServerUrl: undefined}) + updateSettings({ jellyseerrServerUrl: undefined }); }, []); - const requestMedia = useCallback(( - title: string, - request: MediaRequestBody, - ) => { - jellyseerrApi?.request?.(request)?.then((mediaRequest) => { - switch (mediaRequest.status) { - case MediaRequestStatus.PENDING: - case MediaRequestStatus.APPROVED: - toast.success(`Requested ${title}!`) - break; - case MediaRequestStatus.DECLINED: - toast.error(`You don't have permission to request!`) - break; - case MediaRequestStatus.FAILED: - toast.error(`Something went wrong requesting media!`) - break; - } - }) - }, [jellyseerrApi]) + const requestMedia = useCallback( + (title: string, request: MediaRequestBody) => { + jellyseerrApi?.request?.(request)?.then((mediaRequest) => { + switch (mediaRequest.status) { + case MediaRequestStatus.PENDING: + case MediaRequestStatus.APPROVED: + toast.success(`Requested ${title}!`); + break; + case MediaRequestStatus.DECLINED: + toast.error(`You don't have permission to request!`); + break; + case MediaRequestStatus.FAILED: + toast.error(`Something went wrong requesting media!`); + break; + } + }); + }, + [jellyseerrApi] + ); - const isJellyseerrResult = (items: any[] | null | undefined): items is Results[] => { + const isJellyseerrResult = ( + items: any[] | null | undefined + ): items is Results[] => { return ( !items || - items.length >= 0 && Object.hasOwn(items[0], "mediaType") && Object.values(MediaType).includes(items[0]['mediaType']) - ) - } + (items.length >= 0 && + Object.hasOwn(items[0], "mediaType") && + Object.values(MediaType).includes(items[0]["mediaType"])) + ); + }; return { jellyseerrApi, @@ -314,6 +373,6 @@ export const useJellyseerr = () => { setJellyseerrUser, clearAllJellyseerData, isJellyseerrResult, - requestMedia - } + requestMedia, + }; }; diff --git a/package.json b/package.json index 7d7236d5..6b9a6e1d 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,8 @@ "react-native-video": "^6.7.0", "react-native-volume-manager": "^1.10.0", "react-native-web": "~0.19.13", + "react-native-webview": "^13.12.5", + "react-native-youtube-iframe": "^2.3.0", "sonner-native": "^0.14.2", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4",