From 875a017e8cf09f9baa90458e93efad528b4592fd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Jan 2026 22:55:44 +0100 Subject: [PATCH] feat(tv): add scalable typography with user-configurable text size --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 52 ++++++- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 4 +- .../(tabs)/(watchlists)/[watchlistId].tsx | 34 ++--- app/(auth)/tv-request-modal.tsx | 21 ++- app/(auth)/tv-season-select-modal.tsx | 19 ++- app/(auth)/tv-series-season-modal.tsx | 8 +- components/Badge.tsx | 6 +- components/GenreTags.tsx | 7 +- components/ItemContent.tv.tsx | 13 +- components/home/Favorites.tv.tsx | 7 +- components/home/Home.tv.tsx | 11 +- .../InfiniteScrollingCollectionList.tv.tsx | 33 ++-- .../StreamystatsPromotedWatchlists.tv.tsx | 20 ++- .../home/StreamystatsRecommendations.tv.tsx | 20 ++- .../jellyseerr/discover/TVDiscoverSlide.tsx | 10 +- components/jellyseerr/tv/TVJellyseerrPage.tsx | 28 ++-- components/jellyseerr/tv/TVRequestModal.tsx | 9 +- .../jellyseerr/tv/TVRequestOptionRow.tsx | 7 +- .../jellyseerr/tv/TVToggleOptionRow.tsx | 8 +- components/library/TVLibraries.tsx | 9 +- components/library/TVLibraryCard.tsx | 6 +- .../search/TVJellyseerrSearchResults.tsx | 19 ++- components/search/TVSearchBadge.tsx | 4 +- components/search/TVSearchPage.tsx | 10 +- components/search/TVSearchSection.tsx | 30 ++-- components/search/TVSearchTabBadges.tsx | 4 +- components/series/TVEpisodeCard.tsx | 15 +- components/series/TVSeriesHeader.tsx | 9 +- components/series/TVSeriesPage.tsx | 12 +- components/tv/TVActorCard.tsx | 7 +- components/tv/TVCancelButton.tsx | 5 +- components/tv/TVCastCrewText.tsx | 13 +- components/tv/TVCastSection.tsx | 5 +- components/tv/TVFilterButton.tsx | 7 +- components/tv/TVItemCardText.tsx | 44 +++--- components/tv/TVLanguageCard.tsx | 51 ++++--- components/tv/TVMetadataBadges.tsx | 8 +- components/tv/TVNextEpisodeCountdown.tsx | 116 +++++++------- components/tv/TVOptionButton.tsx | 11 +- components/tv/TVOptionCard.tsx | 7 +- components/tv/TVOptionSelector.tsx | 100 ++++++------ components/tv/TVSeriesNavigation.tsx | 5 +- components/tv/TVSeriesSeasonCard.tsx | 7 +- components/tv/TVSubtitleResultCard.tsx | 143 +++++++++--------- components/tv/TVTabButton.tsx | 5 +- components/tv/TVTechnicalDetails.tsx | 13 +- components/tv/TVTrackCard.tsx | 51 ++++--- components/tv/settings/TVLogoutButton.tsx | 5 +- components/tv/settings/TVSectionHeader.tsx | 38 ++--- .../tv/settings/TVSettingsOptionButton.tsx | 7 +- components/tv/settings/TVSettingsRow.tsx | 7 +- components/tv/settings/TVSettingsStepper.tsx | 7 +- .../tv/settings/TVSettingsTextInput.tsx | 7 +- components/tv/settings/TVSettingsToggle.tsx | 5 +- .../video-player/controls/Controls.tv.tsx | 35 +++-- .../controls/TechnicalInfoOverlay.tsx | 17 ++- constants/TVTypography.ts | 28 ++++ translations/en.json | 7 +- utils/atoms/settings.ts | 10 ++ 59 files changed, 712 insertions(+), 494 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 1463713d..8110d1d5 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -17,7 +17,11 @@ import { } from "@/components/tv"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; +import { + AudioTranscodeMode, + TVTypographyScale, + useSettings, +} from "@/utils/atoms/settings"; export default function SettingsTV() { const { t } = useTranslation(); @@ -39,6 +43,8 @@ export default function SettingsTV() { settings.subtitleMode || SubtitlePlaybackMode.Default; const currentAlignX = settings.mpvSubtitleAlignX ?? "center"; const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom"; + const currentTypographyScale = + settings.tvTypographyScale || TVTypographyScale.Default; // Audio transcoding options const audioTranscodeModeOptions: TVOptionItem[] = useMemo( @@ -130,6 +136,33 @@ export default function SettingsTV() { [currentAlignY], ); + // Typography scale options + const typographyScaleOptions: TVOptionItem[] = useMemo( + () => [ + { + label: t("home.settings.appearance.text_size_small"), + value: TVTypographyScale.Small, + selected: currentTypographyScale === TVTypographyScale.Small, + }, + { + label: t("home.settings.appearance.text_size_default"), + value: TVTypographyScale.Default, + selected: currentTypographyScale === TVTypographyScale.Default, + }, + { + label: t("home.settings.appearance.text_size_large"), + value: TVTypographyScale.Large, + selected: currentTypographyScale === TVTypographyScale.Large, + }, + { + label: t("home.settings.appearance.text_size_extra_large"), + value: TVTypographyScale.ExtraLarge, + selected: currentTypographyScale === TVTypographyScale.ExtraLarge, + }, + ], + [t, currentTypographyScale], + ); + // Get display labels for option buttons const audioTranscodeLabel = useMemo(() => { const option = audioTranscodeModeOptions.find((o) => o.selected); @@ -151,6 +184,11 @@ export default function SettingsTV() { return option?.label || "Bottom"; }, [alignYOptions]); + const typographyScaleLabel = useMemo(() => { + const option = typographyScaleOptions.find((o) => o.selected); + return option?.label || t("home.settings.appearance.text_size_default"); + }, [typographyScaleOptions, t]); + return ( @@ -344,6 +382,18 @@ export default function SettingsTV() { {/* Appearance Section */} + + showOptions({ + title: t("home.settings.appearance.text_size"), + options: typographyScaleOptions, + onSelect: (value) => + updateSettings({ tvTypographyScale: value }), + }) + } + /> { }; const { libraryId } = searchParams; + const typography = useScaledTVTypography(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { width: screenWidth } = useWindowDimensions(); @@ -947,7 +949,7 @@ const Page = () => { paddingTop: 100, }} > - + {t("library.no_results")} diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 2ee4592c..05620423 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -29,7 +29,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; import { @@ -46,17 +46,22 @@ import { userAtom } from "@/providers/JellyfinProvider"; const TV_ITEM_GAP = 20; const TV_HORIZONTAL_PADDING = 60; -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => ( +type Typography = ReturnType; + +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => ( {item.Name} = ({ item }) => ( ); export default function WatchlistDetailScreen() { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const router = useRouter(); const navigation = useNavigation(); @@ -212,11 +218,11 @@ export default function WatchlistDetailScreen() { )} - + ); }, - [router], + [router, typography], ); const renderItem = useCallback( @@ -356,7 +362,7 @@ export default function WatchlistDetailScreen() { {watchlist.description && ( - + {items?.length ?? 0}{" "} {(items?.length ?? 0) === 1 ? t("watchlists.item") @@ -395,18 +399,14 @@ export default function WatchlistDetailScreen() { size={20} color='#9ca3af' /> - + {watchlist.isPublic ? t("watchlists.public") : t("watchlists.private")} {!isOwner && ( - + {t("watchlists.by_owner")} )} @@ -426,7 +426,7 @@ export default function WatchlistDetailScreen() { - {t("jellyseerr.advanced")} - {modalState.title} + + {t("jellyseerr.advanced")} + + + {modalState.title} + {isDataLoaded && isReady ? ( - + {t("jellyseerr.request_button")} @@ -451,13 +461,11 @@ const styles = StyleSheet.create({ overflow: "visible", }, heading: { - fontSize: TVTypography.heading, fontWeight: "bold", color: "#FFFFFF", marginBottom: 8, }, subtitle: { - fontSize: TVTypography.callout, color: "rgba(255,255,255,0.6)", marginBottom: 24, }, @@ -482,7 +490,6 @@ const styles = StyleSheet.create({ marginTop: 24, }, buttonText: { - fontSize: TVTypography.callout, fontWeight: "bold", color: "#FFFFFF", }, diff --git a/app/(auth)/tv-season-select-modal.tsx b/app/(auth)/tv-season-select-modal.tsx index 09b46cc5..b9285e65 100644 --- a/app/(auth)/tv-season-select-modal.tsx +++ b/app/(auth)/tv-season-select-modal.tsx @@ -16,7 +16,7 @@ import { import { Text } from "@/components/common/Text"; import { TVButton } from "@/components/tv"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useTVRequestModal } from "@/hooks/useTVRequestModal"; @@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC = ({ }; export default function TVSeasonSelectModalPage() { + const typography = useScaledTVTypography(); const router = useRouter(); const modalState = useAtomValue(tvSeasonSelectModalAtom); const { t } = useTranslation(); @@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() { trapFocusRight style={styles.content} > - {t("jellyseerr.select_seasons")} - {modalState.title} + + {t("jellyseerr.select_seasons")} + + + {modalState.title} + {/* Season cards horizontal scroll */} - + {t("jellyseerr.request_selected")} {selectedSeasons.size > 0 && ` (${selectedSeasons.size})`} @@ -377,13 +384,11 @@ const styles = StyleSheet.create({ overflow: "visible", }, heading: { - fontSize: TVTypography.heading, fontWeight: "bold", color: "#FFFFFF", marginBottom: 8, }, subtitle: { - fontSize: TVTypography.callout, color: "rgba(255,255,255,0.6)", marginBottom: 24, }, @@ -413,7 +418,6 @@ const styles = StyleSheet.create({ flex: 1, }, seasonTitle: { - fontSize: TVTypography.callout, fontWeight: "600", marginBottom: 4, }, @@ -436,7 +440,6 @@ const styles = StyleSheet.create({ marginTop: 24, }, buttonText: { - fontSize: TVTypography.callout, fontWeight: "bold", color: "#FFFFFF", }, diff --git a/app/(auth)/tv-series-season-modal.tsx b/app/(auth)/tv-series-season-modal.tsx index 05b9ca8c..b1117e6f 100644 --- a/app/(auth)/tv-series-season-modal.tsx +++ b/app/(auth)/tv-series-season-modal.tsx @@ -12,12 +12,13 @@ import { } from "react-native"; import { Text } from "@/components/common/Text"; import { TVCancelButton, TVOptionCard } from "@/components/tv"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal"; import { store } from "@/utils/store"; export default function TVSeriesSeasonModalPage() { + const typography = useScaledTVTypography(); const router = useRouter(); const modalState = useAtomValue(tvSeriesSeasonModalAtom); const { t } = useTranslation(); @@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() { trapFocusRight style={styles.content} > - {t("item_card.select_season")} + + {t("item_card.select_season")} + {isReady && ( = ({ variant = "purple", ...props }) => { + const typography = useScaledTVTypography(); + const content = ( {iconLeft && {iconLeft}} @@ -69,7 +71,7 @@ export const Badge: React.FC = ({ {iconLeft && {iconLeft}} diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx index bc83eafa..29f1cb30 100644 --- a/components/GenreTags.tsx +++ b/components/GenreTags.tsx @@ -10,7 +10,7 @@ import { type ViewProps, } from "react-native"; import { GlassEffectView } from "react-native-glass-effect-view"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { Text } from "./common/Text"; interface TagProps { @@ -25,6 +25,9 @@ export const Tag: React.FC< textStyle?: StyleProp; } & ViewProps > = ({ text, textClass, textStyle, ...props }) => { + // Hook must be called at the top level, before any conditional returns + const typography = useScaledTVTypography(); + if (Platform.OS === "ios" && !Platform.isTV) { return ( @@ -60,7 +63,7 @@ export const Tag: React.FC< backgroundColor: "rgba(0,0,0,0.3)", }} > - + {text} diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index c8cde42c..9c4dfe0d 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -37,7 +37,7 @@ import { TVTechnicalDetails, } from "@/components/tv"; import type { Track } from "@/components/video-player/controls/types"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; @@ -69,6 +69,7 @@ interface ItemContentTVProps { // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { + const typography = useScaledTVTypography(); const [api] = useAtom(apiAtom); const [_user] = useAtom(userAtom); const isOffline = useOfflineMode(); @@ -484,7 +485,7 @@ export const ItemContentTV: React.FC = React.memo( ) : ( = React.memo( = React.memo( = React.memo( > = React.memo( /> ; export const Favorites = () => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); @@ -148,7 +149,7 @@ export const Favorites = () => { /> { style={{ textAlign: "center", opacity: 0.7, - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", }} > diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 772ea094..1f7ae86b 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -32,7 +32,7 @@ import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPr import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv"; import { TVHeroCarousel } from "@/components/home/TVHeroCarousel"; import { Loader } from "@/components/Loader"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; @@ -62,6 +62,7 @@ type Section = InfiniteScrollingCollectionListSection; const BACKDROP_DEBOUNCE_MS = 300; export const Home = () => { + const typography = useScaledTVTypography(); const _router = useRouter(); const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -579,7 +580,7 @@ export const Home = () => { > { style={{ textAlign: "center", opacity: 0.7, - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", }} > @@ -632,7 +633,7 @@ export const Home = () => { > { style={{ textAlign: "center", opacity: 0.7, - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", }} > diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ef7bcae7..c8f7879e 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -20,7 +20,7 @@ import MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import ContinueWatchingPoster, { @@ -48,22 +48,27 @@ interface Props extends ViewProps { parentId?: string; } +type Typography = ReturnType; + // TV-specific ItemCardText with larger fonts -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => { return ( {item.Type === "Episode" ? ( <> {item.Name} = ({ item }) => { <> {item.Name} void; onBlur?: () => void; -}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => { + typography: Typography; +}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => { const { t } = useTranslation(); const width = orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH; @@ -137,7 +143,7 @@ const TVSeeAllCard: React.FC<{ /> = ({ parentId, ...props }) => { + const typography = useScaledTVTypography(); const effectivePageSize = Math.max(1, pageSize); const hasCalledOnLoaded = useRef(false); const router = useRouter(); @@ -343,7 +350,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ > {renderPoster()} - + ); }, @@ -354,6 +361,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress, handleItemFocus, handleItemBlur, + typography, ], ); @@ -365,7 +373,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ {/* Section Header */} = ({ @@ -421,7 +429,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ color: "#262626", backgroundColor: "#262626", borderRadius: 6, - fontSize: TVTypography.callout, + fontSize: typography.callout, }} numberOfLines={1} > @@ -478,6 +486,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ disabled={disabled} onFocus={handleSeeAllFocus} onBlur={handleItemBlur} + typography={typography} /> )} diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 1bb168a8..e2c5ddd7 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -16,7 +16,7 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -26,18 +26,23 @@ import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; const ITEM_GAP = 16; const SCALE_PADDING = 20; -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { +type Typography = ReturnType; + +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => { return ( {item.Name} = ({ onItemFocus, ...props }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -142,11 +148,11 @@ const WatchlistSection: React.FC = ({ {item.Type === "Movie" && } {item.Type === "Series" && } - + ); }, - [handleItemPress, onItemFocus], + [handleItemPress, onItemFocus, typography], ); if (!isLoading && (!items || items.length === 0)) return null; @@ -155,7 +161,7 @@ const WatchlistSection: React.FC = ({ ; + interface Props extends ViewProps { title: string; type: "Movie" | "Series"; @@ -34,18 +36,21 @@ interface Props extends ViewProps { onItemFocus?: (item: BaseItemDto) => void; } -const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { +const TVItemCardText: React.FC<{ + item: BaseItemDto; + typography: Typography; +}> = ({ item, typography }) => { return ( {item.Name} = ({ onItemFocus, ...props }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -203,11 +209,11 @@ export const StreamystatsRecommendations: React.FC = ({ {item.Type === "Movie" && } {item.Type === "Series" && } - + ); }, - [handleItemPress, onItemFocus], + [handleItemPress, onItemFocus, typography], ); if (!streamyStatsEnabled) return null; @@ -218,7 +224,7 @@ export const StreamystatsRecommendations: React.FC = ({ = ({ item, isFirstItem = false, }) => { + const typography = useScaledTVTypography(); const router = useRouter(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = @@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC = ({ = ({ {year && ( = ({ slide, isFirstSlide = false, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); @@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC = ({ = ({ onPress, refSetter, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08 }); @@ -128,7 +129,7 @@ const TVCastCard: React.FC = ({ = ({ }; export const TVJellyseerrPage: React.FC = () => { + const typography = useScaledTVTypography(); const insets = useSafeAreaInsets(); const params = useLocalSearchParams(); const { t } = useTranslation(); @@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => { {/* Title */} { {/* Year */} { > { /> { /> { /> { /> { { /> { /> { = ({ onClose, onRequested, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); @@ -389,7 +390,7 @@ export const TVRequestModal: React.FC = ({ > = ({ = ({ /> = ({ hasTVPreferredFocus = false, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02, @@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC = ({ > @@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC = ({ = ({ onToggle, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08, @@ -57,7 +58,7 @@ const TVToggleChip: React.FC = ({ > = ({ onToggle, disabled = false, }) => { + const typography = useScaledTVTypography(); if (items.length === 0) return null; return ( = ({ library, isFirst, onPress }) => { const [api] = useAtom(apiAtom); const { t } = useTranslation(); + const typography = useScaledTVTypography(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; const opacity = useRef(new Animated.Value(0.7)).current; @@ -190,7 +192,7 @@ const TVLibraryRow: React.FC<{ { const insets = useSafeAreaInsets(); const router = useRouter(); const { t } = useTranslation(); + const typography = useScaledTVTypography(); const { data: userViews, isLoading: viewsLoading } = useQuery({ queryKey: ["user-views", user?.Id], @@ -360,7 +363,7 @@ export const TVLibraries: React.FC = () => { alignItems: "center", }} > - + {t("library.no_libraries_found")} diff --git a/components/library/TVLibraryCard.tsx b/components/library/TVLibraryCard.tsx index 70918762..ef607b74 100644 --- a/components/library/TVLibraryCard.tsx +++ b/components/library/TVLibraryCard.tsx @@ -12,6 +12,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -44,6 +45,7 @@ export const TVLibraryCard: React.FC = ({ library }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { t } = useTranslation(); + const typography = useScaledTVTypography(); const url = useMemo( () => @@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC = ({ library }) => { = ({ library }) => { {itemsCount !== undefined && ( = ({ onPress, isFirstItem = false, }) => { + const typography = useScaledTVTypography(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -113,7 +114,7 @@ const TVJellyseerrPoster: React.FC = ({ = ({ {year && ( = ({ item, onPress, }) => { + const typography = useScaledTVTypography(); const { jellyseerrApi } = useJellyseerr(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08 }); @@ -202,7 +204,7 @@ const TVJellyseerrPersonPoster: React.FC = ({ = ({ isFirstSection = false, onItemPress, }) => { + const typography = useScaledTVTypography(); if (!items || items.length === 0) return null; return ( = ({ isFirstSection = false, onItemPress, }) => { + const typography = useScaledTVTypography(); if (!items || items.length === 0) return null; return ( = ({ isFirstSection: _isFirstSection = false, onItemPress, }) => { + const typography = useScaledTVTypography(); if (!items || items.length === 0) return null; return ( = ({ onPress, hasTVPreferredFocus = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); @@ -41,7 +43,7 @@ export const TVSearchBadge: React.FC = ({ > { + const typography = useScaledTVTypography(); const itemWidth = 210; return ( @@ -72,7 +73,7 @@ const TVLoadingSkeleton: React.FC = () => { color: "#262626", backgroundColor: "#262626", borderRadius: 6, - fontSize: TVTypography.callout, + fontSize: typography.callout, }} numberOfLines={1} > @@ -150,6 +151,7 @@ export const TVSearchPage: React.FC = ({ onJellyseerrPersonPress, discoverSliders, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); @@ -308,7 +310,7 @@ export const TVSearchPage: React.FC = ({ = ({ diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index 695c64cc..eea3836b 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -11,27 +11,28 @@ import MoviePoster, { } from "@/components/posters/MoviePoster.tv"; import SeriesPoster from "@/components/posters/SeriesPoster.tv"; import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; const ITEM_GAP = 16; const SCALE_PADDING = 20; // TV-specific ItemCardText with larger fonts const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const typography = useScaledTVTypography(); return ( {item.Type === "Episode" ? ( <> {item.Name} = ({ item }) => { = ({ item }) => { <> {item.Name} = ({ item }) => { <> {item.Name} = ({ item }) => { <> {item.Name} = ({ item }) => { ) : item.Type === "Person" ? ( {item.Name} @@ -119,13 +120,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { <> {item.Name} = ({ imageUrlGetter, ...props }) => { + const typography = useScaledTVTypography(); const flatListRef = useRef>(null); const [focusedCount, setFocusedCount] = useState(0); const prevFocusedCount = useRef(0); @@ -358,7 +360,7 @@ export const TVSearchSection: React.FC = ({ {/* Section Header */} = ({ hasTVPreferredFocus = false, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 }); @@ -61,7 +63,7 @@ const TVSearchTabBadge: React.FC = ({ > = ({ onBlur, refSetter, }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const thumbnailUrl = useMemo(() => { @@ -112,7 +113,7 @@ export const TVEpisodeCard: React.FC = ({ {episodeLabel && ( = ({ )} {duration && ( <> - + - + {duration} @@ -138,7 +135,7 @@ export const TVEpisodeCard: React.FC = ({ = ({ item }) => { + const typography = useScaledTVTypography(); const api = useAtomValue(apiAtom); const logoUrl = useMemo(() => { @@ -58,7 +59,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => { ) : ( = ({ item }) => { }} > {yearString && ( - + {yearString} )} @@ -123,7 +124,7 @@ export const TVSeriesHeader: React.FC = ({ item }) => { > void; disabled?: boolean; }> = ({ seasonName, onPress, disabled = false }) => { + const typography = useScaledTVTypography(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -190,7 +191,7 @@ const TVSeasonButton: React.FC<{ > = ({ allEpisodes = [], isLoading: _isLoading, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -567,7 +569,7 @@ export const TVSeriesPage: React.FC = ({ /> = ({ = ({ diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index 888da829..aec682e9 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -3,7 +3,7 @@ import { Image } from "expo-image"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVActorCardProps { @@ -19,6 +19,7 @@ export interface TVActorCardProps { export const TVActorCard = React.forwardRef( ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.08 }); @@ -84,7 +85,7 @@ export const TVActorCard = React.forwardRef( ( {person.Role && ( = ({ label = "Cancel", disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); @@ -48,7 +49,7 @@ export const TVCancelButton: React.FC = ({ /> = React.memo( ({ director, cast, hideCast = false }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); if (!director && (!cast || cast.length === 0)) { @@ -24,7 +25,7 @@ export const TVCastCrewText: React.FC = React.memo( = React.memo( = React.memo( > {t("item_card.director")} - + {director.Name} @@ -55,7 +56,7 @@ export const TVCastCrewText: React.FC = React.memo( = React.memo( > {t("item_card.cast")} - + {cast.map((c) => c.Name).join(", ")} diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx index 828ca3f6..f1f1276b 100644 --- a/components/tv/TVCastSection.tsx +++ b/components/tv/TVCastSection.tsx @@ -3,7 +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 { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVActorCard } from "./TVActorCard"; export interface TVCastSectionProps { @@ -24,6 +24,7 @@ export const TVCastSection: React.FC = React.memo( firstActorRefSetter, upwardFocusDestination, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); if (cast.length === 0) { @@ -34,7 +35,7 @@ export const TVCastSection: React.FC = React.memo( = ({ disabled = false, hasActiveFilter = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 }); @@ -54,7 +55,7 @@ export const TVFilterButton: React.FC = ({ {label ? ( @@ -63,7 +64,7 @@ export const TVFilterButton: React.FC = ({ ) : null} = ({ item }) => ( - - - {item.Name} - - - {item.ProductionYear} - - -); +export const TVItemCardText: React.FC = ({ item }) => { + const typography = useScaledTVTypography(); + + return ( + + + {item.Name} + + + {item.ProductionYear} + + + ); +}; diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx index 24e3ec84..7b4b712c 100644 --- a/components/tv/TVLanguageCard.tsx +++ b/components/tv/TVLanguageCard.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVLanguageCardProps { @@ -15,6 +15,8 @@ export interface TVLanguageCardProps { export const TVLanguageCard = React.forwardRef( ({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -72,26 +74,27 @@ export const TVLanguageCard = React.forwardRef( }, ); -const styles = StyleSheet.create({ - languageCard: { - width: 120, - height: 60, - borderRadius: 12, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 12, - }, - languageCardText: { - fontSize: TVTypography.callout, - fontWeight: "500", - }, - languageCardCode: { - fontSize: TVTypography.callout, - marginTop: 2, - }, - checkmark: { - position: "absolute", - top: 8, - right: 8, - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + languageCard: { + width: 120, + height: 60, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + languageCardText: { + fontSize: typography.callout, + fontWeight: "500", + }, + languageCardCode: { + fontSize: typography.callout, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, + }); diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx index b2ccab2e..4698644e 100644 --- a/components/tv/TVMetadataBadges.tsx +++ b/components/tv/TVMetadataBadges.tsx @@ -3,7 +3,7 @@ import React from "react"; import { View } from "react-native"; import { Badge } from "@/components/Badge"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; export interface TVMetadataBadgesProps { year?: number | null; @@ -14,6 +14,8 @@ export interface TVMetadataBadgesProps { export const TVMetadataBadges: React.FC = React.memo( ({ year, duration, officialRating, communityRating }) => { + const typography = useScaledTVTypography(); + return ( = React.memo( }} > {year != null && ( - + {year} )} {duration && ( - + {duration} )} diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx index 2030d109..ef1ea6cc 100644 --- a/components/tv/TVNextEpisodeCountdown.tsx +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -1,7 +1,7 @@ import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BlurView } from "expo-blur"; -import { type FC, useEffect, useRef } from "react"; +import { type FC, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Image, StyleSheet, View } from "react-native"; import Animated, { @@ -13,7 +13,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; export interface TVNextEpisodeCountdownProps { @@ -31,6 +31,7 @@ export const TVNextEpisodeCountdown: FC = ({ isPlaying, onFinish, }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const progress = useSharedValue(0); const onFinishRef = useRef(onFinish); @@ -69,6 +70,8 @@ export const TVNextEpisodeCountdown: FC = ({ width: `${progress.value * 100}%`, })); + const styles = useMemo(() => createStyles(typography), [typography]); + if (!show) return null; return ( @@ -105,57 +108,58 @@ export const TVNextEpisodeCountdown: FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - position: "absolute", - bottom: 180, - right: 80, - zIndex: 100, - }, - blur: { - borderRadius: 16, - overflow: "hidden", - }, - innerContainer: { - flexDirection: "row", - alignItems: "stretch", - }, - thumbnail: { - width: 180, - backgroundColor: "rgba(0,0,0,0.3)", - }, - content: { - padding: 16, - justifyContent: "center", - width: 280, - }, - label: { - fontSize: TVTypography.callout, - color: "rgba(255,255,255,0.5)", - textTransform: "uppercase", - letterSpacing: 1, - marginBottom: 4, - }, - seriesName: { - fontSize: TVTypography.callout, - color: "rgba(255,255,255,0.7)", - marginBottom: 2, - }, - episodeInfo: { - fontSize: TVTypography.body, - color: "#fff", - fontWeight: "600", - marginBottom: 12, - }, - progressContainer: { - height: 4, - backgroundColor: "rgba(255,255,255,0.2)", - borderRadius: 2, - overflow: "hidden", - }, - progressBar: { - height: "100%", - backgroundColor: "#fff", - borderRadius: 2, - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + container: { + position: "absolute", + bottom: 180, + right: 80, + zIndex: 100, + }, + blur: { + borderRadius: 16, + overflow: "hidden", + }, + innerContainer: { + flexDirection: "row", + alignItems: "stretch", + }, + thumbnail: { + width: 180, + backgroundColor: "rgba(0,0,0,0.3)", + }, + content: { + padding: 16, + justifyContent: "center", + width: 280, + }, + label: { + fontSize: typography.callout, + color: "rgba(255,255,255,0.5)", + textTransform: "uppercase", + letterSpacing: 1, + marginBottom: 4, + }, + seriesName: { + fontSize: typography.callout, + color: "rgba(255,255,255,0.7)", + marginBottom: 2, + }, + episodeInfo: { + fontSize: typography.body, + color: "#fff", + fontWeight: "600", + marginBottom: 12, + }, + progressContainer: { + height: 4, + backgroundColor: "rgba(255,255,255,0.2)", + borderRadius: 2, + overflow: "hidden", + }, + progressBar: { + height: "100%", + backgroundColor: "#fff", + borderRadius: 2, + }, + }); diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx index 342caac9..41359561 100644 --- a/components/tv/TVOptionButton.tsx +++ b/components/tv/TVOptionButton.tsx @@ -2,7 +2,7 @@ import { BlurView } from "expo-blur"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVOptionButtonProps { @@ -14,6 +14,7 @@ export interface TVOptionButtonProps { export const TVOptionButton = React.forwardRef( ({ label, value, onPress, hasTVPreferredFocus }, ref) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); @@ -50,7 +51,7 @@ export const TVOptionButton = React.forwardRef( > @@ -58,7 +59,7 @@ export const TVOptionButton = React.forwardRef( ( > @@ -96,7 +97,7 @@ export const TVOptionButton = React.forwardRef( ( }, ref, ) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -59,7 +60,7 @@ export const TVOptionCard = React.forwardRef( > ( {sublabel && ( ({ cardWidth = 160, cardHeight = 75, }: TVOptionSelectorProps) => { + const typography = useScaledTVTypography(); const [isReady, setIsReady] = useState(false); const firstCardRef = useRef(null); @@ -91,6 +92,8 @@ export const TVOptionSelector = ({ } }, [isReady]); + const styles = useMemo(() => createStyles(typography), [typography]); + if (!visible) return null; return ( @@ -151,50 +154,51 @@ export const TVOptionSelector = ({ ); }; -const styles = StyleSheet.create({ - overlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "flex-end", - zIndex: 1000, - }, - sheetContainer: { - width: "100%", - }, - blurContainer: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - overflow: "hidden", - }, - content: { - paddingTop: 24, - paddingBottom: 50, - overflow: "visible", - }, - title: { - fontSize: TVTypography.callout, - fontWeight: "500", - color: "rgba(255,255,255,0.6)", - marginBottom: 16, - paddingHorizontal: 48, - textTransform: "uppercase", - letterSpacing: 1, - }, - scrollView: { - overflow: "visible", - }, - scrollContent: { - paddingHorizontal: 48, - paddingVertical: 20, - gap: 12, - }, - cancelButtonContainer: { - marginTop: 16, - paddingHorizontal: 48, - alignItems: "flex-start", - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + zIndex: 1000, + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + title: { + fontSize: typography.callout, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 20, + gap: 12, + }, + cancelButtonContainer: { + marginTop: 16, + paddingHorizontal: 48, + alignItems: "flex-start", + }, + }); diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx index 6e088664..33813775 100644 --- a/components/tv/TVSeriesNavigation.tsx +++ b/components/tv/TVSeriesNavigation.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; export interface TVSeriesNavigationProps { @@ -16,6 +16,7 @@ export interface TVSeriesNavigationProps { export const TVSeriesNavigation: React.FC = React.memo( ({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); // Only show for episodes with a series @@ -27,7 +28,7 @@ export const TVSeriesNavigation: React.FC = React.memo( = ({ onPress, hasTVPreferredFocus, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -104,7 +105,7 @@ export const TVSeriesSeasonCard: React.FC = ({ = ({ {subtitle && ( (({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.03 }); @@ -197,72 +199,73 @@ export const TVSubtitleResultCard = React.forwardRef< ); }); -const styles = StyleSheet.create({ - resultCard: { - width: 220, - minHeight: 120, - borderRadius: 14, - padding: 14, - borderWidth: 1, - }, - providerBadge: { - alignSelf: "flex-start", - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 6, - marginBottom: 8, - }, - providerText: { - fontSize: TVTypography.callout, - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: 0.5, - }, - resultName: { - fontSize: TVTypography.callout, - fontWeight: "500", - marginBottom: 8, - lineHeight: 18, - }, - resultMeta: { - flexDirection: "row", - alignItems: "center", - gap: 12, - marginBottom: 8, - }, - resultMetaText: { - fontSize: TVTypography.callout, - }, - ratingContainer: { - flexDirection: "row", - alignItems: "center", - gap: 3, - }, - downloadCountContainer: { - flexDirection: "row", - alignItems: "center", - gap: 3, - }, - flagsContainer: { - flexDirection: "row", - gap: 6, - flexWrap: "wrap", - }, - flag: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - }, - flagText: { - fontSize: TVTypography.callout, - fontWeight: "600", - color: "#fff", - }, - downloadingOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0,0,0,0.5)", - borderRadius: 14, - justifyContent: "center", - alignItems: "center", - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + resultCard: { + width: 220, + minHeight: 120, + borderRadius: 14, + padding: 14, + borderWidth: 1, + }, + providerBadge: { + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + marginBottom: 8, + }, + providerText: { + fontSize: typography.callout, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + resultName: { + fontSize: typography.callout, + fontWeight: "500", + marginBottom: 8, + lineHeight: 18, + }, + resultMeta: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 8, + }, + resultMetaText: { + fontSize: typography.callout, + }, + ratingContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + downloadCountContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + flagsContainer: { + flexDirection: "row", + gap: 6, + flexWrap: "wrap", + }, + flag: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + flagText: { + fontSize: typography.callout, + fontWeight: "600", + color: "#fff", + }, + downloadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0,0,0,0.5)", + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + }, + }); diff --git a/components/tv/TVTabButton.tsx b/components/tv/TVTabButton.tsx index c421f573..d545985b 100644 --- a/components/tv/TVTabButton.tsx +++ b/components/tv/TVTabButton.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Animated, Pressable } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVTabButtonProps { @@ -21,6 +21,7 @@ export const TVTabButton: React.FC = ({ switchOnFocus = false, disabled = false, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05, @@ -56,7 +57,7 @@ export const TVTabButton: React.FC = ({ > = React.memo( ({ mediaStreams }) => { + const typography = useScaledTVTypography(); const { t } = useTranslation(); const videoStream = mediaStreams.find((s) => s.Type === "Video"); @@ -24,7 +25,7 @@ export const TVTechnicalDetails: React.FC = React.memo( = React.memo( = React.memo( > Video - + {videoStream.DisplayTitle || `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} @@ -56,7 +57,7 @@ export const TVTechnicalDetails: React.FC = React.memo( = React.memo( > Audio - + {audioStream.DisplayTitle || `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx index e1b7106f..7ec27d09 100644 --- a/components/tv/TVTrackCard.tsx +++ b/components/tv/TVTrackCard.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; export interface TVTrackCardProps { @@ -15,6 +15,8 @@ export interface TVTrackCardProps { export const TVTrackCard = React.forwardRef( ({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => { + const typography = useScaledTVTypography(); + const styles = createStyles(typography); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -77,26 +79,27 @@ export const TVTrackCard = React.forwardRef( }, ); -const styles = StyleSheet.create({ - trackCard: { - width: 180, - height: 80, - borderRadius: 14, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 12, - }, - trackCardText: { - fontSize: TVTypography.callout, - textAlign: "center", - }, - trackCardSublabel: { - fontSize: TVTypography.callout, - marginTop: 2, - }, - checkmark: { - position: "absolute", - top: 8, - right: 8, - }, -}); +const createStyles = (typography: ReturnType) => + StyleSheet.create({ + trackCard: { + width: 180, + height: 80, + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + trackCardText: { + fontSize: typography.callout, + textAlign: "center", + }, + trackCardSublabel: { + fontSize: typography.callout, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, + }); diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx index 9df832f1..77a8ddf9 100644 --- a/components/tv/settings/TVLogoutButton.tsx +++ b/components/tv/settings/TVLogoutButton.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVLogoutButtonProps { @@ -15,6 +15,7 @@ export const TVLogoutButton: React.FC = ({ disabled, }) => { const { t } = useTranslation(); + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.05 }); @@ -49,7 +50,7 @@ export const TVLogoutButton: React.FC = ({ > = ({ title }) => ( - - {title} - -); +export const TVSectionHeader: React.FC = ({ title }) => { + const typography = useScaledTVTypography(); + + return ( + + {title} + + ); +}; diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx index 52978caa..07f879ce 100644 --- a/components/tv/settings/TVSettingsOptionButton.tsx +++ b/components/tv/settings/TVSettingsOptionButton.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVSettingsOptionButtonProps { @@ -20,6 +20,7 @@ export const TVSettingsOptionButton: React.FC = ({ isFirst, disabled, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -49,13 +50,13 @@ export const TVSettingsOptionButton: React.FC = ({ }, ]} > - + {label} = ({ showChevron = true, disabled, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -51,13 +52,13 @@ export const TVSettingsRow: React.FC = ({ }, ]} > - + {label} = ({ isFirst, disabled, }) => { + const typography = useScaledTVTypography(); const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 }); const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); @@ -54,7 +55,7 @@ export const TVSettingsStepper: React.FC = ({ focusable={!disabled} > - + {label} @@ -89,7 +90,7 @@ export const TVSettingsStepper: React.FC = ({ = ({ secureTextEntry, disabled, }) => { + const typography = useScaledTVTypography(); const inputRef = useRef(null); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -56,7 +57,7 @@ export const TVSettingsTextInput: React.FC = ({ > = ({ autoCapitalize='none' autoCorrect={false} style={{ - fontSize: TVTypography.body, + fontSize: typography.body, color: "#FFFFFF", backgroundColor: "rgba(255, 255, 255, 0.05)", borderRadius: 8, diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx index c50a6518..3522f711 100644 --- a/components/tv/settings/TVSettingsToggle.tsx +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Animated, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; export interface TVSettingsToggleProps { @@ -19,6 +19,7 @@ export const TVSettingsToggle: React.FC = ({ isFirst, disabled, }) => { + const typography = useScaledTVTypography(); const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount: 1.02 }); @@ -48,7 +49,7 @@ export const TVSettingsToggle: React.FC = ({ }, ]} > - + {label} = ({ playMethod, transcodeReasons, }) => { + const typography = useScaledTVTypography(); const insets = useSafeAreaInsets(); const { width: screenWidth } = useWindowDimensions(); const { t } = useTranslation(); @@ -973,14 +974,16 @@ export const Controls: FC = ({ - + {formatTimeString(currentTime, "ms")} - + -{formatTimeString(remainingTime, "ms")} - + {t("player.ends_at")} {getFinishTime()} @@ -1006,12 +1009,18 @@ export const Controls: FC = ({ {item?.Type === "Episode" && ( {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} - {item?.Name} + + {item?.Name} + {item?.Type === "Movie" && ( - {item?.ProductionYear} + + {item?.ProductionYear} + )} @@ -1110,14 +1119,16 @@ export const Controls: FC = ({ - + {formatTimeString(currentTime, "ms")} - + -{formatTimeString(remainingTime, "ms")} - + {t("player.ends_at")} {getFinishTime()} @@ -1151,11 +1162,9 @@ const styles = StyleSheet.create({ }, subtitleText: { color: "rgba(255,255,255,0.6)", - fontSize: TVTypography.body, }, titleText: { color: "#fff", - fontSize: TVTypography.heading, fontWeight: "bold", }, controlButtonsRow: { @@ -1218,7 +1227,6 @@ const styles = StyleSheet.create({ }, timeText: { color: "rgba(255,255,255,0.7)", - fontSize: TVTypography.body, }, timeRight: { flexDirection: "column", @@ -1226,7 +1234,6 @@ const styles = StyleSheet.create({ }, endsAtText: { color: "rgba(255,255,255,0.5)", - fontSize: TVTypography.callout, marginTop: 2, }, // Minimal seek bar styles diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index 5d87e697..ad6dded5 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -15,7 +15,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { TVTypography } from "@/constants/TVTypography"; +import { useScaledTVTypography } from "@/constants/TVTypography"; import type { TechnicalInfo } from "@/modules/mpv-player"; import { useSettings } from "@/utils/atoms/settings"; import { HEADER_LAYOUT } from "./constants"; @@ -183,6 +183,7 @@ export const TechnicalInfoOverlay: FC = memo( currentSubtitleIndex, currentAudioIndex, }) => { + const typography = useScaledTVTypography(); const { settings } = useSettings(); const insets = useSafeAreaInsets(); const [info, setInfo] = useState(null); @@ -277,8 +278,15 @@ export const TechnicalInfoOverlay: FC = memo( : HEADER_LAYOUT.CONTAINER_PADDING + 20, }; - const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText; - const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText; + const textStyle = Platform.isTV + ? [ + styles.infoTextTV, + { fontSize: typography.body, lineHeight: typography.body * 1.5 }, + ] + : styles.infoText; + const reasonStyle = Platform.isTV + ? [styles.reasonTextTV, { fontSize: typography.callout }] + : styles.reasonText; const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox; return ( @@ -383,9 +391,7 @@ const styles = StyleSheet.create({ }, infoTextTV: { color: "white", - fontSize: TVTypography.body, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", - lineHeight: TVTypography.body * 1.5, }, warningText: { color: "#ff9800", @@ -396,6 +402,5 @@ const styles = StyleSheet.create({ }, reasonTextTV: { color: "#fbbf24", - fontSize: TVTypography.callout, }, }); diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index ec3fb43b..c00ff16b 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -1,3 +1,5 @@ +import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; + /** * TV Typography Scale * @@ -23,3 +25,29 @@ export const TVTypography = { } as const; export type TVTypographyKey = keyof typeof TVTypography; + +const scaleMultipliers: Record = { + [TVTypographyScale.Small]: 0.85, + [TVTypographyScale.Default]: 1.0, + [TVTypographyScale.Large]: 1.15, + [TVTypographyScale.ExtraLarge]: 1.3, +}; + +/** + * Hook that returns scaled TV typography values based on user settings. + * Use this instead of the static TVTypography constant for dynamic scaling. + */ +export const useScaledTVTypography = () => { + const { settings } = useSettings(); + const scale = + scaleMultipliers[settings.tvTypographyScale] ?? + scaleMultipliers[TVTypographyScale.Default]; + + return { + display: Math.round(TVTypography.display * scale), + title: Math.round(TVTypography.title * scale), + heading: Math.round(TVTypography.heading * scale), + body: Math.round(TVTypography.body * scale), + callout: Math.round(TVTypography.callout * scale), + }; +}; diff --git a/translations/en.json b/translations/en.json index 855d1cf6..c435ea30 100644 --- a/translations/en.json +++ b/translations/en.json @@ -126,7 +126,12 @@ "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", "hide_remote_session_button": "Hide Remote Session Button", "show_home_backdrop": "Dynamic Home Backdrop", - "show_hero_carousel": "Hero Carousel" + "show_hero_carousel": "Hero Carousel", + "text_size": "Text Size", + "text_size_small": "Small", + "text_size_default": "Default", + "text_size_large": "Large", + "text_size_extra_large": "Extra Large" }, "network": { "title": "Network", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 3038199b..c3f9ba81 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -134,6 +134,14 @@ export enum VideoPlayer { MPV = 0, } +// TV Typography scale presets +export enum TVTypographyScale { + Small = "small", + Default = "default", + Large = "large", + ExtraLarge = "extraLarge", +} + // Audio transcoding mode - controls how surround audio is handled // This controls server-side transcoding behavior for audio streams. // MPV decodes via FFmpeg and supports most formats, but mobile devices @@ -202,6 +210,7 @@ export type Settings = { // TV-specific settings showHomeBackdrop: boolean; showTVHeroCarousel: boolean; + tvTypographyScale: TVTypographyScale; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -291,6 +300,7 @@ export const defaultValues: Settings = { // TV-specific settings showHomeBackdrop: true, showTVHeroCarousel: true, + tvTypographyScale: TVTypographyScale.Default, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false,