Feat/tv interface uniform scale (#1562)

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
lance chant
2026-05-20 07:56:39 +02:00
committed by GitHub
parent 3438e78cab
commit ece5750d34
13 changed files with 197 additions and 107 deletions

View File

@@ -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<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@@ -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<{
>
<Ionicons
name='arrow-forward'
size={32}
size={scaleSize(32)}
color='white'
style={{ marginBottom: 8 }}
style={{ marginBottom: scaleSize(8) }}
/>
<Text
style={{
@@ -250,7 +251,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
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<Props> = ({
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
<View
@@ -329,19 +330,27 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (
@@ -350,7 +359,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
marginLeft: itemWidth / 2,
marginRight: ITEM_GAP,
justifyContent: "center",
height: orientation === "horizontal" ? 191 : 315,
height:
orientation === "horizontal"
? scaleSize(191)
: scaleSize(315),
}}
>
<ActivityIndicator size='small' color='white' />

View File

@@ -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<WatchlistSectionProps> = ({
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
</View>
@@ -286,12 +287,12 @@ export const StreamystatsPromotedWatchlists: React.FC<
<View style={{ overflow: "visible" }} {...props}>
<View
style={{
height: 16,
width: 128,
height: scaleSize(16),
width: scaleSize(128),
backgroundColor: "#262626",
borderRadius: 4,
borderRadius: scaleSize(4),
marginLeft: SCALE_PADDING,
marginBottom: 16,
marginBottom: scaleSize(16),
}}
/>
<View
@@ -309,8 +310,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
backgroundColor: "#262626",
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
</View>

View File

@@ -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<Props> = ({
backgroundColor: "#262626",
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(12),
marginBottom: scaleSize(8),
}}
/>
</View>

View File

@@ -33,6 +33,7 @@ import {
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -129,7 +130,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
<GlassPosterView
imageUrl={posterUrl}
aspectRatio={16 / 9}
cornerRadius={24}
cornerRadius={scaleSize(24)}
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
@@ -154,15 +155,15 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
style={{
width: sizes.posters.episode,
aspectRatio: 16 / 9,
borderRadius: 24,
borderRadius: scaleSize(24),
overflow: "hidden",
transform: [{ scale }],
borderWidth: 2,
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
shadowRadius: focused ? scaleSize(20) : 0,
}}
>
{posterUrl ? (
@@ -183,7 +184,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
>
<Ionicons
name='film-outline'
size={48}
size={scaleSize(48)}
color='rgba(255,255,255,0.3)'
/>
</View>
@@ -472,7 +473,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
bottom:
40 + sizes.posters.episode * (9 / 16) + sizes.gaps.small * 2 + 20,
scaleSize(40) +
sizes.posters.episode * (9 / 16) +
sizes.gaps.small * 2 +
scaleSize(20),
}}
>
{/* Logo or Title */}
@@ -480,9 +484,9 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
height: scaleSize(100),
width: SCREEN_WIDTH * 0.35,
marginBottom: 16,
marginBottom: scaleSize(16),
}}
contentFit='contain'
contentPosition='left'
@@ -493,7 +497,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 12,
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
@@ -507,7 +511,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.9)",
marginBottom: 12,
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
@@ -521,7 +525,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.8)",
marginBottom: 16,
marginBottom: scaleSize(16),
maxWidth: SCREEN_WIDTH * 0.5,
lineHeight: typography.body * 1.4,
}}
@@ -536,7 +540,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
flexDirection: "row",
alignItems: "center",
gap: 16,
gap: scaleSize(16),
}}
>
{year && (
@@ -562,10 +566,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
{activeItem?.OfficialRating && (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
paddingHorizontal: scaleSize(8),
paddingVertical: scaleSize(2),
borderRadius: scaleSize(4),
borderWidth: scaleSize(1),
borderColor: "rgba(255,255,255,0.5)",
}}
>
@@ -584,15 +588,15 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
gap: scaleSize(6),
}}
>
<View
style={{
width: 60,
height: 4,
width: scaleSize(60),
height: scaleSize(4),
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: 2,
borderRadius: scaleSize(2),
overflow: "hidden",
}}
>
@@ -624,7 +628,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
position: "absolute",
left: 0,
right: 0,
bottom: 40,
bottom: scaleSize(40),
}}
>
<FlatList
@@ -633,12 +637,21 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
contentContainerStyle={{
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
// Below is a work around with the contentInset, same in infiniteScrollingCollectionList, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard}
removeClippedSubviews={false}
initialNumToRender={8}

View File

@@ -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<TVPosterCardProps> = ({
: null;
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
}}
>
{episodeLabel && (
<Text
style={{
@@ -259,7 +266,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{item.ChannelName}
@@ -277,7 +284,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{artist}
@@ -296,7 +303,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{artist}
@@ -312,7 +319,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{item.ChildCount} tracks
@@ -328,7 +335,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{item.ProductionYear}
@@ -344,23 +351,23 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<View
style={{
position: "absolute",
top: 12,
left: 12,
top: scaleSize(12),
left: scaleSize(12),
backgroundColor: "#FFFFFF",
borderRadius: 8,
borderRadius: scaleSize(8),
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
paddingHorizontal: scaleSize(12),
paddingVertical: scaleSize(8),
gap: scaleSize(6),
zIndex: 10,
}}
>
<Ionicons name='play' size={16} color='#000000' />
<Ionicons name='play' size={scaleSize(16)} color='#000000' />
<Text
style={{
color: "#000000",
fontSize: 14,
fontSize: scaleSize(14),
fontWeight: "700",
}}
>
@@ -382,7 +389,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
<Ionicons name='play-circle' size={scaleSize(56)} color='white' />
</View>
) : null;
@@ -395,9 +402,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
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<TVPosterCardProps> = ({
<GlassPosterView
imageUrl={imageUrl}
aspectRatio={aspectRatio}
cornerRadius={24}
cornerRadius={scaleSize(24)}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={focused}
@@ -431,10 +438,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
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<TVPosterCardProps> = ({
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: 4,
marginTop: scaleSize(4),
fontWeight: "500",
}}
>
@@ -498,8 +505,13 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
// Default: show name
return (
<Text
numberOfLines={1}
style={{ fontSize: typography.body, color: "#FFFFFF" }}
numberOfLines={3}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: scaleSize(4),
fontWeight: "500",
}}
>
{item.Name}
</Text>
@@ -551,7 +563,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
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<TVPosterCardProps> = ({
{/* Text below poster */}
{showText && (
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<View
style={{ marginTop: scaleSize(12), paddingHorizontal: scaleSize(4) }}
>
{item.Type === "Episode" ? (
<>
{renderSubtitle()}