fix(tv): modals

This commit is contained in:
Fredrik Burmester
2026-01-18 15:22:44 +01:00
parent 773701d0c1
commit d545ca3584
10 changed files with 1323 additions and 149 deletions

View File

@@ -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<View>(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 (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(option.value)}
width={cardWidth}
height={cardHeight}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
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,
},
});

View File

@@ -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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.trackCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.trackCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.languageCard,
animatedStyle,
{
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.languageCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={1}
>
{name}
</Text>
<Text
style={[
styles.languageCardCode,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{code.toUpperCase()}
</Text>
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={isDownloading}
>
<Animated.View
style={[
styles.resultCard,
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.1)",
},
]}
>
{/* Provider/Source badge */}
<View
style={[
styles.providerBadge,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={[
styles.providerText,
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
]}
>
{result.providerName}
</Text>
</View>
{/* Name */}
<Text
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
numberOfLines={2}
>
{result.name}
</Text>
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
<Ionicons
name='star'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.communityRating.toFixed(1)}
</Text>
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
name='download-outline'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.downloadCount.toLocaleString()}
</Text>
</View>
)}
</View>
{/* Flags */}
<View style={styles.flagsContainer}>
{result.isHashMatch && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,150,0,0.2)"
: "rgba(0,200,0,0.2)",
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
</View>
)}
{result.hearingImpaired && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Ionicons
name='ear-outline'
size={12}
color={focused ? "#000" : "#fff"}
/>
</View>
)}
{result.aiTranslated && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,150,0.2)"
: "rgba(100,100,255,0.2)",
},
]}
>
<Text style={styles.flagText}>AI</Text>
</View>
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
</View>
)}
</Animated.View>
</Pressable>
);
});
export default function TVSubtitleModal() {
const router = useRouter();
const { t } = useTranslation();
const modalState = useAtomValue(tvSubtitleModalAtom);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(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 (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tab bar */}
<View style={styles.tabRow}>
<TVTabButton
label={t("item_card.subtitles.tracks") || "Tracks"}
active={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
/>
<TVTabButton
label={t("player.download") || "Download"}
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
</View>
</View>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{trackOptions.map((option, index) => (
<TVTrackCard
key={option.value}
ref={
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
</ScrollView>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
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,
},
});

View File

@@ -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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<JotaiProvider store={jotaiStore}>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
@@ -429,6 +429,22 @@ function Layout() {
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}

View File

@@ -11,13 +11,10 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
BackHandler,
Dimensions,
Platform,
Pressable,
ScrollView,
TVFocusGuideView,
useTVEventHandler,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -26,19 +23,16 @@ import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import type { TVOptionItem } from "@/components/tv";
import {
TVButton,
TVOptionSelector,
useTVFocusAnimation,
} from "@/components/tv";
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
import { TVButton, useTVFocusAnimation } from "@/components/tv";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -385,10 +379,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = 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<ModalType>(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<View | null>(
@@ -400,28 +395,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = 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,
})
}
/>
)}
</View>
@@ -1204,45 +1206,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
</View>
</ScrollView>
{/* Option selector modals */}
<TVOptionSelector
visible={openModal === "quality"}
title={t("item_card.quality")}
options={qualityOptions}
onSelect={handleQualityChange}
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
visible={openModal === "mediaSource"}
title={t("item_card.video")}
options={mediaSourceOptions}
onSelect={handleMediaSourceChange}
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
visible={openModal === "audio"}
title={t("item_card.audio")}
options={audioOptions}
onSelect={handleAudioChange}
onClose={() => setOpenModal(null)}
/>
{/* Unified Subtitle Sheet (tracks + download) */}
{item && (
<TVSubtitleSheet
visible={openModal === "subtitle"}
item={item}
mediaSourceId={selectedOptions?.mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={selectedOptions?.subtitleIndex ?? -1}
onSubtitleIndexChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
/>
)}
</View>
);
},

View File

