diff --git a/app/(auth)/tv-option-modal.tsx b/app/(auth)/tv-option-modal.tsx new file mode 100644 index 00000000..5ee980c0 --- /dev/null +++ b/app/(auth)/tv-option-modal.tsx @@ -0,0 +1,167 @@ +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Easing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVOptionCard } from "@/components/tv"; +import useRouter from "@/hooks/useAppRouter"; +import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal"; +import { store } from "@/utils/store"; + +export default function TVOptionModal() { + const router = useRouter(); + const modalState = useAtomValue(tvOptionModalAtom); + + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + const initialSelectedIndex = useMemo(() => { + if (!modalState?.options) return 0; + const idx = modalState.options.findIndex((o) => o.selected); + return idx >= 0 ? idx : 0; + }, [modalState?.options]); + + // Animate in on mount + useEffect(() => { + 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(); + + // Delay focus setup to allow layout + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + }, [overlayOpacity, sheetTranslateY]); + + // Request focus on the first card when ready + useEffect(() => { + if (isReady && firstCardRef.current) { + const timer = setTimeout(() => { + (firstCardRef.current as any)?.requestTVFocus?.(); + }, 50); + return () => clearTimeout(timer); + } + }, [isReady]); + + const handleSelect = (value: any) => { + modalState?.onSelect(value); + store.set(tvOptionModalAtom, null); + router.back(); + }; + + // If no modal state, just go back (shouldn't happen in normal usage) + if (!modalState) { + return null; + } + + const { title, options, cardWidth = 160, cardHeight = 75 } = modalState; + + return ( + + + + + {title} + {isReady && ( + + {options.map((option, index) => ( + handleSelect(option.value)} + width={cardWidth} + height={cardHeight} + /> + ))} + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + sheetContainer: { + width: "100%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 50, + overflow: "visible", + }, + title: { + fontSize: 18, + fontWeight: "500", + color: "rgba(255,255,255,0.6)", + marginBottom: 16, + paddingHorizontal: 48, + textTransform: "uppercase", + letterSpacing: 1, + }, + scrollView: { + overflow: "visible", + }, + scrollContent: { + paddingHorizontal: 48, + paddingVertical: 10, + gap: 12, + }, +}); diff --git a/app/(auth)/tv-subtitle-modal.tsx b/app/(auth)/tv-subtitle-modal.tsx new file mode 100644 index 00000000..22e8828e --- /dev/null +++ b/app/(auth)/tv-subtitle-modal.tsx @@ -0,0 +1,925 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Animated, + Easing, + Pressable, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { TVTabButton, useTVFocusAnimation } from "@/components/tv"; +import useRouter from "@/hooks/useAppRouter"; +import { + type SubtitleSearchResult, + useRemoteSubtitles, +} from "@/hooks/useRemoteSubtitles"; +import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal"; +import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; +import { store } from "@/utils/store"; + +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 default function TVSubtitleModal() { + const router = useRouter(); + const { t } = useTranslation(); + const modalState = useAtomValue(tvSubtitleModalAtom); + + const [activeTab, setActiveTab] = useState("tracks"); + const [selectedLanguage, setSelectedLanguage] = useState("eng"); + const [downloadingId, setDownloadingId] = useState(null); + const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false); + const [isReady, setIsReady] = useState(false); + const [isTabContentReady, setIsTabContentReady] = useState(false); + const firstTrackRef = useRef(null); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(300)).current; + + const { + hasOpenSubtitlesApiKey, + isSearching, + searchError, + searchResults, + search, + downloadAsync, + reset, + } = useRemoteSubtitles({ + itemId: modalState?.item?.Id ?? "", + item: modalState?.item ?? ({} as any), + mediaSourceId: modalState?.mediaSourceId, + }); + + const resetRef = useRef(reset); + resetRef.current = reset; + + const subtitleTracks = modalState?.subtitleTracks ?? []; + const currentSubtitleIndex = modalState?.currentSubtitleIndex ?? -1; + + const initialSelectedTrackIndex = useMemo(() => { + if (currentSubtitleIndex === -1) return 0; + const trackIdx = subtitleTracks.findIndex( + (t) => t.Index === currentSubtitleIndex, + ); + return trackIdx >= 0 ? trackIdx + 1 : 0; + }, [subtitleTracks, currentSubtitleIndex]); + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(300); + + 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(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => clearTimeout(timer); + }, [overlayOpacity, sheetTranslateY]); + + useEffect(() => { + if (activeTab === "download" && !hasSearchedThisSession && modalState) { + search({ language: selectedLanguage }); + setHasSearchedThisSession(true); + } + }, [activeTab, hasSearchedThisSession, search, selectedLanguage, modalState]); + + useEffect(() => { + if (isReady) { + setIsTabContentReady(false); + const timer = setTimeout(() => setIsTabContentReady(true), 50); + return () => clearTimeout(timer); + } + setIsTabContentReady(false); + }, [activeTab, isReady]); + + const handleClose = useCallback(() => { + store.set(tvSubtitleModalAtom, null); + router.back(); + }, [router]); + + const handleLanguageSelect = useCallback( + (code: string) => { + setSelectedLanguage(code); + search({ language: code }); + }, + [search], + ); + + const handleTrackSelect = useCallback( + (index: number) => { + modalState?.onSubtitleIndexChange(index); + handleClose(); + }, + [modalState, handleClose], + ); + + const handleDownload = useCallback( + async (result: SubtitleSearchResult) => { + setDownloadingId(result.id); + + try { + const downloadResult = await downloadAsync(result); + + if (downloadResult.type === "server") { + modalState?.onServerSubtitleDownloaded?.(); + } else if (downloadResult.type === "local" && downloadResult.path) { + modalState?.onLocalSubtitleDownloaded?.(downloadResult.path); + } + + handleClose(); + } catch (error) { + console.error("Failed to download subtitle:", error); + } finally { + setDownloadingId(null); + } + }, + [downloadAsync, modalState, handleClose], + ); + + const displayLanguages = useMemo( + () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16), + [], + ); + + const trackOptions = useMemo(() => { + const noneOption = { + label: t("item_card.subtitles.none"), + sublabel: undefined as string | undefined, + value: -1, + selected: currentSubtitleIndex === -1, + }; + const options = subtitleTracks.map((track) => ({ + label: + track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, + sublabel: track.Codec?.toUpperCase(), + value: track.Index!, + selected: track.Index === currentSubtitleIndex, + })); + return [noneOption, ...options]; + }, [subtitleTracks, currentSubtitleIndex, t]); + + if (!modalState) { + return null; + } + + return ( + + + + + {/* Header with tabs */} + + + {t("item_card.subtitles.label") || "Subtitles"} + + + {/* Tab bar */} + + setActiveTab("tracks")} + /> + setActiveTab("download")} + /> + + + + {/* Tracks Tab Content */} + {activeTab === "tracks" && isTabContentReady && ( + + + {trackOptions.map((option, index) => ( + handleTrackSelect(option.value)} + /> + ))} + + + )} + + {/* Download Tab Content */} + {activeTab === "download" && isTabContentReady && ( + <> + {/* Language Selector */} + + + {t("player.language") || "Language"} + + + {displayLanguages.map((lang, index) => ( + handleLanguageSelect(lang.code)} + /> + ))} + + + + {/* Results Section */} + + + {t("player.results") || "Results"} + {searchResults && ` (${searchResults.length})`} + + + {/* Loading state */} + {isSearching && ( + + + + {t("player.searching") || "Searching..."} + + + )} + + {/* Error state */} + {searchError && !isSearching && ( + + + + {t("player.search_failed") || "Search failed"} + + + {!hasOpenSubtitlesApiKey + ? t("player.no_subtitle_provider") || + "No subtitle provider configured on server" + : String(searchError)} + + + )} + + {/* No results */} + {searchResults && + searchResults.length === 0 && + !isSearching && + !searchError && ( + + + + {t("player.no_subtitles_found") || + "No subtitles found"} + + + )} + + {/* Results list */} + {searchResults && + searchResults.length > 0 && + !isSearching && ( + + {searchResults.map((result, index) => ( + handleDownload(result)} + /> + ))} + + )} + + + {/* API Key hint if no fallback available */} + {!hasOpenSubtitlesApiKey && ( + + + + {t("player.add_opensubtitles_key_hint") || + "Add OpenSubtitles API key in settings for client-side fallback"} + + + )} + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.6)", + justifyContent: "flex-end", + }, + sheetContainer: { + maxHeight: "70%", + }, + blurContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + }, + content: { + paddingTop: 24, + paddingBottom: 48, + }, + header: { + paddingHorizontal: 48, + marginBottom: 20, + }, + title: { + fontSize: 24, + fontWeight: "600", + color: "#fff", + marginBottom: 16, + }, + tabRow: { + flexDirection: "row", + gap: 24, + }, + section: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 14, + fontWeight: "500", + color: "rgba(255,255,255,0.5)", + textTransform: "uppercase", + letterSpacing: 1, + marginBottom: 12, + paddingHorizontal: 48, + }, + tracksScroll: { + overflow: "visible", + }, + tracksScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 12, + }, + 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, + }, + languageScroll: { + overflow: "visible", + }, + languageScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 10, + }, + languageCard: { + width: 120, + height: 60, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + languageCardText: { + fontSize: 15, + fontWeight: "500", + }, + languageCardCode: { + fontSize: 11, + marginTop: 2, + }, + resultsScroll: { + overflow: "visible", + }, + resultsScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 12, + }, + 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", + }, + loadingContainer: { + paddingVertical: 40, + alignItems: "center", + }, + loadingText: { + color: "rgba(255,255,255,0.6)", + marginTop: 12, + fontSize: 14, + }, + errorContainer: { + paddingVertical: 40, + paddingHorizontal: 48, + alignItems: "center", + }, + errorText: { + color: "rgba(255,100,100,0.9)", + marginTop: 8, + fontSize: 16, + fontWeight: "500", + }, + errorHint: { + color: "rgba(255,255,255,0.5)", + marginTop: 4, + fontSize: 13, + textAlign: "center", + }, + emptyContainer: { + paddingVertical: 40, + alignItems: "center", + }, + emptyText: { + color: "rgba(255,255,255,0.5)", + marginTop: 8, + fontSize: 14, + }, + apiKeyHint: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 48, + paddingTop: 8, + }, + apiKeyHintText: { + color: "rgba(255,255,255,0.4)", + fontSize: 12, + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 7d86119f..bddb6120 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -60,7 +60,7 @@ import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import useRouter from "@/hooks/useAppRouter"; import { userAtom } from "@/providers/JellyfinProvider"; -import { store } from "@/utils/store"; +import { store as jotaiStore, store } from "@/utils/store"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; @@ -179,7 +179,7 @@ export default function RootLayout() { return ( - + @@ -429,6 +429,22 @@ function Layout() { }} /> + + = React.memo( router.push(`/player/direct-player?${queryParams.toString()}`); }; - // Modal state for option selectors - type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null; - const [openModal, setOpenModal] = useState(null); - const isModalOpen = openModal !== null; + // TV Option Modal hook for quality, audio, media source selectors + const { showOptions } = useTVOptionModal(); + + // TV Subtitle Modal hook + const { showSubtitleModal } = useTVSubtitleModal(); // State for first actor card ref (used for focus guide) const [firstActorCardRef, setFirstActorCardRef] = useState( @@ -400,28 +395,6 @@ export const ItemContentTV: React.FC = React.memo( 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]); - - // tvOS menu button handler for closing modals - 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( @@ -883,7 +856,13 @@ export const ItemContentTV: React.FC = React.memo( } label={t("item_card.quality")} value={selectedQualityLabel} - onPress={() => setOpenModal("quality")} + onPress={() => + showOptions({ + title: t("item_card.quality"), + options: qualityOptions, + onSelect: handleQualityChange, + }) + } /> {/* Media source selector (only if multiple sources) */} @@ -896,7 +875,13 @@ export const ItemContentTV: React.FC = React.memo( } label={t("item_card.video")} value={selectedMediaSourceLabel} - onPress={() => setOpenModal("mediaSource")} + onPress={() => + showOptions({ + title: t("item_card.video"), + options: mediaSourceOptions, + onSelect: handleMediaSourceChange, + }) + } /> )} @@ -910,7 +895,13 @@ export const ItemContentTV: React.FC = React.memo( } label={t("item_card.audio")} value={selectedAudioLabel} - onPress={() => setOpenModal("audio")} + onPress={() => + showOptions({ + title: t("item_card.audio"), + options: audioOptions, + onSelect: handleAudioChange, + }) + } /> )} @@ -925,7 +916,18 @@ export const ItemContentTV: React.FC = React.memo( } label={t("item_card.subtitles.label")} value={selectedSubtitleLabel} - onPress={() => setOpenModal("subtitle")} + onPress={() => + showSubtitleModal({ + item, + mediaSourceId: selectedOptions?.mediaSource?.Id, + subtitleTracks, + currentSubtitleIndex: + selectedOptions?.subtitleIndex ?? -1, + onSubtitleIndexChange: handleSubtitleChange, + onServerSubtitleDownloaded: + handleServerSubtitleDownloaded, + }) + } /> )} @@ -1204,45 +1206,6 @@ export const ItemContentTV: React.FC = React.memo( )} - - {/* Option selector modals */} - setOpenModal(null)} - /> - - setOpenModal(null)} - /> - - setOpenModal(null)} - /> - - {/* Unified Subtitle Sheet (tracks + download) */} - {item && ( - setOpenModal(null)} - onServerSubtitleDownloaded={handleServerSubtitleDownloaded} - /> - )} ); }, diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 45dc99dc..b669a142 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -17,9 +17,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { - BackHandler, Image, - Platform, Pressable, Animated as RNAnimated, StyleSheet, @@ -37,13 +35,15 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import type { TVOptionItem } from "@/components/tv"; -import { TVOptionSelector, useTVFocusAnimation } from "@/components/tv"; +import { useTVFocusAnimation } from "@/components/tv"; import useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; +import { useTVOptionModal } from "@/hooks/useTVOptionModal"; +import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal"; 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"; @@ -51,7 +51,6 @@ import { CONTROLS_CONSTANTS } from "./constants"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoTime } from "./hooks/useVideoTime"; import { TrickplayBubble } from "./TrickplayBubble"; -import { TVSubtitleSheet } from "./TVSubtitleSheet"; import { useControlsTimeout } from "./useControlsTimeout"; interface Props { @@ -337,24 +336,15 @@ export const Controls: FC = ({ const nextItem = nextItemProp ?? internalNextItem; - type ModalType = "audio" | "subtitle" | null; - const [openModal, setOpenModal] = useState(null); - const isModalOpen = openModal !== null; + // TV Option Modal hook for audio selector + const { showOptions } = useTVOptionModal(); - const [lastOpenedModal, setLastOpenedModal] = useState(null); + // TV Subtitle Modal hook + const { showSubtitleModal } = useTVSubtitleModal(); - useEffect(() => { - if (Platform.OS === "android" && isModalOpen) { - const backHandler = BackHandler.addEventListener( - "hardwareBackPress", - () => { - setOpenModal(null); - return true; - }, - ); - return () => backHandler.remove(); - } - }, [isModalOpen]); + // Track which button should have preferred focus when controls show + type LastModalType = "audio" | "subtitle" | null; + const [lastOpenedModal, setLastOpenedModal] = useState(null); const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; @@ -474,10 +464,8 @@ export const Controls: FC = ({ }, []); const handleBack = useCallback(() => { - if (isModalOpen) { - setOpenModal(null); - } - }, [isModalOpen]); + // No longer needed since modals are screen-based + }, []); const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, @@ -488,15 +476,13 @@ export const Controls: FC = ({ const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); - setOpenModal("audio"); + showOptions({ + title: t("item_card.audio"), + options: audioOptions, + onSelect: handleAudioChange, + }); controlsInteractionRef.current(); - }, []); - - const handleOpenSubtitleSheet = useCallback(() => { - setLastOpenedModal("subtitle"); - setOpenModal("subtitle"); - controlsInteractionRef.current(); - }, []); + }, [showOptions, t, audioOptions, handleAudioChange]); const handleServerSubtitleDownloaded = useCallback(() => { onServerSubtitleDownloaded?.(); @@ -509,6 +495,29 @@ export const Controls: FC = ({ [addSubtitleFile], ); + const handleOpenSubtitleSheet = useCallback(() => { + setLastOpenedModal("subtitle"); + showSubtitleModal({ + item, + mediaSourceId: mediaSource?.Id, + subtitleTracks, + currentSubtitleIndex: subtitleIndex ?? -1, + onSubtitleIndexChange: handleSubtitleChange, + onServerSubtitleDownloaded: handleServerSubtitleDownloaded, + onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded, + }); + controlsInteractionRef.current(); + }, [ + showSubtitleModal, + item, + mediaSource?.Id, + subtitleTracks, + subtitleIndex, + handleSubtitleChange, + handleServerSubtitleDownloaded, + handleLocalSubtitleDownloaded, + ]); + const effectiveProgress = useSharedValue(0); const SEEK_THRESHOLD_MS = 5000; @@ -759,7 +768,7 @@ export const Controls: FC = ({ = ({ = ({ onPress={handleSeekBackwardButton} onLongPress={startContinuousSeekBackward} onPressOut={stopContinuousSeeking} - disabled={isModalOpen} + disabled={false} size={28} /> = ({ onPress={handleSeekForwardButton} onLongPress={startContinuousSeekForward} onPressOut={stopContinuousSeeking} - disabled={isModalOpen} + disabled={false} size={28} /> @@ -827,10 +836,8 @@ export const Controls: FC = ({ )} @@ -838,10 +845,8 @@ export const Controls: FC = ({ @@ -892,26 +897,6 @@ export const Controls: FC = ({ - - setOpenModal(null)} - /> - - setOpenModal(null)} - onServerSubtitleDownloaded={handleServerSubtitleDownloaded} - onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded} - /> ); }; diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx index 5366183a..e3f08b2d 100644 --- a/components/video-player/controls/TVSubtitleSheet.tsx +++ b/components/video-player/controls/TVSubtitleSheet.tsx @@ -381,6 +381,13 @@ export const TVSubtitleSheet: React.FC = ({ onLocalSubtitleDownloaded, }) => { const { t } = useTranslation(); + + console.log( + "[TVSubtitleSheet] visible:", + visible, + "tracks:", + subtitleTracks.length, + ); const [activeTab, setActiveTab] = useState("tracks"); const [selectedLanguage, setSelectedLanguage] = useState("eng"); const [downloadingId, setDownloadingId] = useState(null); diff --git a/hooks/useTVOptionModal.ts b/hooks/useTVOptionModal.ts new file mode 100644 index 00000000..c6acffe8 --- /dev/null +++ b/hooks/useTVOptionModal.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { + type TVOptionItem, + tvOptionModalAtom, +} from "@/utils/atoms/tvOptionModal"; +import { store } from "@/utils/store"; + +interface ShowOptionsParams { + title: string; + options: TVOptionItem[]; + onSelect: (value: T) => void; + cardWidth?: number; + cardHeight?: number; +} + +export const useTVOptionModal = () => { + const router = useRouter(); + + const showOptions = useCallback( + (params: ShowOptionsParams) => { + // Use store.set for synchronous update before navigation + store.set(tvOptionModalAtom, { + title: params.title, + options: params.options, + onSelect: params.onSelect, + cardWidth: params.cardWidth, + cardHeight: params.cardHeight, + }); + router.push("/(auth)/tv-option-modal"); + }, + [router], + ); + + return { showOptions }; +}; diff --git a/hooks/useTVSubtitleModal.ts b/hooks/useTVSubtitleModal.ts new file mode 100644 index 00000000..f1e8fcb3 --- /dev/null +++ b/hooks/useTVSubtitleModal.ts @@ -0,0 +1,40 @@ +import type { + BaseItemDto, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client"; +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal"; +import { store } from "@/utils/store"; + +interface ShowSubtitleModalParams { + item: BaseItemDto; + mediaSourceId?: string | null; + subtitleTracks: MediaStream[]; + currentSubtitleIndex: number; + onSubtitleIndexChange: (index: number) => void; + onServerSubtitleDownloaded?: () => void; + onLocalSubtitleDownloaded?: (path: string) => void; +} + +export const useTVSubtitleModal = () => { + const router = useRouter(); + + const showSubtitleModal = useCallback( + (params: ShowSubtitleModalParams) => { + store.set(tvSubtitleModalAtom, { + item: params.item, + mediaSourceId: params.mediaSourceId, + subtitleTracks: params.subtitleTracks, + currentSubtitleIndex: params.currentSubtitleIndex, + onSubtitleIndexChange: params.onSubtitleIndexChange, + onServerSubtitleDownloaded: params.onServerSubtitleDownloaded, + onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded, + }); + router.push("/(auth)/tv-subtitle-modal"); + }, + [router], + ); + + return { showSubtitleModal }; +}; diff --git a/utils/atoms/tvOptionModal.ts b/utils/atoms/tvOptionModal.ts new file mode 100644 index 00000000..74bc2ab5 --- /dev/null +++ b/utils/atoms/tvOptionModal.ts @@ -0,0 +1,18 @@ +import { atom } from "jotai"; + +export type TVOptionItem = { + label: string; + sublabel?: string; + value: T; + selected: boolean; +}; + +export type TVOptionModalState = { + title: string; + options: TVOptionItem[]; + onSelect: (value: any) => void; + cardWidth?: number; + cardHeight?: number; +} | null; + +export const tvOptionModalAtom = atom(null); diff --git a/utils/atoms/tvSubtitleModal.ts b/utils/atoms/tvSubtitleModal.ts new file mode 100644 index 00000000..ac68ebfd --- /dev/null +++ b/utils/atoms/tvSubtitleModal.ts @@ -0,0 +1,17 @@ +import type { + BaseItemDto, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client"; +import { atom } from "jotai"; + +export type TVSubtitleModalState = { + item: BaseItemDto; + mediaSourceId?: string | null; + subtitleTracks: MediaStream[]; + currentSubtitleIndex: number; + onSubtitleIndexChange: (index: number) => void; + onServerSubtitleDownloaded?: () => void; + onLocalSubtitleDownloaded?: (path: string) => void; +} | null; + +export const tvSubtitleModalAtom = atom(null);