fix(tv): poster design and other stuff

This commit is contained in:
Fredrik Burmester
2026-01-30 09:15:44 +01:00
parent 0cd74519d4
commit aed3a8f493
26 changed files with 758 additions and 1362 deletions

View File

@@ -27,13 +27,8 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import { TVFilterButton } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -293,26 +288,19 @@ const page: React.FC = () => {
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: posterSizes.poster,
}}
>
<TVFocusablePoster
<TVPosterCard
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
width={posterSizes.poster}
/>
</View>
);
},
[router, showItemActions],
[router, showItemActions, posterSizes.poster],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -34,13 +34,8 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
@@ -476,26 +471,14 @@ const Page = () => {
}
return (
<View
<TVPosterCard
key={item.Id}
style={{
width: posterSizes.poster,
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, api, typography],

View File

@@ -24,9 +24,7 @@ import {
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
@@ -46,31 +44,6 @@ import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => (
<View style={{ marginTop: 12 }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
@@ -205,27 +178,18 @@ export default function WatchlistDetailScreen() {
};
return (
<View
<TVPosterCard
key={item.Id}
style={{
width: posterSizes.poster,
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
</View>
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, typography],
[router, showItemActions, posterSizes.poster],
);
const renderItem = useCallback(

View File

@@ -1,188 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
// TV version uses fixed width, size prop kept for API compatibility
size: _size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const url = useMemo(() => {
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item, useEpisodePoster]);
const progress = useMemo(() => {
if (item.Type === "Program") {
if (!item.StartDate || !item.EndDate) {
return 0;
}
const startDate = new Date(item.StartDate);
const endDate = new Date(item.EndDate);
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
if (total <= 0) {
return 0;
}
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
const isWatched = item.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (!url) {
return (
<View
style={{
width: posterSizes.landscape,
aspectRatio: 16 / 9,
borderRadius: 24,
}}
/>
);
}
if (useGlass) {
return (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={url}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={posterSizes.landscape}
style={{ width: posterSizes.landscape }}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
);
}
// Fallback for older tvOS versions
return (
<View
style={{
position: "relative",
width: posterSizes.landscape,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
width: "100%",
height: "100%",
}}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);
};
export default ContinueWatchingPoster;

View File

@@ -16,17 +16,15 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 24;
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
@@ -47,76 +45,6 @@ interface Props extends ViewProps {
}
type Typography = ReturnType<typeof useScaledTVTypography>;
// TV-specific ItemCardText with appropriately sized fonts
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
width?: number;
}> = ({ item, typography, width }) => {
const renderSubtitle = () => {
if (item.Type === "Episode") {
return (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
);
}
if (item.Type === "Program") {
// For Live TV programs, show channel name
const channelName = item.ChannelName;
return channelName ? (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
}}
>
{channelName}
</Text>
) : null;
}
// Default: show production year
return item.ProductionYear ? (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
}}
>
{item.ProductionYear}
</Text>
) : null;
};
return (
<View style={{ marginTop: 12, flexDirection: "column", width }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.body, color: "#FFFFFF" }}
>
{item.Name}
</Text>
{renderSubtitle()}
</View>
);
};
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
// TV-specific "See All" card for end of lists
@@ -139,7 +67,7 @@ const TVSeeAllCard: React.FC<{
}) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return (
@@ -200,6 +128,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
@@ -279,7 +209,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [data]);
const itemWidth =
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
@@ -310,70 +240,17 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Program") {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Playlist" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Playlist" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Video" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Video" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
return (
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation={orientation}
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText
item={item}
typography={typography}
width={itemWidth}
/>
</View>
@@ -387,7 +264,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions,
handleItemFocus,
handleItemBlur,
typography,
ITEM_GAP,
],
);

View File

@@ -11,10 +11,9 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -23,36 +22,8 @@ import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
jellyfinServerId: string;
@@ -67,6 +38,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -135,27 +108,31 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[],
[posterSizes.poster, ITEM_GAP],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
width={posterSizes.poster}
/>
</View>
);
},
[handleItemPress, showItemActions, onItemFocus, typography],
[
ITEM_GAP,
posterSizes.poster,
handleItemPress,
showItemActions,
onItemFocus,
],
);
if (!isLoading && (!items || items.length === 0)) return null;
@@ -230,6 +207,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();

View File

