wip: controls next up

This commit is contained in:
Fredrik Burmester
2026-01-16 17:16:08 +01:00
parent ff3f88c53b
commit 866aa44277
10 changed files with 1472 additions and 36 deletions

View File

@@ -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 (
<OfflineModeProvider isOffline={isOffline}>
<TVSeriesPage
item={item}
allEpisodes={allEpisodes}
isLoading={isLoading}
/>
</OfflineModeProvider>
);
}
return (
<OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView

View File

@@ -490,7 +490,7 @@ const TVOptionButton: React.FC<{
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.04);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
@@ -501,6 +501,10 @@ const TVOptionButton: React.FC<{
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
}}
>
<View
@@ -1001,12 +1005,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</TVFocusableButton>
</View>
{/* Playback options row */}
{/* Playback options */}
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
flexDirection: "column",
alignItems: "flex-start",
gap: 10,
marginBottom: 24,
}}
>

View File

@@ -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 (
<Pressable
onPress={onPress}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: 40 }}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused
? "rgba(168, 85, 247, 0.2)"
: "transparent",
borderWidth: 2,
borderColor: isFocused ? Colors.primary : "transparent",
}}
>
<Ionicons
name='chevron-back'
size={28}
color={isFocused ? "#FFFFFF" : Colors.primary}
/>
<Text
style={{
color: isFocused ? "#FFFFFF" : Colors.primary,
fontSize: 20,
marginLeft: 4,
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVLogin: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
@@ -402,25 +467,10 @@ export const TVLogin: React.FC = () => {
}}
>
{/* Back Button */}
<Pressable
<TVBackButton
onPress={() => removeServer()}
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 40,
}}
>
<Ionicons
name='chevron-back'
size={28}
color={Colors.primary}
/>
<Text
style={{ color: Colors.primary, fontSize: 20, marginLeft: 4 }}
>
{t("login.change_server")}
</Text>
</Pressable>
label={t("login.change_server")}
/>
{/* Title */}
<Text

View File

