diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index dbeccca2..4fe3de6c 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -20,7 +20,10 @@ import { useTranslation } from "react-i18next"; import { ActivityIndicator, Animated, + Dimensions, Easing, + PixelRatio, + Platform, ScrollView, View, } from "react-native"; @@ -40,11 +43,12 @@ import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { scaleSize } from "@/utils/scaleSize"; -const HORIZONTAL_PADDING = 60; -const TOP_PADDING = 100; +const HORIZONTAL_PADDING = scaleSize(60); +const TOP_PADDING = scaleSize(100); // Generous gap between sections for Apple TV+ aesthetic -const SECTION_GAP = 24; +const SECTION_GAP = scaleSize(10); type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; @@ -79,6 +83,22 @@ export const Home = () => { const _invalidateCache = useInvalidatePlaybackProgressCache(); const { showItemActions } = useTVItemActionModal(); + // Log TV viewport dimensions for DPI scaling debug + useEffect(() => { + const w = Dimensions.get("window"); + const s = Dimensions.get("screen"); + console.log("========== TV DIMENSIONS =========="); + console.log("Platform.OS:", Platform.OS, "isTV:", Platform.isTV); + console.log("Window:", w.width, "x", w.height); + console.log("Screen:", s.width, "x", s.height); + console.log("PixelRatio:", PixelRatio.get()); + console.log( + "scaleSize(210):", + 210 * Math.min(w.width / 1920, w.height / 1080), + ); + console.log("===================================="); + }, []); + // Dynamic backdrop state with debounce const [focusedItem, setFocusedItem] = useState(null); const debounceTimerRef = useRef | null>(null); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ce32656d..16aac0b0 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -24,9 +24,10 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; +import { scaleSize } from "@/utils/scaleSize"; // Extra padding to accommodate scale animation (1.05x) and glow shadow -const SCALE_PADDING = 20; +const SCALE_PADDING = scaleSize(20); interface Props extends ViewProps { title?: string | null; @@ -81,7 +82,7 @@ const TVSeeAllCard: React.FC<{ style={{ width, aspectRatio, - borderRadius: 24, + borderRadius: scaleSize(24), backgroundColor: "rgba(255, 255, 255, 0.08)", justifyContent: "center", alignItems: "center", @@ -91,9 +92,9 @@ const TVSeeAllCard: React.FC<{ > = ({ fontSize: typography.heading, fontWeight: "700", color: "#FFFFFF", - marginBottom: 20, + marginBottom: scaleSize(20), marginLeft: sizes.padding.horizontal, letterSpacing: 0.5, }} @@ -286,8 +287,8 @@ export const InfiniteScrollingCollectionList: React.FC = ({ backgroundColor: "#262626", width: itemWidth, aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(12), + marginBottom: scaleSize(8), }} /> = ({ windowSize={5} removeClippedSubviews={false} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + ListHeaderComponent={ + + } style={{ overflow: "visible" }} contentInset={{ left: sizes.padding.horizontal, @@ -342,6 +346,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ style={{ flexDirection: "row", alignItems: "center", + width: sizes.padding.horizontal, }} > {isFetchingNextPage && ( @@ -350,7 +355,10 @@ export const InfiniteScrollingCollectionList: React.FC = ({ marginLeft: itemWidth / 2, marginRight: ITEM_GAP, justifyContent: "center", - height: orientation === "horizontal" ? 191 : 315, + height: + orientation === "horizontal" + ? scaleSize(191) + : scaleSize(315), }} > diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index f2ae66a8..27730a1e 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -19,10 +19,11 @@ import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; -const SCALE_PADDING = 20; +const SCALE_PADDING = scaleSize(20); interface WatchlistSectionProps extends ViewProps { watchlist: StreamystatsWatchlist; @@ -168,8 +169,8 @@ const WatchlistSection: React.FC = ({ backgroundColor: "#262626", width: posterSizes.poster, aspectRatio: 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(12), + marginBottom: scaleSize(8), }} /> @@ -286,12 +287,12 @@ export const StreamystatsPromotedWatchlists: React.FC< diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index a35da259..151d37a2 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -18,6 +18,7 @@ import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; import { createStreamystatsApi } from "@/utils/streamystats/api"; import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; @@ -220,8 +221,8 @@ export const StreamystatsRecommendations: React.FC = ({ backgroundColor: "#262626", width: sizes.posters.poster, aspectRatio: 10 / 15, - borderRadius: 12, - marginBottom: 8, + borderRadius: scaleSize(12), + marginBottom: scaleSize(8), }} /> diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 6cb4fa82..83274ea3 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -21,6 +21,7 @@ import { } from "@/modules/glass-poster"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { scaleSize } from "@/utils/scaleSize"; import { runtimeTicksToMinutes } from "@/utils/time"; export interface TVPosterCardProps { @@ -225,7 +226,13 @@ export const TVPosterCard: React.FC = ({ : null; return ( - + {episodeLabel && ( = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {item.ChannelName} @@ -277,7 +284,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {artist} @@ -296,7 +303,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {artist} @@ -312,7 +319,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {item.ChildCount} tracks @@ -328,7 +335,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#9CA3AF", - marginTop: 4, + marginTop: scaleSize(4), }} > {item.ProductionYear} @@ -344,23 +351,23 @@ export const TVPosterCard: React.FC = ({ - + @@ -382,7 +389,7 @@ export const TVPosterCard: React.FC = ({ justifyContent: "center", }} > - + ) : null; @@ -395,9 +402,9 @@ export const TVPosterCard: React.FC = ({ style={{ width, aspectRatio, - borderRadius: 24, + borderRadius: scaleSize(24), backgroundColor: "#1a1a1a", - borderWidth: 2, + borderWidth: scaleSize(2), borderColor: focused ? "#FFFFFF" : "transparent", }} /> @@ -411,7 +418,7 @@ export const TVPosterCard: React.FC = ({ = ({ position: "relative", width, aspectRatio, - borderRadius: 24, + borderRadius: scaleSize(4), overflow: "hidden", backgroundColor: "#1a1a1a", - borderWidth: 2, + borderWidth: scaleSize(2), borderColor: focused ? "#FFFFFF" : "transparent", }} > @@ -470,7 +477,7 @@ export const TVPosterCard: React.FC = ({ style={{ fontSize: typography.callout, color: "#FFFFFF", - marginTop: 4, + marginTop: scaleSize(4), fontWeight: "500", }} > @@ -498,8 +505,13 @@ export const TVPosterCard: React.FC = ({ // Default: show name return ( {item.Name} @@ -551,7 +563,7 @@ export const TVPosterCard: React.FC = ({ shadowColor: useGlass ? undefined : shadowColor, shadowOffset: useGlass ? undefined : { width: 0, height: 0 }, shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0, - shadowRadius: useGlass ? undefined : focused ? 12 : 0, + shadowRadius: useGlass ? undefined : focused ? scaleSize(12) : 0, }} > {renderPosterImage()} @@ -560,7 +572,9 @@ export const TVPosterCard: React.FC = ({ {/* Text below poster */} {showText && ( - + {item.Type === "Episode" ? ( <> {renderSubtitle()} diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts index 20c38daa..676609bc 100644 --- a/constants/TVSizes.ts +++ b/constants/TVSizes.ts @@ -1,10 +1,12 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; /** * TV Layout Sizes * * Unified constants for TV interface layout including posters, gaps, and padding. - * All values scale based on the user's tvTypographyScale setting. + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. */ // ============================================================================= @@ -48,7 +50,7 @@ export const TVGaps = { */ export const TVPadding = { /** Horizontal padding from screen edges */ - horizontal: 60, + horizontal: 90, /** Padding to accommodate scale animations (1.05x) */ scale: 20, @@ -129,20 +131,20 @@ export const useScaledTVSizes = (): ScaledTVSizes => { return { posters: { - poster: Math.round(TVPosterSizes.poster * scale), - landscape: Math.round(TVPosterSizes.landscape * scale), - episode: Math.round(TVPosterSizes.episode * scale), + poster: Math.round(scaleSize(TVPosterSizes.poster) * scale), + landscape: Math.round(scaleSize(TVPosterSizes.landscape) * scale), + episode: Math.round(scaleSize(TVPosterSizes.episode) * scale), }, gaps: { - item: Math.round(TVGaps.item * scale), - section: Math.round(TVGaps.section * scale), - small: Math.round(TVGaps.small * scale), - large: Math.round(TVGaps.large * scale), + item: Math.round(scaleSize(TVGaps.item) * scale), + section: Math.round(scaleSize(TVGaps.section) * scale), + small: Math.round(scaleSize(TVGaps.small) * scale), + large: Math.round(scaleSize(TVGaps.large) * scale), }, padding: { - horizontal: Math.round(TVPadding.horizontal * scale), - scale: Math.round(TVPadding.scale * scale), - vertical: Math.round(TVPadding.vertical * scale), + horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale), + scale: Math.round(scaleSize(TVPadding.scale) * scale), + vertical: Math.round(scaleSize(TVPadding.vertical) * scale), heroHeight: TVPadding.heroHeight * scale, }, animation: TVAnimation, diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index a2ac3b80..833f617e 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -4,25 +4,28 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; * TV Typography Scale * * Consistent text sizes for TV interface components. - * These sizes are optimized for TV viewing distance. + * Design values are for 1920×1080 and scaled proportionally + * to the actual viewport via scaleSize(). */ +import { scaleSize } from "@/utils/scaleSize"; + export const TVTypography = { - /** Hero titles, movie/show names - 70px */ - display: 70, + /** Hero titles, movie/show names */ + display: scaleSize(70), - /** Episode series name, major headings - 42px */ - title: 42, + /** Episode series name, major headings */ + title: scaleSize(42), - /** Section headers (Cast, Technical Details, From this Series) - 32px */ - heading: 32, + /** Section headers (Cast, Technical Details, From this Series) */ + heading: scaleSize(32), - /** Overview, actor names, card titles, metadata - 20px */ - body: 20, + /** Overview, actor names, card titles, metadata */ + body: scaleSize(40), - /** Secondary text, labels, subtitles - 16px */ - callout: 16, -} as const; + /** Secondary text, labels, subtitles */ + callout: scaleSize(26), +}; export type TVTypographyKey = keyof typeof TVTypography; diff --git a/utils/scaleSize.ts b/utils/scaleSize.ts new file mode 100644 index 00000000..09cc9a56 --- /dev/null +++ b/utils/scaleSize.ts @@ -0,0 +1,9 @@ +import { Dimensions } from "react-native"; + +const { width: W, height: H } = Dimensions.get("window"); + +export const scaleSize = (size: number): number => { + const widthRatio = W / 1920; + const heightRatio = H / 1080; + return size * Math.min(widthRatio, heightRatio); +};