diff --git a/app/_layout.tsx b/app/_layout.tsx index 79278b70..7d86119f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,6 +10,7 @@ import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; import { Platform } from "react-native"; import { GlobalModal } from "@/components/GlobalModal"; + import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; @@ -443,7 +444,7 @@ function Layout() { }} closeButton /> - + {!Platform.isTV && } diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 8286b595..f0c33cc6 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -25,6 +25,7 @@ import { Pressable, ScrollView, TVFocusGuideView, + useTVEventHandler, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -301,6 +302,63 @@ const _TVOptionRowModal: React.FC<{ ); }; +// Cancel button for TV option selectors +const TVCancelButton: React.FC<{ onPress: () => void }> = ({ onPress }) => { + const { t } = useTranslation(); + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 120, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + + + + {t("common.cancel") || "Cancel"} + + + + ); +}; + // TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style) const TVOptionSelector = ({ visible, @@ -452,6 +510,19 @@ const TVOptionSelector = ({ ))} )} + + {/* Cancel button */} + {isReady && ( + + + + )} @@ -940,6 +1011,15 @@ export const ItemContentTV: React.FC = React.memo( } }, [isModalOpen]); + // tvOS menu button handler for closing modals + // Note: This may not receive events if React Navigation intercepts them first + useTVEventHandler((evt) => { + if (!evt || !isModalOpen) return; + if (evt.eventType === "menu" || evt.eventType === "back") { + setOpenModal(null); + } + }); + // Get available audio tracks const audioTracks = useMemo(() => { const streams = selectedOptions?.mediaSource?.MediaStreams?.filter( diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index b421d7db..b9022f62 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -212,6 +212,13 @@ const TVOptionSelector = ({ ))} )} + + {/* Cancel button */} + {isReady && ( + + + + )} @@ -219,6 +226,61 @@ const TVOptionSelector = ({ ); }; +// Cancel button for TV option selectors +const TVCancelButton: React.FC<{ onPress: () => void; label: string }> = ({ + onPress, + label, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 120, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + + + + {label} + + + + ); +}; + // Option card for horizontal selector (with forwardRef for programmatic focus) const TVOptionCard = React.forwardRef< View, @@ -663,6 +725,23 @@ const selectorStyles = StyleSheet.create({ tabText: { fontSize: 18, }, + cancelButtonContainer: { + paddingHorizontal: 48, + paddingTop: 16, + alignItems: "flex-start", + }, + cancelButton: { + flexDirection: "row", + alignItems: "center", + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 20, + gap: 8, + }, + cancelButtonText: { + fontSize: 16, + fontWeight: "600", + }, }); // TV Next Episode Countdown component - horizontal layout with animated progress bar diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx index 8d0d22a9..7c393267 100644 --- a/components/video-player/controls/TVSubtitleSheet.tsx +++ b/components/video-player/controls/TVSubtitleSheet.tsx @@ -474,6 +474,61 @@ const SubtitleResultCard = React.forwardRef< ); }); +// Cancel button for TV subtitle sheet +const TVCancelButton: React.FC<{ onPress: () => void; label: string }> = ({ + onPress, + label, +}) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 120, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + > + + + + {label} + + + + ); +}; + export const TVSubtitleSheet: React.FC = ({ visible, item, @@ -847,6 +902,16 @@ export const TVSubtitleSheet: React.FC = ({ )} )} + + {/* Cancel button */} + {isReady && ( + + + + )} @@ -1086,4 +1151,21 @@ const styles = StyleSheet.create({ color: "rgba(255,255,255,0.4)", fontSize: 12, }, + cancelButtonContainer: { + paddingHorizontal: 48, + paddingTop: 20, + alignItems: "flex-start", + }, + cancelButton: { + flexDirection: "row", + alignItems: "center", + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 20, + gap: 8, + }, + cancelButtonText: { + fontSize: 16, + fontWeight: "600", + }, });