@@ -11,10 +11,8 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -23,11 +21,6 @@ import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
@@ -36,31 +29,6 @@ interface Props extends ViewProps {
onItemFocus?: (item: BaseItemDto) => void;
}
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
export const StreamystatsRecommendations: React.FC<Props> = ({
title,
type,
@@ -70,7 +38,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -192,31 +160,29 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
length: sizes.posters.poster + sizes.gaps.item,
offset: (sizes.posters.poster + sizes.gaps.item) * index,
index,
}),
[],
[sizes],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
<View style={{ marginRight: sizes.gaps.item }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
width={sizes.posters.poster}
/>
</View>
);
},
[handleItemPress, showItemActions, onItemFocus, typography],
[sizes, handleItemPress, showItemActions, onItemFocus],
);
if (!streamyStatsEnabled) return null;
@@ -231,7 +197,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}
>
@@ -242,17 +208,17 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: posterSizes.poster }}>
<View key={i} style={{ width: sizes.posters.poster }}>
<View
style={{
backgroundColor: "#262626",
width: posterSizes.poster,
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
@@ -275,8 +241,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale,
paddingHorizontal: sizes.padding.scale,
}}
/>
)}

View File

@@ -23,7 +23,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -36,9 +36,6 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
const CARD_GAP = 24;
const CARD_PADDING = 60;
interface TVHeroCarouselProps {
items: BaseItemDto[];
@@ -49,14 +46,14 @@ interface TVHeroCarouselProps {
interface HeroCardProps {
item: BaseItemDto;
isFirst: boolean;
cardWidth: number;
sizes: ScaledTVSizes;
onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void;
onLongPress?: (item: BaseItemDto) => void;
}
const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, cardWidth, onFocus, onPress, onLongPress }) => {
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -87,8 +84,6 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
return null;
}, [api, item]);
const progress = item.UserData?.PlayedPercentage || 0;
const animateTo = useCallback(
(value: number) =>
Animated.timing(scale, {
@@ -102,9 +97,9 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(1.1);
animateTo(sizes.animation.focusScale);
onFocus(item);
}, [animateTo, onFocus, item]);
}, [animateTo, onFocus, item, sizes.animation.focusScale]);
const handleBlur = useCallback(() => {
setFocused(false);
@@ -120,7 +115,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
}, [onLongPress, item]);
// Use glass poster for tvOS 26+
if (useGlass) {
if (useGlass && posterUrl) {
const progress = item.UserData?.PlayedPercentage || 0;
return (
<Pressable
onPress={handlePress}
@@ -128,17 +124,17 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: CARD_GAP }}
style={{ marginRight: sizes.gaps.item }}
>
<GlassPosterView
imageUrl={posterUrl}
aspectRatio={16 / 9}
cornerRadius={16}
cornerRadius={24}
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
width={cardWidth}
style={{ width: cardWidth }}
width={sizes.posters.episode}
style={{ width: sizes.posters.episode }}
/>
</Pressable>
);
@@ -152,13 +148,13 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: CARD_GAP }}
style={{ marginRight: sizes.gaps.item }}
>
<Animated.View
style={{
width: cardWidth,
width: sizes.posters.episode,
aspectRatio: 16 / 9,
borderRadius: 16,
borderRadius: 24,
overflow: "hidden",
transform: [{ scale }],
shadowColor: "#FFFFFF",
@@ -206,7 +202,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
onItemLongPress,
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -365,13 +361,13 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<HeroCard
item={item}
isFirst={index === 0}
cardWidth={posterSizes.heroCard}
sizes={sizes}
onFocus={handleCardFocus}
onPress={handleCardPress}
onLongPress={onItemLongPress}
/>
),
[handleCardFocus, handleCardPress, onItemLongPress, posterSizes.heroCard],
[handleCardFocus, handleCardPress, onItemLongPress, sizes],
);
// Memoize keyExtractor
@@ -379,8 +375,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
<View style={{ height: HERO_HEIGHT, width: "100%" }}>
<View style={{ height: heroHeight, width: "100%" }}>
{/* Backdrop layers with crossfade */}
<View
style={{
@@ -469,8 +467,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<View
style={{
position: "absolute",
left: insets.left + CARD_PADDING,
right: insets.right + CARD_PADDING,
left: insets.left + sizes.padding.horizontal,
right: insets.right + sizes.padding.horizontal,
bottom: 40,
}}
>
@@ -624,7 +622,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingVertical: 12 }}
contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard}
removeClippedSubviews={false}
initialNumToRender={8}

View File

