From aed3a8f49343684d5c0b352744d120d0d38cd4ac Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Jan 2026 09:15:44 +0100 Subject: [PATCH] fix(tv): poster design and other stuff --- .../collections/[collectionId].tsx | 28 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 35 +- .../(tabs)/(watchlists)/[watchlistId].tsx | 56 +-- components/ContinueWatchingPoster.tv.tsx | 188 -------- .../InfiniteScrollingCollectionList.tv.tsx | 145 +----- .../StreamystatsPromotedWatchlists.tv.tsx | 61 +-- .../home/StreamystatsRecommendations.tv.tsx | 76 +--- components/home/TVHeroCarousel.tsx | 48 +- components/persons/TVActorPage.tsx | 37 +- components/posters/MoviePoster.tv.tsx | 106 ----- components/posters/SeriesPoster.tv.tsx | 92 ---- .../search/TVJellyseerrSearchResults.tsx | 2 +- components/search/TVSearchBadge.tsx | 2 +- components/search/TVSearchSection.tsx | 426 +++++++----------- components/search/TVSearchTabBadges.tsx | 2 +- components/series/TVEpisodeCard.tsx | 222 --------- components/series/TVEpisodeList.tsx | 102 ++--- components/tv/TVActorCard.tsx | 2 +- components/tv/TVCastSection.tsx | 4 +- components/tv/TVHorizontalList.tsx | 221 +++++++++ components/tv/TVSeriesNavigation.tsx | 20 +- components/tv/settings/TVSettingsToggle.tsx | 4 +- components/video-player/controls/constants.ts | 1 + constants/TVPosterSizes.ts | 61 +-- constants/TVSizes.ts | 175 +++++++ .../glass-poster/ios/GlassPosterView.swift | 4 +- 26 files changed, 758 insertions(+), 1362 deletions(-) delete mode 100644 components/ContinueWatchingPoster.tv.tsx delete mode 100644 components/posters/MoviePoster.tv.tsx delete mode 100644 components/posters/SeriesPoster.tv.tsx delete mode 100644 components/series/TVEpisodeCard.tsx create mode 100644 components/tv/TVHorizontalList.tsx create mode 100644 constants/TVSizes.ts diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index 749d8508..fe44932b 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -27,13 +27,8 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { - TVFilterButton, - TVFocusablePoster, - TVItemCardText, -} from "@/components/tv"; +import { TVFilterButton } from "@/components/tv"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -293,26 +288,19 @@ const page: React.FC = () => { style={{ marginRight: TV_ITEM_GAP, marginBottom: TV_ITEM_GAP, - width: posterSizes.poster, }} > - showItemActions(item)} - > - {item.Type === "Movie" && } - {(item.Type === "Series" || item.Type === "Episode") && ( - - )} - {item.Type !== "Movie" && - item.Type !== "Series" && - item.Type !== "Episode" && } - - + width={posterSizes.poster} + /> ); }, - [router, showItemActions], + [router, showItemActions, posterSizes.poster], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 2ac4c58e..ccf38d3e 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -34,13 +34,8 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { - TVFilterButton, - TVFocusablePoster, - TVItemCardText, -} from "@/components/tv"; +import { TVFilterButton, TVFocusablePoster } from "@/components/tv"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -476,26 +471,14 @@ const Page = () => { } return ( - - showItemActions(item)} - > - {item.Type === "Movie" && } - {(item.Type === "Series" || item.Type === "Episode") && ( - - )} - {item.Type !== "Movie" && - item.Type !== "Series" && - item.Type !== "Episode" && } - - - + item={item} + orientation='vertical' + onPress={handlePress} + onLongPress={() => showItemActions(item)} + width={posterSizes.poster} + /> ); }, [router, showItemActions, api, typography], diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 07b03b17..c649bdf6 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -24,9 +24,7 @@ import { } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { ItemPoster } from "@/components/posters/ItemPoster"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; @@ -46,31 +44,6 @@ import { userAtom } from "@/providers/JellyfinProvider"; const TV_ITEM_GAP = 20; const TV_HORIZONTAL_PADDING = 60; -type Typography = ReturnType; - -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; -}> = ({ item, typography }) => ( - - - {item.Name} - - - {item.ProductionYear} - - -); - export default function WatchlistDetailScreen() { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -205,27 +178,18 @@ export default function WatchlistDetailScreen() { }; return ( - - showItemActions(item)} - hasTVPreferredFocus={index === 0} - > - {item.Type === "Movie" && } - {(item.Type === "Series" || item.Type === "Episode") && ( - - )} - - - + item={item} + orientation='vertical' + onPress={handlePress} + onLongPress={() => showItemActions(item)} + hasTVPreferredFocus={index === 0} + width={posterSizes.poster} + /> ); }, - [router, showItemActions, typography], + [router, showItemActions, posterSizes.poster], ); const renderItem = useCallback( diff --git a/components/ContinueWatchingPoster.tv.tsx b/components/ContinueWatchingPoster.tv.tsx deleted file mode 100644 index fe41c147..00000000 --- a/components/ContinueWatchingPoster.tv.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtomValue } from "jotai"; -import type React from "react"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { ProgressBar } from "./common/ProgressBar"; -import { WatchedIndicator } from "./WatchedIndicator"; - -type ContinueWatchingPosterProps = { - item: BaseItemDto; - useEpisodePoster?: boolean; - size?: "small" | "normal"; - showPlayButton?: boolean; -}; - -const ContinueWatchingPoster: React.FC = ({ - item, - useEpisodePoster = false, - // TV version uses fixed width, size prop kept for API compatibility - size: _size = "normal", - showPlayButton = false, -}) => { - const api = useAtomValue(apiAtom); - const posterSizes = useScaledTVPosterSizes(); - - const url = useMemo(() => { - if (!api) { - return; - } - if (item.Type === "Episode" && useEpisodePoster) { - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - if (item.Type === "Episode") { - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; - } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - if (item.Type === "Movie") { - if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; - } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - if (item.Type === "Program") { - if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; - } - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - } - - if (item.ImageTags?.Thumb) { - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`; - } - - return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; - }, [api, item, useEpisodePoster]); - - const progress = useMemo(() => { - if (item.Type === "Program") { - if (!item.StartDate || !item.EndDate) { - return 0; - } - const startDate = new Date(item.StartDate); - const endDate = new Date(item.EndDate); - const now = new Date(); - const total = endDate.getTime() - startDate.getTime(); - if (total <= 0) { - return 0; - } - const elapsed = now.getTime() - startDate.getTime(); - return (elapsed / total) * 100; - } - return item.UserData?.PlayedPercentage || 0; - }, [item]); - - const isWatched = item.UserData?.Played === true; - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - if (!url) { - return ( - - ); - } - - if (useGlass) { - return ( - - - {showPlayButton && ( - - - - )} - - ); - } - - // Fallback for older tvOS versions - return ( - - - - {showPlayButton && ( - - - - )} - - - - - ); -}; - -export default ContinueWatchingPoster; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index e92ea676..b4bfb73a 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -16,17 +16,15 @@ import { } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; -import ContinueWatchingPoster from "../ContinueWatchingPoster.tv"; -import SeriesPoster from "../posters/SeriesPoster.tv"; -const ITEM_GAP = 24; // Extra padding to accommodate scale animation (1.05x) and glow shadow const SCALE_PADDING = 20; @@ -47,76 +45,6 @@ interface Props extends ViewProps { } type Typography = ReturnType; - -// TV-specific ItemCardText with appropriately sized fonts -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; - width?: number; -}> = ({ item, typography, width }) => { - const renderSubtitle = () => { - if (item.Type === "Episode") { - return ( - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - {" - "} - {item.SeriesName} - - ); - } - - if (item.Type === "Program") { - // For Live TV programs, show channel name - const channelName = item.ChannelName; - return channelName ? ( - - {channelName} - - ) : null; - } - - // Default: show production year - return item.ProductionYear ? ( - - {item.ProductionYear} - - ) : null; - }; - - return ( - - - {item.Name} - - {renderSubtitle()} - - ); -}; - type PosterSizes = ReturnType; // TV-specific "See All" card for end of lists @@ -139,7 +67,7 @@ const TVSeeAllCard: React.FC<{ }) => { const { t } = useTranslation(); const width = - orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; + orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; return ( @@ -200,6 +128,8 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); @@ -279,7 +209,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }, [data]); const itemWidth = - orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster; + orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; const handleItemPress = useCallback( (item: BaseItemDto) => { @@ -310,70 +240,17 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; - const isHorizontal = orientation === "horizontal"; - - const renderPoster = () => { - if (item.Type === "Episode" && isHorizontal) { - return ; - } - if (item.Type === "Episode" && !isHorizontal) { - return ; - } - if (item.Type === "Movie" && isHorizontal) { - return ; - } - if (item.Type === "Movie" && !isHorizontal) { - return ; - } - if (item.Type === "Series" && !isHorizontal) { - return ; - } - if (item.Type === "Series" && isHorizontal) { - return ; - } - if (item.Type === "Program") { - return ; - } - if (item.Type === "BoxSet" && !isHorizontal) { - return ; - } - if (item.Type === "BoxSet" && isHorizontal) { - return ; - } - if (item.Type === "Playlist" && !isHorizontal) { - return ; - } - if (item.Type === "Playlist" && isHorizontal) { - return ; - } - if (item.Type === "Video" && !isHorizontal) { - return ; - } - if (item.Type === "Video" && isHorizontal) { - return ; - } - // Default fallback - return isHorizontal ? ( - - ) : ( - - ); - }; return ( - - + handleItemPress(item)} onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} onBlur={handleItemBlur} - > - {renderPoster()} - - @@ -387,7 +264,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ showItemActions, handleItemFocus, handleItemBlur, - typography, + ITEM_GAP, ], ); diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 07ba09b2..983cae57 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -11,10 +11,9 @@ import { FlatList, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -23,36 +22,8 @@ import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; -const ITEM_GAP = 16; const SCALE_PADDING = 20; -type Typography = ReturnType; - -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; -}> = ({ item, typography }) => { - return ( - - - {item.Name} - - - {item.ProductionYear} - - - ); -}; - interface WatchlistSectionProps extends ViewProps { watchlist: StreamystatsWatchlist; jellyfinServerId: string; @@ -67,6 +38,8 @@ const WatchlistSection: React.FC = ({ }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -135,27 +108,31 @@ const WatchlistSection: React.FC = ({ offset: (posterSizes.poster + ITEM_GAP) * index, index, }), - [], + [posterSizes.poster, ITEM_GAP], ); const renderItem = useCallback( ({ item }: { item: BaseItemDto }) => { return ( - - + handleItemPress(item)} onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} - hasTVPreferredFocus={false} - > - {item.Type === "Movie" && } - {item.Type === "Series" && } - - + width={posterSizes.poster} + /> ); }, - [handleItemPress, showItemActions, onItemFocus, typography], + [ + ITEM_GAP, + posterSizes.poster, + handleItemPress, + showItemActions, + onItemFocus, + ], ); if (!isLoading && (!items || items.length === 0)) return null; @@ -230,6 +207,8 @@ export const StreamystatsPromotedWatchlists: React.FC< StreamystatsPromotedWatchlistsProps > = ({ enabled = true, onItemFocus, ...props }) => { const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index c8a9cf0b..b72d120d 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -11,10 +11,8 @@ import { FlatList, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -23,11 +21,6 @@ import { useSettings } from "@/utils/atoms/settings"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; -const ITEM_GAP = 16; -const SCALE_PADDING = 20; - -type Typography = ReturnType; - interface Props extends ViewProps { title: string; type: "Movie" | "Series"; @@ -36,31 +29,6 @@ interface Props extends ViewProps { onItemFocus?: (item: BaseItemDto) => void; } -const TVItemCardText: React.FC<{ - item: BaseItemDto; - typography: Typography; -}> = ({ item, typography }) => { - return ( - - - {item.Name} - - - {item.ProductionYear} - - - ); -}; - export const StreamystatsRecommendations: React.FC = ({ title, type, @@ -70,7 +38,7 @@ export const StreamystatsRecommendations: React.FC = ({ ...props }) => { const typography = useScaledTVTypography(); - const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -192,31 +160,29 @@ export const StreamystatsRecommendations: React.FC = ({ const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ - length: posterSizes.poster + ITEM_GAP, - offset: (posterSizes.poster + ITEM_GAP) * index, + length: sizes.posters.poster + sizes.gaps.item, + offset: (sizes.posters.poster + sizes.gaps.item) * index, index, }), - [], + [sizes], ); const renderItem = useCallback( ({ item }: { item: BaseItemDto }) => { return ( - - + handleItemPress(item)} onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} - hasTVPreferredFocus={false} - > - {item.Type === "Movie" && } - {item.Type === "Series" && } - - + width={sizes.posters.poster} + /> ); }, - [handleItemPress, showItemActions, onItemFocus, typography], + [sizes, handleItemPress, showItemActions, onItemFocus], ); if (!streamyStatsEnabled) return null; @@ -231,7 +197,7 @@ export const StreamystatsRecommendations: React.FC = ({ fontWeight: "700", color: "#FFFFFF", marginBottom: 20, - marginLeft: SCALE_PADDING, + marginLeft: sizes.padding.scale, letterSpacing: 0.5, }} > @@ -242,17 +208,17 @@ export const StreamystatsRecommendations: React.FC = ({ {[1, 2, 3, 4, 5].map((i) => ( - + = ({ getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ - paddingVertical: SCALE_PADDING, - paddingHorizontal: SCALE_PADDING, + paddingVertical: sizes.padding.scale, + paddingHorizontal: sizes.padding.scale, }} /> )} diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 527bd74d..0c318905 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -23,7 +23,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { @@ -36,9 +36,6 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById" import { runtimeTicksToMinutes } from "@/utils/time"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); -const HERO_HEIGHT = SCREEN_HEIGHT * 0.62; -const CARD_GAP = 24; -const CARD_PADDING = 60; interface TVHeroCarouselProps { items: BaseItemDto[]; @@ -49,14 +46,14 @@ interface TVHeroCarouselProps { interface HeroCardProps { item: BaseItemDto; isFirst: boolean; - cardWidth: number; + sizes: ScaledTVSizes; onFocus: (item: BaseItemDto) => void; onPress: (item: BaseItemDto) => void; onLongPress?: (item: BaseItemDto) => void; } const HeroCard: React.FC = React.memo( - ({ item, isFirst, cardWidth, onFocus, onPress, onLongPress }) => { + ({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => { const api = useAtomValue(apiAtom); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -87,8 +84,6 @@ const HeroCard: React.FC = React.memo( return null; }, [api, item]); - const progress = item.UserData?.PlayedPercentage || 0; - const animateTo = useCallback( (value: number) => Animated.timing(scale, { @@ -102,9 +97,9 @@ const HeroCard: React.FC = React.memo( const handleFocus = useCallback(() => { setFocused(true); - animateTo(1.1); + animateTo(sizes.animation.focusScale); onFocus(item); - }, [animateTo, onFocus, item]); + }, [animateTo, onFocus, item, sizes.animation.focusScale]); const handleBlur = useCallback(() => { setFocused(false); @@ -120,7 +115,8 @@ const HeroCard: React.FC = React.memo( }, [onLongPress, item]); // Use glass poster for tvOS 26+ - if (useGlass) { + if (useGlass && posterUrl) { + const progress = item.UserData?.PlayedPercentage || 0; return ( = React.memo( onFocus={handleFocus} onBlur={handleBlur} hasTVPreferredFocus={isFirst} - style={{ marginRight: CARD_GAP }} + style={{ marginRight: sizes.gaps.item }} > ); @@ -152,13 +148,13 @@ const HeroCard: React.FC = React.memo( onFocus={handleFocus} onBlur={handleBlur} hasTVPreferredFocus={isFirst} - style={{ marginRight: CARD_GAP }} + style={{ marginRight: sizes.gaps.item }} > = ({ onItemLongPress, }) => { const typography = useScaledTVTypography(); - const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -365,13 +361,13 @@ export const TVHeroCarousel: React.FC = ({ ), - [handleCardFocus, handleCardPress, onItemLongPress, posterSizes.heroCard], + [handleCardFocus, handleCardPress, onItemLongPress, sizes], ); // Memoize keyExtractor @@ -379,8 +375,10 @@ export const TVHeroCarousel: React.FC = ({ if (items.length === 0) return null; + const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; + return ( - + {/* Backdrop layers with crossfade */} = ({ @@ -624,7 +622,7 @@ export const TVHeroCarousel: React.FC = ({ keyExtractor={keyExtractor} showsHorizontalScrollIndicator={false} style={{ overflow: "visible" }} - contentContainerStyle={{ paddingVertical: 12 }} + contentContainerStyle={{ paddingVertical: sizes.gaps.small }} renderItem={renderHeroCard} removeClippedSubviews={false} initialNumToRender={8} diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index f7062c61..cab9d566 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -26,11 +26,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { Loader } from "@/components/Loader"; -import MoviePoster from "@/components/posters/MoviePoster.tv"; -import SeriesPoster from "@/components/posters/SeriesPoster.tv"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVItemCardText } from "@/components/tv/TVItemCardText"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; @@ -43,7 +41,6 @@ const { width: SCREEN_WIDTH } = Dimensions.get("window"); const HORIZONTAL_PADDING = 80; const TOP_PADDING = 140; const ACTOR_IMAGE_SIZE = 250; -const ITEM_GAP = 16; const SCALE_PADDING = 20; interface TVActorPageProps { @@ -59,6 +56,8 @@ export const TVActorPage: React.FC = ({ personId }) => { const from = (segments as string[])[2] || "(home)"; const posterSizes = useScaledTVPosterSizes(); const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -243,19 +242,15 @@ export const TVActorPage: React.FC = ({ personId }) => { const renderMovieItem = useCallback( ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( - handleItemPress(filmItem)} onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={index === 0} - > - - - - - - - + width={posterSizes.poster} + /> ), [handleItemPress, showItemActions, posterSizes.poster], @@ -265,19 +260,15 @@ export const TVActorPage: React.FC = ({ personId }) => { const renderSeriesItem = useCallback( ({ item: filmItem, index }: { item: BaseItemDto; index: number }) => ( - handleItemPress(filmItem)} onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={movies.length === 0 && index === 0} - > - - - - - - - + width={posterSizes.poster} + /> ), [handleItemPress, showItemActions, posterSizes.poster, movies.length], diff --git a/components/posters/MoviePoster.tv.tsx b/components/posters/MoviePoster.tv.tsx deleted file mode 100644 index ed047543..00000000 --- a/components/posters/MoviePoster.tv.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; - -type MoviePosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -const MoviePoster: React.FC = ({ - item, - showProgress = false, -}) => { - const [api] = useAtom(apiAtom); - const posterSizes = useScaledTVPosterSizes(); - - const url = useMemo(() => { - return getPrimaryImageUrl({ - api, - item, - width: posterSizes.poster * 2, // 2x for quality on large screens - }); - }, [api, item, posterSizes.poster]); - - const progress = item.UserData?.PlayedPercentage || 0; - const isWatched = item.UserData?.Played === true; - - const blurhash = useMemo(() => { - const key = item.ImageTags?.Primary as string; - return item.ImageBlurHashes?.Primary?.[key]; - }, [item]); - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - if (useGlass) { - return ( - - ); - } - - // Fallback for older tvOS versions - return ( - - - - {showProgress && progress > 0 && ( - - )} - - ); -}; - -export default MoviePoster; diff --git a/components/posters/SeriesPoster.tv.tsx b/components/posters/SeriesPoster.tv.tsx deleted file mode 100644 index 125d9d3e..00000000 --- a/components/posters/SeriesPoster.tv.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtom } from "jotai"; -import { useMemo } from "react"; -import { View } from "react-native"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; - -type SeriesPosterProps = { - item: BaseItemDto; - showProgress?: boolean; -}; - -const SeriesPoster: React.FC = ({ item }) => { - const [api] = useAtom(apiAtom); - const posterSizes = useScaledTVPosterSizes(); - - const url = useMemo(() => { - if (item.Type === "Episode") { - return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${posterSizes.poster * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`; - } - return getPrimaryImageUrl({ - api, - item, - width: posterSizes.poster * 2, // 2x for quality on large screens - }); - }, [api, item, posterSizes.poster]); - - const blurhash = useMemo(() => { - const key = item.ImageTags?.Primary as string; - return item.ImageBlurHashes?.Primary?.[key]; - }, [item]); - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - if (useGlass) { - return ( - - ); - } - - // Fallback for older tvOS versions - return ( - - - - ); -}; - -export default SeriesPoster; diff --git a/components/search/TVJellyseerrSearchResults.tsx b/components/search/TVJellyseerrSearchResults.tsx index 5c8192f4..cba3a554 100644 --- a/components/search/TVJellyseerrSearchResults.tsx +++ b/components/search/TVJellyseerrSearchResults.tsx @@ -151,7 +151,7 @@ const TVJellyseerrPersonPoster: React.FC = ({ const typography = useScaledTVTypography(); const { jellyseerrApi } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation(); const posterUrl = item.profilePath ? jellyseerrApi?.imageProxy(item.profilePath, "w185") diff --git a/components/search/TVSearchBadge.tsx b/components/search/TVSearchBadge.tsx index 8e15eec0..61b47d64 100644 --- a/components/search/TVSearchBadge.tsx +++ b/components/search/TVSearchBadge.tsx @@ -17,7 +17,7 @@ export const TVSearchBadge: React.FC = ({ }) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); + useTVFocusAnimation({ duration: 150 }); return ( = ({ item }) => { - const typography = useScaledTVTypography(); - return ( - - {item.Type === "Episode" ? ( - <> - - {item.Name} - - - {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} - {" - "} - {item.SeriesName} - - - ) : item.Type === "MusicArtist" ? ( - - {item.Name} - - ) : item.Type === "MusicAlbum" ? ( - <> - - {item.Name} - - - {item.AlbumArtist || item.Artists?.join(", ")} - - - ) : item.Type === "Audio" ? ( - <> - - {item.Name} - - - {item.Artists?.join(", ") || item.AlbumArtist} - - - ) : item.Type === "Playlist" ? ( - <> - - {item.Name} - - - {item.ChildCount} tracks - - - ) : item.Type === "Person" ? ( - - {item.Name} - - ) : ( - <> - - {item.Name} - - - {item.ProductionYear} - - - )} - - ); -}; - interface TVSearchSectionProps extends ViewProps { title: string; items: BaseItemDto[]; @@ -160,6 +35,8 @@ export const TVSearchSection: React.FC = ({ }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); + const sizes = useScaledTVSizes(); + const ITEM_GAP = sizes.gaps.item; const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); @@ -189,146 +66,176 @@ export const TVSearchSection: React.FC = ({ offset: (itemWidth + ITEM_GAP) * index, index, }), - [itemWidth], + [itemWidth, ITEM_GAP], ); const renderItem = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => { const isFirstItem = isFirstSection && index === 0; - const isHorizontal = orientation === "horizontal"; - const renderPoster = () => { - // Music Artist - circular avatar - if (item.Type === "MusicArtist") { - const imageUrl = imageUrlGetter?.(item); - return ( - + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} > - {imageUrl ? ( - - ) : ( - - 👤 - - )} + + {imageUrl ? ( + + ) : ( + + 👤 + + )} + + + + + {item.Name} + - ); - } - - // Music Album, Audio, Playlist - square images - if ( - item.Type === "MusicAlbum" || - item.Type === "Audio" || - item.Type === "Playlist" - ) { - const imageUrl = imageUrlGetter?.(item); - const icon = - item.Type === "Playlist" - ? "🎶" - : item.Type === "Audio" - ? "🎵" - : "🎵"; - return ( - - {imageUrl ? ( - - ) : ( - - {icon} - - )} - - ); - } - - // Person (Actor) - if (item.Type === "Person") { - return ; - } - - // Episode rendering - if (item.Type === "Episode" && isHorizontal) { - return ; - } - if (item.Type === "Episode" && !isHorizontal) { - return ; - } - - // Movie rendering - if (item.Type === "Movie" && isHorizontal) { - return ; - } - if (item.Type === "Movie" && !isHorizontal) { - return ; - } - - // Series rendering - if (item.Type === "Series" && !isHorizontal) { - return ; - } - if (item.Type === "Series" && isHorizontal) { - return ; - } - - // BoxSet (Collection) - if (item.Type === "BoxSet" && !isHorizontal) { - return ; - } - if (item.Type === "BoxSet" && isHorizontal) { - return ; - } - - // Default fallback - return isHorizontal ? ( - - ) : ( - + ); - }; + } - // Special width for music artists (circular) - const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth; + // Special handling for MusicAlbum, Audio, Playlist (square images) + if ( + item.Type === "MusicAlbum" || + item.Type === "Audio" || + item.Type === "Playlist" + ) { + const imageUrl = imageUrlGetter?.(item); + const icon = + item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵"; + return ( + + onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } + hasTVPreferredFocus={isFirstItem && !disabled} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + disabled={disabled} + > + + {imageUrl ? ( + + ) : ( + + {icon} + + )} + + + + + {item.Name} + + {item.Type === "MusicAlbum" && ( + + {item.AlbumArtist || item.Artists?.join(", ")} + + )} + {item.Type === "Audio" && ( + + {item.Artists?.join(", ") || item.AlbumArtist} + + )} + {item.Type === "Playlist" && ( + + {item.ChildCount} tracks + + )} + + + ); + } + // Use TVPosterCard for all other item types return ( - - + onItemPress(item)} onLongPress={ onItemLongPress ? () => onItemLongPress(item) : undefined @@ -337,10 +244,8 @@ export const TVSearchSection: React.FC = ({ onFocus={handleItemFocus} onBlur={handleItemBlur} disabled={disabled} - > - {renderPoster()} - - + width={itemWidth} + /> ); }, @@ -354,6 +259,9 @@ export const TVSearchSection: React.FC = ({ handleItemBlur, disabled, imageUrlGetter, + posterSizes.poster, + typography.callout, + ITEM_GAP, ], ); diff --git a/components/search/TVSearchTabBadges.tsx b/components/search/TVSearchTabBadges.tsx index d0d5f857..d15d43ce 100644 --- a/components/search/TVSearchTabBadges.tsx +++ b/components/search/TVSearchTabBadges.tsx @@ -23,7 +23,7 @@ const TVSearchTabBadge: React.FC = ({ }) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); + useTVFocusAnimation({ duration: 150 }); // Design language: white for focused/selected, transparent white for unfocused const getBackgroundColor = () => { diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx deleted file mode 100644 index af1d8353..00000000 --- a/components/series/TVEpisodeCard.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { Image } from "expo-image"; -import { useAtomValue } from "jotai"; -import React, { useMemo } from "react"; -import { View } from "react-native"; -import { ProgressBar } from "@/components/common/ProgressBar"; -import { Text } from "@/components/common/Text"; -import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; -import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; -import { useScaledTVTypography } from "@/constants/TVTypography"; -import { - GlassPosterView, - isGlassEffectAvailable, -} from "@/modules/glass-poster"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { runtimeTicksToMinutes } from "@/utils/time"; - -interface TVEpisodeCardProps { - episode: BaseItemDto; - hasTVPreferredFocus?: boolean; - disabled?: boolean; - /** When true, the item remains focusable even when disabled (for navigation purposes) */ - focusableWhenDisabled?: boolean; - /** Shows a "Now Playing" badge on the card */ - isCurrent?: boolean; - onPress: () => void; - onLongPress?: () => void; - onFocus?: () => void; - onBlur?: () => void; - /** Setter function for the ref (for focus guide destinations) */ - refSetter?: (ref: View | null) => void; -} - -export const TVEpisodeCard: React.FC = ({ - episode, - hasTVPreferredFocus = false, - disabled = false, - focusableWhenDisabled = false, - isCurrent = false, - onPress, - onLongPress, - onFocus, - onBlur, - refSetter, -}) => { - const typography = useScaledTVTypography(); - const posterSizes = useScaledTVPosterSizes(); - const api = useAtomValue(apiAtom); - - const thumbnailUrl = useMemo(() => { - if (!api) return null; - - // Try to get episode primary image first - if (episode.ImageTags?.Primary) { - return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`; - } - - // Fall back to series thumb or backdrop - if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) { - return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`; - } - - // Default episode image - return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`; - }, [api, episode]); - - const duration = useMemo(() => { - if (!episode.RunTimeTicks) return null; - return runtimeTicksToMinutes(episode.RunTimeTicks); - }, [episode.RunTimeTicks]); - - const episodeLabel = useMemo(() => { - const season = episode.ParentIndexNumber; - const ep = episode.IndexNumber; - if (season !== undefined && ep !== undefined) { - return `S${season}:E${ep}`; - } - return null; - }, [episode.ParentIndexNumber, episode.IndexNumber]); - - const progress = episode.UserData?.PlayedPercentage || 0; - const isWatched = episode.UserData?.Played === true; - - // Use glass effect on tvOS 26+ - const useGlass = isGlassEffectAvailable(); - - // Now Playing badge component (shared between glass and fallback) - const NowPlayingBadge = isCurrent ? ( - - - - Now Playing - - - ) : null; - - return ( - - - {useGlass ? ( - - - {NowPlayingBadge} - - ) : ( - - {thumbnailUrl ? ( - - ) : ( - - )} - - - {NowPlayingBadge} - - )} - - - {/* Episode info below thumbnail */} - - - {episodeLabel && ( - - {episodeLabel} - - )} - {duration && ( - <> - - • - - - {duration} - - - )} - - - {episode.Name} - - - - ); -}; diff --git a/components/series/TVEpisodeList.tsx b/components/series/TVEpisodeList.tsx index 5e48e9c1..0f271320 100644 --- a/components/series/TVEpisodeList.tsx +++ b/components/series/TVEpisodeList.tsx @@ -1,12 +1,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import React from "react"; +import React, { useCallback } from "react"; import { ScrollView, View } from "react-native"; -import { Text } from "@/components/common/Text"; -import { TVEpisodeCard } from "@/components/series/TVEpisodeCard"; -import { useScaledTVTypography } from "@/constants/TVTypography"; - -const LIST_GAP = 24; -const VERTICAL_PADDING = 12; +import { TVHorizontalList } from "@/components/tv/TVHorizontalList"; +import { TVPosterCard } from "@/components/tv/TVPosterCard"; interface TVEpisodeListProps { episodes: BaseItemDto[]; @@ -28,7 +24,7 @@ interface TVEpisodeListProps { firstEpisodeRefSetter?: (ref: View | null) => void; /** Text to show when episodes array is empty */ emptyText?: string; - /** Horizontal padding for the list content (default: 80) */ + /** Horizontal padding for the list content */ horizontalPadding?: number; } @@ -43,57 +39,51 @@ export const TVEpisodeList: React.FC = ({ scrollViewRef, firstEpisodeRefSetter, emptyText, - horizontalPadding = 80, + horizontalPadding, }) => { - const typography = useScaledTVTypography(); + const renderItem = useCallback( + ({ item: episode, index }: { item: BaseItemDto; index: number }) => { + const isCurrent = currentEpisodeId + ? episode.Id === currentEpisodeId + : false; + return ( + onEpisodePress(episode)} + onLongPress={ + onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined + } + onFocus={onFocus} + onBlur={onBlur} + disabled={isCurrent || disabled} + focusableWhenDisabled={isCurrent} + isCurrent={isCurrent} + refSetter={index === 0 ? firstEpisodeRefSetter : undefined} + /> + ); + }, + [ + currentEpisodeId, + disabled, + firstEpisodeRefSetter, + onBlur, + onEpisodeLongPress, + onEpisodePress, + onFocus, + ], + ); - if (episodes.length === 0 && emptyText) { - return ( - - {emptyText} - - ); - } + const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []); return ( - } - horizontal - showsHorizontalScrollIndicator={false} - style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }} - contentContainerStyle={{ - paddingHorizontal: horizontalPadding, - paddingVertical: VERTICAL_PADDING, - gap: LIST_GAP, - }} - > - {episodes.map((episode, index) => { - const isCurrent = currentEpisodeId - ? episode.Id === currentEpisodeId - : false; - return ( - onEpisodePress(episode)} - onLongPress={ - onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined - } - onFocus={onFocus} - onBlur={onBlur} - disabled={isCurrent || disabled} - focusableWhenDisabled={isCurrent} - isCurrent={isCurrent} - refSetter={index === 0 ? firstEpisodeRefSetter : undefined} - /> - ); - })} - + ); }; diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index aec682e9..29817512 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -21,7 +21,7 @@ export const TVActorCard = React.forwardRef( ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); + useTVFocusAnimation(); const imageUrl = person.Id ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90` diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx index f1f1276b..95c5f3d1 100644 --- a/components/tv/TVCastSection.tsx +++ b/components/tv/TVCastSection.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, TVFocusGuideView, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVActorCard } from "./TVActorCard"; @@ -25,6 +26,7 @@ export const TVCastSection: React.FC = React.memo( upwardFocusDestination, }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { t } = useTranslation(); if (cast.length === 0) { @@ -57,7 +59,7 @@ export const TVCastSection: React.FC = React.memo( contentContainerStyle={{ paddingHorizontal: 80, paddingVertical: 16, - gap: 28, + gap: sizes.gaps.item, }} > {cast.map((person, index) => ( diff --git a/components/tv/TVHorizontalList.tsx b/components/tv/TVHorizontalList.tsx new file mode 100644 index 00000000..87a2db73 --- /dev/null +++ b/components/tv/TVHorizontalList.tsx @@ -0,0 +1,221 @@ +import React, { useCallback } from "react"; +import { FlatList, ScrollView, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; +import { useScaledTVTypography } from "@/constants/TVTypography"; + +interface TVHorizontalListProps { + /** Data items to render */ + data: T[]; + /** Unique key extractor */ + keyExtractor: (item: T, index: number) => string; + /** Render function for each item */ + renderItem: (info: { item: T; index: number }) => React.ReactElement | null; + /** Optional section title */ + title?: string; + /** Text to show when data array is empty */ + emptyText?: string; + /** Whether to use FlatList (for large/infinite lists) or ScrollView (for small lists) */ + useFlatList?: boolean; + /** Called when end is reached (only for FlatList) */ + onEndReached?: () => void; + /** Ref for the scroll view */ + scrollViewRef?: React.RefObject | null>; + /** Footer component (only for FlatList) */ + ListFooterComponent?: React.ReactElement | null; + /** Whether this is the first section (for initial focus) */ + isFirstSection?: boolean; + /** Loading state */ + isLoading?: boolean; + /** Skeleton item count when loading */ + skeletonCount?: number; + /** Skeleton render function */ + renderSkeleton?: () => React.ReactElement; + /** + * Custom horizontal padding (overrides default sizes.padding.scale). + * Use this when the list needs to extend beyond its parent's padding. + * The list will use negative margin to extend beyond the parent, + * then add this padding inside to align content properly. + */ + horizontalPadding?: number; +} + +/** + * TVHorizontalList - A unified horizontal list component for TV. + * + * Provides consistent spacing and layout for horizontal lists: + * - Uses `sizes.gaps.item` (24px default) for gap between items + * - Uses `sizes.padding.scale` (20px default) for padding to accommodate focus scale + * - Supports both ScrollView (small lists) and FlatList (large/infinite lists) + */ +export function TVHorizontalList({ + data, + keyExtractor, + renderItem, + title, + emptyText, + useFlatList = false, + onEndReached, + scrollViewRef, + ListFooterComponent, + isLoading = false, + skeletonCount = 5, + renderSkeleton, + horizontalPadding, +}: TVHorizontalListProps) { + const sizes = useScaledTVSizes(); + const typography = useScaledTVTypography(); + + // Use custom horizontal padding if provided, otherwise use default scale padding + const effectiveHorizontalPadding = horizontalPadding ?? sizes.padding.scale; + // Apply negative margin when using custom padding to extend beyond parent + const marginHorizontal = horizontalPadding ? -horizontalPadding : 0; + + // Wrap renderItem to add consistent gap + const renderItemWithGap = useCallback( + ({ item, index }: { item: T; index: number }) => { + const isLast = index === data.length - 1; + return ( + + {renderItem({ item, index })} + + ); + }, + [data.length, renderItem, sizes.gaps.item], + ); + + // Empty state + if (!isLoading && data.length === 0 && emptyText) { + return ( + + {title && ( + + {title} + + )} + + {emptyText} + + + ); + } + + // Loading state + if (isLoading && renderSkeleton) { + return ( + + {title && ( + + {title} + + )} + + {Array.from({ length: skeletonCount }).map((_, i) => ( + {renderSkeleton()} + ))} + + + ); + } + + const contentContainerStyle = { + paddingHorizontal: effectiveHorizontalPadding, + paddingVertical: sizes.padding.scale, + }; + + const listStyle = { + overflow: "visible" as const, + marginHorizontal, + }; + + return ( + + {title && ( + + {title} + + )} + + {useFlatList ? ( + >} + horizontal + data={data} + keyExtractor={keyExtractor} + renderItem={renderItemWithGap} + showsHorizontalScrollIndicator={false} + removeClippedSubviews={false} + style={listStyle} + contentContainerStyle={contentContainerStyle} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={5} + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + ListFooterComponent={ListFooterComponent} + /> + ) : ( + } + horizontal + showsHorizontalScrollIndicator={false} + style={listStyle} + contentContainerStyle={contentContainerStyle} + > + {data.map((item, index) => ( + + {renderItem({ item, index })} + + ))} + {ListFooterComponent} + + )} + + ); +} diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx index 33813775..5414dde7 100644 --- a/components/tv/TVSeriesNavigation.tsx +++ b/components/tv/TVSeriesNavigation.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; +import { useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; @@ -17,6 +18,7 @@ export interface TVSeriesNavigationProps { export const TVSeriesNavigation: React.FC = React.memo( ({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => { const typography = useScaledTVTypography(); + const sizes = useScaledTVSizes(); const { t } = useTranslation(); // Only show for episodes with a series @@ -25,13 +27,14 @@ export const TVSeriesNavigation: React.FC = React.memo( } return ( - + {t("item_card.from_this_series") || "From this Series"} @@ -39,11 +42,14 @@ export const TVSeriesNavigation: React.FC = React.memo( {/* Series card */} diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx index 3522f711..a2a3e565 100644 --- a/components/tv/settings/TVSettingsToggle.tsx +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -57,7 +57,7 @@ export const TVSettingsToggle: React.FC = ({ width: 56, height: 32, borderRadius: 16, - backgroundColor: value ? "#34C759" : "#4B5563", + backgroundColor: value ? "#FFFFFF" : "#4B5563", justifyContent: "center", paddingHorizontal: 2, }} @@ -67,7 +67,7 @@ export const TVSettingsToggle: React.FC = ({ width: 28, height: 28, borderRadius: 14, - backgroundColor: "#FFFFFF", + backgroundColor: value ? "#000000" : "#FFFFFF", alignSelf: value ? "flex-end" : "flex-start", }} /> diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts index 06f661db..cec24162 100644 --- a/components/video-player/controls/constants.ts +++ b/components/video-player/controls/constants.ts @@ -7,6 +7,7 @@ export const CONTROLS_CONSTANTS = { PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks LONG_PRESS_INITIAL_SEEK: 30, LONG_PRESS_ACCELERATION: 1.2, + LONG_PRESS_MAX_ACCELERATION: 4, LONG_PRESS_INTERVAL: 300, SLIDER_DEBOUNCE_MS: 3, } as const; diff --git a/constants/TVPosterSizes.ts b/constants/TVPosterSizes.ts index 5295adef..132b75a6 100644 --- a/constants/TVPosterSizes.ts +++ b/constants/TVPosterSizes.ts @@ -1,57 +1,12 @@ -import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; - /** - * TV Poster Sizes - * - * Base sizes for poster components on TV interfaces. - * These are scaled dynamically based on the user's tvTypographyScale setting. + * @deprecated Import from "@/constants/TVSizes" instead. + * This file is kept for backwards compatibility. */ -export const TVPosterSizes = { - /** Portrait posters (movies, series) - 10:15 aspect ratio */ - poster: 256, +export { + type ScaledTVPosterSizes, + TVPosterSizes, + useScaledTVPosterSizes, +} from "./TVSizes"; - /** Landscape posters (continue watching, thumbs) - 16:9 aspect ratio */ - landscape: 396, - - /** Episode cards - 16:9 aspect ratio */ - episode: 336, - - /** Hero carousel cards - 16:9 aspect ratio */ - heroCard: 276, -} as const; - -export type TVPosterSizeKey = keyof typeof TVPosterSizes; - -/** - * Linear poster size offsets (in pixels) - synchronized with typography scale. - * Uses fixed pixel steps for consistent linear scaling across all poster types. - */ -const posterScaleOffsets: Record = { - [TVTypographyScale.Small]: -10, - [TVTypographyScale.Default]: 0, - [TVTypographyScale.Large]: 10, - [TVTypographyScale.ExtraLarge]: 20, -}; - -/** - * Hook that returns scaled TV poster sizes based on user settings. - * Use this instead of the static TVPosterSizes constant for dynamic scaling. - * - * @example - * const posterSizes = useScaledTVPosterSizes(); - * - */ -export const useScaledTVPosterSizes = () => { - const { settings } = useSettings(); - const offset = - posterScaleOffsets[settings.tvTypographyScale] ?? - posterScaleOffsets[TVTypographyScale.Default]; - - return { - poster: TVPosterSizes.poster + offset, - landscape: TVPosterSizes.landscape + offset, - episode: TVPosterSizes.episode + offset, - heroCard: TVPosterSizes.heroCard + offset, - }; -}; +export type TVPosterSizeKey = keyof typeof import("./TVSizes").TVPosterSizes; diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts new file mode 100644 index 00000000..20c38daa --- /dev/null +++ b/constants/TVSizes.ts @@ -0,0 +1,175 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; + +/** + * TV Layout Sizes + * + * Unified constants for TV interface layout including posters, gaps, and padding. + * All values scale based on the user's tvTypographyScale setting. + */ + +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= + +/** + * Base poster widths in pixels. + * Heights are calculated from aspect ratios. + */ +export const TVPosterSizes = { + /** Portrait posters (movies, series) - 10:15 aspect ratio */ + poster: 210, + + /** Landscape posters (continue watching, thumbs, hero) - 16:9 aspect ratio */ + landscape: 340, + + /** Episode cards - 16:9 aspect ratio */ + episode: 320, +} as const; + +/** + * Base gap/spacing values in pixels. + */ +export const TVGaps = { + /** Gap between items in horizontal lists */ + item: 24, + + /** Gap between sections vertically */ + section: 32, + + /** Small gap for tight layouts */ + small: 12, + + /** Large gap for spacious layouts */ + large: 48, +} as const; + +/** + * Base padding values in pixels. + */ +export const TVPadding = { + /** Horizontal padding from screen edges */ + horizontal: 60, + + /** Padding to accommodate scale animations (1.05x) */ + scale: 20, + + /** Vertical padding for content areas */ + vertical: 24, + + /** Hero section height as percentage of screen height (0.0 - 1.0) */ + heroHeight: 0.6, +} as const; + +/** + * Animation and interaction values. + */ +export const TVAnimation = { + /** Scale factor for focused items */ + focusScale: 1.05, +} as const; + +// ============================================================================= +// SCALING +// ============================================================================= + +/** + * Scale multipliers for each typography scale level. + * Applied to poster sizes and gaps. + */ +const sizeScaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.9, + [TVTypographyScale.Default]: 1.0, + [TVTypographyScale.Large]: 1.1, + [TVTypographyScale.ExtraLarge]: 1.2, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVPosterSizes = { + poster: number; + landscape: number; + episode: number; +}; + +export type ScaledTVGaps = { + item: number; + section: number; + small: number; + large: number; +}; + +export type ScaledTVPadding = { + horizontal: number; + scale: number; + vertical: number; + heroHeight: number; +}; + +export type ScaledTVSizes = { + posters: ScaledTVPosterSizes; + gaps: ScaledTVGaps; + padding: ScaledTVPadding; + animation: typeof TVAnimation; +}; + +/** + * Hook that returns all scaled TV sizes based on user settings. + * + * @example + * const sizes = useScaledTVSizes(); + * + */ +export const useScaledTVSizes = (): ScaledTVSizes => { + const { settings } = useSettings(); + const scale = + sizeScaleMultipliers[settings.tvTypographyScale] ?? + sizeScaleMultipliers[TVTypographyScale.Default]; + + return { + posters: { + poster: Math.round(TVPosterSizes.poster * scale), + landscape: Math.round(TVPosterSizes.landscape * scale), + episode: Math.round(TVPosterSizes.episode * scale), + }, + gaps: { + item: Math.round(TVGaps.item * scale), + section: Math.round(TVGaps.section * scale), + small: Math.round(TVGaps.small * scale), + large: Math.round(TVGaps.large * scale), + }, + padding: { + horizontal: Math.round(TVPadding.horizontal * scale), + scale: Math.round(TVPadding.scale * scale), + vertical: Math.round(TVPadding.vertical * scale), + heroHeight: TVPadding.heroHeight * scale, + }, + animation: TVAnimation, + }; +}; + +/** + * Hook that returns only scaled poster sizes. + * Use this for backwards compatibility or when you only need poster sizes. + */ +export const useScaledTVPosterSizes = (): ScaledTVPosterSizes => { + const sizes = useScaledTVSizes(); + return sizes.posters; +}; + +/** + * Hook that returns only scaled gap sizes. + */ +export const useScaledTVGaps = (): ScaledTVGaps => { + const sizes = useScaledTVSizes(); + return sizes.gaps; +}; + +/** + * Hook that returns only scaled padding sizes. + */ +export const useScaledTVPadding = (): ScaledTVPadding => { + const sizes = useScaledTVSizes(); + return sizes.padding; +}; diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift index 77c5efb8..b68cdb97 100644 --- a/modules/glass-poster/ios/GlassPosterView.swift +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -59,7 +59,7 @@ struct GlassPosterView: View { .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .focusable() .focused($isInternallyFocused) - .scaleEffect(isCurrentlyFocused ? 1.08 : 1.0) + .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) .animation(.easeOut(duration: 0.15), value: isCurrentlyFocused) } #endif @@ -87,7 +87,7 @@ struct GlassPosterView: View { } } .frame(width: width, height: height) - .scaleEffect(isFocused ? 1.08 : 1.0) + .scaleEffect(isFocused ? 1.05 : 1.0) .animation(.easeOut(duration: 0.15), value: isFocused) }