Compare commits

..

3 Commits

Author SHA1 Message Date
Lance Chant
bab11addee Attempt 2 at scaling
Added some more logic for scaling to hopefully have a uniform state

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-04-10 15:59:50 +02:00
Lance Chant
8ee1197186 Merge branch 'feat/tv-interface' into feat/tv-interface-uniform-scale
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-04-10 14:45:56 +02:00
Lance Chant
8c21054d33 fix scaling
Attempt 2 at trying to make a uniform scale across apple and android tv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-26 09:34:08 +02:00
8 changed files with 129 additions and 71 deletions

View File

@@ -20,7 +20,10 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Animated, Animated,
Dimensions,
Easing, Easing,
PixelRatio,
Platform,
ScrollView, ScrollView,
View, View,
} from "react-native"; } from "react-native";
@@ -40,11 +43,12 @@ import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
const HORIZONTAL_PADDING = 60; const HORIZONTAL_PADDING = scaleSize(60);
const TOP_PADDING = 100; const TOP_PADDING = scaleSize(100);
// Generous gap between sections for Apple TV+ aesthetic // Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = 24; const SECTION_GAP = scaleSize(10);
type InfiniteScrollingCollectionListSection = { type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList"; type: "InfiniteScrollingCollectionList";
@@ -79,6 +83,22 @@ export const Home = () => {
const _invalidateCache = useInvalidatePlaybackProgressCache(); const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal(); 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 // Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null); const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | 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 useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow // Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20; const SCALE_PADDING = scaleSize(20);
interface Props extends ViewProps { interface Props extends ViewProps {
title?: string | null; title?: string | null;
@@ -81,7 +82,7 @@ const TVSeeAllCard: React.FC<{
style={{ style={{
width, width,
aspectRatio, aspectRatio,
borderRadius: 24, borderRadius: scaleSize(24),
backgroundColor: "rgba(255, 255, 255, 0.08)", backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
@@ -91,9 +92,9 @@ const TVSeeAllCard: React.FC<{
> >
<Ionicons <Ionicons
name='arrow-forward' name='arrow-forward'
size={32} size={scaleSize(32)}
color='white' color='white'
style={{ marginBottom: 8 }} style={{ marginBottom: scaleSize(8) }}
/> />
<Text <Text
style={{ style={{
@@ -250,7 +251,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "700", fontWeight: "700",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 20, marginBottom: scaleSize(20),
marginLeft: sizes.padding.horizontal, marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5, letterSpacing: 0.5,
}} }}
@@ -286,8 +287,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
backgroundColor: "#262626", backgroundColor: "#262626",
width: itemWidth, width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15, aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: 12, borderRadius: scaleSize(12),
marginBottom: 8, marginBottom: scaleSize(8),
}} }}
/> />
<View <View
@@ -328,6 +329,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
windowSize={5} windowSize={5}
removeClippedSubviews={false} removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }} maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
ListHeaderComponent={
<View style={{ width: sizes.padding.horizontal }} />
}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
contentInset={{ contentInset={{
left: sizes.padding.horizontal, left: sizes.padding.horizontal,
@@ -342,6 +346,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
width: sizes.padding.horizontal,
}} }}
> >
{isFetchingNextPage && ( {isFetchingNextPage && (
@@ -350,7 +355,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
marginLeft: itemWidth / 2, marginLeft: itemWidth / 2,
marginRight: ITEM_GAP, marginRight: ITEM_GAP,
justifyContent: "center", justifyContent: "center",
height: orientation === "horizontal" ? 191 : 315, height:
orientation === "horizontal"
? scaleSize(191)
: scaleSize(315),
}} }}
> >
<ActivityIndicator size='small' color='white' /> <ActivityIndicator size='small' color='white' />

View File

@@ -19,10 +19,11 @@ import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api"; import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types"; import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const SCALE_PADDING = 20; const SCALE_PADDING = scaleSize(20);
interface WatchlistSectionProps extends ViewProps { interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist; watchlist: StreamystatsWatchlist;
@@ -168,8 +169,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
backgroundColor: "#262626", backgroundColor: "#262626",
width: posterSizes.poster, width: posterSizes.poster,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 12, borderRadius: scaleSize(12),
marginBottom: 8, marginBottom: scaleSize(8),
}} }}
/> />
</View> </View>
@@ -286,12 +287,12 @@ export const StreamystatsPromotedWatchlists: React.FC<
<View style={{ overflow: "visible" }} {...props}> <View style={{ overflow: "visible" }} {...props}>
<View <View
style={{ style={{
height: 16, height: scaleSize(16),
width: 128, width: scaleSize(128),
backgroundColor: "#262626", backgroundColor: "#262626",
borderRadius: 4, borderRadius: scaleSize(4),
marginLeft: SCALE_PADDING, marginLeft: SCALE_PADDING,
marginBottom: 16, marginBottom: scaleSize(16),
}} }}
/> />
<View <View
@@ -309,8 +310,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
backgroundColor: "#262626", backgroundColor: "#262626",
width: posterSizes.poster, width: posterSizes.poster,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 12, borderRadius: scaleSize(12),
marginBottom: 8, marginBottom: scaleSize(8),
}} }}
/> />
</View> </View>

