mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-28 22:18:14 +00:00
feat(tv): add scalable poster sizes synchronized with typography settings
This commit is contained in:
@@ -142,22 +142,22 @@ export default function SettingsTV() {
|
||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_small"),
|
||||
label: t("home.settings.appearance.display_size_small"),
|
||||
value: TVTypographyScale.Small,
|
||||
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_default"),
|
||||
label: t("home.settings.appearance.display_size_default"),
|
||||
value: TVTypographyScale.Default,
|
||||
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_large"),
|
||||
label: t("home.settings.appearance.display_size_large"),
|
||||
value: TVTypographyScale.Large,
|
||||
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_extra_large"),
|
||||
label: t("home.settings.appearance.display_size_extra_large"),
|
||||
value: TVTypographyScale.ExtraLarge,
|
||||
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||
},
|
||||
@@ -188,7 +188,7 @@ export default function SettingsTV() {
|
||||
|
||||
const typographyScaleLabel = useMemo(() => {
|
||||
const option = typographyScaleOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.appearance.text_size_default");
|
||||
return option?.label || t("home.settings.appearance.display_size_default");
|
||||
}, [typographyScaleOptions, t]);
|
||||
|
||||
return (
|
||||
@@ -385,11 +385,11 @@ export default function SettingsTV() {
|
||||
{/* Appearance Section */}
|
||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.appearance.text_size")}
|
||||
label={t("home.settings.appearance.display_size")}
|
||||
value={typographyScaleLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.appearance.text_size"),
|
||||
title: t("home.settings.appearance.display_size"),
|
||||
options: typographyScaleOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({ tvTypographyScale: value }),
|
||||
|
||||
@@ -27,15 +27,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import MoviePoster from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import {
|
||||
TVFilterButton,
|
||||
TVFocusablePoster,
|
||||
TVItemCardText,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
@@ -60,6 +59,7 @@ const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
@@ -153,7 +153,7 @@ const page: React.FC = () => {
|
||||
// Calculate columns for TV grid
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||
@@ -291,7 +291,7 @@ const page: React.FC = () => {
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster onPress={handlePress}>
|
||||
|
||||
@@ -33,15 +33,14 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import MoviePoster from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import {
|
||||
TVFilterButton,
|
||||
TVFocusablePoster,
|
||||
TVItemCardText,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
@@ -85,6 +84,7 @@ const Page = () => {
|
||||
const { libraryId } = searchParams;
|
||||
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
@@ -409,7 +409,7 @@ const Page = () => {
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster onPress={handlePress}>
|
||||
|
||||
@@ -24,11 +24,10 @@ import {
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
@@ -73,6 +72,7 @@ const TVItemCardText: React.FC<{
|
||||
|
||||
export default function WatchlistDetailScreen() {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
@@ -206,7 +206,7 @@ export default function WatchlistDetailScreen() {
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -13,8 +14,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
export const TV_LANDSCAPE_WIDTH = 400;
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
useEpisodePoster?: boolean;
|
||||
@@ -30,6 +29,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
showPlayButton = false,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (!api) {
|
||||
@@ -91,7 +91,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: TV_LANDSCAPE_WIDTH,
|
||||
width: posterSizes.landscape,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
@@ -109,8 +109,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
progress={progress}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={false}
|
||||
width={TV_LANDSCAPE_WIDTH}
|
||||
style={{ width: TV_LANDSCAPE_WIDTH }}
|
||||
width={posterSizes.landscape}
|
||||
style={{ width: posterSizes.landscape }}
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View
|
||||
@@ -136,7 +136,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
width: TV_LANDSCAPE_WIDTH,
|
||||
width: posterSizes.landscape,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -16,16 +16,13 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import MoviePoster from "@/components/posters/MoviePoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import ContinueWatchingPoster, {
|
||||
TV_LANDSCAPE_WIDTH,
|
||||
} from "../ContinueWatchingPoster.tv";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster.tv";
|
||||
import SeriesPoster from "../posters/SeriesPoster.tv";
|
||||
|
||||
const ITEM_GAP = 24;
|
||||
@@ -101,6 +98,8 @@ const TVItemCardText: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
|
||||
|
||||
// TV-specific "See All" card for end of lists
|
||||
const TVSeeAllCard: React.FC<{
|
||||
onPress: () => void;
|
||||
@@ -109,10 +108,19 @@ const TVSeeAllCard: React.FC<{
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
typography: Typography;
|
||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
|
||||
posterSizes: PosterSizes;
|
||||
}> = ({
|
||||
onPress,
|
||||
orientation,
|
||||
disabled,
|
||||
onFocus,
|
||||
onBlur,
|
||||
typography,
|
||||
posterSizes,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const width =
|
||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
|
||||
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
||||
|
||||
return (
|
||||
@@ -172,6 +180,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const effectivePageSize = Math.max(1, pageSize);
|
||||
const hasCalledOnLoaded = useRef(false);
|
||||
const router = useRouter();
|
||||
@@ -250,7 +259,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
}, [data]);
|
||||
|
||||
const itemWidth =
|
||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
|
||||
|
||||
const handleItemPress = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
@@ -487,6 +496,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
onFocus={handleSeeAllFocus}
|
||||
onBlur={handleItemBlur}
|
||||
typography={typography}
|
||||
posterSizes={posterSizes}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -11,11 +11,10 @@ import { FlatList, View, type ViewProps } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -66,6 +65,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
@@ -129,8 +129,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||
length: posterSizes.poster + ITEM_GAP,
|
||||
offset: (posterSizes.poster + ITEM_GAP) * index,
|
||||
index,
|
||||
}),
|
||||
[],
|
||||
@@ -139,7 +139,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
return (
|
||||
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
|
||||
<TVFocusablePoster
|
||||
onPress={() => handleItemPress(item)}
|
||||
onFocus={() => onItemFocus?.(item)}
|
||||
@@ -182,11 +182,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||
<View key={i} style={{ width: posterSizes.poster }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#262626",
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
@@ -226,6 +226,7 @@ interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||
export const StreamystatsPromotedWatchlists: React.FC<
|
||||
StreamystatsPromotedWatchlistsProps
|
||||
> = ({ enabled = true, onItemFocus, ...props }) => {
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
@@ -316,11 +317,11 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||
<View key={i} style={{ width: posterSizes.poster }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#262626",
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
|
||||
@@ -11,11 +11,10 @@ import { FlatList, View, type ViewProps } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -70,6 +69,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
@@ -190,8 +190,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||
length: posterSizes.poster + ITEM_GAP,
|
||||
offset: (posterSizes.poster + ITEM_GAP) * index,
|
||||
index,
|
||||
}),
|
||||
[],
|
||||
@@ -200,7 +200,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
return (
|
||||
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
|
||||
<TVFocusablePoster
|
||||
onPress={() => handleItemPress(item)}
|
||||
onFocus={() => onItemFocus?.(item)}
|
||||
@@ -245,11 +245,11 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||
<View key={i} style={{ width: posterSizes.poster }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#262626",
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
|
||||
@@ -23,6 +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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
@@ -36,7 +37,6 @@ import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
|
||||
const CARD_WIDTH = 280;
|
||||
const CARD_GAP = 24;
|
||||
const CARD_PADDING = 60;
|
||||
|
||||
@@ -48,12 +48,13 @@ interface TVHeroCarouselProps {
|
||||
interface HeroCardProps {
|
||||
item: BaseItemDto;
|
||||
isFirst: boolean;
|
||||
cardWidth: number;
|
||||
onFocus: (item: BaseItemDto) => void;
|
||||
onPress: (item: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
({ item, isFirst, onFocus, onPress }) => {
|
||||
({ item, isFirst, cardWidth, onFocus, onPress }) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
@@ -129,8 +130,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
progress={progress}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={focused}
|
||||
width={CARD_WIDTH}
|
||||
style={{ width: CARD_WIDTH }}
|
||||
width={cardWidth}
|
||||
style={{ width: cardWidth }}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -147,7 +148,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: CARD_WIDTH,
|
||||
width: cardWidth,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
@@ -196,6 +197,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
onItemFocus,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@@ -354,11 +356,12 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
<HeroCard
|
||||
item={item}
|
||||
isFirst={index === 0}
|
||||
cardWidth={posterSizes.heroCard}
|
||||
onFocus={handleCardFocus}
|
||||
onPress={handleCardPress}
|
||||
/>
|
||||
),
|
||||
[handleCardFocus, handleCardPress],
|
||||
[handleCardFocus, handleCardPress, posterSizes.heroCard],
|
||||
);
|
||||
|
||||
// Memoize keyExtractor
|
||||
|
||||
@@ -28,9 +28,8 @@ import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import MoviePoster from "@/components/posters/MoviePoster.tv";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
@@ -103,6 +102,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -276,8 +276,8 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
// List item layout
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||
length: posterSizes.poster + ITEM_GAP,
|
||||
offset: (posterSizes.poster + ITEM_GAP) * index,
|
||||
index,
|
||||
}),
|
||||
[],
|
||||
@@ -297,7 +297,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={filmItem} />
|
||||
<View style={{ width: TV_POSTER_WIDTH, marginTop: 8 }}>
|
||||
<View style={{ width: posterSizes.poster, marginTop: 8 }}>
|
||||
<ItemCardText item={filmItem} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
export const TV_POSTER_WIDTH = 260;
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
@@ -23,14 +22,15 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
showProgress = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
|
||||
const url = useMemo(() => {
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 520, // 2x for quality on large screens
|
||||
width: posterSizes.poster * 2, // 2x for quality on large screens
|
||||
});
|
||||
}, [api, item]);
|
||||
}, [api, item, posterSizes.poster]);
|
||||
|
||||
const progress = item.UserData?.PlayedPercentage || 0;
|
||||
const isWatched = item.UserData?.Played === true;
|
||||
@@ -52,8 +52,8 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
progress={showProgress ? progress : 0}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={false}
|
||||
width={TV_POSTER_WIDTH}
|
||||
style={{ width: TV_POSTER_WIDTH }}
|
||||
width={posterSizes.poster}
|
||||
style={{ width: posterSizes.poster }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
position: "relative",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
aspectRatio: 10 / 15,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -10,8 +11,6 @@ import {
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
export const TV_POSTER_WIDTH = 260;
|
||||
|
||||
type SeriesPosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
@@ -19,17 +18,18 @@ type SeriesPosterProps = {
|
||||
|
||||
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=780&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${posterSizes.poster * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||
}
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 520, // 2x for quality on large screens
|
||||
width: posterSizes.poster * 2, // 2x for quality on large screens
|
||||
});
|
||||
}, [api, item]);
|
||||
}, [api, item, posterSizes.poster]);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
@@ -48,8 +48,8 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||
progress={0}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={false}
|
||||
width={TV_POSTER_WIDTH}
|
||||
style={{ width: TV_POSTER_WIDTH }}
|
||||
width={posterSizes.poster}
|
||||
style={{ width: posterSizes.poster }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
aspectRatio: 10 / 15,
|
||||
position: "relative",
|
||||
borderRadius: 24,
|
||||
|
||||
@@ -2,15 +2,12 @@ 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, {
|
||||
TV_LANDSCAPE_WIDTH,
|
||||
} from "@/components/ContinueWatchingPoster.tv";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster.tv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
const ITEM_GAP = 16;
|
||||
@@ -160,6 +157,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||
const [focusedCount, setFocusedCount] = useState(0);
|
||||
const prevFocusedCount = useRef(0);
|
||||
@@ -181,7 +179,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
}, []);
|
||||
|
||||
const itemWidth =
|
||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||
orientation === "horizontal" ? posterSizes.landscape : posterSizes.poster;
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
@@ -249,8 +247,8 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: TV_POSTER_WIDTH,
|
||||
height: TV_POSTER_WIDTH,
|
||||
width: posterSizes.poster,
|
||||
height: posterSizes.poster,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
|
||||
@@ -7,12 +7,11 @@ 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 { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
export const TV_EPISODE_WIDTH = 340;
|
||||
|
||||
interface TVEpisodeCardProps {
|
||||
episode: BaseItemDto;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
@@ -34,6 +33,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
refSetter,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const thumbnailUrl = useMemo(() => {
|
||||
@@ -68,7 +68,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
||||
|
||||
return (
|
||||
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}>
|
||||
<View style={{ width: posterSizes.episode, opacity: disabled ? 0.5 : 1 }}>
|
||||
<TVFocusablePoster
|
||||
onPress={onPress}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
@@ -79,7 +79,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: TV_EPISODE_WIDTH,
|
||||
width: posterSizes.episode,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface TVFocusablePosterProps {
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
/** When true, the item remains focusable even when disabled (for navigation purposes) */
|
||||
focusableWhenDisabled?: boolean;
|
||||
/** Setter function for the ref (for focus guide destinations) */
|
||||
refSetter?: (ref: View | null) => void;
|
||||
}
|
||||
@@ -31,6 +33,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
|
||||
onFocus: onFocusProp,
|
||||
onBlur: onBlurProp,
|
||||
disabled = false,
|
||||
focusableWhenDisabled = false,
|
||||
refSetter,
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
@@ -62,7 +65,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
focusable={!disabled || focusableWhenDisabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
|
||||
57
constants/TVPosterSizes.ts
Normal file
57
constants/TVPosterSizes.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
export const TVPosterSizes = {
|
||||
/** Portrait posters (movies, series) - 10:15 aspect ratio */
|
||||
poster: 260,
|
||||
|
||||
/** Landscape posters (continue watching, thumbs) - 16:9 aspect ratio */
|
||||
landscape: 400,
|
||||
|
||||
/** Episode cards - 16:9 aspect ratio */
|
||||
episode: 340,
|
||||
|
||||
/** Hero carousel cards - 16:9 aspect ratio */
|
||||
heroCard: 280,
|
||||
} as const;
|
||||
|
||||
export type TVPosterSizeKey = keyof typeof TVPosterSizes;
|
||||
|
||||
/**
|
||||
* Poster scale multipliers - much smaller range than typography.
|
||||
* Posters are already near-perfect size, only need slight increases at larger settings.
|
||||
*/
|
||||
const posterScaleMultipliers: Record<TVTypographyScale, number> = {
|
||||
[TVTypographyScale.Small]: 0.95,
|
||||
[TVTypographyScale.Default]: 1.0,
|
||||
[TVTypographyScale.Large]: 1.05,
|
||||
[TVTypographyScale.ExtraLarge]: 1.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 scale =
|
||||
posterScaleMultipliers[settings.tvTypographyScale] ??
|
||||
posterScaleMultipliers[TVTypographyScale.Default];
|
||||
|
||||
return {
|
||||
poster: Math.round(TVPosterSizes.poster * scale),
|
||||
landscape: Math.round(TVPosterSizes.landscape * scale),
|
||||
episode: Math.round(TVPosterSizes.episode * scale),
|
||||
heroCard: Math.round(TVPosterSizes.heroCard * scale),
|
||||
};
|
||||
};
|
||||
@@ -128,11 +128,11 @@
|
||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||
"show_hero_carousel": "Hero Carousel",
|
||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||
"text_size": "Text Size",
|
||||
"text_size_small": "Small",
|
||||
"text_size_default": "Default",
|
||||
"text_size_large": "Large",
|
||||
"text_size_extra_large": "Extra Large"
|
||||
"display_size": "Display Size",
|
||||
"display_size_small": "Small",
|
||||
"display_size_default": "Default",
|
||||
"display_size_large": "Large",
|
||||
"display_size_extra_large": "Extra Large"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
|
||||
@@ -126,11 +126,11 @@
|
||||
"show_home_backdrop": "Dynamisk hembakgrund",
|
||||
"show_hero_carousel": "Hjältekarusell",
|
||||
"show_series_poster_on_episode": "Visa serieaffisch på avsnitt",
|
||||
"text_size": "Textstorlek",
|
||||
"text_size_small": "Liten",
|
||||
"text_size_default": "Standard",
|
||||
"text_size_large": "Stor",
|
||||
"text_size_extra_large": "Extra stor"
|
||||
"display_size": "Visningsstorlek",
|
||||
"display_size_small": "Liten",
|
||||
"display_size_default": "Standard",
|
||||
"display_size_large": "Stor",
|
||||
"display_size_extra_large": "Extra stor"
|
||||
},
|
||||
"network": {
|
||||
"title": "Nätverk",
|
||||
|
||||
Reference in New Issue
Block a user