mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-23 01:04:43 +01:00
1758 lines
52 KiB
TypeScript
1758 lines
52 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type {
|
|
BaseItemDto,
|
|
MediaSourceInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { BlurView } from "expo-blur";
|
|
import { Image } from "expo-image";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { useAtom } from "jotai";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Animated,
|
|
BackHandler,
|
|
Dimensions,
|
|
Easing,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
TVFocusGuideView,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Badge } from "@/components/Badge";
|
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
|
import { ItemImage } from "@/components/common/ItemImage";
|
|
import { Text } from "@/components/common/Text";
|
|
import { GenreTags } from "@/components/GenreTags";
|
|
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
|
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
|
|
|
export type SelectedOptions = {
|
|
bitrate: Bitrate;
|
|
mediaSource: MediaSourceInfo | undefined;
|
|
audioIndex: number | undefined;
|
|
subtitleIndex: number;
|
|
};
|
|
|
|
interface ItemContentTVProps {
|
|
item?: BaseItemDto | null;
|
|
itemWithSources?: BaseItemDto | null;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
// Focusable button component for TV with Apple TV-style animations
|
|
const TVFocusableButton: React.FC<{
|
|
onPress: () => void;
|
|
children: React.ReactNode;
|
|
hasTVPreferredFocus?: boolean;
|
|
style?: any;
|
|
variant?: "primary" | "secondary";
|
|
}> = ({
|
|
onPress,
|
|
children,
|
|
hasTVPreferredFocus,
|
|
style,
|
|
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}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
transform: [{ scale }],
|
|
shadowColor: isPrimary ? "#fff" : "#a855f7",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.6 : 0,
|
|
shadowRadius: focused ? 20 : 0,
|
|
},
|
|
style,
|
|
]}
|
|
>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
// Info row component for metadata display
|
|
const _InfoRow: React.FC<{ label: string; value: string }> = ({
|
|
label,
|
|
value,
|
|
}) => (
|
|
<View style={{ flexDirection: "row", marginBottom: 8 }}>
|
|
<Text style={{ color: "#9CA3AF", fontSize: 16, width: 100 }}>{label}</Text>
|
|
<Text style={{ color: "#FFFFFF", fontSize: 16, flex: 1 }}>{value}</Text>
|
|
</View>
|
|
);
|
|
|
|
// Option item for the TV selector modal
|
|
type TVOptionItem<T> = {
|
|
label: string;
|
|
value: T;
|
|
selected: boolean;
|
|
};
|
|
|
|
// TV Option Selector (Modal style - saved as backup)
|
|
const _TVOptionSelectorModal = <T,>({
|
|
visible,
|
|
title,
|
|
options,
|
|
onSelect,
|
|
onClose,
|
|
}: {
|
|
visible: boolean;
|
|
title: string;
|
|
options: TVOptionItem<T>[];
|
|
onSelect: (value: T) => void;
|
|
onClose: () => void;
|
|
}) => {
|
|
// Find the initially selected index
|
|
const initialSelectedIndex = useMemo(() => {
|
|
const idx = options.findIndex((o) => o.selected);
|
|
return idx >= 0 ? idx : 0;
|
|
}, [options]);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
zIndex: 1000,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: "#111",
|
|
borderRadius: 16,
|
|
paddingVertical: 24,
|
|
paddingHorizontal: 32,
|
|
minWidth: 420,
|
|
maxWidth: SCREEN_WIDTH * 0.4,
|
|
maxHeight: SCREEN_HEIGHT * 0.7,
|
|
overflow: "visible",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<Text
|
|
style={{
|
|
fontSize: 24,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
marginBottom: 16,
|
|
paddingHorizontal: 8,
|
|
}}
|
|
>
|
|
{title}
|
|
</Text>
|
|
|
|
{/* Options list */}
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
style={{ maxHeight: SCREEN_HEIGHT * 0.5, overflow: "visible" }}
|
|
contentContainerStyle={{ paddingVertical: 4, paddingHorizontal: 4 }}
|
|
>
|
|
{options.map((option, index) => (
|
|
<_TVOptionRowModal
|
|
key={index}
|
|
label={option.label}
|
|
selected={option.selected}
|
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
|
onPress={() => {
|
|
onSelect(option.value);
|
|
onClose();
|
|
}}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Individual option row in the modal selector (backup)
|
|
const _TVOptionRowModal: React.FC<{
|
|
label: string;
|
|
selected: boolean;
|
|
hasTVPreferredFocus?: boolean;
|
|
onPress: () => void;
|
|
}> = ({ label, selected, 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: 120,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.02);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
style={{ marginBottom: 2 }}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
transform: [{ scale }],
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
backgroundColor: focused ? "#2a2a2a" : "transparent",
|
|
borderRadius: 10,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
}}
|
|
>
|
|
<View style={{ width: 28, marginRight: 12 }}>
|
|
{selected && <Ionicons name='checkmark' size={22} color='#a855f7' />}
|
|
</View>
|
|
<Text
|
|
style={{
|
|
fontSize: 18,
|
|
color: focused || selected ? "#FFFFFF" : "#888",
|
|
fontWeight: selected ? "600" : "400",
|
|
flex: 1,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
// TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style)
|
|
const TVOptionSelector = <T,>({
|
|
visible,
|
|
title,
|
|
options,
|
|
onSelect,
|
|
onClose,
|
|
}: {
|
|
visible: boolean;
|
|
title: string;
|
|
options: TVOptionItem<T>[];
|
|
onSelect: (value: T) => void;
|
|
onClose: () => void;
|
|
}) => {
|
|
const [isReady, setIsReady] = useState(false);
|
|
const firstCardRef = useRef<View>(null);
|
|
|
|
// Animation values
|
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
|
|
|
const initialSelectedIndex = useMemo(() => {
|
|
const idx = options.findIndex((o) => o.selected);
|
|
return idx >= 0 ? idx : 0;
|
|
}, [options]);
|
|
|
|
// Animate in when visible
|
|
useEffect(() => {
|
|
if (visible) {
|
|
// Reset values and animate in
|
|
overlayOpacity.setValue(0);
|
|
sheetTranslateY.setValue(200);
|
|
|
|
Animated.parallel([
|
|
Animated.timing(overlayOpacity, {
|
|
toValue: 1,
|
|
duration: 250,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(sheetTranslateY, {
|
|
toValue: 0,
|
|
duration: 300,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
|
|
|
// Delay rendering to work around hasTVPreferredFocus timing issue
|
|
useEffect(() => {
|
|
if (visible) {
|
|
const timer = setTimeout(() => setIsReady(true), 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
setIsReady(false);
|
|
}, [visible]);
|
|
|
|
// Programmatic focus fallback
|
|
useEffect(() => {
|
|
if (isReady && firstCardRef.current) {
|
|
const timer = setTimeout(() => {
|
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isReady]);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<Animated.View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
justifyContent: "flex-end",
|
|
zIndex: 1000,
|
|
opacity: overlayOpacity,
|
|
}}
|
|
>
|
|
<Animated.View style={{ transform: [{ translateY: sheetTranslateY }] }}>
|
|
<BlurView
|
|
intensity={80}
|
|
tint='dark'
|
|
style={{
|
|
borderTopLeftRadius: 24,
|
|
borderTopRightRadius: 24,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<TVFocusGuideView
|
|
autoFocus
|
|
trapFocusUp
|
|
trapFocusDown
|
|
trapFocusLeft
|
|
trapFocusRight
|
|
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,
|
|
}}
|
|
>
|
|
{title}
|
|
</Text>
|
|
|
|
{/* Horizontal options */}
|
|
{isReady && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={{ overflow: "visible" }}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 48,
|
|
paddingVertical: 10,
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{options.map((option, index) => (
|
|
<TVOptionCard
|
|
key={index}
|
|
ref={
|
|
index === initialSelectedIndex ? firstCardRef : undefined
|
|
}
|
|
label={option.label}
|
|
selected={option.selected}
|
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
|
onPress={() => {
|
|
onSelect(option.value);
|
|
onClose();
|
|
}}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
</TVFocusGuideView>
|
|
</BlurView>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
// Option card for horizontal selector (Apple TV style) - with forwardRef for programmatic focus
|
|
const TVOptionCard = React.forwardRef<
|
|
View,
|
|
{
|
|
label: string;
|
|
selected: boolean;
|
|
hasTVPreferredFocus?: boolean;
|
|
onPress: () => void;
|
|
}
|
|
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
const animateTo = (v: number) =>
|
|
Animated.timing(scale, {
|
|
toValue: v,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
return (
|
|
<Pressable
|
|
ref={ref}
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.05);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
transform: [{ scale }],
|
|
width: 160,
|
|
height: 75,
|
|
backgroundColor: focused
|
|
? "#fff"
|
|
: selected
|
|
? "rgba(255,255,255,0.2)"
|
|
: "rgba(255,255,255,0.08)",
|
|
borderRadius: 14,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingHorizontal: 12,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: focused ? "#000" : "#fff",
|
|
fontWeight: focused || selected ? "600" : "400",
|
|
textAlign: "center",
|
|
}}
|
|
numberOfLines={2}
|
|
>
|
|
{label}
|
|
</Text>
|
|
{selected && !focused && (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 8,
|
|
right: 8,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name='checkmark'
|
|
size={16}
|
|
color='rgba(255,255,255,0.8)'
|
|
/>
|
|
</View>
|
|
)}
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
// Circular actor card with Apple TV style focus animations
|
|
const TVActorCard = React.forwardRef<
|
|
View,
|
|
{
|
|
person: {
|
|
Id?: string | null;
|
|
Name?: string | null;
|
|
Role?: string | null;
|
|
};
|
|
apiBasePath?: string;
|
|
onPress: () => void;
|
|
hasTVPreferredFocus?: boolean;
|
|
}
|
|
>(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
|
|
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 imageUrl = person.Id
|
|
? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90`
|
|
: null;
|
|
|
|
return (
|
|
<Pressable
|
|
ref={ref}
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.08);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
transform: [{ scale }],
|
|
alignItems: "center",
|
|
width: 120,
|
|
shadowColor: "#fff",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.5 : 0,
|
|
shadowRadius: focused ? 16 : 0,
|
|
}}
|
|
>
|
|
{/* Circular image */}
|
|
<View
|
|
style={{
|
|
width: 100,
|
|
height: 100,
|
|
borderRadius: 50,
|
|
overflow: "hidden",
|
|
backgroundColor: "rgba(255,255,255,0.1)",
|
|
marginBottom: 12,
|
|
borderWidth: focused ? 3 : 0,
|
|
borderColor: "#fff",
|
|
}}
|
|
>
|
|
{imageUrl ? (
|
|
<Image
|
|
source={{ uri: imageUrl }}
|
|
style={{ width: "100%", height: "100%" }}
|
|
contentFit='cover'
|
|
/>
|
|
) : (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='person' size={40} color='rgba(255,255,255,0.4)' />
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Name */}
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
|
textAlign: "center",
|
|
marginBottom: 2,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{person.Name}
|
|
</Text>
|
|
|
|
{/* Role */}
|
|
{person.Role && (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: focused
|
|
? "rgba(255,255,255,0.8)"
|
|
: "rgba(255,255,255,0.5)",
|
|
textAlign: "center",
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{person.Role}
|
|
</Text>
|
|
)}
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
// Series/Season poster card with Apple TV style focus animations
|
|
const TVSeriesSeasonCard: React.FC<{
|
|
title: string;
|
|
subtitle?: string;
|
|
imageUrl: string | null;
|
|
onPress: () => void;
|
|
hasTVPreferredFocus?: boolean;
|
|
}> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => {
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
const animateTo = (v: number) =>
|
|
Animated.timing(scale, {
|
|
toValue: v,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.05);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
transform: [{ scale }],
|
|
width: 140,
|
|
shadowColor: "#fff",
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: focused ? 0.5 : 0,
|
|
shadowRadius: focused ? 16 : 0,
|
|
}}
|
|
>
|
|
{/* Poster image */}
|
|
<View
|
|
style={{
|
|
width: 140,
|
|
aspectRatio: 2 / 3,
|
|
borderRadius: 12,
|
|
overflow: "hidden",
|
|
backgroundColor: "rgba(255,255,255,0.1)",
|
|
marginBottom: 12,
|
|
borderWidth: focused ? 3 : 0,
|
|
borderColor: "#fff",
|
|
}}
|
|
>
|
|
{imageUrl ? (
|
|
<Image
|
|
source={{ uri: imageUrl }}
|
|
style={{ width: "100%", height: "100%" }}
|
|
contentFit='cover'
|
|
/>
|
|
) : (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='film' size={40} color='rgba(255,255,255,0.4)' />
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Title */}
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
|
textAlign: "center",
|
|
marginBottom: 2,
|
|
}}
|
|
numberOfLines={2}
|
|
>
|
|
{title}
|
|
</Text>
|
|
|
|
{/* Subtitle */}
|
|
{subtitle && (
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: focused
|
|
? "rgba(255,255,255,0.8)"
|
|
: "rgba(255,255,255,0.5)",
|
|
textAlign: "center",
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{subtitle}
|
|
</Text>
|
|
)}
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
// Button to open option selector
|
|
const TVOptionButton = React.forwardRef<
|
|
View,
|
|
{
|
|
label: string;
|
|
value: string;
|
|
onPress: () => void;
|
|
hasTVPreferredFocus?: boolean;
|
|
}
|
|
>(({ label, value, onPress, hasTVPreferredFocus }, ref) => {
|
|
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
|
|
ref={ref}
|
|
onPress={onPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
animateTo(1.02);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
animateTo(1);
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<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: 10,
|
|
paddingHorizontal: 16,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: focused ? "#444" : "#bbb",
|
|
}}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: focused ? "#000" : "#FFFFFF",
|
|
fontWeight: "500",
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
|
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|
({ item, itemWithSources }) => {
|
|
const [api] = useAtom(apiAtom);
|
|
const [_user] = useAtom(userAtom);
|
|
const isOffline = useOfflineMode();
|
|
const { settings } = useSettings();
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
const _itemColors = useImageColorsReturn({ item });
|
|
|
|
const [selectedOptions, setSelectedOptions] = useState<
|
|
SelectedOptions | undefined
|
|
>(undefined);
|
|
|
|
const {
|
|
defaultAudioIndex,
|
|
defaultBitrate,
|
|
defaultMediaSource,
|
|
defaultSubtitleIndex,
|
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
|
|
|
const logoUrl = useMemo(
|
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
|
[api, item],
|
|
);
|
|
|
|
// Set default play options
|
|
useEffect(() => {
|
|
setSelectedOptions(() => ({
|
|
bitrate: defaultBitrate,
|
|
mediaSource: defaultMediaSource ?? undefined,
|
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
audioIndex: defaultAudioIndex,
|
|
}));
|
|
}, [
|
|
defaultAudioIndex,
|
|
defaultBitrate,
|
|
defaultSubtitleIndex,
|
|
defaultMediaSource,
|
|
]);
|
|
|
|
const handlePlay = () => {
|
|
if (!item || !selectedOptions) return;
|
|
|
|
const queryParams = new URLSearchParams({
|
|
itemId: item.Id!,
|
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
|
playbackPosition:
|
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
offline: isOffline ? "true" : "false",
|
|
});
|
|
|
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
|
};
|
|
|
|
// Modal state for option selectors
|
|
type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null;
|
|
const [openModal, setOpenModal] = useState<ModalType>(null);
|
|
const isModalOpen = openModal !== null;
|
|
|
|
// State for first actor card ref (used for focus guide)
|
|
// Using state instead of useRef to trigger re-renders when ref is set
|
|
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
|
null,
|
|
);
|
|
|
|
// State for last option button ref (used for upward focus guide from cast)
|
|
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
|
|
null,
|
|
);
|
|
|
|
// Android TV BackHandler for closing modals
|
|
useEffect(() => {
|
|
if (Platform.OS === "android" && isModalOpen) {
|
|
const backHandler = BackHandler.addEventListener(
|
|
"hardwareBackPress",
|
|
() => {
|
|
setOpenModal(null);
|
|
return true;
|
|
},
|
|
);
|
|
return () => backHandler.remove();
|
|
}
|
|
}, [isModalOpen]);
|
|
|
|
// Get available audio tracks
|
|
const audioTracks = useMemo(() => {
|
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
|
(s) => s.Type === "Audio",
|
|
);
|
|
return streams ?? [];
|
|
}, [selectedOptions?.mediaSource]);
|
|
|
|
// Get available subtitle tracks
|
|
const subtitleTracks = useMemo(() => {
|
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
|
(s) => s.Type === "Subtitle",
|
|
);
|
|
return streams ?? [];
|
|
}, [selectedOptions?.mediaSource]);
|
|
|
|
// Get available media sources
|
|
const mediaSources = useMemo(() => {
|
|
return (itemWithSources ?? item)?.MediaSources ?? [];
|
|
}, [item, itemWithSources]);
|
|
|
|
// Audio options for selector
|
|
const audioOptions = useMemo(() => {
|
|
return audioTracks.map((track) => ({
|
|
label:
|
|
track.DisplayTitle ||
|
|
`${track.Language || "Unknown"} (${track.Codec})`,
|
|
value: track.Index!,
|
|
selected: track.Index === selectedOptions?.audioIndex,
|
|
}));
|
|
}, [audioTracks, selectedOptions?.audioIndex]);
|
|
|
|
// Media source options for selector
|
|
const mediaSourceOptions = useMemo(() => {
|
|
return mediaSources.map((source) => {
|
|
const videoStream = source.MediaStreams?.find(
|
|
(s) => s.Type === "Video",
|
|
);
|
|
const displayName =
|
|
videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`;
|
|
return {
|
|
label: displayName,
|
|
value: source,
|
|
selected: source.Id === selectedOptions?.mediaSource?.Id,
|
|
};
|
|
});
|
|
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
|
|
|
|
// Quality/bitrate options for selector
|
|
const qualityOptions = useMemo(() => {
|
|
return BITRATES.map((bitrate) => ({
|
|
label: bitrate.key,
|
|
value: bitrate,
|
|
selected: bitrate.value === selectedOptions?.bitrate?.value,
|
|
}));
|
|
}, [selectedOptions?.bitrate?.value]);
|
|
|
|
// Handlers for option changes
|
|
const handleAudioChange = useCallback((audioIndex: number) => {
|
|
setSelectedOptions((prev) =>
|
|
prev ? { ...prev, audioIndex } : undefined,
|
|
);
|
|
}, []);
|
|
|
|
const handleSubtitleChange = useCallback((subtitleIndex: number) => {
|
|
setSelectedOptions((prev) =>
|
|
prev ? { ...prev, subtitleIndex } : undefined,
|
|
);
|
|
}, []);
|
|
|
|
const handleMediaSourceChange = useCallback(
|
|
(mediaSource: MediaSourceInfo) => {
|
|
// When media source changes, reset audio/subtitle to defaults
|
|
const defaultAudio = mediaSource.MediaStreams?.find(
|
|
(s) => s.Type === "Audio" && s.IsDefault,
|
|
);
|
|
const defaultSubtitle = mediaSource.MediaStreams?.find(
|
|
(s) => s.Type === "Subtitle" && s.IsDefault,
|
|
);
|
|
setSelectedOptions((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
mediaSource,
|
|
audioIndex: defaultAudio?.Index ?? prev.audioIndex,
|
|
subtitleIndex: defaultSubtitle?.Index ?? -1,
|
|
}
|
|
: undefined,
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleQualityChange = useCallback((bitrate: Bitrate) => {
|
|
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
|
|
}, []);
|
|
|
|
// Handle server-side subtitle download - invalidate queries to refresh tracks
|
|
const handleServerSubtitleDownloaded = useCallback(() => {
|
|
if (item?.Id) {
|
|
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
|
|
}
|
|
}, [item?.Id, queryClient]);
|
|
|
|
// Get display values for buttons
|
|
const selectedAudioLabel = useMemo(() => {
|
|
const track = audioTracks.find(
|
|
(t) => t.Index === selectedOptions?.audioIndex,
|
|
);
|
|
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
|
}, [audioTracks, selectedOptions?.audioIndex, t]);
|
|
|
|
const selectedSubtitleLabel = useMemo(() => {
|
|
if (selectedOptions?.subtitleIndex === -1)
|
|
return t("item_card.subtitles.none");
|
|
const track = subtitleTracks.find(
|
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
|
);
|
|
return (
|
|
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
|
);
|
|
}, [subtitleTracks, selectedOptions?.subtitleIndex, t]);
|
|
|
|
const selectedMediaSourceLabel = useMemo(() => {
|
|
const source = selectedOptions?.mediaSource;
|
|
if (!source) return t("item_card.video");
|
|
const videoStream = source.MediaStreams?.find((s) => s.Type === "Video");
|
|
return videoStream?.DisplayTitle || source.Name || t("item_card.video");
|
|
}, [selectedOptions?.mediaSource, t]);
|
|
|
|
const selectedQualityLabel = useMemo(() => {
|
|
return selectedOptions?.bitrate?.key || t("item_card.quality");
|
|
}, [selectedOptions?.bitrate?.key, t]);
|
|
|
|
// Format year and duration
|
|
const year = item?.ProductionYear;
|
|
const duration = item?.RunTimeTicks
|
|
? runtimeTicksToMinutes(item.RunTimeTicks)
|
|
: null;
|
|
const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
|
const remainingTime = hasProgress
|
|
? runtimeTicksToMinutes(
|
|
(item?.RunTimeTicks || 0) -
|
|
(item?.UserData?.PlaybackPositionTicks || 0),
|
|
)
|
|
: null;
|
|
|
|
// Get director
|
|
const director = item?.People?.find((p) => p.Type === "Director");
|
|
|
|
// Get cast (first 3 for text display)
|
|
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
|
|
|
|
// Get full cast for visual display (up to 10 actors)
|
|
const fullCast = useMemo(() => {
|
|
return (
|
|
item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
|
|
);
|
|
}, [item?.People]);
|
|
|
|
// Series/Season image URLs for episodes
|
|
const seriesImageUrl = useMemo(() => {
|
|
if (item?.Type !== "Episode" || !item.SeriesId) return null;
|
|
return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
|
|
}, [api, item?.Type, item?.SeriesId]);
|
|
|
|
const seasonImageUrl = useMemo(() => {
|
|
if (item?.Type !== "Episode") return null;
|
|
const seasonId = item.SeasonId || item.ParentId;
|
|
if (!seasonId) return null;
|
|
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
|
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
|
|
|
// Determine which option button is the last one (for focus guide targeting)
|
|
const lastOptionButton = useMemo(() => {
|
|
const hasSubtitleOption =
|
|
subtitleTracks.length > 0 ||
|
|
selectedOptions?.subtitleIndex !== undefined;
|
|
const hasAudioOption = audioTracks.length > 0;
|
|
const hasMediaSourceOption = mediaSources.length > 1;
|
|
|
|
if (hasSubtitleOption) return "subtitle";
|
|
if (hasAudioOption) return "audio";
|
|
if (hasMediaSourceOption) return "mediaSource";
|
|
return "quality";
|
|
}, [
|
|
subtitleTracks.length,
|
|
selectedOptions?.subtitleIndex,
|
|
audioTracks.length,
|
|
mediaSources.length,
|
|
]);
|
|
|
|
if (!item || !selectedOptions) 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 area */}
|
|
<ScrollView
|
|
style={{ flex: 1 }}
|
|
contentContainerStyle={{
|
|
paddingTop: insets.top + 140,
|
|
paddingBottom: insets.bottom + 60,
|
|
paddingHorizontal: insets.left + 80,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Top section - Logo/Title + Metadata */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
minHeight: SCREEN_HEIGHT * 0.45,
|
|
}}
|
|
>
|
|
{/* Left side - Back button + Poster */}
|
|
<View
|
|
style={{
|
|
width: SCREEN_WIDTH * 0.22,
|
|
marginRight: 50,
|
|
}}
|
|
>
|
|
{/* Poster */}
|
|
<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" }}>
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Episode info for TV shows */}
|
|
{item.Type === "Episode" && (
|
|
<View style={{ marginBottom: 12 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 24,
|
|
color: "#FFFFFF",
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{item.SeriesName}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 20,
|
|
color: "#9CA3AF",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Metadata badges row */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
gap: 12,
|
|
marginBottom: 20,
|
|
}}
|
|
>
|
|
{year != null && (
|
|
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{year}</Text>
|
|
)}
|
|
{duration && (
|
|
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>
|
|
{duration}
|
|
</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,
|
|
marginBottom: 32,
|
|
}}
|
|
numberOfLines={4}
|
|
>
|
|
{item.Overview}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
gap: 16,
|
|
marginBottom: 32,
|
|
}}
|
|
>
|
|
<TVFocusableButton
|
|
onPress={handlePlay}
|
|
hasTVPreferredFocus
|
|
variant='primary'
|
|
>
|
|
<Ionicons
|
|
name='play'
|
|
size={28}
|
|
color='#000000'
|
|
style={{ marginRight: 10 }}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: 20,
|
|
fontWeight: "bold",
|
|
color: "#000000",
|
|
}}
|
|
>
|
|
{hasProgress
|
|
? `${remainingTime} ${t("item_card.left")}`
|
|
: t("common.play")}
|
|
</Text>
|
|
</TVFocusableButton>
|
|
</View>
|
|
|
|
{/* Playback options */}
|
|
<View
|
|
style={{
|
|
flexDirection: "column",
|
|
alignItems: "flex-start",
|
|
gap: 10,
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
{/* Quality selector */}
|
|
<TVOptionButton
|
|
ref={
|
|
lastOptionButton === "quality"
|
|
? setLastOptionButtonRef
|
|
: undefined
|
|
}
|
|
label={t("item_card.quality")}
|
|
value={selectedQualityLabel}
|
|
onPress={() => setOpenModal("quality")}
|
|
/>
|
|
|
|
{/* Media source selector (only if multiple sources) */}
|
|
{mediaSources.length > 1 && (
|
|
<TVOptionButton
|
|
ref={
|
|
lastOptionButton === "mediaSource"
|
|
? setLastOptionButtonRef
|
|
: undefined
|
|
}
|
|
label={t("item_card.video")}
|
|
value={selectedMediaSourceLabel}
|
|
onPress={() => setOpenModal("mediaSource")}
|
|
/>
|
|
)}
|
|
|
|
{/* Audio selector */}
|
|
{audioTracks.length > 0 && (
|
|
<TVOptionButton
|
|
ref={
|
|
lastOptionButton === "audio"
|
|
? setLastOptionButtonRef
|
|
: undefined
|
|
}
|
|
label={t("item_card.audio")}
|
|
value={selectedAudioLabel}
|
|
onPress={() => setOpenModal("audio")}
|
|
/>
|
|
)}
|
|
|
|
{/* Subtitle selector */}
|
|
{(subtitleTracks.length > 0 ||
|
|
selectedOptions?.subtitleIndex !== undefined) && (
|
|
<TVOptionButton
|
|
ref={
|
|
lastOptionButton === "subtitle"
|
|
? setLastOptionButtonRef
|
|
: undefined
|
|
}
|
|
label={t("item_card.subtitles.label")}
|
|
value={selectedSubtitleLabel}
|
|
onPress={() => setOpenModal("subtitle")}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* Focus guide to direct navigation from options to cast list */}
|
|
{fullCast.length > 0 && firstActorCardRef && (
|
|
<TVFocusGuideView
|
|
destinations={[firstActorCardRef]}
|
|
style={{ height: 1, width: "100%" }}
|
|
/>
|
|
)}
|
|
|
|
{/* Progress bar (if partially watched) */}
|
|
{hasProgress && item.RunTimeTicks != null && (
|
|
<View style={{ maxWidth: 400, marginBottom: 24 }}>
|
|
<View
|
|
style={{
|
|
height: 4,
|
|
backgroundColor: "rgba(255,255,255,0.2)",
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: `${((item.UserData?.PlaybackPositionTicks || 0) / item.RunTimeTicks) * 100}%`,
|
|
height: "100%",
|
|
backgroundColor: "#a855f7",
|
|
borderRadius: 2,
|
|
}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Additional info section */}
|
|
<View style={{ marginTop: 40 }}>
|
|
{/* Cast & Crew */}
|
|
{(director || (cast && cast.length > 0)) && (
|
|
<View style={{ marginBottom: 32 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
{t("item_card.cast_and_crew")}
|
|
</Text>
|
|
<View style={{ flexDirection: "row", gap: 40 }}>
|
|
{director && (
|
|
<View>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: "#6B7280",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
{t("item_card.director")}
|
|
</Text>
|
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
|
{director.Name}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{/* Only show text cast if visual cast section won't be shown */}
|
|
{cast &&
|
|
cast.length > 0 &&
|
|
!(
|
|
(item.Type === "Movie" ||
|
|
item.Type === "Series" ||
|
|
item.Type === "Episode") &&
|
|
fullCast.length > 0
|
|
) && (
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: "#6B7280",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
{t("item_card.cast")}
|
|
</Text>
|
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
|
{cast.map((c) => c.Name).join(", ")}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Technical details */}
|
|
{selectedOptions.mediaSource?.MediaStreams &&
|
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
|
<View style={{ marginBottom: 32 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
{t("item_card.technical_details")}
|
|
</Text>
|
|
<View style={{ flexDirection: "row", gap: 40 }}>
|
|
{/* Video info */}
|
|
{(() => {
|
|
const videoStream =
|
|
selectedOptions.mediaSource?.MediaStreams?.find(
|
|
(s) => s.Type === "Video",
|
|
);
|
|
if (!videoStream) return null;
|
|
return (
|
|
<View>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: "#6B7280",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
Video
|
|
</Text>
|
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
|
{videoStream.DisplayTitle ||
|
|
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
|
|
</Text>
|
|
</View>
|
|
);
|
|
})()}
|
|
{/* Audio info */}
|
|
{(() => {
|
|
const audioStream =
|
|
selectedOptions.mediaSource?.MediaStreams?.find(
|
|
(s) => s.Type === "Audio",
|
|
);
|
|
if (!audioStream) return null;
|
|
return (
|
|
<View>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: "#6B7280",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
Audio
|
|
</Text>
|
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
|
{audioStream.DisplayTitle ||
|
|
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
|
|
</Text>
|
|
</View>
|
|
);
|
|
})()}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
|
{(item.Type === "Movie" ||
|
|
item.Type === "Series" ||
|
|
item.Type === "Episode") &&
|
|
fullCast.length > 0 && (
|
|
<View style={{ marginBottom: 32 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
marginBottom: 20,
|
|
}}
|
|
>
|
|
{t("item_card.cast")}
|
|
</Text>
|
|
{/* Focus guide to direct upward navigation from cast back to options */}
|
|
{lastOptionButtonRef && (
|
|
<TVFocusGuideView
|
|
destinations={[lastOptionButtonRef]}
|
|
style={{ height: 1, width: "100%" }}
|
|
/>
|
|
)}
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 80,
|
|
paddingVertical: 12,
|
|
gap: 20,
|
|
}}
|
|
>
|
|
{fullCast.map((person, index) => (
|
|
<TVActorCard
|
|
key={person.Id || index}
|
|
ref={index === 0 ? setFirstActorCardRef : undefined}
|
|
person={person}
|
|
apiBasePath={api?.basePath}
|
|
onPress={() => {
|
|
if (person.Id) {
|
|
router.push(`/(auth)/persons/${person.Id}`);
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
{/* From this Series - Episode only */}
|
|
{item.Type === "Episode" && item.SeriesId && (
|
|
<View style={{ marginBottom: 32 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
marginBottom: 20,
|
|
}}
|
|
>
|
|
{t("item_card.from_this_series") || "From this Series"}
|
|
</Text>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 80,
|
|
paddingVertical: 12,
|
|
gap: 24,
|
|
}}
|
|
>
|
|
{/* Series card */}
|
|
<TVSeriesSeasonCard
|
|
title={item.SeriesName || "Series"}
|
|
subtitle={t("item_card.view_series") || "View Series"}
|
|
imageUrl={seriesImageUrl}
|
|
onPress={() => {
|
|
router.push(`/(auth)/series/${item.SeriesId}`);
|
|
}}
|
|
hasTVPreferredFocus={false}
|
|
/>
|
|
|
|
{/* Season card */}
|
|
{(item.SeasonId || item.ParentId) && (
|
|
<TVSeriesSeasonCard
|
|
title={
|
|
item.SeasonName || `Season ${item.ParentIndexNumber}`
|
|
}
|
|
subtitle={t("item_card.view_season") || "View Season"}
|
|
imageUrl={seasonImageUrl}
|
|
onPress={() => {
|
|
router.push(
|
|
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Option selector modals */}
|
|
<TVOptionSelector
|
|
visible={openModal === "quality"}
|
|
title={t("item_card.quality")}
|
|
options={qualityOptions}
|
|
onSelect={handleQualityChange}
|
|
onClose={() => setOpenModal(null)}
|
|
/>
|
|
|
|
<TVOptionSelector
|
|
visible={openModal === "mediaSource"}
|
|
title={t("item_card.video")}
|
|
options={mediaSourceOptions}
|
|
onSelect={handleMediaSourceChange}
|
|
onClose={() => setOpenModal(null)}
|
|
/>
|
|
|
|
<TVOptionSelector
|
|
visible={openModal === "audio"}
|
|
title={t("item_card.audio")}
|
|
options={audioOptions}
|
|
onSelect={handleAudioChange}
|
|
onClose={() => setOpenModal(null)}
|
|
/>
|
|
|
|
{/* Unified Subtitle Sheet (tracks + download) */}
|
|
{item && (
|
|
<TVSubtitleSheet
|
|
visible={openModal === "subtitle"}
|
|
item={item}
|
|
mediaSourceId={selectedOptions?.mediaSource?.Id}
|
|
subtitleTracks={subtitleTracks}
|
|
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
|
|
onSubtitleIndexChange={handleSubtitleChange}
|
|
onClose={() => setOpenModal(null)}
|
|
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
},
|
|
);
|
|
|
|
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
|
|
export const ItemContent = ItemContentTV;
|