feat(tv): add scalable poster sizes synchronized with typography settings

This commit is contained in:
Fredrik Burmester
2026-01-26 18:04:22 +01:00
parent bbd7854287
commit d51cf47eb4
18 changed files with 176 additions and 104 deletions

View File

@@ -16,16 +16,13 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "../ContinueWatchingPoster.tv";
import ContinueWatchingPoster from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 24;
@@ -101,6 +98,8 @@ const TVItemCardText: React.FC<{
);
};
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
// TV-specific "See All" card for end of lists
const TVSeeAllCard: React.FC<{
onPress: () => void;
@@ -109,10 +108,19 @@ const TVSeeAllCard: React.FC<{
onFocus?: () => void;
onBlur?: () => void;
typography: Typography;
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
posterSizes: PosterSizes;
}> = ({
onPress,
orientation,
disabled,
onFocus,
onBlur,
typography,
posterSizes,
}) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return (
@@ -172,6 +180,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
@@ -250,7 +259,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [data]);
const itemWidth =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
@@ -487,6 +496,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
typography={typography}
posterSizes={posterSizes}
/>
)}
</View>

View File

@@ -11,11 +11,10 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
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 { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -66,6 +65,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -129,8 +129,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[],
@@ -139,7 +139,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
@@ -182,11 +182,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
@@ -226,6 +226,7 @@ interface StreamystatsPromotedWatchlistsProps extends ViewProps {
export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -316,11 +317,11 @@ export const StreamystatsPromotedWatchlists: React.FC<
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,

View File

@@ -11,11 +11,10 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
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 { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -70,6 +69,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -190,8 +190,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[],
@@ -200,7 +200,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
@@ -245,11 +245,11 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,

View File

@@ -23,6 +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 { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -36,7 +37,6 @@ import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
const CARD_WIDTH = 280;
const CARD_GAP = 24;
const CARD_PADDING = 60;
@@ -48,12 +48,13 @@ interface TVHeroCarouselProps {
interface HeroCardProps {
item: BaseItemDto;
isFirst: boolean;
cardWidth: number;
onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void;
}
const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, onFocus, onPress }) => {
({ item, isFirst, cardWidth, onFocus, onPress }) => {
const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -129,8 +130,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
width={CARD_WIDTH}
style={{ width: CARD_WIDTH }}
width={cardWidth}
style={{ width: cardWidth }}
/>
</Pressable>
);
@@ -147,7 +148,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
>
<Animated.View
style={{
width: CARD_WIDTH,
width: cardWidth,
aspectRatio: 16 / 9,
borderRadius: 16,
overflow: "hidden",
@@ -196,6 +197,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
onItemFocus,
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -354,11 +356,12 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<HeroCard
item={item}
isFirst={index === 0}
cardWidth={posterSizes.heroCard}
onFocus={handleCardFocus}
onPress={handleCardPress}
/>
),
[handleCardFocus, handleCardPress],
[handleCardFocus, handleCardPress, posterSizes.heroCard],
);
// Memoize keyExtractor