diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
index 9c2d3f48..32f5d1cd 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
@@ -14,6 +14,7 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
+import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
@@ -159,6 +160,19 @@ const page: React.FC = () => {
// For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;
+ // TV version
+ if (Platform.isTV) {
+ return (
+
+
+
+ );
+ }
+
return (
{
setFocused(true);
- animateTo(1.04);
+ animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
@@ -501,6 +501,10 @@ const TVOptionButton: React.FC<{
= React.memo(
- {/* Playback options row */}
+ {/* Playback options */}
diff --git a/components/login/TVLogin.tsx b/components/login/TVLogin.tsx
index 52fed37b..571478d3 100644
--- a/components/login/TVLogin.tsx
+++ b/components/login/TVLogin.tsx
@@ -4,9 +4,11 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import {
Alert,
+ Animated,
+ Easing,
KeyboardAvoidingView,
Pressable,
ScrollView,
@@ -40,6 +42,69 @@ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
+const TVBackButton: React.FC<{ onPress: () => void; label: string }> = ({
+ onPress,
+ label,
+}) => {
+ const [isFocused, setIsFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateFocus = (focused: boolean) => {
+ Animated.timing(scale, {
+ toValue: focused ? 1.05 : 1,
+ duration: 150,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+ };
+
+ return (
+ {
+ setIsFocused(true);
+ animateFocus(true);
+ }}
+ onBlur={() => {
+ setIsFocused(false);
+ animateFocus(false);
+ }}
+ style={{ alignSelf: "flex-start", marginBottom: 40 }}
+ >
+
+
+
+ {label}
+
+
+
+ );
+};
+
export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
@@ -402,25 +467,10 @@ export const TVLogin: React.FC = () => {
}}
>
{/* Back Button */}
- removeServer()}
- style={{
- flexDirection: "row",
- alignItems: "center",
- marginBottom: 40,
- }}
- >
-
-
- {t("login.change_server")}
-
-
+ label={t("login.change_server")}
+ />
{/* Title */}
= ({
>
{label}
-
-
+
diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx
new file mode 100644
index 00000000..e6fcc833
--- /dev/null
+++ b/components/series/TVEpisodeCard.tsx
@@ -0,0 +1,141 @@
+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 { apiAtom } from "@/providers/JellyfinProvider";
+import { runtimeTicksToMinutes } from "@/utils/time";
+
+export const TV_EPISODE_WIDTH = 340;
+
+interface TVEpisodeCardProps {
+ episode: BaseItemDto;
+ hasTVPreferredFocus?: boolean;
+ disabled?: boolean;
+ onPress: () => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+}
+
+export const TVEpisodeCard: React.FC = ({
+ episode,
+ hasTVPreferredFocus = false,
+ disabled = false,
+ onPress,
+ onFocus,
+ onBlur,
+}) => {
+ 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]);
+
+ return (
+
+
+
+ {thumbnailUrl ? (
+
+ ) : (
+
+ )}
+ {!episode.UserData?.Played && }
+
+
+
+
+ {/* Episode info below thumbnail */}
+
+
+ {episodeLabel && (
+
+ {episodeLabel}
+
+ )}
+ {duration && (
+ <>
+ •
+ {duration}
+ >
+ )}
+
+
+ {episode.Name}
+
+
+
+ );
+};
diff --git a/components/series/TVSeasonSelector.tsx b/components/series/TVSeasonSelector.tsx
new file mode 100644
index 00000000..947bc918
--- /dev/null
+++ b/components/series/TVSeasonSelector.tsx
@@ -0,0 +1,195 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { BlurView } from "expo-blur";
+import React, { useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
+import { Text } from "@/components/common/Text";
+
+interface TVSeasonSelectorProps {
+ visible: boolean;
+ seasons: BaseItemDto[];
+ selectedSeasonIndex: number | string | null | undefined;
+ onSelect: (seasonIndex: number) => void;
+ onClose: () => void;
+}
+
+const TVSeasonCard: React.FC<{
+ season: BaseItemDto;
+ isSelected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}> = ({ season, isSelected, hasTVPreferredFocus, onPress }) => {
+ 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 seasonName = useMemo(() => {
+ if (season.Name) return season.Name;
+ if (season.IndexNumber !== undefined) return `Season ${season.IndexNumber}`;
+ return "Season";
+ }, [season]);
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus}
+ >
+
+
+ {seasonName}
+
+ {isSelected && !focused && (
+
+
+
+ )}
+
+
+ );
+};
+
+export const TVSeasonSelector: React.FC = ({
+ visible,
+ seasons,
+ selectedSeasonIndex,
+ onSelect,
+ onClose,
+}) => {
+ const { t } = useTranslation();
+
+ const initialFocusIndex = useMemo(() => {
+ const idx = seasons.findIndex(
+ (s) =>
+ s.IndexNumber === selectedSeasonIndex ||
+ s.Name === String(selectedSeasonIndex),
+ );
+ return idx >= 0 ? idx : 0;
+ }, [seasons, selectedSeasonIndex]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
+ {/* Title */}
+
+ {t("item_card.select_season")}
+
+
+ {/* Horizontal season cards */}
+
+ {seasons.map((season, index) => (
+ {
+ onSelect(season.IndexNumber ?? index);
+ onClose();
+ }}
+ />
+ ))}
+
+
+
+
+ );
+};
diff --git a/components/series/TVSeriesHeader.tsx b/components/series/TVSeriesHeader.tsx
new file mode 100644
index 00000000..fa7f1b9b
--- /dev/null
+++ b/components/series/TVSeriesHeader.tsx
@@ -0,0 +1,118 @@
+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 { Dimensions, View } from "react-native";
+import { Badge } from "@/components/Badge";
+import { Text } from "@/components/common/Text";
+import { GenreTags } from "@/components/GenreTags";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+
+interface TVSeriesHeaderProps {
+ item: BaseItemDto;
+}
+
+export const TVSeriesHeader: React.FC = ({ item }) => {
+ const api = useAtomValue(apiAtom);
+
+ const logoUrl = useMemo(() => {
+ if (!api || !item) return null;
+ return getLogoImageUrlById({ api, item });
+ }, [api, item]);
+
+ const yearString = useMemo(() => {
+ const startYear = item.StartDate
+ ? new Date(item.StartDate).getFullYear()
+ : item.ProductionYear;
+
+ const endYear = item.EndDate ? new Date(item.EndDate).getFullYear() : null;
+
+ if (startYear && endYear) {
+ if (startYear === endYear) return String(startYear);
+ return `${startYear} - ${endYear}`;
+ }
+ if (startYear) return String(startYear);
+ return null;
+ }, [item.StartDate, item.EndDate, item.ProductionYear]);
+
+ return (
+
+ {/* Logo or Title */}
+ {logoUrl ? (
+
+ ) : (
+
+ {item.Name}
+
+ )}
+
+ {/* Metadata badges row */}
+
+ {yearString && (
+ {yearString}
+ )}
+ {item.OfficialRating && (
+
+ )}
+ {item.CommunityRating != null && (
+ }
+ />
+ )}
+
+
+ {/* Genres */}
+ {item.Genres && item.Genres.length > 0 && (
+
+
+
+ )}
+
+ {/* Overview */}
+ {item.Overview && (
+
+ {item.Overview}
+
+ )}
+
+ );
+};
diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx
new file mode 100644
index 00000000..bd8047a7
--- /dev/null
+++ b/components/series/TVSeriesPage.tsx
@@ -0,0 +1,635 @@
+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 } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useTranslation } from "react-i18next";
+import {
+ Animated,
+ Dimensions,
+ Easing,
+ FlatList,
+ Pressable,
+ ScrollView,
+ 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 {
+ TV_EPISODE_WIDTH,
+ TVEpisodeCard,
+} from "@/components/series/TVEpisodeCard";
+import { TVSeasonSelector } from "@/components/series/TVSeasonSelector";
+import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
+import useRouter from "@/hooks/useAppRouter";
+import { useDownload } from "@/providers/DownloadProvider";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useOfflineMode } from "@/providers/OfflineModeProvider";
+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 ITEM_GAP = 16;
+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";
+}> = ({
+ onPress,
+ children,
+ hasTVPreferredFocus,
+ disabled = false,
+ variant = "primary",
+}) => {
+ 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 (
+ {
+ setFocused(true);
+ animateTo(1.05);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+ {children}
+
+
+
+ );
+};
+
+// Season selector button
+const TVSeasonButton: React.FC<{
+ seasonName: string;
+ onPress: () => void;
+ disabled?: boolean;
+}> = ({ seasonName, onPress, disabled = false }) => {
+ const [focused, setFocused] = useState(false);
+ const scale = useRef(new Animated.Value(1)).current;
+
+ const animateTo = (v: number) =>
+ Animated.timing(scale, {
+ toValue: v,
+ duration: 120,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }).start();
+
+ return (
+ {
+ setFocused(true);
+ animateTo(1.02);
+ }}
+ onBlur={() => {
+ setFocused(false);
+ animateTo(1);
+ }}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+
+ {seasonName}
+
+
+
+
+
+ );
+};
+
+export const TVSeriesPage: React.FC = ({
+ item,
+ allEpisodes = [],
+ isLoading: _isLoading,
+}) => {
+ 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();
+
+ // Season state
+ const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
+ const selectedSeasonIndex = useMemo(
+ () => seasonIndexState[item.Id ?? ""] ?? 1,
+ [item.Id, seasonIndexState],
+ );
+
+ // Modal state
+ const [openModal, setOpenModal] = useState<"season" | null>(null);
+ const isModalOpen = openModal !== null;
+
+ // ScrollView ref for page scrolling
+ const mainScrollRef = useRef(null);
+ // FlatList ref for scrolling back
+ const episodeListRef = useRef>(null);
+ const [focusedCount, setFocusedCount] = useState(0);
+ const prevFocusedCount = useRef(0);
+
+ // Scroll back to start when episode list loses focus
+ useEffect(() => {
+ if (prevFocusedCount.current > 0 && focusedCount === 0) {
+ episodeListRef.current?.scrollToOffset({ offset: 0, animated: true });
+ // Scroll page back to top when leaving episode section
+ mainScrollRef.current?.scrollTo({ y: 0, animated: true });
+ }
+ prevFocusedCount.current = focusedCount;
+ }, [focusedCount]);
+
+ const handleEpisodeFocus = useCallback(() => {
+ setFocusedCount((c) => {
+ // Scroll page down when first episode receives focus
+ if (c === 0) {
+ mainScrollRef.current?.scrollTo({ y: 200, animated: true });
+ }
+ return 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,
+ 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 : 0,
+ 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],
+ );
+
+ // Episode list item layout
+ const getItemLayout = useCallback(
+ (_data: ArrayLike | null | undefined, index: number) => ({
+ length: TV_EPISODE_WIDTH + ITEM_GAP,
+ offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index,
+ index,
+ }),
+ [],
+ );
+
+ // Render episode card
+ const renderEpisode = useCallback(
+ ({ item: episode }: { item: BaseItemDto; index: number }) => (
+
+ handleEpisodePress(episode)}
+ disabled={isModalOpen}
+ onFocus={handleEpisodeFocus}
+ onBlur={handleEpisodeBlur}
+ />
+
+ ),
+ [handleEpisodePress, isModalOpen, handleEpisodeFocus, handleEpisodeBlur],
+ );
+
+ // 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 (
+
+ {/* Full-screen backdrop */}
+
+
+ {/* Gradient overlays for readability */}
+
+
+
+
+ {/* Main content */}
+
+ {/* Top section - Poster + Content */}
+
+ {/* Left side - Poster */}
+
+
+
+
+
+
+ {/* Right side - Content */}
+
+
+
+ {/* Action buttons */}
+
+
+
+
+ {playButtonText}
+
+
+
+ {seasons.length > 1 && (
+ setOpenModal("season")}
+ disabled={isModalOpen}
+ />
+ )}
+
+
+
+
+ {/* Episodes section */}
+
+
+ {selectedSeasonName}
+
+
+ ep.Id!}
+ renderItem={renderEpisode}
+ showsHorizontalScrollIndicator={false}
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ removeClippedSubviews={false}
+ getItemLayout={getItemLayout}
+ style={{ overflow: "visible" }}
+ contentContainerStyle={{
+ paddingVertical: SCALE_PADDING,
+ paddingHorizontal: SCALE_PADDING,
+ }}
+ ListEmptyComponent={
+
+ {t("item_card.no_episodes_for_this_season")}
+
+ }
+ />
+
+
+
+ {/* Season selector modal */}
+ setOpenModal(null)}
+ />
+
+ );
+};
diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx
index 56c1de80..ada3f875 100644
--- a/components/video-player/controls/Controls.tv.tsx
+++ b/components/video-player/controls/Controls.tv.tsx
@@ -1,9 +1,12 @@
import { Ionicons } from "@expo/vector-icons";
+import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
+import { useLocalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
import {
type FC,
useCallback,
@@ -14,6 +17,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
+ Image,
Pressable,
Animated as RNAnimated,
Easing as RNEasing,
@@ -23,7 +27,9 @@ import {
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
+ cancelAnimation,
Easing,
+ runOnJS,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
@@ -32,7 +38,13 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
+import useRouter from "@/hooks/useAppRouter";
+import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useRemoteControl } from "./hooks/useRemoteControl";
@@ -480,6 +492,159 @@ const selectorStyles = StyleSheet.create({
},
});
+// TV Next Episode Countdown component - horizontal layout with animated progress bar
+const TVNextEpisodeCountdown: FC<{
+ nextItem: BaseItemDto;
+ api: Api | null;
+ show: boolean;
+ isPlaying: boolean;
+ onFinish: () => void;
+}> = ({ nextItem, api, show, isPlaying, onFinish }) => {
+ const { t } = useTranslation();
+ const progress = useSharedValue(0);
+ const onFinishRef = useRef(onFinish);
+
+ // Keep onFinish ref updated
+ onFinishRef.current = onFinish;
+
+ // Get episode thumbnail
+ const imageUrl = getPrimaryImageUrl({
+ api,
+ item: nextItem,
+ width: 360, // 2x for retina
+ quality: 80,
+ });
+
+ // Handle animation based on show and isPlaying state
+ useEffect(() => {
+ if (show && isPlaying) {
+ // Start/restart animation from beginning
+ progress.value = 0;
+ progress.value = withTiming(
+ 1,
+ {
+ duration: 8000, // 8 seconds (ends 2 seconds before episode end)
+ easing: Easing.linear,
+ },
+ (finished) => {
+ if (finished && onFinishRef.current) {
+ runOnJS(onFinishRef.current)();
+ }
+ },
+ );
+ } else {
+ // Pause: cancel animation and reset progress
+ cancelAnimation(progress);
+ progress.value = 0;
+ }
+ }, [show, isPlaying, progress]);
+
+ // Animated style for progress bar
+ const progressStyle = useAnimatedStyle(() => ({
+ width: `${progress.value * 100}%`,
+ }));
+
+ if (!show) return null;
+
+ return (
+
+
+
+ {/* Episode Thumbnail - left side */}
+ {imageUrl && (
+
+ )}
+
+ {/* Content - right side */}
+
+ {/* Label: "Next Episode" */}
+
+ {t("player.next_episode")}
+
+
+ {/* Series Name */}
+
+ {nextItem.SeriesName}
+
+
+ {/* Episode Info: S#E# - Episode Name */}
+
+ S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
+ {nextItem.Name}
+
+
+ {/* Progress Bar */}
+
+
+
+
+
+
+
+ );
+};
+
+const countdownStyles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ bottom: 140,
+ right: 48,
+ zIndex: 100,
+ },
+ blur: {
+ borderRadius: 16,
+ overflow: "hidden",
+ },
+ innerContainer: {
+ flexDirection: "row",
+ alignItems: "stretch",
+ },
+ thumbnail: {
+ width: 180,
+ backgroundColor: "rgba(0,0,0,0.3)",
+ },
+ content: {
+ padding: 16,
+ justifyContent: "center",
+ width: 280,
+ },
+ label: {
+ fontSize: 13,
+ color: "rgba(255,255,255,0.5)",
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ marginBottom: 4,
+ },
+ seriesName: {
+ fontSize: 16,
+ color: "rgba(255,255,255,0.7)",
+ marginBottom: 2,
+ },
+ episodeInfo: {
+ fontSize: 20,
+ color: "#fff",
+ fontWeight: "600",
+ marginBottom: 12,
+ },
+ progressContainer: {
+ height: 4,
+ backgroundColor: "rgba(255,255,255,0.2)",
+ borderRadius: 2,
+ overflow: "hidden",
+ },
+ progressBar: {
+ height: "100%",
+ backgroundColor: "#fff",
+ borderRadius: 2,
+ },
+});
+
export const Controls: FC = ({
item,
seek,
@@ -500,6 +665,21 @@ export const Controls: FC = ({
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
+ const api = useAtomValue(apiAtom);
+ const { settings } = useSettings();
+ const router = useRouter();
+ const {
+ bitrateValue,
+ subtitleIndex: paramSubtitleIndex,
+ audioIndex: paramAudioIndex,
+ } = useLocalSearchParams<{
+ bitrateValue: string;
+ subtitleIndex: string;
+ audioIndex: string;
+ }>();
+
+ // TV is always online
+ const { nextItem } = usePlaybackManager({ item, isOffline: false });
// Modal state for option selectors
// "settings" shows the settings panel, "audio"/"subtitle" for direct selection
@@ -748,6 +928,66 @@ export const Controls: FC = ({
disabled: false,
});
+ // goToNextItem function for auto-play
+ const goToNextItem = useCallback(
+ ({ isAutoPlay }: { isAutoPlay?: boolean } = {}) => {
+ if (!nextItem || !settings) {
+ return;
+ }
+
+ const previousIndexes = {
+ subtitleIndex: paramSubtitleIndex
+ ? Number.parseInt(paramSubtitleIndex, 10)
+ : undefined,
+ audioIndex: paramAudioIndex
+ ? Number.parseInt(paramAudioIndex, 10)
+ : undefined,
+ };
+
+ const {
+ mediaSource: newMediaSource,
+ audioIndex: defaultAudioIndex,
+ subtitleIndex: defaultSubtitleIndex,
+ } = getDefaultPlaySettings(nextItem, settings, {
+ indexes: previousIndexes,
+ source: mediaSource ?? undefined,
+ });
+
+ const queryParams = new URLSearchParams({
+ itemId: nextItem.Id ?? "",
+ audioIndex: defaultAudioIndex?.toString() ?? "",
+ subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
+ mediaSourceId: newMediaSource?.Id ?? "",
+ bitrateValue: bitrateValue?.toString() ?? "",
+ playbackPosition:
+ nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
+ }).toString();
+
+ router.replace(`player/direct-player?${queryParams}` as any);
+ },
+ [
+ nextItem,
+ settings,
+ paramSubtitleIndex,
+ paramAudioIndex,
+ mediaSource,
+ bitrateValue,
+ router,
+ ],
+ );
+
+ // Should show countdown? (TV always auto-plays next episode, no episode count limit)
+ const shouldShowCountdown = useMemo(() => {
+ if (!nextItem) return false;
+ if (item?.Type !== "Episode") return false;
+ return remainingTime > 0 && remainingTime <= 10000;
+ }, [nextItem, item, remainingTime]);
+
+ // Handler for when countdown animation finishes
+ const handleAutoPlayFinish = useCallback(() => {
+ goToNextItem({ isAutoPlay: true });
+ }, [goToNextItem]);
+
// Check if we have any settings to show
const hasSettings =
audioTracks.length > 0 ||
@@ -756,6 +996,12 @@ export const Controls: FC = ({
return (
+ {/* Dark tint overlay when controls are visible */}
+
+
{/* Center Play Button - shown when paused */}
{!isPlaying && showControls && (
@@ -772,6 +1018,17 @@ export const Controls: FC = ({
)}
+ {/* Next Episode Countdown - always visible when countdown active */}
+ {nextItem && (
+
+ )}
+
{/* Top hint - swipe up for settings */}
{showControls && hasSettings && !isModalOpen && (
= ({
@@ -820,9 +1077,9 @@ export const Controls: FC = ({
{/* Metadata */}
{item?.Type === "Episode" && (
-
- {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
-
+ {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
)}
{item?.Name}
{item?.Type === "Movie" && (
@@ -924,6 +1181,14 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
},
+ darkOverlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
+ },
centerContainer: {
position: "absolute",
top: 0,
@@ -1022,6 +1287,6 @@ const styles = StyleSheet.create({
},
settingsHintText: {
color: "rgba(255,255,255,0.5)",
- fontSize: 14,
+ fontSize: 16,
},
});
diff --git a/translations/en.json b/translations/en.json
index d9c08461..f9df7d5a 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -626,6 +626,7 @@
"series": "Series",
"seasons": "Seasons",
"season": "Season",
+ "select_season": "Select Season",
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",