mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-30 15:08:25 +00:00
fix(tv): poster design and other stuff
This commit is contained in:
@@ -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 || "", []);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
221
components/tv/TVHorizontalList.tsx
Normal file
221
components/tv/TVHorizontalList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
175
constants/TVSizes.ts
Normal 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;
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user