@@ -1,5 +1,5 @@
import React, { useRef, useState } from "react";
import { Animated, Easing, Pressable, Switch, View } from "react-native";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -87,12 +87,25 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
>
{label}
</Text>
<View pointerEvents='none'>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
<View
pointerEvents='none'
style={{
width: 60,
height: 34,
borderRadius: 17,
backgroundColor: value ? Colors.primary : "#3f3f46",
justifyContent: "center",
paddingHorizontal: 3,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "white",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
</View>
</View>

View File

@@ -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<TVEpisodeCardProps> = ({
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 (
<View style={{ width: TV_EPISODE_WIDTH }}>
<TVFocusablePoster
onPress={onPress}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
>
<View
style={{
width: TV_EPISODE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: 1,
borderColor: "#262626",
}}
>
{thumbnailUrl ? (
<Image
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
/>
)}
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
<ProgressBar item={episode} />
</View>
</TVFocusablePoster>
{/* Episode info below thumbnail */}
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
{episodeLabel && (
<Text
style={{
fontSize: 14,
color: "#9CA3AF",
fontWeight: "500",
}}
>
{episodeLabel}
</Text>
)}
{duration && (
<>
<Text style={{ color: "#6B7280", fontSize: 14 }}></Text>
<Text style={{ fontSize: 14, color: "#9CA3AF" }}>{duration}</Text>
</>
)}
</View>
<Text
numberOfLines={2}
style={{
fontSize: 16,
color: "#FFFFFF",
marginTop: 4,
fontWeight: "500",
}}
>
{episode.Name}
</Text>
</View>
</View>
);
};

View File

@@ -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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 85,
backgroundColor: focused
? "#fff"
: isSelected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 16,
}}
>
<Text
style={{
fontSize: 18,
color: focused ? "#000" : "#fff",
fontWeight: focused || isSelected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{seasonName}
</Text>
{isSelected && !focused && (
<View
style={{
position: "absolute",
top: 10,
right: 10,
}}
>
<Ionicons
name='checkmark'
size={18}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
export const TVSeasonSelector: React.FC<TVSeasonSelectorProps> = ({
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 (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{t("item_card.select_season")}
</Text>
{/* Horizontal season cards */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{seasons.map((season, index) => (
<TVSeasonCard
key={season.Id || index}
season={season}
isSelected={
season.IndexNumber === selectedSeasonIndex ||
season.Name === String(selectedSeasonIndex)
}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(season.IndexNumber ?? index);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};

View File

@@ -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<TVSeriesHeaderProps> = ({ 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 (
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
width: "80%",
marginBottom: 24,
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: 52,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
numberOfLines={2}
>
{item.Name}
</Text>
)}
{/* Metadata badges row */}
<View
style={{
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
marginBottom: 20,
}}
>
{yearString && (
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{yearString}</Text>
)}
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' />
)}
{item.CommunityRating != null && (
<Badge
text={item.CommunityRating.toFixed(1)}
variant='gray'
iconLeft={<Ionicons name='star' size={16} color='gold' />}
/>
)}
</View>
{/* Genres */}
{item.Genres && item.Genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={item.Genres} />
</View>
)}
{/* Overview */}
{item.Overview && (
<Text
style={{
fontSize: 18,
color: "#D1D5DB",
lineHeight: 28,
maxWidth: SCREEN_WIDTH * 0.45,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
)}
</View>
);
};

View File

@@ -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 (
<Pressable
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 [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 (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
}}
>
<View
style={{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 20,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
>
{seasonName}
</Text>
<Ionicons
name='chevron-down'
size={18}
color={focused ? "#000" : "#FFFFFF"}
/>
</View>
</Animated.View>
</Pressable>
);
};
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
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<ScrollView>(null);
// FlatList ref for scrolling back
const episodeListRef = useRef<FlatList<BaseItemDto>>(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<BaseItemDto> | 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 }) => (
<View style={{ marginRight: ITEM_GAP }}>
<TVEpisodeCard
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={isModalOpen}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
/>
</View>
),
[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 (
<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 - Poster + Content */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
marginRight: 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>
{/* Right 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
disabled={isModalOpen}
variant='primary'
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: "#000000",
}}
>
{playButtonText}
</Text>
</TVFocusableButton>
{seasons.length > 1 && (
<TVSeasonButton
seasonName={selectedSeasonName}
onPress={() => setOpenModal("season")}
disabled={isModalOpen}
/>
)}
</View>
</View>
</View>
{/* Episodes section */}
<View style={{ marginTop: 40, overflow: "visible" }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{selectedSeasonName}
</Text>
<FlatList
ref={episodeListRef}
horizontal
data={episodesForSeason}
keyExtractor={(ep) => 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={
<Text
style={{
color: "#737373",
fontSize: 16,
marginLeft: SCALE_PADDING,
}}
>
{t("item_card.no_episodes_for_this_season")}
</Text>
}
/>
</View>
</ScrollView>
{/* Season selector modal */}
<TVSeasonSelector
visible={openModal === "season"}
seasons={seasons}
selectedSeasonIndex={selectedSeasonIndex}
onSelect={handleSeasonSelect}
onClose={() => setOpenModal(null)}
/>
</View>
);
};

View File

@@ -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 (
<View style={countdownStyles.container} pointerEvents='none'>
<BlurView intensity={80} tint='dark' style={countdownStyles.blur}>
<View style={countdownStyles.innerContainer}>
{/* Episode Thumbnail - left side */}
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={countdownStyles.thumbnail}
resizeMode='cover'
/>
)}
{/* Content - right side */}
<View style={countdownStyles.content}>
{/* Label: "Next Episode" */}
<Text style={countdownStyles.label}>
{t("player.next_episode")}
</Text>
{/* Series Name */}
<Text style={countdownStyles.seriesName} numberOfLines={1}>
{nextItem.SeriesName}
</Text>
{/* Episode Info: S#E# - Episode Name */}
<Text style={countdownStyles.episodeInfo} numberOfLines={1}>
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
{nextItem.Name}
</Text>
{/* Progress Bar */}
<View style={countdownStyles.progressContainer}>
<Animated.View
style={[countdownStyles.progressBar, progressStyle]}
/>
</View>
</View>
</View>
</BlurView>
</View>
);
};
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<Props> = ({
item,
seek,
@@ -500,6 +665,21 @@ export const Controls: FC<Props> = ({
}) => {
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<Props> = ({
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<Props> = ({
return (
<View style={styles.controlsContainer} pointerEvents='box-none'>
{/* Dark tint overlay when controls are visible */}
<Animated.View
style={[styles.darkOverlay, bottomAnimatedStyle]}
pointerEvents='none'
/>
{/* Center Play Button - shown when paused */}
{!isPlaying && showControls && (
<View style={styles.centerContainer}>
@@ -772,6 +1018,17 @@ export const Controls: FC<Props> = ({
</View>
)}
{/* Next Episode Countdown - always visible when countdown active */}
{nextItem && (
<TVNextEpisodeCountdown
nextItem={nextItem}
api={api}
show={shouldShowCountdown}
isPlaying={isPlaying}
onFinish={handleAutoPlayFinish}
/>
)}
{/* Top hint - swipe up for settings */}
{showControls && hasSettings && !isModalOpen && (
<Animated.View
@@ -794,7 +1051,7 @@ export const Controls: FC<Props> = ({
</Text>
<Ionicons
name='chevron-down'
size={16}
size={20}
color='rgba(255,255,255,0.5)'
/>
</View>
@@ -820,9 +1077,9 @@ export const Controls: FC<Props> = ({
{/* Metadata */}
<View style={styles.metadataContainer}>
{item?.Type === "Episode" && (
<Text style={styles.subtitleText}>
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
</Text>
<Text
style={styles.subtitleText}
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
)}
<Text style={styles.titleText}>{item?.Name}</Text>
{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,
},
});

View File

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