From 3d73f604ac240915b027d3e4e3706f2cf58700a6 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Sep 2024 17:10:33 +0200 Subject: [PATCH 1/2] wip --- .../collections/[collectionId].tsx | 5 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 4 +- components/common/ItemImage.tsx | 40 +++++++++++++--- components/common/TouchableItemRouter.tsx | 2 +- components/posters/ItemPoster.tsx | 47 +++++++++++++++++++ 5 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 components/posters/ItemPoster.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx index b9a657be..311b3805 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx @@ -1,8 +1,10 @@ +import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { FilterButton } from "@/components/filters/FilterButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; +import { ItemPoster } from "@/components/posters/ItemPoster"; import MoviePoster from "@/components/posters/MoviePoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { @@ -194,7 +196,8 @@ const page: React.FC = () => { width: "89%", }} > - + + {/* */} diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 77b2e3ff..4932c86d 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -46,6 +46,7 @@ import { FlashList } from "@shopify/flash-list"; import { Loader } from "@/components/Loader"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { orientationAtom } from "@/utils/atoms/orientation"; +import { ItemPoster } from "@/components/posters/ItemPoster"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); @@ -208,7 +209,8 @@ const Page = () => { width: "89%", }} > - + {/* */} + diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index c3ad2ed6..d25850ee 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -1,20 +1,31 @@ import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image, ImageProps, ImageSource } from "expo-image"; import { useAtom } from "jotai"; import { useMemo } from "react"; +import { View } from "react-native"; interface Props extends ImageProps { item: BaseItemDto; - variant?: "Backdrop" | "Primary" | "Thumb" | "Logo"; + variant?: + | "Primary" + | "Backdrop" + | "ParentBackdrop" + | "ParentLogo" + | "Logo" + | "AlbumPrimary" + | "SeriesPrimary" + | "Screenshot" + | "Thumb"; quality?: number; width?: number; } export const ItemImage: React.FC = ({ item, - variant, + variant = "Primary", quality = 90, width = 1000, ...props @@ -28,6 +39,8 @@ export const ItemImage: React.FC = ({ let blurhash: string | null | undefined; let src: ImageSource | null = null; + console.log("ImageItem ~ " + variant, item.Name, item.ImageTags); + switch (variant) { case "Backdrop": if (item.Type === "Episode") { @@ -35,7 +48,7 @@ export const ItemImage: React.FC = ({ if (!tag) break; blurhash = item.ImageBlurHashes?.Backdrop?.[tag]; src = { - uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`, + uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`, blurhash, }; break; @@ -45,7 +58,7 @@ export const ItemImage: React.FC = ({ if (!tag) break; blurhash = item.ImageBlurHashes?.Backdrop?.[tag]; src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`, + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`, blurhash, }; break; @@ -55,7 +68,7 @@ export const ItemImage: React.FC = ({ blurhash = item.ImageBlurHashes?.Primary?.[tag]; src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`, blurhash, }; break; @@ -65,29 +78,42 @@ export const ItemImage: React.FC = ({ blurhash = item.ImageBlurHashes?.Thumb?.[tag]; src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`, + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}&width=${width}`, blurhash, }; break; default: tag = item.ImageTags?.["Primary"]; src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`, }; break; } + console.log("src: ", src?.uri?.slice(0, 30)); + return src; }, [item.ImageTags]); useImageColors(source?.uri); + // return placeholder icon if no source + if (!source?.uri) return; + + ; + ; + return ( > = ({ } if (item.Type === "UserView") { - Alert.alert("Not implemented"); + router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`); return; } diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx new file mode 100644 index 00000000..97cbfd7f --- /dev/null +++ b/components/posters/ItemPoster.tsx @@ -0,0 +1,47 @@ +import { View, ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { + BaseItemDto, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { ItemImage } from "../common/ItemImage"; +import { WatchedIndicator } from "../WatchedIndicator"; +import { useState } from "react"; + +interface Props extends ViewProps { + item: BaseItemDto; + showProgress?: boolean; +} + +export const ItemPoster: React.FC = ({ + item, + showProgress, + ...props +}) => { + const [progress, setProgress] = useState( + item.UserData?.PlayedPercentage || 0 + ); + + if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet") + return ( + + + + {showProgress && progress > 0 && ( + + )} + + ); + + return ( + + + + ); +}; From d4252682be2411a37b400e4d8a5f361f27e1ffa4 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 3 Sep 2024 08:54:05 +0300 Subject: [PATCH 2/2] wip: use general poster component --- components/ItemContent.tsx | 43 ++++++++------ components/common/ItemImage.tsx | 96 ++++++++++--------------------- components/posters/ItemPoster.tsx | 10 +++- hooks/useImageColors.ts | 12 ++-- utils/getItemImage.ts | 87 ++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 89 deletions(-) create mode 100644 utils/getItemImage.ts diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 2722e012..2bdb18db 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -11,8 +11,10 @@ import { ItemImage } from "@/components/common/ItemImage"; import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; +import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { getItemImage } from "@/utils/getItemImage"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; @@ -25,23 +27,22 @@ import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useNavigation } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Chromecast } from "./Chromecast"; import { ItemHeader } from "./ItemHeader"; -import { MediaSourceSelector } from "./MediaSourceSelector"; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - runOnJS, -} from "react-native-reanimated"; import { Loader } from "./Loader"; -import { set } from "lodash"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { MediaSourceSelector } from "./MediaSourceSelector"; export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); @@ -61,7 +62,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { value: undefined, }); - const [loadingImage, setLoadingImage] = useState(true); const [loadingLogo, setLoadingLogo] = useState(true); const [orientation, setOrientation] = useState( @@ -233,12 +233,22 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { }); const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); + const themeImageColorSource = useMemo(() => { + if (!api || !item) return; + return getItemImage({ + item, + api, + variant: "Primary", + quality: 80, + width: 300, + }); + }, [api, item]); + + useImageColors(themeImageColorSource?.uri); const loading = useMemo(() => { - return Boolean( - isLoading || isFetching || loadingImage || (logoUrl && loadingLogo) - ); - }, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]); + return Boolean(isLoading || isFetching || (logoUrl && loadingLogo)); + }, [isLoading, isFetching, loadingLogo, logoUrl]); const insets = useSafeAreaInsets(); @@ -263,6 +273,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { {localItem && ( = React.memo(({ id }) => { width: "100%", height: "100%", }} - onLoad={() => setLoadingImage(false)} - onError={() => setLoadingImage(false)} /> )} diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index d25850ee..bf4c0f1a 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -1,5 +1,6 @@ import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom } from "@/providers/JellyfinProvider"; +import { getItemImage } from "@/utils/getItemImage"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image, ImageProps, ImageSource } from "expo-image"; @@ -21,6 +22,8 @@ interface Props extends ImageProps { | "Thumb"; quality?: number; width?: number; + useThemeColor?: boolean; + onError?: () => void; } export const ItemImage: React.FC = ({ @@ -28,80 +31,41 @@ export const ItemImage: React.FC = ({ variant = "Primary", quality = 90, width = 1000, + useThemeColor = false, + onError, ...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; - - console.log("ImageItem ~ " + variant, item.Name, item.ImageTags); - - 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}&width=${width}`, - 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}&width=${width}`, - blurhash, - }; - break; - case "Primary": - tag = item.ImageTags?.["Primary"]; - if (!tag) break; - blurhash = item.ImageBlurHashes?.Primary?.[tag]; - - src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`, - blurhash, - }; - break; - case "Thumb": - tag = item.ImageTags?.["Thumb"]; - if (!tag) break; - blurhash = item.ImageBlurHashes?.Thumb?.[tag]; - - src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}&width=${width}`, - blurhash, - }; - break; - default: - tag = item.ImageTags?.["Primary"]; - src = { - uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`, - }; - break; + if (!api) { + onError && onError(); + return; } - - console.log("src: ", src?.uri?.slice(0, 30)); - - return src; - }, [item.ImageTags]); - - useImageColors(source?.uri); + return getItemImage({ + item, + api, + variant, + quality, + width, + }); + }, [api, item, quality, variant, width]); // return placeholder icon if no source - if (!source?.uri) return; - - ; - ; + if (!source?.uri) + return ( + + + + ); return ( = ({ if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet") return ( - + = ({ ); return ( - + ); diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts index 7d93e8ce..7f15b4b0 100644 --- a/hooks/useImageColors.ts +++ b/hooks/useImageColors.ts @@ -1,12 +1,16 @@ -import { useState, useEffect } from "react"; -import { getColors } from "react-native-image-colors"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { getColors } from "react-native-image-colors"; -export const useImageColors = (uri: string | undefined | null) => { +export const useImageColors = ( + uri: string | undefined | null, + disabled = false +) => { const [, setPrimaryColor] = useAtom(itemThemeColorAtom); useEffect(() => { + if (disabled) return; if (uri) { getColors(uri, { fallback: "#fff", @@ -38,5 +42,5 @@ export const useImageColors = (uri: string | undefined | null) => { console.error("Error getting colors", error); }); } - }, [uri, setPrimaryColor]); + }, [uri, setPrimaryColor, disabled]); }; diff --git a/utils/getItemImage.ts b/utils/getItemImage.ts new file mode 100644 index 00000000..d106b0cf --- /dev/null +++ b/utils/getItemImage.ts @@ -0,0 +1,87 @@ +import { Api } from "@jellyfin/sdk"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { ImageSource } from "expo-image"; + +interface Props { + item: BaseItemDto; + api: Api; + quality?: number; + width?: number; + variant?: + | "Primary" + | "Backdrop" + | "ParentBackdrop" + | "ParentLogo" + | "Logo" + | "AlbumPrimary" + | "SeriesPrimary" + | "Screenshot" + | "Thumb"; +} + +export const getItemImage = ({ + item, + api, + variant = "Primary", + quality = 90, + width = 1000, +}: Props) => { + 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}&width=${width}`, + 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}&width=${width}`, + blurhash, + }; + break; + case "Primary": + tag = item.ImageTags?.["Primary"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Primary?.[tag]; + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`, + blurhash, + }; + break; + case "Thumb": + tag = item.ImageTags?.["Thumb"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Thumb?.[tag]; + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}&width=${width}`, + blurhash, + }; + break; + default: + tag = item.ImageTags?.["Primary"]; + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`, + }; + break; + } + + if (!src?.uri) return null; + + return src; +};