Files
streamyfin/components/series/TVSeriesPage.tsx
Fredrik Burmester c93132177c fix(tv): scale search input font and box with tvTypographyScale setting
The TV search input hardcoded fontSize and box dimensions, so it ignored
the TV display size setting. Drive font, height, padding, and icon from
the scaled `body` typography token so the whole component scales.
2026-05-30 13:29:56 +02:00

636 lines
18 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { LinearGradient } from "expo-linear-gradient";
import { useSegments } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import {
buildOfflineSeasons,
getDownloadedEpisodesForSeason,
} from "@/utils/downloads/offline-series";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HORIZONTAL_PADDING = 80;
const TOP_PADDING = 140;
const POSTER_WIDTH_PERCENT = 0.22;
const SCALE_PADDING = 20;
interface TVSeriesPageProps {
item: BaseItemDto;
allEpisodes?: BaseItemDto[];
isLoading?: boolean;
}
// Focusable button component for TV
const TVFocusableButton: React.FC<{
onPress: () => void;
children: React.ReactNode;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
variant?: "primary" | "secondary";
refSetter?: (ref: View | null) => void;
}> = ({
onPress,
children,
hasTVPreferredFocus,
disabled = false,
variant = "primary",
refSetter,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isPrimary = variant === "primary";
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
backgroundColor: focused
? isPrimary
? "#ffffff"
: "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};
// Season selector button
const TVSeasonButton: React.FC<{
seasonName: string;
onPress: () => void;
disabled?: boolean;
}> = ({ seasonName, onPress, disabled = false }) => {
const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
}}
>
<View
style={{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
}}
>
<Text
style={{
fontSize: typography.callout,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "bold",
}}
>
{seasonName}
</Text>
<Ionicons
name='chevron-down'
size={28}
color={focused ? "#000" : "#FFFFFF"}
/>
</View>
</Animated.View>
</Pressable>
);
};
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
item,
allEpisodes = [],
isLoading: _isLoading,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const isOffline = useOfflineMode();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload();
const { showSeasonModal } = useTVSeriesSeasonModal();
const { showItemActions } = useTVItemActionModal();
const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
const isSeasonModalVisible = seasonModalState !== null;
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item.Id);
// Season state
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const selectedSeasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""] ?? 1,
[item.Id, seasonIndexState],
);
// Focus guide refs (using useState to trigger re-renders when refs are set)
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
const [firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// ScrollView ref for page scrolling
const mainScrollRef = useRef<ScrollView>(null);
// ScrollView ref for scrolling back
const episodeListRef = useRef<ScrollView>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
// Track focus count for episode list
useEffect(() => {
prevFocusedCount.current = focusedCount;
}, [focusedCount]);
const handleEpisodeFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const handleEpisodeBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Fetch seasons
const { data: seasons = [] } = useQuery({
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
}
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`,
{
params: {
userId: user.Id,
itemId: item.Id,
Fields: "ItemCounts,PrimaryImageAspectRatio",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
return response.data.Items || [];
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
// Get selected season ID
const selectedSeasonId = useMemo(() => {
const season = seasons.find(
(s: BaseItemDto) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return season?.Id ?? null;
}, [seasons, selectedSeasonIndex]);
// Get selected season number for offline mode
const selectedSeasonNumber = useMemo(() => {
if (!isOffline) return null;
const season = seasons.find(
(s: BaseItemDto) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return season?.IndexNumber ?? null;
}, [isOffline, seasons, selectedSeasonIndex]);
// Fetch episodes for selected season
const { data: episodesForSeason = [] } = useQuery({
queryKey: [
"episodes",
item.Id,
isOffline ? selectedSeasonNumber : selectedSeasonId,
isOffline,
downloadedItems.length,
],
queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeason(
getDownloadedItems(),
item.Id!,
selectedSeasonNumber!,
);
}
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
return res.data.Items || [];
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline
? !!item.Id && selectedSeasonNumber !== null
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Find next unwatched episode
const nextUnwatchedEpisode = useMemo(() => {
// First check all episodes for a "next up" candidate
for (const ep of allEpisodes) {
if (!ep.UserData?.Played) {
// Check if it has progress (continue watching)
if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) {
return ep;
}
}
}
// Find first unwatched
return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0];
}, [allEpisodes]);
// Get season name for button
const selectedSeasonName = useMemo(() => {
const season = seasons.find(
(s: BaseItemDto) =>
s.IndexNumber === selectedSeasonIndex ||
s.Name === String(selectedSeasonIndex),
);
return season?.Name || `Season ${selectedSeasonIndex}`;
}, [seasons, selectedSeasonIndex]);
// Handle episode press
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, from);
router.push(navigation as any);
},
[from, router],
);
// Handle play next episode
const handlePlayNextEpisode = useCallback(() => {
if (nextUnwatchedEpisode) {
const navigation = getItemNavigation(nextUnwatchedEpisode, from);
router.push(navigation as any);
}
}, [nextUnwatchedEpisode, from, router]);
// Handle season selection
const handleSeasonSelect = useCallback(
(seasonIdx: number) => {
if (!item.Id) return;
setSeasonIndexState((prev) => ({
...prev,
[item.Id!]: seasonIdx,
}));
},
[item.Id, setSeasonIndexState],
);
// Season options for the modal
const seasonOptions = useMemo(() => {
return seasons.map((season: BaseItemDto) => ({
label: season.Name || `Season ${season.IndexNumber}`,
value: season.IndexNumber ?? 0,
selected:
season.IndexNumber === selectedSeasonIndex ||
season.Name === String(selectedSeasonIndex),
}));
}, [seasons, selectedSeasonIndex]);
// Open season modal
const handleOpenSeasonModal = useCallback(() => {
if (!item.Id) return;
showSeasonModal({
seasons: seasonOptions,
selectedSeasonIndex,
itemId: item.Id,
onSeasonSelect: handleSeasonSelect,
});
}, [
item.Id,
seasonOptions,
selectedSeasonIndex,
handleSeasonSelect,
showSeasonModal,
]);
// Get play button text
const playButtonText = useMemo(() => {
if (!nextUnwatchedEpisode) return t("common.play");
const season = nextUnwatchedEpisode.ParentIndexNumber;
const episode = nextUnwatchedEpisode.IndexNumber;
const hasProgress =
(nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0;
if (hasProgress) {
return `${t("home.continue")} S${season}:E${episode}`;
}
return `${t("common.play")} S${season}:E${episode}`;
}, [nextUnwatchedEpisode, t]);
if (!item) return null;
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Full-screen backdrop */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<ItemImage
variant='Backdrop'
item={item}
style={{ width: "100%", height: "100%" }}
/>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
{/* Main content */}
<ScrollView
ref={mainScrollRef}
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: insets.bottom + 60,
}}
showsVerticalScrollIndicator={false}
>
{/* Top section - Content + Poster */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
<TVSeriesHeader item={item} />
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginTop: 32,
}}
>
<TVFocusableButton
onPress={handlePlayNextEpisode}
hasTVPreferredFocus={!isSeasonModalVisible}
disabled={isSeasonModalVisible}
variant='primary'
refSetter={setPlayButtonRef}
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
>
{playButtonText}
</Text>
</TVFocusableButton>
{seasons.length > 1 && (
<TVSeasonButton
seasonName={selectedSeasonName}
onPress={handleOpenSeasonModal}
disabled={isSeasonModalVisible}
/>
)}
<TVFavoriteButton item={item} disabled={isSeasonModalVisible} />
</View>
</View>
{/* Right side - Poster */}
<View
style={{
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
<ItemImage
variant='Primary'
item={item}
style={{ width: "100%", height: "100%" }}
/>
</View>
</View>
</View>
{/* Episodes section */}
<View style={{ marginTop: 40, overflow: "visible" }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
marginLeft: SCALE_PADDING,
}}
>
{selectedSeasonName}
</Text>
{/* Bidirectional focus guides - stacked together above the list */}
{/* Downward: Play button → first episode */}
{firstEpisodeRef && (
<TVFocusGuideView
destinations={[firstEpisodeRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Upward: episodes → Play button */}
{playButtonRef && (
<TVFocusGuideView
destinations={[playButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<TVEpisodeList
episodes={episodesForSeason}
disabled={isSeasonModalVisible}
onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
scrollViewRef={episodeListRef}
firstEpisodeRefSetter={setFirstEpisodeRef}
emptyText={t("item_card.no_episodes_for_this_season")}
horizontalPadding={HORIZONTAL_PADDING}
/>
</View>
</ScrollView>
</View>
);
};