@@ -26,11 +26,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVItemCardText } from "@/components/tv/TVItemCardText";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -43,7 +41,6 @@ const { width: SCREEN_WIDTH } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const ACTOR_IMAGE_SIZE = 250;
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
interface TVActorPageProps {
@@ -59,6 +56,8 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const from = (segments as string[])[2] || "(home)";
const posterSizes = useScaledTVPosterSizes();
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -243,19 +242,15 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const renderMovieItem = useCallback(
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster
<TVPosterCard
item={filmItem}
orientation='vertical'
onPress={() => handleItemPress(filmItem)}
onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)}
hasTVPreferredFocus={index === 0}
>
<View>
<MoviePoster item={filmItem} />
<View style={{ width: posterSizes.poster }}>
<TVItemCardText item={filmItem} />
</View>
</View>
</TVFocusablePoster>
width={posterSizes.poster}
/>
</View>
),
[handleItemPress, showItemActions, posterSizes.poster],
@@ -265,19 +260,15 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const renderSeriesItem = useCallback(
({ item: filmItem, index }: { item: BaseItemDto; index: number }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster
<TVPosterCard
item={filmItem}
orientation='vertical'
onPress={() => handleItemPress(filmItem)}
onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)}
hasTVPreferredFocus={movies.length === 0 && index === 0}
>
<View>
<SeriesPoster item={filmItem} />
<View style={{ width: posterSizes.poster }}>
<TVItemCardText item={filmItem} />
</View>
</View>
</TVFocusablePoster>
width={posterSizes.poster}
/>
</View>
),
[handleItemPress, showItemActions, posterSizes.poster, movies.length],

View File

@@ -1,106 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const MoviePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const url = useMemo(() => {
return getPrimaryImageUrl({
api,
item,
width: posterSizes.poster * 2, // 2x for quality on large screens
});
}, [api, item, posterSizes.poster]);
const progress = item.UserData?.PlayedPercentage || 0;
const isWatched = item.UserData?.Played === true;
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (useGlass) {
return (
<GlassPosterView
imageUrl={url ?? null}
aspectRatio={10 / 15}
cornerRadius={24}
progress={showProgress ? progress : 0}
showWatchedIndicator={isWatched}
isFocused={false}
width={posterSizes.poster}
style={{ width: posterSizes.poster }}
/>
);
}
// Fallback for older tvOS versions
return (
<View
style={{
position: "relative",
borderRadius: 24,
overflow: "hidden",
width: posterSizes.poster,
aspectRatio: 10 / 15,
}}
>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: 10 / 15,
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View
style={{
height: 4,
backgroundColor: "#dc2626",
width: "100%",
}}
/>
)}
</View>
);
};
export default MoviePoster;

View File

@@ -1,92 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type SeriesPosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom);
const posterSizes = useScaledTVPosterSizes();
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${posterSizes.poster * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
}
return getPrimaryImageUrl({
api,
item,
width: posterSizes.poster * 2, // 2x for quality on large screens
});
}, [api, item, posterSizes.poster]);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (useGlass) {
return (
<GlassPosterView
imageUrl={url ?? null}
aspectRatio={10 / 15}
cornerRadius={24}
progress={0}
showWatchedIndicator={false}
isFocused={false}
width={posterSizes.poster}
style={{ width: posterSizes.poster }}
/>
);
}
// Fallback for older tvOS versions
return (
<View
style={{
width: posterSizes.poster,
aspectRatio: 10 / 15,
position: "relative",
borderRadius: 24,
overflow: "hidden",
}}
>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
height: "100%",
width: "100%",
}}
/>
</View>
);
};
export default SeriesPoster;

View File

@@ -151,7 +151,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
const typography = useScaledTVTypography();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
useTVFocusAnimation();
const posterUrl = item.profilePath
? jellyseerrApi?.imageProxy(item.profilePath, "w185")

View File

