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)
}