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}}",