diff --git a/README.md b/README.md index 6b3bcf99..87a6420c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp - + ## 🌟 Features diff --git a/app.json b/app.json index 8a0f4f54..d0023760 100644 --- a/app.json +++ b/app.json @@ -111,7 +111,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 3509be51..6a1d95ca 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -77,6 +77,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 0f777a45..95cfd856 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"; @@ -116,7 +116,7 @@ export default function index() { }, []); const { - data: userViews, + data, isError: e1, isLoading: l1, } = useQuery({ @@ -136,6 +136,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 8f6d102a..38a0d34a 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -13,20 +13,22 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { UserInfo } from "@/components/settings/UserInfo"; import { useJellyfin } from "@/providers/JellyfinProvider"; import { clearLogs } from "@/utils/log"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import { useNavigation, useRouter } from "expo-router"; 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(); const insets = useSafeAreaInsets(); const { logout } = useJellyfin(); + const successHapticFeedback = useHaptic("success"); const onClearLogsClicked = async () => { clearLogs(); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + successHapticFeedback(); }; const navigation = useNavigation(); @@ -66,6 +68,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/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx new file mode 100644 index 00000000..5a930982 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx @@ -0,0 +1,247 @@ +import { + router, + useLocalSearchParams, + useNavigation, + useSegments, +} from "expo-router"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { TouchableOpacity, View } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { Text } from "@/components/common/Text"; +import { Animated } from "react-native"; +import { Image } from "expo-image"; +import { OverviewText } from "@/components/OverviewText"; +import { orderBy } from "lodash"; +import { FlashList } from "@shopify/flash-list"; +import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; +import Poster from "@/components/posters/Poster"; +import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon"; + +const ANIMATION_ENTER = 250; +const ANIMATION_EXIT = 250; +const BACKDROP_DURATION = 5000; + +export default function page() { + const insets = useSafeAreaInsets(); + const local = useLocalSearchParams(); + const segments = useSegments(); + const { jellyseerrApi, jellyseerrUser } = useJellyseerr(); + + const { personId } = local as { personId: string }; + const from = segments[2]; + + const [currentIndex, setCurrentIndex] = useState(0); + const fadeAnim = useRef(new Animated.Value(0)).current; + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ["jellyseerr", "person", personId], + queryFn: async () => ({ + details: await jellyseerrApi?.personDetails(personId), + combinedCredits: await jellyseerrApi?.personCombinedCredits(personId), + }), + enabled: !!jellyseerrApi && !!personId, + }); + + const locale = useMemo(() => { + return jellyseerrUser?.settings?.locale || "en"; + }, [jellyseerrUser]); + + const region = useMemo( + () => jellyseerrUser?.settings?.region || "US", + [jellyseerrUser] + ); + + const castedRoles: PersonCreditCast[] = useMemo( + () => + orderBy( + data?.combinedCredits?.cast, + ["voteCount", "voteAverage"], + "desc" + ), + [data?.combinedCredits] + ); + + const backdrops = useMemo( + () => castedRoles.map((c) => c.backdropPath), + [data?.combinedCredits] + ); + + const enterAnimation = useCallback( + () => + Animated.timing(fadeAnim, { + toValue: 1, + duration: ANIMATION_ENTER, + useNativeDriver: true, + }), + [fadeAnim] + ); + + const exitAnimation = useCallback( + () => + Animated.timing(fadeAnim, { + toValue: 0, + duration: ANIMATION_EXIT, + useNativeDriver: true, + }), + [fadeAnim] + ); + + useEffect(() => { + if (backdrops?.length) { + enterAnimation().start(); + const intervalId = setInterval(() => { + exitAnimation().start((end) => { + if (end.finished) + setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length); + }); + }, BACKDROP_DURATION); + + return () => clearInterval(intervalId); + } + }, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]); + + const viewDetails = (credit: PersonCreditCast) => { + router.push({ + //@ts-ignore + pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, + //@ts-ignore + params: { + ...credit, + mediaTitle: credit.title, + releaseYear: new Date(credit.releaseDate).getFullYear(), + canRequest: "false", + posterSrc: jellyseerrApi?.imageProxy( + credit.posterPath, + "w300_and_h450_face" + ), + }, + }); + }; + + return ( + + + } + logo={ + + } + > + + + + + {data?.details?.name} + + + Born{" "} + {new Date(data?.details?.birthday!!).toLocaleDateString( + `${locale}-${region}`, + { + year: "numeric", + month: "long", + day: "numeric", + } + )}{" "} + | {data?.details?.placeOfBirth} + + + + + + + + + No results + + + } + contentInsetAdjustmentBehavior="automatic" + ListHeaderComponent={ + Appearances + } + renderItem={({ item }) => ( + viewDetails(item)} + > + + + {/*{item.title}*/} + {item.character && ( + + as {item.character} + + )} + + )} + keyExtractor={(item) => item.id.toString()} + estimatedItemSize={255} + numColumns={3} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => } + /> + + + + + ); +} 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 42edcb59..b839708d 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -1,22 +1,33 @@ -import React, { useCallback, useRef, useState } from "react"; -import { useLocalSearchParams } from "expo-router"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; import { Text } from "@/components/common/Text"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { Image } from "expo-image"; -import { TouchableOpacity, View} from "react-native"; +import { TouchableOpacity, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { OverviewText } from "@/components/OverviewText"; import { GenreTags } from "@/components/GenreTags"; -import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; import { useQuery } from "@tanstack/react-query"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { Button } from "@/components/Button"; import { BottomSheetBackdrop, BottomSheetBackdropProps, - BottomSheetModal, BottomSheetTextInput, + BottomSheetModal, + BottomSheetTextInput, BottomSheetView, } from "@gorhom/bottom-sheet"; import { @@ -27,24 +38,24 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import { JellyserrRatings } from "@/components/Ratings"; +import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; +import DetailFacts from "@/components/jellyseerr/DetailFacts"; +import { ItemActions } from "@/components/series/SeriesActions"; +import Cast from "@/components/jellyseerr/Cast"; +import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; const Page: React.FC = () => { const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); - const { - mediaTitle, - releaseYear, - canRequest: canRequestString, - posterSrc, - ...result - } = params as unknown as { - mediaTitle: string; - releaseYear: number; - canRequest: string; - posterSrc: string; - } & Partial; + const { mediaTitle, releaseYear, posterSrc, ...result } = + params as unknown as { + mediaTitle: string; + releaseYear: number; + canRequest: string; + posterSrc: string; + } & Partial; - const canRequest = canRequestString === "true"; + const navigation = useNavigation(); const { jellyseerrApi, requestMedia } = useJellyseerr(); const [issueType, setIssueType] = useState(); @@ -55,7 +66,7 @@ const Page: React.FC = () => { data: details, isFetching, isLoading, - refetch + refetch, } = useQuery({ enabled: !!jellyseerrApi && !!result && !!result.id, queryKey: ["jellyseerr", "detail", result.mediaType, result.id], @@ -72,6 +83,8 @@ const Page: React.FC = () => { }, }); + const canRequest = useJellyseerrCanRequest(details); + const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( { } }, [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" + ), }} /> ) : ( @@ -182,7 +209,9 @@ const Page: React.FC = () => { g.name) || []} /> - {canRequest ? ( + {isLoading || isFetching ? ( + + ) : canRequest ? ( @@ -213,6 +242,11 @@ const Page: React.FC = () => { refetch={refetch} /> )} + + @@ -279,13 +313,11 @@ const Page: React.FC = () => { - + { - if (!api || !user?.Id) { - return null; - } - - const response = await getUserViewsApi(api).getUserViews({ - userId: user.Id, + const response = await getUserViewsApi(api!).getUserViews({ + userId: user?.Id, }); return response.data.Items || null; }, - enabled: !!api && !!user?.Id, - staleTime: 60 * 1000 * 60, + staleTime: 60, }); + const libraries = useMemo( + () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), + [data, settings?.hiddenLibraries] + ); + useEffect(() => { for (const item of data || []) { queryClient.prefetchQuery({ @@ -63,7 +63,7 @@ export default function index() { ); - if (!data) + if (!libraries) return ( No libraries found @@ -81,7 +81,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 12cbad20..1119e2a4 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -36,6 +36,7 @@ export default function SearchLayout() { }} /> + ); } diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 7d9ecebe..1ae0059c 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -31,12 +31,18 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useDebounce } from "use-debounce"; import { useJellyseerr } from "@/hooks/useJellyseerr"; -import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { + MovieResult, + PersonResult, + TvResult, +} from "@/utils/jellyseerr/server/models/Search"; import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import { Tag } from "@/components/GenreTags"; import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide"; import { sortBy } from "lodash"; +import PersonPoster from "@/components/jellyseerr/PersonPoster"; +import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery"; type SearchType = "Library" | "Discover"; @@ -149,8 +155,8 @@ export default function search() { enabled: searchType === "Library" && debouncedSearch.length > 0, }); - const { data: jellyseerrResults, isFetching: j1 } = useQuery({ - queryKey: ["search", "jellyseerrResults", debouncedSearch], + const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({ + queryKey: ["search", "jellyseerr", "results", debouncedSearch], queryFn: async () => { const response = await jellyseerrApi?.search({ query: new URLSearchParams(debouncedSearch).toString(), @@ -166,14 +172,15 @@ export default function search() { debouncedSearch.length > 0, }); - const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({ - queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch], - queryFn: async () => jellyseerrApi?.discoverSettings(), - enabled: - !!jellyseerrApi && - searchType === "Discover" && - debouncedSearch.length == 0, - }); + const { data: jellyseerrDiscoverSettings, isFetching: j2 } = + useReactNavigationQuery({ + queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: + !!jellyseerrApi && + searchType === "Discover" && + debouncedSearch.length == 0, + }); const jellyseerrMovieResults: MovieResult[] | undefined = useMemo( () => @@ -191,6 +198,14 @@ export default function search() { [jellyseerrResults] ); + const jellyseerrPersonResults: PersonResult[] | undefined = useMemo( + () => + jellyseerrResults?.filter( + (r) => r.mediaType === "person" + ) as PersonResult[], + [jellyseerrResults] + ); + const { data: series, isFetching: l2 } = useQuery({ queryKey: ["search", "series", debouncedSearch], queryFn: () => @@ -300,7 +315,7 @@ export default function search() { paddingRight: insets.right, }} > - + {Platform.OS === "android" && ( )} /> + ( + + )} + /> )} diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 47e5bfaa..be8f24b0 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { useCallback, useRef } from "react"; import { Platform } from "react-native"; -import { withLayoutContext } from "expo-router"; +import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; import { createNativeBottomTabNavigator, @@ -13,12 +13,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, @@ -29,6 +30,23 @@ export const NativeTabs = withLayoutContext< export default function TabLayout() { const [settings] = useSettings(); + const router = useRouter(); + + useFocusEffect( + useCallback(() => { + const hasShownIntro = storage.getBoolean("hasShownIntro"); + if (!hasShownIntro) { + const timer = setTimeout(() => { + router.push("/intro/page"); + }, 1000); + + return () => { + clearTimeout(timer); + }; + } + }, []) + ); + return ( <> - - {error} - - - - + ) : ( <> - + { Enter the URL to your Jellyfin server { textContentType="URL" maxLength={500} /> - - Make sure to include http or https - - { - 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/2lzaWduYXR1cmV4GXNlbGYjanVtYmY9YzJwYS5zaWduYXR1cmVqYXNzZXJ0aW9uc4KiY3VybHgnc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5hY3Rpb25zZGhhc2hYIEppwb3/qN5BMHi+JO3M+DE6wdFklTRWcaANawazN9SvomN1cmx4KXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaGFzaC5kYXRhZGhhc2hYINldUhaCxi4Jgpd/7+NsOOho+1iZ9chabhSccExPzJS9Y2FsZ2ZzaGEyNTYAAChAanVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAACgQY2JvctKEWQzCogE4JBghglkGEDCCBgwwggP0oAMCAQICEH/ydB/Rxt5DtZR6jmVwnp4wDQYJKoZIhvcNAQELBQAwdTELMAkGA1UEBhMCVVMxIzAhBgNVBAoTGkFkb2JlIFN5c3RlbXMgSW5jb3Jwb3JhdGVkMR0wGwYDVQQLExRBZG9iZSBUcnVzdCBTZXJ2aWNlczEiMCAGA1UEAxMZQWRvYmUgUHJvZHVjdCBTZXJ2aWNlcyBHMzAeFw0yNDAxMTEwMDAwMDBaFw0yNTAxMTAyMzU5NTlaMH8xETAPBgNVBAMMCGNhaS1wcm9kMRMwEQYDVQQKDApBZG9iZSBJbmMuMREwDwYDVQQHDAhTYW4gSm9zZTETMBEGA1UECAwKQ2FsaWZvcm5pYTELMAkGA1UEBhMCVVMxIDAeBgkqhkiG9w0BCQEWEWNhaS1vcHNAYWRvYmUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA79MAp32GPZZBw7MpK0xuxWJZ2BwXMrmpbg+bvVC487/hbE1ji4PDYa8/UU8SPRHgW7t1pu3+L6j7EGH8ZBKdMCGug1ZhDmYWwHkX24cm1kPw+Fr73JOJhGUfkGZk6SJ+x1+tYG7TBR5SVMZGAXLSKALfUwQBW8/XeSINlhtG7B9/W+v/FEl5yCJOBQenbQUU9cXhMEg7cDndWAaV1zQSZkVh1zSWWfOaH9rQU3rIP5DL06ziScWA2fe1ONesHL21aJpXnrPjV1GN/2QeMR/jbGYpbO5tWy9r9oUpx4i6KmXlCpJWx1Jk+GaY62QnbbiLFpuY9jz1yq+xylLgm2UlwQIDAQAFo4IBjDCCAYgwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwHgYDVR0lBBcwFQYJKoZIhvcvAQEMBggrBgEFBQcDBDCBjgYDVR0gBIGGMIGDMIGABgkqhkiG9y8BAgMwczBxBggrBgEFBQcCAjBlDGNZb3UgYXJlIG5vdCBwZXJtaXR0ZWQgdG8gdXNlIHRoaXMgTGljZW5zZSBDZXJ0aWZpY2F0ZSBleGNlcHQgYXMgcGVybWl0dGVkIGJ5IHRoZSBsaWNlbnNlIGFncmVlbWVudC4wXQYDVR0fBFYwVDBSoFCgToZMaHR0cDovL3BraS1jcmwuc3ltYXV0aC5jb20vY2FfN2E1YzNhMGM3MzExNzQwNmFkZDE5MzEyYmMxYmMyM2YvTGF0ZXN0Q1JMLmNybDA3BggrBgEFBQcBAQQrMCkwJwYIKwYBBQUHMAGGG2h0dHA6Ly9wa2ktb2NzcC5zeW1hdXRoLmNvbTAfBgNVHSMEGDAWgBRXKXoyTcz+5DVOwB8kc85zU6vfajANBgkqhkiG9w0BAQsFAAOCAgEAIWPV/Nti76MPfipUnZACP/eVrEv59WObHuWCZHj1By8bGm5UmjTgPQYlXyTj8XE/iY27phgrHg0piDsWDzu5s8B6TKkaMmUvgtk+UgukybbfdtBC6KvtGgy40cO4DkEUoPDitDxT1igbQqdKogAoVKqDEVqnF+CFQQztbGcZhFI9XKTsCQwf9hw7LhJCo6jANBIABNyQtSwWIpPeSEJhPVgWLyKepgQxJMqL6sgYZxGq9pCSQn2gS8pafyQFLByZwEBD/DxytRZZL6b3ZXqF+fZZsE9fsBxpcWFiv8pFvgBQOtCzlSbfG8o7bgBPJXm7mAA8j3t3hDEeEx0Gx8B/9a89pzTebWVrD3SEe0uZl9EbVC++F4EosRJFdYwzuP1iJO1d5I3VxGa9FrVq/FYBGORvvDaTwandizCwae43ozCI97QPEUtS+jJztz1kapHcBsLAh7LxnE82rlmq1o4vfdFsQUz7HEpOkPFkyKohyPTn1FIq4lkJKX3jBA6Na/sxyUZo9uvs4CA+0AeNcTXldyugRUF+mspdbMLiIduigdDLu+LJ3UcxvvLTE3374waDvUD1vzrXVsmJrCxk9CnI/RGmiINSZoDbUQcKPX/PXmCUmMHp0PhnXaanZwSI5Ot0Pit4AnZaU7PvrSQmew1/cp3ZmJcfeB4FGRT3DYprp+lZBqUwggahMIIEiaADAgECAhAMqLZUe4nm0gaJdc2Lm4niMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNVBAYTAlVTMSMwIQYDVQQKExpBZG9iZSBTeXN0ZW1zIEluY29ycG9yYXRlZDEdMBsGA1UECxMUQWRvYmUgVHJ1c3QgU2VydmljZXMxGTAXBgNVBAMTEEFkb2JlIFJvb3QgQ0EgRzIwHhcNMTYxMTI5MDAwMDAwWhcNNDExMTI4MjM1OTU5WjB1MQswCQYDVQQGEwJVUzEjMCEGA1UEChMaQWRvYmUgU3lzdGVtcyBJbmNvcnBvcmF0ZWQxHTAbBgNVBAsTFEFkb2JlIFRydXN0IFNlcnZpY2VzMSIwIAYDVQQDExlBZG9iZSBQcm9kdWN0IFNlcnZpY2VzIEczMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtx8uvb0Js1xIbP4Mg65sAepReCWkgD6Jp7GyiGTa9ol2gfn5HfOV/HiYjZiOz+TuHFU+DXNad86xEqgVeGVMlvIHGe/EHcKBxvEDXdlTXB5zIEkfl0/SGn7J6vTX8MNybfSi95eQDUOZ9fjCaq+PBFjS5ZfeNmzi/yR+MsA0jKKoWarSRCFFFBpUFQWfAgLyXOyxOnXQOQudjxNj6Wu0X0IB13+IH11WcKcWEWXM4j4jh6hLy29Cd3EoVG3oxcVenMF/EMgD2tXjx4NUbTNB1/g9+MR6Nw5Mhp5k/g3atNExAxhtugC+T3SDShSEJfs2quiiRUHtX3RhOcK1s1OJgT5s2s9xGy5/uxVpcAIaK2KiDJXW3xxN8nXPmk1NSVu/mxtfapr4TvSJbhrU7UA3qhQY9n4On2sbH1X1Tw+7LTek8KCA5ZDghOERPiIp/Jt893qov1bE5rJkagcVg0Wqjh89NhCaBA8VyRt3ovlGyCKdNV2UL3bn5vdFsTk7qqmp9makz1/SuVXYxIf6L6+8RXOatXWaPkmucuLE1TPOeP7S1N5JToFCs80l2D2EtxoQXGCR48K/cTUR5zV/fQ+hdIOzoo0nFn77Y8Ydd2k7/x9BE78pmoeMnw6VXYfXCuWEgj6p7jpbLoxQMoWMCVzlg72WVNhJFlSw4aD8fc6ezeECAwEAAaOCATQwggEwMBIGA1UdEwEB/wQIMAYBAf8CAQAwNQYDVR0fBC4wLDAqoCigJoYkaHR0cDovL2NybC5hZG9iZS5jb20vYWRvYmVyb290ZzIuY3JsMA4GA1UdDwEB/wQEAwIBBjAUBgNVHSUEDTALBgkqhkiG9y8BAQcwVwYDVR0gBFAwTjBMBgkqhkiG9y8BAgMwPzA9BggrBgEFBQcCARYxaHR0cHM6Ly93d3cuYWRvYmUuY29tL21pc2MvcGtpL3Byb2Rfc3ZjZV9jcHMuaHRtbDAkBgNVHREEHTAbpBkwFzEVMBMGA1UEAxMMU1lNQy00MDk2LTMzMB0GA1UdDgQWBBRXKXoyTcz+5DVOwB8kc85zU6vfajAfBgNVHSMEGDAWgBSmHOFtVCRMqI9Icr9uqYzV5Owx1DANBgkqhkiG9w0BAQsFAAOCAgEAcc7lB4ym3C3cyOA7ZV4AkoGV65UgJK+faThdyXzxuNqlTQBlOyXBGFyevlm33BsGO1mDJfozuyLyT2+7IVxWFvW5yYMV+5S1NeChMXIZnCzWNXnuiIQSdmPD82TEVCkneQpFET4NDwSxo8/ykfw6Hx8fhuKz0wjhjkWMXmK3dNZXIuYVcbynHLyJOzA+vWU3sH2T0jPtFp7FN39GZne4YG0aVMlnHhtHhxaXVCiv2RVoR4w1QtvKHQpzfPObR53Cl74iLStGVFKPwCLYRSpYRF7J6vVS/XxW4LzvN2b6VEKOcvJmN3LhpxFRl3YYzW+dwnwtbuHW6WJlmjffbLm1MxLFGlG95aCz31X8wzqYNsvb9+5AXcv8Ll69tLXmO1OtsY/3wILNUEp4VLZTE3wqm3n8hMnClZiiKyZCS7L4E0mClbx+BRSMH3eVo6jgve41/fK3FQM4QCNIkpGs7FjjLy+ptC+JyyWqcfvORrFV/GOgB5hD+G5ghJcIpeigD/lHsCRYsOa5sFdqREhwIWLmSWtNwfLZdJ3dkCc7yRpm3gal6qRfTkYpxTNxxKyvKbkaJDoxR9vtWrC3iNrQd9VvxC3TXtuzoHbqumeqgcAqefWF9u6snQ4Q9FkXzeuJArNuSvPIhgBjVtggH0w0vm/lmCQYiC/Y12GeCxfgYlL33buiZnNpZ1RzdKFpdHN0VG9rZW5zgaFjdmFsWQ41MIIOMTADAgEAMIIOKAYJKoZIhvcNAQcCoIIOGTCCDhUCAQMxDzANBglghkgBZQMEAgEFADCBggYLKoZIhvcNAQkQAQSgcwRxMG8CAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCAGrvDRboHNPkk5YkMOZNouE7RbAZbeV+ub1WJkA2xwMQIRALU2g1IN0avJA0iiHGfFgBsYDzIwMjQwNDA0MDY0MDAxWgIIfHSsvWnNmIigggu9MIIFBzCCAu+gAwIBAgIQBR6ekdcekQq75D1c7dDd2TANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIzMDkwODAwMDAwMFoXDTM0MTIwNzIzNTk1OVowWDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTAwLgYDVQQDEydEaWdpQ2VydCBBZG9iZSBBQVRMIFRpbWVzdGFtcCBSZXNwb25kZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARNLK5R+QP/tefzBZdWrDYfEPE7mzrBFX7tKpSaxdLJo7cC9SHh2fwAeyefbtU66YaNQQzfOZX02N9KzQbH0/pso4IBizCCAYcwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMB8GA1UdIwQYMBaAFLoW2W1NhS9zKXaaL3WMaiCPnshvMB0GA1UdDgQWBBSwNapWwyGpi87TuLyLFiVXne804TBaBgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRSU0E0MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3JsMIGQBggrBgEFBQcBAQSBgzCBgDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFgGCCsGAQUFBzAChkxodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRSU0E0MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQB4K4xCx4QQhFiUgskV+5bC9AvSyYG19a8lWMkjUcR5DEdi6guz0GUSYAzUfpCaKfD+b9gc6f4zK88OFOKWOq2L9yPB6RZSWuLgcFEyFIB1qYvF8XdSRBF/eDzjg4ux8knpF+tANOeQaMxW+xhlWsW9C63kE0V55K+oIDzVD1/RoftknDsZU3UEC4GW5HWL8aNwKenMva4mYo0cTmaojslksTFIYCsXis8KxVul23tGsDYTlF2cyMXOIsaSs1kiLaTyd9GYgUJ+PVNwA2E57IWzfWZEwNaR3/zaL9mVL73XZGfFGL8KPbwby0w755gAZ0TASml2ALN2Qr8PQpAzzlk3lCTBUQLZlMedqIWgN5w/GwielH6UNqRXznUocKW+hir9IPgYHHSBtixzydFH5q/l5qYGYKvxyIHtIY3AgA6Yw4Kts+AdC+MbQANTPDK1MdNocW+9dOJxSqjLr+cyU0Jd7IMKl1Mj/vcx0D/cv2eRcfwEFqzlwluenVez+HBQSZfMx6op5YZDkrWdZttvvR5avngtISdpZBdS7s0XSSW/+dS16DykZ6KRQ54Ol6aA+3husOGKQMffj9NCblKAbGEq3bLhYslskEBgQJ4yOvYIG0i3FvoScrbop2sWsFZSLSZEtnleWeF7MT4O3/NrkZHbTdIUx3iPdwjdzlnkXm5yuzCCBq4wggSWoAMCAQICEAc2N7ckVHzYR6z9KGYqXlswDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MB4XDTIyMDMyMzAwMDAwMFoXDTM3MDMyMjIzNTk1OVowYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMaGNQZJs8E9cklRVcclA8TykTepl1Gh1tKD0Z5Mom2gsMyD+Vr2EaFEFUJfpIjzaPp985yJC3+dH54PMx9QEwsmc5Zt+FeoAn39Q7SE2hHxc7Gz7iuAhIoiGN/r2j3EF3+rGSs+QtxnjupRPfDWVtTnKC3r07G1decfBmWNlCnT2exp39mQh0YAe9tEQYncfGpXevA3eZ9drMvohGS0UvJ2R/dhgxndX7RUCyFobjchu0CsX7LeSn3O9TkSZ+8OpWNs5KbFHc02DVzV5huowWR0QKfAcsW6Th+xtVhNef7Xj3OTrCw54qVI1vCwMROpVymWJy71h6aPTnYVVSZwmCZ/oBpHIEPjQ2OAe3VuJyWQmDo4EbP29p7mO1vsgd4iFNmCKseSv6De4z6ic/rnH1pslPJSlRErWHRAKKtzQ87fSqEcazjFKfPKqpZzQmiftkaznTqj1QPgv/CiPMpC3BhIfxQ0z9JMq++bPf4OuGQq+nUoJEHtQr8FnGZJUlD0UfM2SU2LINIsVzV5K6jzRWC8I41Y99xh3pP+OcD5sjClTNfpmEpYPtMDiP6zj9NeS3YSUZPJjAw7W4oiqMEmCPkUEBIDfV8ju2TjY+Cm4T72wnSyPx4JduyrXUZ14mCjWAkBKAAOhFTuzuldyF4wEr1GnrXTdrnSDmuZDNIztM2xAgMBAAGjggFdMIIBWTASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS6FtltTYUvcyl2mi91jGogj57IbzAfBgNVHSMEGDAWgBTs1+OC0nFdZEzfLmc/57qYrhwPTzAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwdwYIKwYBBQUHAQEEazBpMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQQYIKwYBBQUHMAKGNWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3J0MEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3JsMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAfVmOwJO2b5ipRCIBfmbW2CFC4bAYLhBNE88wU86/GPvHUF3iSyn7cIoNqilp/GnBzx0H6T5gyNgL5Vxb122H+oQgJTQxZ822EpZvxFBMYh0MCIKoFr2pVs8Vc40BIiXOlWk/R3f7cnQU1/+rT4osequFzUNf7WC2qk+RZp4snuCKrOX9jLxkJodskr2dfNBwCnzvqLx1T7pa96kQsl3p/yhUifDVinF2ZdrM8HKjI/rAJ4JErpknG6skHibBt94q6/aesXmZgaNWhqsKRcnfxI2g55j7+6adcq/Ex8HBanHZxhOACcS2n82HhyS7T6NJuXdmkfFynOlLAlKnN36TU6w7HQhJD5TNOXrd/yVjmScsPT9rp/Fmw0HNT7ZAmyEhQNC3EyTN3B14OuSereU0cZLXJmvkOHOrpgFPvT87eK1MrfvElXvtCl8zOYdBeHo46Zzh3SP9HSjTx/no8Zhf+yvYfvJGnXUsHicsJttvFXseGYs2uJPU5vIXmVnKcPA3v5gA3yAWTyf7YGcWoWa63VXAOimGsJigK+2VQbc61RWYMbRiCQ8KvYHZE/6/pNHzV9m8BPqC3jLfBInwAM1dwvnQI38AC+R2AibZ8GV2QqYphwlHK+Z/GqSFD/yYlvZVVCsfgPrA8g4r5db7qS9EFUrnEw4d2zc4GqEr9u3WfPwxggG3MIIBswIBATB3MGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAUenpHXHpEKu+Q9XO3Q3dkwDQYJYIZIAWUDBAIBBQCggdEwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNDA0MDQwNjQwMDFaMCsGCyqGSIb3DQEJEAIMMRwwGjAYMBYEFNkauTP+F63pgh6mE/WkOnFOPn59MC8GCSqGSIb3DQEJBDEiBCBVjhiwVbdRlWhcd+zekIXbDQeN4mcEm18w9lDC4G09szA3BgsqhkiG9w0BCRACLzEoMCYwJDAiBCCC2vGUlXs2hAJFj9UnAGn+YscUVvqeC4ar+CfoUyAn2TAKBggqhkjOPQQDAgRGMEQCIErHs7kfjvydI2pHBtbV05TM1+Wtuf0wRhu3n7PrudbHAiBd9DhbIe1KnCm8yxaPz4sqEsjzgGOCNujAxmd8Xq4FUWNwYWRZC+UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQEAcNiFxc4R79ozvFI3cymplwVvAWDIKFyiBFAYVnZ4u3HEcPLDTfIt9X7Nd1vyzbJIZpVE6NOicYEaRwt+uauSMcSPsX9PHUJgyWALEQ6RHudtr57nbNIgmioCefdyEtzGbCylEalKZNWNlzjT2rgZFB1shhJ3hhVHDBPaKX2KxL3C8utMK2iBREKaVCatCmw4JVECUjwN7Qn3V347tiBf5wbCt/a+q382311bbBSW57XWiNjoek/xXArl25l6pWZSkTcShpTPT7ynjoFFRwCewR5+xU+2LKETQ4wrV3n5nK6RayHlThKGkqv3GuPOMk8ogRGaHezj/nphLuUsoIjpNA== + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/jellyseerr.PNG b/assets/images/jellyseerr.PNG new file mode 100644 index 00000000..c72a8da1 Binary files /dev/null and b/assets/images/jellyseerr.PNG differ diff --git a/bun.lockb b/bun.lockb index fc89caa0..3a1947ce 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/Button.tsx b/components/Button.tsx index 1a73ad01..4f7e25c4 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -1,4 +1,4 @@ -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import { Text, TouchableOpacity, View } from "react-native"; import { Loader } from "./Loader"; @@ -37,12 +37,14 @@ 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"; } }, [color]); + const lightHapticFeedback = useHaptic("light"); + return ( > = ({ onPress={() => { if (!loading && !disabled && onPress) { onPress(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); } }} disabled={disabled || loading} {...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/PlayButton.tsx b/components/PlayButton.tsx index e5c5dd87..e432f2a8 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -32,7 +32,7 @@ import Animated, { import { Button } from "./Button"; import { SelectedOptions } from "./ItemContent"; import { chromecastProfile } from "@/utils/profiles/chromecast"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -64,6 +64,7 @@ export const PlayButton: React.FC = ({ const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); const [settings] = useSettings(); + const lightHapticFeedback = useHaptic("light"); const goToPlayer = useCallback( (q: string, bitrateValue: number | undefined) => { @@ -79,7 +80,7 @@ export const PlayButton: React.FC = ({ const onPress = useCallback(async () => { if (!item) return; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); const queryParams = new URLSearchParams({ itemId: item.Id!, diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx index 049c5ed0..5d2faf73 100644 --- a/components/RoundButton.tsx +++ b/components/RoundButton.tsx @@ -6,7 +6,7 @@ import { TouchableOpacity, TouchableOpacityProps, } from "react-native"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; interface Props extends TouchableOpacityProps { onPress?: () => void; @@ -29,10 +29,11 @@ export const RoundButton: React.FC> = ({ }) => { const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9"; const fillColorClass = fillColor === "primary" ? "bg-purple-600" : ""; + const lightHapticFeedback = useHaptic("light"); const handlePress = () => { if (hapticFeedback) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); } onPress?.(); }; diff --git a/components/common/Input.tsx b/components/common/Input.tsx index ba4ab45b..d82a3225 100644 --- a/components/common/Input.tsx +++ b/components/common/Input.tsx @@ -7,7 +7,7 @@ export function Input(props: TextInputProps) { return ( { - 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/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index e8387da5..53b3ecec 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -1,5 +1,5 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; import { @@ -26,6 +26,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => { const { deleteFile } = useDownload(); const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); + const successHapticFeedback = useHaptic("success"); const base64Image = useMemo(() => { return storage.getString(item.Id!); @@ -41,7 +42,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => { const handleDeleteFile = useCallback(() => { if (item.Id) { deleteFile(item.Id); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + successHapticFeedback(); } }, [deleteFile, item.Id]); diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 3073bd0a..bb61f3c8 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -3,7 +3,7 @@ import { useActionSheet, } from "@expo/react-native-action-sheet"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; @@ -28,6 +28,7 @@ export const MovieCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); + const successHapticFeedback = useHaptic("success"); const handleOpenFile = useCallback(() => { openFile(item); @@ -43,7 +44,7 @@ export const MovieCard: React.FC = ({ item }) => { const handleDeleteFile = useCallback(() => { if (item.Id) { deleteFile(item.Id); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + successHapticFeedback(); } }, [deleteFile, item.Id]); diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index a22c586f..00767621 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter"; import { Loader } from "../Loader"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { useRouter, useSegments } from "expo-router"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; interface Props extends ViewProps {} @@ -128,6 +128,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { const [api] = useAtom(apiAtom); const router = useRouter(); const screenWidth = Dimensions.get("screen").width; + const lightHapticFeedback = useHaptic("light"); const uri = useMemo(() => { if (!api) return null; @@ -153,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => { const handleRoute = useCallback(() => { if (!from) return; const url = itemRouter(item, from); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); // @ts-ignore if (url) router.push(url); }, [item, from]); 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/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/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx new file mode 100644 index 00000000..57ff9f58 --- /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/${id}`)}> + + + {name} + {subName && {subName}} + + + ) +} + +export default PersonPoster; \ No newline at end of file diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx index 5a9647ae..5f363d42 100644 --- a/components/posters/JellyseerrPoster.tsx +++ b/components/posters/JellyseerrPoster.tsx @@ -1,55 +1,47 @@ -import {View, ViewProps} from "react-native"; -import {Image} from "expo-image"; -import {MaterialCommunityIcons} from "@expo/vector-icons"; -import {Text} from "@/components/common/Text"; -import {useEffect, useMemo, useState} from "react"; -import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search"; -import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; -import {useJellyseerr} from "@/hooks/useJellyseerr"; -import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions"; -import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter"; -import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus"; +import { View, ViewProps } from "react-native"; +import { Image } from "expo-image"; +import { Text } from "@/components/common/Text"; +import { useMemo } from "react"; +import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { + MediaStatus, + MediaType, +} from "@/utils/jellyseerr/server/constants/media"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { + hasPermission, + Permission, +} from "@/utils/jellyseerr/server/lib/permissions"; +import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter"; +import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon"; +import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon"; +import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; interface Props extends ViewProps { item: MovieResult | TvResult; } -const JellyseerrPoster: React.FC = ({ - item, - ...props -}) => { - const {jellyseerrUser, jellyseerrApi} = useJellyseerr(); - // const imageSource = +const JellyseerrPoster: React.FC = ({ item, ...props }) => { + const { jellyseerrApi } = useJellyseerr(); - const imageSrc = useMemo(() => - item.posterPath ? - `https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}` - : jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`, + const imageSrc = useMemo( + () => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"), [item, jellyseerrApi] - ) - const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item]) - const releaseYear = useMemo(() => - new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(), + ); + const title = useMemo( + () => (item.mediaType === MediaType.MOVIE ? item.title : item.name), [item] - ) + ); + const releaseYear = useMemo( + () => + new Date( + item.mediaType === MediaType.MOVIE + ? item.releaseDate + : item.firstAirDate + ).getFullYear(), + [item] + ); - const showRequestButton = useMemo(() => - jellyseerrUser && hasPermission( - [ - Permission.REQUEST, - item.mediaType === 'movie' - ? Permission.REQUEST_MOVIE - : Permission.REQUEST_TV, - ], - jellyseerrUser.permissions, - {type: 'or'} - ), - [item, jellyseerrUser] - ) - - const canRequest = useMemo(() => { - const status = item?.mediaInfo?.status - return showRequestButton && !status || status === MediaStatus.UNKNOWN - }, [item]) + const canRequest = useJellyseerrCanRequest(item); return ( = ({ mediaTitle={title} releaseYear={releaseYear} canRequest={canRequest} - posterSrc={imageSrc} + posterSrc={imageSrc!!} > = ({ width: "100%", }} /> - + {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/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 2b312f0e..01cd1e84 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -55,7 +55,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 e573929a..52851533 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -29,7 +29,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 bcd9b336..80bfd254 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, @@ -61,7 +61,7 @@ const RenderItem = ({ item, index }: any) => { key={item.id} id={item.id} source={{ - uri: jellyseerrApi?.tvStillImageProxy(item.stillPath), + uri: jellyseerrApi?.imageProxy(item.stillPath), }} cachePolicy={"memory-disk"} contentFit="cover" @@ -246,7 +246,7 @@ const JellyseerrSeasons: React.FC<{ seasons?.find((s) => s.seasonNumber === season.seasonNumber) ?.status === MediaStatus.UNKNOWN; return ( - 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 d280a167..cbd8fc18 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"; @@ -19,6 +20,7 @@ import { ListItem } from "../list/ListItem"; interface Props extends ViewProps {} export const OtherSettings: React.FC = () => { + const router = useRouter(); const [settings, updateSettings] = useSettings(); /******************** @@ -54,7 +56,7 @@ export const OtherSettings: React.FC = () => { if (!settings) return null; return ( - + { } /> + router.push("/settings/hide-libraries/page")} + title="Hide Libraries" + showArrow + /> + + + updateSettings({ disableHapticFeedback: value }) + } + /> + ); }; diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index 9efbec43..85a8259f 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -7,7 +7,7 @@ import { BottomSheetView, } from "@gorhom/bottom-sheet"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import { useAtom } from "jotai"; import React, { useCallback, useRef, useState } from "react"; import { Alert, View, ViewProps } from "react-native"; @@ -23,6 +23,8 @@ export const QuickConnect: React.FC = ({ ...props }) => { const [user] = useAtom(userAtom); const [quickConnectCode, setQuickConnectCode] = useState(); const bottomSheetModalRef = useRef(null); + const successHapticFeedback = useHaptic("success"); + const errorHapticFeedback = useHaptic("error"); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -43,16 +45,16 @@ export const QuickConnect: React.FC = ({ ...props }) => { userId: user?.Id, }); if (res.status === 200) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + successHapticFeedback(); Alert.alert("Success", "Quick connect authorized"); setQuickConnectCode(undefined); bottomSheetModalRef?.current?.close(); } else { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + errorHapticFeedback(); Alert.alert("Error", "Invalid code"); } } catch (e) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + errorHapticFeedback(); Alert.alert("Error", "Invalid code"); } } diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 5b693acd..9064bc14 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -4,7 +4,7 @@ import { useDownload } from "@/providers/DownloadProvider"; import { clearLogs } from "@/utils/log"; import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import { View } from "react-native"; import * as Progress from "react-native-progress"; import { toast } from "sonner-native"; @@ -13,6 +13,8 @@ import { ListItem } from "../list/ListItem"; export const StorageSettings = () => { const { deleteAllFiles, appSizeUsage } = useDownload(); + const successHapticFeedback = useHaptic("success"); + const errorHapticFeedback = useHaptic("error"); const { data: size, isLoading: appSizeLoading } = useQuery({ queryKey: ["appSize", appSizeUsage], @@ -29,9 +31,9 @@ export const StorageSettings = () => { const onDeleteClicked = async () => { try { await deleteAllFiles(); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + successHapticFeedback(); } catch (e) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + errorHapticFeedback(); toast.error("Error deleting files"); } }; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 2fd1cba3..620e112e 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -29,7 +29,7 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import { Image } from "expo-image"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useAtom } from "jotai"; @@ -157,10 +157,12 @@ export const Controls: React.FC = ({ isVlc ); + const lightHapticFeedback = useHaptic("light"); + const goToPreviousItem = useCallback(() => { if (!previousItem || !settings) return; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); const previousIndexes: previousIndexes = { subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, @@ -198,7 +200,7 @@ export const Controls: React.FC = ({ const goToNextItem = useCallback(() => { if (!nextItem || !settings) return; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); const previousIndexes: previousIndexes = { subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, @@ -326,7 +328,7 @@ export const Controls: React.FC = ({ const handleSkipBackward = useCallback(async () => { if (!settings?.rewindSkipTime) return; wasPlayingRef.current = isPlaying; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); try { const curr = progress.value; if (curr !== undefined) { @@ -344,7 +346,7 @@ export const Controls: React.FC = ({ const handleSkipForward = useCallback(async () => { if (!settings?.forwardSkipTime) return; wasPlayingRef.current = isPlaying; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); try { const curr = progress.value; if (curr !== undefined) { @@ -361,7 +363,7 @@ export const Controls: React.FC = ({ const toggleIgnoreSafeAreas = useCallback(() => { setIgnoreSafeAreas((prev) => !prev); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); }, []); const memoizedRenderBubble = useCallback(() => { @@ -440,7 +442,7 @@ export const Controls: React.FC = ({ const gotoItem = await getItemById(api, itemId); if (!settings || !gotoItem) return; - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); const previousIndexes: previousIndexes = { subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, @@ -584,7 +586,7 @@ export const Controls: React.FC = ({ )} { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); router.back(); }} className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts index 1430e7c9..14a77161 100644 --- a/hooks/useCreditSkipper.ts +++ b/hooks/useCreditSkipper.ts @@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { writeToLog } from "@/utils/log"; import { msToSeconds, secondsToMs } from "@/utils/time"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "./useHaptic"; interface CreditTimestamps { Introduction: { @@ -29,6 +29,7 @@ export const useCreditSkipper = ( ) => { const [api] = useAtom(apiAtom); const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); + const lightHapticFeedback = useHaptic("light"); if (isVlc) { currentTime = msToSeconds(currentTime); @@ -79,7 +80,7 @@ export const useCreditSkipper = ( if (!creditTimestamps) return; console.log(`Skipping credits to ${creditTimestamps.Credits.End}`); try { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); wrappedSeek(creditTimestamps.Credits.End); setTimeout(() => { play(); diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index 5c2d9cc6..39009305 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -6,7 +6,7 @@ import { } from "@jellyfin/sdk/lib/generated-client"; import { useMemo } from "react"; -// Used only for intial play settings. +// Used only for initial play settings. const useDefaultPlaySettings = ( item: BaseItemDto, settings: Settings | null diff --git a/hooks/useHaptic.ts b/hooks/useHaptic.ts new file mode 100644 index 00000000..c992def1 --- /dev/null +++ b/hooks/useHaptic.ts @@ -0,0 +1,54 @@ +import { useCallback, useMemo } from "react"; +import { Platform } from "react-native"; +import * as Haptics from "expo-haptics"; +import { useSettings } from "@/utils/atoms/settings"; + +export type HapticFeedbackType = + | "light" + | "medium" + | "heavy" + | "selection" + | "success" + | "warning" + | "error"; + +export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => { + const [settings] = useSettings(); + + const createHapticHandler = useCallback( + (type: Haptics.ImpactFeedbackStyle) => { + return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type); + }, + [] + ); + const createNotificationFeedback = useCallback( + (type: Haptics.NotificationFeedbackType) => { + return Platform.OS === "web" + ? () => {} + : () => Haptics.notificationAsync(type); + }, + [] + ); + + const hapticHandlers = useMemo( + () => ({ + light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light), + medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium), + heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy), + selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync, + success: createNotificationFeedback( + Haptics.NotificationFeedbackType.Success + ), + warning: createNotificationFeedback( + Haptics.NotificationFeedbackType.Warning + ), + error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error), + }), + [createHapticHandler, createNotificationFeedback] + ); + + if (settings?.disableHapticFeedback) { + return () => {}; + } + return hapticHandlers[feedbackType]; +}; diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts index 15aaff05..b41872dc 100644 --- a/hooks/useIntroSkipper.ts +++ b/hooks/useIntroSkipper.ts @@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { writeToLog } from "@/utils/log"; import { msToSeconds, secondsToMs } from "@/utils/time"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "./useHaptic"; interface IntroTimestamps { EpisodeId: string; @@ -33,6 +33,7 @@ export const useIntroSkipper = ( if (isVlc) { currentTime = msToSeconds(currentTime); } + const lightHapticFeedback = useHaptic("light"); const wrappedSeek = (seconds: number) => { if (isVlc) { @@ -78,7 +79,7 @@ export const useIntroSkipper = ( const skipIntro = useCallback(() => { if (!introTimestamps) return; try { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); wrappedSeek(introTimestamps.IntroEnd); setTimeout(() => { play(); diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 8393798d..815510fa 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -28,6 +28,11 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue"; import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { writeErrorLog } from "@/utils/log"; import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; +import { + CombinedCredit, + PersonDetails, +} from "@/utils/jellyseerr/server/models/Person"; +import { useQueryClient } from "@tanstack/react-query"; interface SearchParams { query: string; @@ -55,6 +60,8 @@ export enum Endpoints { API_V1 = "/api/v1", SEARCH = "/search", REQUEST = "/request", + PERSON = "/person", + COMBINED_CREDITS = "/combined_credits", MOVIE = "/movie", RATINGS = "/ratings", ISSUE = "/issue", @@ -204,6 +211,27 @@ export class JellyseerrApi { }); } + async personDetails(id: number | string): Promise { + 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( @@ -238,14 +266,20 @@ export class JellyseerrApi { }); } - tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) { - return ( - this.axios.defaults.baseURL + - `/_next/image?` + - new URLSearchParams( - `url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}` - ).toString() - ); + imageProxy( + path?: string, + tmdbPath: string = "original", + width: number = 1920, + quality: number = 75 + ) { + return path + ? this.axios.defaults.baseURL + + `/_next/image?` + + new URLSearchParams( + `url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}` + ).toString() + : this.axios?.defaults.baseURL + + `/images/overseerr_poster_not_found_logo_top.png`; } async submitIssue(mediaId: number, issueType: IssueType, message: string) { @@ -321,6 +355,7 @@ const jellyseerrUserAtom = atom(storage.get(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); @@ -338,12 +373,16 @@ export const useJellyseerr = () => { const requestMedia = useCallback( (title: string, request: MediaRequestBody, onSuccess?: () => void) => { - jellyseerrApi?.request?.(request)?.then((mediaRequest) => { + jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => { + await queryClient.invalidateQueries({ + queryKey: ["search", "jellyseerr"], + }); + switch (mediaRequest.status) { case MediaRequestStatus.PENDING: case MediaRequestStatus.APPROVED: toast.success(`Requested ${title}!`); - onSuccess?.() + onSuccess?.(); break; case MediaRequestStatus.DECLINED: toast.error(`You don't have permission to request!`); diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts index ff039cc8..fb30bd14 100644 --- a/hooks/useMarkAsPlayed.ts +++ b/hooks/useMarkAsPlayed.ts @@ -3,13 +3,14 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed"; import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQueryClient } from "@tanstack/react-query"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "./useHaptic"; import { useAtom } from "jotai"; export const useMarkAsPlayed = (item: BaseItemDto) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const queryClient = useQueryClient(); + const lightHapticFeedback = useHaptic("light"); const invalidateQueries = () => { const queriesToInvalidate = [ @@ -29,7 +30,7 @@ export const useMarkAsPlayed = (item: BaseItemDto) => { }; const markAsPlayedStatus = async (played: boolean) => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + lightHapticFeedback(); // Optimistic update queryClient.setQueryData( diff --git a/package.json b/package.json index 6bbe1d00..e5ebb82a 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-native-bottom-tabs": "0.7.1", "react-native-circular-progress": "^1.4.1", "react-native-compressor": "^1.9.0", + "react-native-country-flag": "^2.0.2", "react-native-device-info": "^14.0.1", "react-native-edge-to-edge": "^1.1.3", "react-native-gesture-handler": "~2.16.1", @@ -82,7 +83,7 @@ "react-native-google-cast": "^4.8.3", "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^2.5.2", - "react-native-ios-utilities": "^4.5.1", + "react-native-ios-utilities": "4.5.3", "react-native-mmkv": "^2.12.2", "react-native-pager-view": "6.3.0", "react-native-progress": "^5.0.1", @@ -99,7 +100,6 @@ "react-native-volume-manager": "^1.10.0", "react-native-web": "~0.19.13", "react-native-webview": "13.8.6", - "react-native-youtube-iframe": "^2.3.0", "sonner-native": "^0.14.2", "tailwindcss": "3.3.2", "use-debounce": "^10.0.4", 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/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 78fbbe6f..fb8b137f 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -48,7 +48,7 @@ import useImageStorage from "@/hooks/useImageStorage"; import { storage } from "@/utils/mmkv"; import useDownloadHelper from "@/utils/download"; import { FileInfo } from "expo-file-system"; -import * as Haptics from "expo-haptics"; +import { useHaptic } from "@/hooks/useHaptic"; import * as Application from "expo-application"; export type DownloadedItem = { @@ -78,6 +78,8 @@ function useDownloadProvider() { const [processes, setProcesses] = useAtom(processesAtom); + const successHapticFeedback = useHaptic("success"); + const authHeader = useMemo(() => { return api?.accessToken; }, [api]); @@ -532,9 +534,7 @@ function useDownloadProvider() { if (i.Id) return deleteFile(i.Id); return; }) - ).then(() => - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) - ); + ).then(() => successHapticFeedback()); }; const cleanCacheDirectory = async () => { 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/svenska_kyrkan.sql b/svenska_kyrkan.sql deleted file mode 100644 index e69de29b..00000000 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 c37dd4eb..b473198d 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -84,10 +84,12 @@ export type Settings = { downloadMethod: "optimized" | "remux"; autoDownload: boolean; showCustomMenuLinks: boolean; + disableHapticFeedback: boolean; subtitleSize: number; remuxConcurrentLimit: 1 | 2 | 3 | 4; safeAreaInControlsEnabled: boolean; jellyseerrServerUrl?: string; + hiddenLibraries?: string[]; }; const loadSettings = (): Settings => { @@ -122,10 +124,12 @@ const loadSettings = (): Settings => { downloadMethod: "remux", autoDownload: false, showCustomMenuLinks: false, + disableHapticFeedback: false, subtitleSize: Platform.OS === "ios" ? 60 : 100, remuxConcurrentLimit: 1, safeAreaInControlsEnabled: true, jellyseerrServerUrl: undefined, + hiddenLibraries: [], }; try { 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; +}