diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index c187c68b..69467450 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -8,7 +8,7 @@ import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; -import { router, Stack } from "expo-router"; +import { router } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; import { useMemo } from "react"; @@ -70,7 +70,9 @@ const downloads: React.FC = () => { {queue.map((q) => ( router.push(`/(auth)/items/${q.item.Id}`)} + onPress={() => + router.push(`/(auth)/items/page?id=${q.item.Id}`) + } className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" > @@ -97,7 +99,9 @@ const downloads: React.FC = () => { Active download {process?.item ? ( router.push(`/(auth)/items/${process.item.Id}`)} + onPress={() => + router.push(`/(auth)/items/page?id=${process.item.Id}`) + } className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" > diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx new file mode 100644 index 00000000..fa358ddf --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx @@ -0,0 +1,13 @@ +import { ItemContent } from "@/components/ItemContent"; +import { useLocalSearchParams } from "expo-router"; +import React, { useMemo } from "react"; + +const Page: React.FC = () => { + const { id } = useLocalSearchParams() as { id: string }; + + const memoizedContent = useMemo(() => , [id]); + + return memoizedContent; +}; + +export default React.memo(Page); diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index 4639932f..15ae2870 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -61,6 +61,7 @@ const page: React.FC = () => { return ( m.Id!)} renderItem={(data) => ( - + ( m.Id!)} header="Series" renderItem={(data) => ( - + ( m.Id!)} header="Episodes" renderItem={(data) => ( - + ( router.push(`/items/${item.Id}`)} + onPress={() => router.push(`/items/page?id=${item.Id}`)} className="flex flex-col w-44" > @@ -312,7 +312,7 @@ export default function search() { ids={collections?.map((m) => m.Id!)} header="Collections" renderItem={(data) => ( - + ( m.Id!)} header="Actors" renderItem={(data) => ( - + ( m.Id!)} header="Artists" renderItem={(data) => ( - + ( m.Id!)} header="Albums" renderItem={(data) => ( - + ( m.Id!)} header="Songs" renderItem={(data) => ( - + ( = ({ - + Audio streams diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index e38df8bb..43df6fc5 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -54,7 +54,7 @@ export const BitrateSelector: React.FC = ({ - + Bitrate diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index a16a4369..9f7c5adb 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -231,7 +231,8 @@ export const CurrentlyPlayingBar: React.FC = () => { onPress={() => { if (currentlyPlaying.item?.Type === "Audio") router.push(`/albums/${currentlyPlaying.item?.AlbumId}`); - else router.push(`/items/${currentlyPlaying.item?.Id}`); + else + router.push(`/items/page?id=${currentlyPlaying.item?.Id}`); }} > {currentlyPlaying.item?.Name} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx b/components/ItemContent.tsx similarity index 58% rename from app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx rename to components/ItemContent.tsx index 0a517ff0..ecc0b109 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx +++ b/components/ItemContent.tsx @@ -1,7 +1,6 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { DownloadItem } from "@/components/DownloadItem"; -import { Loader } from "@/components/Loader"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlayButton } from "@/components/PlayButton"; @@ -9,18 +8,16 @@ import { PlayedStatus } from "@/components/PlayedStatus"; import { Ratings } from "@/components/Ratings"; import { SimilarItems } from "@/components/SimilarItems"; import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; +import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; -import { NextEpisodeButton } from "@/components/series/NextEpisodeButton"; -import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader"; +import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; +import { EpisodeTitleHeader } from "@/components/series/EpisodeTitleHeader"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; -import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; -import { getPrimaryParentImageUrl } from "@/utils/jellyfin/image/getPrimaryParentImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { chromecastProfile } from "@/utils/profiles/chromecast"; @@ -30,21 +27,17 @@ import old from "@/utils/profiles/old"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; +import { ItemHeader } from "./ItemHeader"; -const page: React.FC = () => { - const local = useLocalSearchParams(); - const { id } = local as { id: string }; - +export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [settings] = useSettings(); - const castDevice = useCastDevice(); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); @@ -55,7 +48,11 @@ const page: React.FC = () => { value: undefined, }); - const { data: item, isLoading: l1 } = useQuery({ + const { + data: item, + isLoading, + isFetching, + } = useQuery({ queryKey: ["item", id], queryFn: async () => await getUserItemData({ @@ -127,63 +124,36 @@ const page: React.FC = () => { staleTime: 0, }); - const itemBackdropUrl = useMemo( - () => - getBackdropUrl({ - api, - item, - quality: 95, - width: 1200, - }), - [item] - ); - - const seriesBackdropUrl = useMemo( - () => - getParentBackdropImageUrl({ - api, - item, - quality: 95, - width: 1200, - }), - [item] - ); - const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); - const episodePoster = useMemo( - () => - item?.Type === "Episode" - ? `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80` - : null, - [item] + const loading = useMemo( + () => isLoading || isFetching, + [isLoading, isFetching] ); - if (l1) - return ( - - - - ); - - if (!item?.Id) return null; - return ( - {itemBackdropUrl ? ( - - ) : null} + ) : ( + + )} } logo={ @@ -203,62 +173,65 @@ const page: React.FC = () => { } > - - - {item.Type === "Episode" ? ( - + + + + + {item ? ( + + + + ) : ( - <> - - + + + )} - {item?.ProductionYear} - - - - {playbackUrl ? ( - + {item ? ( + + setMaxBitrate(val)} + selected={maxBitrate} + /> + {item && ( + + )} + {item && ( + + )} + ) : ( - + + + + )} - + + - - - - - setMaxBitrate(val)} - selected={maxBitrate} - /> - - - - - - - - - - - - {item.Type === "Episode" && } - - + - + + + + + {item?.Type === "Episode" && ( + + )} + + + + ); -}; - -export default page; +}); diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx new file mode 100644 index 00000000..d4c204b1 --- /dev/null +++ b/components/ItemHeader.tsx @@ -0,0 +1,30 @@ +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { View, ViewProps } from "react-native"; +import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; +import { Ratings } from "./Ratings"; +import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; + +interface Props extends ViewProps { + item?: BaseItemDto | null; +} + +export const ItemHeader: React.FC = ({ item, ...props }) => { + if (!item) + return ( + + + + + + + + ); + + return ( + + {item.Type === "Episode" && } + {item.Type === "Movie" && } + + + ); +}; diff --git a/components/OverviewText.tsx b/components/OverviewText.tsx index 96075f08..1d448618 100644 --- a/components/OverviewText.tsx +++ b/components/OverviewText.tsx @@ -10,35 +10,32 @@ interface Props extends ViewProps { export const OverviewText: React.FC = ({ text, - characterLimit = 140, + characterLimit = 100, ...props }) => { const [limit, setLimit] = useState(characterLimit); if (!text) return null; - if (text.length > characterLimit) - return ( + return ( + + Overview setLimit((prev) => prev === characterLimit ? text.length : characterLimit ) } - {...props} > - + {tc(text, limit)} - - {limit === characterLimit ? "Show more" : "Show less"} - + {text.length > characterLimit && ( + + {limit === characterLimit ? "Show more" : "Show less"} + + )} - ); - - return ( - - {text} ); }; diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index 595de780..643f6afc 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, type PropsWithChildren, type ReactElement } from "react"; +import { type PropsWithChildren, type ReactElement } from "react"; import { View } from "react-native"; import Animated, { interpolate, @@ -6,7 +6,6 @@ import Animated, { useAnimatedStyle, useScrollViewOffset, } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; type Props = PropsWithChildren<{ headerImage: ReactElement; @@ -46,8 +45,6 @@ export const ParallaxScrollView: React.FC = ({ }; }); - const inset = useSafeAreaInsets(); - return ( { item?: BaseItemDto | null; diff --git a/components/Ratings.tsx b/components/Ratings.tsx index f3d73168..65834fdf 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -5,10 +5,11 @@ import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; interface Props extends ViewProps { - item: BaseItemDto; + item?: BaseItemDto | null; } export const Ratings: React.FC = ({ item }) => { + if (!item) return null; return ( {item.OfficialRating && ( diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index f0d6ff40..6777a8d9 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -12,7 +12,7 @@ import { ItemCardText } from "./ItemCardText"; import { Loader } from "./Loader"; interface SimilarItemsProps extends ViewProps { - itemId: string; + itemId?: string | null; } export const SimilarItems: React.FC = ({ @@ -25,7 +25,7 @@ export const SimilarItems: React.FC = ({ const { data: similarItems, isLoading } = useQuery({ queryKey: ["similarItems", itemId], queryFn: async () => { - if (!api || !user?.Id) return []; + if (!api || !user?.Id || !itemId) return []; const response = await getLibraryApi(api).getSimilarItems({ itemId, userId: user.Id, @@ -56,7 +56,7 @@ export const SimilarItems: React.FC = ({ {movies.map((item) => ( router.push(`/items/${item.Id}`)} + onPress={() => router.push(`/items/page?id=${item.Id}`)} className="flex flex-col w-32" > @@ -66,7 +66,9 @@ export const SimilarItems: React.FC = ({ )} - {movies.length === 0 && No similar items} + {movies.length === 0 && ( + No similar items + )} ); }; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 35918de1..a445ffeb 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -47,7 +47,7 @@ export const SubtitleTrackSelector: React.FC = ({ - + Subtitles diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx index eea84aa3..a1015b37 100644 --- a/components/common/HorrizontalScroll.tsx +++ b/components/common/HorrizontalScroll.tsx @@ -1,16 +1,14 @@ import { FlashList, FlashListProps } from "@shopify/flash-list"; -import React, { useEffect } from "react"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; import { View, ViewStyle } from "react-native"; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { Loader } from "../Loader"; import { Text } from "./Text"; type PartialExcept = Partial & Pick; +export interface HorizontalScrollRef { + scrollToIndex: (index: number, viewOffset: number) => void; +} + interface HorizontalScrollProps extends PartialExcept< Omit, "renderItem">, @@ -23,61 +21,69 @@ interface HorizontalScrollProps loadingContainerStyle?: ViewStyle; height?: number; loading?: boolean; + extraData?: any; } -export function HorizontalScroll({ - data = [], - renderItem, - containerStyle, - contentContainerStyle, - loadingContainerStyle, - loading = false, - height = 164, - ...props -}: HorizontalScrollProps): React.ReactElement { - const animatedOpacity = useSharedValue(0); - const animatedStyle1 = useAnimatedStyle(() => { - return { - opacity: withTiming(animatedOpacity.value, { duration: 250 }), - }; - }); +export const HorizontalScroll = forwardRef< + HorizontalScrollRef, + HorizontalScrollProps +>( + ( + { + data = [], + renderItem, + containerStyle, + contentContainerStyle, + loadingContainerStyle, + loading = false, + height = 164, + extraData, + ...props + }: HorizontalScrollProps, + ref: React.ForwardedRef + ) => { + const flashListRef = useRef>(null); - useEffect(() => { - if (data) { - animatedOpacity.value = 1; - } - }, [data]); + useImperativeHandle(ref!, () => ({ + scrollToIndex: (index: number, viewOffset: number) => { + flashListRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0, + viewOffset, + }); + }, + })); - if (data === undefined || data === null || loading) { - return ( - - + const renderFlashListItem = ({ + item, + index, + }: { + item: T; + index: number; + }) => ( + + {renderItem(item, index)} ); - } - const renderFlashListItem = ({ item, index }: { item: T; index: number }) => ( - - {renderItem(item, index)} - - ); + if (!data || loading) { + return ( + + + + + ); + } - return ( - - + ref={flashListRef} data={data} + extraData={extraData} renderItem={renderFlashListItem} horizontal - estimatedItemSize={100} + estimatedItemSize={200} showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, @@ -90,6 +96,6 @@ export function HorizontalScroll({ )} {...props} /> - - ); -} + ); + } +); diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx new file mode 100644 index 00000000..184f87c6 --- /dev/null +++ b/components/common/ItemImage.tsx @@ -0,0 +1,101 @@ +import { View, ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image, ImageProps, ImageSource } from "expo-image"; +import { useMemo, useState } from "react"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; + +interface Props extends ImageProps { + item: BaseItemDto; + variant?: "Backdrop" | "Primary" | "Thumb" | "Logo"; + quality?: number; + width?: number; +} + +export const ItemImage: React.FC = ({ + item, + variant, + quality = 90, + width = 1000, + ...props +}) => { + const [api] = useAtom(apiAtom); + + const source = useMemo(() => { + if (!api) return null; + + let tag: string | null | undefined; + let blurhash: string | null | undefined; + let src: ImageSource | null = null; + + switch (variant) { + case "Backdrop": + if (item.Type === "Episode") { + tag = item.ParentBackdropImageTags?.[0]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Backdrop?.[tag]; + src = { + uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + } + + tag = item.BackdropImageTags?.[0]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Backdrop?.[tag]; + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + case "Primary": + console.log("case Primary"); + tag = item.ImageTags?.["Primary"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Primary?.[tag]; + console.log("bh: ", blurhash); + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + case "Thumb": + console.log("case Thumb"); + tag = item.ImageTags?.["Thumb"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Thumb?.[tag]; + console.log("bh: ", blurhash); + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + default: + console.log("case default"); + tag = item.ImageTags?.["Primary"]; + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + }; + break; + } + + return src; + }, [item.ImageTags]); + + return ( + + ); +}; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 8ca06867..f5e52733 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -72,7 +72,7 @@ export const TouchableItemRouter: React.FC> = ({ // return; // } - router.push(`/(auth)/(tabs)/${from}/items/${item.Id}`); + router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`); }} {...props} > diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 32628690..ab4ef0ae 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -46,7 +46,7 @@ export const ScrollingCollectionList: React.FC = ({ {title} - + = ({ item, ...props }) => { - const router = useRouter(); return ( - - {item?.Name} + + {item?.Name} + {item?.ProductionYear} ); }; diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index d0406ce6..16f5e0d7 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -13,17 +13,19 @@ import { Text } from "../common/Text"; import Poster from "../posters/Poster"; interface Props extends ViewProps { - item: BaseItemDto; + item?: BaseItemDto | null; + loading?: boolean; } -export const CastAndCrew: React.FC = ({ item, ...props }) => { +export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); return ( - + Cast & Crew - > - data={item.People} + ( { diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index 8d06d2e7..f45792d0 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -10,7 +10,7 @@ import { Text } from "../common/Text"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; interface Props extends ViewProps { - item: BaseItemDto; + item?: BaseItemDto | null; } export const CurrentSeries: React.FC = ({ item, ...props }) => { @@ -19,7 +19,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => { return ( Series - + ( = ({ item, ...props }) => { + const router = useRouter(); + + return ( + + router.push(`/(auth)/series/${item.SeriesId}`)} + > + {item?.SeriesName} + + {item?.Name} + + { + router.push( + // @ts-ignore + `/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}` + ); + }} + > + {item?.SeasonName} + + {"—"} + + {`Episode ${item.IndexNumber}`} + + + {item?.ProductionYear} + + ); +}; diff --git a/components/series/NextEpisodeButton.tsx b/components/series/NextEpisodeButton.tsx index 709c9bfd..11140fdd 100644 --- a/components/series/NextEpisodeButton.tsx +++ b/components/series/NextEpisodeButton.tsx @@ -23,40 +23,6 @@ export const NextEpisodeButton: React.FC = ({ const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); - // const { data: seasons } = useQuery({ - // queryKey: ["seasons", item.SeriesId], - // queryFn: async () => { - // if ( - // !api || - // !user?.Id || - // !item?.Id || - // !item?.SeriesId || - // !item?.IndexNumber - // ) - // return []; - - // const response = await getItemsApi(api).getItems({ - // parentId: item?.SeriesId, - // }); - - // console.log("seasons ~", type, response.data); - - // return (response.data.Items as BaseItemDto[]) ?? []; - // }, - // enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId), - // }); - - // const nextSeason = useMemo(() => { - // if (!seasons) return null; - // const currentSeasonIndex = seasons.findIndex( - // (season) => season.Id === item.SeasonId, - // ); - - // if (currentSeasonIndex === seasons.length - 1) return null; - - // return seasons[currentSeasonIndex + 1]; - // }, [seasons]); - const { data: nextEpisode } = useQuery({ queryKey: ["nextEpisode", item.Id, item.ParentId, type], queryFn: async () => { @@ -90,7 +56,7 @@ export const NextEpisodeButton: React.FC = ({ return (