mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-21 10:38:06 +00: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 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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
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