diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 365016ef..e311c582 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -1,464 +1,24 @@ -import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { useAtom } from "jotai"; -import React, { useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Animated, Pressable, ScrollView, TextInput, View } from "react-native"; +import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import type { TVOptionItem } from "@/components/tv"; -import { useTVFocusAnimation } from "@/components/tv"; +import { + TVLogoutButton, + TVSectionHeader, + TVSettingsOptionButton, + TVSettingsRow, + TVSettingsStepper, + TVSettingsTextInput, + TVSettingsToggle, +} from "@/components/tv"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings"; -// TV-optimized focusable row component -const TVSettingsRow: React.FC<{ - label: string; - value: string; - onPress?: () => void; - isFirst?: boolean; - showChevron?: boolean; - disabled?: boolean; -}> = ({ label, value, onPress, isFirst, showChevron = true, disabled }) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.02 }); - - return ( - - - {label} - - - {value} - - {showChevron && ( - - )} - - - - ); -}; - -// TV-optimized toggle row component -const TVSettingsToggle: React.FC<{ - label: string; - value: boolean; - onToggle: (value: boolean) => void; - isFirst?: boolean; - disabled?: boolean; -}> = ({ label, value, onToggle, isFirst, disabled }) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.02 }); - - return ( - onToggle(!value)} - onFocus={handleFocus} - onBlur={handleBlur} - hasTVPreferredFocus={isFirst && !disabled} - disabled={disabled} - focusable={!disabled} - > - - {label} - - - - - - ); -}; - -// TV-optimized stepper row component -const TVSettingsStepper: React.FC<{ - label: string; - value: number; - onDecrease: () => void; - onIncrease: () => void; - formatValue?: (value: number) => string; - isFirst?: boolean; - disabled?: boolean; -}> = ({ - label, - value, - onDecrease, - onIncrease, - formatValue, - isFirst, - disabled, -}) => { - const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 }); - const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); - const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); - - const displayValue = formatValue ? formatValue(value) : String(value); - - return ( - - - - {label} - - - - - - - - - - {displayValue} - - - - - - - - - ); -}; - -// TV Settings Option Button - displays current value and opens bottom sheet -const TVSettingsOptionButton: React.FC<{ - label: string; - value: string; - onPress: () => void; - isFirst?: boolean; - disabled?: boolean; -}> = ({ label, value, onPress, isFirst, disabled }) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.02 }); - - return ( - - - {label} - - - {value} - - - - - - ); -}; - -// TV-optimized text input component -const TVSettingsTextInput: React.FC<{ - label: string; - value: string; - placeholder?: string; - onChangeText: (text: string) => void; - onBlur?: () => void; - secureTextEntry?: boolean; - disabled?: boolean; -}> = ({ - label, - value, - placeholder, - onChangeText, - onBlur, - secureTextEntry, - disabled, -}) => { - const inputRef = useRef(null); - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.02 }); - - const handleInputBlur = () => { - handleBlur(); - onBlur?.(); - }; - - return ( - inputRef.current?.focus()} - onFocus={handleFocus} - onBlur={handleInputBlur} - disabled={disabled} - focusable={!disabled} - > - - - {label} - - - - - ); -}; - -// Section header component -const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( - - {title} - -); - -// Logout button component -const TVLogoutButton: React.FC<{ onPress: () => void; disabled?: boolean }> = ({ - onPress, - disabled, -}) => { - const { t } = useTranslation(); - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05 }); - - return ( - - - - - {t("home.settings.log_out_button")} - - - - - ); -}; - export default function SettingsTV() { const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -616,7 +176,7 @@ export default function SettingsTV() { {/* Audio Section */} - + {/* Subtitles Section */} - + {/* MPV Subtitles Section */} - + {/* OpenSubtitles Section */} - {/* Appearance Section */} - + {/* User Section */} - + void; - hasTVPreferredFocus?: boolean; - } ->(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.08 }); - - const imageUrl = person.Id - ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90` - : null; - - return ( - - - - {imageUrl ? ( - - ) : ( - - - - )} - - - - {person.Name} - - - {person.Role && ( - - {person.Role} - - )} - - - ); -}); - -// 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, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05 }); - - return ( - - - - {imageUrl ? ( - - ) : ( - - - - )} - - - - {title} - - - {subtitle && ( - - {subtitle} - - )} - - - ); -}; - -// 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, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); - - return ( - - - - - {label} - - - {value} - - - - - ); -}); - -// Refresh metadata button with spinning animation -const TVRefreshButton: React.FC<{ - itemId: string | undefined; -}> = ({ itemId }) => { - const queryClient = useQueryClient(); - const [isRefreshing, setIsRefreshing] = useState(false); - const spinValue = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (isRefreshing) { - spinValue.setValue(0); - Animated.loop( - Animated.timing(spinValue, { - toValue: 1, - duration: 1000, - easing: Easing.linear, - useNativeDriver: true, - }), - ).start(); - } else { - spinValue.stopAnimation(); - spinValue.setValue(0); - } - }, [isRefreshing, spinValue]); - - const spin = spinValue.interpolate({ - inputRange: [0, 1], - outputRange: ["0deg", "360deg"], - }); - - const handleRefresh = useCallback(async () => { - if (!itemId || isRefreshing) return; - - setIsRefreshing(true); - const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000)); - try { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["item", itemId] }), - minSpinTime, - ]); - } finally { - setIsRefreshing(false); - } - }, [itemId, queryClient, isRefreshing]); - - return ( - - - - - - ); -}; - // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx new file mode 100644 index 00000000..b477cb5d --- /dev/null +++ b/components/tv/TVActorCard.tsx @@ -0,0 +1,115 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVActorCardProps { + person: { + Id?: string | null; + Name?: string | null; + Role?: string | null; + }; + apiBasePath?: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVActorCard = React.forwardRef( + ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const imageUrl = person.Id + ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90` + : null; + + return ( + + + + {imageUrl ? ( + + ) : ( + + + + )} + + + + {person.Name} + + + {person.Role && ( + + {person.Role} + + )} + + + ); + }, +); diff --git a/components/tv/TVControlButton.tsx b/components/tv/TVControlButton.tsx new file mode 100644 index 00000000..72e06ca2 --- /dev/null +++ b/components/tv/TVControlButton.tsx @@ -0,0 +1,72 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { FC } from "react"; +import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVControlButtonProps { + icon: keyof typeof Ionicons.glyphMap; + onPress: () => void; + onLongPress?: () => void; + onPressOut?: () => void; + disabled?: boolean; + hasTVPreferredFocus?: boolean; + size?: number; + delayLongPress?: number; +} + +export const TVControlButton: FC = ({ + icon, + onPress, + onLongPress, + onPressOut, + disabled, + hasTVPreferredFocus, + size = 32, + delayLongPress = 300, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + width: 64, + height: 64, + borderRadius: 32, + borderWidth: 2, + justifyContent: "center", + alignItems: "center", + }, +}); diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx new file mode 100644 index 00000000..b2b8e6b1 --- /dev/null +++ b/components/tv/TVLanguageCard.tsx @@ -0,0 +1,96 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVLanguageCardProps { + code: string; + name: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +} + +export const TVLanguageCard = React.forwardRef( + ({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {name} + + + {code.toUpperCase()} + + {selected && !focused && ( + + + + )} + + + ); + }, +); + +const styles = StyleSheet.create({ + languageCard: { + width: 120, + height: 60, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + languageCardText: { + fontSize: 15, + fontWeight: "500", + }, + languageCardCode: { + fontSize: 11, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, +}); diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx new file mode 100644 index 00000000..222e3413 --- /dev/null +++ b/components/tv/TVNextEpisodeCountdown.tsx @@ -0,0 +1,160 @@ +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { BlurView } from "expo-blur"; +import { type FC, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Image, StyleSheet, View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/common/Text"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; + +export interface TVNextEpisodeCountdownProps { + nextItem: BaseItemDto; + api: Api | null; + show: boolean; + isPlaying: boolean; + onFinish: () => void; +} + +export const TVNextEpisodeCountdown: FC = ({ + nextItem, + api, + show, + isPlaying, + onFinish, +}) => { + const { t } = useTranslation(); + const progress = useSharedValue(0); + const onFinishRef = useRef(onFinish); + + onFinishRef.current = onFinish; + + const imageUrl = getPrimaryImageUrl({ + api, + item: nextItem, + width: 360, + quality: 80, + }); + + useEffect(() => { + if (show && isPlaying) { + progress.value = 0; + progress.value = withTiming( + 1, + { + duration: 8000, + easing: Easing.linear, + }, + (finished) => { + if (finished && onFinishRef.current) { + runOnJS(onFinishRef.current)(); + } + }, + ); + } else { + cancelAnimation(progress); + progress.value = 0; + } + }, [show, isPlaying, progress]); + + const progressStyle = useAnimatedStyle(() => ({ + width: `${progress.value * 100}%`, + })); + + if (!show) return null; + + return ( + + + + {imageUrl && ( + + )} + + + {t("player.next_episode")} + + + {nextItem.SeriesName} + + + + S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} + {nextItem.Name} + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "absolute", + bottom: 180, + right: 80, + 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, + }, +}); diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx new file mode 100644 index 00000000..8ef8a7dd --- /dev/null +++ b/components/tv/TVOptionButton.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVOptionButtonProps { + label: string; + value: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVOptionButton = React.forwardRef( + ({ label, value, onPress, hasTVPreferredFocus }, ref) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 }); + + return ( + + + + + {label} + + + {value} + + + + + ); + }, +); diff --git a/components/tv/TVRefreshButton.tsx b/components/tv/TVRefreshButton.tsx new file mode 100644 index 00000000..5e44dd94 --- /dev/null +++ b/components/tv/TVRefreshButton.tsx @@ -0,0 +1,70 @@ +import { Ionicons } from "@expo/vector-icons"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Animated, Easing } from "react-native"; +import { TVButton } from "./TVButton"; + +export interface TVRefreshButtonProps { + itemId: string | undefined; + queryClient?: QueryClient; +} + +export const TVRefreshButton: React.FC = ({ + itemId, + queryClient: externalQueryClient, +}) => { + const defaultQueryClient = useQueryClient(); + const queryClient = externalQueryClient ?? defaultQueryClient; + const [isRefreshing, setIsRefreshing] = useState(false); + const spinValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (isRefreshing) { + spinValue.setValue(0); + Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }), + ).start(); + } else { + spinValue.stopAnimation(); + spinValue.setValue(0); + } + }, [isRefreshing, spinValue]); + + const spin = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + + const handleRefresh = useCallback(async () => { + if (!itemId || isRefreshing) return; + + setIsRefreshing(true); + const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000)); + try { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["item", itemId] }), + minSpinTime, + ]); + } finally { + setIsRefreshing(false); + } + }, [itemId, queryClient, isRefreshing]); + + return ( + + + + + + ); +}; diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx new file mode 100644 index 00000000..b7d97642 --- /dev/null +++ b/components/tv/TVSeriesSeasonCard.tsx @@ -0,0 +1,106 @@ +import { Ionicons } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVSeriesSeasonCardProps { + title: string; + subtitle?: string; + imageUrl: string | null; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVSeriesSeasonCard: React.FC = ({ + title, + subtitle, + imageUrl, + onPress, + hasTVPreferredFocus, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {imageUrl ? ( + + ) : ( + + + + )} + + + + {title} + + + {subtitle && ( + + {subtitle} + + )} + + + ); +}; diff --git a/components/tv/TVSubtitleResultCard.tsx b/components/tv/TVSubtitleResultCard.tsx new file mode 100644 index 00000000..5953098c --- /dev/null +++ b/components/tv/TVSubtitleResultCard.tsx @@ -0,0 +1,267 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { + ActivityIndicator, + Animated, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVSubtitleResultCardProps { + result: SubtitleSearchResult; + hasTVPreferredFocus?: boolean; + isDownloading?: boolean; + onPress: () => void; +} + +export const TVSubtitleResultCard = React.forwardRef< + View, + TVSubtitleResultCardProps +>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.03 }); + + return ( + + + {/* Provider/Source badge */} + + + {result.providerName} + + + + {/* Name */} + + {result.name} + + + {/* Meta info row */} + + {/* Format */} + + {result.format?.toUpperCase()} + + + {/* Rating if available */} + {result.communityRating !== undefined && + result.communityRating > 0 && ( + + + + {result.communityRating.toFixed(1)} + + + )} + + {/* Download count if available */} + {result.downloadCount !== undefined && result.downloadCount > 0 && ( + + + + {result.downloadCount.toLocaleString()} + + + )} + + + {/* Flags */} + + {result.isHashMatch && ( + + Hash Match + + )} + {result.hearingImpaired && ( + + + + )} + {result.aiTranslated && ( + + AI + + )} + + + {/* Loading indicator when downloading */} + {isDownloading && ( + + + + )} + + + ); +}); + +const styles = StyleSheet.create({ + resultCard: { + width: 220, + minHeight: 120, + borderRadius: 14, + padding: 14, + borderWidth: 1, + }, + providerBadge: { + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + marginBottom: 8, + }, + providerText: { + fontSize: 11, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + resultName: { + fontSize: 14, + fontWeight: "500", + marginBottom: 8, + lineHeight: 18, + }, + resultMeta: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 8, + }, + resultMetaText: { + fontSize: 12, + }, + ratingContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + downloadCountContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + flagsContainer: { + flexDirection: "row", + gap: 6, + flexWrap: "wrap", + }, + flag: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + flagText: { + fontSize: 10, + fontWeight: "600", + color: "#fff", + }, + downloadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0,0,0,0.5)", + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + }, +}); diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx new file mode 100644 index 00000000..4945285f --- /dev/null +++ b/components/tv/TVTrackCard.tsx @@ -0,0 +1,101 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, StyleSheet, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; + +export interface TVTrackCardProps { + label: string; + sublabel?: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; +} + +export const TVTrackCard = React.forwardRef( + ({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {label} + + {sublabel && ( + + {sublabel} + + )} + {selected && !focused && ( + + + + )} + + + ); + }, +); + +const styles = StyleSheet.create({ + trackCard: { + width: 180, + height: 80, + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + trackCardText: { + fontSize: 16, + textAlign: "center", + }, + trackCardSublabel: { + fontSize: 12, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 8, + right: 8, + }, +}); diff --git a/components/tv/index.ts b/components/tv/index.ts index c1177897..5c804cba 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -1,17 +1,42 @@ +// Hooks export type { UseTVFocusAnimationOptions, UseTVFocusAnimationReturn, } from "./hooks/useTVFocusAnimation"; export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation"; +// Settings components (re-export from settings/) +export * from "./settings"; +// Item content components +export type { TVActorCardProps } from "./TVActorCard"; +export { TVActorCard } from "./TVActorCard"; +// Core components export type { TVButtonProps } from "./TVButton"; export { TVButton } from "./TVButton"; export type { TVCancelButtonProps } from "./TVCancelButton"; export { TVCancelButton } from "./TVCancelButton"; +// Player control components +export type { TVControlButtonProps } from "./TVControlButton"; +export { TVControlButton } from "./TVControlButton"; export type { TVFocusablePosterProps } from "./TVFocusablePoster"; export { TVFocusablePoster } from "./TVFocusablePoster"; +export type { TVLanguageCardProps } from "./TVLanguageCard"; +export { TVLanguageCard } from "./TVLanguageCard"; +export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown"; +export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown"; +export type { TVOptionButtonProps } from "./TVOptionButton"; +export { TVOptionButton } from "./TVOptionButton"; export type { TVOptionCardProps } from "./TVOptionCard"; export { TVOptionCard } from "./TVOptionCard"; export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector"; export { TVOptionSelector } from "./TVOptionSelector"; +export type { TVRefreshButtonProps } from "./TVRefreshButton"; +export { TVRefreshButton } from "./TVRefreshButton"; +export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard"; +export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard"; +export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard"; +export { TVSubtitleResultCard } from "./TVSubtitleResultCard"; export type { TVTabButtonProps } from "./TVTabButton"; export { TVTabButton } from "./TVTabButton"; +// Subtitle sheet components +export type { TVTrackCardProps } from "./TVTrackCard"; +export { TVTrackCard } from "./TVTrackCard"; diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx new file mode 100644 index 00000000..d9d214bc --- /dev/null +++ b/components/tv/settings/TVLogoutButton.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVLogoutButtonProps { + onPress: () => void; + disabled?: boolean; +} + +export const TVLogoutButton: React.FC = ({ + onPress, + disabled, +}) => { + const { t } = useTranslation(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + + {t("home.settings.log_out_button")} + + + + + ); +}; diff --git a/components/tv/settings/TVSectionHeader.tsx b/components/tv/settings/TVSectionHeader.tsx new file mode 100644 index 00000000..6d983598 --- /dev/null +++ b/components/tv/settings/TVSectionHeader.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Text } from "@/components/common/Text"; + +export interface TVSectionHeaderProps { + title: string; +} + +export const TVSectionHeader: React.FC = ({ title }) => ( + + {title} + +); diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx new file mode 100644 index 00000000..f6f2dbea --- /dev/null +++ b/components/tv/settings/TVSettingsOptionButton.tsx @@ -0,0 +1,67 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsOptionButtonProps { + label: string; + value: string; + onPress: () => void; + isFirst?: boolean; + disabled?: boolean; +} + +export const TVSettingsOptionButton: React.FC = ({ + label, + value, + onPress, + isFirst, + disabled, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + return ( + + + {label} + + + {value} + + + + + + ); +}; diff --git a/components/tv/settings/TVSettingsRow.tsx b/components/tv/settings/TVSettingsRow.tsx new file mode 100644 index 00000000..1ea2af21 --- /dev/null +++ b/components/tv/settings/TVSettingsRow.tsx @@ -0,0 +1,71 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsRowProps { + label: string; + value: string; + onPress?: () => void; + isFirst?: boolean; + showChevron?: boolean; + disabled?: boolean; +} + +export const TVSettingsRow: React.FC = ({ + label, + value, + onPress, + isFirst, + showChevron = true, + disabled, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + return ( + + + {label} + + + {value} + + {showChevron && ( + + )} + + + + ); +}; diff --git a/components/tv/settings/TVSettingsStepper.tsx b/components/tv/settings/TVSettingsStepper.tsx new file mode 100644 index 00000000..19c98211 --- /dev/null +++ b/components/tv/settings/TVSettingsStepper.tsx @@ -0,0 +1,128 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsStepperProps { + label: string; + value: number; + onDecrease: () => void; + onIncrease: () => void; + formatValue?: (value: number) => string; + isFirst?: boolean; + disabled?: boolean; +} + +export const TVSettingsStepper: React.FC = ({ + label, + value, + onDecrease, + onIncrease, + formatValue, + isFirst, + disabled, +}) => { + const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 }); + const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); + const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 }); + + const displayValue = formatValue ? formatValue(value) : String(value); + + return ( + + + + {label} + + + + + + + + + + {displayValue} + + + + + + + + + ); +}; diff --git a/components/tv/settings/TVSettingsTextInput.tsx b/components/tv/settings/TVSettingsTextInput.tsx new file mode 100644 index 00000000..6d896e88 --- /dev/null +++ b/components/tv/settings/TVSettingsTextInput.tsx @@ -0,0 +1,83 @@ +import React, { useRef } from "react"; +import { Animated, Pressable, TextInput } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsTextInputProps { + label: string; + value: string; + placeholder?: string; + onChangeText: (text: string) => void; + onBlur?: () => void; + secureTextEntry?: boolean; + disabled?: boolean; +} + +export const TVSettingsTextInput: React.FC = ({ + label, + value, + placeholder, + onChangeText, + onBlur, + secureTextEntry, + disabled, +}) => { + const inputRef = useRef(null); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + const handleInputBlur = () => { + handleBlur(); + onBlur?.(); + }; + + return ( + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleInputBlur} + disabled={disabled} + focusable={!disabled} + > + + + {label} + + + + + ); +}; diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx new file mode 100644 index 00000000..cfeb182f --- /dev/null +++ b/components/tv/settings/TVSettingsToggle.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Animated, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation"; + +export interface TVSettingsToggleProps { + label: string; + value: boolean; + onToggle: (value: boolean) => void; + isFirst?: boolean; + disabled?: boolean; +} + +export const TVSettingsToggle: React.FC = ({ + label, + value, + onToggle, + isFirst, + disabled, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.02 }); + + return ( + onToggle(!value)} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={isFirst && !disabled} + disabled={disabled} + focusable={!disabled} + > + + {label} + + + + + + ); +}; diff --git a/components/tv/settings/index.ts b/components/tv/settings/index.ts new file mode 100644 index 00000000..43ff2f63 --- /dev/null +++ b/components/tv/settings/index.ts @@ -0,0 +1,14 @@ +export type { TVLogoutButtonProps } from "./TVLogoutButton"; +export { TVLogoutButton } from "./TVLogoutButton"; +export type { TVSectionHeaderProps } from "./TVSectionHeader"; +export { TVSectionHeader } from "./TVSectionHeader"; +export type { TVSettingsOptionButtonProps } from "./TVSettingsOptionButton"; +export { TVSettingsOptionButton } from "./TVSettingsOptionButton"; +export type { TVSettingsRowProps } from "./TVSettingsRow"; +export { TVSettingsRow } from "./TVSettingsRow"; +export type { TVSettingsStepperProps } from "./TVSettingsStepper"; +export { TVSettingsStepper } from "./TVSettingsStepper"; +export type { TVSettingsTextInputProps } from "./TVSettingsTextInput"; +export { TVSettingsTextInput } from "./TVSettingsTextInput"; +export type { TVSettingsToggleProps } from "./TVSettingsToggle"; +export { TVSettingsToggle } from "./TVSettingsToggle"; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 7d88f9e5..21e4db31 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -1,10 +1,7 @@ -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 { @@ -16,17 +13,9 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { - Image, - Pressable, - Animated as RNAnimated, - StyleSheet, - View, -} from "react-native"; +import { StyleSheet, View } from "react-native"; import Animated, { - cancelAnimation, Easing, - runOnJS, type SharedValue, useAnimatedReaction, useAnimatedStyle, @@ -35,7 +24,7 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { useTVFocusAnimation } from "@/components/tv"; +import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv"; import useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; @@ -45,7 +34,6 @@ import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time"; import { CONTROLS_CONSTANTS } from "./constants"; import { useRemoteControl } from "./hooks/useRemoteControl"; @@ -84,214 +72,6 @@ interface Props { const TV_SEEKBAR_HEIGHT = 16; const TV_AUTO_HIDE_TIMEOUT = 5000; -// TV Control Button for player controls (icon only, no label) -const TVControlButton: FC<{ - icon: keyof typeof Ionicons.glyphMap; - onPress: () => void; - onLongPress?: () => void; - onPressOut?: () => void; - disabled?: boolean; - hasTVPreferredFocus?: boolean; - size?: number; - delayLongPress?: number; -}> = ({ - icon, - onPress, - onLongPress, - onPressOut, - disabled, - hasTVPreferredFocus, - size = 32, - delayLongPress = 300, -}) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); - - return ( - - - - - - ); -}; - -const controlButtonStyles = StyleSheet.create({ - button: { - width: 64, - height: 64, - borderRadius: 32, - borderWidth: 2, - justifyContent: "center", - alignItems: "center", - }, -}); - -// 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); - - onFinishRef.current = onFinish; - - const imageUrl = getPrimaryImageUrl({ - api, - item: nextItem, - width: 360, - quality: 80, - }); - - useEffect(() => { - if (show && isPlaying) { - progress.value = 0; - progress.value = withTiming( - 1, - { - duration: 8000, - easing: Easing.linear, - }, - (finished) => { - if (finished && onFinishRef.current) { - runOnJS(onFinishRef.current)(); - } - }, - ); - } else { - cancelAnimation(progress); - progress.value = 0; - } - }, [show, isPlaying, progress]); - - const progressStyle = useAnimatedStyle(() => ({ - width: `${progress.value * 100}%`, - })); - - if (!show) return null; - - return ( - - - - {imageUrl && ( - - )} - - - - {t("player.next_episode")} - - - - {nextItem.SeriesName} - - - - S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} - {nextItem.Name} - - - - - - - - - - ); -}; - -const countdownStyles = StyleSheet.create({ - container: { - position: "absolute", - bottom: 180, - right: 80, - 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, diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx index e3f08b2d..6284b566 100644 --- a/components/video-player/controls/TVSubtitleSheet.tsx +++ b/components/video-player/controls/TVSubtitleSheet.tsx @@ -16,7 +16,6 @@ import { ActivityIndicator, Animated, Easing, - Pressable, ScrollView, StyleSheet, TVFocusGuideView, @@ -25,8 +24,10 @@ import { import { Text } from "@/components/common/Text"; import { TVCancelButton, + TVLanguageCard, + TVSubtitleResultCard, TVTabButton, - useTVFocusAnimation, + TVTrackCard, } from "@/components/tv"; import { type SubtitleSearchResult, @@ -48,327 +49,6 @@ interface TVSubtitleSheetProps { type TabType = "tracks" | "download"; -// Track card for subtitle track selection -const TVTrackCard = React.forwardRef< - View, - { - label: string; - sublabel?: string; - selected: boolean; - hasTVPreferredFocus?: boolean; - onPress: () => void; - } ->(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05 }); - - return ( - - - - {label} - - {sublabel && ( - - {sublabel} - - )} - {selected && !focused && ( - - - - )} - - - ); -}); - -// Language selector card -const LanguageCard = React.forwardRef< - View, - { - code: string; - name: string; - selected: boolean; - hasTVPreferredFocus?: boolean; - onPress: () => void; - } ->(({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.05 }); - - return ( - - - - {name} - - - {code.toUpperCase()} - - {selected && !focused && ( - - - - )} - - - ); -}); - -// Subtitle result card -const SubtitleResultCard = React.forwardRef< - View, - { - result: SubtitleSearchResult; - hasTVPreferredFocus?: boolean; - isDownloading?: boolean; - onPress: () => void; - } ->(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { - const { focused, handleFocus, handleBlur, animatedStyle } = - useTVFocusAnimation({ scaleAmount: 1.03 }); - - return ( - - - {/* Provider/Source badge */} - - - {result.providerName} - - - - {/* Name */} - - {result.name} - - - {/* Meta info row */} - - {/* Format */} - - {result.format?.toUpperCase()} - - - {/* Rating if available */} - {result.communityRating !== undefined && - result.communityRating > 0 && ( - - - - {result.communityRating.toFixed(1)} - - - )} - - {/* Download count if available */} - {result.downloadCount !== undefined && result.downloadCount > 0 && ( - - - - {result.downloadCount.toLocaleString()} - - - )} - - - {/* Flags */} - - {result.isHashMatch && ( - - Hash Match - - )} - {result.hearingImpaired && ( - - - - )} - {result.aiTranslated && ( - - AI - - )} - - - {/* Loading indicator when downloading */} - {isDownloading && ( - - - - )} - - - ); -}); - export const TVSubtitleSheet: React.FC = ({ visible, item, @@ -627,7 +307,7 @@ export const TVSubtitleSheet: React.FC = ({ contentContainerStyle={styles.languageScrollContent} > {displayLanguages.map((lang, index) => ( - = ({ contentContainerStyle={styles.resultsScrollContent} > {searchResults.map((result, index) => ( -