View File

@@ -18,6 +18,7 @@ import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api"; import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types"; import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
@@ -220,8 +221,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
backgroundColor: "#262626", backgroundColor: "#262626",
width: sizes.posters.poster, width: sizes.posters.poster,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: 12, borderRadius: scaleSize(12),
marginBottom: 8, marginBottom: scaleSize(8),
}} }}
/> />
</View> </View>

View File

@@ -21,6 +21,7 @@ import {
} from "@/modules/glass-poster"; } from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
export interface TVPosterCardProps { export interface TVPosterCardProps {
@@ -225,7 +226,13 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
: null; : null;
return ( return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}> <View
style={{
flexDirection: "row",
alignItems: "center",
gap: scaleSize(8),
}}
>
{episodeLabel && ( {episodeLabel && (
<Text <Text
style={{ style={{
@@ -259,7 +266,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 4, marginTop: scaleSize(4),
}} }}
> >
{item.ChannelName} {item.ChannelName}
@@ -277,7 +284,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 4, marginTop: scaleSize(4),
}} }}
> >
{artist} {artist}
@@ -296,7 +303,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 4, marginTop: scaleSize(4),
}} }}
> >
{artist} {artist}
@@ -312,7 +319,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 4, marginTop: scaleSize(4),
}} }}
> >
{item.ChildCount} tracks {item.ChildCount} tracks
@@ -328,7 +335,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: 4, marginTop: scaleSize(4),
}} }}
> >
{item.ProductionYear} {item.ProductionYear}
@@ -344,23 +351,23 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<View <View
style={{ style={{
position: "absolute", position: "absolute",
top: 12, top: scaleSize(12),
left: 12, left: scaleSize(12),
backgroundColor: "#FFFFFF", backgroundColor: "#FFFFFF",
borderRadius: 8, borderRadius: scaleSize(8),
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingHorizontal: 12, paddingHorizontal: scaleSize(12),
paddingVertical: 8, paddingVertical: scaleSize(8),
gap: 6, gap: scaleSize(6),
zIndex: 10, zIndex: 10,
}} }}
> >
<Ionicons name='play' size={16} color='#000000' /> <Ionicons name='play' size={scaleSize(16)} color='#000000' />
<Text <Text
style={{ style={{
color: "#000000", color: "#000000",
fontSize: 14, fontSize: scaleSize(14),
fontWeight: "700", fontWeight: "700",
}} }}
> >
@@ -382,7 +389,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
justifyContent: "center", justifyContent: "center",
}} }}
> >
<Ionicons name='play-circle' size={56} color='white' /> <Ionicons name='play-circle' size={scaleSize(56)} color='white' />
</View> </View>
) : null; ) : null;
@@ -395,9 +402,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
width, width,
aspectRatio, aspectRatio,
borderRadius: 24, borderRadius: scaleSize(24),
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
borderWidth: 2, borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent", borderColor: focused ? "#FFFFFF" : "transparent",
}} }}
/> />
@@ -411,7 +418,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<GlassPosterView <GlassPosterView
imageUrl={imageUrl} imageUrl={imageUrl}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
cornerRadius={24} cornerRadius={scaleSize(24)}
progress={progress} progress={progress}
showWatchedIndicator={isWatched} showWatchedIndicator={isWatched}
isFocused={focused} isFocused={focused}
@@ -431,10 +438,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
position: "relative", position: "relative",
width, width,
aspectRatio, aspectRatio,
borderRadius: 24, borderRadius: scaleSize(4),
overflow: "hidden", overflow: "hidden",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
borderWidth: 2, borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent", borderColor: focused ? "#FFFFFF" : "transparent",
}} }}
> >
@@ -470,7 +477,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#FFFFFF", color: "#FFFFFF",
marginTop: 4, marginTop: scaleSize(4),
fontWeight: "500", fontWeight: "500",
}} }}
> >
@@ -498,8 +505,13 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
// Default: show name // Default: show name
return ( return (
<Text <Text
numberOfLines={1} numberOfLines={3}
style={{ fontSize: typography.body, color: "#FFFFFF" }} style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: scaleSize(4),
fontWeight: "500",
}}
> >
{item.Name} {item.Name}
</Text> </Text>
@@ -551,7 +563,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
shadowColor: useGlass ? undefined : shadowColor, shadowColor: useGlass ? undefined : shadowColor,
shadowOffset: useGlass ? undefined : { width: 0, height: 0 }, shadowOffset: useGlass ? undefined : { width: 0, height: 0 },
shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0, shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0,
shadowRadius: useGlass ? undefined : focused ? 12 : 0, shadowRadius: useGlass ? undefined : focused ? scaleSize(12) : 0,
}} }}
> >
{renderPosterImage()} {renderPosterImage()}
@@ -560,7 +572,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
{/* Text below poster */} {/* Text below poster */}
{showText && ( {showText && (
<View style={{ marginTop: 12, paddingHorizontal: 4 }}> <View
style={{ marginTop: scaleSize(12), paddingHorizontal: scaleSize(4) }}
>
{item.Type === "Episode" ? ( {item.Type === "Episode" ? (
<> <>
{renderSubtitle()} {renderSubtitle()}

View File

@@ -1,10 +1,12 @@
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
/** /**
* TV Layout Sizes * TV Layout Sizes
* *
* Unified constants for TV interface layout including posters, gaps, and padding. * 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 = { export const TVPadding = {
/** Horizontal padding from screen edges */ /** Horizontal padding from screen edges */
horizontal: 60, horizontal: 90,
/** Padding to accommodate scale animations (1.05x) */ /** Padding to accommodate scale animations (1.05x) */
scale: 20, scale: 20,
@@ -129,20 +131,20 @@ export const useScaledTVSizes = (): ScaledTVSizes => {
return { return {
posters: { posters: {
poster: Math.round(TVPosterSizes.poster * scale), poster: Math.round(scaleSize(TVPosterSizes.poster) * scale),
landscape: Math.round(TVPosterSizes.landscape * scale), landscape: Math.round(scaleSize(TVPosterSizes.landscape) * scale),
episode: Math.round(TVPosterSizes.episode * scale), episode: Math.round(scaleSize(TVPosterSizes.episode) * scale),
}, },
gaps: { gaps: {
item: Math.round(TVGaps.item * scale), item: Math.round(scaleSize(TVGaps.item) * scale),
section: Math.round(TVGaps.section * scale), section: Math.round(scaleSize(TVGaps.section) * scale),
small: Math.round(TVGaps.small * scale), small: Math.round(scaleSize(TVGaps.small) * scale),
large: Math.round(TVGaps.large * scale), large: Math.round(scaleSize(TVGaps.large) * scale),
}, },
padding: { padding: {
horizontal: Math.round(TVPadding.horizontal * scale), horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale),
scale: Math.round(TVPadding.scale * scale), scale: Math.round(scaleSize(TVPadding.scale) * scale),
vertical: Math.round(TVPadding.vertical * scale), vertical: Math.round(scaleSize(TVPadding.vertical) * scale),
heroHeight: TVPadding.heroHeight * scale, heroHeight: TVPadding.heroHeight * scale,
}, },
animation: TVAnimation, animation: TVAnimation,

View File

@@ -4,25 +4,28 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
* TV Typography Scale * TV Typography Scale
* *
* Consistent text sizes for TV interface components. * 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 = { export const TVTypography = {
/** Hero titles, movie/show names - 70px */ /** Hero titles, movie/show names */
display: 70, display: scaleSize(70),
/** Episode series name, major headings - 42px */ /** Episode series name, major headings */
title: 42, title: scaleSize(42),
/** Section headers (Cast, Technical Details, From this Series) - 32px */ /** Section headers (Cast, Technical Details, From this Series) */
heading: 32, heading: scaleSize(32),
/** Overview, actor names, card titles, metadata - 20px */ /** Overview, actor names, card titles, metadata */
body: 20, body: scaleSize(40),
/** Secondary text, labels, subtitles - 16px */ /** Secondary text, labels, subtitles */
callout: 16, callout: scaleSize(26),
} as const; };
export type TVTypographyKey = keyof typeof TVTypography; export type TVTypographyKey = keyof typeof TVTypography;

9
utils/scaleSize.ts Normal file
View File

@@ -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);
};