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);