mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 13:02:52 +01:00
fix(tv): modals
This commit is contained in:
167
app/(auth)/tv-option-modal.tsx
Normal file
167
app/(auth)/tv-option-modal.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
925
app/(auth)/tv-subtitle-modal.tsx
Normal file
925
app/(auth)/tv-subtitle-modal.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -60,7 +60,7 @@ import { SystemBars } from "react-native-edge-to-edge";
|
|||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store as jotaiStore, store } from "@/utils/store";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<JotaiProvider>
|
<JotaiProvider store={jotaiStore}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<Layout />
|
<Layout />
|
||||||
@@ -429,6 +429,22 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='+not-found' />
|
<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>
|
</Stack>
|
||||||
<Toaster
|
<Toaster
|
||||||
duration={4000}
|
duration={4000}
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
BackHandler,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TVFocusGuideView,
|
TVFocusGuideView,
|
||||||
useTVEventHandler,
|
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
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 { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import type { TVOptionItem } from "@/components/tv";
|
import { TVButton, useTVFocusAnimation } from "@/components/tv";
|
||||||
import {
|
|
||||||
TVButton,
|
|
||||||
TVOptionSelector,
|
|
||||||
useTVFocusAnimation,
|
|
||||||
} from "@/components/tv";
|
|
||||||
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
@@ -385,10 +379,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modal state for option selectors
|
// TV Option Modal hook for quality, audio, media source selectors
|
||||||
type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null;
|
const { showOptions } = useTVOptionModal();
|
||||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
|
||||||
const isModalOpen = openModal !== null;
|
// TV Subtitle Modal hook
|
||||||
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
// State for first actor card ref (used for focus guide)
|
// State for first actor card ref (used for focus guide)
|
||||||
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||||
@@ -400,28 +395,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
null,
|
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
|
// Get available audio tracks
|
||||||
const audioTracks = useMemo(() => {
|
const audioTracks = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
@@ -883,7 +856,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}
|
}
|
||||||
label={t("item_card.quality")}
|
label={t("item_card.quality")}
|
||||||
value={selectedQualityLabel}
|
value={selectedQualityLabel}
|
||||||
onPress={() => setOpenModal("quality")}
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: qualityOptions,
|
||||||
|
onSelect: handleQualityChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Media source selector (only if multiple sources) */}
|
{/* Media source selector (only if multiple sources) */}
|
||||||
@@ -896,7 +875,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}
|
}
|
||||||
label={t("item_card.video")}
|
label={t("item_card.video")}
|
||||||
value={selectedMediaSourceLabel}
|
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")}
|
label={t("item_card.audio")}
|
||||||
value={selectedAudioLabel}
|
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")}
|
label={t("item_card.subtitles.label")}
|
||||||
value={selectedSubtitleLabel}
|
value={selectedSubtitleLabel}
|
||||||
onPress={() => setOpenModal("subtitle")}
|
onPress={() =>
|
||||||
|
showSubtitleModal({
|
||||||
|
item,
|
||||||
|
mediaSourceId: selectedOptions?.mediaSource?.Id,
|
||||||
|
subtitleTracks,
|
||||||
|
currentSubtitleIndex:
|
||||||
|
selectedOptions?.subtitleIndex ?? -1,
|
||||||
|
onSubtitleIndexChange: handleSubtitleChange,
|
||||||
|
onServerSubtitleDownloaded:
|
||||||
|
handleServerSubtitleDownloaded,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -1204,45 +1206,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
BackHandler,
|
|
||||||
Image,
|
Image,
|
||||||
Platform,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
Animated as RNAnimated,
|
Animated as RNAnimated,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -37,13 +35,15 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import type { TVOptionItem } from "@/components/tv";
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
import { TVOptionSelector, useTVFocusAnimation } from "@/components/tv";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
@@ -51,7 +51,6 @@ import { CONTROLS_CONSTANTS } from "./constants";
|
|||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
import { TVSubtitleSheet } from "./TVSubtitleSheet";
|
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -337,24 +336,15 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const nextItem = nextItemProp ?? internalNextItem;
|
const nextItem = nextItemProp ?? internalNextItem;
|
||||||
|
|
||||||
type ModalType = "audio" | "subtitle" | null;
|
// TV Option Modal hook for audio selector
|
||||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
const { showOptions } = useTVOptionModal();
|
||||||
const isModalOpen = openModal !== null;
|
|
||||||
|
|
||||||
const [lastOpenedModal, setLastOpenedModal] = useState<ModalType>(null);
|
// TV Subtitle Modal hook
|
||||||
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
useEffect(() => {
|
// Track which button should have preferred focus when controls show
|
||||||
if (Platform.OS === "android" && isModalOpen) {
|
type LastModalType = "audio" | "subtitle" | null;
|
||||||
const backHandler = BackHandler.addEventListener(
|
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
|
||||||
"hardwareBackPress",
|
|
||||||
() => {
|
|
||||||
setOpenModal(null);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => backHandler.remove();
|
|
||||||
}
|
|
||||||
}, [isModalOpen]);
|
|
||||||
|
|
||||||
const audioTracks = useMemo(() => {
|
const audioTracks = useMemo(() => {
|
||||||
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||||
@@ -474,10 +464,8 @@ export const Controls: FC<Props> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (isModalOpen) {
|
// No longer needed since modals are screen-based
|
||||||
setOpenModal(null);
|
}, []);
|
||||||
}
|
|
||||||
}, [isModalOpen]);
|
|
||||||
|
|
||||||
const { isSliding: isRemoteSliding } = useRemoteControl({
|
const { isSliding: isRemoteSliding } = useRemoteControl({
|
||||||
showControls,
|
showControls,
|
||||||
@@ -488,15 +476,13 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const handleOpenAudioSheet = useCallback(() => {
|
const handleOpenAudioSheet = useCallback(() => {
|
||||||
setLastOpenedModal("audio");
|
setLastOpenedModal("audio");
|
||||||
setOpenModal("audio");
|
showOptions({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioOptions,
|
||||||
|
onSelect: handleAudioChange,
|
||||||
|
});
|
||||||
controlsInteractionRef.current();
|
controlsInteractionRef.current();
|
||||||
}, []);
|
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||||
|
|
||||||
const handleOpenSubtitleSheet = useCallback(() => {
|
|
||||||
setLastOpenedModal("subtitle");
|
|
||||||
setOpenModal("subtitle");
|
|
||||||
controlsInteractionRef.current();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleServerSubtitleDownloaded = useCallback(() => {
|
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||||
onServerSubtitleDownloaded?.();
|
onServerSubtitleDownloaded?.();
|
||||||
@@ -509,6 +495,29 @@ export const Controls: FC<Props> = ({
|
|||||||
[addSubtitleFile],
|
[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 effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
const SEEK_THRESHOLD_MS = 5000;
|
const SEEK_THRESHOLD_MS = 5000;
|
||||||
@@ -759,7 +768,7 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
style={[styles.bottomContainer, bottomAnimatedStyle]}
|
||||||
pointerEvents={showControls && !isModalOpen ? "auto" : "none"}
|
pointerEvents={showControls && !false ? "auto" : "none"}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -788,7 +797,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='play-skip-back'
|
icon='play-skip-back'
|
||||||
onPress={handlePreviousItem}
|
onPress={handlePreviousItem}
|
||||||
disabled={isModalOpen || !previousItem}
|
disabled={false || !previousItem}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
@@ -796,14 +805,14 @@ export const Controls: FC<Props> = ({
|
|||||||
onPress={handleSeekBackwardButton}
|
onPress={handleSeekBackwardButton}
|
||||||
onLongPress={startContinuousSeekBackward}
|
onLongPress={startContinuousSeekBackward}
|
||||||
onPressOut={stopContinuousSeeking}
|
onPressOut={stopContinuousSeeking}
|
||||||
disabled={isModalOpen}
|
disabled={false}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon={isPlaying ? "pause" : "play"}
|
icon={isPlaying ? "pause" : "play"}
|
||||||
onPress={handlePlayPauseButton}
|
onPress={handlePlayPauseButton}
|
||||||
disabled={isModalOpen}
|
disabled={false}
|
||||||
hasTVPreferredFocus={!isModalOpen && lastOpenedModal === null}
|
hasTVPreferredFocus={!false && lastOpenedModal === null}
|
||||||
size={36}
|
size={36}
|
||||||
/>
|
/>
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
@@ -811,13 +820,13 @@ export const Controls: FC<Props> = ({
|
|||||||
onPress={handleSeekForwardButton}
|
onPress={handleSeekForwardButton}
|
||||||
onLongPress={startContinuousSeekForward}
|
onLongPress={startContinuousSeekForward}
|
||||||
onPressOut={stopContinuousSeeking}
|
onPressOut={stopContinuousSeeking}
|
||||||
disabled={isModalOpen}
|
disabled={false}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='play-skip-forward'
|
icon='play-skip-forward'
|
||||||
onPress={handleNextItemButton}
|
onPress={handleNextItemButton}
|
||||||
disabled={isModalOpen || !nextItem}
|
disabled={false || !nextItem}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -827,10 +836,8 @@ export const Controls: FC<Props> = ({
|
|||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='volume-high'
|
icon='volume-high'
|
||||||
onPress={handleOpenAudioSheet}
|
onPress={handleOpenAudioSheet}
|
||||||
disabled={isModalOpen}
|
disabled={false}
|
||||||
hasTVPreferredFocus={
|
hasTVPreferredFocus={!false && lastOpenedModal === "audio"}
|
||||||
!isModalOpen && lastOpenedModal === "audio"
|
|
||||||
}
|
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -838,10 +845,8 @@ export const Controls: FC<Props> = ({
|
|||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='text'
|
icon='text'
|
||||||
onPress={handleOpenSubtitleSheet}
|
onPress={handleOpenSubtitleSheet}
|
||||||
disabled={isModalOpen}
|
disabled={false}
|
||||||
hasTVPreferredFocus={
|
hasTVPreferredFocus={!false && lastOpenedModal === "subtitle"}
|
||||||
!isModalOpen && lastOpenedModal === "subtitle"
|
|
||||||
}
|
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -892,26 +897,6 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -381,6 +381,13 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
|||||||
onLocalSubtitleDownloaded,
|
onLocalSubtitleDownloaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[TVSubtitleSheet] visible:",
|
||||||
|
visible,
|
||||||
|
"tracks:",
|
||||||
|
subtitleTracks.length,
|
||||||
|
);
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
|||||||
36
hooks/useTVOptionModal.ts
Normal file
36
hooks/useTVOptionModal.ts
Normal 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 };
|
||||||
|
};
|
||||||
40
hooks/useTVSubtitleModal.ts
Normal file
40
hooks/useTVSubtitleModal.ts
Normal 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 };
|
||||||
|
};
|
||||||
18
utils/atoms/tvOptionModal.ts
Normal file
18
utils/atoms/tvOptionModal.ts
Normal 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);
|
||||||
17
utils/atoms/tvSubtitleModal.ts
Normal file
17
utils/atoms/tvSubtitleModal.ts
Normal 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);
|
||||||
Reference in New Issue
Block a user