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

@@ -142,22 +142,22 @@ export default function SettingsTV() {
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo( const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [ () => [
{ {
label: t("home.settings.appearance.text_size_small"), label: t("home.settings.appearance.display_size_small"),
value: TVTypographyScale.Small, value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small, selected: currentTypographyScale === TVTypographyScale.Small,
}, },
{ {
label: t("home.settings.appearance.text_size_default"), label: t("home.settings.appearance.display_size_default"),
value: TVTypographyScale.Default, value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default, selected: currentTypographyScale === TVTypographyScale.Default,
}, },
{ {
label: t("home.settings.appearance.text_size_large"), label: t("home.settings.appearance.display_size_large"),
value: TVTypographyScale.Large, value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large, selected: currentTypographyScale === TVTypographyScale.Large,
}, },
{ {
label: t("home.settings.appearance.text_size_extra_large"), label: t("home.settings.appearance.display_size_extra_large"),
value: TVTypographyScale.ExtraLarge, value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge, selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
}, },
@@ -188,7 +188,7 @@ export default function SettingsTV() {
const typographyScaleLabel = useMemo(() => { const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected); const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.text_size_default"); return option?.label || t("home.settings.appearance.display_size_default");
}, [typographyScaleOptions, t]); }, [typographyScaleOptions, t]);
return ( return (
@@ -385,11 +385,11 @@ export default function SettingsTV() {
{/* Appearance Section */} {/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} /> <TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton <TVSettingsOptionButton
label={t("home.settings.appearance.text_size")} label={t("home.settings.appearance.display_size")}
value={typographyScaleLabel} value={typographyScaleLabel}
onPress={() => onPress={() =>
showOptions({ showOptions({
title: t("home.settings.appearance.text_size"), title: t("home.settings.appearance.display_size"),
options: typographyScaleOptions, options: typographyScaleOptions,
onSelect: (value) => onSelect: (value) =>
updateSettings({ tvTypographyScale: value }), updateSettings({ tvTypographyScale: value }),

View File

@@ -27,15 +27,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, { import MoviePoster from "@/components/posters/MoviePoster.tv";
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { import {
TVFilterButton, TVFilterButton,
TVFocusablePoster, TVFocusablePoster,
TVItemCardText, TVItemCardText,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -60,6 +59,7 @@ const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string }; const { collectionId } = searchParams as { collectionId: string };
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
@@ -153,7 +153,7 @@ const page: React.FC = () => {
// Calculate columns for TV grid // Calculate columns for TV grid
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) { if (Platform.isTV) {
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP; const itemWidth = posterSizes.poster + TV_ITEM_GAP;
return Math.max( return Math.max(
1, 1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth), Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
@@ -291,7 +291,7 @@ const page: React.FC = () => {
style={{ style={{
marginRight: TV_ITEM_GAP, marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP, marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH, width: posterSizes.poster,
}} }}
> >
<TVFocusablePoster onPress={handlePress}> <TVFocusablePoster onPress={handlePress}>

View File

@@ -33,15 +33,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, { import MoviePoster from "@/components/posters/MoviePoster.tv";
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { import {
TVFilterButton, TVFilterButton,
TVFocusablePoster, TVFocusablePoster,
TVItemCardText, TVItemCardText,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
@@ -85,6 +84,7 @@ const Page = () => {
const { libraryId } = searchParams; const { libraryId } = searchParams;
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
@@ -409,7 +409,7 @@ const Page = () => {
<View <View
key={item.Id} key={item.Id}
style={{ style={{
width: TV_POSTER_WIDTH, width: posterSizes.poster,
}} }}
> >
<TVFocusablePoster onPress={handlePress}> <TVFocusablePoster onPress={handlePress}>

View File

@@ -24,11 +24,10 @@ import {
} from "@/components/common/TouchableItemRouter"; } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, { import MoviePoster from "@/components/posters/MoviePoster.tv";
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
@@ -73,6 +72,7 @@ const TVItemCardText: React.FC<{
export default function WatchlistDetailScreen() { export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -206,7 +206,7 @@ export default function WatchlistDetailScreen() {
<View <View
key={item.Id} key={item.Id}
style={{ style={{
width: TV_POSTER_WIDTH, width: posterSizes.poster,
}} }}
> >
<TVFocusablePoster <TVFocusablePoster

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from "jotai";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { import {
GlassPosterView, GlassPosterView,
isGlassEffectAvailable, isGlassEffectAvailable,
@@ -13,8 +14,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar"; import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 400;
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
useEpisodePoster?: boolean; useEpisodePoster?: boolean;
@@ -30,6 +29,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
showPlayButton = false, showPlayButton = false,
}) => { }) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const url = useMemo(() => { const url = useMemo(() => {
if (!api) { if (!api) {
@@ -91,7 +91,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return ( return (
<View <View
style={{ style={{
width: TV_LANDSCAPE_WIDTH, width: posterSizes.landscape,
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
borderRadius: 24, borderRadius: 24,
}} }}
@@ -109,8 +109,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
progress={progress} progress={progress}
showWatchedIndicator={isWatched} showWatchedIndicator={isWatched}
isFocused={false} isFocused={false}
width={TV_LANDSCAPE_WIDTH} width={posterSizes.landscape}
style={{ width: TV_LANDSCAPE_WIDTH }} style={{ width: posterSizes.landscape }}
/> />
{showPlayButton && ( {showPlayButton && (
<View <View
@@ -136,7 +136,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
<View <View
style={{ style={{
position: "relative", position: "relative",
width: TV_LANDSCAPE_WIDTH, width: posterSizes.landscape,
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
borderRadius: 24, borderRadius: 24,
overflow: "hidden", overflow: "hidden",

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,8 @@ import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import MoviePoster, { import MoviePoster from "@/components/posters/MoviePoster.tv";
TV_POSTER_WIDTH, import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
} from "@/components/posters/MoviePoster.tv";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -103,6 +102,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -276,8 +276,8 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
// List item layout // List item layout
const getItemLayout = useCallback( const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({ (_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP, length: posterSizes.poster + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, offset: (posterSizes.poster + ITEM_GAP) * index,
index, index,
}), }),
[], [],
@@ -297,7 +297,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
> >
<View> <View>
<MoviePoster item={filmItem} /> <MoviePoster item={filmItem} />
<View style={{ width: TV_POSTER_WIDTH, marginTop: 8 }}> <View style={{ width: posterSizes.poster, marginTop: 8 }}>
<ItemCardText item={filmItem} /> <ItemCardText item={filmItem} />
</View> </View>
</View> </View>

View File

@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator"; import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { import {
GlassPosterView, GlassPosterView,
isGlassEffectAvailable, isGlassEffectAvailable,
@@ -11,8 +12,6 @@ import {
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_POSTER_WIDTH = 260;
type MoviePosterProps = { type MoviePosterProps = {
item: BaseItemDto; item: BaseItemDto;
showProgress?: boolean; showProgress?: boolean;
@@ -23,14 +22,15 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
showProgress = false, showProgress = false,
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const url = useMemo(() => { const url = useMemo(() => {
return getPrimaryImageUrl({ return getPrimaryImageUrl({
api, api,
item, item,
width: 520, // 2x for quality on large screens width: posterSizes.poster * 2, // 2x for quality on large screens
}); });
}, [api, item]); }, [api, item, posterSizes.poster]);
const progress = item.UserData?.PlayedPercentage || 0; const progress = item.UserData?.PlayedPercentage || 0;
const isWatched = item.UserData?.Played === true; const isWatched = item.UserData?.Played === true;
@@ -52,8 +52,8 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
progress={showProgress ? progress : 0} progress={showProgress ? progress : 0}
showWatchedIndicator={isWatched} showWatchedIndicator={isWatched}
isFocused={false} isFocused={false}
width={TV_POSTER_WIDTH} width={posterSizes.poster}
style={{ width: TV_POSTER_WIDTH }} style={{ width: posterSizes.poster }}
/> />
); );
} }
@@ -65,7 +65,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
position: "relative", position: "relative",
borderRadius: 24, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
width: TV_POSTER_WIDTH, width: posterSizes.poster,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
}} }}
> >

View File

@@ -3,6 +3,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { import {
GlassPosterView, GlassPosterView,
isGlassEffectAvailable, isGlassEffectAvailable,
@@ -10,8 +11,6 @@ import {
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_POSTER_WIDTH = 260;
type SeriesPosterProps = { type SeriesPosterProps = {
item: BaseItemDto; item: BaseItemDto;
showProgress?: boolean; showProgress?: boolean;
@@ -19,17 +18,18 @@ type SeriesPosterProps = {
const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => { const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const url = useMemo(() => { const url = useMemo(() => {
if (item.Type === "Episode") { if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`; return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${posterSizes.poster * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
} }
return getPrimaryImageUrl({ return getPrimaryImageUrl({
api, api,
item, item,
width: 520, // 2x for quality on large screens width: posterSizes.poster * 2, // 2x for quality on large screens
}); });
}, [api, item]); }, [api, item, posterSizes.poster]);
const blurhash = useMemo(() => { const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string; const key = item.ImageTags?.Primary as string;
@@ -48,8 +48,8 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
progress={0} progress={0}
showWatchedIndicator={false} showWatchedIndicator={false}
isFocused={false} isFocused={false}
width={TV_POSTER_WIDTH} width={posterSizes.poster}
style={{ width: TV_POSTER_WIDTH }} style={{ width: posterSizes.poster }}
/> />
); );
} }
@@ -58,7 +58,7 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
return ( return (
<View <View
style={{ style={{
width: TV_POSTER_WIDTH, width: posterSizes.poster,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
position: "relative", position: "relative",
borderRadius: 24, borderRadius: 24,

View File

@@ -2,15 +2,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { FlatList, View, type ViewProps } from "react-native"; import { FlatList, View, type ViewProps } from "react-native";
import ContinueWatchingPoster, { import ContinueWatchingPoster from "@/components/ContinueWatchingPoster.tv";
TV_LANDSCAPE_WIDTH,
} from "@/components/ContinueWatchingPoster.tv";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster, { import MoviePoster from "@/components/posters/MoviePoster.tv";
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
const ITEM_GAP = 16; const ITEM_GAP = 16;
@@ -160,6 +157,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
...props ...props
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const flatListRef = useRef<FlatList<BaseItemDto>>(null); const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0); const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0); const prevFocusedCount = useRef(0);
@@ -181,7 +179,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
}, []); }, []);
const itemWidth = const itemWidth =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
const getItemLayout = useCallback( const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({ (_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
@@ -249,8 +247,8 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
return ( return (
<View <View
style={{ style={{
width: TV_POSTER_WIDTH, width: posterSizes.poster,
height: TV_POSTER_WIDTH, height: posterSizes.poster,
borderRadius: 12, borderRadius: 12,
overflow: "hidden", overflow: "hidden",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",

View File

@@ -7,12 +7,11 @@ import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator"; import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
export const TV_EPISODE_WIDTH = 340;
interface TVEpisodeCardProps { interface TVEpisodeCardProps {
episode: BaseItemDto; episode: BaseItemDto;
hasTVPreferredFocus?: boolean; hasTVPreferredFocus?: boolean;
@@ -34,6 +33,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
refSetter, refSetter,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const thumbnailUrl = useMemo(() => { const thumbnailUrl = useMemo(() => {
@@ -68,7 +68,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
}, [episode.ParentIndexNumber, episode.IndexNumber]); }, [episode.ParentIndexNumber, episode.IndexNumber]);
return ( return (
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}> <View style={{ width: posterSizes.episode, opacity: disabled ? 0.5 : 1 }}>
<TVFocusablePoster <TVFocusablePoster
onPress={onPress} onPress={onPress}
hasTVPreferredFocus={hasTVPreferredFocus} hasTVPreferredFocus={hasTVPreferredFocus}
@@ -79,7 +79,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
> >
<View <View
style={{ style={{
width: TV_EPISODE_WIDTH, width: posterSizes.episode,
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
borderRadius: 24, borderRadius: 24,
overflow: "hidden", overflow: "hidden",

View File

@@ -17,6 +17,8 @@ export interface TVFocusablePosterProps {
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
disabled?: boolean; disabled?: boolean;
/** When true, the item remains focusable even when disabled (for navigation purposes) */
focusableWhenDisabled?: boolean;
/** Setter function for the ref (for focus guide destinations) */ /** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void; refSetter?: (ref: View | null) => void;
} }
@@ -31,6 +33,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
onFocus: onFocusProp, onFocus: onFocusProp,
onBlur: onBlurProp, onBlur: onBlurProp,
disabled = false, disabled = false,
focusableWhenDisabled = false,
refSetter, refSetter,
}) => { }) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
@@ -62,7 +65,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
}} }}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled} hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled} disabled={disabled}
focusable={!disabled} focusable={!disabled || focusableWhenDisabled}
> >
<Animated.View <Animated.View
style={[ style={[

View File

@@ -0,0 +1,57 @@
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.
*/
export const TVPosterSizes = {
/** Portrait posters (movies, series) - 10:15 aspect ratio */
poster: 260,
/** Landscape posters (continue watching, thumbs) - 16:9 aspect ratio */
landscape: 400,
/** Episode cards - 16:9 aspect ratio */
episode: 340,
/** Hero carousel cards - 16:9 aspect ratio */
heroCard: 280,
} as const;
export type TVPosterSizeKey = keyof typeof TVPosterSizes;
/**
* Poster scale multipliers - much smaller range than typography.
* Posters are already near-perfect size, only need slight increases at larger settings.
*/
const posterScaleMultipliers: Record<TVTypographyScale, number> = {
[TVTypographyScale.Small]: 0.95,
[TVTypographyScale.Default]: 1.0,
[TVTypographyScale.Large]: 1.05,
[TVTypographyScale.ExtraLarge]: 1.1,
};
/**
* 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();
* <View style={{ width: posterSizes.poster }}>
*/
export const useScaledTVPosterSizes = () => {
const { settings } = useSettings();
const scale =
posterScaleMultipliers[settings.tvTypographyScale] ??
posterScaleMultipliers[TVTypographyScale.Default];
return {
poster: Math.round(TVPosterSizes.poster * scale),
landscape: Math.round(TVPosterSizes.landscape * scale),
episode: Math.round(TVPosterSizes.episode * scale),
heroCard: Math.round(TVPosterSizes.heroCard * scale),
};
};

View File

@@ -128,11 +128,11 @@
"show_home_backdrop": "Dynamic Home Backdrop", "show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel", "show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes", "show_series_poster_on_episode": "Show Series Poster on Episodes",
"text_size": "Text Size", "display_size": "Display Size",
"text_size_small": "Small", "display_size_small": "Small",
"text_size_default": "Default", "display_size_default": "Default",
"text_size_large": "Large", "display_size_large": "Large",
"text_size_extra_large": "Extra Large" "display_size_extra_large": "Extra Large"
}, },
"network": { "network": {
"title": "Network", "title": "Network",

View File

@@ -126,11 +126,11 @@
"show_home_backdrop": "Dynamisk hembakgrund", "show_home_backdrop": "Dynamisk hembakgrund",
"show_hero_carousel": "Hjältekarusell", "show_hero_carousel": "Hjältekarusell",
"show_series_poster_on_episode": "Visa serieaffisch på avsnitt", "show_series_poster_on_episode": "Visa serieaffisch på avsnitt",
"text_size": "Textstorlek", "display_size": "Visningsstorlek",
"text_size_small": "Liten", "display_size_small": "Liten",
"text_size_default": "Standard", "display_size_default": "Standard",
"text_size_large": "Stor", "display_size_large": "Stor",
"text_size_extra_large": "Extra stor" "display_size_extra_large": "Extra stor"
}, },
"network": { "network": {
"title": "Nätverk", "title": "Nätverk",