@@ -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<Props> = ({
const nextItem = nextItemProp ?? internalNextItem;
type ModalType = "audio" | "subtitle" | null;
const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null;
// TV Option Modal hook for audio selector
const { showOptions } = useTVOptionModal();
const [lastOpenedModal, setLastOpenedModal] = useState<ModalType>(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<LastModalType>(null);
const audioTracks = useMemo(() => {
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
@@ -474,10 +464,8 @@ export const Controls: FC<Props> = ({
}, []);
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<Props> = ({
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<Props> = ({
[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<Props> = ({
<Animated.View
style={[styles.bottomContainer, bottomAnimatedStyle]}
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
pointerEvents={showControls && !false ? "auto" : "none"}
>
<View
style={[
@@ -788,7 +797,7 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='play-skip-back'
onPress={handlePreviousItem}
disabled={isModalOpen || !previousItem}
disabled={false || !previousItem}
size={28}
/>
<TVControlButton
@@ -796,14 +805,14 @@ export const Controls: FC<Props> = ({
onPress={handleSeekBackwardButton}
onLongPress={startContinuousSeekBackward}
onPressOut={stopContinuousSeeking}
disabled={isModalOpen}
disabled={false}
size={28}
/>
<TVControlButton
icon={isPlaying ? "pause" : "play"}
onPress={handlePlayPauseButton}
disabled={isModalOpen}
hasTVPreferredFocus={!isModalOpen && lastOpenedModal === null}
disabled={false}
hasTVPreferredFocus={!false && lastOpenedModal === null}
size={36}
/>
<TVControlButton
@@ -811,13 +820,13 @@ export const Controls: FC<Props> = ({
onPress={handleSeekForwardButton}
onLongPress={startContinuousSeekForward}
onPressOut={stopContinuousSeeking}
disabled={isModalOpen}
disabled={false}
size={28}
/>
<TVControlButton
icon='play-skip-forward'
onPress={handleNextItemButton}
disabled={isModalOpen || !nextItem}
disabled={false || !nextItem}
size={28}
/>
@@ -827,10 +836,8 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='volume-high'
onPress={handleOpenAudioSheet}
disabled={isModalOpen}
hasTVPreferredFocus={
!isModalOpen && lastOpenedModal === "audio"
}
disabled={false}
hasTVPreferredFocus={!false && lastOpenedModal === "audio"}
size={24}
/>
)}
@@ -838,10 +845,8 @@ export const Controls: FC<Props> = ({
<TVControlButton
icon='text'
onPress={handleOpenSubtitleSheet}
disabled={isModalOpen}
hasTVPreferredFocus={
!isModalOpen && lastOpenedModal === "subtitle"
}
disabled={false}
hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"}
size={24}
/>
</View>
@@ -892,26 +897,6 @@ export const Controls: FC<Props> = ({
</View>
</View>
</Animated.View>
<TVOptionSelector
visible={openModal === "audio"}
title={t("item_card.audio")}
options={audioOptions}
onSelect={handleAudioChange}
onClose={() => setOpenModal(null)}
/>
<TVSubtitleSheet
visible={openModal === "subtitle"}
item={item}
mediaSourceId={mediaSource?.Id}
subtitleTracks={subtitleTracks}
currentSubtitleIndex={subtitleIndex ?? -1}
onSubtitleIndexChange={handleSubtitleChange}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
/>
</View>
);
};

View File

@@ -381,6 +381,13 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
console.log(
"[TVSubtitleSheet] visible:",
visible,
"tracks:",
subtitleTracks.length,
);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);

36
hooks/useTVOptionModal.ts Normal file
View File

@@ -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<T> {
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
cardWidth?: number;
cardHeight?: number;
}
export const useTVOptionModal = () => {
const router = useRouter();
const showOptions = useCallback(
<T>(params: ShowOptionsParams<T>) => {
// 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 };
};

View File

@@ -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 };
};

View File

@@ -0,0 +1,18 @@
import { atom } from "jotai";
export type TVOptionItem<T = any> = {
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<TVOptionModalState>(null);

View File

@@ -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<TVSubtitleModalState>(null);