@@ -17,7 +17,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
useTVFocusAnimation({ duration: 150 });
return (
<Pressable

View File

@@ -2,140 +2,15 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useCallback, useEffect, useRef, useState } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster.tv";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
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 (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : item.Type === "MusicArtist" ? (
<Text
numberOfLines={2}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
) : item.Type === "MusicAlbum" ? (
<>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</>
) : item.Type === "Audio" ? (
<>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</>
) : item.Type === "Playlist" ? (
<>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ChildCount} tracks
</Text>
</>
) : item.Type === "Person" ? (
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
) : (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
interface TVSearchSectionProps extends ViewProps {
title: string;
items: BaseItemDto[];
@@ -160,6 +35,8 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
@@ -189,146 +66,176 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
[itemWidth, ITEM_GAP],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
// Music Artist - circular avatar
if (item.Type === "MusicArtist") {
const imageUrl = imageUrlGetter?.(item);
return (
<View
style={{
width: 160,
height: 160,
borderRadius: 80,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
// Special handling for MusicArtist (circular avatar)
if (item.Type === "MusicArtist") {
const imageUrl = imageUrlGetter?.(item);
return (
<View style={{ marginRight: ITEM_GAP, width: 160 }}>
<TVFocusablePoster
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 48 }}>👤</Text>
</View>
)}
<View
style={{
width: 160,
height: 160,
borderRadius: 80,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 48 }}>👤</Text>
</View>
)}
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={2}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
);
}
// Music Album, Audio, Playlist - square images
if (
item.Type === "MusicAlbum" ||
item.Type === "Audio" ||
item.Type === "Playlist"
) {
const imageUrl = imageUrlGetter?.(item);
const icon =
item.Type === "Playlist"
? "🎶"
: item.Type === "Audio"
? "🎵"
: "🎵";
return (
<View
style={{
width: posterSizes.poster,
height: posterSizes.poster,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 64 }}>{icon}</Text>
</View>
)}
</View>
);
}
// Person (Actor)
if (item.Type === "Person") {
return <MoviePoster item={item} />;
}
// Episode rendering
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
// Movie rendering
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
// Series rendering
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// BoxSet (Collection)
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
</View>
);
};
}
// Special width for music artists (circular)
const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
// Special handling for MusicAlbum, Audio, Playlist (square images)
if (
item.Type === "MusicAlbum" ||
item.Type === "Audio" ||
item.Type === "Playlist"
) {
const imageUrl = imageUrlGetter?.(item);
const icon =
item.Type === "Playlist" ? "🎶" : item.Type === "Audio" ? "🎵" : "🎵";
return (
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
>
<View
style={{
width: posterSizes.poster,
height: posterSizes.poster,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#262626",
}}
>
<Text style={{ fontSize: 64 }}>{icon}</Text>
</View>
)}
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
{item.Type === "MusicAlbum" && (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
)}
{item.Type === "Audio" && (
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
)}
{item.Type === "Playlist" && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ChildCount} tracks
</Text>
)}
</View>
</View>
);
}
// Use TVPosterCard for all other item types
return (
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation={orientation}
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
@@ -337,10 +244,8 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
onFocus={handleItemFocus}
onBlur={handleItemBlur}
disabled={disabled}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} />
width={itemWidth}
/>
</View>
);
},
@@ -354,6 +259,9 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
handleItemBlur,
disabled,
imageUrlGetter,
posterSizes.poster,
typography.callout,
ITEM_GAP,
],
);

View File

@@ -23,7 +23,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
useTVFocusAnimation({ duration: 150 });
// Design language: white for focused/selected, transparent white for unfocused
const getBackgroundColor = () => {

View File

@@ -1,222 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import React, { useMemo } from "react";
import { View } from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
interface TVEpisodeCardProps {
episode: BaseItemDto;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
/** When true, the item remains focusable even when disabled (for navigation purposes) */
focusableWhenDisabled?: boolean;
/** Shows a "Now Playing" badge on the card */
isCurrent?: boolean;
onPress: () => void;
onLongPress?: () => void;
onFocus?: () => void;
onBlur?: () => void;
/** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void;
}
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
episode,
hasTVPreferredFocus = false,
disabled = false,
focusableWhenDisabled = false,
isCurrent = false,
onPress,
onLongPress,
onFocus,
onBlur,
refSetter,
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom);
const thumbnailUrl = useMemo(() => {
if (!api) return null;
// Try to get episode primary image first
if (episode.ImageTags?.Primary) {
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
}
// Fall back to series thumb or backdrop
if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
}
// Default episode image
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, episode]);
const duration = useMemo(() => {
if (!episode.RunTimeTicks) return null;
return runtimeTicksToMinutes(episode.RunTimeTicks);
}, [episode.RunTimeTicks]);
const episodeLabel = useMemo(() => {
const season = episode.ParentIndexNumber;
const ep = episode.IndexNumber;
if (season !== undefined && ep !== undefined) {
return `S${season}:E${ep}`;
}
return null;
}, [episode.ParentIndexNumber, episode.IndexNumber]);
const progress = episode.UserData?.PlayedPercentage || 0;
const isWatched = episode.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
// Now Playing badge component (shared between glass and fallback)
const NowPlayingBadge = isCurrent ? (
<View
style={{
position: "absolute",
top: 12,
left: 12,
backgroundColor: "#FFFFFF",
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
zIndex: 10,
}}
>
<Ionicons name='play' size={16} color='#000000' />
<Text
style={{
color: "#000000",
fontSize: 14,
fontWeight: "700",
}}
>
Now Playing
</Text>
</View>
) : null;
return (
<View
style={{
width: posterSizes.episode,
opacity: isCurrent ? 0.75 : disabled ? 0.5 : 1,
}}
>
<TVFocusablePoster
onPress={onPress}
onLongPress={onLongPress}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusableWhenDisabled={focusableWhenDisabled}
onFocus={onFocus}
onBlur={onBlur}
refSetter={refSetter}
>
{useGlass ? (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={thumbnailUrl}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={posterSizes.episode}
style={{ width: posterSizes.episode }}
/>
{NowPlayingBadge}
</View>
) : (
<View
style={{
width: posterSizes.episode,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{thumbnailUrl ? (
<Image
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
/>
)}
<WatchedIndicator item={episode} />
<ProgressBar item={episode} />
{NowPlayingBadge}
</View>
)}
</TVFocusablePoster>
{/* Episode info below thumbnail */}
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
{episodeLabel && (
<Text
style={{
fontSize: typography.callout,
color: "#FFFFFF",
fontWeight: "500",
}}
>
{episodeLabel}
</Text>
)}
{duration && (
<>
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
</Text>
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
{duration}
</Text>
</>
)}
</View>
<Text
numberOfLines={2}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: 4,
fontWeight: "500",
}}
>
{episode.Name}
</Text>
</View>
</View>
);
};

View File

@@ -1,12 +1,8 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import React, { useCallback } from "react";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
const LIST_GAP = 24;
const VERTICAL_PADDING = 12;
import { TVHorizontalList } from "@/components/tv/TVHorizontalList";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
interface TVEpisodeListProps {
episodes: BaseItemDto[];
@@ -28,7 +24,7 @@ interface TVEpisodeListProps {
firstEpisodeRefSetter?: (ref: View | null) => void;
/** Text to show when episodes array is empty */
emptyText?: string;
/** Horizontal padding for the list content (default: 80) */
/** Horizontal padding for the list content */
horizontalPadding?: number;
}
@@ -43,57 +39,51 @@ export const TVEpisodeList: React.FC<TVEpisodeListProps> = ({
scrollViewRef,
firstEpisodeRefSetter,
emptyText,
horizontalPadding = 80,
horizontalPadding,
}) => {
const typography = useScaledTVTypography();
const renderItem = useCallback(
({ item: episode, index }: { item: BaseItemDto; index: number }) => {
const isCurrent = currentEpisodeId
? episode.Id === currentEpisodeId
: false;
return (
<TVPosterCard
item={episode}
orientation='horizontal'
onPress={() => onEpisodePress(episode)}
onLongPress={
onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
}
onFocus={onFocus}
onBlur={onBlur}
disabled={isCurrent || disabled}
focusableWhenDisabled={isCurrent}
isCurrent={isCurrent}
refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
/>
);
},
[
currentEpisodeId,
disabled,
firstEpisodeRefSetter,
onBlur,
onEpisodeLongPress,
onEpisodePress,
onFocus,
],
);
if (episodes.length === 0 && emptyText) {
return (
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: 20,
}}
>
{emptyText}
</Text>
);
}
const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []);
return (
<ScrollView
ref={scrollViewRef as React.RefObject<ScrollView>}
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: horizontalPadding,
paddingVertical: VERTICAL_PADDING,
gap: LIST_GAP,
}}
>
{episodes.map((episode, index) => {
const isCurrent = currentEpisodeId
? episode.Id === currentEpisodeId
: false;
return (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => onEpisodePress(episode)}
onLongPress={
onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
}
onFocus={onFocus}
onBlur={onBlur}
disabled={isCurrent || disabled}
focusableWhenDisabled={isCurrent}
isCurrent={isCurrent}
refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
/>
);
})}
</ScrollView>
<TVHorizontalList
data={episodes}
keyExtractor={keyExtractor}
renderItem={renderItem}
emptyText={emptyText}
scrollViewRef={scrollViewRef}
horizontalPadding={horizontalPadding}
/>
);
};

View File

@@ -21,7 +21,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
useTVFocusAnimation();
const imageUrl = person.Id
? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=280&fillHeight=280&quality=90`

View File

@@ -3,6 +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 { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVActorCard } from "./TVActorCard";
@@ -25,6 +26,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
upwardFocusDestination,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
if (cast.length === 0) {
@@ -57,7 +59,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 16,
gap: 28,
gap: sizes.gaps.item,
}}
>
{cast.map((person, index) => (

View File

@@ -0,0 +1,221 @@
import React, { useCallback } from "react";
import { FlatList, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVHorizontalListProps<T> {
/** Data items to render */
data: T[];
/** Unique key extractor */
keyExtractor: (item: T, index: number) => string;
/** Render function for each item */
renderItem: (info: { item: T; index: number }) => React.ReactElement | null;
/** Optional section title */
title?: string;
/** Text to show when data array is empty */
emptyText?: string;
/** Whether to use FlatList (for large/infinite lists) or ScrollView (for small lists) */
useFlatList?: boolean;
/** Called when end is reached (only for FlatList) */
onEndReached?: () => void;
/** Ref for the scroll view */
scrollViewRef?: React.RefObject<ScrollView | FlatList<T> | null>;
/** Footer component (only for FlatList) */
ListFooterComponent?: React.ReactElement | null;
/** Whether this is the first section (for initial focus) */
isFirstSection?: boolean;
/** Loading state */
isLoading?: boolean;
/** Skeleton item count when loading */
skeletonCount?: number;
/** Skeleton render function */
renderSkeleton?: () => React.ReactElement;
/**
* Custom horizontal padding (overrides default sizes.padding.scale).
* Use this when the list needs to extend beyond its parent's padding.
* The list will use negative margin to extend beyond the parent,
* then add this padding inside to align content properly.
*/
horizontalPadding?: number;
}
/**
* TVHorizontalList - A unified horizontal list component for TV.
*
* Provides consistent spacing and layout for horizontal lists:
* - Uses `sizes.gaps.item` (24px default) for gap between items
* - Uses `sizes.padding.scale` (20px default) for padding to accommodate focus scale
* - Supports both ScrollView (small lists) and FlatList (large/infinite lists)
*/
export function TVHorizontalList<T>({
data,
keyExtractor,
renderItem,
title,
emptyText,
useFlatList = false,
onEndReached,
scrollViewRef,
ListFooterComponent,
isLoading = false,
skeletonCount = 5,
renderSkeleton,
horizontalPadding,
}: TVHorizontalListProps<T>) {
const sizes = useScaledTVSizes();
const typography = useScaledTVTypography();
// Use custom horizontal padding if provided, otherwise use default scale padding
const effectiveHorizontalPadding = horizontalPadding ?? sizes.padding.scale;
// Apply negative margin when using custom padding to extend beyond parent
const marginHorizontal = horizontalPadding ? -horizontalPadding : 0;
// Wrap renderItem to add consistent gap
const renderItemWithGap = useCallback(
({ item, index }: { item: T; index: number }) => {
const isLast = index === data.length - 1;
return (
<View style={{ marginRight: isLast ? 0 : sizes.gaps.item }}>
{renderItem({ item, index })}
</View>
);
},
[data.length, renderItem, sizes.gaps.item],
);
// Empty state
if (!isLoading && data.length === 0 && emptyText) {
return (
<View style={{ overflow: "visible" }}>
{title && (
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}
>
{title}
</Text>
)}
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: sizes.padding.scale,
}}
>
{emptyText}
</Text>
</View>
);
}
// Loading state
if (isLoading && renderSkeleton) {
return (
<View style={{ overflow: "visible" }}>
{title && (
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}
>
{title}
</Text>
)}
<View
style={{
flexDirection: "row",
gap: sizes.gaps.item,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
}}
>
{Array.from({ length: skeletonCount }).map((_, i) => (
<View key={i}>{renderSkeleton()}</View>
))}
</View>
</View>
);
}
const contentContainerStyle = {
paddingHorizontal: effectiveHorizontalPadding,
paddingVertical: sizes.padding.scale,
};
const listStyle = {
overflow: "visible" as const,
marginHorizontal,
};
return (
<View style={{ overflow: "visible" }}>
{title && (
<Text
style={{
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: sizes.padding.scale,
letterSpacing: 0.5,
}}
>
{title}
</Text>
)}
{useFlatList ? (
<FlatList
ref={scrollViewRef as React.RefObject<FlatList<T>>}
horizontal
data={data}
keyExtractor={keyExtractor}
renderItem={renderItemWithGap}
showsHorizontalScrollIndicator={false}
removeClippedSubviews={false}
style={listStyle}
contentContainerStyle={contentContainerStyle}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
ListFooterComponent={ListFooterComponent}
/>
) : (
<ScrollView
ref={scrollViewRef as React.RefObject<ScrollView>}
horizontal
showsHorizontalScrollIndicator={false}
style={listStyle}
contentContainerStyle={contentContainerStyle}
>
{data.map((item, index) => (
<View
key={keyExtractor(item, index)}
style={{
marginRight: index === data.length - 1 ? 0 : sizes.gaps.item,
}}
>
{renderItem({ item, index })}
</View>
))}
{ListFooterComponent}
</ScrollView>
)}
</View>
);
}

View File

@@ -3,6 +3,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
@@ -17,6 +18,7 @@ export interface TVSeriesNavigationProps {
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
// Only show for episodes with a series
@@ -25,13 +27,14 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
}
return (
<View style={{ marginBottom: 32 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 24,
marginBottom: 20,
letterSpacing: 0.5,
}}
>
{t("item_card.from_this_series") || "From this Series"}
@@ -39,11 +42,14 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
style={{
marginHorizontal: -sizes.padding.horizontal,
overflow: "visible",
}}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 24,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
>
{/* Series card */}

View File

@@ -57,7 +57,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
width: 56,
height: 32,
borderRadius: 16,
backgroundColor: value ? "#34C759" : "#4B5563",
backgroundColor: value ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
paddingHorizontal: 2,
}}
@@ -67,7 +67,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "#FFFFFF",
backgroundColor: value ? "#000000" : "#FFFFFF",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>

View File

@@ -7,6 +7,7 @@ export const CONTROLS_CONSTANTS = {
PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks
LONG_PRESS_INITIAL_SEEK: 30,
LONG_PRESS_ACCELERATION: 1.2,
LONG_PRESS_MAX_ACCELERATION: 4,
LONG_PRESS_INTERVAL: 300,
SLIDER_DEBOUNCE_MS: 3,
} as const;

View File

@@ -1,57 +1,12 @@
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
/**
* TV Poster Sizes
*
* Base sizes for poster components on TV interfaces.
* These are scaled dynamically based on the user's tvTypographyScale setting.
* @deprecated Import from "@/constants/TVSizes" instead.
* This file is kept for backwards compatibility.
*/
export const TVPosterSizes = {
/** Portrait posters (movies, series) - 10:15 aspect ratio */
poster: 256,
export {
type ScaledTVPosterSizes,
TVPosterSizes,
useScaledTVPosterSizes,
} from "./TVSizes";
/** Landscape posters (continue watching, thumbs) - 16:9 aspect ratio */
landscape: 396,
/** Episode cards - 16:9 aspect ratio */
episode: 336,
/** Hero carousel cards - 16:9 aspect ratio */
heroCard: 276,
} as const;
export type TVPosterSizeKey = keyof typeof TVPosterSizes;
/**
* Linear poster size offsets (in pixels) - synchronized with typography scale.
* Uses fixed pixel steps for consistent linear scaling across all poster types.
*/
const posterScaleOffsets: Record<TVTypographyScale, number> = {
[TVTypographyScale.Small]: -10,
[TVTypographyScale.Default]: 0,
[TVTypographyScale.Large]: 10,
[TVTypographyScale.ExtraLarge]: 20,
};
/**
* Hook that returns scaled TV poster sizes based on user settings.
* Use this instead of the static TVPosterSizes constant for dynamic scaling.
*
* @example
* const posterSizes = useScaledTVPosterSizes();
* <View style={{ width: posterSizes.poster }}>
*/
export const useScaledTVPosterSizes = () => {
const { settings } = useSettings();
const offset =
posterScaleOffsets[settings.tvTypographyScale] ??
posterScaleOffsets[TVTypographyScale.Default];
return {
poster: TVPosterSizes.poster + offset,
landscape: TVPosterSizes.landscape + offset,
episode: TVPosterSizes.episode + offset,
heroCard: TVPosterSizes.heroCard + offset,
};
};
export type TVPosterSizeKey = keyof typeof import("./TVSizes").TVPosterSizes;

175
constants/TVSizes.ts Normal file
View File

@@ -0,0 +1,175 @@
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
/**
* 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 (at Default scale)
// =============================================================================
/**
* Base poster widths in pixels.
* Heights are calculated from aspect ratios.
*/
export const TVPosterSizes = {
/** Portrait posters (movies, series) - 10:15 aspect ratio */
poster: 210,
/** Landscape posters (continue watching, thumbs, hero) - 16:9 aspect ratio */
landscape: 340,
/** Episode cards - 16:9 aspect ratio */
episode: 320,
} as const;
/**
* Base gap/spacing values in pixels.
*/
export const TVGaps = {
/** Gap between items in horizontal lists */
item: 24,
/** Gap between sections vertically */
section: 32,
/** Small gap for tight layouts */
small: 12,
/** Large gap for spacious layouts */
large: 48,
} as const;
/**
* Base padding values in pixels.
*/
export const TVPadding = {
/** Horizontal padding from screen edges */
horizontal: 60,
/** Padding to accommodate scale animations (1.05x) */
scale: 20,
/** Vertical padding for content areas */
vertical: 24,
/** Hero section height as percentage of screen height (0.0 - 1.0) */
heroHeight: 0.6,
} as const;
/**
* Animation and interaction values.
*/
export const TVAnimation = {
/** Scale factor for focused items */
focusScale: 1.05,
} as const;
// =============================================================================
// SCALING
// =============================================================================
/**
* Scale multipliers for each typography scale level.
* Applied to poster sizes and gaps.
*/
const sizeScaleMultipliers: Record<TVTypographyScale, number> = {
[TVTypographyScale.Small]: 0.9,
[TVTypographyScale.Default]: 1.0,
[TVTypographyScale.Large]: 1.1,
[TVTypographyScale.ExtraLarge]: 1.2,
};
// =============================================================================
// HOOKS
// =============================================================================
export type ScaledTVPosterSizes = {
poster: number;
landscape: number;
episode: number;
};
export type ScaledTVGaps = {
item: number;
section: number;
small: number;
large: number;
};
export type ScaledTVPadding = {
horizontal: number;
scale: number;
vertical: number;
heroHeight: number;
};
export type ScaledTVSizes = {
posters: ScaledTVPosterSizes;
gaps: ScaledTVGaps;
padding: ScaledTVPadding;
animation: typeof TVAnimation;
};
/**
* Hook that returns all scaled TV sizes based on user settings.
*
* @example
* const sizes = useScaledTVSizes();
* <View style={{ width: sizes.posters.poster, marginRight: sizes.gaps.item }}>
*/
export const useScaledTVSizes = (): ScaledTVSizes => {
const { settings } = useSettings();
const scale =
sizeScaleMultipliers[settings.tvTypographyScale] ??
sizeScaleMultipliers[TVTypographyScale.Default];
return {
posters: {
poster: Math.round(TVPosterSizes.poster * scale),
landscape: Math.round(TVPosterSizes.landscape * scale),
episode: Math.round(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),
},
padding: {
horizontal: Math.round(TVPadding.horizontal * scale),
scale: Math.round(TVPadding.scale * scale),
vertical: Math.round(TVPadding.vertical * scale),
heroHeight: TVPadding.heroHeight * scale,
},
animation: TVAnimation,
};
};
/**
* Hook that returns only scaled poster sizes.
* Use this for backwards compatibility or when you only need poster sizes.
*/
export const useScaledTVPosterSizes = (): ScaledTVPosterSizes => {
const sizes = useScaledTVSizes();
return sizes.posters;
};
/**
* Hook that returns only scaled gap sizes.
*/
export const useScaledTVGaps = (): ScaledTVGaps => {
const sizes = useScaledTVSizes();
return sizes.gaps;
};
/**
* Hook that returns only scaled padding sizes.
*/
export const useScaledTVPadding = (): ScaledTVPadding => {
const sizes = useScaledTVSizes();
return sizes.padding;
};

View File

@@ -59,7 +59,7 @@ struct GlassPosterView: View {
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.focusable()
.focused($isInternallyFocused)
.scaleEffect(isCurrentlyFocused ? 1.08 : 1.0)
.scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
.animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
}
#endif
@@ -87,7 +87,7 @@ struct GlassPosterView: View {
}
}
.frame(width: width, height: height)
.scaleEffect(isFocused ? 1.08 : 1.0)
.scaleEffect(isFocused ? 1.05 : 1.0)
.animation(.easeOut(duration: 0.15), value: isFocused)
}